innhold/custom/assets/petition-map.js
Ruben bd993ea6ca Increase petition map animation duration to 30 seconds
Add lazy initialization for petition map
Update documentation with new positioning guidance
Note full-width section rendering behavior
2026-02-26 21:40:56 +01:00

421 lines
12 KiB
JavaScript

/**
* Petition signature map widget.
* Uses Leaflet + OpenStreetMap (CartoDB Positron tiles) with Norway fylker GeoJSON.
*
* Features:
* - Animated dot scatter on load (staggered, one per confirmed signer)
* - Dots randomly placed within each fylke polygon using point-in-polygon
* - Mouse/touch parallax: dots nudge slightly toward pointer
* - Click fylke: popup with signer count and names
* - Polls data endpoint every 60s, animates new dots with sprinkle effect
* - Anonymous signers shown with mask icon instead of colored dot
*/
(function () {
'use strict';
const CFG = window.PETITION_MAP_CONFIG;
if (!CFG) return;
// Wait for Leaflet to be available
function waitForLeaflet(cb) {
if (window.L) { cb(); return; }
const t = setInterval(() => { if (window.L) { clearInterval(t); cb(); } }, 50);
}
// ------- Color palette (from site CSS vars) -------
const COLORS = {
dot: 'oklch(0.618 0.1176 173.93)', // --color-green
dotAlt: 'oklch(0.6376 0.0739 242.84)', // --color-blue
anonDot: 'oklch(0.55 0.04 250)',
fylkeFill: 'oklch(0.618 0.1176 173.93)',
fylkeFillH: 'oklch(0.50 0.12 173.93)',
fylkeBorder:'oklch(0.45 0.08 173.93)',
};
// Map from GeoJSON NAME_1 to petition region key
const NAME_TO_KEY = {
'Agder': 'agder',
'Akershus': 'akershus',
'Buskerud': 'buskerud',
'Finnmark': 'finnmark',
'Innlandet': 'innlandet',
'Møre og Romsdal':'more_og_romsdal',
'Nordland': 'nordland',
'Oslo': 'oslo',
'Rogaland': 'rogaland',
'Telemark': 'telemark',
'Troms': 'troms',
'Trøndelag': 'trondelag',
'Vestfold': 'vestfold',
'Vestland': 'vestland',
'Østfold': 'ostfold',
};
// ------- State -------
let map, geojsonLayer, dotLayer;
let fylkeFeatures = {}; // key -> { layer, bounds, polygon coords }
let currentData = null; // last fetched data
let knownTotal = 0;
// ------- Geometry helpers -------
/** Bounding box of a polygon ring [[lon,lat],...] */
function ringBounds(ring) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const [x, y] of ring) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
return { minX, minY, maxX, maxY };
}
/** Ray-casting point-in-polygon for a single ring */
function pointInRing(px, py, ring) {
let inside = false;
const n = ring.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (((yi > py) !== (yj > py)) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/**
* Test if point [lon, lat] is inside a GeoJSON geometry (Polygon or MultiPolygon).
* For MultiPolygon: point must be inside an outer ring and outside all inner rings.
*/
function pointInGeometry(lon, lat, geometry) {
const test = (polygon) => {
const [outer, ...holes] = polygon;
if (!pointInRing(lon, lat, outer)) return false;
for (const hole of holes) {
if (pointInRing(lon, lat, hole)) return false;
}
return true;
};
if (geometry.type === 'Polygon') {
return test(geometry.coordinates);
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.some(test);
}
return false;
}
/**
* Generate a random point within a GeoJSON feature's geometry.
* Uses rejection sampling within the bounding box.
*/
function randomPointInFeature(geometry) {
// Build overall bounding box
const allRings = geometry.type === 'Polygon'
? [geometry.coordinates[0]]
: geometry.coordinates.map(p => p[0]);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const ring of allRings) {
const b = ringBounds(ring);
if (b.minX < minX) minX = b.minX;
if (b.minY < minY) minY = b.minY;
if (b.maxX > maxX) maxX = b.maxX;
if (b.maxY > maxY) maxY = b.maxY;
}
// Apply a small inset to avoid dots on the very edge
const insetX = (maxX - minX) * 0.04;
const insetY = (maxY - minY) * 0.04;
minX += insetX; maxX -= insetX;
minY += insetY; maxY -= insetY;
// Rejection sampling (max 200 attempts)
for (let i = 0; i < 200; i++) {
const lon = minX + Math.random() * (maxX - minX);
const lat = minY + Math.random() * (maxY - minY);
if (pointInGeometry(lon, lat, geometry)) {
return [lat, lon]; // Leaflet uses [lat, lon]
}
}
// Fallback: centroid approximation
return [(minY + maxY) / 2, (minX + maxX) / 2];
}
// ------- Dot rendering -------
/** Create a Leaflet marker with a permanent tooltip label */
function createDotMarker(latLng, signer) {
// Invisible zero-size marker as anchor
const icon = L.divIcon({ iconSize: [0, 0], iconAnchor: [0, 0], className: '' });
const marker = L.marker(latLng, { icon, interactive: false, pane: 'dots' });
if (signer.a) {
marker.bindTooltip(
`<img src="${CFG.anonIconUrl}" alt="" class="anon-icon"> Anonym`,
{ permanent: true, direction: 'center', className: 'signer-tip signer-tip--anon', offset: [0, 0] }
);
} else {
marker.bindTooltip(
escapeHtml(signer.n || ''),
{ permanent: true, direction: 'center', className: 'signer-tip', offset: [0, 0] }
);
}
return marker;
}
/** Place new dots for a region (used by poll updates) */
function renderRegionDots(signers, geometry) {
for (const signer of signers) {
const latLng = randomPointInFeature(geometry);
createDotMarker(latLng, signer).addTo(dotLayer);
}
}
// ------- Fylke layer -------
function buildGeoJsonLayer(geojson) {
geojsonLayer = L.geoJSON(geojson, {
style: {
color: COLORS.fylkeBorder,
weight: 1.5,
fillColor: COLORS.fylkeFill,
fillOpacity: 0.08,
opacity: 0.5,
},
onEachFeature(feature, layer) {
const name = feature.properties.name;
const key = NAME_TO_KEY[name] ?? name.toLowerCase();
fylkeFeatures[key] = {
layer,
geometry: feature.geometry,
};
layer.on('mouseover', function () {
if (!this._popupOpen) {
this.setStyle({ fillOpacity: 0.2, weight: 2.5 });
}
});
layer.on('mouseout', function () {
if (!this._popupOpen) {
this.setStyle({ fillOpacity: 0.08, weight: 1.5 });
}
});
layer.on('click', function (e) {
showFylkePopup(key, name, e.latlng);
});
},
});
return geojsonLayer;
}
function showFylkePopup(key, name, latlng) {
// Reset all layers
geojsonLayer.resetStyle();
const region = currentData?.regions?.[key];
const count = region?.count ?? 0;
const countText = count === 1 ? '1 underskrift' : `${count} underskrifter`;
const html = `
<div class="fylke-popup">
<div class="fylke-popup-title">${escapeHtml(name)}</div>
<div class="fylke-popup-count">${countText}</div>
</div>`;
L.popup({ maxWidth: 240, className: 'fylke-info-popup' })
.setLatLng(latlng)
.setContent(html)
.openOn(map);
// Highlight this fylke
const fl = fylkeFeatures[key]?.layer;
if (fl) {
fl.setStyle({ fillOpacity: 0.25, weight: 2.5, color: COLORS.fylkeFillH });
fl._popupOpen = true;
}
map.on('popupclose', function handler() {
geojsonLayer.resetStyle();
if (fl) fl._popupOpen = false;
map.off('popupclose', handler);
});
}
// ------- Data loading -------
function escapeHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function updateCountLabel(total) {
const el = document.getElementById('petition-map-count');
if (!el) return;
el.textContent = total === 1
? '1 person har signert fra hele Norge'
: `${total.toLocaleString('nb-NO')} personer har signert fra hele Norge`;
}
/** Initial render: place all dots with one smooth staggered animation */
function initialRender(data) {
currentData = data;
knownTotal = data.total ?? 0;
updateCountLabel(knownTotal);
// Collect all dots across all regions, then shuffle for a natural scatter
const allDots = [];
for (const [key, region] of Object.entries(data.regions ?? {})) {
const feature = fylkeFeatures[key];
if (!feature) continue;
for (const signer of region.signers) {
allDots.push({ signer, geometry: feature.geometry });
}
}
// Fisher-Yates shuffle so dots appear from random regions, not one by one
for (let i = allDots.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allDots[i], allDots[j]] = [allDots[j], allDots[i]];
}
const duration = 30000; // ms total animation window
const msPerDot = Math.max(1, duration / Math.max(1, allDots.length));
allDots.forEach((dot, i) => {
const delay = Math.round(i * msPerDot);
setTimeout(() => {
const latLng = randomPointInFeature(dot.geometry);
const marker = createDotMarker(latLng, dot.signer);
marker.addTo(dotLayer);
}, delay);
});
}
/** Poll update: find new signers and add them with sprinkle */
function pollUpdate(newData) {
const oldRegions = currentData?.regions ?? {};
const newRegions = newData.regions ?? {};
for (const [key, newRegion] of Object.entries(newRegions)) {
const oldCount = oldRegions[key]?.count ?? 0;
const newCount = newRegion.count ?? 0;
if (newCount > oldCount) {
const added = newRegion.signers.slice(0, newCount - oldCount);
const feature = fylkeFeatures[key];
if (feature) {
renderRegionDots(added, feature.geometry);
}
}
}
currentData = newData;
knownTotal = newData.total ?? 0;
updateCountLabel(knownTotal);
}
async function fetchData() {
// Cache-bust with timestamp for polling (static file may be cached by browser)
const sep = CFG.dataUrl.includes('?') ? '&' : '?';
const res = await fetch(CFG.dataUrl + sep + 't=' + Date.now());
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}
// ------- Init -------
const mapEl = document.getElementById('petition-map');
if (!mapEl) return;
function initMap() {
waitForLeaflet(async function () {
// Remove loading indicator once Leaflet starts
const loadingEl = document.getElementById('map-loading');
map = L.map('petition-map', {
center: [65.5, 16],
zoom: 5,
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false,
});
// CartoDB Positron — muted, clean, no distracting details
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd',
maxZoom: 10,
minZoom: 4,
}).addTo(map);
// Custom pane so dots render above fylke polygons
map.createPane('dots');
map.getPane('dots').style.zIndex = 450; // above overlay (400) but below popups (700)
// Dot layer
dotLayer = L.layerGroup({ pane: 'dots' }).addTo(map);
// Load GeoJSON
let geojson;
try {
const res = await fetch(CFG.geojsonUrl);
geojson = await res.json();
} catch (e) {
if (loadingEl) loadingEl.textContent = 'Kunne ikke laste kart.';
return;
}
buildGeoJsonLayer(geojson).addTo(map);
if (loadingEl) loadingEl.remove();
// Load initial data
try {
const data = await fetchData();
initialRender(data);
} catch (e) {
updateCountLabel(0);
}
// Poll for updates
setInterval(async function () {
try {
const data = await fetchData();
if ((data.total ?? 0) > knownTotal) {
pollUpdate(data);
}
} catch (_) { /* silent */ }
}, CFG.pollInterval);
});
}
// Only initialise when the map element enters the viewport
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries, obs) => {
if (entries[0].isIntersecting) {
obs.disconnect();
initMap();
}
}, { threshold: 0.1 });
observer.observe(mapEl);
} else {
// Fallback for browsers without IntersectionObserver
initMap();
}
})();