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/FacilitiesTable.astro | 186 +++++++++++++++++++++++++ src/components/ParkMap.astro | 261 +++++++++++++++++++++++++++++++---- src/components/ParksTable.astro | 114 +++++++++++++-- src/components/PwaRegister.astro | 19 +++ 4 files changed, 535 insertions(+), 45 deletions(-) create mode 100644 src/components/FacilitiesTable.astro create mode 100644 src/components/PwaRegister.astro (limited to 'src/components') diff --git a/src/components/FacilitiesTable.astro b/src/components/FacilitiesTable.astro new file mode 100644 index 0000000..2974581 --- /dev/null +++ b/src/components/FacilitiesTable.astro @@ -0,0 +1,186 @@ +--- +// 地域センター・公民館・図書館・市役所支所・文化施設の一覧。 +// トイレは市の公共施設として基本的に利用できる。給水は公式ページに明記が少ないため「未確認」が多い。 +import facilitiesData from "@/data/kodaira-facilities.json"; + +type Facility = { + name: string; + type: string; + address: string; + hours: string; + closed: string; + toilet: string; + water: string; + note: string; + source: string; +}; + +const facilities = facilitiesData as Facility[]; +const types = ["地域センター", "公民館", "図書館", "市役所支所", "文化施設"]; +const mapsUrl = (name: string) => + `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`小平市${name}`)}`; +--- + +
+
+ + + + + {facilities.length}件 +
+ +
+ + + + + + + + + + + + + + + {facilities.map((f) => ( + + + + + + + + + + + ))} + +
施設名種別住所開館時間休館日トイレ給水備考
+ {f.source ? ( + {f.name} + ) : ( + f.name + )} + {f.type} + + {f.address || "—"} + + {f.hours || "未確認"}{f.closed || "未確認"}{f.toilet || "未確認"}{f.water || "未確認"}{f.note}
+
+ +

+ トイレは市の公共施設として基本的に利用できます(○)。給水は公式ページに明記が少ないため「未確認」が多いです。最新の情報は各施設の公式ページで確認してください。地図の位置は住所・名称からの概略です。 +

+
+ + + + 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()) { diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index 6f8fdbd..bea8ff2 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -5,16 +5,74 @@ // 絞り込み・並べ替え・カード表示は利用者の端末で実行する。 import parksData from "@/data/kodaira-parks.json"; import osmData from "@/data/parks-geojson"; +import nearbyData from "@/data/kodaira-nearby-parks.json"; type Park = Record; -const parks = parksData as Park[]; + +function nearbyCentroid(feature: any): [number, number] { + const g = feature.geometry; + const ring = g.type === "Polygon" ? g.coordinates[0] : g.coordinates[0][0]; + if (!ring?.length) return [0, 0]; + let lat = 0; + let lon = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lat / ring.length, lon / ring.length]; +} + +// 市境500m以内の市外公園。小平市の公園と同じ表に「市外」バッジ付きで並べる。 +const nearbyRows: Park[] = ((nearbyData as any).features ?? []).map((f: any) => { + const p = f.properties || {}; + const [lat, lon] = nearbyCentroid(f); + const area = Math.round(Number(p.area_m2) || 0); + const name = String(p.name || "").trim(); + // 市外の公園も公園名で開く。市名が分かる場合は市名を付ける。 + const mapsQuery = name + ? p.city + ? `${p.city}${name}` + : name + : `${lat},${lon}`; + return { + "公園名": name, + "住所": p.city ? `${p.city}(市外)` : "市外", + "町名": "市外", + "面積㎡": area ? String(area) : "", + "500㎡以上": "", + "種別": "市外", + "南西部指定管理": "", + "トイレ": "", + "水飲み場": "", + "ベンチ・椅子": "", + "テーブル": "", + "パーゴラ": "", + "東屋": "", + "防災用具": "", + "ボール遊び": "", + "遊具": "", + "駐車場": "", + "その他設備": "", + "緯度": String(lat), + "経度": String(lon), + "備考": `市外・小平市境から約${Math.round(Number(p.distance_m) || 0)}m`, + "設置者": "市外", + "状態": "", + "地図キー": `${p.osm_type || ""}/${p.osm_id || ""}`, + "地図URL": `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapsQuery)}`, + }; +}); + +const parks = [...(parksData as Park[]), ...nearbyRows]; // OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。 -const mappedNames = new Set( - (osmData as any).features - .map((f: any) => f.properties?.our_name) +// 市外の近隣公園も地図に表示しているので、同じように地図で見られる。 +const mappedNames = new Set([ + ...(osmData as any).features.map((f: any) => f.properties?.our_name).filter(Boolean), + ...((nearbyData as any).features ?? []) + .map((f: any) => String(f.properties?.name || "").trim()) .filter(Boolean), -); +]); const EMPTY = "—"; @@ -28,6 +86,7 @@ const TYPE_LABEL: Record = { 緑地: "緑地", 団地内公園: "団地内公園", 児童遊園: "児童遊園", + 市外: "市外の近隣公園", }; const TYPE_CLASS: Record = { 街区: "parks-type--block", @@ -38,6 +97,7 @@ const TYPE_CLASS: Record = { 緑地: "parks-type--green", 団地内公園: "parks-type--danchi", 児童遊園: "parks-type--jidou", + 市外: "parks-type--green", }; // 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。 @@ -143,16 +203,17 @@ function sizeTitle(area: number): string { return "目安: 10,000㎡以上(広め)"; } -// 座標があればその位置を開く。ない場合は公園名で検索する(地番は地図側で正しく解決されないため)。 -// 公園名だけだと別の自治体の同名公園に飛ぶので「小平市」を必ず付ける。 +// 公園名で検索する。公園名だけだと別の自治体の同名公園に飛ぶので「小平市」を必ず付ける。 +// 公園名がない場合だけ住所、それもなければ座標を使う。 function mapsUrl(p: Park): string { - const lat = p["緯度"]; - const lng = p["経度"]; - if (lat && lng) { - return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; - } - const query = encodeURIComponent(`小平市${p["公園名"]}`); - return `https://www.google.com/maps/search/?api=1&query=${query}`; + if (p["地図URL"]) return p["地図URL"]; + const name = (p["公園名"] || "").trim(); + const query = name + ? `小平市${name}` + : p["住所"] + ? `小平市${p["住所"]}` + : `${p["緯度"]},${p["経度"]}`; + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; } // 検索対象(公園名・住所・町名・種別・設備・備考)。 @@ -164,6 +225,7 @@ function searchIndex(p: Park): string { p["種別"], TYPE_LABEL[p["種別"]], p["設置者"], + p["状態"], p["トイレ"], p["水飲み場"], p["ボール遊び"], @@ -243,11 +305,15 @@ const total = parks.length; type="button" class="park-name-link" data-focus-park={park["公園名"]} + data-focus-key={park["地図キー"] || ""} title="地図でこの公園の位置を表示" >{park["公園名"]} ) : ( park["公園名"] )} + {park["状態"] === "閉鎖" ? ( + 閉鎖 + ) : null} {park["設置者"] && park["設置者"] !== "小平市" ? ( { const name = btn.dataset.focusPark; if (!name) return; - document.dispatchEvent(new CustomEvent("park:focus", { detail: { name } })); + document.dispatchEvent( + new CustomEvent("park:focus", { + detail: { name, key: btn.dataset.focusKey || "" }, + }), + ); }); }); diff --git a/src/components/PwaRegister.astro b/src/components/PwaRegister.astro new file mode 100644 index 0000000..82359a4 --- /dev/null +++ b/src/components/PwaRegister.astro @@ -0,0 +1,19 @@ + -- cgit v1.3.1