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
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;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue