/** * 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( ` 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 = `
${escapeHtml(name)}
${countText}
`; 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, '&') .replace(//g, '>') .replace(/"/g, '"'); } 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: '© OpenStreetMap © CARTO', 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(); } })();