361 lines
14 KiB
PHP
361 lines
14 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Natural Earth World Map SVG generator voor CodePress CMS.
|
|
*
|
|
* Zet de Natural Earth 110m TopoJSON (publiek domein) om in een plain SVG
|
|
* waarin elk land-pad zijn ISO 3166-1 alpha-2 code als id meekrijgt, zodat
|
|
* de admin-statistiekpagina landen met pure CSS kan inkleuren. Wordt via
|
|
* de CLI uitgevoerd en schrijft public/assets/img/world-map.svg.
|
|
*
|
|
* @since 2.6.4
|
|
* @package CodePress
|
|
*/
|
|
|
|
/**
|
|
* ISO 3166-1 numeric -> alpha-2. Keys are integers because the TopoJSON ids
|
|
* are zero padded strings ("032"), which must not be compared as strings.
|
|
*/
|
|
function isoNumericToAlpha2(): array
|
|
{
|
|
return [
|
|
4 => 'AF', 8 => 'AL', 10 => 'AQ', 12 => 'DZ', 16 => 'AS', 20 => 'AD', 24 => 'AO', 28 => 'AG',
|
|
31 => 'AZ', 32 => 'AR', 36 => 'AU', 40 => 'AT', 44 => 'BS', 48 => 'BH', 50 => 'BD', 51 => 'AM',
|
|
52 => 'BB', 56 => 'BE', 60 => 'BM', 64 => 'BT', 68 => 'BO', 70 => 'BA', 72 => 'BW', 76 => 'BR',
|
|
84 => 'BZ', 86 => 'IO', 90 => 'SB', 92 => 'VG', 96 => 'BN', 100 => 'BG', 104 => 'MM', 108 => 'BI',
|
|
112 => 'BY', 116 => 'KH', 120 => 'CM', 124 => 'CA', 132 => 'CV', 136 => 'KY', 140 => 'CF',
|
|
144 => 'LK', 148 => 'TD', 152 => 'CL', 156 => 'CN', 158 => 'TW', 170 => 'CO', 174 => 'KM',
|
|
175 => 'YT', 178 => 'CG', 180 => 'CD', 184 => 'CK', 188 => 'CR', 191 => 'HR', 192 => 'CU',
|
|
196 => 'CY', 203 => 'CZ', 204 => 'BJ', 208 => 'DK', 212 => 'DM', 214 => 'DO', 218 => 'EC',
|
|
222 => 'SV', 226 => 'GQ', 231 => 'ET', 232 => 'ER', 233 => 'EE', 234 => 'FO', 238 => 'FK',
|
|
242 => 'FJ', 246 => 'FI', 248 => 'AX', 250 => 'FR', 254 => 'GF', 258 => 'PF', 260 => 'TF',
|
|
262 => 'DJ', 266 => 'GA', 268 => 'GE', 270 => 'GM', 275 => 'PS', 276 => 'DE', 288 => 'GH',
|
|
292 => 'GI', 296 => 'KI', 300 => 'GR', 304 => 'GL', 308 => 'GD', 312 => 'GP', 316 => 'GU',
|
|
320 => 'GT', 324 => 'GN', 328 => 'GY', 332 => 'HT', 340 => 'HN', 344 => 'HK', 348 => 'HU',
|
|
352 => 'IS', 356 => 'IN', 360 => 'ID', 364 => 'IR', 368 => 'IQ', 372 => 'IE', 376 => 'IL',
|
|
380 => 'IT', 384 => 'CI', 388 => 'JM', 392 => 'JP', 398 => 'KZ', 400 => 'JO', 404 => 'KE',
|
|
408 => 'KP', 410 => 'KR', 414 => 'KW', 417 => 'KG', 418 => 'LA', 422 => 'LB', 426 => 'LS',
|
|
428 => 'LV', 430 => 'LR', 434 => 'LY', 438 => 'LI', 440 => 'LT', 442 => 'LU', 446 => 'MO',
|
|
450 => 'MG', 454 => 'MW', 458 => 'MY', 462 => 'MV', 466 => 'ML', 470 => 'MT', 474 => 'MQ',
|
|
478 => 'MR', 480 => 'MU', 484 => 'MX', 492 => 'MC', 496 => 'MN', 498 => 'MD', 499 => 'ME',
|
|
500 => 'MS', 504 => 'MA', 508 => 'MZ', 512 => 'OM', 516 => 'NA', 520 => 'NR', 524 => 'NP',
|
|
528 => 'NL', 531 => 'CW', 533 => 'AW', 534 => 'SX', 540 => 'NC', 548 => 'VU', 554 => 'NZ',
|
|
558 => 'NI', 562 => 'NE', 566 => 'NG', 570 => 'NU', 578 => 'NO', 580 => 'MP', 583 => 'FM',
|
|
584 => 'MH', 585 => 'PW', 586 => 'PK', 591 => 'PA', 598 => 'PG', 600 => 'PY', 604 => 'PE',
|
|
608 => 'PH', 612 => 'PN', 616 => 'PL', 620 => 'PT', 624 => 'GW', 626 => 'TL', 630 => 'PR',
|
|
634 => 'QA', 638 => 'RE', 642 => 'RO', 643 => 'RU', 646 => 'RW', 652 => 'BL', 654 => 'SH',
|
|
659 => 'KN', 660 => 'AI', 662 => 'LC', 663 => 'MF', 666 => 'PM', 670 => 'VC', 674 => 'SM',
|
|
678 => 'ST', 682 => 'SA', 686 => 'SN', 688 => 'RS', 690 => 'SC', 694 => 'SL', 702 => 'SG',
|
|
703 => 'SK', 704 => 'VN', 705 => 'SI', 706 => 'SO', 710 => 'ZA', 716 => 'ZW', 724 => 'ES',
|
|
728 => 'SS', 729 => 'SD', 732 => 'EH', 740 => 'SR', 744 => 'SJ', 748 => 'SZ', 752 => 'SE',
|
|
756 => 'CH', 760 => 'SY', 762 => 'TJ', 764 => 'TH', 768 => 'TG', 772 => 'TK', 776 => 'TO',
|
|
780 => 'TT', 784 => 'AE', 788 => 'TN', 792 => 'TR', 795 => 'TM', 796 => 'TC', 798 => 'TV',
|
|
800 => 'UG', 804 => 'UA', 807 => 'MK', 818 => 'EG', 826 => 'GB', 831 => 'GG', 832 => 'JE',
|
|
833 => 'IM', 834 => 'TZ', 840 => 'US', 850 => 'VI', 854 => 'BF', 858 => 'UY', 860 => 'UZ',
|
|
862 => 'VE', 876 => 'WF', 882 => 'WS', 887 => 'YE', 894 => 'ZM',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Natural Earth includes a few disputed areas without an official numeric id.
|
|
* They are matched on their English name instead.
|
|
*/
|
|
function nameToAlpha2(): array
|
|
{
|
|
return [
|
|
'kosovo' => 'XK',
|
|
'somaliland' => 'SO',
|
|
'n. cyprus' => 'CY',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Main generator-functie: bouwt de wereldkaart-SVG uit TopoJSON.
|
|
*
|
|
* Download (met caching) de countries-110m.json, decodeert delta-gecodeerde
|
|
* arcs, projecteert op een equirectangular canvas met antimeridian-unwrap,
|
|
* verzamelt paden per ISO alpha-2 code en schrijft de SVG naar
|
|
* public/assets/img/world-map.svg.
|
|
*
|
|
* @since 2.6.4
|
|
* @param bool $verbose Of voortgang en statistieken naar stdout worden geschreven.
|
|
* @return bool True bij succes, false bij een download- of parse-fout.
|
|
*/
|
|
function generateWorldMapSvg(bool $verbose = false): bool
|
|
{
|
|
$outPath = dirname(__DIR__) . '/public/assets/img/world-map.svg';
|
|
$imgDir = dirname($outPath);
|
|
if (!is_dir($imgDir)) {
|
|
@mkdir($imgDir, 0755, true);
|
|
}
|
|
|
|
$topoUrl = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json';
|
|
$cacheFile = sys_get_temp_dir() . '/world-atlas-110m.json';
|
|
|
|
if (!file_exists($cacheFile) || filesize($cacheFile) < 1000) {
|
|
$ctx = stream_context_create(['http' => ['timeout' => 20, 'user_agent' => 'CodePressCMS']]);
|
|
$data = @file_get_contents($topoUrl, false, $ctx);
|
|
if ($data) {
|
|
file_put_contents($cacheFile, $data);
|
|
}
|
|
}
|
|
|
|
if (!file_exists($cacheFile)) {
|
|
return false;
|
|
}
|
|
|
|
$topo = json_decode(file_get_contents($cacheFile), true);
|
|
if (!$topo || empty($topo['objects']['countries']['geometries'])) {
|
|
return false;
|
|
}
|
|
|
|
// Canvas: equirectangular, cropped to the usual web-map latitude band so
|
|
// Antarctica does not dominate and the Arctic is not overly stretched.
|
|
$canvasW = 1000.0;
|
|
$latTop = 84.0;
|
|
$latBottom = -60.0;
|
|
$canvasH = $canvasW * (($latTop - $latBottom) / 360.0);
|
|
|
|
$scale = $topo['transform']['scale'];
|
|
$translate = $topo['transform']['translate'];
|
|
|
|
/**
|
|
* Delta-gecodeerde arcs decoderen naar plain lon/lat-punten.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
// Decode delta-encoded arcs into plain lon/lat pairs. Projection happens
|
|
// later, after antimeridian handling, because that works in degrees.
|
|
$decodedArcs = [];
|
|
foreach ($topo['arcs'] as $arcIndex => $arc) {
|
|
$x = 0;
|
|
$y = 0;
|
|
$points = [];
|
|
foreach ($arc as $delta) {
|
|
$x += $delta[0];
|
|
$y += $delta[1];
|
|
$points[] = [
|
|
$x * $scale[0] + $translate[0],
|
|
$y * $scale[1] + $translate[1],
|
|
];
|
|
}
|
|
$decodedArcs[$arcIndex] = $points;
|
|
}
|
|
|
|
$numericMap = isoNumericToAlpha2();
|
|
$nameMap = nameToAlpha2();
|
|
|
|
// Collect paths per country code (Natural Earth can list a code more than once)
|
|
$pathsByCode = [];
|
|
$names = [];
|
|
$skipped = [];
|
|
|
|
/**
|
|
* Per land de TopoJSON-geometrie omzetten in SVG-paden.
|
|
*
|
|
* Bepaalt ISO alpha-2 (via numeric map of naam map), slaat Antarctica over,
|
|
* bouwt Polygon/MultiPolygon-rings op en verzamelt paden per code.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
foreach ($topo['objects']['countries']['geometries'] as $geo) {
|
|
$rawId = $geo['id'] ?? '';
|
|
$countryName = $geo['properties']['name'] ?? 'Unknown';
|
|
|
|
$alpha2 = null;
|
|
if ($rawId !== '' && ctype_digit((string)$rawId)) {
|
|
$alpha2 = $numericMap[(int)$rawId] ?? null;
|
|
}
|
|
if ($alpha2 === null) {
|
|
$alpha2 = $nameMap[strtolower($countryName)] ?? null;
|
|
}
|
|
|
|
if ($alpha2 === null) {
|
|
$skipped[] = $rawId . ' ' . $countryName;
|
|
continue;
|
|
}
|
|
|
|
// Antarctica falls outside the cropped canvas
|
|
if ($alpha2 === 'AQ') {
|
|
continue;
|
|
}
|
|
|
|
$type = $geo['type'];
|
|
if ($type === 'Polygon') {
|
|
$polygons = [$geo['arcs']];
|
|
} elseif ($type === 'MultiPolygon') {
|
|
$polygons = $geo['arcs'];
|
|
} else {
|
|
continue;
|
|
}
|
|
|
|
$pathD = '';
|
|
foreach ($polygons as $rings) {
|
|
foreach ($rings as $ring) {
|
|
$ringPoints = [];
|
|
foreach ($ring as $arcIdx) {
|
|
$reversed = $arcIdx < 0;
|
|
$actualIdx = $reversed ? ~$arcIdx : $arcIdx;
|
|
$arcPoints = $decodedArcs[$actualIdx] ?? [];
|
|
if ($reversed) {
|
|
$arcPoints = array_reverse($arcPoints);
|
|
}
|
|
if (!empty($ringPoints) && !empty($arcPoints)) {
|
|
array_shift($arcPoints); // drop point shared with previous arc
|
|
}
|
|
foreach ($arcPoints as $pt) {
|
|
$ringPoints[] = $pt;
|
|
}
|
|
}
|
|
|
|
if (count($ringPoints) < 3) {
|
|
continue;
|
|
}
|
|
|
|
// Unwrap longitudes so a ring crossing the antimeridian stays
|
|
// continuous instead of jumping from +180 to -180 and smearing
|
|
// a band right across the map (Russia, Fiji).
|
|
$offset = 0.0;
|
|
$unwrapped = [];
|
|
$prevLon = null;
|
|
foreach ($ringPoints as $pt) {
|
|
$lon = $pt[0];
|
|
if ($prevLon !== null) {
|
|
$step = $lon + $offset - $prevLon;
|
|
if ($step > 180.0) {
|
|
$offset -= 360.0;
|
|
} elseif ($step < -180.0) {
|
|
$offset += 360.0;
|
|
}
|
|
}
|
|
$lonAdj = $lon + $offset;
|
|
$unwrapped[] = [$lonAdj, $pt[1]];
|
|
$prevLon = $lonAdj;
|
|
}
|
|
|
|
$lons = array_column($unwrapped, 0);
|
|
$minLon = min($lons);
|
|
$maxLon = max($lons);
|
|
|
|
// Draw the ring once normally, and once shifted a full turn when
|
|
// it sticks out past a map edge. Anything outside the viewBox is
|
|
// clipped by the SVG itself, so both edges end up correct.
|
|
$shifts = [0.0];
|
|
if ($maxLon > 180.0) {
|
|
$shifts[] = -360.0;
|
|
}
|
|
if ($minLon < -180.0) {
|
|
$shifts[] = 360.0;
|
|
}
|
|
|
|
foreach ($shifts as $shift) {
|
|
if ($shift !== 0.0
|
|
&& ($minLon + $shift > 180.0 || $maxLon + $shift < -180.0)) {
|
|
continue; // fully off-canvas
|
|
}
|
|
|
|
$segments = [];
|
|
$visible = false;
|
|
foreach ($unwrapped as $i => $pt) {
|
|
$lon = $pt[0] + $shift;
|
|
$lat = $pt[1];
|
|
|
|
$svgX = ($lon + 180.0) * ($canvasW / 360.0);
|
|
$svgY = ($latTop - $lat) * ($canvasH / ($latTop - $latBottom));
|
|
|
|
if ($svgX >= -50 && $svgX <= $canvasW + 50 && $svgY <= $canvasH + 50) {
|
|
$visible = true;
|
|
}
|
|
|
|
$segments[] = ($i === 0 ? 'M' : 'L') . round($svgX, 2) . ',' . round($svgY, 2);
|
|
}
|
|
|
|
if ($visible) {
|
|
$pathD .= implode(' ', $segments) . ' Z ';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$pathD = trim($pathD);
|
|
if ($pathD === '') {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($pathsByCode[$alpha2])) {
|
|
$pathsByCode[$alpha2] = '';
|
|
$names[$alpha2] = $countryName;
|
|
}
|
|
$pathsByCode[$alpha2] .= ($pathsByCode[$alpha2] === '' ? '' : ' ') . $pathD;
|
|
}
|
|
|
|
/**
|
|
* SVG-document samenstellen en wegschrijven.
|
|
*
|
|
* Sorteert paden per code, formattert <path>-elementen met id, data-name,
|
|
* class, d en <title>, voegt inline CSS toe en schrijft het resultaat naar
|
|
* de output-map.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
ksort($pathsByCode);
|
|
|
|
$svgPaths = [];
|
|
foreach ($pathsByCode as $code => $d) {
|
|
$svgPaths[] = sprintf(
|
|
' <path id="%s" data-name="%s" class="country" d="%s"><title>%s</title></path>',
|
|
$code,
|
|
htmlspecialchars($names[$code], ENT_QUOTES, 'UTF-8'),
|
|
$d,
|
|
htmlspecialchars($names[$code], ENT_QUOTES, 'UTF-8')
|
|
);
|
|
}
|
|
|
|
$h = round($canvasH, 2);
|
|
$svg = '<?xml version="1.0" encoding="UTF-8"?>' . "\n"
|
|
. '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . (int)$canvasW . ' ' . $h . '"'
|
|
. ' width="100%" height="auto" preserveAspectRatio="xMidYMid meet"'
|
|
. ' fill-rule="evenodd" class="codepress-world-map" role="img"'
|
|
. ' aria-label="Wereldkaart met bezoekers per land">' . "\n"
|
|
. ' <style>' . "\n"
|
|
. ' .codepress-world-map { display: block; background: #eaf2fb; }' . "\n"
|
|
. ' .codepress-world-map .country { fill: #dfe4ea; stroke: #ffffff; stroke-width: 0.5;'
|
|
. ' stroke-linejoin: round; vector-effect: non-scaling-stroke; transition: fill .15s ease; }' . "\n"
|
|
. ' .codepress-world-map .country:hover { fill: #ffc107; }' . "\n"
|
|
. ' </style>' . "\n"
|
|
. implode("\n", $svgPaths) . "\n"
|
|
. '</svg>' . "\n";
|
|
|
|
file_put_contents($outPath, $svg);
|
|
|
|
if ($verbose) {
|
|
echo 'Landen getekend: ' . count($pathsByCode) . PHP_EOL;
|
|
echo 'Canvas: ' . (int)$canvasW . ' x ' . $h . PHP_EOL;
|
|
echo 'Bestandsgrootte: ' . round(filesize($outPath) / 1024) . ' KB' . PHP_EOL;
|
|
if ($skipped) {
|
|
echo 'Overgeslagen (geen ISO-code): ' . implode(', ', $skipped) . PHP_EOL;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* CLI-entrypoint: wereldkaart-SVG genereren.
|
|
*
|
|
* Roept generateWorldMapSvg() aan en rapporteert het resultaat; bij een
|
|
* fout wordt met exit(1) afgesloten.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
if (php_sapi_name() === 'cli') {
|
|
echo "Wereldkaart SVG genereren uit Natural Earth TopoJSON...\n";
|
|
if (generateWorldMapSvg(true)) {
|
|
echo "Klaar: public/assets/img/world-map.svg\n";
|
|
} else {
|
|
echo "Fout bij genereren van de wereldkaart.\n";
|
|
exit(1);
|
|
}
|
|
}
|