From a71727538f65ab4f10f5239550be82590f9d47d1 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:29:24 +0900 Subject: parks: 公園一覧と地図、公共施設の情報を更新 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 近隣公園のクリックを市内公園と同じ挙動にし、Googleマップを公園名で開く - 公共施設のトイレを○にし、ルネこだいらを追加 - 未取得だった施設の座標を追加し、再ジオコーディング時の上書きを防止 - 手入力ポリゴン、近隣公園、PWA、ライセンス表示を追加 --- src/components/ParkMap.astro | 261 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 231 insertions(+), 30 deletions(-) (limited to 'src/components/ParkMap.astro') diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 8537af2..1c75bee 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -13,12 +13,14 @@

- 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © OpenStreetMapOpenStreetMap contributors', }).addTo(map); + const FullscreenControl = L.Control.extend({ + onAdd: () => { + const container = L.DomUtil.create("div", "leaflet-bar park-map-fullscreen"); + const button = L.DomUtil.create("button", "", container); + button.type = "button"; + button.title = "全画面表示"; + button.setAttribute("aria-label", "全画面表示"); + button.innerHTML = "⛶"; + L.DomEvent.disableClickPropagation(container); + L.DomEvent.on(button, "click", (ev: Event) => { + L.DomEvent.stop(ev); + const target = map.getContainer(); + if (!document.fullscreenElement) { + target.requestFullscreen?.().catch(() => {}); + } else { + document.exitFullscreen?.().catch(() => {}); + } + }); + const update = () => { + const active = document.fullscreenElement === map.getContainer(); + button.innerHTML = active ? "✕" : "⛶"; + button.title = active ? "全画面を終了" : "全画面表示"; + button.setAttribute("aria-label", active ? "全画面を終了" : "全画面表示"); + map.invalidateSize(); + }; + document.addEventListener("fullscreenchange", update); + return container; + }, + }); + if (document.fullscreenEnabled) { + new FullscreenControl({ position: "topright" }).addTo(map); + } + const layersByName = new Map(); const allLayers: any[] = []; + type NearbyEntry = { name: string; layer: any; city: string; key: string }; + const nearbyEntries: NearbyEntry[] = []; + const nearbyInfoByName = new Map(); + ((nearbyData as any).features ?? []).forEach((f: any) => { + const p = f.properties || {}; + const name = String(p.name || "").trim(); + if (name) nearbyInfoByName.set(name, p); + }); + const nearbyMapsQuery = (name: string, city = "") => + name ? (city ? `${city}${name}` : name) : ""; const parkDataByName = new Map(); (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p)); const ownerByName = new Map(); @@ -303,22 +403,24 @@ osmName: string, listed = true, owner = "", + mapsQuery = "", ) => { 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}`); + const name = (ourName || "").trim(); + const query = mapsQuery || (name + ? `小平市${name}` + : rec?.["住所"] + ? `小平市${rec["住所"]}` + : rec?.["緯度"] && rec?.["経度"] + ? `${rec["緯度"]},${rec["経度"]}` + : "小平市"); + selectedMapsLink.href = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; selectedTableLink.dataset.parkRow = encodeURIComponent(ourName); selectedTableLink.hidden = !listed; if (selectedNoteEl) selectedNoteEl.hidden = listed; @@ -359,6 +461,7 @@ new SelectedControl({ position: "bottomleft" }).addTo(map); const layer = L.geoJSON(geojson as any, { + bubblingMouseEvents: false, style: (feature: any) => feature?.properties?.matched ? MATCHED_STYLE : UNMATCHED_STYLE, onEachFeature: (feature: any, lyr: any) => { @@ -375,15 +478,13 @@ const path = lyr.getElement?.(); if (path) path.setAttribute("data-park", ourName); }); - lyr.on("click", () => { + lyr.on("click", (ev: any) => { + if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent); 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"); @@ -432,6 +533,54 @@ interactive: false, }).addTo(map); + // 市外の近い公園(500m以内)は別レイヤーにする。 + // 小平市内の公園と同じく、クリックで一覧を絞り込み、左下に情報を表示する。 + const nearbyLayer = L.geoJSON(nearbyData as any, { + style: NEARBY_STYLE, + onEachFeature: (feature: any, lyr: any) => { + const p = feature.properties || {}; + const name = String(p.name || "").trim(); + const city = String(p.city || "").trim(); + const key = `${p.osm_type || ""}/${p.osm_id || ""}`; + if (name) nearbyEntries.push({ name, layer: lyr, city, key }); + lyr.on("click", (ev: any) => { + if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent); + if (!name) return; + filterName(name, false); + showSelected(name, name, true, city || "市外", nearbyMapsQuery(name, city)); + }); + }, + }).addTo(map); + + // 地域センター・公民館・図書館・市役所支所。 + const publicFacilityLayer = L.layerGroup(); + (facilities as any[]).forEach((f) => { + if (f.lat == null || f.lon == null) return; + L.circleMarker([f.lat, f.lon], { + radius: 6, + color: "#4a148c", + weight: 1.5, + fillColor: "#8e24aa", + fillOpacity: 0.85, + }) + .bindPopup( + `${f.name}
${f.type}
${f.hours || ""}${f.closed ? `
休: ${f.closed}` : ""}
トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`, + ) + .addTo(publicFacilityLayer); + }); + publicFacilityLayer.addTo(map); + + L.control + .layers( + null, + { + "市外の近い公園(500m以内)": nearbyLayer, + "公共施設": publicFacilityLayer, + }, + { collapsed: false, position: "topright" }, + ) + .addTo(map); + // 拡大時だけ表示する設備アイコン(トイレ・水飲み場) // 一覧と一致した公園のポリゴン内にだけ表示する。 const facilityLayer = L.layerGroup(); @@ -459,6 +608,7 @@ iconAnchor: [0, 0], }), riseOnHover: false, + interactive: false, }).addTo(facilityLayer); }); @@ -492,24 +642,59 @@ }; fitAll(); - const focusPark = (name: string) => { + const focusPark = (name: string, key = "") => { + if (key) { + const nearbyHit = nearbyEntries.find((entry) => entry.key === key); + if (nearbyHit) { + map.fitBounds(nearbyHit.layer.getBounds(), { padding: [40, 40], maxZoom: 17 }); + showSelected( + name, + name, + true, + nearbyHit.city || "市外", + nearbyMapsQuery(name, nearbyHit.city), + ); + return true; + } + } 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; + if (target) { + map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: 17 }); + showSelected( + name, + target._osmName || name, + true, + ownerByName.get(name) || "", + ); + return true; + } + const nearbyTargets = nearbyEntries + .filter((entry) => entry.name === name) + .map((entry) => entry.layer); + if (nearbyTargets.length) { + const group = L.featureGroup(nearbyTargets); + map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 }); + const info = nearbyInfoByName.get(name); + const city = String(info?.city || "").trim(); + showSelected(name, name, true, city || "市外", nearbyMapsQuery(name, city)); + return true; + } + return false; }; document.addEventListener("park:focus", (e: Event) => { - const name = (e as CustomEvent).detail?.name; - if (!name || !layersByName.has(name)) return; + const detail = (e as CustomEvent).detail || {}; + const name = detail.name; + if (!name) return; + const key = detail.key || ""; + if ( + !key && + !layersByName.has(name) && + !nearbyEntries.some((entry) => entry.name === name) + ) + return; el.scrollIntoView({ behavior: "smooth", block: "center" }); - focusPark(name); + focusPark(name, key); }); document.addEventListener("park:filter", (e: Event) => { @@ -532,6 +717,9 @@ lyr.setStyle(name ? MATCHED_STYLE : UNMATCHED_STYLE); } }); + nearbyEntries.forEach(({ name, layer: lyr }) => { + lyr.setStyle(name && names.has(name) ? NEARBY_SELECTED_STYLE : NEARBY_STYLE); + }); return; } @@ -549,6 +737,19 @@ } }); + nearbyEntries.forEach(({ name, layer: lyr }) => { + if (!active) { + lyr.setStyle(NEARBY_STYLE); + return; + } + if (name && names.has(name)) { + lyr.setStyle(NEARBY_STYLE); + visible.push(lyr); + } else { + lyr.setStyle(NEARBY_DIM_STYLE); + } + }); + if (active && visible.length && visible.length < 30) { const bounds = L.featureGroup(visible).getBounds(); if (bounds.isValid()) { -- cgit v1.3.1