aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components/ParksTable.astro
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-18 22:16:21 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-18 22:16:21 +0900
commit9a169c21e1032493a68d575b534cd31179b437e3 (patch)
treeba9d63f456e1eb403379926c9de89eb5ce100c84 /src/components/ParksTable.astro
parent4e84129d4b2b3e806689371f8912ffa2f4767113 (diff)
公園一覧: 非市立公園・市民広場の追加と地図調整
未一致のOSMポリゴンを一覧に追加し、都立・民間・未確認のバッジを表示 市民広場3件を追加し、花小金井四丁目市民広場の名称を修正 地図の初期表示を市境に合わせ、市外をマスク Googleマップリンクを座標優先に変更 手動照合用のレビューCSV生成とOSM ID上書きを追加
Diffstat (limited to 'src/components/ParksTable.astro')
-rw-r--r--src/components/ParksTable.astro249
1 files changed, 186 insertions, 63 deletions
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<string, string>;
const parks = parksData as Park[];
+// OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。
+const mappedNames = new Set<string>(
+ (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<string, string> = {
街区: "街区公園",
近隣: "近隣公園",
運動: "運動公園",
+ 広域: "広域公園",
+ 広場: "広場",
+ 緑地: "緑地",
};
const TYPE_CLASS: Record<string, string> = {
街区: "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;
</fieldset>
<span class="parks-count" data-parks-count aria-live="polite">{total}件</span>
</div>
- <p class="parks-hint">設備の情報は市の南西部地域の公園のみです。設備で絞ると、この情報がある公園だけが対象になります。</p>
+ <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p>
<p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p>
<div class="parks-scroll">
@@ -195,22 +231,39 @@ const total = parks.length;
data-fac={facTokens(park)}
data-search={searchIndex(park)}
>
- <td class="is-name" data-label="公園名">{park["公園名"]}</td>
+ <td class="is-name" data-label="公園名">
+ {mappedNames.has(park["公園名"]) ? (
+ <button
+ type="button"
+ class="park-name-link"
+ data-focus-park={park["公園名"]}
+ title="地図でこの公園の位置を表示"
+ >{park["公園名"]}</button>
+ ) : (
+ park["公園名"]
+ )}
+ {park["設置者"] && park["設置者"] !== "小平市" ? (
+ <span
+ class:list={["parks-owner", ownerClass(park["設置者"])]}
+ title={`設置者: ${park["設置者"]}`}
+ >{ownerLabel(park["設置者"])}</span>
+ ) : null}
+ </td>
<td class="is-address" data-label="住所">
<a
- href={mapsUrl(park["公園名"])}
+ href={mapsUrl(park)}
target="_blank"
rel="noopener"
title={`Googleマップで「${park["公園名"]}」を開く`}
- >{park["住所"]}</a>
+ >{park["住所"] || EMPTY}</a>
</td>
<td
class="is-num"
data-label="面積"
- data-size={sizeLabel(areaNumber(park["面積㎡"]))}
- title={sizeTitle(areaNumber(park["面積㎡"]))}
+ data-size={park["面積㎡"] ? sizeLabel(areaNumber(park["面積㎡"])) : ""}
+ title={park["面積㎡"] ? sizeTitle(areaNumber(park["面積㎡"])) : ""}
>
- {areaText(park["面積㎡"])}㎡
+ {park["面積㎡"] ? `${areaText(park["面積㎡"])}㎡` : EMPTY}
</td>
<td class="is-type" data-label="種別">
{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 {
+ font-size: 0.75rem;
}
- .parks table,
- .parks tbody,
- .parks tr,
+ .parks th,
.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 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;
}
}
</style>
@@ -520,17 +582,26 @@ const total = parks.length;
const select = root.querySelector<HTMLSelectElement>("[data-parks-town]");
const search = root.querySelector<HTMLInputElement>("[data-parks-search]");
const count = root.querySelector("[data-parks-count]");
- const none = root.querySelector("[data-parks-none]");
+ const none = root.querySelector<HTMLElement>("[data-parks-none]");
const tbody = root.querySelector("tbody");
const checks = Array.from(root.querySelectorAll<HTMLInputElement>("[data-fac-filter]"));
const sortButtons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-sort]"));
if (!tbody) return;
+ root.querySelectorAll<HTMLButtonElement>("[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<HTMLTableRowElement>("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;