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
421 lines
12 KiB
JavaScript
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, '&')
|
|
.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 = 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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();
|
|
}
|
|
|
|
})();
|