From fa73b6a9f36268986af68192f65978539f4d059c Mon Sep 17 00:00:00 2001
From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>
Date: Sun, 25 Oct 2026 21:29:16 +0900
Subject: parks: シェルター等の統合と編集画面の刷新
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
パーゴラ・東屋を「シェルター等」に統合し、編集画面に確認チェック・遊具別入力・時計・選択行ハイライトを追加した。周辺公園と周辺施設の自治体名を補完し、公園データの文字化けを修正した。
---
scripts/fill-nearby-cities.py | 129 +++++++++++++
scripts/park-editor.html | 311 +++++++++++++++++++++++++++++---
scripts/update-nearby-facilities.py | 21 ++-
scripts/update-nearby-parks.py | 43 ++++-
src/components/FacilitiesTable.astro | 2 +-
src/components/ParkMap.astro | 18 +-
src/components/ParkMapHelp.astro | 8 +-
src/components/ParksFilters.astro | 1 +
src/components/ParksTable.astro | 12 +-
src/content/docs/kodaira-data/parks.mdx | 2 +-
src/data/kodaira-nearby-facilities.json | 20 +-
src/data/kodaira-nearby-parks.json | 2 +-
src/data/kodaira-parks.json | 2 +-
src/data/parks-rows.ts | 15 +-
14 files changed, 530 insertions(+), 56 deletions(-)
create mode 100644 scripts/fill-nearby-cities.py
diff --git a/scripts/fill-nearby-cities.py b/scripts/fill-nearby-cities.py
new file mode 100644
index 0000000..9f2a087
--- /dev/null
+++ b/scripts/fill-nearby-cities.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""周辺公園・周辺施設の city が空のとき、Nominatimで市区町村を補完する。
+
+使い方:
+ py -X utf8 scripts/fill-nearby-cities.py
+
+- src/data/kodaira-nearby-parks.json の properties.city
+- src/data/kodaira-nearby-facilities.json の city
+
+既に city が入っている行は変更しない。Nominatimの利用ポリシーに従い、
+逆ジオコーディングは1リクエストあたり約1.1秒待つ。
+"""
+import json
+import os
+import re
+import time
+import urllib.parse
+import urllib.request
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+PARKS = os.path.join(ROOT, "src", "data", "kodaira-nearby-parks.json")
+FACILITIES = os.path.join(ROOT, "src", "data", "kodaira-nearby-facilities.json")
+UA = "yasutakeyohei.com nearby city fill (https://yasutakeyohei.com/)"
+
+
+def centroid(feature):
+ geom = feature.get("geometry") or {}
+ if geom.get("type") == "Point":
+ lon, lat = geom["coordinates"][:2]
+ return lat, lon
+ if geom.get("type") == "Polygon":
+ ring = geom.get("coordinates", [[]])[0]
+ elif geom.get("type") == "MultiPolygon":
+ ring = geom.get("coordinates", [[[]]])[0][0]
+ else:
+ return None, None
+ if not ring:
+ return None, None
+ lon = sum(p[0] for p in ring) / len(ring)
+ lat = sum(p[1] for p in ring) / len(ring)
+ return lat, lon
+
+
+def city_from_text(text):
+ text = text or ""
+ # 「東村山市」のように市区町村名の中に「村」が含まれるため、市→町→村の順で探す。
+ for suffix in ("市", "町", "村"):
+ match = re.search(r"(?:東京都)?([^\s0-9]+?" + suffix + r")", text)
+ if match:
+ return match.group(1)
+ return ""
+
+
+def reverse_city(lat, lon):
+ params = urllib.parse.urlencode(
+ {
+ "format": "jsonv2",
+ "lat": lat,
+ "lon": lon,
+ "zoom": 10,
+ "addressdetails": 1,
+ "accept-language": "ja",
+ }
+ )
+ url = "https://nominatim.openstreetmap.org/reverse?" + params
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
+ try:
+ with urllib.request.urlopen(req, timeout=30) as res:
+ data = json.load(res)
+ except Exception as exc: # noqa: BLE001
+ print(f" warning: reverse failed ({lat},{lon}): {exc}")
+ return ""
+ finally:
+ time.sleep(1.1)
+ address = data.get("address") or {}
+ for key in ("city", "town", "village", "municipality"):
+ value = (address.get(key) or "").strip()
+ if value:
+ return value
+ return ""
+
+
+def main():
+ parks_data = json.load(open(PARKS, encoding="utf-8"))
+ parks_changed = 0
+ for feature in parks_data.get("features", []):
+ props = feature.setdefault("properties", {})
+ if (props.get("city") or "").strip():
+ continue
+ lat, lon = centroid(feature)
+ if lat is None or lon is None:
+ continue
+ city = reverse_city(lat, lon)
+ if city:
+ props["city"] = city
+ parks_changed += 1
+ print(f" park: {props.get('name')} -> {city}")
+
+ if parks_changed:
+ with open(PARKS, "w", encoding="utf-8") as fh:
+ json.dump(parks_data, fh, ensure_ascii=False, separators=(",", ":"))
+ fh.write("\n")
+
+ facilities = json.load(open(FACILITIES, encoding="utf-8"))
+ facilities_changed = 0
+ for item in facilities:
+ if (item.get("city") or "").strip():
+ continue
+ city = city_from_text(item.get("address", "")) or city_from_text(item.get("name", ""))
+ if not city:
+ lat = item.get("lat")
+ lon = item.get("lon")
+ if lat is not None and lon is not None:
+ city = reverse_city(lat, lon)
+ if city:
+ item["city"] = city
+ facilities_changed += 1
+ print(f" facility: {item.get('name')} -> {city}")
+
+ if facilities_changed:
+ with open(FACILITIES, "w", encoding="utf-8") as fh:
+ json.dump(facilities, fh, ensure_ascii=False, indent=2)
+ fh.write("\n")
+
+ print(f"updated parks {parks_changed}, facilities {facilities_changed}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/park-editor.html b/scripts/park-editor.html
index 868a510..32e91c4 100644
--- a/scripts/park-editor.html
+++ b/scripts/park-editor.html
@@ -23,11 +23,26 @@
th, td { border: 1px solid #9994; padding: 0; }
th { position: sticky; top: 0; background: Canvas; padding: .25rem .4rem; white-space: nowrap; z-index: 5; }
td { padding: 0; }
- td input { width: 100%; box-sizing: border-box; border: none; padding: .25rem .35rem; font: inherit; background: transparent; color: inherit; }
- td input:focus { outline: 2px solid #3b82f6; }
+ td input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; border: none; padding: .25rem .35rem; font: inherit; background: transparent; color: inherit; }
+ td input:not([type="checkbox"]):focus { outline: 2px solid #3b82f6; }
tr.dirty td { background: #ff9d0033; }
+ tbody tr.is-focused td { background: #3b82f622 !important; }
+ tbody tr.is-focused td.rownum { color: #1d4ed8; font-weight: 700; opacity: 1; }
+ tbody tr.is-focused td:first-child { box-shadow: inset 3px 0 0 #2563eb; }
+ tbody tr.is-focused td:last-child { box-shadow: inset -3px 0 0 #2563eb; }
+ tbody tr.is-focused.dirty td.rownum::after {
+ content: " ●";
+ color: #f59e0b;
+ font-size: 0.8em;
+ }
tr[hidden] { display: none; }
td.rownum { padding: 0 .4rem; text-align: right; opacity: .5; }
+ td.checkcell { padding: 0 .35rem; text-align: center; white-space: nowrap; }
+ td.checkcell input[type="checkbox"] { width: auto; height: auto; margin: 0; padding: 0; }
+ #check-filter { padding: .3rem .4rem; }
+ .checked-count { font-size: .82rem; opacity: .85; }
+ .check-note { font-size: .72rem; opacity: .6; }
+ #clear-checks { padding: .3rem .6rem; }
td.mapcell { text-align: center; }
td.mapcell a { text-decoration: none; padding: 0 .4rem; cursor: pointer; }
@@ -38,6 +53,14 @@
();
@@ -555,6 +561,7 @@
osmName && osmName !== ourName ? `OSM: ${osmName}` : "";
// 表と同じく、公園名で検索する。名前がない場合だけ住所・座標を使う。
const rec = parkDataByName.get(ourName);
+ const fixed = rec?.["地図URL"];
const name = (ourName || "").trim();
const query = mapsQuery || (name
? `小平市${name}`
@@ -563,7 +570,8 @@
: rec?.["緯度"] && rec?.["経度"]
? `${rec["緯度"]},${rec["経度"]}`
: "小平市");
- selectedMapsLink.href = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
+ selectedMapsLink.href =
+ fixed || `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
selectedTableLink.dataset.parkRow = encodeURIComponent(ourName);
selectedTableLink.hidden = !listed;
if (selectedNoteEl) selectedNoteEl.hidden = listed;
@@ -762,6 +770,12 @@
'',
);
}
+ const clock = (p["時計"] || "").trim();
+ if (clock === "○" || clock === "あり" || clock === "有") {
+ icons.push(
+ '⏰',
+ );
+ }
if (!icons.length) return;
const center = lyr.getBounds().getCenter();
L.marker([center.lat, center.lng], {
diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro
index 26d9917..61156c1 100644
--- a/src/components/ParkMapHelp.astro
+++ b/src/components/ParkMapHelp.astro
@@ -14,10 +14,11 @@
小平市の境界
WCトイレ
水飲み場(拡大時)
+ ⏰時計(拡大時)
- 公園の範囲を赤、市外の近い公園を青、公共施設を紫(市外はオレンジ)で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園のうち、範囲が取得できないものは丸で表示しています。トイレと水飲み場のアイコンは拡大すると表示されます。さらに拡大すると、遊具アイコン(上の凡例)が表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © OpenStreetMap
ピクニック・広場
+