aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components/ParksTable.astro
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ParksTable.astro')
-rw-r--r--src/components/ParksTable.astro260
1 files changed, 135 insertions, 125 deletions
diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro
index bea8ff2..65b8412 100644
--- a/src/components/ParksTable.astro
+++ b/src/components/ParksTable.astro
@@ -1,78 +1,9 @@
---
-// 小平市の公園一覧(/kodaira-data/parks/)を表示する。
-// データは src/data/kodaira-parks.json(市立332件+市立以外)。
+// 小平市の公園・周辺の公園・公共施設の一覧(/kodaira-data/parks/)を表示する。
+// データは src/data/parks-rows.ts で組み立てる。
// DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。
// 絞り込み・並べ替え・カード表示は利用者の端末で実行する。
-import parksData from "@/data/kodaira-parks.json";
-import osmData from "@/data/parks-geojson";
-import nearbyData from "@/data/kodaira-nearby-parks.json";
-
-type Park = Record<string, string>;
-
-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<string>([
- ...(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),
-]);
+import { parks, mappedNames, type Park } from "@/data/parks-rows";
const EMPTY = "—";
@@ -87,6 +18,12 @@ const TYPE_LABEL: Record<string, string> = {
団地内公園: "団地内公園",
児童遊園: "児童遊園",
市外: "市外の近隣公園",
+ 地域センター: "地域センター",
+ 公民館: "公民館",
+ 児童館: "児童館",
+ 図書館: "図書館",
+ 市役所支所: "市役所支所",
+ 文化施設: "文化施設",
};
const TYPE_CLASS: Record<string, string> = {
街区: "parks-type--block",
@@ -98,6 +35,30 @@ const TYPE_CLASS: Record<string, string> = {
団地内公園: "parks-type--danchi",
児童遊園: "parks-type--jidou",
市外: "parks-type--green",
+ 地域センター: "parks-type--facility",
+ 公民館: "parks-type--facility",
+ 児童館: "parks-type--facility",
+ 図書館: "parks-type--facility",
+ 市役所支所: "parks-type--facility",
+ 文化施設: "parks-type--facility",
+};
+// 種別は列幅を小さくするため、漢字1文字の記号で表示する。
+const TYPE_MARK: Record<string, string> = {
+ 街区: "街",
+ 近隣: "近",
+ 運動: "運",
+ 広域: "広",
+ 広場: "場",
+ 緑地: "緑",
+ 団地内公園: "団",
+ 児童遊園: "児",
+ 市外: "外",
+ 地域センター: "📍",
+ 公民館: "🏛",
+ 児童館: "🧒",
+ 図書館: "📚",
+ 市役所支所: "🏢",
+ 文化施設: "🎭",
};
// 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。
@@ -125,6 +86,9 @@ function facilityBadges(p: Park): Badge[] {
if (p["水飲み場"]) {
badges.push({ kind: "water", label: "水飲み場", title: `水飲み場: ${p["水飲み場"]}` });
}
+ if (p["給水"]) {
+ badges.push({ kind: "water", label: "給水", title: `給水: ${p["給水"]}` });
+ }
if (p["遊具"]) {
badges.push({ kind: "toy", label: "遊具", title: `遊具: ${p["遊具"]}` });
}
@@ -150,12 +114,34 @@ function facTokens(p: Park): string {
const tokens: string[] = [];
if (p["トイレ"]) tokens.push("toilet");
if (p["水飲み場"]) tokens.push("water");
+ if (p["給水"]) tokens.push("water");
if (p["遊具"]) tokens.push("toy");
+ const play = p["遊具"] || "";
+ if (play.includes("砂場")) tokens.push("sand");
+ if (play.includes("ブランコ")) tokens.push("swing");
+ if (play.includes("シーソー")) tokens.push("seesaw");
+ if (play.includes("スプリング")) tokens.push("spring");
+ if (play.includes("ジャングル")) tokens.push("jungle");
+ if (play.includes("ヒコーキ") || play.includes("飛行機")) tokens.push("plane");
+ if (play.includes("滑り台") || play.includes("滑台")) tokens.push("slide");
+ if (play.includes("複合遊具")) tokens.push("complex");
+ if (play.includes("ロープウェイ")) tokens.push("rope");
if (p["パーゴラ"]) tokens.push("pergola");
if (p["東屋"]) tokens.push("azumaya");
const ball = p["ボール遊び"];
if (ball && ball !== "禁止") tokens.push("ball");
if (p["南西部指定管理"]) tokens.push("cm");
+ // ピクニック広場・芝生広場・テーブルのある公園。
+ const picnicText = `${p["公園名"] || ""} ${p["種別"] || ""} ${p["その他設備"] || ""} ${p["備考"] || ""}`;
+ if (
+ (p["テーブル"] || "").trim() ||
+ p["種別"] === "広場" ||
+ picnicText.includes("芝生") ||
+ picnicText.includes("ピクニック") ||
+ picnicText.includes("広場")
+ ) {
+ tokens.push("picnic");
+ }
return tokens.join(" ");
}
@@ -228,56 +214,32 @@ function searchIndex(p: Park): string {
p["状態"],
p["トイレ"],
p["水飲み場"],
+ p["給水"],
p["ボール遊び"],
...DETAIL_FIELDS.map((f) => p[f.key]),
]
.join(" ")
.toLowerCase();
}
-
-const towns = [...new Set(parks.map((p) => p["町名"]).filter(Boolean))].sort((a, b) =>
- a.localeCompare(b, "ja"),
-);
-const total = parks.length;
---
<div class="parks" data-parks>
- <div class="parks-controls">
- <label>
- <span>町名</span>
- <select data-parks-town>
- <option value="">すべての町名</option>
- {towns.map((town) => (
- <option value={town}>{town}</option>
- ))}
- </select>
- </label>
- <label>
- <span>キーワード</span>
- <input type="search" data-parks-search placeholder="公園名・住所・遊具など" />
- </label>
- <fieldset class="parks-facilities">
- <legend>設備・管理で絞る</legend>
- <label><input type="checkbox" data-fac-filter value="toilet" />トイレ</label>
- <label><input type="checkbox" data-fac-filter value="water" />水飲み場</label>
- <label><input type="checkbox" data-fac-filter value="toy" />遊具</label>
- <label><input type="checkbox" data-fac-filter value="pergola" />パーゴラ</label>
- <label><input type="checkbox" data-fac-filter value="azumaya" />東屋</label>
- <label><input type="checkbox" data-fac-filter value="ball" />ボール遊び</label>
- <label><input type="checkbox" data-fac-filter value="cm" />指定管理</label>
- </fieldset>
- <span class="parks-count" data-parks-count aria-live="polite">{total}件</span>
- </div>
- <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p>
- <p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p>
+ <p class="parks-none" data-parks-none hidden>条件に合う公園・施設が見つかりませんでした。</p>
<div class="parks-scroll">
<table>
+ <colgroup>
+ <col class="col-name" />
+ <col class="col-address" />
+ <col class="col-area" />
+ <col class="col-type" />
+ <col class="col-facility" />
+ </colgroup>
<thead>
<tr>
<th scope="col" aria-sort="none">
<button type="button" class="parks-sort" data-sort="name">
- 公園名<span class="parks-sort-mark" aria-hidden="true"></span>
+ 名称<span class="parks-sort-mark" aria-hidden="true"></span>
</button>
</th>
<th scope="col">住所</th>
@@ -293,13 +255,14 @@ const total = parks.length;
<tbody>
{parks.map((park) => (
<tr
+ data-kind={park["施設"] === "1" ? park["種別"] : "park"}
data-town={park["町名"]}
data-area={park["面積㎡"]}
data-name={park["公園名"]}
data-fac={facTokens(park)}
data-search={searchIndex(park)}
>
- <td class="is-name" data-label="公園名">
+ <td class="is-name" data-label="名称">
{mappedNames.has(park["公園名"]) ? (
<button
type="button"
@@ -308,6 +271,14 @@ const total = parks.length;
data-focus-key={park["地図キー"] || ""}
title="地図でこの公園の位置を表示"
>{park["公園名"]}</button>
+ ) : park["公式URL"] ? (
+ <a
+ class="park-name-link"
+ href={park["公式URL"]}
+ target="_blank"
+ rel="noopener"
+ title="公式ページを開く"
+ >{park["公園名"]}</a>
) : (
park["公園名"]
)}
@@ -344,7 +315,7 @@ const total = parks.length;
title={TYPE_LABEL[park["種別"]]}
aria-label={TYPE_LABEL[park["種別"]]}
>
- {park["種別"]}
+ <span aria-hidden="true">{TYPE_MARK[park["種別"]] || park["種別"].slice(0, 1)}</span>
</span>
) : (
EMPTY
@@ -424,16 +395,35 @@ const total = parks.length;
opacity: 1;
}
.parks-scroll {
- overflow-x: auto;
+ max-height: 70vh;
+ overflow: auto;
+ overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--sl-color-hairline, #ddd);
border-radius: 0.3rem;
}
.parks table {
width: 100%;
+ min-width: 760px;
+ table-layout: fixed;
border-collapse: collapse;
font-size: 0.85rem;
}
+ .parks col.col-name {
+ width: 24%;
+ }
+ .parks col.col-address {
+ width: 18%;
+ }
+ .parks col.col-area {
+ width: 11%;
+ }
+ .parks col.col-type {
+ width: 7%;
+ }
+ .parks col.col-facility {
+ width: 40%;
+ }
.parks th,
.parks td {
border-bottom: 1px solid var(--sl-color-hairline, #ddd);
@@ -481,6 +471,9 @@ const total = parks.length;
}
.parks .is-name {
font-weight: 600;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ line-height: 1.5;
}
.parks-owner {
display: inline-block;
@@ -521,6 +514,8 @@ const total = parks.length;
white-space: nowrap;
}
.park-name-link {
+ display: inline-block;
+ max-width: 100%;
padding: 0;
border: 0;
background: none;
@@ -532,6 +527,8 @@ const total = parks.length;
text-decoration: underline;
text-decoration-color: var(--sl-color-gray-4);
text-underline-offset: 2px;
+ white-space: normal;
+ overflow-wrap: anywhere;
}
.park-name-link:hover {
color: var(--sl-color-accent);
@@ -579,16 +576,15 @@ const total = parks.length;
.parks-type {
display: inline-flex;
align-items: center;
- gap: 0.3em;
- font-size: 0.9em;
- }
- .parks-type::before {
- content: "";
- width: 0.7em;
- height: 0.7em;
- border-radius: 50%;
- background: var(--type-color, #888);
- flex: none;
+ justify-content: center;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 0.35em;
+ background: color-mix(in srgb, var(--type-color, #888) 18%, transparent);
+ color: var(--type-color, #888);
+ font-size: 0.8em;
+ font-weight: 700;
+ line-height: 1;
}
.parks-type--block {
--type-color: #2e9e4f;
@@ -614,6 +610,10 @@ const total = parks.length;
.parks-type--jidou {
--type-color: #d81b60;
}
+ .parks-type--facility {
+ --type-color: #00838f;
+ font-size: 1em;
+ }
.parks .is-facility {
white-space: normal;
min-width: 12rem;
@@ -674,12 +674,15 @@ const total = parks.length;
<script>
document.querySelectorAll("[data-parks]").forEach((root) => {
- const select = root.querySelector<HTMLSelectElement>("[data-parks-town]");
- const search = root.querySelector<HTMLInputElement>("[data-parks-search]");
- const count = root.querySelector("[data-parks-count]");
+ const select = document.querySelector<HTMLSelectElement>("[data-parks-town]");
+ const kindSelect = document.querySelector<HTMLSelectElement>("[data-parks-kind]");
+ const search = document.querySelector<HTMLInputElement>("[data-parks-search]");
+ const count = document.querySelector("[data-parks-count]");
const none = root.querySelector<HTMLElement>("[data-parks-none]");
const tbody = root.querySelector("tbody");
- const checks = Array.from(root.querySelectorAll<HTMLInputElement>("[data-fac-filter]"));
+ const checks = Array.from(
+ document.querySelectorAll<HTMLInputElement>("[data-fac-filter]"),
+ );
const sortButtons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-sort]"));
if (!tbody) return;
@@ -703,17 +706,19 @@ const total = parks.length;
let selectedName = "";
const applyFilter = () => {
+ const kind = kindSelect ? kindSelect.value : "";
const town = select ? select.value : "";
const query = search ? search.value.trim().toLowerCase() : "";
const wanted = checks.filter((c) => c.checked).map((c) => c.value);
let shown = 0;
allRows().forEach((row) => {
const facs = (row.dataset.fac || "").split(" ");
+ const okKind = !kind || row.dataset.kind === kind;
const okTown = !town || row.dataset.town === town;
const okText = !query || (row.dataset.search || "").includes(query);
const okFac = wanted.every((token) => facs.includes(token));
const okSelected = !selectedName || row.dataset.name === selectedName;
- const ok = okTown && okText && okFac && okSelected;
+ const ok = okKind && okTown && okText && okFac && okSelected;
row.classList.toggle("is-hidden", !ok);
if (ok) shown += 1;
});
@@ -729,7 +734,7 @@ const total = parks.length;
new CustomEvent("park:filter", {
detail: {
names: visibleNames,
- active: Boolean(town || query || wanted.length || selectedName),
+ active: Boolean(kind || town || query || wanted.length || selectedName),
fromMap,
},
}),
@@ -765,6 +770,10 @@ const total = parks.length;
clearSelected();
applyFilter();
});
+ kindSelect?.addEventListener("change", () => {
+ clearSelected();
+ applyFilter();
+ });
search?.addEventListener("input", () => {
clearSelected();
applyFilter();
@@ -784,6 +793,7 @@ const total = parks.length;
selectedName = name;
if (search) search.value = name;
if (select) select.value = "";
+ if (kindSelect) kindSelect.value = "";
checks.forEach((c) => {
c.checked = false;
});