Add lazy initialization for petition map Update documentation with new positioning guidance Note full-width section rendering behavior
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 = 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: '© <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();
|
|
}
|
|
|
|
})();
|