about summary refs log tree commit diff
diff options
context:
space:
mode:
authorRobert Günzler <r@gnzler.io>2026-02-22 22:16:26 +0100
committerRobert Günzler <r@gnzler.io>2026-03-23 11:44:57 -0300
commit8bca0ebd8645bf2714035ed61ac140956a83fc21 (patch)
tree105f92dec40e7ac62f5cbe7d4c359a3343891070
parent975bc35109d4ee153da1936f8baaca03a9a1941e (diff)
downloadsite-8bca0ebd8645bf2714035ed61ac140956a83fc21.tar.gz
add map
Signed-off-by: Robert Günzler <r@gnzler.io>
-rw-r--r--.webdeployignore3
-rw-r--r--Caddyfile.valid_paths1
-rw-r--r--map/controls.js39
-rw-r--r--map/index.html18
-rw-r--r--map/main.js284
-rw-r--r--map/style.css28
6 files changed, 373 insertions, 0 deletions
diff --git a/.webdeployignore b/.webdeployignore
index 6531d46..8f5168e 100644
--- a/.webdeployignore
+++ b/.webdeployignore
@@ -13,3 +13,6 @@
 - *.jxl
 - *.pdf
 - *.flac
+
++ map/**.pmtiles
+- map/export-data/*
diff --git a/Caddyfile.valid_paths b/Caddyfile.valid_paths
index 06ddd02..47d13ba 100644
--- a/Caddyfile.valid_paths
+++ b/Caddyfile.valid_paths
@@ -6,3 +6,4 @@ path /robots.txt
 path /links/*
 path /media/*
 path /site/*
+path /map/*
diff --git a/map/controls.js b/map/controls.js
new file mode 100644
index 0000000..e407a0c
--- /dev/null
+++ b/map/controls.js
@@ -0,0 +1,39 @@
+export class CustomControl {
+    _map = undefined;
+    _container = undefined;
+    _data = undefined;
+
+    constructor(data) {
+        if (Object.hasOwn(data, "label") &&
+            Object.hasOwn(data, "icon") &&
+            Object.hasOwn(data, "onclick")) {
+            this._data = data;
+        } else {
+            throw new Error("missing required property");
+        }
+    }
+
+    onAdd(map) {
+        this._map = map;
+        this._container = document.createElement('div');
+        this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
+
+        const button = document.createElement('button');
+        button.type = 'button';
+        button.title = this._data.label;
+        button.textContent = this._data.icon;
+        button.onclick = () => {
+            this._data.onclick();
+        }
+
+        this._container.appendChild(button);
+        // this._map.on('mousedown', () => { });
+        return this._container;
+    }
+
+    onRemove() {
+        this._container.parentNode.removeChild(this._container);
+        // this._map.off('mousedown', () => { });
+        this._map = undefined;
+    }
+}
diff --git a/map/index.html b/map/index.html
new file mode 100644
index 0000000..a0ca740
--- /dev/null
+++ b/map/index.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8">
+		<meta name="viewport" content="width=device-width, initial-scale=1.0">
+		<meta name="color-scheme" content="dark light">
+		<link href="style.css" rel="stylesheet">
+		<script type="module" src="main.js"></script>
+	</head>
+	<body>
+		<header>
+			<nav>
+				<a href="/"><img src="/media/interface/logo.svg" alt="gzr.im"></a>
+			</nav>
+		</header>
+		<main id="map"></main>
+	</body>
+</html>
diff --git a/map/main.js b/map/main.js
new file mode 100644
index 0000000..5340ca1
--- /dev/null
+++ b/map/main.js
@@ -0,0 +1,284 @@
+import * as MaplibreGL from 'https://cdn.skypack.dev/maplibre-gl?min';
+import * as Diplomat from 'https://cdn.skypack.dev/@americana/diplomat?min';
+import * as PMTiles from 'https://cdn.skypack.dev/pmtiles?min';
+import * as D3 from 'https://cdn.skypack.dev/d3?min';
+import turf from 'https://cdn.skypack.dev/turf?min';
+
+
+// local imports
+import {
+    CustomControl,
+} from './controls.js';
+
+// add the PMTiles plugin to the maplibregl global.
+const pmproto = new PMTiles.Protocol();
+MaplibreGL.addProtocol('pmtiles', pmproto.tile);
+
+// globals
+export const map = initMap();
+export const urlState = initUrlState();
+
+
+function initUrlState() {
+    const params = new URLSearchParams(window.location.search);
+
+    if (params.has('show')) {
+        const show = params.get('show');
+        const vals = show.split(',');
+        if (vals.includes('japan')) {
+            loadJapan();
+        }
+    }
+
+    return params;
+}
+
+function initMap() {
+    const map = new MaplibreGL.Map({
+        container: 'map',
+        style: 'https://tiles.openfreemap.org/styles/positron',
+    });
+
+    // overwrite basemap style
+    const style = D3.json('https://tiles.openfreemap.org/styles/positron')
+    style.then((data) => {
+        data.layers = data.layers.map((l) => {
+            if (l.id === "boundary_2") {
+                l.paint["line-color"] = "red";
+            }
+            if (l.id === "boundary_3") {
+                l.minzoom = 4;
+                l.paint["line-color"] = "orange";
+            }
+            if (l.id === "label_state") {
+                l.minzoom = 4;
+                l.paint["text-color"] = "orange";
+            }
+            if (l.id === "label_other") {
+                l.minzoom = 4;
+                l.paint["text-color"] = "orange";
+            }
+            return l;
+        });
+        map.setStyle(data);
+    });
+
+    map.addControl(new MaplibreGL.FullscreenControl());
+
+    map.addControl(new MaplibreGL.NavigationControl({
+        showZoom: true,
+        showCompass: true,
+        visualizeRoll: true,
+        visualizePitch: true,
+    }));
+
+    map.addControl(new MaplibreGL.GlobeControl());
+
+    map.addControl(new MaplibreGL.GeolocateControl({
+        positionOptions: {
+            enableHighAccuracy: true
+        },
+    }));
+
+    map.addControl(new CustomControl({
+        label: 'Japan',
+        icon: '🇯🇵',
+        onclick: () => {
+            loadJapan();
+        },
+    }));
+    map.addControl(new CustomControl({
+        label: 'Raw',
+        icon: 'R',
+        onclick: () => {
+            loadRaw();
+        },
+    }));
+    map.addControl(new CustomControl({
+        label: 'Visited',
+        icon: 'V',
+        onclick: () => {
+            loadVisited();
+        },
+    }));
+
+    // https://github.com/osm-americana/diplomat/
+    map.once("styledata", (event) => {
+      Diplomat.localizeStyle(map);
+    });
+
+    return map;
+}
+
+
+function loadRaw() {
+    const raw = D3.json('export-data/data.geojson');
+    raw.then((data) => {
+        if (map.getSource('raw') === undefined) {
+            map.addSource('raw', {
+                type: 'geojson',
+                data: data,
+            });
+            map.addLayer({
+                id: 'raw',
+                source: 'raw',
+                type: 'heatmap',
+                paint: {
+                    // Increase the heatmap weight based on frequency and property magnitude
+                    'heatmap-weight': [
+                        'interpolate',
+                        ['linear'],
+                        ['get', 'mag'],
+                        0,
+                        0,
+                        6,
+                        1
+                    ],
+                    // Increase the heatmap color weight weight by zoom level
+                    // heatmap-intensity is a multiplier on top of heatmap-weight
+                    'heatmap-intensity': [
+                        'interpolate',
+                        ['linear'],
+                        ['zoom'],
+                        0,
+                        1,
+                        9,
+                        3
+                    ],
+                    // Color ramp for heatmap.  Domain is 0 (low) to 1 (high).
+                    // Begin color ramp at 0-stop with a 0-transparency color
+                    // to create a blur-like effect.
+                    'heatmap-color': [
+                        'interpolate',
+                        ['linear'],
+                        ['heatmap-density'],
+                        0,
+                        'rgba(33,102,172,0)',
+                        0.2,
+                        'rgb(103,169,207)',
+                        0.4,
+                        'rgb(209,229,240)',
+                        0.6,
+                        'rgb(253,219,199)',
+                        0.8,
+                        'rgb(239,138,98)',
+                        1,
+                        'rgb(178,24,43)'
+                    ],
+                    // Adjust the heatmap radius by zoom level
+                    'heatmap-radius': [
+                        'interpolate',
+                        ['linear'],
+                        ['zoom'],
+                        0,
+                        2,
+                        9,
+                        20
+                    ],
+                },
+            });
+        }
+        map.fitBounds(turf.bbox(data), {
+            padding: 50,
+        });
+    })
+}
+
+function loadVisited() {
+    // add data
+    const vis = D3.json('export-data/visited.geojson');
+    vis.then((data) => {
+        if (map.getSource('visited') === undefined) {
+            map.addSource('visited', {
+                type: 'geojson',
+                data: data,
+            });
+            // boundaries
+            map.addLayer({
+                id: 'visited-bounds',
+                type: 'fill',
+                source: 'visited',
+                layout: {},
+                paint: {
+                    'fill-color': 'hotpink',
+                    'fill-opacity': 0.6,
+                },
+                filter: ['==', '$type', 'Polygon'],
+            });
+            // labels
+            map.addLayer({
+                id: 'visited-labels',
+                type: 'symbol',
+                source: 'visited',
+                layout: {
+                    'text-field': '{iso3166}\n{timestamp}',
+                    'text-font': ['Noto Sans Regular'],
+                    'text-size': 12,
+                    'text-variable-anchor': ['top', 'bottom', 'left', 'right'],
+                    'text-radial-offset': 0.3,
+                    'text-justify': 'auto',
+                },
+                paint: {
+                    'text-color': 'black',
+                    'text-opacity': 1.0,
+                },
+                filter: ['==', '$type', 'Polygon'],
+            });
+        }
+        map.fitBounds(turf.bbox(data), {
+            padding: 50,
+        });
+    });
+}
+
+function loadJapan() {
+    function normalizeLocation() {
+        let addr = window.location.toString();
+        let idx = addr.lastIndexOf("/");
+        return addr.slice(0, idx);
+    }
+
+    const jpn_url = 'export-data/japan-admin_level=4.pmtiles'
+    const jpn = new PMTiles.PMTiles(jpn_url);
+    pmproto.add(jpn);
+    jpn.getHeader().then(hdr => {
+        map.fitBounds([[hdr.minLon, hdr.minLat],[hdr.maxLon, hdr.maxLat]], {
+            padding: 1,
+        });
+    //     map.addSource('jpn', {
+    //         type: 'vector',
+    //         url: `pmtiles://${normalizeLocation()}/${jpn_url}`,
+    //         attribution: '© <a href="https://osm-boundaries.com/about">OSM-Boundaries</a>'
+    //     });
+    //     map.addLayer({
+    //         id: 'jpn-bounds',
+    //         source: 'jpn',
+    //         'source-layer': 'japan-bounds',
+    //         type: 'line',
+    //         layout: {},
+    //         paint: {
+    //             'line-color': 'orange',
+    //             'line-width': 2,
+    //             'line-opacity': 0.5,
+    //         },
+    //     });
+    //     map.addLayer({
+    //         id: 'jpn-labels',
+    //         source: 'jpn',
+    //         'source-layer': 'japan-labels',
+    //         type: 'symbol',
+    //         layout: {
+    //             'text-field': '{name_en}',
+    //             'text-font': ['Noto Sans Regular'],
+    //             'text-size': 12,
+    //             'text-variable-anchor': ['top', 'bottom', 'left', 'right'],
+    //             'text-radial-offset': 0.2,
+    //             'text-justify': 'auto',
+    //         },
+    //         paint: {
+    //             'text-color': 'black',
+    //             'text-opacity': 1.0,
+    //         },
+    //     });
+    });
+}
diff --git a/map/style.css b/map/style.css
new file mode 100644
index 0000000..b9639bf
--- /dev/null
+++ b/map/style.css
@@ -0,0 +1,28 @@
+@import url("https://esm.sh/maplibre-gl/dist/maplibre-gl.css");
+
+body {
+  margin: 0;
+}
+
+#map {
+  width: 100vw;
+  height: 100vh;
+}
+
+header {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 9999;
+  padding: 15px 10px;
+
+  & nav a {
+    background: black;
+    color: white;
+
+    & [alt="gzr.im"] {
+      max-height: 55px;
+      vertical-align: bottom;
+    }
+  }
+}