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:
parent
1ee0e0f0a0
commit
a7829982d0
10 changed files with 1134 additions and 1 deletions
1
custom/assets/anon.svg
Normal file
1
custom/assets/anon.svg
Normal 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 |
1
custom/assets/norway-fylker.geojson
Normal file
1
custom/assets/norway-fylker.geojson
Normal file
File diff suppressed because one or more lines are too long
115
custom/assets/petition-map.css
Normal file
115
custom/assets/petition-map.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
421
custom/assets/petition-map.js
Normal file
421
custom/assets/petition-map.js
Normal 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, '&')
|
||||
.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();
|
||||
}
|
||||
|
||||
})();
|
||||
6
custom/data/.htaccess
Normal file
6
custom/data/.htaccess
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Deny direct web access to all data files
|
||||
<Files "*">
|
||||
Require all denied
|
||||
</Files>
|
||||
|
||||
Options -Indexes
|
||||
192
custom/plugins/page/petition-map.php
Normal file
192
custom/plugins/page/petition-map.php
Normal 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;
|
||||
});
|
||||
106
custom/plugins/page/petiton-map.docs.md
Normal file
106
custom/plugins/page/petiton-map.docs.md
Normal 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.
|
||||
289
custom/tools/generate-mock-petitions.php
Normal file
289
custom/tools/generate-mock-petitions.php
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue