aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components/ParkMap.astro
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ParkMap.astro')
-rw-r--r--src/components/ParkMap.astro656
1 files changed, 656 insertions, 0 deletions
diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro
new file mode 100644
index 0000000..8537af2
--- /dev/null
+++ b/src/components/ParkMap.astro
@@ -0,0 +1,656 @@
+---
+// 小平市の公園ポリゴン(OpenStreetMap)を地図上に色塗り表示する。
+// 一覧と一致した公園は赤、一致しない公園は薄い赤で表示する。
+---
+
+<div class="park-map-wrap">
+ <div
+ id="park-map"
+ class="park-map"
+ role="region"
+ aria-label="小平市の公園の位置(OpenStreetMap)"
+ ></div>
+ <div class="park-map-legend" aria-hidden="true">
+ <span><i class="swatch swatch-matched"></i>一覧と一致</span>
+ <span><i class="swatch swatch-unmatched"></i>一覧にない公園・緑地</span>
+ <span><i class="swatch swatch-boundary"></i>小平市の境界</span>
+ <span><i class="swatch-fac swatch-fac-toilet">WC</i>トイレ</span>
+ <span><i class="swatch-fac swatch-fac-water"><svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M12 2.7c-3.4 4.2-6 8.1-6 11.3a6 6 0 0 0 12 0c0-3.2-2.6-7.1-6-11.3z" /></svg></i>水飲み場(拡大時)</span>
+ </div>
+ <p class="park-map-note">
+ 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a
+ href="https://www.openstreetmap.org/copyright"
+ target="_blank"
+ rel="noopener">OpenStreetMap</a
+ > contributors
+ </p>
+ <p class="park-map-locate-status" data-locate-status hidden></p>
+</div>
+
+<style>
+ .park-map-wrap {
+ position: relative;
+ z-index: 11;
+ margin: 1rem 0 1.5rem;
+ }
+ .park-map {
+ height: 480px;
+ width: 100%;
+ border: 1px solid var(--sl-color-gray-5);
+ border-radius: 8px;
+ }
+ .park-map-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1rem;
+ align-items: center;
+ margin-top: 0.5rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.8rem;
+ }
+ .park-map-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ }
+ .swatch {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ }
+ .swatch-matched {
+ background: #e53935;
+ }
+ .swatch-unmatched {
+ background: #ffcdd2;
+ }
+ .swatch-boundary {
+ height: 0;
+ border-top: 4px solid #6a1b9a;
+ border-radius: 0;
+ background: transparent;
+ }
+ .park-map-locate-status {
+ margin-top: 0.3rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.8rem;
+ }
+ :global(.park-locate) {
+ width: 34px;
+ height: 34px;
+ border: 0;
+ background: #fff;
+ color: #1e88e5;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+ }
+ :global(.park-locate:hover) {
+ background: #f0f7ff;
+ }
+ .park-map-note {
+ margin-top: 0.4rem;
+ color: var(--sl-color-gray-2);
+ font-size: 0.8rem;
+ }
+ :global(.park-map-selected) {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.1rem 0.55rem;
+ max-width: min(92vw, 340px);
+ padding: 0.28rem 0.55rem;
+ background: rgba(255, 255, 255, 0.95);
+ border: 1px solid var(--sl-color-gray-5);
+ border-radius: 6px;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
+ color: #1e293b;
+ font-size: 0.8rem;
+ line-height: 1.45;
+ }
+ :global(.park-map-selected[hidden]) {
+ display: none;
+ }
+ :global(.park-map-selected-name) {
+ font-weight: 700;
+ }
+ :global(.park-map-selected-owner) {
+ padding: 0.02rem 0.3rem;
+ border: 1px solid currentColor;
+ border-radius: 999px;
+ font-size: 0.68rem;
+ font-weight: 600;
+ opacity: 0.85;
+ }
+ :global(.park-map-selected-owner[hidden]) {
+ display: none;
+ }
+ :global(.park-map-selected-note) {
+ color: #666;
+ font-size: 0.72rem;
+ }
+ :global(.park-map-selected a) {
+ color: #0b57d0;
+ white-space: nowrap;
+ }
+ .swatch-fac {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ margin-right: 0.3rem;
+ color: #fff;
+ font-style: normal;
+ font-size: 7px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ .swatch-fac-toilet {
+ background: #455a64;
+ border-radius: 4px;
+ }
+ .swatch-fac-water {
+ background: #0277bd;
+ border-radius: 50%;
+ }
+ :global(.park-fac-list) {
+ display: inline-flex;
+ gap: 2px;
+ transform: translate(-50%, -50%);
+ }
+ :global(.park-fac) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ color: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ :global(.park-fac-toilet) {
+ background: #455a64;
+ border: 1px solid #263238;
+ border-radius: 5px;
+ }
+ :global(.park-fac-water) {
+ background: #0277bd;
+ border: 1px solid #01579b;
+ border-radius: 50%;
+ }
+ :global(.park-fac svg) {
+ display: block;
+ }
+ /* StarlightのMarkdown余白ルールがLeafletの各ペインに margin-top:1rem を
+ 付けて地図を下へずらすため、地図内のペインは余白なしにする。 */
+ .park-map :global(.leaflet-pane) {
+ margin: 0 !important;
+ }
+ /* Starlightの canvas/svg 向け max-width が Leaflet の描画領域を
+ 0幅にしてしまうため、地図内だけ上書きする。 */
+ .park-map :global(.leaflet-overlay-pane canvas),
+ .park-map :global(.leaflet-overlay-pane svg) {
+ max-width: none !important;
+ max-height: none !important;
+ margin: 0 !important;
+ }
+ /* Starlightの画像用 margin がタイルに付くと行が16pxずれるため、0にする。 */
+ .park-map :global(.leaflet-tile) {
+ margin: 0 !important;
+ }
+ @media (max-width: 640px) {
+ .park-map {
+ height: 360px;
+ }
+ }
+</style>
+
+<script>
+ import L from "leaflet";
+ import "leaflet/dist/leaflet.css";
+ import geojson from "@/data/parks-geojson";
+ import boundary from "@/data/kodaira-boundary.json";
+ import parks from "@/data/kodaira-parks.json";
+
+ const MATCHED_STYLE = {
+ color: "#b71c1c",
+ weight: 2,
+ fillColor: "#e53935",
+ fillOpacity: 0.45,
+ };
+ const UNMATCHED_STYLE = {
+ color: "#ef5350",
+ weight: 1.2,
+ fillColor: "#ffcdd2",
+ fillOpacity: 0.3,
+ };
+ const DIM_STYLE = {
+ color: "#ef9a9a",
+ weight: 0.8,
+ fillColor: "#ffebee",
+ fillOpacity: 0.05,
+ opacity: 0.3,
+ };
+ const SELECTED_STYLE = {
+ color: "#7f0000",
+ weight: 3,
+ fillColor: "#d32f2f",
+ fillOpacity: 0.6,
+ };
+
+ const el = document.getElementById("park-map");
+ if (el && !el.dataset.initialized) {
+ el.dataset.initialized = "1";
+
+ const map = L.map(el, {
+ scrollWheelZoom: false,
+ zoomSnap: 1,
+ zoomDelta: 1,
+ zoomAnimation: false,
+ fadeAnimation: false,
+ markerZoomAnimation: false,
+ }).setView([35.728, 139.477], 13);
+
+ // 帰属表示の「Leaflet」旗アイコンを消す(OSMの表示は残す)。
+ map.attributionControl.setPrefix(false);
+
+ const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
+ maxZoom: 19,
+ attribution:
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
+ }).addTo(map);
+
+ const layersByName = new Map<string, any>();
+ const allLayers: any[] = [];
+ const parkDataByName = new Map<string, any>();
+ (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p));
+ const ownerByName = new Map<string, string>();
+ parkDataByName.forEach((p, name) => {
+ const owner = (p["設置者"] || "").trim();
+ if (owner && owner !== "小平市") ownerByName.set(name, owner);
+ });
+ const ownerLabel = (owner: string) =>
+ owner.includes("都営")
+ ? "都営"
+ : owner === "東京都"
+ ? "都立"
+ : owner.includes("UR")
+ ? "UR"
+ : owner.includes("民間")
+ ? "民間"
+ : owner;
+
+ const filterName = (name: string, scroll = false) => {
+ document.dispatchEvent(
+ new CustomEvent("park:filter-name", { detail: { name, scroll } }),
+ );
+ };
+
+ // 選択中の公園を地図左下に控えめに表示する(ポップアップは使わない)。
+ let selectedBar: HTMLElement | null = null;
+ let selectedNameEl: HTMLElement | null = null;
+ let selectedMapsLink: HTMLAnchorElement | null = null;
+ let selectedTableLink: HTMLAnchorElement | null = null;
+ let selectedNoteEl: HTMLElement | null = null;
+ let selectedOwnerEl: HTMLElement | null = null;
+
+ const showSelected = (
+ ourName: string,
+ osmName: string,
+ listed = true,
+ owner = "",
+ ) => {
+ if (!selectedBar || !selectedNameEl || !selectedMapsLink || !selectedTableLink)
+ return;
+ selectedNameEl.textContent = ourName;
+ selectedNameEl.title =
+ osmName && osmName !== ourName ? `OSM: ${osmName}` : "";
+ // 名前が「緑地(OSM …)」のような場合は名前検索が使えないため、
+ // 座標があれば緯度経度で開く。
+ const rec = parkDataByName.get(ourName);
+ const lat = rec?.["緯度"];
+ const lng = rec?.["経度"];
+ selectedMapsLink.href =
+ lat && lng
+ ? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`
+ : "https://www.google.com/maps/search/?api=1&query=" +
+ encodeURIComponent(`小平市${ourName}`);
+ selectedTableLink.dataset.parkRow = encodeURIComponent(ourName);
+ selectedTableLink.hidden = !listed;
+ if (selectedNoteEl) selectedNoteEl.hidden = listed;
+ if (selectedOwnerEl) {
+ selectedOwnerEl.textContent = owner ? ownerLabel(owner) : "";
+ selectedOwnerEl.hidden = !owner;
+ }
+ selectedBar.hidden = false;
+ };
+
+ const SelectedControl = L.Control.extend({
+ onAdd: () => {
+ const box = L.DomUtil.create("div", "park-map-selected");
+ box.hidden = true;
+ box.innerHTML =
+ '<span class="park-map-selected-name"></span>' +
+ '<span class="park-map-selected-owner" hidden></span>' +
+ '<span class="park-map-selected-note" hidden>一覧にない緑地・公園</span>' +
+ '<a data-park-maps-link href="#" target="_blank" rel="noopener">Googleマップ</a>' +
+ '<a data-park-table-link href="#">一覧で見る</a>';
+ selectedBar = box;
+ selectedNameEl = box.querySelector<HTMLElement>(".park-map-selected-name");
+ selectedMapsLink = box.querySelector<HTMLAnchorElement>("[data-park-maps-link]");
+ selectedTableLink = box.querySelector<HTMLAnchorElement>("[data-park-table-link]");
+ selectedNoteEl = box.querySelector<HTMLElement>(".park-map-selected-note");
+ selectedOwnerEl = box.querySelector<HTMLElement>(".park-map-selected-owner");
+ selectedTableLink?.addEventListener("click", (ev: Event) => {
+ ev.preventDefault();
+ const name = decodeURIComponent(
+ (selectedTableLink as HTMLElement).dataset.parkRow || "",
+ );
+ if (name) filterName(name, true);
+ });
+ L.DomEvent.disableClickPropagation(box);
+ return box;
+ },
+ });
+ new SelectedControl({ position: "bottomleft" }).addTo(map);
+
+ const layer = L.geoJSON(geojson as any, {
+ style: (feature: any) =>
+ feature?.properties?.matched ? MATCHED_STYLE : UNMATCHED_STYLE,
+ onEachFeature: (feature: any, lyr: any) => {
+ const p = feature.properties || {};
+ const name = p.name || "名称未設定";
+ const ourName = p.our_name || "";
+ lyr._parkName = ourName;
+ lyr._osmName = name;
+ allLayers.push(lyr);
+ if (ourName) layersByName.set(ourName, lyr);
+
+ if (ourName) {
+ lyr.on("add", () => {
+ const path = lyr.getElement?.();
+ if (path) path.setAttribute("data-park", ourName);
+ });
+ lyr.on("click", () => {
+ filterName(ourName, false);
+ showSelected(ourName, name, true, ownerByName.get(ourName) || "");
+ });
+ } else if (p.name) {
+ // 一覧にないOSM上の公園・緑地。名前があればバーに表示する。
+ lyr.on("click", () => showSelected(name, name, false));
+ } else {
+ // 名前のない緑地はクリックに反応させない。
+ lyr.on("add", () => {
+ const path = lyr.getElement?.();
+ if (path) path.classList.remove("leaflet-interactive");
+ });
+ }
+ },
+ }).addTo(map);
+
+ // 市外を薄く覆い、小平市内の部分だけを見せるマスク。
+ // 境界線より先に追加して、境界線がマスクの上に描かれるようにする。
+ const boundaryGeom = (boundary as any).features[0].geometry;
+ const boundaryRings =
+ boundaryGeom.type === "Polygon"
+ ? boundaryGeom.coordinates
+ : boundaryGeom.coordinates.map((p: any) => p[0]);
+ const WORLD_RING = [
+ [-180, -85],
+ [180, -85],
+ [180, 85],
+ [-180, 85],
+ [-180, -85],
+ ];
+ L.geoJSON(
+ {
+ type: "Feature",
+ properties: {},
+ geometry: {
+ type: "Polygon",
+ coordinates: [WORLD_RING, ...boundaryRings],
+ },
+ } as any,
+ {
+ style: { fillColor: "#ffffff", fillOpacity: 0.78, stroke: false },
+ interactive: false,
+ },
+ ).addTo(map);
+
+ // 小平市の境界線(塗りなし・太い実線)
+ const boundaryLayer = L.geoJSON(boundary as any, {
+ style: {
+ color: "#6a1b9a",
+ weight: 4,
+ opacity: 0.9,
+ fill: false,
+ },
+ interactive: false,
+ }).addTo(map);
+
+ // 拡大時だけ表示する設備アイコン(トイレ・水飲み場)
+ // 一覧と一致した公園のポリゴン内にだけ表示する。
+ const facilityLayer = L.layerGroup();
+ layersByName.forEach((lyr, name) => {
+ const p = parkDataByName.get(name);
+ if (!p) return;
+ const icons: string[] = [];
+ if ((p["トイレ"] || "").trim()) {
+ icons.push(
+ '<span class="park-fac park-fac-toilet" title="トイレあり" aria-label="トイレあり">WC</span>',
+ );
+ }
+ if ((p["水飲み場"] || "").trim()) {
+ icons.push(
+ '<span class="park-fac park-fac-water" title="水飲み場あり" aria-label="水飲み場あり"><svg viewBox="0 0 24 24" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="M12 2.7c-3.4 4.2-6 8.1-6 11.3a6 6 0 0 0 12 0c0-3.2-2.6-7.1-6-11.3z"/></svg></span>',
+ );
+ }
+ if (!icons.length) return;
+ const center = lyr.getBounds().getCenter();
+ L.marker([center.lat, center.lng], {
+ icon: L.divIcon({
+ className: "park-fac-markers",
+ html: `<span class="park-fac-list">${icons.join("")}</span>`,
+ iconSize: null,
+ iconAnchor: [0, 0],
+ }),
+ riseOnHover: false,
+ }).addTo(facilityLayer);
+ });
+
+ const updateFacilities = () => {
+ if (map.getZoom() >= 16) {
+ if (!map.hasLayer(facilityLayer)) facilityLayer.addTo(map);
+ } else if (map.hasLayer(facilityLayer)) {
+ map.removeLayer(facilityLayer);
+ }
+ };
+ map.on("zoomend", updateFacilities);
+ updateFacilities();
+
+ let userInteracted = false;
+ map.on("zoomstart dragstart", () => {
+ userInteracted = true;
+ });
+
+ const fitAll = () => {
+ if (userInteracted) return;
+ // 初期表示は小平市の市境だけに合わせる(市外にまたがる都立公園などで広がらないように)。
+ const bounds = boundaryLayer.getBounds();
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [20, 20], animate: false });
+ return;
+ }
+ const parkBounds = layer.getBounds();
+ if (parkBounds.isValid()) {
+ map.fitBounds(parkBounds, { padding: [20, 20], animate: false });
+ }
+ };
+ fitAll();
+
+ const focusPark = (name: string) => {
+ const target = layersByName.get(name);
+ if (!target) return false;
+ map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: 17 });
+ showSelected(
+ name,
+ target._osmName || name,
+ true,
+ ownerByName.get(name) || "",
+ );
+ return true;
+ };
+
+ document.addEventListener("park:focus", (e: Event) => {
+ const name = (e as CustomEvent).detail?.name;
+ if (!name || !layersByName.has(name)) return;
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
+ focusPark(name);
+ });
+
+ document.addEventListener("park:filter", (e: Event) => {
+ const detail = (e as CustomEvent).detail || {};
+ const names = new Set<string>(detail.names || []);
+ const active = Boolean(detail.active);
+ const fromMap = Boolean(detail.fromMap);
+ const visible: any[] = [];
+
+ if (!fromMap && selectedBar) selectedBar.hidden = true;
+
+ // 地図のポリゴンクリックで表を1件に絞った場合は、
+ // ほかの公園のポリゴンを残したまま、選択した公園だけ強調する。
+ if (fromMap) {
+ allLayers.forEach((lyr) => {
+ const name = lyr._parkName || "";
+ if (name && names.has(name)) {
+ lyr.setStyle(SELECTED_STYLE);
+ } else {
+ lyr.setStyle(name ? MATCHED_STYLE : UNMATCHED_STYLE);
+ }
+ });
+ return;
+ }
+
+ allLayers.forEach((lyr) => {
+ const name = lyr._parkName || "";
+ if (!active) {
+ lyr.setStyle(name ? MATCHED_STYLE : UNMATCHED_STYLE);
+ return;
+ }
+ if (name && names.has(name)) {
+ lyr.setStyle(MATCHED_STYLE);
+ visible.push(lyr);
+ } else {
+ lyr.setStyle(DIM_STYLE);
+ }
+ });
+
+ if (active && visible.length && visible.length < 30) {
+ const bounds = L.featureGroup(visible).getBounds();
+ if (bounds.isValid()) {
+ map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 });
+ }
+ }
+ });
+
+ // 現在地表示(スマホでは自動で取得を試みる)
+ const statusEl = document.querySelector<HTMLElement>("[data-locate-status]");
+ const setStatus = (msg: string) => {
+ if (!statusEl) return;
+ statusEl.textContent = msg;
+ statusEl.hidden = !msg;
+ };
+
+ let locationMarker: any = null;
+ let accuracyCircle: any = null;
+ let watchId: number | null = null;
+
+ const locate = (center = false) => {
+ if (!navigator.geolocation) {
+ setStatus("このブラウザでは現在地を取得できません。");
+ return;
+ }
+ setStatus("現在地を取得しています…");
+ const onSuccess = (pos: GeolocationPosition) => {
+ const { latitude, longitude, accuracy } = pos.coords;
+ if (!locationMarker) {
+ locationMarker = L.circleMarker([latitude, longitude], {
+ radius: 7,
+ color: "#ffffff",
+ weight: 2,
+ fillColor: "#1e88e5",
+ fillOpacity: 1,
+ }).bindPopup("現在地", { autoPan: false });
+ locationMarker.on("add", () => {
+ locationMarker.getElement()?.setAttribute("data-locate", "true");
+ });
+ locationMarker.addTo(map);
+ } else {
+ locationMarker.setLatLng([latitude, longitude]);
+ }
+ if (!accuracyCircle) {
+ accuracyCircle = L.circle([latitude, longitude], {
+ radius: Math.min(accuracy, 1000),
+ color: "#1e88e5",
+ weight: 1,
+ fillColor: "#90caf9",
+ fillOpacity: 0.15,
+ interactive: false,
+ }).addTo(map);
+ } else {
+ accuracyCircle
+ .setLatLng([latitude, longitude])
+ .setRadius(Math.min(accuracy, 1000));
+ }
+ setStatus(`現在地を表示しています(精度 約${Math.round(accuracy)}m)。`);
+ if (center) map.setView([latitude, longitude], Math.max(map.getZoom(), 15));
+ };
+ const onError = () => {
+ setStatus("現在地を取得できませんでした。位置情報の利用を許可してください。");
+ };
+ if (watchId !== null) navigator.geolocation.clearWatch(watchId);
+ watchId = navigator.geolocation.watchPosition(onSuccess, onError, {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 30000,
+ });
+ };
+
+ const LocateControl = L.Control.extend({
+ onAdd: () => {
+ const btn = L.DomUtil.create("button", "leaflet-bar park-locate");
+ btn.setAttribute("type", "button");
+ btn.title = "現在地を表示";
+ btn.setAttribute("aria-label", "現在地を表示");
+ btn.innerHTML = '<span aria-hidden="true">◎</span>';
+ L.DomEvent.disableClickPropagation(btn);
+ btn.addEventListener("click", () => locate(true));
+ return btn;
+ },
+ });
+ new LocateControl({ position: "topright" }).addTo(map);
+
+ if (window.matchMedia("(max-width: 640px)").matches) {
+ locate(true);
+ }
+ window.addEventListener("pagehide", () => {
+ if (watchId !== null) navigator.geolocation.clearWatch(watchId);
+ });
+
+ // レイアウト確定後にサイズを再計算し、タイルを再配置する。
+ const refresh = () => {
+ map.invalidateSize();
+ tileLayer.redraw();
+ fitAll();
+ };
+ requestAnimationFrame(refresh);
+ window.setTimeout(refresh, 300);
+ window.setTimeout(refresh, 1000);
+ window.addEventListener("load", refresh);
+ window.addEventListener("resize", () => map.invalidateSize());
+ }
+</script>