aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-24 12:29:24 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-24 12:29:24 +0900
commita71727538f65ab4f10f5239550be82590f9d47d1 (patch)
tree4608d37faead4f253c3311982a400a8fb88fba9e /scripts
parentc7efe674b34a2949b76d62bb71a972427965af92 (diff)
parks: 公園一覧と地図、公共施設の情報を更新
- 近隣公園のクリックを市内公園と同じ挙動にし、Googleマップを公園名で開く - 公共施設のトイレを○にし、ルネこだいらを追加 - 未取得だった施設の座標を追加し、再ジオコーディング時の上書きを防止 - 手入力ポリゴン、近隣公園、PWA、ライセンス表示を追加
Diffstat (limited to 'scripts')
-rw-r--r--scripts/geocode-facilities.py86
-rw-r--r--scripts/import-manual-parks.py445
-rw-r--r--scripts/make-park-qgis-kit.py278
-rw-r--r--scripts/update-nearby-parks.py378
-rw-r--r--scripts/update-osm-parks.py266
5 files changed, 1431 insertions, 22 deletions
diff --git a/scripts/geocode-facilities.py b/scripts/geocode-facilities.py
new file mode 100644
index 0000000..12c727c
--- /dev/null
+++ b/scripts/geocode-facilities.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""小平市の公共施設JSONに緯度・経度を付与する(Nominatim)。
+
+使い方:
+ py -X utf8 scripts/geocode-facilities.py
+
+- `src/data/kodaira-facilities.json` の各施設に lat / lon / geocode を追加する。
+- 住所で見つからない場合は「小平市 + 施設名」で再試行する。
+- 見つからない場合は lat / lon を null にし、note に理由を追記する。
+- Nominatimの利用ポリシーに従い、1リクエストあたり約1.1秒待つ。
+"""
+import json
+import time
+import urllib.parse
+import urllib.request
+
+PATH = "src/data/kodaira-facilities.json"
+UA = "yasutakeyohei.com facilities geocoder (park list)"
+
+# 住所・名称検索では正しい位置にならない施設。手動の座標を優先する。
+# 値は ((緯度, 経度), geocodeラベル)。
+MANUAL = {
+ "小平市役所(本庁)": ((35.7279025, 139.4774885), "手動補正"),
+ "西部出張所": ((35.735038, 139.461827), "小川西町図書館と同じ建物として補正"),
+ "小川分室": ((35.72604, 139.459319), "小川公民館と同じ建物として補正"),
+ "東部出張所": ((35.7279272, 139.5140803), "東部市民センターと同じ建物として補正"),
+ "喜平図書館": ((35.7173213, 139.4908793), "名称"),
+}
+
+
+def geocode(query):
+ url = (
+ "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&countrycodes=jp&q="
+ + urllib.parse.quote(query)
+ )
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
+ with urllib.request.urlopen(req, timeout=30) as r:
+ data = json.loads(r.read().decode("utf-8"))
+ if not data:
+ return None
+ return float(data[0]["lat"]), float(data[0]["lon"])
+
+
+def main():
+ data = json.load(open(PATH, encoding="utf-8"))
+ for i, f in enumerate(data, 1):
+ name = f.get("name", "")
+ address = f.get("address", "")
+ if name in MANUAL:
+ (lat, lon), label = MANUAL[name]
+ f["lat"], f["lon"] = lat, lon
+ f["geocode"] = label
+ f["note"] = str(f.get("note", "")).replace("地図の位置は未取得。", "").strip()
+ print(f"{i}/{len(data)} {name}: {lat:.6f},{lon:.6f} ({label})")
+ continue
+ found = None
+ source = ""
+ if address:
+ found = geocode("東京都" + address)
+ source = "住所"
+ if not found and name:
+ time.sleep(1.1)
+ found = geocode("小平市" + name)
+ source = "名称"
+ if found and found[0] is not None:
+ f["lat"], f["lon"] = found
+ f["geocode"] = source
+ f["note"] = str(f.get("note", "")).replace("地図の位置は未取得。", "").strip()
+ print(f"{i}/{len(data)} {name}: {found[0]:.6f},{found[1]:.6f} ({source})")
+ else:
+ f["lat"], f["lon"] = None, None
+ f["geocode"] = ""
+ note = str(f.get("note", "")).strip()
+ if "地図の位置は未取得。" not in note:
+ f["note"] = (note + " " if note else "") + "地図の位置は未取得。"
+ print(f"{i}/{len(data)} {name}: NOT FOUND")
+ time.sleep(1.1)
+ with open(PATH, "w", encoding="utf-8") as fh:
+ json.dump(data, fh, ensure_ascii=False, indent=2)
+ fh.write("\n")
+ ok = sum(1 for f in data if f.get("lat") is not None)
+ print(f"geocoded {ok} / {len(data)} -> {PATH}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/import-manual-parks.py b/scripts/import-manual-parks.py
new file mode 100644
index 0000000..7eb3f1c
--- /dev/null
+++ b/scripts/import-manual-parks.py
@@ -0,0 +1,445 @@
+#!/usr/bin/env python3
+"""QGISで描いた公園ポリゴン(GeoJSON)を kodaira-parks-manual.json に取り込む。
+
+使い方:
+ py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson --dry-run
+ py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson
+
+主な仕様:
+ - 公園名は properties の `our_name` を優先。なければ `name`、それもなければ
+ 最寄りの市の公園(既定60m以内・曖昧でない場合)から自動判定する
+ - 1つの公園を複数ポリゴンに分けた場合は、同じ `our_name` で複数Featureを入れる
+ - 同じ公園名の既存Featureは置き換える(追記したい場合は --append)
+ - 面積が --min-area 未満のポリゴンは下書きの消し忘れとみなし、取り込まない
+ - OSM側に既にポリゴンがある公園名は、二重描画を防ぐため既定で拒否(--force で許可)
+"""
+
+import argparse
+import difflib
+import json
+import math
+import os
+import sys
+import unicodedata
+
+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")
+DEFAULT_INPUT = os.path.join(ROOT, "reference", "qgis", "manual-parks.geojson")
+
+ID_BASE = 900000
+
+# 描いた面積と台帳面積の倍率。この範囲を外れたら警告する
+AREA_RATIO_WARN_LOW = 0.7
+AREA_RATIO_WARN_HIGH = 1.5
+
+# 小平市の範囲(余裕を持たせた bounding box)
+LAT_MIN, LAT_MAX = 35.66, 35.79
+LON_MIN, LON_MAX = 139.40, 139.55
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+
+def norm(s):
+ s = "".join(unicodedata.normalize("NFKC", str(s or "")).split())
+ return s.replace("ヶ", "ケ").replace("ツ", "ッ")
+
+
+def load(path):
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def rings(geom):
+ """外周リングの一覧。空のジオメトリでも落ちないようにする。"""
+ if not geom:
+ return []
+ coords = geom.get("coordinates") or []
+ if geom.get("type") == "Polygon":
+ return [coords[0]] if coords and coords[0] else []
+ if geom.get("type") == "MultiPolygon":
+ return [poly[0] for poly in coords if poly and poly[0]]
+ return []
+
+
+def close_ring(ring):
+ """最初と最後の点が一致していなければ閉じる。(ring, 閉じたか)"""
+ if not ring:
+ return ring, False
+ if ring[0] != ring[-1]:
+ return ring + [ring[0]], True
+ return ring, False
+
+
+def area_m2(geom):
+ total = 0.0
+ for ring in rings(geom):
+ if len(ring) < 4:
+ continue
+ lat0 = sum(p[1] for p in ring) / len(ring)
+ kx = 111320 * math.cos(math.radians(lat0))
+ ky = 111320
+ s = 0.0
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:]):
+ s += (x1 * kx) * (y2 * ky) - (x2 * kx) * (y1 * ky)
+ total += abs(s) / 2
+ return total
+
+
+def ring_area_centroid(ring):
+ a = cx = cy = 0.0
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:]):
+ cross = x1 * y2 - x2 * y1
+ a += cross
+ cx += (x1 + x2) * cross
+ cy += (y1 + y2) * cross
+ if a == 0:
+ return 0.0, ring[0][0], ring[0][1]
+ a /= 2
+ return abs(a), cx / (6 * a), cy / (6 * a)
+
+
+def centroid(geom):
+ """面積で重み付けした重心。退化している場合は最初の点を返す。"""
+ parts = []
+ for ring in rings(geom):
+ area, cx, cy = ring_area_centroid(ring)
+ parts.append((area, cx, cy))
+ total = sum(p[0] for p in parts)
+ if total == 0:
+ pts = [p for ring in rings(geom) for p in ring]
+ if not pts:
+ return None, None
+ return pts[0][1], pts[0][0]
+ return sum(a * cy for a, _, cy in parts) / total, sum(a * cx for a, cx, _ in parts) / total
+
+
+def dist_m(lat1, lon1, lat2, lon2):
+ dy = (lat1 - lat2) * 111320
+ dx = (lon1 - lon2) * 111320 * math.cos(math.radians((lat1 + lat2) / 2))
+ return math.hypot(dx, dy)
+
+
+def nearest_park(lat, lon, parks, max_distance):
+ cand = sorted(
+ ((dist_m(lat, lon, q["lat"], q["lon"]), q["name"]) for q in parks),
+ key=lambda x: x[0],
+ )
+ if not cand:
+ return None, "公園一覧に座標がありません"
+ d1, n1 = cand[0]
+ if d1 > max_distance:
+ return None, f"最寄りの公園 {n1} まで {d1:.0f}m(--max-distance {max_distance:.0f}m 超)"
+ if len(cand) > 1:
+ d2, n2 = cand[1]
+ if d2 - d1 < 20 and d2 < d1 * 1.8:
+ return None, f"{n1} {d1:.0f}m と {n2} {d2:.0f}m が近く、判別できません"
+ return n1, ""
+
+
+def resolve_name(props, lat, lon, parks, by_norm, max_distance, use_auto):
+ """(公園名, 判定方法, 補足メッセージ) を返す。判定できなければ (None, ...)。"""
+ raw = str(props.get("our_name") or "").strip()
+ if raw:
+ if norm(raw) in by_norm:
+ return by_norm[norm(raw)], "our_name", ""
+ close = difflib.get_close_matches(norm(raw), list(by_norm), n=1, cutoff=0.8)
+ hint = f"(候補: {by_norm[close[0]]})" if close else ""
+ return None, "our_name", f"一覧にない公園名: {raw}{hint}"
+
+ raw = str(props.get("name") or "").strip()
+ if raw:
+ if norm(raw) in by_norm:
+ return by_norm[norm(raw)], "name", ""
+ close = difflib.get_close_matches(norm(raw), list(by_norm), n=1, cutoff=0.9)
+ if close:
+ return by_norm[close[0]], "name(類似)", ""
+ return None, "name", f"一覧にない公園名: {raw}"
+
+ if use_auto and lat is not None:
+ name, why = nearest_park(lat, lon, parks, max_distance)
+ if name:
+ return name, "最寄り", ""
+ return None, "最寄り", why
+ return None, "-", "our_name / name がなく、自動判定も無効"
+
+
+def round_coords(coords, digits=7):
+ if isinstance(coords, (int, float)):
+ return round(coords, digits)
+ return [round_coords(c, digits) for c in coords]
+
+
+def iter_features(obj):
+ if obj.get("type") == "FeatureCollection":
+ return obj.get("features") or []
+ if obj.get("type") == "Feature":
+ return [obj]
+ raise SystemExit("GeoJSON の Feature / FeatureCollection ではありません")
+
+
+def main():
+ ap = argparse.ArgumentParser(description="QGISで描いた公園ポリゴンの取り込み")
+ ap.add_argument("input", nargs="?", default=DEFAULT_INPUT, help="取り込む GeoJSON")
+ ap.add_argument("--out", default=MANUAL, help="出力先(既定: src/data/kodaira-parks-manual.json)")
+ ap.add_argument("--dry-run", action="store_true", help="書き込まずに結果だけ表示")
+ ap.add_argument("--append", action="store_true", help="同名の既存Featureを残して追記する")
+ ap.add_argument("--allow-partial", action="store_true", help="判定できないFeatureがあっても、判定できた分だけ書き込む")
+ ap.add_argument("--no-auto-name", action="store_true", help="最寄り公園からの自動判定を使わない")
+ ap.add_argument("--max-distance", type=float, default=60.0, help="自動判定に使う最寄り公園までの距離(m)")
+ ap.add_argument("--min-area", type=float, default=20.0, help="これ未満の面積は下書きとみなす(㎡)")
+ ap.add_argument("--max-area", type=float, default=200000.0, help="これを超える面積は警告(㎡)")
+ ap.add_argument("--force", action="store_true", help="OSM側に既にポリゴンがある公園も取り込む")
+ ap.add_argument(
+ "--force-name",
+ action="append",
+ default=[],
+ metavar="公園名",
+ help="指定した公園だけ、OSM側にポリゴンがあっても取り込む(複数指定可)",
+ )
+ ap.add_argument("--update-parks", action="store_true", help="kodaira-parks.json の空の緯度・経度を重心で埋める")
+ args = ap.parse_args()
+ force_names = set(args.force_name or [])
+
+ if not os.path.exists(args.input):
+ sys.exit(f"入力が見つかりません: {args.input}")
+
+ ours = load(OURS)
+ osm = load(OSM)
+ manual = load(args.out) if os.path.exists(args.out) else {"type": "FeatureCollection", "features": []}
+
+ parks = []
+ by_norm = {}
+ official_areas = {}
+ for r in ours:
+ name = str(r.get("公園名") or "").strip()
+ if not name:
+ continue
+ by_norm[norm(name)] = name
+ try:
+ official_areas[name] = float(r.get("面積㎡") or "")
+ except ValueError:
+ pass
+ try:
+ parks.append({"name": name, "lat": float(r["緯度"]), "lon": float(r["経度"])})
+ except (KeyError, ValueError):
+ pass
+
+ osm_names = {
+ f["properties"].get("our_name")
+ for f in osm.get("features", [])
+ if f["properties"].get("matched") and f["properties"].get("our_name")
+ }
+ # OSM側のポリゴン面積(手入力と合わせて台帳面積と比べる)
+ osm_areas = {}
+ for f in osm.get("features", []):
+ p = f.get("properties", {})
+ name = p.get("our_name")
+ if p.get("matched") and name:
+ osm_areas[name] = osm_areas.get(name, 0.0) + area_m2(f["geometry"])
+ manual_features = manual.get("features") or []
+ manual_names = {f["properties"].get("our_name") for f in manual_features}
+
+ errors = []
+ warnings = []
+ resolved = [] # dict(name, method, geom, area, lat, lon, props)
+
+ for i, feat in enumerate(iter_features(load(args.input)), start=1):
+ props = feat.get("properties") or {}
+ geom = feat.get("geometry")
+ if not geom or geom.get("type") not in ("Polygon", "MultiPolygon"):
+ errors.append(f"#{i}: ポリゴンではありません({geom.get('type') if geom else 'geometry なし'})")
+ continue
+ if str(props.get("draft") or "").lower() in ("true", "1", "yes"):
+ errors.append(f"#{i}: draft:true のFeatureが残っています(下書きを削除するか属性を消してください)")
+ continue
+ if not geom.get("coordinates"):
+ errors.append(
+ f"#{i}: ジオメトリが空です(fid {props.get('fid', '?')}, "
+ f"{props.get('our_name', '名前なし')})。未完成のポリゴンを削除してください"
+ )
+ continue
+
+ # リングを閉じる
+ changed = 0
+ if geom["type"] == "Polygon":
+ new_rings = []
+ for idx, ring in enumerate(geom["coordinates"]):
+ fixed, did = close_ring(ring)
+ changed += did
+ new_rings.append(fixed)
+ geom["coordinates"] = new_rings
+ else:
+ new_polys = []
+ for poly in geom["coordinates"]:
+ new_rings = []
+ for ring in poly:
+ fixed, did = close_ring(ring)
+ changed += did
+ new_rings.append(fixed)
+ new_polys.append(new_rings)
+ geom["coordinates"] = new_polys
+ if changed:
+ warnings.append(f"#{i}: 閉じていなかったリングを{changed}個補正しました")
+
+ # 座標系のチェック
+ pts = [p for ring in rings(geom) for p in ring]
+ if not pts:
+ errors.append(f"#{i}: 座標が空です")
+ continue
+ if any(abs(p[0]) > 180 or abs(p[1]) > 90 for p in pts):
+ errors.append(f"#{i}: 緯度経度ではない座標です。EPSG:4326 で書き出してください")
+ continue
+ lat, lon = centroid(geom)
+ if not (LAT_MIN <= lat <= LAT_MAX and LON_MIN <= lon <= LON_MAX):
+ warnings.append(
+ f"#{i}: 重心が小平市の範囲外です ({lat:.5f}, {lon:.5f})。座標系を確認してください"
+ )
+
+ name, method, why = resolve_name(
+ props, lat, lon, parks, by_norm, args.max_distance, not args.no_auto_name
+ )
+ if not name:
+ errors.append(f"#{i}: 公園名を判定できません — {why}")
+ continue
+
+ area = area_m2(geom)
+ if area < args.min_area:
+ errors.append(
+ f"#{i}: {name} の面積が {area:.1f}㎡ です。下書きの消し忘れか、形状を確認してください"
+ )
+ continue
+ if area > args.max_area:
+ warnings.append(f"#{i}: {name} の面積が {area:.1f}㎡ と大きすぎます")
+ forced = args.force or name in force_names
+ if name in osm_names and name not in manual_names and not forced:
+ errors.append(
+ f"#{i}: {name} はOSM側に既にポリゴンがあります"
+ "(--force または --force-name で強制取り込み)"
+ )
+ continue
+
+ resolved.append(
+ {"name": name, "method": method, "geom": geom, "area": area, "lat": lat, "lon": lon}
+ )
+
+ if errors and not args.allow_partial:
+ print("取り込みを中止しました。次の点を直してください。\n")
+ for e in errors:
+ print(f" - {e}")
+ if warnings:
+ print("\n警告:")
+ for w in warnings:
+ print(f" - {w}")
+ sys.exit(1)
+ if errors and args.allow_partial:
+ print("次のFeatureは取り込みませんでした:\n")
+ for e in errors:
+ print(f" - {e}")
+ print()
+
+ # 公園名ごとにまとめる(複数ポリゴン可)
+ grouped = {}
+ for item in resolved:
+ grouped.setdefault(item["name"], []).append(item)
+
+ existing_ids = {}
+ for f in manual_features:
+ p = f["properties"]
+ try:
+ osm_id = int(p.get("osm_id"))
+ except (TypeError, ValueError):
+ continue
+ if p.get("our_name"):
+ existing_ids[p["our_name"]] = osm_id
+ next_id = max([ID_BASE, *existing_ids.values()]) + 1
+
+ new_features = []
+ for name, items in grouped.items():
+ osm_id = existing_ids.get(name)
+ if osm_id is None:
+ osm_id = next_id
+ next_id += 1
+ for item in items:
+ geom = item["geom"]
+ geom["coordinates"] = round_coords(geom["coordinates"])
+ new_features.append(
+ {
+ "type": "Feature",
+ "properties": {
+ "name": "",
+ "our_name": name,
+ "matched": True,
+ "osm_type": "manual",
+ "osm_id": osm_id,
+ "leisure": "park",
+ },
+ "geometry": geom,
+ }
+ )
+
+ touched = set(grouped)
+ if args.append:
+ kept = manual_features
+ else:
+ kept = [f for f in manual_features if f["properties"].get("our_name") not in touched]
+ merged = kept + new_features
+ merged.sort(key=lambda f: (f["properties"].get("osm_id") or 0))
+
+ print(f"取り込み: {len(new_features)} Feature / {len(grouped)} 公園")
+ print(f" 置き換え: {len(touched & manual_names)} 公園 / 新規: {len(touched - manual_names)} 公園")
+ for name, items in sorted(grouped.items()):
+ method = items[0]["method"]
+ area = sum(i["area"] for i in items)
+ official = official_areas.get(name)
+ osm_area = osm_areas.get(name, 0.0)
+ combined = area + osm_area
+ detail = ""
+ if official:
+ ratio = combined / official
+ extra = f" + OSM {osm_area:.0f}㎡ = {combined:.0f}㎡" if osm_area else ""
+ detail = f"、台帳 {official:.0f}㎡({ratio:.2f}倍{extra})"
+ if ratio < AREA_RATIO_WARN_LOW or ratio > AREA_RATIO_WARN_HIGH:
+ warnings.append(
+ f"{name}: 面積が台帳の {ratio:.2f} 倍です({combined:.0f}㎡ / {official:.0f}㎡)。描き直しを検討してください"
+ )
+ print(f" - {name}: {len(items)}ポリゴン, {area:.0f}㎡{detail}, 判定={method}")
+ if warnings:
+ print("\n警告:")
+ for w in warnings:
+ print(f" - {w}")
+
+ if args.dry_run:
+ print("\n--dry-run のため書き込んでいません。")
+ return
+
+ with open(args.out, "w", encoding="utf-8") as f:
+ json.dump({"type": "FeatureCollection", "features": merged}, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+
+ if args.update_parks and grouped:
+ updated = 0
+ for r in ours:
+ name = r.get("公園名")
+ if name not in grouped:
+ continue
+ lat = grouped[name][0]["lat"]
+ lon = grouped[name][0]["lon"]
+ if not str(r.get("緯度") or "").strip():
+ r["緯度"] = f"{lat:.7f}"
+ updated += 1
+ if not str(r.get("経度") or "").strip():
+ r["経度"] = f"{lon:.7f}"
+ updated += 1
+ if updated:
+ with open(OURS, "w", encoding="utf-8") as f:
+ json.dump(ours, f, ensure_ascii=False, separators=(",", ":"))
+ print(f"kodaira-parks.json の緯度・経度を {updated} 項目更新しました")
+
+ print(f"\n書き込み: {args.out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/make-park-qgis-kit.py b/scripts/make-park-qgis-kit.py
new file mode 100644
index 0000000..0df6d5a
--- /dev/null
+++ b/scripts/make-park-qgis-kit.py
@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+"""QGISで公園ポリゴンを手描きするための下地データを出力する。
+
+使い方:
+ py -X utf8 scripts/make-park-qgis-kit.py
+
+出力(reference/ は .gitignore 対象):
+ reference/qgis/parks-without-polygon.geojson … ポリゴンが未整備の市の公園(点)
+ reference/qgis/existing-parks.geojson … 整備済みポリゴン(参考・重複防止)
+ reference/qgis/README.md … QGISでの手順
+
+描いたポリゴンは scripts/import-manual-parks.py で
+src/data/kodaira-parks-manual.json に取り込む。
+"""
+
+import argparse
+import json
+import math
+import os
+import re
+import sys
+import unicodedata
+
+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")
+OUT_DIR = os.path.join(ROOT, "reference", "qgis")
+MANUAL_QGIS = os.path.join(OUT_DIR, "manual-parks.geojson")
+
+if hasattr(sys.stdout, "reconfigure"):
+ sys.stdout.reconfigure(encoding="utf-8")
+
+
+def load(path):
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def norm(s):
+ s = "".join(unicodedata.normalize("NFKC", str(s or "")).split())
+ return s.replace("ヶ", "ケ").replace("ツ", "ッ")
+
+
+def has_coords(geom):
+ """空のポリゴン(QGISの未完成Feature)を弾く。"""
+ if not geom:
+ return False
+ coords = geom.get("coordinates") or []
+ if geom.get("type") == "Polygon":
+ return bool(coords and coords[0])
+ if geom.get("type") == "MultiPolygon":
+ return any(poly and poly[0] for poly in coords)
+ return False
+
+
+def center(geom):
+ if geom["type"] == "Polygon":
+ pts = geom["coordinates"][0]
+ else:
+ pts = [p for poly in geom["coordinates"] for p in poly[0]]
+ return (
+ (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2,
+ (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2,
+ )
+
+
+def dist_m(a, b):
+ dy = (a[0] - b[0]) * 111320
+ dx = (a[1] - b[1]) * 111320 * math.cos(math.radians((a[0] + b[0]) / 2))
+ return math.hypot(dx, dy)
+
+
+def write_geojson(path, features):
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump({"type": "FeatureCollection", "features": features}, f, ensure_ascii=False)
+ f.write("\n")
+
+
+def main():
+ ap = argparse.ArgumentParser(description="QGISで公園ポリゴンを手描きするための下地データを出力する")
+ ap.add_argument("--list", action="store_true", help="ポリゴン未整備の公園一覧も表示する")
+ args = ap.parse_args()
+
+ ours = load(OURS)
+ osm = load(OSM)
+ manual = load(MANUAL)
+
+ # QGISで描いたが、まだ取り込んでいないポリゴンの公園名
+ drawn_names = set()
+ if os.path.exists(MANUAL_QGIS):
+ try:
+ for feat in load(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:
+ drawn_names.add(norm(name))
+ except (ValueError, OSError) as e:
+ print(f" warning: {MANUAL_QGIS} を読めません: {e}", file=sys.stderr)
+
+ polygons = []
+ covered = set()
+ osm_by_id = {}
+ osm_unmatched_centers = []
+ for feat in osm.get("features", []):
+ p = feat.get("properties", {})
+ osm_by_id[int(p.get("osm_id") or 0)] = p.get("our_name") or ""
+ if p.get("matched") and p.get("our_name"):
+ covered.add(p["our_name"])
+ polygons.append(
+ {
+ "type": "Feature",
+ "properties": {
+ "our_name": p["our_name"],
+ "source": f"OSM {p.get('osm_type', '')}/{p.get('osm_id', '')}",
+ },
+ "geometry": feat["geometry"],
+ }
+ )
+ else:
+ osm_unmatched_centers.append((center(feat["geometry"]), p))
+ for feat in manual.get("features", []):
+ p = feat.get("properties", {})
+ if p.get("our_name"):
+ covered.add(p["our_name"])
+ polygons.append(
+ {
+ "type": "Feature",
+ "properties": {
+ "our_name": p["our_name"],
+ "source": f"manual {p.get('osm_id', '')}",
+ },
+ "geometry": feat["geometry"],
+ }
+ )
+
+ points = []
+ no_point = []
+ already = []
+ for r in ours:
+ name = r.get("公園名", "")
+ if not name or name in covered:
+ continue
+ # 「緑地(OSM 1234567)」は同じIDのポリゴンが既にある
+ m = re.match(r"^緑地(OSM (\d+))$", name)
+ if m and int(m.group(1)) in osm_by_id:
+ already.append(name)
+ continue
+ try:
+ lat = float(r.get("緯度") or "")
+ lon = float(r.get("経度") or "")
+ except ValueError:
+ no_point.append(name)
+ continue
+ # 名前未設定のOSMポリゴンが同じ場所にある場合も、二重描き防止のため除外
+ if any(dist_m((lat, lon), c) <= 20 for c, _ in osm_unmatched_centers):
+ already.append(name)
+ continue
+ points.append(
+ {
+ "type": "Feature",
+ "properties": {
+ "no": len(points) + 1,
+ "our_name": name,
+ "address": r.get("住所", ""),
+ "kind": r.get("種別", ""),
+ "official_area_m2": r.get("面積㎡", ""),
+ "drawn": norm(name) in drawn_names,
+ "lat": lat,
+ "lon": lon,
+ },
+ "geometry": {"type": "Point", "coordinates": [lon, lat]},
+ }
+ )
+
+ counts = {
+ "covered": len(covered),
+ "missing": len(points) + len(no_point),
+ "no_point": len(no_point),
+ "already": len(already),
+ "drawn": sum(1 for p in points if p["properties"]["drawn"]),
+ }
+
+ os.makedirs(OUT_DIR, exist_ok=True)
+ write_geojson(os.path.join(OUT_DIR, "parks-without-polygon.geojson"), points)
+ write_geojson(os.path.join(OUT_DIR, "existing-parks.geojson"), polygons)
+ readme = f"""# QGIS で公園ポリゴンを描く
+
+`scripts/make-park-qgis-kit.py` の生成物です。再生成すると上書きされます。
+
+- ポリゴン整備済み: {counts['covered']} 件
+- OSM側に既にポリゴンあり(未リンク): {counts['already']} 件
+- ポリゴン未整備: {counts['missing']} 件(うち手入力済み・未取り込み {counts['drawn']} 件、座標なし {counts['no_point']} 件)
+
+## 1. QGIS に背景地図を追加
+
+「レイヤ > レイヤを追加 > XYZ タイルを追加」で次を登録します。
+
+| 用途 | URL | 最大ズーム |
+|---|---|---|
+| 国土地理院 空中写真(推奨) | `https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{{z}}/{{x}}/{{y}}.jpg` | 18 |
+| 国土地理院 淡色地図 | `https://cyberjapandata.gsi.go.jp/xyz/pale/{{z}}/{{x}}/{{y}}.png` | 18 |
+| OpenStreetMap | `https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png` | 19 |
+
+## 2. 下地を読み込む
+
+- `parks-without-polygon.geojson` … まだポリゴンがない公園(点)。`our_name` に公園名が入っています
+- `existing-parks.geojson` … 整備済みポリゴン。二重描き防止用
+
+## 3. ポリゴン用レイヤを作る
+
+1. 「レイヤ > レイヤを作成 > 新しい GeoJSON レイヤを作成」
+2. ファイル名は `reference/qgis/manual-parks.geojson`、ジオメトリ型は **Polygon**、CRS は **EPSG:4326**
+3. フィールドを1つ追加: 名前 `our_name`、型 **文字列**
+4. 編集モードにして「ポリゴンを追加」で公園の輪郭を描く
+ - 点レイヤのラベルを次の式にすると、公園名と台帳面積が同時に出ます
+ `"our_name" || '\n' || "official_area_m2" || ' ㎡'`
+ - スナップを有効にすると描きやすい
+5. 属性入力欄に `parks-without-polygon.geojson` の `our_name` をそのまま入力
+ (1つの公園が複数ポリゴンに分かれる場合は、同じ `our_name` で複数描く)
+6. 編集を保存
+
+> `our_name` を空欄にしても、取り込み時に最寄りの公園点から自動で判定します
+> (既定 60m 以内・曖昧でない場合のみ)。
+
+## 4. 取り込む
+
+```bash
+py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson --dry-run
+py -X utf8 scripts/import-manual-parks.py reference/qgis/manual-parks.geojson
+```
+
+`--dry-run` で公園名・面積・台帳面積との倍率・警告を確認してから本実行してください。
+台帳面積の 0.7〜1.5 倍を外れると警告が出ます。
+取り込み後に `npm run build` で地図と表を確認します。
+
+## 進捗の管理
+
+`parks-without-polygon.geojson` の `drawn` は、`manual-parks.geojson` に同じ `our_name` のポリゴンがあると `true` になります。
+
+- 属性テーブルで `drawn` を確認できます
+- スタイルを `drawn = true` で灰色にすると、残りが分かりやすくなります
+- 取り込み後に再生成すると、取り込んだ公園はこの一覧から消えます
+
+> GeoPackage(`.gpkg`)で描いている場合は、`manual-parks.geojson` に書き出してから再生成してください。
+
+## 注意
+
+- 座標系は EPSG:4326(緯度経度)で保存してください。Web メルカトル(EPSG:3857)のまま書き出すと位置がずれます
+- 面積が 20㎡ 未満のポリゴンは、下書きの消し忘れとみなして取り込みを中止します
+- `existing-parks.geojson` にある公園は描かないでください(重複します)
+"""
+ with open(os.path.join(OUT_DIR, "README.md"), "w", encoding="utf-8") as f:
+ f.write(readme)
+
+ print(f"ポリゴン整備済み: {counts['covered']} 件")
+ print(f"OSM側に既にポリゴンあり(未リンク): {counts['already']} 件")
+ print(f"ポリゴン未整備: {counts['missing']} 件(座標なし {counts['no_point']} 件)")
+ if no_point:
+ print("座標がないため地図に出せない公園(先に kodaira-parks.json の緯度・経度を埋める):")
+ for n in no_point:
+ print(f" - {n}")
+ print(f"出力: {OUT_DIR}")
+
+ if args.list:
+ print("\nポリゴン未整備の公園:")
+ for p in points:
+ props = p["properties"]
+ mark = " [手入力済み]" if props["drawn"] else ""
+ print(f" {props['no']:3d}. {props['our_name']}({props['address'] or '住所未登録'}){mark}")
+ for name in no_point:
+ print(f" --- {name}(緯度・経度未登録)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/update-nearby-parks.py b/scripts/update-nearby-parks.py
new file mode 100644
index 0000000..39268cb
--- /dev/null
+++ b/scripts/update-nearby-parks.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""小平市の境界から500m以内にある近隣市の公園をOpenStreetMapから抽出する。
+
+使い方:
+ py -X utf8 scripts/update-nearby-parks.py
+
+入力:
+ src/data/kodaira-boundary.json 小平市の境界(EPSG:4326)
+ src/data/kodaira-parks-osm.json 小平市内の公園(重複除外に使う)
+出力:
+ src/data/kodaira-nearby-parks.json
+
+leisure=park の way / relation を取得し、小平市内のものと既存データに
+含まれるものを除いたうえで、重心または頂点が境界から500m以内の
+名前付き公園だけを距離順に書き出す。
+
+OpenStreetMapのデータは ODbL 1.0。地図表示時は
+「© OpenStreetMap contributors」の帰属表示が必要。
+"""
+import json
+import math
+import os
+import sys
+import time
+import urllib.request
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DATA_DIR = os.path.join(ROOT, "src", "data")
+BOUNDARY_IN = os.path.join(DATA_DIR, "kodaira-boundary.json")
+EXISTING_IN = os.path.join(DATA_DIR, "kodaira-parks-osm.json")
+OUT = os.path.join(DATA_DIR, "kodaira-nearby-parks.json")
+
+BBOX = (35.68, 139.39, 35.76, 139.56) # south, west, north, east
+MAX_DIST_M = 500.0
+M_PER_DEG_LAT = 111320.0
+NOTE = "市外・小平市境界から500m以内"
+
+ENDPOINTS = [
+ "https://overpass-api.de/api/interpreter",
+ "https://overpass.kumi.systems/api/interpreter",
+ "https://overpass.private.coffee/api/interpreter",
+]
+
+
+def fetch_overpass():
+ """leisure=park を取得する。全エンドポイントが失敗したら例外を投げる。"""
+ south, west, north, east = BBOX
+ query = (
+ "[out:json][timeout:180];"
+ "("
+ f'way["leisure"="park"]({south},{west},{north},{east});'
+ f'relation["leisure"="park"]({south},{west},{north},{east});'
+ ");"
+ "out geom;"
+ )
+ last_err = None
+ for endpoint in ENDPOINTS:
+ for attempt in range(2):
+ try:
+ req = urllib.request.Request(
+ endpoint,
+ data=query.encode("utf-8"),
+ headers={"User-Agent": "kodaira-nearby-parks/1.0 (yasutakeyohei.com)"},
+ )
+ with urllib.request.urlopen(req, timeout=240) as res:
+ data = json.load(res)
+ if "elements" not in data:
+ raise ValueError("レスポンスに elements がありません")
+ print(f" {endpoint} から {len(data['elements'])} 件取得")
+ return data["elements"]
+ except Exception as exc: # noqa: BLE001 - 全滅時のみ異常終了する
+ last_err = exc
+ print(f" {endpoint} 失敗 ({attempt + 1}/2): {exc}", file=sys.stderr)
+ time.sleep(5 * (attempt + 1))
+ raise SystemExit(f"Overpass APIの取得に失敗しました(出力は作成していません): {last_err}")
+
+
+def projector(lat0, lon0):
+ """緯度 lat0・経度 lon0 を原点にした局所平面(メートル)への投影。
+
+ 原点を引かないと座標が1e7規模になり、小さい公園の重心計算で
+ 桁落ちして十数メートルの誤差が出る。
+ """
+ kx = M_PER_DEG_LAT * math.cos(math.radians(lat0))
+
+ def to_xy(lon, lat):
+ return ((lon - lon0) * kx, (lat - lat0) * M_PER_DEG_LAT)
+
+ return to_xy
+
+
+def same_pt(a, b, eps=1e-9):
+ return abs(a[0] - b[0]) <= eps and abs(a[1] - b[1]) <= eps
+
+
+def is_closed(ring, eps=1e-9):
+ return len(ring) >= 4 and same_pt(ring[0], ring[-1], eps)
+
+
+def stitch_rings(seqs):
+ """端点が一致するwayの座標列をつないで閉じたリングにする。"""
+ pending = [list(s) for s in seqs if len(s) >= 2]
+ rings = []
+ while pending:
+ cur = pending.pop(0)
+ growing = True
+ while not is_closed(cur) and growing:
+ growing = False
+ for i, seq in enumerate(pending):
+ if same_pt(cur[-1], seq[0]):
+ cur = cur + seq[1:]
+ elif same_pt(cur[-1], seq[-1]):
+ cur = cur + seq[-2::-1]
+ elif same_pt(cur[0], seq[-1]):
+ cur = seq[:-1] + cur
+ elif same_pt(cur[0], seq[0]):
+ cur = seq[:0:-1] + cur
+ else:
+ continue
+ del pending[i]
+ growing = True
+ break
+ if len(cur) >= 3:
+ if not is_closed(cur):
+ cur = cur + [cur[0]]
+ rings.append(cur)
+ return rings
+
+
+def point_in_ring(x, y, ring):
+ """レイキャスティングによる点の内外判定(平面座標でも経緯度でも可)。"""
+ inside = False
+ j = len(ring) - 1
+ for i in range(len(ring)):
+ xi, yi = ring[i]
+ xj, yj = ring[j]
+ if (yi > y) != (yj > y):
+ if x < (xj - xi) * (y - yi) / (yj - yi) + xi:
+ inside = not inside
+ j = i
+ return inside
+
+
+def way_polygons(el):
+ pts = [(p["lon"], p["lat"]) for p in el.get("geometry") or [] if p and "lon" in p]
+ if len(pts) < 3:
+ return []
+ if not same_pt(pts[0], pts[-1]):
+ pts.append(pts[0])
+ return [[pts]]
+
+
+def relation_polygons(el):
+ outer, inner = [], []
+ for member in el.get("members") or []:
+ if member.get("type") != "way":
+ continue
+ pts = [(p["lon"], p["lat"]) for p in member.get("geometry") or [] if p and "lon" in p]
+ if len(pts) >= 2:
+ (inner if member.get("role") == "inner" else outer).append(pts)
+ inner_rings = stitch_rings(inner)
+ polygons = []
+ for ring in stitch_rings(outer):
+ holes = [r for r in inner_rings if point_in_ring(r[0][0], r[0][1], ring)]
+ polygons.append([ring] + holes)
+ return polygons
+
+
+def build_polygons(el):
+ if el.get("type") == "relation":
+ return relation_polygons(el)
+ return way_polygons(el)
+
+
+def ring_area(ring):
+ total = 0.0
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
+ total += x1 * y2 - x2 * y1
+ return total / 2.0
+
+
+def ring_centroid(ring):
+ area = ring_area(ring)
+ if abs(area) < 1e-9:
+ xs = [p[0] for p in ring]
+ ys = [p[1] for p in ring]
+ return (sum(xs) / len(xs), sum(ys) / len(ys)), 0.0
+ cx = cy = 0.0
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
+ cross = x1 * y2 - x2 * y1
+ cx += (x1 + x2) * cross
+ cy += (y1 + y2) * cross
+ return (cx / (6.0 * area), cy / (6.0 * area)), abs(area)
+
+
+def polygon_centroid(polygon):
+ """外周と穴を考慮した多角形の重心と面積。"""
+ (ox, oy), outer_area = ring_centroid(polygon[0])
+ sx, sy, net = ox * outer_area, oy * outer_area, outer_area
+ for ring in polygon[1:]:
+ (cx, cy), area = ring_centroid(ring)
+ sx -= cx * area
+ sy -= cy * area
+ net -= area
+ if net <= 1e-9:
+ return (ox, oy), outer_area
+ return (sx / net, sy / net), net
+
+
+class BoundaryIndex:
+ """小平市境界の内外判定と、任意の点からの最短距離(メートル)。"""
+
+ def __init__(self, polygons):
+ self.polygons = polygons
+ self.segments = []
+ for polygon in polygons:
+ for ring in polygon:
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
+ dx, dy = x2 - x1, y2 - y1
+ self.segments.append((x1, y1, dx, dy, dx * dx + dy * dy))
+
+ def contains(self, x, y):
+ for polygon in self.polygons:
+ if point_in_ring(x, y, polygon[0]) and not any(
+ point_in_ring(x, y, hole) for hole in polygon[1:]
+ ):
+ return True
+ return False
+
+ def min_distance(self, points):
+ """points(メートル座標の配列)から境界線までの最短距離。"""
+ best = float("inf")
+ for px, py in points:
+ for ax, ay, dx, dy, len2 in self.segments:
+ if len2 > 0.0:
+ t = ((px - ax) * dx + (py - ay) * dy) / len2
+ if t < 0.0:
+ t = 0.0
+ elif t > 1.0:
+ t = 1.0
+ else:
+ t = 0.0
+ dist = math.hypot(px - ax - t * dx, py - ay - t * dy)
+ if dist < best:
+ best = dist
+ if best == 0.0:
+ return 0.0
+ return best
+
+
+def load_boundary():
+ data = json.load(open(BOUNDARY_IN, encoding="utf-8"))
+ polygons = []
+ for feature in data["features"]:
+ geom = feature["geometry"]
+ if geom["type"] == "Polygon":
+ polygons.append(geom["coordinates"])
+ elif geom["type"] == "MultiPolygon":
+ polygons.extend(geom["coordinates"])
+ if not polygons:
+ raise SystemExit("小平市の境界ポリゴンが見つかりません")
+ return polygons
+
+
+def existing_keys():
+ data = json.load(open(EXISTING_IN, encoding="utf-8"))
+ return {
+ (f["properties"].get("osm_type"), f["properties"].get("osm_id"))
+ for f in data["features"]
+ }
+
+
+def geojson_geometry(polygons):
+ def out_ring(ring):
+ return [[round(x, 7), round(y, 7)] for x, y in ring]
+
+ if len(polygons) == 1:
+ return {"type": "Polygon", "coordinates": [out_ring(r) for r in polygons[0]]}
+ return {
+ "type": "MultiPolygon",
+ "coordinates": [[out_ring(r) for r in polygon] for polygon in polygons],
+ }
+
+
+def main():
+ print("Overpass API から取得中...")
+ elements = fetch_overpass()
+
+ boundary_ll = load_boundary()
+ lons = [lon for polygon in boundary_ll for ring in polygon for lon, _ in ring]
+ lats = [lat for polygon in boundary_ll for ring in polygon for _, lat in ring]
+ to_xy = projector(sum(lats) / len(lats), sum(lons) / len(lons))
+ boundary_xy = [
+ [[to_xy(lon, lat) for lon, lat in ring] for ring in polygon]
+ for polygon in boundary_ll
+ ]
+ boundary = BoundaryIndex(boundary_xy)
+
+ skip = existing_keys()
+ features = []
+ skipped_inside = 0
+ for el in elements:
+ if (el.get("type"), el.get("id")) in skip:
+ continue
+ tags = el.get("tags") or {}
+ name = (tags.get("name") or "").strip()
+ if not name:
+ continue
+
+ polygons_ll = build_polygons(el)
+ if not polygons_ll:
+ continue
+ polygons = [
+ [[to_xy(lon, lat) for lon, lat in ring] for ring in polygon]
+ for polygon in polygons_ll
+ ]
+
+ area = 0.0
+ cx = cy = 0.0
+ for polygon in polygons:
+ (px, py), poly_area = polygon_centroid(polygon)
+ cx += px * poly_area
+ cy += py * poly_area
+ area += poly_area
+ if area > 1e-9:
+ cx /= area
+ cy /= area
+ else:
+ vertices = [pt for polygon in polygons for ring in polygon for pt in ring]
+ cx = sum(pt[0] for pt in vertices) / len(vertices)
+ cy = sum(pt[1] for pt in vertices) / len(vertices)
+
+ if boundary.contains(cx, cy):
+ skipped_inside += 1
+ continue
+
+ points = [(cx, cy)]
+ for polygon in polygons:
+ for ring in polygon:
+ points.extend(ring)
+ distance = boundary.min_distance(points)
+ if distance > MAX_DIST_M:
+ continue
+
+ features.append(
+ {
+ "type": "Feature",
+ "properties": {
+ "name": name,
+ "distance_m": int(round(distance)),
+ "area_m2": int(round(area)),
+ "osm_type": el.get("type"),
+ "osm_id": el.get("id"),
+ "city": (tags.get("addr:city") or "").strip(),
+ "note": NOTE,
+ },
+ "geometry": geojson_geometry(polygons_ll),
+ }
+ )
+
+ features.sort(key=lambda f: (f["properties"]["distance_m"], f["properties"]["name"]))
+ out = {"type": "FeatureCollection", "features": features}
+ with open(OUT, "w", encoding="utf-8") as fp:
+ json.dump(out, fp, ensure_ascii=False, separators=(",", ":"))
+ fp.write("\n")
+
+ if features:
+ dists = [f["properties"]["distance_m"] for f in features]
+ print(
+ f"features {len(features)} (distance {min(dists)}-{max(dists)}m, "
+ f"市内で除外 {skipped_inside}) -> {OUT}"
+ )
+ else:
+ print(f"features 0 (市内で除外 {skipped_inside}) -> {OUT}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/update-osm-parks.py b/scripts/update-osm-parks.py
index fffb2f3..fcca883 100644
--- a/scripts/update-osm-parks.py
+++ b/scripts/update-osm-parks.py
@@ -11,8 +11,11 @@
OpenStreetMapのデータは ODbL 1.0。地図表示時は
「© OpenStreetMap contributors」の帰属表示が必要。
"""
+import difflib
import json
+import math
import os
+import re
import sys
import time
import unicodedata
@@ -30,17 +33,39 @@ OVERRIDES = "src/data/park-name-overrides.json"
NORTH_SHIFT_M = 0
LAT_PER_M = 1 / 111320
+# 名前が一致しないポリゴンの自動照合。誤リンクを避けるため保守的に絞る。
+NEAR_DIST_M = 20 # 公園の点がポリゴンからこの距離以内なら同一とみなす
+NEAR_NAME_MIN = 0.5 # 近接照合で、名前付きポリゴンに求める名前類似度
+FUZZY_NAME_MIN = 0.8 # 正規化後の名前類似度がこれを超えたら候補(厳密に大)
+FUZZY_MAX_DIST_M = 500 # 名前類似だけで照合する場合の距離上限
+# ポリゴン面積と台帳面積の比。極端に違うものは別施設とみなす
+AREA_RATIO_MIN = 0.2
+AREA_RATIO_MAX = 5.0
+
+# 「緑地(OSM 1234567)」は OSM の ID を名前に含んでいる
+OSM_ID_NAME_RE = re.compile(r"^緑地(OSM (\d+))$")
+
+# 数字・方位・新旧など「位置の違い」を表す文字。これだけが違う名前は
+# 別公園の可能性が高い(例: 七小西第1公園 / 七小西第2公園)。
+POSITIONAL_CHARS = set("01234567890123456789一二三四五六七八九十第東西南北上中下前後新")
+
ENDPOINTS = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter",
"https://overpass.private.coffee/api/interpreter",
]
+# leisure=park だけでは取りこぼす児童遊園・広場・緑地も取得する。
+# 照合は下の厳しめの条件(完全一致→近接→名前類似)で行う。
QUERY = f"""[out:json][timeout:90];
area["name"="{CITY}"]["boundary"="administrative"]->.a;
(
- way["leisure"="park"](area.a);
- relation["leisure"="park"](area.a);
+ way["leisure"~"^(park|playground|garden|recreation_ground|village_green)$"](area.a);
+ relation["leisure"~"^(park|playground|garden|recreation_ground|village_green)$"](area.a);
+ way["landuse"~"^(recreation_ground|village_green)$"](area.a);
+ relation["landuse"~"^(recreation_ground|village_green)$"](area.a);
+ way["landuse"="forest"]["name"~"コゲラの森|こげらの森"](area.a);
+ relation["landuse"="forest"]["name"~"コゲラの森|こげらの森"](area.a);
);
out geom;
"""
@@ -63,10 +88,74 @@ ALIAS = {
def norm(s):
- s = unicodedata.normalize("NFKC", str(s)).replace(" ", "").replace(" ", "")
+ s = "".join(unicodedata.normalize("NFKC", str(s or "")).split())
return s.replace("ヶ", "ケ").replace("ツ", "ッ")
+def similarity(a, b):
+ return difflib.SequenceMatcher(None, a, b).ratio()
+
+
+def strip_positional(s):
+ return "".join(c for c in s if c not in POSITIONAL_CHARS)
+
+
+def rings(geom):
+ if geom["type"] == "Polygon":
+ return [geom["coordinates"][0]]
+ return [poly[0] for poly in geom["coordinates"]]
+
+
+def point_in_ring(lon, lat, ring):
+ inside = False
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:]):
+ if (y1 > lat) != (y2 > lat):
+ if lon < (x2 - x1) * (lat - y1) / (y2 - y1) + x1:
+ inside = not inside
+ return inside
+
+
+def segment_dist_m(lon, lat, a, b):
+ """点から線分までの距離(メートル)。"""
+ k = math.cos(math.radians(lat))
+ x1, y1 = a[0] * k, a[1]
+ x2, y2 = b[0] * k, b[1]
+ px, py = lon * k, lat
+ dx, dy = x2 - x1, y2 - y1
+ if dx == 0 and dy == 0:
+ t = 0.0
+ else:
+ t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)))
+ return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) * 111320
+
+
+def polygon_dist_m(geom, lat, lon):
+ """公園の代表点からポリゴンまでの距離。内側なら0。"""
+ rs = rings(geom)
+ for ring in rs:
+ if point_in_ring(lon, lat, ring):
+ return 0.0
+ return min(
+ (segment_dist_m(lon, lat, a, b) for ring in rs for a, b in zip(ring, ring[1:])),
+ default=float("inf"),
+ )
+
+
+def polygon_area_m2(geom):
+ total = 0.0
+ for ring in rings(geom):
+ if len(ring) < 4:
+ continue
+ lat0 = sum(p[1] for p in ring) / len(ring)
+ kx = 111320 * math.cos(math.radians(lat0))
+ ky = 111320
+ s = 0.0
+ for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
+ s += (x1 * kx) * (y2 * ky) - (x2 * kx) * (y1 * ky)
+ total += abs(s) / 2
+ return total
+
+
def fetch(query):
last = None
for ep in ENDPOINTS:
@@ -150,6 +239,41 @@ def main():
our_names = {norm(r["公園名"]): r["公園名"] for r in ours}
alias_norm = {norm(k): v for k, v in ALIAS.items()}
+ # 市の公園の代表点(緯度・経度)と台帳面積
+ our_points = []
+ our_areas = {}
+ our_id_names = {}
+ for r in ours:
+ name = r["公園名"]
+ m = OSM_ID_NAME_RE.match(name)
+ if m:
+ our_id_names[int(m.group(1))] = name
+ try:
+ our_areas[name] = float(r.get("面積㎡") or "")
+ except ValueError:
+ pass
+ try:
+ lat = float(r.get("緯度") or "")
+ lon = float(r.get("経度") or "")
+ except ValueError:
+ continue
+ our_points.append((name, lat, lon))
+
+ # 前回の出力(新規に照合できたもののレポートと、部分取得時の補完に使う)
+ previous_matched = set()
+ previous_features = {}
+ if os.path.exists(OUT):
+ try:
+ prev = json.load(open(OUT, encoding="utf-8"))
+ for f in prev.get("features", []):
+ p = f.get("properties", {})
+ key = f"{p.get('osm_type')}/{p.get('osm_id')}"
+ previous_features[key] = f
+ if p.get("matched"):
+ previous_matched.add(key)
+ except (ValueError, KeyError):
+ pass
+
# OSM ID単位の手動上書き(osm_type/osm_id -> 市の公園名)
overrides = {}
if os.path.exists(OVERRIDES):
@@ -165,38 +289,72 @@ def main():
else:
print(f" warning: override city not found: {key} -> {city}", file=sys.stderr)
- def match(name):
+ def exact_match(name):
k = norm(name)
if k in our_names:
- return our_names[k]
+ return our_names[k], "exact"
if k in alias_norm:
- return alias_norm[k]
+ return alias_norm[k], "alias"
# 「〇〇児童公園」→「〇〇公園」のような表記差を吸収
k2 = k.replace("児童", "")
if k2 in our_names:
- return our_names[k2]
- return ""
+ return our_names[k2], "exact"
+ return "", ""
+
+ def area_ok(park, osm_area):
+ official = our_areas.get(park)
+ if not official:
+ return True
+ return AREA_RATIO_MIN <= osm_area / official <= AREA_RATIO_MAX
+
+ def fuzzy_match(name, geom):
+ """ポリゴンが重なる、または名前が十分似ている市の公園を返す。"""
+ dists = [(polygon_dist_m(geom, q[1], q[2]), q[0]) for q in our_points]
+ near = sorted(dists)
+ near_dist, near_park = near[0]
+ osm_area = polygon_area_m2(geom)
+ if near_dist <= NEAR_DIST_M:
+ # 複数の公園が重なる場合はどちらか判定できないので見送る
+ if len(near) > 1 and near[1][0] <= NEAR_DIST_M:
+ return "", "", near_dist, 0.0
+ # 別名の公園は、ポリゴンが同じ場所にあっても結びつけない
+ if name and similarity(norm(name), norm(near_park)) < NEAR_NAME_MIN:
+ return "", "", near_dist, 0.0
+ # 極端に面積が違う場合は別施設(大きなグラウンド等)とみなす
+ if not area_ok(near_park, osm_area):
+ return "", "", near_dist, 0.0
+ return near_park, "near", near_dist, 0.0
+
+ if not name:
+ return "", "", near_dist, 0.0
+ k = norm(name)
+ ranked = sorted(
+ ((similarity(k, norm(park)), dist, park) for dist, park in dists),
+ key=lambda x: (-x[0], x[1]),
+ )
+ sim, dist, park = ranked[0]
+ if sim <= FUZZY_NAME_MIN or dist > FUZZY_MAX_DIST_M:
+ return "", "", dist, sim
+ # 数字・方位だけの違いは別公園の可能性が高い。重なっている場合のみ採用
+ if strip_positional(k) == strip_positional(norm(park)) and dist > NEAR_DIST_M:
+ return "", "", dist, sim
+ if not area_ok(park, osm_area):
+ return "", "", dist, sim
+ return park, "fuzzy", dist, sim
data = fetch(QUERY)
+ fetched_keys = {f"{e['type']}/{e['id']}" for e in data["elements"]}
features = []
applied = 0
- for e in data["elements"]:
+ covered = set()
+ new_matches = []
+
+ # leisure=park を先に処理し、公園本体が広場・遊び場より先に枠を取るようにする
+ elements = sorted(data["elements"], key=lambda e: (e.get("tags", {}).get("leisure") != "park", e["type"], e["id"]))
+ for e in elements:
tags = e.get("tags", {})
name = tags.get("name", "")
key = f"{e['type']}/{e['id']}"
- our_name = overrides.get(key) or match(name)
- if key in overrides:
- applied += 1
- props = {
- "name": name,
- "our_name": our_name,
- "matched": bool(our_name),
- "osm_type": e["type"],
- "osm_id": e["id"],
- }
- for k in ["leisure", "addr:city", "addr:street", "addr:housenumber", "opening_hours"]:
- if k in tags:
- props[k] = tags[k]
if e["type"] == "way":
ring = [pt(p) for p in e.get("geometry", [])]
@@ -221,8 +379,63 @@ def main():
else:
geom = {"type": "MultiPolygon", "coordinates": [[r] + inner_rings for r in outer_rings]}
+ our_name, reason = overrides.get(key, ""), "override" if key in overrides else ""
+ dist = sim = None
+ if key in overrides:
+ applied += 1
+ elif not our_name and e["id"] in our_id_names:
+ # 「緑地(OSM 1234567)」は ID が名前に入っているので直接結びつける
+ our_name, reason = our_id_names[e["id"]], "osm_id"
+ elif not our_name and not exact_match(name)[0]:
+ our_name, reason, dist, sim = fuzzy_match(name, geom)
+ if not our_name:
+ our_name, reason = exact_match(name)
+
+ # 公園本体(leisure=park)以外が既に整備済みの公園に重なる場合は、
+ # 公園内の遊び場・広場とみなして取り込まない。
+ core = tags.get("leisure") == "park"
+ if our_name in covered and not (core and reason in ("exact", "alias", "override")):
+ continue
+
+ props = {
+ "name": name,
+ "our_name": our_name,
+ "matched": bool(our_name),
+ "osm_type": e["type"],
+ "osm_id": e["id"],
+ }
+ if our_name:
+ props["match"] = reason
+ for k in ["leisure", "landuse", "addr:city", "addr:street", "addr:housenumber", "opening_hours"]:
+ if k in tags:
+ props[k] = tags[k]
+
+ if our_name:
+ covered.add(our_name)
+ if key not in previous_matched:
+ new_matches.append((our_name, key, reason, tags.get("leisure") or tags.get("landuse", ""), dist, sim))
+
features.append({"type": "Feature", "properties": props, "geometry": geom})
+ # Overpassが部分的な結果を返すと、override対象のFeatureが抜けることがある。
+ # その場合は前回のデータを保持して、公園のポリゴンを失わないようにする。
+ for key, city in overrides.items():
+ if key in fetched_keys:
+ continue
+ prev_feat = previous_features.get(key)
+ if not prev_feat:
+ continue
+ feat = json.loads(json.dumps(prev_feat))
+ feat["properties"]["our_name"] = city
+ feat["properties"]["matched"] = True
+ feat["properties"]["match"] = "override"
+ features.append(feat)
+ applied += 1
+ print(
+ f" warning: {key} は今回の取得に含まれないため前回のデータを保持しました",
+ file=sys.stderr,
+ )
+
features.sort(key=lambda f: (f["properties"].get("name", ""), f["properties"]["osm_type"], f["properties"]["osm_id"]))
out = {"type": "FeatureCollection", "features": features}
json.dump(out, open(OUT, "w", encoding="utf-8"), ensure_ascii=False, separators=(",", ":"))
@@ -232,6 +445,15 @@ def main():
print(f"features {len(features)} (named {named}, matched {matched}) -> {OUT}")
if overrides:
print(f" overrides applied {applied} / {len(overrides)}")
+ if new_matches:
+ print(f" newly matched: {len(new_matches)}")
+ for our_name, key, reason, tag, dist, sim in sorted(new_matches):
+ detail = []
+ if sim:
+ detail.append(f"類似{sim:.2f}")
+ if dist is not None:
+ detail.append(f"{dist:.0f}m")
+ print(f" {our_name} <- {key} [{tag}/{reason}] {' '.join(detail)}")
boundary = build_boundary()
if boundary: