innhold/custom/assets/petition-map.js
Ruben a7829982d0 Add petition map to medical cannabis petition page
Add anonymous SVG icon for anonymous signers

Add Norway fylker GeoJSON for map boundaries

Add CSS styles for petition map

Add JavaScript for interactive petition map

Add .htaccess to block direct access to data files

Add petition-map plugin to process and display map data

Add documentation for the petition-map plugin

Add mock petition data generator tool
2026-02-25 23:11:35 +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 = 6000; // 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();
}
})();