diff options
Diffstat (limited to 'src/components/ParksTable.astro')
| -rw-r--r-- | src/components/ParksTable.astro | 260 |
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; }); |
