diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/ParksTable.astro | 479 | ||||
| -rw-r--r-- | src/content/docs/kodaira-data/parks.mdx | 14 | ||||
| -rw-r--r-- | src/data/updates.json | 14 |
3 files changed, 434 insertions, 73 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> diff --git a/src/content/docs/kodaira-data/parks.mdx b/src/content/docs/kodaira-data/parks.mdx index 6cf9576..6887653 100644 --- a/src/content/docs/kodaira-data/parks.mdx +++ b/src/content/docs/kodaira-data/parks.mdx @@ -1,11 +1,11 @@ --- title: 小平市の公園一覧 -description: 小平市内の公園321件を、町名やキーワードで検索できる一覧にまとめました。 +description: 小平市内の公園321件を、町名・キーワード・設備で検索できる一覧にまとめました。 --- import ParksTable from '@/components/ParksTable.astro'; -小平市内の公園の一覧です。町名で絞り込んだり、公園名や住所、遊具などのキーワードで検索したりできます。スマートフォンでは表を横にスクロールできます。 +小平市内の公園の一覧です。町名や設備で絞り込んだり、公園名や住所、遊具などのキーワードで検索したりできます。公園名と面積の見出しを押すと、並べ替えられます。住所を押すと、公園名で地図を検索します。スマートフォンでは1件ずつカードで表示します。 <ParksTable /> @@ -13,12 +13,14 @@ import ParksTable from '@/components/ParksTable.astro'; 出典は、小平市が公開している次の資料です。 -- 公園名・住所・面積・500㎡以上・ボール遊び:「500㎡以上・未満の小平市立公園一覧(住所順)」(令和6年1月1日現在) +- 公園名・住所・面積・ボール遊び:「500㎡以上・未満の小平市立公園一覧(住所順)」(令和6年1月1日現在) - 種別・南西部指定管理:「別紙7 公園一覧」(令和6年度 指定管理者公募) - トイレ・水飲み場・ベンチ・テーブル・防災用具・遊具:「参考資料7 公園施設一覧(市南西部地域)」(令和6年度 指定管理者公募、公園台帳より) :::note -- 施設の情報(トイレ・水飲み場・ベンチ・テーブル・防災用具・遊具)は、市の南西部地域の公園のみです。全市分は公開されていません。 -- 施設の情報は「竣工時のデータが基」との注記があります。現在と異なる場合があります。 -- 空欄は「—」で示しています。出典に記載がないという意味であり、施設がないという意味ではありません。 +- 「種別」は、市の資料にある公園の区分です。街区は街区公園、近隣は近隣公園、運動は運動公園を指します。 +- 面積の「小・中・大」は目安です。小は1000㎡未満、中は1000〜10000㎡、大は10000㎡以上としています。 +- 「種別」と設備(トイレ・水飲み場・遊具など)の情報は、市の南西部地域の公園のみです。全市分は公開されていません。設備の絞り込みは、この情報がある公園だけが対象です。 +- 設備の情報は「竣工時のデータが基」との注記があります。現在と異なる場合があります。 +- 空欄は「—」で示しています。出典に記載がないという意味であり、設備がないという意味ではありません。 ::: diff --git a/src/data/updates.json b/src/data/updates.json index fc72608..2eabc28 100644 --- a/src/data/updates.json +++ b/src/data/updates.json @@ -1,5 +1,12 @@ [ { + "title": "小平市の公園一覧", + "url": "/kodaira-data/parks/", + "group": "", + "date": "2026-10-06", + "kind": "new" + }, + { "title": "(1)カーボンニュートラルの虚実を、まず科学的に捉えよ", "url": "/ippan-situmon/r2d/3gatu/1-carbon-neutral-giman/", "group": "令和2年3月定例会", @@ -271,12 +278,5 @@ "group": "令和8年9月定例会", "date": "2026-09-15", "kind": "new" - }, - { - "title": "(3)いじめ対策委員会記録がほぼ皆無だった件に絡む矛盾について", - "url": "/ippan-situmon/r8d/9gatu/3-ijime-taisaku-kiroku-mujun/", - "group": "令和8年9月定例会", - "date": "2026-09-15", - "kind": "new" } ] |
