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 ++++- 4 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 scripts/fill-nearby-cities.py (limited to 'scripts') 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 @@

公園・施設データ編集

+ + + このブラウザのみ + @@ -49,16 +72,37 @@ diff --git a/scripts/update-nearby-facilities.py b/scripts/update-nearby-facilities.py index dcef5d6..2e25bc3 100644 --- a/scripts/update-nearby-facilities.py +++ b/scripts/update-nearby-facilities.py @@ -109,9 +109,27 @@ def address_of(tags): return city +def existing_cities(): + """前回の出力に手動で補完した city を保持する。""" + if not os.path.exists(OUT): + return {} + try: + data = json.load(open(OUT, encoding="utf-8")) + except (OSError, ValueError): + return {} + result = {} + for item in data: + key = (item.get("osm_type"), item.get("osm_id")) + city = (item.get("city") or "").strip() + if city: + result[key] = city + return result + + def main(): print("Overpass API から近隣施設を取得中...") elements = fetch_overpass() + city_by_key = existing_cities() np = load_module(os.path.join(ROOT, "scripts", "update-nearby-parks.py"), "nearby_parks") boundary_ll = np.load_boundary() @@ -160,7 +178,8 @@ def main(): "lat": lat, "lon": lon, "geocode": "OSM", - "city": (tags.get("addr:city") or "").strip(), + "city": (tags.get("addr:city") or "").strip() + or city_by_key.get((el.get("type"), el.get("id")), ""), "distance_m": int(round(distance)), "osm_type": el.get("type"), "osm_id": el.get("id"), diff --git a/scripts/update-nearby-parks.py b/scripts/update-nearby-parks.py index ef30f53..9f30e00 100644 --- a/scripts/update-nearby-parks.py +++ b/scripts/update-nearby-parks.py @@ -323,6 +323,42 @@ def existing_keys(): } +def existing_cities(): + """前回の出力に手動で補完した city を保持する。""" + if not os.path.exists(OUT): + return {} + try: + data = json.load(open(OUT, encoding="utf-8")) + except (OSError, ValueError): + return {} + result = {} + for feature in data.get("features", []): + props = feature.get("properties") or {} + key = (props.get("osm_type"), props.get("osm_id")) + city = (props.get("city") or "").strip() + if city: + result[key] = city + return result + + +def existing_clocks(): + """前回の出力に手動で入れた時計情報を保持する。""" + if not os.path.exists(OUT): + return {} + try: + data = json.load(open(OUT, encoding="utf-8")) + except (OSError, ValueError): + return {} + result = {} + for feature in data.get("features", []): + props = feature.get("properties") or {} + key = (props.get("osm_type"), props.get("osm_id")) + clock = (props.get("時計") or "").strip() + if clock: + result[key] = clock + return result + + def exclusion_polygons(): """近隣公園から除外する大きな公園のポリゴン(経緯度)。 @@ -483,6 +519,8 @@ def main(): boundary = BoundaryIndex(boundary_xy) skip = existing_keys() + city_by_key = existing_cities() + clock_by_key = existing_clocks() exclusions = exclusion_polygons() features = [] skipped_inside = 0 @@ -555,9 +593,11 @@ def main(): "area_m2": int(round(area)), "osm_type": el.get("type"), "osm_id": el.get("id"), - "city": (tags.get("addr:city") or "").strip(), + "city": (tags.get("addr:city") or "").strip() + or city_by_key.get((el.get("type"), el.get("id")), ""), "note": NOTE, "name_source": name_source, + "時計": clock_by_key.get((el.get("type"), el.get("id")), ""), }, "geometry": geojson_geometry(polygons_ll), } @@ -579,6 +619,7 @@ def main(): "city": manual["city"], "note": manual["note"], "name_source": "manual", + "時計": clock_by_key.get((manual["osm_type"], manual["osm_id"]), ""), }, "geometry": { "type": "Point", -- cgit v1.3.1