diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-24 12:29:24 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-24 12:29:24 +0900 |
| commit | a71727538f65ab4f10f5239550be82590f9d47d1 (patch) | |
| tree | 4608d37faead4f253c3311982a400a8fb88fba9e /src/components/ParkMap.astro | |
| parent | c7efe674b34a2949b76d62bb71a972427965af92 (diff) | |
parks: 公園一覧と地図、公共施設の情報を更新
- 近隣公園のクリックを市内公園と同じ挙動にし、Googleマップを公園名で開く
- 公共施設のトイレを○にし、ルネこだいらを追加
- 未取得だった施設の座標を追加し、再ジオコーディング時の上書きを防止
- 手入力ポリゴン、近隣公園、PWA、ライセンス表示を追加
Diffstat (limited to 'src/components/ParkMap.astro')
| -rw-r--r-- | src/components/ParkMap.astro | 261 |
1 files changed, 231 insertions, 30 deletions
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 @@ <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-nearby"></i>市外の近い公園(500m以内)</span> + <span><i class="swatch swatch-facility"></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 + 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園と公共施設は別レイヤーで表示し、右上のレイヤ切替でON/OFFできます。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a @@ -39,6 +41,26 @@ border: 1px solid var(--sl-color-gray-5); border-radius: 8px; } + .park-map:fullscreen, + .park-map:-webkit-full-screen { + height: 100vh !important; + width: 100vw !important; + border: 0; + border-radius: 0; + } + :global(.park-map-fullscreen button) { + width: 30px; + height: 30px; + border: 0; + background: #fff; + color: #1e293b; + font-size: 18px; + line-height: 1; + cursor: pointer; + } + :global(.park-map-fullscreen button:hover) { + background: #f1f5f9; + } .park-map-legend { display: flex; flex-wrap: wrap; @@ -65,6 +87,13 @@ .swatch-unmatched { background: #ffcdd2; } + .swatch-nearby { + background: #42a5f5; + } + .swatch-facility { + background: #8e24aa; + border-radius: 50%; + } .swatch-boundary { height: 0; border-top: 4px solid #6a1b9a; @@ -155,6 +184,10 @@ background: #0277bd; border-radius: 50%; } + :global(.park-fac-markers), + :global(.park-fac-list) { + pointer-events: none; + } :global(.park-fac-list) { display: inline-flex; gap: 2px; @@ -198,9 +231,12 @@ max-height: none !important; margin: 0 !important; } - /* Starlightの画像用 margin がタイルに付くと行が16pxずれるため、0にする。 */ + /* Starlightの画像用スタイル(margin / border / border-radius)が + タイルに付くと、行ずれやタイルごとの枠が出るため打ち消す。 */ .park-map :global(.leaflet-tile) { margin: 0 !important; + border: 0 !important; + border-radius: 0 !important; } @media (max-width: 640px) { .park-map { @@ -214,6 +250,8 @@ import "leaflet/dist/leaflet.css"; import geojson from "@/data/parks-geojson"; import boundary from "@/data/kodaira-boundary.json"; + import nearbyData from "@/data/kodaira-nearby-parks.json"; + import facilities from "@/data/kodaira-facilities.json"; import parks from "@/data/kodaira-parks.json"; const MATCHED_STYLE = { @@ -241,13 +279,32 @@ fillColor: "#d32f2f", fillOpacity: 0.6, }; + const NEARBY_STYLE = { + color: "#1565c0", + weight: 1.5, + fillColor: "#42a5f5", + fillOpacity: 0.25, + }; + const NEARBY_SELECTED_STYLE = { + color: "#0d47a1", + weight: 3, + fillColor: "#1565c0", + fillOpacity: 0.55, + }; + const NEARBY_DIM_STYLE = { + color: "#90caf9", + weight: 0.8, + fillColor: "#bbdefb", + fillOpacity: 0.05, + opacity: 0.3, + }; const el = document.getElementById("park-map"); if (el && !el.dataset.initialized) { el.dataset.initialized = "1"; const map = L.map(el, { - scrollWheelZoom: false, + scrollWheelZoom: true, zoomSnap: 1, zoomDelta: 1, zoomAnimation: false, @@ -264,8 +321,51 @@ '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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<string, any>(); const allLayers: any[] = []; + type NearbyEntry = { name: string; layer: any; city: string; key: string }; + const nearbyEntries: NearbyEntry[] = []; + const nearbyInfoByName = new Map<string, any>(); + ((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<string, any>(); (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p)); const ownerByName = new Map<string, string>(); @@ -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( + `<strong>${f.name}</strong><br>${f.type}<br>${f.hours || ""}${f.closed ? `<br>休: ${f.closed}` : ""}<br>トイレ: ${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()) { |
