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
This commit is contained in:
Ruben 2026-02-25 23:11:35 +01:00
parent 1ee0e0f0a0
commit a7829982d0
10 changed files with 1134 additions and 1 deletions

View file

@ -4,4 +4,6 @@
<p class="text-small text-muted">Har du allerede signert, men ikke mottatt bekreftelsesmail? <a href="send-bekreftelse-pa-nytt/">Send bekreftelse nytt</a></p>
<?= $petition_map ?? '' ?>
<?= $petition_signatures ?? '' ?>

View file

@ -1,5 +1,5 @@
date = "2026-01-15"
plugins = "petition-form"
plugins = "petition-form, petition-map"
thank_you_page = "takk"
hide_list = true
newsletter_list_uuids = "dfcf73f4-c86a-43a1-9ddb-31309f7392a9,c4849164-d5e7-4aca-9721-423282773fa1"

1
custom/assets/anon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="800" height="800" viewBox="0 0 512 512" fill="white"><path d="M509 167c-15 1-65 6-129 6-91 0-97 32-124 32-31 0-29-32-124-32-64 0-114-5-129-6-10 0 4 104 47 143 33 29 111 54 173 13 23-18 33-18 33-18s10 0 33 18c62 41 140 16 173-13 43-39 57-143 47-143m-307 88c-45 51-120 16-121-35 85-13 125 32 121 35m108 0c-4-3 36-48 121-35-1 51-76 86-121 35"/></svg>

After

Width:  |  Height:  |  Size: 415 B

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,115 @@
/* Petition map page styles */
.petition-map-section {
margin-top: 2rem;
.petition-map-header {
background: var(--color-green-light);
padding-block: 1.5rem 1rem;
margin-bottom: 0;
h1 {
margin-top: 0;
font-size: clamp(1.4rem, 4vw, 2rem);
}
.petition-map-subtitle {
color: oklch(0.45 0.05 173.93);
font-size: 0.95rem;
margin-top: 0.4rem;
}
}
#petition-map {
position: relative;
height: clamp(420px, 70vh, 800px);
background: oklch(0.96 0.005 220);
.map-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: oklch(0.55 0.03 220);
font-size: 0.9rem;
z-index: 10;
pointer-events: none;
}
/* Leaflet overrides for muted style */
.leaflet-container {
background: oklch(0.96 0.005 220);
font-family: var(--font-body);
}
.leaflet-control-attribution {
font-size: 0.65rem;
background: oklch(1 0 0 / 0.75);
}
}
.petition-map-footer {
padding-block: 1rem 2rem;
.back-link {
font-size: 0.9rem;
}
}
}
/* Fylke polygon styles (set via Leaflet path options, these are fallbacks) */
.leaflet-interactive {
cursor: pointer;
}
/* Signer name tooltip */
.signer-tip {
background: oklch(0.618 0.1176 173.93);
color: oklch(0.97 0 0);
border: none;
box-shadow: none;
font-family: var(--font-body);
font-size: 0.65rem;
font-weight: 600;
padding: 1px 5px;
border-radius: 8px;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
&::before { display: none; } /* hide tooltip arrow */
}
/* Anonymous tooltip */
.signer-tip--anon {
background: oklch(0.618 0.1176 173.93);
padding: 2px 5px 2px 4px;
.anon-icon {
width: 14px;
height: 14px;
vertical-align: middle;
}
}
/* Fylke popup / info panel */
.fylke-popup {
font-family: var(--font-body);
font-size: 0.85rem;
max-width: 220px;
.fylke-popup-title {
font-family: var(--font-heading);
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.4rem;
color: var(--color-green);
}
.fylke-popup-count {
font-size: 0.8rem;
color: oklch(0.5 0.03 173.93);
}
}

View file

@ -0,0 +1,421 @@
/**
* 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();
}
})();

6
custom/data/.htaccess Normal file
View file

@ -0,0 +1,6 @@
# Deny direct web access to all data files
<Files "*">
Require all denied
</Files>
Options -Indexes

View file

@ -0,0 +1,192 @@
<?php
/**
* Petition Map Plugin (page plugin)
*
* Enable via metadata.ini: plugins = "petition-form, petition-map"
* Requires petition_id to be set (inherited from petition-form setup).
*
* Injects template variable $petition_map containing the full widget HTML.
* Embed in a content file: <?= $petition_map ?>
*
* Data flow:
* CSV -> petitionMapBuildData() -> cache (custom/data/) + public JSON (custom/assets/)
* Public JSON is served at /petition-map-data.json by the router's asset mechanism.
* JS fetches it and renders dots on a Leaflet map.
*
* Public JSON format:
* { "generated": int, "total": int, "regions": { "oslo": { "count": 42, "signers": [{"n":"Kari","a":false}] } } }
* n = first name (null for anonymous), a = anonymous (bool)
* No emails, surnames, tokens, or IP hashes ever leave the server.
*/
define('PETITION_MAP_CACHE_TTL', 60);
define('PETITION_MAP_CACHE_PATH', dirname(__DIR__, 2) . '/data/petition-map-cache.json');
define('PETITION_MAP_PUBLIC_PATH', dirname(__DIR__, 2) . '/assets/petition-map-data.json');
/**
* Build public-safe map data from the petition CSV.
*/
function petitionMapBuildData(string $csvPath): array {
$regions = [];
$total = 0;
if (!file_exists($csvPath)) {
return ['generated' => time(), 'total' => 0, 'regions' => (object)[]];
}
$fp = fopen($csvPath, 'r');
if (!$fp) {
return ['generated' => time(), 'total' => 0, 'regions' => (object)[]];
}
if (flock($fp, LOCK_SH)) {
fgetcsv($fp, null, ',', '"', '');
while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) {
if (!isset($row[6]) || $row[6] !== 'confirmed') continue;
$region = $row[4] ?? '';
$display = $row[5] ?? 'semi';
if (empty($region)) continue;
$anonymous = ($display === 'anonymous');
$name = $anonymous ? null : trim($row[2] ?? '');
if ($name !== null) {
$name = mb_substr($name, 0, 50);
if ($name === '') $name = null;
}
if (!isset($regions[$region])) {
$regions[$region] = ['count' => 0, 'signers' => []];
}
$regions[$region]['count']++;
$regions[$region]['signers'][] = ['n' => $name, 'a' => $anonymous];
$total++;
}
flock($fp, LOCK_UN);
}
fclose($fp);
return [
'generated' => time(),
'total' => $total,
'regions' => empty($regions) ? (object)[] : $regions,
];
}
function petitionMapReadCache(): ?array {
$path = PETITION_MAP_CACHE_PATH;
if (!file_exists($path)) return null;
if ((time() - filemtime($path)) > PETITION_MAP_CACHE_TTL) return null;
$json = file_get_contents($path);
if (!$json) return null;
$data = json_decode($json, true);
return is_array($data) ? $data : null;
}
function petitionMapWriteCache(array $data): void {
$json = json_encode($data, JSON_UNESCAPED_UNICODE);
if ($json === false) return;
foreach ([PETITION_MAP_CACHE_PATH, PETITION_MAP_PUBLIC_PATH] as $path) {
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0750, true);
}
$tmp = $path . '.tmp.' . getmypid();
if (file_put_contents($tmp, $json, LOCK_EX) !== false) {
rename($tmp, $path);
}
}
}
function petitionMapGetData(string $csvPath): array {
$cached = petitionMapReadCache();
if ($cached !== null) return $cached;
$data = petitionMapBuildData($csvPath);
petitionMapWriteCache($data);
return $data;
}
/**
* Render the map widget HTML block.
*/
function petitionMapRenderWidget(Context $ctx): string {
$langPrefix = $ctx->get('langPrefix', '');
// CSS hash for cache busting
$cssPath = dirname(__DIR__, 2) . '/assets/petition-map.css';
$cssHash = file_exists($cssPath) ? substr(hash_file('md5', $cssPath), 0, 8) : '';
$jsPath = dirname(__DIR__, 2) . '/assets/petition-map.js';
$jsHash = file_exists($jsPath) ? substr(hash_file('md5', $jsPath), 0, 8) : '';
$dataJsonPath = PETITION_MAP_PUBLIC_PATH;
$dataHash = file_exists($dataJsonPath) ? substr(hash_file('md5', $dataJsonPath), 0, 8) : '';
$html = '';
// Leaflet CSS
$html .= '<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="anonymous">';
// Map CSS
$html .= '<link rel="stylesheet" href="/petition-map.css?v=' . $cssHash . '">';
// Map container
$html .= '<section class="petition-map-section escape">';
$html .= '<div id="petition-map" aria-label="Kart over underskrifter i Norge">';
$html .= '<div class="map-loading" id="map-loading" aria-live="polite">Laster kart…</div>';
$html .= '</div>';
$html .= '</section>';
// Config for JS
$html .= '<script>';
$html .= 'window.PETITION_MAP_CONFIG={';
$html .= 'dataUrl:"/petition-map-data.json?v=' . $dataHash . '",';
$html .= 'geojsonUrl:"/norway-fylker.geojson",';
$html .= 'anonIconUrl:"/anon.svg",';
$html .= 'pollInterval:60000';
$html .= '};';
$html .= '</script>';
// Leaflet JS + map JS
$html .= '<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="anonymous"></script>';
$html .= '<script src="/petition-map.js?v=' . $jsHash . '"></script>';
return $html;
}
// --- Hook ---
Hooks::add(Hook::TEMPLATE_VARS, function(array $vars, Context $ctx) {
// Resolve page directory and metadata (reuse petition-form helpers if available)
if (!function_exists('petitionResolvePageDir')) return $vars;
$pageDir = petitionResolvePageDir($ctx);
if (!$pageDir) return $vars;
$metadata = loadMetadata($pageDir);
$plugins = $metadata['plugins'] ?? '';
if (strpos($plugins, 'petition-map') === false) return $vars;
$petitionId = $metadata['petition_id']
?? (function_exists('petitionGetIdFromPath') ? petitionGetIdFromPath($pageDir) : null);
if (!$petitionId) return $vars;
$petitionId = preg_replace('/[^a-z0-9\-_]/i', '', $petitionId);
if (empty($petitionId)) return $vars;
$csvPath = dirname(__DIR__, 2) . "/data/petitions/{$petitionId}.csv";
// Refresh cache + public JSON if stale
petitionMapGetData($csvPath);
// Inject widget HTML as template variable
$vars['petition_map'] = petitionMapRenderWidget($ctx);
return $vars;
});

View file

@ -0,0 +1,106 @@
# petition-map plugin
Page plugin that renders an interactive Leaflet map of petition signers across Norway's 15 fylker.
## Enable
In a page's `metadata.ini` (must also have petition-form loaded first):
```ini
plugins = "petition-form, petition-map"
```
Embed in a content file (e.g. `85-kart.php`):
```php
<?= $petition_map ?? '' ?>
```
## Architecture
```
petition-form CSV
petition-map.php (PHP, page plugin)
├── Reads CSV, extracts: first name, anonymous flag, region, confirmed status
├── Writes private cache: custom/data/petition-map-cache.json (60s TTL)
└── Writes public JSON: custom/assets/petition-map-data.json (served at /petition-map-data.json)
petition-map.js (client-side)
├── Fetches /petition-map-data.json + /norway-fylker.geojson
├── Renders Leaflet map with CartoDB Positron tiles (muted, no labels)
├── Draws 15 fylke outlines from GeoJSON
├── Places name labels (L.tooltip permanent) randomly within each fylke polygon
└── Polls every 60s for new signers
```
## Files
| File | Location | Purpose |
|---|---|---|
| `petition-map.php` | `custom/plugins/page/` | PHP plugin: cache logic, data builder, HTML widget renderer, TEMPLATE_VARS hook |
| `petition-map.js` | `custom/assets/` | Client JS: Leaflet map, dot placement, point-in-polygon, polling |
| `petition-map.css` | `custom/assets/` | Map and label styles |
| `norway-fylker.geojson` | `custom/assets/` | Simplified fylke boundaries (15 current fylker, from OpenStreetMap Overpass, RDP simplified to ~69KB) |
| `petition-map-data.json` | `custom/assets/` | Auto-generated public signer data (written by PHP, served as static asset) |
| `petition-map-cache.json` | `custom/data/` | Private server-side cache (same data, controls rebuild frequency) |
| `anon.svg` | `custom/assets/` | Mask icon for anonymous signers |
| `generate-mock-petitions.php` | `custom/tools/` | Dev-only CLI to generate mock CSV data |
## Public JSON format
```json
{
"generated": 1772052913,
"total": 500,
"regions": {
"oslo": {
"count": 57,
"signers": [
{"n": "Kari", "a": false},
{"n": null, "a": true}
]
}
}
}
```
- `n` = first name (null if anonymous or empty)
- `a` = anonymous boolean
- No emails, surnames, tokens, or IP hashes ever leave the server
## Privacy rules (from CSV `display` column)
- `full` or `semi`: first name shown on map
- `anonymous`: shown as mask icon, no name
- Only `confirmed` status rows are included
## Region keys
15 keys matching the petition-form select options and GeoJSON `name` property:
`agder`, `akershus`, `buskerud`, `finnmark`, `innlandet`, `more_og_romsdal`, `nordland`, `oslo`, `rogaland`, `telemark`, `troms`, `trondelag`, `vestfold`, `vestland`, `ostfold`
## JS internals
- **Point placement**: rejection sampling within fylke polygon bounding box, ray-casting point-in-polygon test, 4% edge inset
- **Label rendering**: `L.tooltip({ permanent: true })` bound to invisible zero-size `L.marker` in a custom `dots` pane (z-index 450)
- **Initial animation**: all labels shuffled (Fisher-Yates) and staggered over 4.5s
- **Polling**: fetches data JSON every 60s with cache-bust timestamp; new signers added immediately
- **Fylke interaction**: hover highlights polygon, click shows popup with fylke name + count
## Cache invalidation
Delete `custom/data/petition-map-cache.json` to force a rebuild on next page load. The mock generator does this automatically. The public JSON is rewritten whenever the cache rebuilds.
## Dev tools
Generate mock data (run inside the container):
```sh
podman exec stopplidelsen.no php /var/www/custom/tools/generate-mock-petitions.php
```
Interactive: asks for count and regional variance. Writes CSV and invalidates cache files.

View file

@ -0,0 +1,289 @@
#!/usr/bin/env php
<?php
/**
* Mock petition data generator DEV ONLY.
*
* Generates realistic-looking confirmed petition signatures for testing
* the map widget. Writes directly to the real CSV (dev environment only).
*
* Run from host:
* podman exec stopplidelsen.no php /var/www/custom/tools/generate-mock-petitions.php
*/
// Safety check: refuse to run outside the dev container
$inContainer = file_exists('/var/www/html') && file_exists('/var/www/custom');
if (!$inContainer && !in_array('--force', $argv)) {
echo "Warning: this script is intended to run inside the dev container.\n";
echo " podman exec stopplidelsen.no php /var/www/custom/tools/generate-mock-petitions.php\n";
echo " Pass --force to run anyway.\n";
exit(1);
}
$csvPath = '/var/www/custom/data/petitions/medisinsk-cannabis-pa-resept.csv';
// ------- Name lists -------
$firstNames = [
'Kari', 'Ola', 'Anne', 'Per', 'Ingrid', 'Lars', 'Kristin', 'Erik',
'Marianne', 'Tor', 'Hanne', 'Bjørn', 'Silje', 'Trond', 'Nina', 'Morten',
'Lene', 'Håkon', 'Anita', 'Svein', 'Marit', 'Gunnar', 'Tonje', 'Rune',
'Bente', 'Terje', 'Camilla', 'Geir', 'Astrid', 'Jon', 'Trine', 'Anders',
'Heidi', 'Vegard', 'Siri', 'Frode', 'Marte', 'Steinar', 'Lise', 'Øyvind',
'Karianne', 'Petter', 'Tone', 'Kenneth', 'Gro', 'Vidar', 'Line', 'Eivind',
'Maria', 'Jonas', 'Ida', 'Martin', 'Katrine', 'Fredrik', 'Kirsten', 'Helge',
'Turid', 'Ole', 'Berit', 'Arild', 'Wendy', 'Ragnar', 'Stine', 'Ivar',
'Åse', 'Knut', 'Elise', 'Harald', 'Solveig', 'Dag', 'Kristine', 'Leif',
'Elin', 'Nils', 'Mia', 'Eirik', 'Randi', 'Sigurd', 'Thea', 'Magnus',
];
$surnames = [
'Hansen', 'Johansen', 'Olsen', 'Larsen', 'Andersen', 'Pedersen', 'Nilsen',
'Kristiansen', 'Jensen', 'Karlsen', 'Johnsen', 'Pettersen', 'Eriksen',
'Berg', 'Haugen', 'Hagen', 'Johannessen', 'Bakke', 'Strand', 'Lie',
'Halvorsen', 'Solberg', 'Lund', 'Dahl', 'Moen', 'Sørensen', 'Bøe',
'Berge', 'Aas', 'Holm', 'Martinsen', 'Paulsen', 'Jakobsen', 'Stensrud',
'Nygaard', 'Christoffersen', 'Amundsen', 'Svendsen', 'Lunde', 'Sæther',
'Eide', 'Henriksen', 'Myhre', 'Fjeld', 'Vik', 'Thorsen', 'Bakken',
'Aasen', 'Mathisen', 'Elstad', 'Vold', 'Lindqvist', 'Torp', 'Nesse',
'Ruud', 'Brekke', 'Midtbø', 'Grøtting', 'Kleven', 'Hauge', 'Skogen',
];
$regions = [
'agder', 'akershus', 'buskerud', 'finnmark', 'innlandet',
'more_og_romsdal', 'nordland', 'oslo', 'rogaland', 'telemark',
'troms', 'trondelag', 'vestfold', 'vestland', 'ostfold',
];
$displayOptions = ['semi', 'semi', 'semi', 'full', 'anonymous']; // weighted
// ------- Prompt helpers -------
function prompt(string $question, string $default = ''): string {
echo $question;
if ($default !== '') echo " [$default]";
echo ': ';
$line = trim(fgets(STDIN));
return $line === '' ? $default : $line;
}
function promptInt(string $question, int $default, int $min = 1, int $max = PHP_INT_MAX): int {
while (true) {
$val = (int)prompt($question, (string)$default);
if ($val >= $min && $val <= $max) return $val;
echo " Please enter a number between $min and $max.\n";
}
}
function promptFloat(string $question, float $default, float $min = 0.0, float $max = 1.0): float {
while (true) {
$val = (float)prompt($question, (string)$default);
if ($val >= $min && $val <= $max) return $val;
echo " Please enter a number between $min and $max.\n";
}
}
// ------- Distribution helpers -------
/**
* Generate a biased region distribution.
*
* @param int $n Total signatures to distribute
* @param float $variance 0.0 = perfectly even, 1.0 = highly concentrated (Oslo-heavy)
* @return array [region => count]
*/
function generateRegionDistribution(int $n, float $variance, array $regions): array {
// Base weights (approx. Norwegian population distribution, normalized)
$baseWeights = [
'oslo' => 12.0,
'akershus' => 11.5,
'vestland' => 9.0,
'rogaland' => 8.0,
'trondelag' => 7.5,
'innlandet' => 5.5,
'buskerud' => 5.0,
'vestfold' => 4.5,
'ostfold' => 4.5,
'more_og_romsdal'=> 4.5,
'agder' => 4.0,
'telemark' => 3.5,
'nordland' => 4.0,
'troms' => 3.5,
'finnmark' => 1.5,
];
// Blend between uniform (variance=0) and biased (variance=1)
$uniform = 1.0 / count($regions);
$total = array_sum($baseWeights);
$weights = [];
foreach ($regions as $r) {
$biased = ($baseWeights[$r] ?? 1.0) / $total;
$weights[$r] = (1 - $variance) * $uniform + $variance * $biased;
}
// Normalise
$wsum = array_sum($weights);
foreach ($weights as &$w) $w /= $wsum;
// Allocate counts
$counts = [];
$remaining = $n;
$keys = array_keys($weights);
foreach ($keys as $i => $r) {
if ($i === count($keys) - 1) {
$counts[$r] = $remaining;
} else {
$c = (int)round($n * $weights[$r]);
$counts[$r] = $c;
$remaining -= $c;
}
}
return $counts;
}
// ------- CSV writer -------
function appendSignature(string $csvPath, array $data): void {
$fp = fopen($csvPath, 'a');
if (!$fp) {
echo "ERROR: Cannot open $csvPath\n";
exit(1);
}
fputcsv($fp, $data, ',', '"', '');
fclose($fp);
}
function initCsv(string $csvPath): void {
$fp = fopen($csvPath, 'w');
if (!$fp) {
echo "ERROR: Cannot create $csvPath\n";
exit(1);
}
fputcsv($fp, ['timestamp', 'email', 'firstname', 'surname', 'region', 'display', 'status', 'token', 'token_created', 'ip_hash'], ',', '"', '');
fclose($fp);
}
// ------- Main -------
echo "\n";
echo "╔══════════════════════════════════════════════╗\n";
echo "║ Mock Petition Data Generator — DEV ONLY ║\n";
echo "╚══════════════════════════════════════════════╝\n\n";
$existingCount = 0;
if (file_exists($csvPath)) {
$fp = fopen($csvPath, 'r');
fgetcsv($fp, null, ',', '"', ''); // skip header
while (fgetcsv($fp, null, ',', '"', '')) $existingCount++;
fclose($fp);
echo " Current CSV: $csvPath\n";
echo " Existing rows: $existingCount\n\n";
} else {
echo " CSV does not exist yet, will create it.\n\n";
}
$action = prompt(" Action — [a]ppend or [r]eplace (wipes existing data)", 'a');
$replace = strtolower($action[0] ?? 'a') === 'r';
if ($replace && $existingCount > 0) {
$confirm = prompt(" ⚠️ This will delete $existingCount existing rows. Type 'yes' to confirm", '');
if (strtolower($confirm) !== 'yes') {
echo " Aborted.\n";
exit(0);
}
}
echo "\n";
$count = promptInt(" How many mock petitioners to generate", 1000, 1, 100000);
echo "\n Regional distribution:\n";
echo " 0.0 = perfectly even across all fylker\n";
echo " 1.0 = highly concentrated (Oslo/Akershus heavy, matches real population)\n";
$variance = promptFloat(" Variance (0.0 1.0)", 0.7, 0.0, 1.0);
echo "\n";
// Generate distribution
$dist = generateRegionDistribution($count, $variance, $regions);
echo " Distribution preview:\n";
arsort($dist);
foreach ($dist as $r => $c) {
$bar = str_repeat('█', (int)round($c / $count * 40));
printf(" %-20s %4d %s\n", $r, $c, $bar);
}
echo "\n";
$go = prompt(" Generate $count signatures? [y/n]", 'y');
if (strtolower($go[0] ?? 'n') !== 'y') {
echo " Aborted.\n";
exit(0);
}
// Prepare CSV
if ($replace || !file_exists($csvPath)) {
initCsv($csvPath);
}
// Generate signatures
$fp = fopen($csvPath, 'a');
if (!$fp) {
echo "ERROR: Cannot open $csvPath for writing\n";
exit(1);
}
$now = time();
$generated = 0;
$usedEmails = [];
foreach ($dist as $region => $regionCount) {
for ($i = 0; $i < $regionCount; $i++) {
$firstname = $GLOBALS['firstNames'][array_rand($GLOBALS['firstNames'])];
$surname = $GLOBALS['surnames'][array_rand($GLOBALS['surnames'])];
$display = $GLOBALS['displayOptions'][array_rand($GLOBALS['displayOptions'])];
// Unique-ish email
$slug = strtolower($firstname . '.' . $surname . '.' . $generated);
$email = $slug . '@mock.test';
// Timestamp spread over last 60 days
$timestamp = $now - random_int(0, 60 * 86400);
$token = bin2hex(random_bytes(32));
$ipHash = bin2hex(random_bytes(32));
fputcsv($fp, [
$timestamp,
$email,
$firstname,
$surname,
$region,
$display,
'confirmed', // all mock sigs are confirmed
$token,
$timestamp,
$ipHash,
], ',', '"', '');
$generated++;
}
}
fclose($fp);
echo "\n ✓ Generated $generated mock signatures.\n";
echo " ✓ Written to: $csvPath\n\n";
// Invalidate map cache (both private cache and public static file)
foreach ([
'/var/www/custom/data/petition-map-cache.json',
'/var/www/custom/assets/petition-map-data.json',
] as $cacheFile) {
if (file_exists($cacheFile)) {
unlink($cacheFile);
echo " ✓ Cleared: $cacheFile\n";
}
}
echo "\n";