diff options
Diffstat (limited to 'src/components/ParkMap.astro')
| -rw-r--r-- | src/components/ParkMap.astro | 656 |
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: + '© <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> |
