From 9a169c21e1032493a68d575b534cd31179b437e3 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:16:21 +0900 Subject: 公園一覧: 非市立公園・市民広場の追加と地図調整 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未一致のOSMポリゴンを一覧に追加し、都立・民間・未確認のバッジを表示 市民広場3件を追加し、花小金井四丁目市民広場の名称を修正 地図の初期表示を市境に合わせ、市外をマスク Googleマップリンクを座標優先に変更 手動照合用のレビューCSV生成とOSM ID上書きを追加 --- src/components/ParksTable.astro | 249 ++++++++++++++++++++++++++++++---------- 1 file changed, 186 insertions(+), 63 deletions(-) (limited to 'src/components/ParksTable.astro') diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index 5dbaa9c..b6537ab 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -1,13 +1,21 @@ --- // 小平市の公園一覧(/kodaira-data/parks/)を表示する。 -// データは src/data/kodaira-parks.json(321件、市の公開資料から作成)。 +// データは src/data/kodaira-parks.json(市立332件+市立以外)。 // DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。 // 絞り込み・並べ替え・カード表示は利用者の端末で実行する。 import parksData from "@/data/kodaira-parks.json"; +import osmData from "@/data/kodaira-parks-osm.json"; type Park = Record; const parks = parksData as Park[]; +// OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。 +const mappedNames = new Set( + (osmData as any).features + .map((f: any) => f.properties?.our_name) + .filter(Boolean), +); + const EMPTY = "—"; // 種別の表示(色付きの丸+区分名。正式名は title / aria-label で補う)。 @@ -15,13 +23,32 @@ const TYPE_LABEL: Record = { 街区: "街区公園", 近隣: "近隣公園", 運動: "運動公園", + 広域: "広域公園", + 広場: "広場", + 緑地: "緑地", }; const TYPE_CLASS: Record = { 街区: "parks-type--block", 近隣: "parks-type--neighbor", 運動: "parks-type--sports", + 広域: "parks-type--wide", + 広場: "parks-type--open", + 緑地: "parks-type--green", }; +// 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。 +function ownerLabel(owner: string): string { + if (owner === "東京都") return "都立"; + if (owner.includes("UR")) return "UR"; + if (owner.includes("民間")) return "民間"; + return owner; +} +function ownerClass(owner: string): string { + if (owner === "東京都") return "parks-owner--tokyo"; + if (owner === "未確認") return "parks-owner--unknown"; + return "parks-owner--other"; +} + // 設備バッジ(アイコンは CSS の ::before で描く)。 type Badge = { kind: string; label: string; title: string }; function facilityBadges(p: Park): Badge[] { @@ -73,6 +100,8 @@ const DETAIL_FIELDS: { key: string; icon: string }[] = [ { key: "テーブル", icon: "🍽" }, { key: "防災用具", icon: "🧯" }, { key: "遊具", icon: "🎠" }, + { key: "駐車場", icon: "🅿" }, + { key: "その他設備", icon: "🔧" }, { key: "備考", icon: "" }, ]; function detailText(p: Park): string { @@ -108,10 +137,15 @@ function sizeTitle(area: number): string { return "目安: 10,000㎡以上(広め)"; } -// 住所ではなく公園名で検索する(地番は地図側で正しく解決されないため)。 -// 「小平市」を付けないと別の自治体の同名公園に飛ぶので必ず付ける。 -function mapsUrl(name: string): string { - const query = encodeURIComponent(`小平市${name}`); +// 座標があればその位置を開く。ない場合は公園名で検索する(地番は地図側で正しく解決されないため)。 +// 公園名だけだと別の自治体の同名公園に飛ぶので「小平市」を必ず付ける。 +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}`; } @@ -122,6 +156,8 @@ function searchIndex(p: Park): string { p["住所"], p["町名"], p["種別"], + TYPE_LABEL[p["種別"]], + p["設置者"], p["トイレ"], p["水飲み場"], p["ボール遊び"], @@ -131,7 +167,7 @@ function searchIndex(p: Park): string { .toLowerCase(); } -const towns = [...new Set(parks.map((p) => p["町名"]))].sort((a, b) => +const towns = [...new Set(parks.map((p) => p["町名"]).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ja"), ); const total = parks.length; @@ -164,7 +200,7 @@ const total = parks.length; {total}件 -

設備の情報は市の南西部地域の公園のみです。設備で絞ると、この情報がある公園だけが対象になります。

+

設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。

@@ -195,22 +231,39 @@ const total = parks.length; data-fac={facTokens(park)} data-search={searchIndex(park)} > - {park["公園名"]} + + {mappedNames.has(park["公園名"]) ? ( + + ) : ( + park["公園名"] + )} + {park["設置者"] && park["設置者"] !== "小平市" ? ( + {ownerLabel(park["設置者"])} + ) : null} + {park["住所"]} + >{park["住所"] || EMPTY} - {areaText(park["面積㎡"])}㎡ + {park["面積㎡"] ? `${areaText(park["面積㎡"])}㎡` : EMPTY} {park["種別"] ? ( @@ -357,6 +410,46 @@ const total = parks.length; .parks .is-name { font-weight: 600; } + .parks-owner { + display: inline-block; + margin-left: 0.35rem; + padding: 0.02rem 0.35rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.5; + vertical-align: 0.08em; + white-space: nowrap; + } + .parks-owner--tokyo { + color: #2e7d32; + } + .parks-owner--other { + color: #6d4c41; + } + .parks-owner--unknown { + color: #757575; + } + .park-name-link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--sl-color-gray-4); + text-underline-offset: 2px; + } + .park-name-link:hover { + color: var(--sl-color-accent); + } + .parks tbody tr.is-highlighted td { + background: #fff59d !important; + } .parks .is-address { font-size: 0.8em; white-space: normal; @@ -417,6 +510,15 @@ const total = parks.length; .parks-type--sports { --type-color: #e8862a; } + .parks-type--wide { + --type-color: #7b1fa2; + } + .parks-type--open { + --type-color: #8d6e63; + } + .parks-type--green { + --type-color: #43a047; + } .parks .is-facility { white-space: normal; min-width: 12rem; @@ -463,54 +565,14 @@ const total = parks.length; opacity: 0.85; } - /* スマートフォンでは1件ずつカードで表示する。 */ + /* スマートフォンでも表形式を維持し、横スクロールで見られるようにする。 */ @media (max-width: 640px) { - .parks-scroll { - overflow-x: visible; - border: none; - border-radius: 0; - } - .parks table, - .parks tbody, - .parks tr, - .parks td { - display: block; - width: auto; - } - .parks thead { - display: none; - } - .parks tbody tr { - margin-bottom: 0.6rem; - padding: 0.4rem 0.6rem; - border: 1px solid var(--sl-color-hairline, #ddd); - border-radius: 0.4rem; - } - .parks tbody tr:nth-child(even) td { - background: transparent; + .parks table { + font-size: 0.75rem; } + .parks th, .parks td { - border: none; - padding: 0.1rem 0; - white-space: normal; - max-width: none; - } - .parks td::before { - content: attr(data-label) ":"; - display: inline-block; - min-width: 4.5em; - font-size: 0.85em; - opacity: 0.7; - } - .parks td.is-name { - font-size: 1rem; - } - .parks td.is-name::before { - content: none; - } - .parks td.is-num, - .parks td.is-type { - text-align: left; + padding: 0.3rem 0.4rem; } } @@ -520,17 +582,26 @@ const total = parks.length; const select = root.querySelector("[data-parks-town]"); const search = root.querySelector("[data-parks-search]"); const count = root.querySelector("[data-parks-count]"); - const none = root.querySelector("[data-parks-none]"); + const none = root.querySelector("[data-parks-none]"); const tbody = root.querySelector("tbody"); const checks = Array.from(root.querySelectorAll("[data-fac-filter]")); const sortButtons = Array.from(root.querySelectorAll("[data-sort]")); if (!tbody) return; + root.querySelectorAll("[data-focus-park]").forEach((btn) => { + btn.addEventListener("click", () => { + const name = btn.dataset.focusPark; + if (!name) return; + document.dispatchEvent(new CustomEvent("park:focus", { detail: { name } })); + }); + }); + const allRows = () => Array.from(tbody.querySelectorAll("tr")); const total = allRows().length; let sortKey: string | null = null; let sortDir = 1; + let selectedName = ""; const applyFilter = () => { const town = select ? select.value : ""; @@ -542,12 +613,28 @@ const total = parks.length; const okTown = !town || row.dataset.town === town; const okText = !query || (row.dataset.search || "").includes(query); const okFac = wanted.every((token) => facs.includes(token)); - const ok = okTown && okText && okFac; + const okSelected = !selectedName || row.dataset.name === selectedName; + const ok = okTown && okText && okFac && okSelected; row.classList.toggle("is-hidden", !ok); if (ok) shown += 1; }); if (count) count.textContent = `${shown} / ${total}件`; if (none) none.hidden = shown !== 0; + // 地図側に絞り込み結果を通知する。 + const visibleNames = allRows() + .filter((row) => !row.classList.contains("is-hidden")) + .map((row) => row.dataset.name || "") + .filter(Boolean); + const fromMap = Boolean(selectedName && search?.value === selectedName); + document.dispatchEvent( + new CustomEvent("park:filter", { + detail: { + names: visibleNames, + active: Boolean(town || query || wanted.length || selectedName), + fromMap, + }, + }), + ); }; const applySort = () => { @@ -572,9 +659,45 @@ const total = parks.length; sorted.forEach((row) => tbody.appendChild(row)); }; - select?.addEventListener("change", applyFilter); - search?.addEventListener("input", applyFilter); - checks.forEach((c) => c.addEventListener("change", applyFilter)); + const clearSelected = () => { + selectedName = ""; + }; + select?.addEventListener("change", () => { + clearSelected(); + applyFilter(); + }); + search?.addEventListener("input", () => { + clearSelected(); + applyFilter(); + }); + checks.forEach((c) => + c.addEventListener("change", () => { + clearSelected(); + applyFilter(); + }), + ); + + // 地図のポリゴンクリックなどから、公園名で1件だけ絞り込む。 + document.addEventListener("park:filter-name", (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + const name = detail.name; + if (!name) return; + selectedName = name; + if (search) search.value = name; + if (select) select.value = ""; + checks.forEach((c) => { + c.checked = false; + }); + applyFilter(); + const row = allRows().find((r) => r.dataset.name === name); + if (row) { + row.classList.add("is-highlighted"); + window.setTimeout(() => row.classList.remove("is-highlighted"), 2000); + if (detail.scroll) { + row.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }); sortButtons.forEach((btn) => { btn.addEventListener("click", () => { const key = btn.dataset.sort; -- cgit v1.3.1