diff --git a/package-lock.json b/package-lock.json
index 8b6d9c2..da82586 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,9 +13,13 @@
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@tailwindcss/vite": "^4.1.17",
+ "@types/leaflet": "^1.9.21",
+ "@types/leaflet.markercluster": "^1.5.6",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"astro": "^5.15.8",
+ "leaflet": "^1.9.4",
+ "leaflet.markercluster": "^1.5.3",
"marked": "^17.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -1833,6 +1837,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1855,6 +1860,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1877,6 +1883,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1893,6 +1900,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1909,6 +1917,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1925,6 +1934,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1941,6 +1951,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1957,6 +1968,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1973,6 +1985,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1989,6 +2002,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2005,6 +2019,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2021,6 +2036,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2037,6 +2053,7 @@
"cpu": [
"arm"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2059,6 +2076,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2081,6 +2099,7 @@
"cpu": [
"ppc64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2103,6 +2122,7 @@
"cpu": [
"riscv64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2125,6 +2145,7 @@
"cpu": [
"s390x"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2147,6 +2168,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2169,6 +2191,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2191,6 +2214,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2213,6 +2237,7 @@
"cpu": [
"wasm32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -2232,6 +2257,7 @@
"cpu": [
"arm64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2251,6 +2277,7 @@
"cpu": [
"ia32"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2270,6 +2297,7 @@
"cpu": [
"x64"
],
+ "dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3478,6 +3506,12 @@
"@types/estree": "*"
}
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -3494,6 +3528,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/leaflet": {
+ "version": "1.9.21",
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
+ "node_modules/@types/leaflet.markercluster": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz",
+ "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/leaflet": "^1.9"
+ }
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -8107,6 +8159,21 @@
"node": ">=0.10"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/leaflet.markercluster": {
+ "version": "1.5.3",
+ "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
+ "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "leaflet": "^1.3.1"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
diff --git a/package.json b/package.json
index 8a060a9..918b936 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,13 @@
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@tailwindcss/vite": "^4.1.17",
+ "@types/leaflet": "^1.9.21",
+ "@types/leaflet.markercluster": "^1.5.6",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"astro": "^5.15.8",
+ "leaflet": "^1.9.4",
+ "leaflet.markercluster": "^1.5.3",
"marked": "^17.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 57844d5..2f311c4 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -25,9 +25,9 @@ const homeUrl = lang === "ja" ? "/ja/" : "/";
diff --git a/src/components/Map.jsx b/src/components/Map.jsx
new file mode 100644
index 0000000..f581999
--- /dev/null
+++ b/src/components/Map.jsx
@@ -0,0 +1,213 @@
+/**
+ * Map – Generic Leaflet map component.
+ *
+ * Props:
+ * geojson {object} GeoJSON FeatureCollection to render (already filtered by caller).
+ * features {string[]} Feature flags:
+ * "clusters" – cluster nearby markers with leaflet.markercluster
+ * "fitBounds" – auto-zoom to fit all visible markers
+ * "legend" – show a colour-coded legend overlay
+ * labels {object} i18n strings. Supported key: `legendTitle`.
+ * getMarkerColour {function} (feature) => CSS colour string. Defaults to grey.
+ * renderPopup {function} (feature) => HTML string for the Leaflet popup.
+ * Defaults to a minimal name/coordinates popup.
+ * legendEntries {Array} [{ label: string, colour: string }] shown in the legend.
+ * center {[lat,lng]} Initial map centre. Defaults to central Kyoto.
+ * zoom {number} Initial zoom level. Defaults to 13.
+ * height {string} CSS height for the map container. Defaults to "480px".
+ */
+
+import { useEffect, useRef } from "react";
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+/** Create a circular SVG DivIcon for a given colour. */
+function makeIcon(L, colour) {
+ const svg = `
`;
+ return L.divIcon({
+ html: svg,
+ className: "",
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ popupAnchor: [0, -14],
+ });
+}
+
+function defaultRenderPopup(feature) {
+ const props = feature.properties ?? {};
+ const [lng, lat] = feature.geometry?.coordinates ?? [0, 0];
+ return `
+ ${props.name ?? `${lat.toFixed(4)}, ${lng.toFixed(4)}`}
+
`;
+}
+
+// ── Component ────────────────────────────────────────────────────────────────
+
+export default function Map({
+ geojson,
+ features = ["fitBounds"],
+ labels = {},
+ getMarkerColour = () => "#6B7280",
+ renderPopup = defaultRenderPopup,
+ legendEntries = [],
+ center = [35.005, 135.765],
+ zoom = 13,
+ height = "480px",
+}) {
+ const containerRef = useRef(null);
+ const mapRef = useRef(null);
+ const layerRef = useRef(null);
+
+ // Keep latest prop callbacks in refs so async renderMarkers always uses
+ // the current version without those functions being effect dependencies.
+ const geojsonRef = useRef(geojson);
+ const getMarkerColourRef = useRef(getMarkerColour);
+ const renderPopupRef = useRef(renderPopup);
+ geojsonRef.current = geojson;
+ getMarkerColourRef.current = getMarkerColour;
+ renderPopupRef.current = renderPopup;
+
+ const enableClusters = features.includes("clusters");
+ const enableFitBounds = features.includes("fitBounds");
+ const enableLegend = features.includes("legend");
+
+ // ── Initialise the map once on mount ──────────────────────────────────────
+ useEffect(() => {
+ if (!containerRef.current || mapRef.current) return;
+
+ let L;
+ let destroyed = false;
+
+ (async () => {
+ L = (await import("leaflet")).default;
+ await import("leaflet/dist/leaflet.css");
+
+ if (destroyed) return;
+
+ // Fix default icon paths broken by bundlers
+ delete L.Icon.Default.prototype._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
+ iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
+ shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
+ });
+
+ const map = L.map(containerRef.current, {
+ center,
+ zoom,
+ scrollWheelZoom: false,
+ });
+
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ attribution:
+ '©
OpenStreetMap contributors',
+ maxZoom: 19,
+ }).addTo(map);
+
+ mapRef.current = map;
+
+ // Render the initial set of markers
+ renderMarkers(L, map);
+ })();
+
+ return () => {
+ destroyed = true;
+ if (mapRef.current) {
+ mapRef.current.remove();
+ mapRef.current = null;
+ layerRef.current = null;
+ }
+ };
+ // center/zoom are intentionally read once at mount time
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // ── Re-render markers whenever the caller passes new geojson ──────────────
+ // (e.g. after a filter is applied in the parent component)
+ useEffect(() => {
+ if (!mapRef.current) return;
+ import("leaflet").then((mod) => renderMarkers(mod.default, mapRef.current));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [geojson]);
+
+ // ── Core marker rendering logic ───────────────────────────────────────────
+ async function renderMarkers(L, map) {
+ if (!map) return;
+
+ // Remove the previous layer
+ if (layerRef.current) {
+ map.removeLayer(layerRef.current);
+ layerRef.current = null;
+ }
+
+ const featureList = geojsonRef.current?.features ?? [];
+
+ let layer;
+ if (enableClusters) {
+ const { default: MC } = await import("leaflet.markercluster");
+ await import("leaflet.markercluster/dist/MarkerCluster.css");
+ await import("leaflet.markercluster/dist/MarkerCluster.Default.css");
+ void MC; // imported for side-effects; augments L.markerClusterGroup
+ layer = L.markerClusterGroup({ showCoverageOnHover: false });
+ } else {
+ layer = L.layerGroup();
+ }
+
+ const bounds = [];
+
+ for (const feature of featureList) {
+ const [lng, lat] = feature.geometry.coordinates;
+ const colour = getMarkerColourRef.current(feature);
+ const icon = makeIcon(L, colour);
+ const marker = L.marker([lat, lng], { icon });
+
+ marker.bindPopup(renderPopupRef.current(feature), { maxWidth: 280 });
+ layer.addLayer(marker);
+ bounds.push([lat, lng]);
+ }
+
+ layer.addTo(map);
+ layerRef.current = layer;
+
+ if (enableFitBounds && bounds.length > 0) {
+ if (bounds.length === 1) {
+ map.setView(bounds[0], 14);
+ } else {
+ map.fitBounds(bounds, { padding: [40, 40] });
+ }
+ }
+ }
+
+ // ── Render ────────────────────────────────────────────────────────────────
+ return (
+
+
+
+ {enableLegend && legendEntries.length > 0 && (
+
+
+ {labels.legendTitle ?? "Legend"}
+
+
+ {legendEntries.map(({ label, colour }) => (
+ -
+
+ {label}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+
+
+
+
diff --git a/src/components/MemberMap.jsx b/src/components/MemberMap.jsx
new file mode 100644
index 0000000..d9a18af
--- /dev/null
+++ b/src/components/MemberMap.jsx
@@ -0,0 +1,200 @@
+/**
+ * MemberMap - Specialised map for community members.
+ *
+ * Handles role-based filtering and passes the filtered GeoJSON to the
+ * generic