/**
* 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 = `