From a01062f43d36112c00d5c007ac9d488c14d8fb0d Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:24:24 +0900 Subject: parks: アイコン・フィルタ・編集画面を更新し、公開を一時停止 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 遊具アイコンをSVGRepo素材に差し替え、施設種別をアイコン表示 - フィルタに種類・遊具・ピクニックを追加し、適用時の地図移動を停止 - 現在地ボタンを追跡トグルに変更し、初回以外は地図を動かさない - 編集画面を市内公園・周辺公園・公共施設の4タブに拡張 - 公園ページをドラフト化し、サイドバーとPWAの公開を停止 --- astro.config.mjs | 6 +- package.json | 2 + public/kodaira-data/parks/LICENSE.txt | 9 + public/manifest.webmanifest | 6 +- public/sw.js | 13 +- scripts/make-park-qgis-kit.py | 65 ++++- scripts/park-editor.html | 254 ++++++++++++++---- scripts/park-editor.mjs | 106 ++++++-- scripts/update-nearby-facilities.py | 179 +++++++++++++ scripts/update-nearby-parks.py | 243 ++++++++++++++++- src/components/FacilitiesTable.astro | 70 ++++- src/components/ParkMap.astro | 446 ++++++++++++++++++++++++++------ src/components/ParkMapHelp.astro | 111 ++++++++ src/components/ParksFilters.astro | 184 +++++++++++++ src/components/ParksTable.astro | 260 ++++++++++--------- src/content/docs/kodaira-data/parks.mdx | 90 ++++--- src/data/kodaira-nearby-facilities.json | 306 ++++++++++++++++++++++ src/data/kodaira-nearby-parks.json | 2 +- src/data/park-icons.ts | 15 ++ src/data/parks-rows.ts | 141 ++++++++++ src/styles/custom.css | 62 +++++ 21 files changed, 2229 insertions(+), 341 deletions(-) create mode 100644 scripts/update-nearby-facilities.py create mode 100644 src/components/ParkMapHelp.astro create mode 100644 src/components/ParksFilters.astro create mode 100644 src/data/kodaira-nearby-facilities.json create mode 100644 src/data/park-icons.ts create mode 100644 src/data/parks-rows.ts diff --git a/astro.config.mjs b/astro.config.mjs index c93811b..07681ae 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -352,7 +352,8 @@ export default defineConfig({ label: "小平市のデータ", collapsed: true, items: [ - { label: "小平市の公園一覧", link: "/kodaira-data/parks/" }, + // 公園ページはドラフト公開停止中(公開時に戻す) + // { label: "公園さんぽマップ", link: "/kodaira-data/parks/" }, { label: "小平市ホームページのアーカイブ(暫定)等", collapsed: true, @@ -502,6 +503,8 @@ export default defineConfig({ content: "document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('[href=\"#_top\"] span').forEach(el => { if (el.textContent === 'トップへ戻る') el.textContent = ''; }); });", }, + // 公園ページはドラフト公開停止中のため、PWAのheadは無効化(公開時に戻す) + /* { tag: "link", attrs: { rel: "manifest", href: "/manifest.webmanifest" }, @@ -522,6 +525,7 @@ export default defineConfig({ tag: "meta", attrs: { name: "apple-mobile-web-app-title", content: "小平市公園" }, }, + */ ], components: { PageTitle: "./src/components/starlight/PageTitle.astro", diff --git a/package.json b/package.json index 0a06b50..4a57562 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "osm:update": "py -X utf8 scripts/update-osm-parks.py", "parks:kit": "py -X utf8 scripts/make-park-qgis-kit.py", "parks:import": "py -X utf8 scripts/import-manual-parks.py", + "parks:nearby": "py -X utf8 scripts/update-nearby-parks.py", + "parks:facilities": "py -X utf8 scripts/update-nearby-facilities.py", "preview": "astro preview", "postinstall": "patch-package", "astro": "astro" diff --git a/public/kodaira-data/parks/LICENSE.txt b/public/kodaira-data/parks/LICENSE.txt index 6149587..4422b13 100644 --- a/public/kodaira-data/parks/LICENSE.txt +++ b/public/kodaira-data/parks/LICENSE.txt @@ -8,3 +8,12 @@ https://creativecommons.org/licenses/by/4.0/deed.ja このポリゴンは正確な境界座標ではありません。参考としてご利用ください。 出典として「安竹洋平(https://yasutakeyohei.com/)」を明示すれば、自由に利用できます。 OSM由来のデータはOpenStreetMapのODbLライセンスです。 + +遊具アイコン: +SVGRepo(https://www.svgrepo.com/)の素材を利用しています。 +各素材の作者・ライセンスは SVGRepo の該当ページおよび +https://www.svgrepo.com/page/licensing/ をご確認ください。 + +使用素材(SVGRepo): +airplane-plane, climbing-bars, flying-fox, picnic, playground, +rocket-playground, shovel-repair, slide, soccer-ball, swing, teeter diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 91af084..06d85c6 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -1,8 +1,8 @@ { "id": "/kodaira-data/parks/", - "name": "小平市の公園一覧", - "short_name": "小平市公園", - "description": "小平市内の公園・公共施設・近隣公園を検索できる一覧", + "name": "こだいら 公園さんぽマップ", + "short_name": "公園さんぽ", + "description": "小平市と周辺の公園・公共施設を検索できる地図", "lang": "ja", "start_url": "/kodaira-data/parks/", "scope": "/", diff --git a/public/sw.js b/public/sw.js index 99af941..571f752 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,6 +1,7 @@ -const CACHE = "kodaira-parks-v1"; +// 公園ページはドラフト公開停止中のため、公園ページをAPP_SHELLから外し、 +// キャッシュを v2 に更新して旧キャッシュを削除する。公開時に元に戻す。 +const CACHE = "kodaira-parks-v2"; const APP_SHELL = [ - "/kodaira-data/parks/", "/manifest.webmanifest", "/icons/icon-192.png", "/icons/icon-512.png", @@ -34,7 +35,7 @@ self.addEventListener("fetch", (event) => { // OSMのタイルなど外部オリジンのものはキャッシュしない。 if (url.origin !== self.location.origin) return; - // ページ遷移はネットワーク優先。オフライン時はキャッシュ→公園ページ。 + // ページ遷移はネットワーク優先。オフライン時はキャッシュ。 if (request.mode === "navigate") { event.respondWith( fetch(request) @@ -43,11 +44,7 @@ self.addEventListener("fetch", (event) => { caches.open(CACHE).then((cache) => cache.put(request, copy)); return response; }) - .catch(() => - caches - .match(request) - .then((cached) => cached || caches.match("/kodaira-data/parks/")), - ), + .catch(() => caches.match(request)), ); return; } diff --git a/scripts/make-park-qgis-kit.py b/scripts/make-park-qgis-kit.py index 0df6d5a..b84630e 100644 --- a/scripts/make-park-qgis-kit.py +++ b/scripts/make-park-qgis-kit.py @@ -25,8 +25,10 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) OURS = os.path.join(ROOT, "src", "data", "kodaira-parks.json") OSM = os.path.join(ROOT, "src", "data", "kodaira-parks-osm.json") MANUAL = os.path.join(ROOT, "src", "data", "kodaira-parks-manual.json") +NEARBY = os.path.join(ROOT, "src", "data", "kodaira-nearby-parks.json") OUT_DIR = os.path.join(ROOT, "reference", "qgis") MANUAL_QGIS = os.path.join(OUT_DIR, "manual-parks.geojson") +NEARBY_MANUAL_QGIS = os.path.join(OUT_DIR, "nearby-parks-manual.geojson") if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") @@ -85,6 +87,7 @@ def main(): ours = load(OURS) osm = load(OSM) manual = load(MANUAL) + nearby = load(NEARBY) # QGISで描いたが、まだ取り込んでいないポリゴンの公園名 drawn_names = set() @@ -100,6 +103,19 @@ def main(): except (ValueError, OSError) as e: print(f" warning: {MANUAL_QGIS} を読めません: {e}", file=sys.stderr) + nearby_drawn_names = set() + if os.path.exists(NEARBY_MANUAL_QGIS): + try: + for feat in load(NEARBY_MANUAL_QGIS).get("features", []): + p = feat.get("properties") or {} + if not has_coords(feat.get("geometry")): + continue + name = str(p.get("our_name") or "").strip() + if name: + nearby_drawn_names.add(norm(name)) + except (ValueError, OSError) as e: + print(f" warning: {NEARBY_MANUAL_QGIS} を読めません: {e}", file=sys.stderr) + polygons = [] covered = set() osm_by_id = {} @@ -170,10 +186,42 @@ def main(): "drawn": norm(name) in drawn_names, "lat": lat, "lon": lon, + "source": "city", + }, + "geometry": {"type": "Point", "coordinates": [lon, lat]}, + } + ) + + # 市外の近隣公園のうち、まだポリゴンがないもの(点)も描画対象に含める。 + nearby_points = 0 + for feat in nearby.get("features", []): + geom = feat.get("geometry") or {} + if geom.get("type") != "Point": + continue + p = feat.get("properties") or {} + name = str(p.get("name") or "").strip() + if not name: + continue + lon, lat = geom["coordinates"][:2] + points.append( + { + "type": "Feature", + "properties": { + "no": len(points) + 1, + "our_name": name, + "address": f"{p.get('city') or '市外'}(市外)", + "kind": "市外の近隣公園", + "official_area_m2": p.get("area_m2", ""), + "drawn": norm(name) in nearby_drawn_names, + "lat": lat, + "lon": lon, + "source": "nearby", + "distance_m": p.get("distance_m", ""), }, "geometry": {"type": "Point", "coordinates": [lon, lat]}, } ) + nearby_points += 1 counts = { "covered": len(covered), @@ -181,6 +229,7 @@ def main(): "no_point": len(no_point), "already": len(already), "drawn": sum(1 for p in points if p["properties"]["drawn"]), + "nearby_points": nearby_points, } os.makedirs(OUT_DIR, exist_ok=True) @@ -193,6 +242,7 @@ def main(): - ポリゴン整備済み: {counts['covered']} 件 - OSM側に既にポリゴンあり(未リンク): {counts['already']} 件 - ポリゴン未整備: {counts['missing']} 件(うち手入力済み・未取り込み {counts['drawn']} 件、座標なし {counts['no_point']} 件) +- 市外の近隣公園(点): {counts['nearby_points']} 件 ## 1. QGIS に背景地図を追加 @@ -206,7 +256,7 @@ def main(): ## 2. 下地を読み込む -- `parks-without-polygon.geojson` … まだポリゴンがない公園(点)。`our_name` に公園名が入っています +- `parks-without-polygon.geojson` … まだポリゴンがない公園(点)。`our_name` に公園名が入っています。`source` が `city` なら小平市内、`nearby` なら市外の近隣公園です - `existing-parks.geojson` … 整備済みポリゴン。二重描き防止用 ## 3. ポリゴン用レイヤを作る @@ -225,6 +275,15 @@ def main(): > `our_name` を空欄にしても、取り込み時に最寄りの公園点から自動で判定します > (既定 60m 以内・曖昧でない場合のみ)。 +### 市外の近隣公園を描く場合 + +`parks-without-polygon.geojson` の `source` が `nearby` の点は、小平市外の近隣公園です。 +小平市内の公園とは別のファイルに描きます。 + +1. `reference/qgis/nearby-parks-manual.geojson` を新規作成(Polygon / EPSG:4326、`our_name` フィールド) +2. 公園の輪郭を描き、`our_name` に点の名前を入れる +3. `py -X utf8 scripts/update-nearby-parks.py` で取り込む + ## 4. 取り込む ```bash @@ -234,11 +293,12 @@ py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson `--dry-run` で公園名・面積・台帳面積との倍率・警告を確認してから本実行してください。 台帳面積の 0.7〜1.5 倍を外れると警告が出ます。 +市外の近隣公園は `py -X utf8 scripts/update-nearby-parks.py` で取り込みます。 取り込み後に `npm run build` で地図と表を確認します。 ## 進捗の管理 -`parks-without-polygon.geojson` の `drawn` は、`manual-parks.geojson` に同じ `our_name` のポリゴンがあると `true` になります。 +`parks-without-polygon.geojson` の `drawn` は、`manual-parks.geojson` または `nearby-parks-manual.geojson` に同じ `our_name` のポリゴンがあると `true` になります。 - 属性テーブルで `drawn` を確認できます - スタイルを `drawn = true` で灰色にすると、残りが分かりやすくなります @@ -258,6 +318,7 @@ py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson print(f"ポリゴン整備済み: {counts['covered']} 件") print(f"OSM側に既にポリゴンあり(未リンク): {counts['already']} 件") print(f"ポリゴン未整備: {counts['missing']} 件(座標なし {counts['no_point']} 件)") + print(f"市外の近隣公園(点): {counts['nearby_points']} 件") if no_point: print("座標がないため地図に出せない公園(先に kodaira-parks.json の緯度・経度を埋める):") for n in no_point: diff --git a/scripts/park-editor.html b/scripts/park-editor.html index 8c519e9..868a510 100644 --- a/scripts/park-editor.html +++ b/scripts/park-editor.html @@ -3,17 +3,22 @@ -公園データ編集 +公園・施設データ編集 diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 1c75bee..e4f226b 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -10,22 +10,6 @@ role="region" aria-label="小平市の公園の位置(OpenStreetMap)" > - -

- 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園と公共施設は別レイヤーで表示し、右上のレイヤ切替でON/OFFできます。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © OpenStreetMap contributors -

@@ -94,6 +78,10 @@ background: #8e24aa; border-radius: 50%; } + .swatch-facility-nearby { + background: #fb8c00; + border-radius: 50%; + } .swatch-boundary { height: 0; border-top: 4px solid #6a1b9a; @@ -106,18 +94,46 @@ font-size: 0.8rem; } :global(.park-locate) { - width: 34px; - height: 34px; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; border: 0; + border-radius: 50%; background: #fff; color: #1e88e5; - font-size: 20px; - line-height: 1; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); cursor: pointer; } :global(.park-locate:hover) { background: #f0f7ff; } + :global(.park-locate.is-tracking) { + background: #1e88e5; + color: #fff; + animation: park-locate-pulse 1.8s infinite; + } + :global(.park-locate.is-tracking:hover) { + background: #1565c0; + } + @keyframes park-locate-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(30, 136, 229, 0.45); + } + 70% { + box-shadow: 0 0 0 9px rgba(30, 136, 229, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(30, 136, 229, 0); + } + } + :global(.park-locate svg) { + display: block; + width: 20px; + height: 20px; + } .park-map-note { margin-top: 0.4rem; color: var(--sl-color-gray-2); @@ -218,6 +234,122 @@ :global(.park-fac svg) { display: block; } + :global(.park-facility-marker) { + background: none; + border: 0; + } + :global(.park-facility-emoji) { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 22px; + height: 22px; + border: 2px solid #8e24aa; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + font-size: 13px; + line-height: 1; + } + :global(.park-facility-marker.is-nearby .park-facility-emoji) { + border-color: #fb8c00; + } + :global(.park-play-markers), + :global(.park-play-list) { + pointer-events: none; + } + :global(.park-play-list) { + display: inline-flex; + gap: 2px; + transform: translate(-50%, -50%) translateY(-24px); + } + :global(.park-play) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid currentColor; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + font-size: 9px; + font-weight: 700; + line-height: 1; + } + :global(.park-play-plane) { + color: #c62828; + } + :global(.park-play-swing) { + color: #ef6c00; + } + :global(.park-play-seesaw) { + color: #6d4c41; + } + :global(.park-play-spring) { + color: #d81b60; + } + :global(.park-play-slide) { + color: #2e7d32; + } + :global(.park-play-jungle) { + color: #6a1b9a; + } + :global(.park-play-sand) { + color: #3e2723; + } + :global(.park-play-sand svg *) { + vector-effect: non-scaling-stroke; + } + :global(.park-play-complex) { + color: #0277bd; + } + :global(.park-play-ball) { + color: #1b5e20; + } + :global(.park-play-rope) { + color: #00838f; + } + :global(.park-locate-marker) { + border: 0; + background: none; + } + :global(.park-locate-arrow) { + display: block; + transform-origin: 50% 50%; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); + } + .park-map-help { + margin-top: 0.4rem; + } + .park-map-help summary { + cursor: pointer; + color: var(--sl-color-text-accent); + font-size: 0.85rem; + } + .park-play-legend { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.8rem; + margin: 0.5rem 0 0; + color: var(--sl-color-gray-2); + font-size: 0.78rem; + } + .park-play-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + :global(.park-play svg) { + display: block; + width: 100%; + height: 100%; + } + :global(.park-play-swing svg) { + width: 70%; + height: 70%; + } /* StarlightのMarkdown余白ルールがLeafletの各ペインに margin-top:1rem を 付けて地図を下へずらすため、地図内のペインは余白なしにする。 */ .park-map :global(.leaflet-pane) { @@ -252,6 +384,8 @@ import boundary from "@/data/kodaira-boundary.json"; import nearbyData from "@/data/kodaira-nearby-parks.json"; import facilities from "@/data/kodaira-facilities.json"; + import nearbyFacilities from "@/data/kodaira-nearby-facilities.json"; + import { PARK_ICONS } from "@/data/park-icons"; import parks from "@/data/kodaira-parks.json"; const MATCHED_STYLE = { @@ -285,6 +419,13 @@ fillColor: "#42a5f5", fillOpacity: 0.25, }; + const NEARBY_POINT_STYLE = { + radius: 7, + color: "#0d47a1", + weight: 2, + fillColor: "#42a5f5", + fillOpacity: 0.85, + }; const NEARBY_SELECTED_STYLE = { color: "#0d47a1", weight: 3, @@ -365,7 +506,9 @@ if (name) nearbyInfoByName.set(name, p); }); const nearbyMapsQuery = (name: string, city = "") => - name ? (city ? `${city}${name}` : name) : ""; + name ? (city && !name.includes(city) ? `${city}${name}` : name) : ""; + const nearbyBaseStyle = (lyr: any) => + lyr._isPoint ? NEARBY_POINT_STYLE : NEARBY_STYLE; const parkDataByName = new Map(); (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p)); const ownerByName = new Map(); @@ -536,12 +679,17 @@ // 市外の近い公園(500m以内)は別レイヤーにする。 // 小平市内の公園と同じく、クリックで一覧を絞り込み、左下に情報を表示する。 const nearbyLayer = L.geoJSON(nearbyData as any, { - style: NEARBY_STYLE, + // 範囲が取得できない公園は点(丸)で表示する。 + pointToLayer: (_feature: any, latlng: any) => + L.circleMarker(latlng, NEARBY_POINT_STYLE), + style: (feature: any) => + feature?.geometry?.type === "Point" ? NEARBY_POINT_STYLE : NEARBY_STYLE, onEachFeature: (feature: any, lyr: any) => { const p = feature.properties || {}; const name = String(p.name || "").trim(); const city = String(p.city || "").trim(); const key = `${p.osm_type || ""}/${p.osm_id || ""}`; + lyr._isPoint = feature.geometry?.type === "Point"; if (name) nearbyEntries.push({ name, layer: lyr, city, key }); lyr.on("click", (ev: any) => { if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent); @@ -552,35 +700,51 @@ }, }).addTo(map); - // 地域センター・公民館・図書館・市役所支所。 + // 地域センター・公民館・図書館・市役所支所・文化施設。 + // 市外の近隣施設はオレンジで表示する。 const publicFacilityLayer = L.layerGroup(); - (facilities as any[]).forEach((f) => { - if (f.lat == null || f.lon == null) return; - L.circleMarker([f.lat, f.lon], { - radius: 6, - color: "#4a148c", - weight: 1.5, - fillColor: "#8e24aa", - fillOpacity: 0.85, - }) - .bindPopup( - `${f.name}
${f.type}
${f.hours || ""}${f.closed ? `
休: ${f.closed}` : ""}
トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`, - ) - .addTo(publicFacilityLayer); - }); + const facilityMarkers: { name: string; layer: any }[] = []; + const FACILITY_ICON: Record = { + 地域センター: "📍", + 公民館: "🏛", + 児童館: "🧒", + 図書館: "📚", + 市役所支所: "🏢", + 文化施設: "🎭", + }; + const setFacilityOpacity = (layer: any, show: boolean) => { + if (typeof layer.setOpacity === "function") { + layer.setOpacity(show ? 1 : 0.2); + } else if (typeof layer.setStyle === "function") { + layer.setStyle({ + opacity: show ? 1 : 0.15, + fillOpacity: show ? 0.85 : 0.1, + }); + } + }; + const addFacilities = (list: any[], nearby: boolean) => { + list.forEach((f) => { + if (f.lat == null || f.lon == null) return; + const icon = FACILITY_ICON[f.type] || ""; + const marker = L.marker([f.lat, f.lon], { + icon: L.divIcon({ + className: `park-facility-marker${nearby ? " is-nearby" : ""}`, + html: `${icon || "🏢"}`, + iconSize: [22, 22], + iconAnchor: [11, 11], + }), + }) + .bindPopup( + `${icon} ${f.name}${nearby ? "(市外)" : ""}
${f.type}
${f.hours || ""}${f.closed ? `
休: ${f.closed}` : ""}
トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`, + ) + .addTo(publicFacilityLayer); + facilityMarkers.push({ name: String(f.name || ""), layer: marker }); + }); + }; + addFacilities(facilities as any[], false); + addFacilities(nearbyFacilities as any[], true); publicFacilityLayer.addTo(map); - L.control - .layers( - null, - { - "市外の近い公園(500m以内)": nearbyLayer, - "公共施設": publicFacilityLayer, - }, - { collapsed: false, position: "topright" }, - ) - .addTo(map); - // 拡大時だけ表示する設備アイコン(トイレ・水飲み場) // 一覧と一致した公園のポリゴン内にだけ表示する。 const facilityLayer = L.layerGroup(); @@ -612,12 +776,76 @@ }).addTo(facilityLayer); }); + // 拡大時だけ表示する遊具アイコン。 + // 公園ポリゴンの中心に、遊具の種類を絵アイコンで表示する。 + const PLAY_DEFS = [ + { tokens: ["ヒコーキ", "飛行機"], key: "airplane", title: PARK_ICONS.airplane.title, cls: "park-play-plane", icon: PARK_ICONS.airplane.svg }, + { tokens: ["ジャングル"], key: "climbing", title: PARK_ICONS.climbing.title, cls: "park-play-jungle", icon: PARK_ICONS.climbing.svg }, + { tokens: ["ブランコ"], key: "swing", title: PARK_ICONS.swing.title, cls: "park-play-swing", icon: PARK_ICONS.swing.svg }, + { tokens: ["シーソー"], key: "seesaw", title: PARK_ICONS.teeter.title, cls: "park-play-seesaw", icon: PARK_ICONS.teeter.svg }, + { tokens: ["スプリング"], key: "spring", title: PARK_ICONS.spring.title, cls: "park-play-spring", icon: PARK_ICONS.spring.svg }, + { tokens: ["滑り台", "滑台"], key: "slide", title: PARK_ICONS.slide.title, cls: "park-play-slide", icon: PARK_ICONS.slide.svg }, + { tokens: ["ロープウェイ"], key: "flyingfox", title: PARK_ICONS.flyingfox.title, cls: "park-play-rope", icon: PARK_ICONS.flyingfox.svg }, + { tokens: ["砂場"], key: "sand", title: PARK_ICONS.sand.title, cls: "park-play-sand", icon: PARK_ICONS.sand.svg }, + { tokens: ["複合遊具"], key: "playground", title: PARK_ICONS.playground.title, cls: "park-play-complex", icon: PARK_ICONS.playground.svg }, + { tokens: [], key: "ball", title: PARK_ICONS.ball.title, cls: "park-play-ball", icon: PARK_ICONS.ball.svg }, + ]; + const playLegend = document.querySelector("[data-play-legend]"); + if (playLegend) { + playLegend.innerHTML = PLAY_DEFS.map( + (def) => + `${def.title}`, + ).join(""); + } + const playLayer = L.layerGroup(); + layersByName.forEach((lyr, name) => { + const p = parkDataByName.get(name); + if (!p) return; + const text = (p["遊具"] || "").trim(); + const ballText = (p["ボール遊び"] || "").trim(); + const hasBall = Boolean(ballText && ballText !== "禁止"); + if (!text && !hasBall) return; + const seen = new Set(); + const icons: string[] = []; + for (const def of PLAY_DEFS) { + const matched = + def.key === "ball" + ? hasBall + : def.tokens.some((token) => text.includes(token)); + if (!matched) continue; + if (seen.has(def.key)) continue; + // ヒコーキジャングルはジャングルジムの一種なので、飛があればジは出さない。 + if (def.key === "climbing" && seen.has("airplane")) continue; + seen.add(def.key); + icons.push( + `${def.icon}`, + ); + } + if (!icons.length) return; + const center = lyr.getBounds().getCenter(); + L.marker([center.lat, center.lng], { + icon: L.divIcon({ + className: "park-play-markers", + html: `${icons.join("")}`, + iconSize: null, + iconAnchor: [0, 0], + }), + riseOnHover: false, + interactive: false, + }).addTo(playLayer); + }); + const updateFacilities = () => { if (map.getZoom() >= 16) { if (!map.hasLayer(facilityLayer)) facilityLayer.addTo(map); } else if (map.hasLayer(facilityLayer)) { map.removeLayer(facilityLayer); } + if (map.getZoom() >= 17) { + if (!map.hasLayer(playLayer)) playLayer.addTo(map); + } else if (map.hasLayer(playLayer)) { + map.removeLayer(playLayer); + } }; map.on("zoomend", updateFacilities); updateFacilities(); @@ -646,7 +874,8 @@ if (key) { const nearbyHit = nearbyEntries.find((entry) => entry.key === key); if (nearbyHit) { - map.fitBounds(nearbyHit.layer.getBounds(), { padding: [40, 40], maxZoom: 17 }); + const group = L.featureGroup([nearbyHit.layer]); + map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 }); showSelected( name, name, @@ -702,7 +931,6 @@ const names = new Set(detail.names || []); const active = Boolean(detail.active); const fromMap = Boolean(detail.fromMap); - const visible: any[] = []; if (!fromMap && selectedBar) selectedBar.hidden = true; @@ -718,7 +946,12 @@ } }); nearbyEntries.forEach(({ name, layer: lyr }) => { - lyr.setStyle(name && names.has(name) ? NEARBY_SELECTED_STYLE : NEARBY_STYLE); + lyr.setStyle( + name && names.has(name) ? NEARBY_SELECTED_STYLE : nearbyBaseStyle(lyr), + ); + }); + facilityMarkers.forEach(({ layer }) => { + setFacilityOpacity(layer, true); }); return; } @@ -731,7 +964,6 @@ } if (name && names.has(name)) { lyr.setStyle(MATCHED_STYLE); - visible.push(lyr); } else { lyr.setStyle(DIM_STYLE); } @@ -739,26 +971,23 @@ nearbyEntries.forEach(({ name, layer: lyr }) => { if (!active) { - lyr.setStyle(NEARBY_STYLE); + lyr.setStyle(nearbyBaseStyle(lyr)); return; } if (name && names.has(name)) { - lyr.setStyle(NEARBY_STYLE); - visible.push(lyr); + lyr.setStyle(nearbyBaseStyle(lyr)); } else { lyr.setStyle(NEARBY_DIM_STYLE); } }); - if (active && visible.length && visible.length < 30) { - const bounds = L.featureGroup(visible).getBounds(); - if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }); - } - } + // 公共施設のマーカーも絞り込みに合わせて表示する。 + facilityMarkers.forEach(({ name, layer }) => { + setFacilityOpacity(layer, !active || names.has(name)); + }); }); - // 現在地表示(スマホでは自動で取得を試みる) + // 現在地表示(ボタン操作時のみ取得) const statusEl = document.querySelector("[data-locate-status]"); const setStatus = (msg: string) => { if (!statusEl) return; @@ -769,30 +998,72 @@ let locationMarker: any = null; let accuracyCircle: any = null; let watchId: number | null = null; + let tracking = false; + let firstFix = true; + let locateButton: HTMLButtonElement | null = null; + + const LOCATE_ICON = + ''; + const TRACKING_ICON = + ''; + + const updateLocateButton = () => { + if (!locateButton) return; + locateButton.classList.toggle("is-tracking", tracking); + locateButton.setAttribute("aria-pressed", tracking ? "true" : "false"); + const label = tracking ? "現在地の追跡を停止" : "現在地を表示"; + locateButton.title = label; + locateButton.setAttribute("aria-label", label); + locateButton.innerHTML = tracking ? TRACKING_ICON : LOCATE_ICON; + }; + + const stopTracking = () => { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId); + watchId = null; + } + tracking = false; + updateLocateButton(); + }; - const locate = (center = false) => { + const locate = () => { + if (tracking) { + stopTracking(); + setStatus("現在地の追跡を停止しました。"); + return; + } if (!navigator.geolocation) { setStatus("このブラウザでは現在地を取得できません。"); return; } setStatus("現在地を取得しています…"); + firstFix = true; const onSuccess = (pos: GeolocationPosition) => { const { latitude, longitude, accuracy } = pos.coords; if (!locationMarker) { - locationMarker = L.circleMarker([latitude, longitude], { - radius: 7, - color: "#ffffff", - weight: 2, - fillColor: "#1e88e5", - fillOpacity: 1, - }).bindPopup("現在地", { autoPan: false }); - locationMarker.on("add", () => { - locationMarker.getElement()?.setAttribute("data-locate", "true"); + const icon = L.divIcon({ + className: "park-locate-marker", + html: '', + iconSize: [24, 24], + iconAnchor: [12, 12], }); - locationMarker.addTo(map); + locationMarker = L.marker([latitude, longitude], { + icon, + interactive: false, + keyboard: false, + }).addTo(map); } else { locationMarker.setLatLng([latitude, longitude]); } + const heading = + typeof pos.coords.heading === "number" && + Number.isFinite(pos.coords.heading) + ? pos.coords.heading + : 0; + const arrow = locationMarker + .getElement() + ?.querySelector(".park-locate-arrow"); + if (arrow) arrow.style.transform = `rotate(${heading}deg)`; if (!accuracyCircle) { accuracyCircle = L.circle([latitude, longitude], { radius: Math.min(accuracy, 1000), @@ -807,13 +1078,21 @@ .setLatLng([latitude, longitude]) .setRadius(Math.min(accuracy, 1000)); } - setStatus(`現在地を表示しています(精度 約${Math.round(accuracy)}m)。`); - if (center) map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + if (firstFix) { + map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + firstFix = false; + } + setStatus( + `現在地を追跡中です(精度 約${Math.round(accuracy)}m)。もう一度押すと停止します。`, + ); }; const onError = () => { setStatus("現在地を取得できませんでした。位置情報の利用を許可してください。"); + stopTracking(); }; if (watchId !== null) navigator.geolocation.clearWatch(watchId); + tracking = true; + updateLocateButton(); watchId = navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 10000, @@ -823,23 +1102,22 @@ const LocateControl = L.Control.extend({ onAdd: () => { - const btn = L.DomUtil.create("button", "leaflet-bar park-locate"); + const btn = L.DomUtil.create("button", "park-locate") as HTMLButtonElement; btn.setAttribute("type", "button"); - btn.title = "現在地を表示"; - btn.setAttribute("aria-label", "現在地を表示"); - btn.innerHTML = ''; + locateButton = btn; L.DomEvent.disableClickPropagation(btn); - btn.addEventListener("click", () => locate(true)); + btn.addEventListener("click", () => locate()); + updateLocateButton(); return btn; }, }); new LocateControl({ position: "topright" }).addTo(map); - if (window.matchMedia("(max-width: 640px)").matches) { - locate(true); - } + // 位置情報の許可確認は、ボタン操作(ユーザー操作)のときだけ出す。 + // ページ読み込み時に自動で watchPosition を呼ぶと、ブラウザによっては + // 許可ダイアログが出ずに失敗することがあるため。 window.addEventListener("pagehide", () => { - if (watchId !== null) navigator.geolocation.clearWatch(watchId); + stopTracking(); }); // レイアウト確定後にサイズを再計算し、タイルを再配置する。 diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro new file mode 100644 index 0000000..26d9917 --- /dev/null +++ b/src/components/ParkMapHelp.astro @@ -0,0 +1,111 @@ +--- +// 地図の見方・凡例。地図と一覧表を近づけるため、一覧表の下に表示する。 +// 遊具アイコンの凡例は ParkMap.astro のスクリプトが [data-play-legend] に流し込む。 +--- + +
+ 地図の見方・凡例 + +
+

+ 公園の範囲を赤、市外の近い公園を青、公共施設を紫(市外はオレンジ)で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園のうち、範囲が取得できないものは丸で表示しています。トイレと水飲み場のアイコンは拡大すると表示されます。さらに拡大すると、遊具アイコン(上の凡例)が表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © OpenStreetMap contributors +

+
+ + diff --git a/src/components/ParksFilters.astro b/src/components/ParksFilters.astro new file mode 100644 index 0000000..bf65d6e --- /dev/null +++ b/src/components/ParksFilters.astro @@ -0,0 +1,184 @@ +--- +// 公園・公共施設の一覧を絞り込むコントロール。地図の上に表示する。 +// 実際の絞り込みは ParksTable.astro のスクリプトが行う。 +import { parks, towns, total } from "@/data/parks-rows"; +import { PARK_ICONS } from "@/data/park-icons"; + +const facilityTypes = [ + "図書館", + "公民館", + "児童館", + "地域センター", + "市役所支所", + "文化施設", +].filter((type) => + parks.some((p) => p["施設"] === "1" && p["種別"] === type), +); +--- + +
+ + + +
+ 設備・遊具で絞る + + + + + + + + + + + + + + +
+ {total}件 +
+ +

+ 情報には誤りが含まれる場合があります。最新の情報は各施設の公式ページでご確認ください。 +

+ + 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; - -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([ - ...(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 = { 団地内公園: "団地内公園", 児童遊園: "児童遊園", 市外: "市外の近隣公園", + 地域センター: "地域センター", + 公民館: "公民館", + 児童館: "児童館", + 図書館: "図書館", + 市役所支所: "市役所支所", + 文化施設: "文化施設", }; const TYPE_CLASS: Record = { 街区: "parks-type--block", @@ -98,6 +35,30 @@ const TYPE_CLASS: Record = { 団地内公園: "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 = { + 街区: "街", + 近隣: "近", + 運動: "運", + 広域: "広", + 広場: "場", + 緑地: "緑", + 団地内公園: "団", + 児童遊園: "児", + 市外: "外", + 地域センター: "📍", + 公民館: "🏛", + 児童館: "🧒", + 図書館: "📚", + 市役所支所: "🏢", + 文化施設: "🎭", }; // 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。 @@ -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; ---
-
- - -
- 設備・管理で絞る - - - - - - - -
- {total}件 -
-

設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。

- +
+ + + + + + + @@ -293,13 +255,14 @@ const total = parks.length; {parks.map((park) => ( -
住所
+ {mappedNames.has(park["公園名"]) ? ( + ) : park["公式URL"] ? ( + {park["公園名"]} ) : ( park["公園名"] )} @@ -344,7 +315,7 @@ const total = parks.length; title={TYPE_LABEL[park["種別"]]} aria-label={TYPE_LABEL[park["種別"]]} > - {park["種別"]} + ) : ( 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;