diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 14:24:46 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 14:24:46 +0900 |
| commit | 688b74a658a217a0e4dac67bf95a859ebb793e07 (patch) | |
| tree | c604e24194855ce6a2685f00a0203164366ea3d0 /src/components/ParksTable.astro | |
| parent | b02772f108fc9cf5271b9625ec89ec89323fe7b5 (diff) | |
ParksTable: 公園一覧に設備フィルタ・並べ替え・カード表示を追加
設備(トイレ・水飲み場・遊具・ボール遊び)で絞り込めるようにする。
公園名・面積の見出しで並べ替え、0件時は案内を表示する。
スマホはカード表示にし、面積に小・中・大の目安を付ける。
設備はアイコンバッジで示し、地図リンクは住所ではなく公園名
(+小平市)で検索するようにする。
updates.json を再生成(公園一覧を追加、上限40件)。
Diffstat (limited to 'src/components/ParksTable.astro')
| -rw-r--r-- | src/components/ParksTable.astro | 479 |
1 files changed, 419 insertions, 60 deletions
diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index 268bdfe..8d7f648 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -1,7 +1,8 @@ --- // 小平市の公園一覧(/kodaira-data/parks/)を表示する。 // データは src/data/kodaira-parks.json(321件、市の公開資料から作成)。 -// 町名セレクトとキーワード入力で絞り込む(絞り込みは利用者の端末で実行)。 +// DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。 +// 絞り込み・並べ替え・カード表示は利用者の端末で実行する。 import parksData from "@/data/kodaira-parks.json"; type Park = Record<string, string>; @@ -9,42 +10,110 @@ const parks = parksData as Park[]; const EMPTY = "—"; -const columns: { key: string; label: string; numeric?: boolean; wide?: boolean }[] = [ - { key: "公園名", label: "公園名" }, - { key: "住所", label: "住所" }, - { key: "面積㎡", label: "面積(㎡)", numeric: true }, - { key: "500㎡以上", label: "500㎡以上" }, - { key: "種別", label: "種別" }, - { key: "南西部指定管理", label: "南西部指定管理" }, - { key: "トイレ", label: "トイレ", wide: true }, - { key: "水飲み場", label: "水飲み場" }, - { key: "ベンチ・椅子", label: "ベンチ・椅子", wide: true }, - { key: "テーブル", label: "テーブル", wide: true }, - { key: "防災用具", label: "防災用具", wide: true }, - { key: "ボール遊び", label: "ボール遊び", wide: true }, - { key: "遊具", label: "遊具", wide: true }, - { key: "備考", label: "備考", wide: true }, -]; +// 種別の表示(色付きの丸+区分名。正式名は title / aria-label で補う)。 +const TYPE_LABEL: Record<string, string> = { + 街区: "街区公園", + 近隣: "近隣公園", + 運動: "運動公園", +}; +const TYPE_CLASS: Record<string, string> = { + 街区: "parks-type--block", + 近隣: "parks-type--neighbor", + 運動: "parks-type--sports", +}; + +// 設備バッジ(アイコンは CSS の ::before で描く)。 +type Badge = { kind: string; label: string; title: string }; +function facilityBadges(p: Park): Badge[] { + const badges: Badge[] = []; + if (p["トイレ"]) { + badges.push({ kind: "toilet", label: "トイレ", title: `トイレ: ${p["トイレ"]}` }); + } + if (p["水飲み場"]) { + badges.push({ kind: "water", label: "水飲み場", title: `水飲み場: ${p["水飲み場"]}` }); + } + if (p["遊具"]) { + badges.push({ kind: "toy", label: "遊具", title: `遊具: ${p["遊具"]}` }); + } + const ball = p["ボール遊び"]; + if (ball && ball !== "禁止") { + const label = ball.includes("一部") ? "ボール遊び一部可" : "ボール遊び可"; + badges.push({ kind: "ball", label, title: `ボール遊び: ${ball}` }); + } + return badges; +} -function display(key: string, value: string): string { - if (value === "" || value == null) return EMPTY; - if (key === "面積㎡") { - const n = Number(value); - if (Number.isFinite(n)) return n.toLocaleString("ja-JP"); +// 設備フィルタ用のトークン(data-fac)。 +function facTokens(p: Park): string { + const tokens: string[] = []; + if (p["トイレ"]) tokens.push("toilet"); + if (p["水飲み場"]) tokens.push("water"); + if (p["遊具"]) tokens.push("toy"); + const ball = p["ボール遊び"]; + if (ball && ball !== "禁止") tokens.push("ball"); + return tokens.join(" "); +} + +// 設備セルの補足テキスト(バッジで示した項目の詳細と、その他の項目)。 +const DETAIL_FIELDS: { key: string; label: string }[] = [ + { key: "ベンチ・椅子", label: "ベンチ・椅子" }, + { key: "テーブル", label: "テーブル" }, + { key: "防災用具", label: "防災用具" }, + { key: "遊具", label: "遊具" }, + { key: "備考", label: "備考" }, +]; +function detailText(p: Park): string { + const parts: string[] = []; + if (p["南西部指定管理"]) parts.push("南西部指定管理"); + for (const { key, label } of DETAIL_FIELDS) { + const value = p[key]; + if (!value) continue; + parts.push(key === "備考" ? value : `${label}: ${value}`); } - return value; + const ball = p["ボール遊び"]; + if (ball && ball !== "禁止" && ball !== "可") parts.push(`ボール遊び: ${ball}`); + return parts.join(" / "); } -// 検索対象(公園名・住所・町名・種別・ボール遊び・遊具・備考)。 +// 面積(数値) +function areaNumber(value: string): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} +function areaText(value: string): string { + const n = areaNumber(value); + return n ? n.toLocaleString("ja-JP") : value; +} +// 面積の目安(小・中・大)。しきい値はページの注記に合わせる。 +function sizeLabel(area: number): string { + if (area < 1000) return "小"; + if (area < 10000) return "中"; + return "大"; +} +function sizeTitle(area: number): string { + if (area < 1000) return "目安: 1,000㎡未満(小さめ)"; + if (area < 10000) return "目安: 1,000〜10,000㎡(ふつう)"; + return "目安: 10,000㎡以上(広め)"; +} + +// 住所ではなく公園名で検索する(地番は地図側で正しく解決されないため)。 +// 「小平市」を付けないと別の自治体の同名公園に飛ぶので必ず付ける。 +function mapsUrl(name: string): string { + const query = encodeURIComponent(`小平市${name}`); + return `https://www.google.com/maps/search/?api=1&query=${query}`; +} + +// 検索対象(公園名・住所・町名・種別・設備・備考)。 function searchIndex(p: Park): string { return [ p["公園名"], p["住所"], p["町名"], p["種別"], + p["トイレ"], + p["水飲み場"], p["ボール遊び"], - p["遊具"], - p["備考"], + ...DETAIL_FIELDS.map((f) => p[f.key]), ] .join(" ") .toLowerCase(); @@ -71,32 +140,85 @@ const total = parks.length; <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="ball" />ボール遊び</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> <div class="parks-scroll"> <table> <thead> <tr> - {columns.map((col) => ( - <th scope="col">{col.label}</th> - ))} + <th scope="col" aria-sort="none"> + <button type="button" class="parks-sort" data-sort="name"> + 公園名<span class="parks-sort-mark" aria-hidden="true"></span> + </button> + </th> + <th scope="col">住所</th> + <th scope="col" aria-sort="none"> + <button type="button" class="parks-sort" data-sort="area"> + 面積(㎡)<span class="parks-sort-mark" aria-hidden="true"></span> + </button> + </th> + <th scope="col">種別</th> + <th scope="col">施設・備考</th> </tr> </thead> <tbody> {parks.map((park) => ( - <tr data-town={park["町名"]} data-search={searchIndex(park)}> - {columns.map((col) => ( - <td - class:list={[ - col.numeric && "is-num", - col.wide && "is-wide", - park[col.key] === "" && "is-empty", - ]} - > - {display(col.key, park[col.key])} - </td> - ))} + <tr + data-town={park["町名"]} + data-area={park["面積㎡"]} + data-name={park["公園名"]} + data-fac={facTokens(park)} + data-search={searchIndex(park)} + > + <td class="is-name" data-label="公園名">{park["公園名"]}</td> + <td class="is-address" data-label="住所"> + <a + href={mapsUrl(park["公園名"])} + target="_blank" + rel="noopener" + title={`Googleマップで「${park["公園名"]}」を開く`} + >{park["住所"]}</a> + </td> + <td + class="is-num" + data-label="面積" + data-size={sizeLabel(areaNumber(park["面積㎡"]))} + title={sizeTitle(areaNumber(park["面積㎡"]))} + > + {areaText(park["面積㎡"])}㎡ + </td> + <td class="is-type" data-label="種別"> + {park["種別"] ? ( + <span + class:list={["parks-type", TYPE_CLASS[park["種別"]]]} + title={TYPE_LABEL[park["種別"]]} + aria-label={TYPE_LABEL[park["種別"]]} + > + {park["種別"]} + </span> + ) : ( + EMPTY + )} + </td> + <td class="is-facility" data-label="施設・備考"> + {facilityBadges(park).map((b) => ( + <span class:list={["fac-badge", `fac-${b.kind}`]} title={b.title} aria-label={b.title}> + {b.label} + </span> + ))} + {detailText(park) ? <span class="fac-detail">{detailText(park)}</span> : null} + {facilityBadges(park).length === 0 && !detailText(park) ? EMPTY : null} + </td> </tr> ))} </tbody> @@ -108,9 +230,9 @@ const total = parks.length; .parks-controls { display: flex; flex-wrap: wrap; - gap: 0.75rem 1rem; + gap: 0.6rem 1rem; align-items: center; - margin: 1rem 0; + margin: 1rem 0 0.4rem; } .parks-controls label { display: inline-flex; @@ -119,7 +241,7 @@ const total = parks.length; font-size: 0.9rem; } .parks-controls select, - .parks-controls input { + .parks-controls input[type="search"] { padding: 0.3rem 0.5rem; border: 1px solid var(--sl-color-hairline, #ccc); border-radius: 0.3rem; @@ -127,10 +249,41 @@ const total = parks.length; color: var(--sl-color-text, inherit); font: inherit; } + .parks-facilities { + display: flex; + flex-wrap: wrap; + gap: 0.3rem 0.8rem; + align-items: center; + margin: 0; + padding: 0.2rem 0.6rem; + border: 1px solid var(--sl-color-hairline, #ccc); + border-radius: 0.3rem; + } + .parks-facilities legend { + padding: 0 0.2rem; + font-size: 0.8em; + color: inherit; + opacity: 0.7; + } + .parks-facilities label { + font-size: 0.85rem; + } .parks-count { margin-left: auto; font-size: 0.9rem; - color: var(--sl-color-gray-3, #666); + opacity: 0.75; + } + .parks-hint, + .parks-none { + margin: 0 0 0.6rem; + font-size: 0.8rem; + opacity: 0.75; + } + .parks-none { + padding: 0.6rem; + border: 1px dashed var(--sl-color-hairline, #ccc); + border-radius: 0.3rem; + opacity: 1; } .parks-scroll { overflow-x: auto; @@ -166,19 +319,177 @@ const total = parks.length; .parks tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--sl-color-text, #333) 5%, transparent); } + .parks tr.is-hidden { + display: none; + } + .parks-sort { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + .parks-sort:hover .parks-sort-mark { + color: var(--sl-color-text-accent, #4c8); + } + .parks-sort-mark { + display: inline-block; + min-width: 1em; + text-align: center; + } + .parks .is-name { + font-weight: 600; + } + .parks .is-address { + font-size: 0.8em; + white-space: normal; + min-width: 9rem; + } + .parks .is-address a { + color: var(--sl-color-text-accent, #357); + } + .parks .is-address a::before { + content: "📍"; + margin-right: 0.2em; + } .parks .is-num { text-align: right; font-variant-numeric: tabular-nums; + white-space: nowrap; } - .parks .is-wide { - white-space: normal; - min-width: 11rem; - max-width: 20rem; + .parks .is-num::after { + content: attr(data-size); + display: inline-block; + margin-left: 0.4em; + padding: 0 0.45em; + border-radius: 0.7em; + font-size: 0.75em; + line-height: 1.7; + vertical-align: middle; + background: color-mix(in srgb, currentColor 14%, transparent); + } + .parks .is-num[data-size="中"]::after { + background: color-mix(in srgb, #2e9e4f 22%, transparent); + } + .parks .is-num[data-size="大"]::after { + background: color-mix(in srgb, #2f7fe0 24%, transparent); } - .parks .is-empty { - color: var(--sl-color-gray-4, #aaa); + .parks .is-type { text-align: center; } + .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; + } + .parks-type--block { + --type-color: #2e9e4f; + } + .parks-type--neighbor { + --type-color: #2f7fe0; + } + .parks-type--sports { + --type-color: #e8862a; + } + .parks .is-facility { + white-space: normal; + min-width: 12rem; + max-width: 26rem; + } + .parks .fac-badge { + display: inline-flex; + align-items: center; + gap: 0.25em; + margin: 0 0.3em 0.2em 0; + padding: 0 0.5em; + border-radius: 1em; + font-size: 0.8em; + line-height: 1.8; + white-space: nowrap; + background: color-mix(in srgb, currentColor 10%, transparent); + } + .parks .fac-toilet::before { + content: "🚻"; + } + .parks .fac-water::before { + content: "🚰"; + } + .parks .fac-toy::before { + content: "🎠"; + } + .parks .fac-ball::before { + content: "⚽"; + } + .parks .fac-detail { + display: block; + margin-top: 0.25em; + font-size: 0.8em; + 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 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; + } + } </style> <script> @@ -186,25 +497,73 @@ 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 rows = Array.from(root.querySelectorAll<HTMLTableRowElement>("tbody tr")); - const total = rows.length; + const none = root.querySelector("[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; + + const allRows = () => Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr")); + const total = allRows().length; - const apply = () => { + let sortKey: string | null = null; + let sortDir = 1; + + const applyFilter = () => { 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; - rows.forEach((row) => { + allRows().forEach((row) => { + const facs = (row.dataset.fac || "").split(" "); const okTown = !town || row.dataset.town === town; const okText = !query || (row.dataset.search || "").includes(query); - const ok = okTown && okText; - row.hidden = !ok; + const okFac = wanted.every((token) => facs.includes(token)); + const ok = okTown && okText && okFac; + row.classList.toggle("is-hidden", !ok); if (ok) shown += 1; }); if (count) count.textContent = `${shown} / ${total}件`; + if (none) none.hidden = shown !== 0; }; - select?.addEventListener("change", apply); - search?.addEventListener("input", apply); - apply(); + const applySort = () => { + sortButtons.forEach((btn) => { + const th = btn.closest("th"); + const active = btn.dataset.sort === sortKey; + if (th) { + th.setAttribute("aria-sort", active ? (sortDir === 1 ? "ascending" : "descending") : "none"); + } + const mark = btn.querySelector(".parks-sort-mark"); + if (mark) mark.textContent = active ? (sortDir === 1 ? "▲" : "▼") : ""; + }); + if (!sortKey) return; + const sorted = allRows().sort((a, b) => { + if (sortKey === "area") { + const av = Number.parseFloat(a.dataset.area || "") || 0; + const bv = Number.parseFloat(b.dataset.area || "") || 0; + return (av - bv) * sortDir; + } + return (a.dataset.name || "").localeCompare(b.dataset.name || "", "ja") * sortDir; + }); + sorted.forEach((row) => tbody.appendChild(row)); + }; + + select?.addEventListener("change", applyFilter); + search?.addEventListener("input", applyFilter); + checks.forEach((c) => c.addEventListener("change", applyFilter)); + sortButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.dataset.sort; + if (sortKey === key) { + sortDir = -sortDir; + } else { + sortKey = key ?? null; + sortDir = 1; + } + applySort(); + }); + }); + applyFilter(); }); </script> |
