diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/make-park-qgis-kit.py | 65 | ||||
| -rw-r--r-- | scripts/park-editor.html | 254 | ||||
| -rw-r--r-- | scripts/park-editor.mjs | 106 | ||||
| -rw-r--r-- | scripts/update-nearby-facilities.py | 179 | ||||
| -rw-r--r-- | scripts/update-nearby-parks.py | 243 |
5 files changed, 771 insertions, 76 deletions
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 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>公園データ編集</title> +<title>公園・施設データ編集</title> <style> :root { color-scheme: light dark; } body { font-family: system-ui, -apple-system, "Hiragino Kaku Gothic ProN", sans-serif; margin: 0; } - header { position: sticky; top: 0; background: Canvas; border-bottom: 1px solid #9995; padding: .5rem .75rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; z-index: 10; } + header { position: sticky; top: 0; background: Canvas; border-bottom: 1px solid #9995; padding: .45rem .75rem; z-index: 20; } + .head-row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } header h1 { font-size: 1rem; margin: 0 .5rem 0 0; } #q { padding: .3rem .5rem; } #count { font-size: .85rem; opacity: .7; } #save { padding: .35rem .9rem; font-weight: bold; } #status { font-size: .85rem; } - .wrap { overflow: auto; height: calc(100vh - 3.2rem); } + #tabs { display: flex; gap: .3rem; flex-wrap: wrap; margin-top: .45rem; } + #tabs button { padding: .3rem .7rem; border: 1px solid #9996; border-radius: 999px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: .85rem; } + #tabs button.active { background: #3b82f6; border-color: #3b82f6; color: #fff; } + #warning { margin: 0; padding: .35rem .75rem; background: #ff9d0022; border-bottom: 1px solid #9995; font-size: .8rem; } + .wrap { overflow: auto; height: calc(100vh - 7.4rem); } table { border-collapse: collapse; font-size: .8rem; } th, td { border: 1px solid #9994; padding: 0; } th { position: sticky; top: 0; background: Canvas; padding: .25rem .4rem; white-space: nowrap; z-index: 5; } @@ -29,123 +34,268 @@ </head> <body> <header> - <h1>公園データ編集</h1> - <input id="q" placeholder="絞り込み(公園名・町名・住所)" size="24"> - <span id="count"></span> - <button id="save">保存</button> - <button id="revert">再読込</button> - <span id="status"></span> + <div class="head-row"> + <h1>公園・施設データ編集</h1> + <input id="q" placeholder="絞り込み(名称・住所・備考など)" size="24"> + <span id="count"></span> + <button id="save">保存</button> + <button id="revert">再読込</button> + <span id="status"></span> + </div> + <nav id="tabs"></nav> </header> +<p id="warning" hidden></p> <div class="wrap"><table id="tbl"><thead></thead><tbody></tbody></table></div> <script> -const FIELDS = ["公園名","住所","町名","面積㎡","500㎡以上","種別","南西部指定管理","設置者","トイレ","水飲み場","ベンチ・椅子","テーブル","パーゴラ","東屋","防災用具","ボール遊び","遊具","緯度","経度","備考"]; -let parks = []; +const FIELD = (key, label) => ({ key, label }); + +const DATASETS = { + parks: { + label: "市内公園", + fields: [ + FIELD("公園名", "公園名"), FIELD("住所", "住所"), FIELD("町名", "町名"), + FIELD("面積㎡", "面積㎡"), FIELD("500㎡以上", "500㎡以上"), FIELD("種別", "種別"), + FIELD("南西部指定管理", "南西部指定管理"), FIELD("設置者", "設置者"), FIELD("状態", "状態"), + FIELD("トイレ", "トイレ"), FIELD("水飲み場", "水飲み場"), FIELD("ベンチ・椅子", "ベンチ・椅子"), + FIELD("テーブル", "テーブル"), FIELD("パーゴラ", "パーゴラ"), FIELD("東屋", "東屋"), + FIELD("防災用具", "防災用具"), FIELD("ボール遊び", "ボール遊び"), FIELD("遊具", "遊具"), + FIELD("緯度", "緯度"), FIELD("経度", "経度"), FIELD("備考", "備考"), + ], + }, + nearbyParks: { + label: "周辺の公園", + warning: "周辺の公園は update-nearby-parks.py で再生成すると上書きされます。編集内容は別途保存してください。", + fields: [ + FIELD("name", "名称"), FIELD("city", "市"), FIELD("distance_m", "距離(m)"), + FIELD("area_m2", "面積(㎡)"), FIELD("note", "備考"), + FIELD("osm_type", "OSM種別"), FIELD("osm_id", "OSM ID"), + FIELD("name_source", "名称の出所"), + ], + }, + facilities: { + label: "市内の公共施設", + fields: [ + FIELD("name", "名称"), FIELD("type", "種別"), FIELD("address", "住所"), + FIELD("hours", "開館時間"), FIELD("closed", "休館日"), FIELD("toilet", "トイレ"), + FIELD("water", "給水"), FIELD("note", "備考"), FIELD("source", "出典URL"), + FIELD("lat", "緯度"), FIELD("lon", "経度"), FIELD("city", "市"), + FIELD("distance_m", "距離(m)"), + ], + }, + nearbyFacilities: { + label: "周辺の公共施設", + warning: "周辺の公共施設は update-nearby-facilities.py で再生成すると上書きされます。編集内容は別途保存してください。", + fields: [ + FIELD("name", "名称"), FIELD("type", "種別"), FIELD("address", "住所"), + FIELD("hours", "開館時間"), FIELD("closed", "休館日"), FIELD("toilet", "トイレ"), + FIELD("water", "給水"), FIELD("note", "備考"), FIELD("source", "出典URL"), + FIELD("lat", "緯度"), FIELD("lon", "経度"), FIELD("city", "市"), + FIELD("distance_m", "距離(m)"), + ], + }, +}; + +const NUMERIC = { + parks: new Set(), + nearbyParks: new Set(["distance_m", "area_m2"]), + facilities: new Set(["lat", "lon", "distance_m"]), + nearbyFacilities: new Set(["lat", "lon", "distance_m"]), +}; + const tbl = document.getElementById("tbl"); const thead = tbl.querySelector("thead"); const tbody = tbl.querySelector("tbody"); const q = document.getElementById("q"); const count = document.getElementById("count"); -const status = document.getElementById("status"); +const statusEl = document.getElementById("status"); +const warningEl = document.getElementById("warning"); +const tabsEl = document.getElementById("tabs"); + +let currentId = null; +let data = null; +let rows = []; +let isFC = false; +let fields = []; -function mapsUrl(p) { - if (p["緯度"] && p["経度"]) { - return "https://www.google.com/maps/search/?api=1&query=" + p["緯度"] + "," + p["経度"]; +function hasDirty() { + return Boolean(tbody.querySelector("tr.dirty")); +} + +function featureCentroid(feature) { + const g = feature && feature.geometry; + if (!g) return null; + if (g.type === "Point") return [g.coordinates[1], g.coordinates[0]]; + const ring = g.type === "Polygon" ? g.coordinates[0] : g.coordinates[0] && g.coordinates[0][0]; + if (!ring || !ring.length) return null; + let lat = 0; + let lon = 0; + for (const [x, y] of ring) { + lon += x; + lat += y; + } + return [lat / ring.length, lon / ring.length]; +} + +function mapsUrl(row, props) { + const lat = props["緯度"] != null && props["緯度"] !== "" ? props["緯度"] : props.lat; + const lon = props["経度"] != null && props["経度"] !== "" ? props["経度"] : props.lon; + let query = ""; + if (lat != null && lat !== "" && lon != null && lon !== "") { + query = `${lat},${lon}`; + } else if (isFC) { + const c = featureCentroid(row); + if (c) query = `${c[0]},${c[1]}`; } - return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent("小平市" + (p["公園名"] || "")); + if (!query && props.city && props.name) query = `${props.city}${props.name}`; + if (!query && props["公園名"]) query = `小平市${props["公園名"]}`; + if (!query && props.name) query = props.name; + if (!query) query = "小平市"; + return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent(query); +} + +function renderTabs() { + tabsEl.innerHTML = ""; + Object.entries(DATASETS).forEach(([id, dataset]) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = dataset.label; + btn.dataset.id = id; + btn.addEventListener("click", () => { + if (id === currentId) return; + if (hasDirty() && !confirm("未保存の変更は破棄されます。よろしいですか?")) return; + load(id); + }); + tabsEl.appendChild(btn); + }); } -async function load() { - const res = await fetch("/api/parks", { cache: "no-store" }); - parks = await res.json(); - render(); +function updateTabs() { + tabsEl.querySelectorAll("button").forEach((btn) => { + btn.classList.toggle("active", btn.dataset.id === currentId); + }); +} + +async function load(id) { + currentId = id; + fields = DATASETS[id].fields; + updateTabs(); + warningEl.hidden = !DATASETS[id].warning; + warningEl.textContent = DATASETS[id].warning || ""; + statusEl.textContent = "読み込み中…"; + try { + const res = await fetch("/api/data/" + id, { cache: "no-store" }); + if (!res.ok) throw new Error(await res.text()); + data = await res.json(); + isFC = data.type === "FeatureCollection"; + rows = isFC ? data.features : data; + render(); + statusEl.textContent = `${DATASETS[id].label}: ${rows.length}件`; + } catch (e) { + statusEl.textContent = "読み込みに失敗: " + e.message; + } } function render() { - thead.innerHTML = "<tr>" + ["#", "地図"].concat(FIELDS).map(function (f) { return "<th>" + f + "</th>"; }).join("") + "</tr>"; + thead.innerHTML = "<tr>" + ["#", "地図"].concat(fields.map((f) => f.label)).map((label) => "<th>" + label + "</th>").join("") + "</tr>"; tbody.innerHTML = ""; - parks.forEach(function (p, r) { + rows.forEach((row, r) => { + const props = isFC ? row.properties || {} : row; const tr = document.createElement("tr"); - const th = document.createElement("td"); - th.className = "rownum"; - th.textContent = String(r + 1); - tr.appendChild(th); + const rowNum = document.createElement("td"); + rowNum.className = "rownum"; + rowNum.textContent = String(r + 1); + tr.appendChild(rowNum); + const mapTd = document.createElement("td"); mapTd.className = "mapcell"; const mapA = document.createElement("a"); mapA.textContent = "🗺"; mapA.title = "Googleマップで開く"; - mapA.href = mapsUrl(p); + mapA.href = mapsUrl(row, props); mapA.target = "_blank"; mapA.rel = "noopener"; - mapA.addEventListener("click", function (ev) { - ev.preventDefault(); - const name = tr.querySelector('input[data-c="0"]').value.trim(); - if (name) window.open(mapsUrl(name), "_blank", "noopener"); - }); mapTd.appendChild(mapA); tr.appendChild(mapTd); - FIELDS.forEach(function (f, c) { + + fields.forEach((field, c) => { const td = document.createElement("td"); const inp = document.createElement("input"); - inp.value = p[f] != null ? p[f] : ""; + inp.value = props[field.key] != null ? props[field.key] : ""; inp.dataset.r = r; inp.dataset.c = c; - inp.addEventListener("input", function () { + inp.addEventListener("input", () => { tr.classList.add("dirty"); - status.textContent = "未保存の変更があります"; + statusEl.textContent = "未保存の変更があります"; }); td.appendChild(inp); tr.appendChild(td); }); - tr.dataset.search = (p["公園名"] || "") + " " + (p["町名"] || "") + " " + (p["住所"] || ""); + + tr.dataset.search = fields.map((f) => String(props[f.key] != null ? props[f.key] : "")).join(" "); tbody.appendChild(tr); }); filter(); } function filter() { - const s = q.value.trim(); + const s = q.value.trim().toLowerCase(); let shown = 0; - Array.prototype.forEach.call(tbody.children, function (tr) { - const ok = !s || tr.dataset.search.indexOf(s) >= 0; + Array.prototype.forEach.call(tbody.children, (tr) => { + const ok = !s || tr.dataset.search.toLowerCase().indexOf(s) >= 0; tr.hidden = !ok; if (ok) shown++; }); - count.textContent = shown + " / " + parks.length + " 件"; + count.textContent = shown + " / " + rows.length + " 件"; } function collect() { - Array.prototype.forEach.call(tbody.querySelectorAll("input"), function (inp) { - parks[Number(inp.dataset.r)][FIELDS[Number(inp.dataset.c)]] = inp.value; + Array.prototype.forEach.call(tbody.querySelectorAll("input"), (inp) => { + const row = rows[Number(inp.dataset.r)]; + const props = isFC ? row.properties || (row.properties = {}) : row; + const field = fields[Number(inp.dataset.c)]; + let value = inp.value; + if (NUMERIC[currentId].has(field.key)) { + const t = value.trim(); + if (t === "") { + value = null; + } else { + const n = Number(t); + value = Number.isFinite(n) ? n : null; + } + } + props[field.key] = value; }); } document.getElementById("save").onclick = async function () { collect(); - status.textContent = "保存中…"; + statusEl.textContent = "保存中…"; try { - const res = await fetch("/api/parks", { + const res = await fetch("/api/data/" + currentId, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(parks), + body: JSON.stringify(data), }); if (res.ok) { - status.textContent = "保存しました(" + parks.length + "件)。git にコミットを忘れずに。"; - Array.prototype.forEach.call(tbody.children, function (tr) { tr.classList.remove("dirty"); }); + const result = await res.json(); + statusEl.textContent = `保存しました(${DATASETS[currentId].label} ${result.count}件)。git にコミットを忘れずに。`; + tbody.querySelectorAll("tr.dirty").forEach((tr) => tr.classList.remove("dirty")); } else { - status.textContent = "保存に失敗: " + (await res.text()); + statusEl.textContent = "保存に失敗: " + (await res.text()); } } catch (e) { - status.textContent = "保存に失敗: " + e.message; + statusEl.textContent = "保存に失敗: " + e.message; } }; document.getElementById("revert").onclick = function () { - if (confirm("保存していない変更は破棄されます。よろしいですか?")) load(); + if (hasDirty() && !confirm("保存していない変更は破棄されます。よろしいですか?")) return; + load(currentId); }; q.addEventListener("input", filter); -load(); +renderTabs(); +load("parks"); </script> </body> </html> diff --git a/scripts/park-editor.mjs b/scripts/park-editor.mjs index 7a6c2fa..a872008 100644 --- a/scripts/park-editor.mjs +++ b/scripts/park-editor.mjs @@ -1,9 +1,10 @@ -// 公園データの簡易エディタ(ローカル専用)。 +// 公園・公共施設データの簡易エディタ(ローカル専用)。 // // 使い方: -// node scripts/park-editor.mjs +// npm run park-editor // → 表示された URL(既定 http://localhost:8787/)をブラウザで開く -// → 表のセルを直接編集し、「保存」を押すと src/data/kodaira-parks.json を更新 +// → タブで「市内公園 / 周辺の公園 / 市内の公共施設 / 周辺の公共施設」を切り替え +// → 表のセルを直接編集し、「保存」を押すと該当するJSONを更新 // → 反映確認後、git にコミットする // // 注意: ローカル(127.0.0.1)にのみ公開します。追加パッケージは不要です。 @@ -13,36 +14,109 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const DATA = join(root, "src/data/kodaira-parks.json"); const HTML = join(root, "scripts/park-editor.html"); const PORT = Number(process.env.PORT || 8787); +const DATASETS = { + parks: { + label: "市内公園", + path: join(root, "src/data/kodaira-parks.json"), + pretty: false, + }, + nearbyParks: { + label: "周辺の公園", + path: join(root, "src/data/kodaira-nearby-parks.json"), + pretty: false, + }, + facilities: { + label: "市内の公共施設", + path: join(root, "src/data/kodaira-facilities.json"), + pretty: true, + }, + nearbyFacilities: { + label: "周辺の公共施設", + path: join(root, "src/data/kodaira-nearby-facilities.json"), + pretty: true, + }, +}; + function send(res, code, type, body) { res.writeHead(code, { "content-type": type }); res.end(body); } +function datasetFromUrl(url) { + const match = /^\/api\/data\/([A-Za-z]+)$/.exec(url); + if (!match) return null; + return DATASETS[match[1]] ? { id: match[1], dataset: DATASETS[match[1]] } : null; +} + const server = createServer(async (req, res) => { try { const url = (req.url || "").split("?")[0]; if (req.method === "GET" && (url === "/" || url === "/index.html")) { send(res, 200, "text/html; charset=utf-8", await readFile(HTML, "utf-8")); - } else if (req.method === "GET" && url === "/api/parks") { - send(res, 200, "application/json; charset=utf-8", await readFile(DATA, "utf-8")); - } else if (req.method === "POST" && url === "/api/parks") { + return; + } + + if (req.method === "GET" && url === "/api/datasets") { + send( + res, + 200, + "application/json; charset=utf-8", + JSON.stringify( + Object.entries(DATASETS).map(([id, dataset]) => ({ id, label: dataset.label })), + ), + ); + return; + } + + const target = datasetFromUrl(url); + if (!target) { + send(res, 404, "text/plain; charset=utf-8", "not found"); + return; + } + + if (req.method === "GET") { + send( + res, + 200, + "application/json; charset=utf-8", + await readFile(target.dataset.path, "utf-8"), + ); + return; + } + + if (req.method === "POST") { let body = ""; for await (const chunk of req) body += chunk; - const parks = JSON.parse(body); - if (!Array.isArray(parks) || parks.length === 0) { + const data = JSON.parse(body); + + if (target.id === "nearbyParks") { + if (!data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) { + throw new Error("周辺の公園は FeatureCollection ではありません"); + } + } else if (!Array.isArray(data) || data.length === 0) { throw new Error("データが配列ではありません"); } - // 元ファイルと同じコンパクトな1行JSONで保存する(日本語はそのまま)。 - await writeFile(DATA, JSON.stringify(parks), "utf-8"); - console.log(`保存しました: ${parks.length} 件 → ${DATA}`); - send(res, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true, count: parks.length })); - } else { - send(res, 404, "text/plain; charset=utf-8", "not found"); + + const text = target.dataset.pretty + ? `${JSON.stringify(data, null, 2)}\n` + : `${JSON.stringify(data)}\n`; + await writeFile(target.dataset.path, text, "utf-8"); + + const count = target.id === "nearbyParks" ? data.features.length : data.length; + console.log(`保存しました: ${target.dataset.label} ${count}件 → ${target.dataset.path}`); + send( + res, + 200, + "application/json; charset=utf-8", + JSON.stringify({ ok: true, count }), + ); + return; } + + send(res, 405, "text/plain; charset=utf-8", "method not allowed"); } catch (e) { console.error(e); send(res, 500, "text/plain; charset=utf-8", String(e)); @@ -50,5 +124,5 @@ const server = createServer(async (req, res) => { }); server.listen(PORT, "127.0.0.1", () => { - console.log(`公園データ編集: http://localhost:${PORT}/ (Ctrl+C で終了)`); + console.log(`公園・施設データ編集: http://localhost:${PORT}/ (Ctrl+C で終了)`); }); diff --git a/scripts/update-nearby-facilities.py b/scripts/update-nearby-facilities.py new file mode 100644 index 0000000..dcef5d6 --- /dev/null +++ b/scripts/update-nearby-facilities.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""小平市の境界から500m以内にある近隣市の公共施設をOpenStreetMapから抽出する。 + +使い方: + py -X utf8 scripts/update-nearby-facilities.py + +出力: + src/data/kodaira-nearby-facilities.json + +対象: amenity=library / community_centre / townhall / arts_centre / theatre +小平市内の施設は kodaira-facilities.json 側にあるため除外する。 +""" +import importlib.util +import json +import os +import urllib.parse +import urllib.request + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUT = os.path.join(ROOT, "src", "data", "kodaira-nearby-facilities.json") +MAX_DIST_M = 500.0 +BBOX = (35.68, 139.39, 35.76, 139.56) +AMENITIES = "library|community_centre|townhall|arts_centre|theatre" +EXCLUDE_NAME_PARTS = ("付属図書館", "コミュニティ サロン") + +ENDPOINTS = [ + "https://overpass-api.de/api/interpreter", + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +] + + +def load_module(path, name): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def fetch_overpass(): + south, west, north, east = BBOX + query = ( + "[out:json][timeout:180];" + "(" + f'node["amenity"~"{AMENITIES}"]({south},{west},{north},{east});' + f'way["amenity"~"{AMENITIES}"]({south},{west},{north},{east});' + f'relation["amenity"~"{AMENITIES}"]({south},{west},{north},{east});' + ");" + "out tags center;" + ) + 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-facilities/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}") + raise SystemExit(f"Overpass APIの取得に失敗しました(出力は作成していません): {last_err}") + + +def facility_type(tags): + amenity = tags.get("amenity", "") + name = tags.get("name", "") + if amenity == "library": + return "図書館" + if amenity == "community_centre": + return "児童館" if "児童館" in name else "公民館" + if amenity == "townhall": + return "市役所支所" + if amenity in ("arts_centre", "theatre"): + return "文化施設" + return "" + + +def address_of(tags): + full = (tags.get("addr:full") or "").strip() + if full: + return full + city = (tags.get("addr:city") or "").strip() + neighbourhood = ( + tags.get("addr:neighbourhood") + or tags.get("addr:suburb") + or tags.get("addr:quarter") + or "" + ).strip() + street = (tags.get("addr:street") or "").strip() + block = (tags.get("addr:block_number") or "").strip() + number = (tags.get("addr:housenumber") or "").strip() + if block and number: + lot = f"{block}-{number}" + else: + lot = block or number + if street: + return f"{city}{street}{lot}" + if neighbourhood: + return f"{city}{neighbourhood}{lot}" + # 町名がない番地だけでは住所にならないため、市区町村だけにする。 + return city + + +def main(): + print("Overpass API から近隣施設を取得中...") + elements = fetch_overpass() + + np = load_module(os.path.join(ROOT, "scripts", "update-nearby-parks.py"), "nearby_parks") + boundary_ll = np.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 = np.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 = np.BoundaryIndex(boundary_xy) + + features = [] + for el in elements: + tags = el.get("tags") or {} + name = (tags.get("name") or "").strip() + if not name: + continue + if any(part in name for part in EXCLUDE_NAME_PARTS): + continue + kind = facility_type(tags) + if not kind: + continue + center = el.get("center") or {} + lat = el.get("lat", center.get("lat")) + lon = el.get("lon", center.get("lon")) + if lat is None or lon is None: + continue + x, y = to_xy(lon, lat) + if boundary.contains(x, y): + continue + distance = boundary.min_distance([(x, y)]) + if distance > MAX_DIST_M: + continue + features.append( + { + "name": name, + "type": kind, + "address": address_of(tags), + "hours": (tags.get("opening_hours") or "").strip(), + "closed": "", + "toilet": "未確認", + "water": "未確認", + "note": f"市外・小平市境から約{int(round(distance))}m(OpenStreetMap)", + "source": f"https://www.openstreetmap.org/{el.get('type')}/{el.get('id')}", + "lat": lat, + "lon": lon, + "geocode": "OSM", + "city": (tags.get("addr:city") or "").strip(), + "distance_m": int(round(distance)), + "osm_type": el.get("type"), + "osm_id": el.get("id"), + "nearby": True, + } + ) + + features.sort(key=lambda f: (f["distance_m"], f["name"])) + with open(OUT, "w", encoding="utf-8") as fh: + json.dump(features, fh, ensure_ascii=False, indent=2) + fh.write("\n") + print(f"nearby facilities {len(features)} -> {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/update-nearby-parks.py b/scripts/update-nearby-parks.py index 39268cb..ef30f53 100644 --- a/scripts/update-nearby-parks.py +++ b/scripts/update-nearby-parks.py @@ -20,6 +20,7 @@ OpenStreetMapのデータは ODbL 1.0。地図表示時は import json import math import os +import re import sys import time import urllib.request @@ -29,11 +30,29 @@ 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") +NEARBY_MANUAL = os.path.join(ROOT, "reference", "qgis", "nearby-parks-manual.geojson") 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以内" +PARK_NAME_RE = re.compile(r"(公園|緑地|広場|遊園|庭園|森)") + +# OSMに公園ポリゴンがない近隣公園。位置・面積は自治体の公開情報による。 +# 地図では点(範囲未取得)として表示する。 +MANUAL_FEATURES = [ + { + "name": "北町パンダ公園", + "distance_m": 94, + "area_m2": 1675, + "osm_type": "manual", + "osm_id": "hokucho-panda", + "city": "国分寺市", + "lat": 35.721831, + "lon": 139.447225, + "note": "市外・小平市境界から500m以内(公園の範囲は未取得。位置は国分寺市の公園情報による)", + }, +] ENDPOINTS = [ "https://overpass-api.de/api/interpreter", @@ -50,6 +69,7 @@ def fetch_overpass(): "(" f'way["leisure"="park"]({south},{west},{north},{east});' f'relation["leisure"="park"]({south},{west},{north},{east});' + f'node["name"~"公園|緑地|広場|遊園|庭園|森"]({south},{west},{north},{east});' ");" "out geom;" ) @@ -141,6 +161,39 @@ def point_in_ring(x, y, ring): return inside +def polygon_contains(polygon, x, y): + if not point_in_ring(x, y, polygon[0]): + return False + return not any(point_in_ring(x, y, hole) for hole in polygon[1:]) + + +def name_from_named_node(polygons_ll, named_nodes, used_names): + """名前のない公園ポリゴンの中にある名前付きノードから公園名を推測する。 + + 公園ポリゴンの名称がノード側にしかない場合(例: 窪東公園)に使う。 + 既に別の公園で使われている名前は候補から除く。 + 複数ある場合は重心に最も近いものを選ぶ。 + """ + best_name = "" + best_dist = float("inf") + for polygon in polygons_ll: + ring = polygon[0] + xs = [pt[0] for pt in ring] + ys = [pt[1] for pt in ring] + cx = sum(xs) / len(xs) + cy = sum(ys) / len(ys) + for name, lon, lat in named_nodes: + if name in used_names: + continue + if not polygon_contains(polygon, lon, lat): + continue + dist = (lon - cx) ** 2 + (lat - cy) ** 2 + if dist < best_dist: + best_dist = dist + best_name = name + return best_name + + 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: @@ -270,6 +323,120 @@ def existing_keys(): } +def exclusion_polygons(): + """近隣公園から除外する大きな公園のポリゴン(経緯度)。 + + 都立小金井公園の内側にある広場・公園は、独立した近隣公園として + 重複表示しない。小金井公園本体は小平市の一覧側に残す。 + """ + data = json.load(open(EXISTING_IN, encoding="utf-8")) + polygons = [] + for feature in data["features"]: + name = feature["properties"].get("our_name") or "" + if name != "都立小金井公園": + continue + geom = feature["geometry"] + if geom["type"] == "Polygon": + polygons.append(geom["coordinates"]) + elif geom["type"] == "MultiPolygon": + polygons.extend(geom["coordinates"]) + return polygons + + +def manual_polygons_ll(geom): + if geom.get("type") == "Polygon": + return [geom.get("coordinates") or []] + if geom.get("type") == "MultiPolygon": + return geom.get("coordinates") or [] + return [] + + +def merge_nearby_manual(features, boundary, to_xy): + """reference/qgis/nearby-parks-manual.geojson の手入力ポリゴンを反映する。 + + `our_name` が既存の近隣公園と一致すれば、点やOSMポリゴンを + 手入力ポリゴンで置き換える。一致しなければ新しい公園として追加する。 + """ + if not os.path.exists(NEARBY_MANUAL): + return 0 + try: + data = json.load(open(NEARBY_MANUAL, encoding="utf-8")) + except (OSError, ValueError) as exc: + print(f" warning: {NEARBY_MANUAL} を読めません: {exc}", file=sys.stderr) + return 0 + + by_name = {f["properties"]["name"]: f for f in features} + count = 0 + for index, feat in enumerate(data.get("features", []), 1): + props = feat.get("properties") or {} + name = (props.get("our_name") or props.get("name") or "").strip() + geom = feat.get("geometry") or {} + polygons_ll = manual_polygons_ll(geom) + if not name or not polygons_ll: + continue + + polygons = [] + for polygon in polygons_ll: + rings = [] + for ring in polygon: + pts = [(pt[0], pt[1]) for pt in ring if len(pt) >= 2] + if len(pts) < 3: + continue + if not same_pt(pts[0], pts[-1]): + pts.append(pts[0]) + rings.append(pts) + if rings: + polygons.append(rings) + if not polygons: + continue + + polygons_xy = [ + [[to_xy(lon, lat) for lon, lat in ring] for ring in polygon] + for polygon in polygons + ] + area = 0.0 + cx = cy = 0.0 + for polygon in polygons_xy: + (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_xy 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) + + points = [(cx, cy)] + for polygon in polygons_xy: + for ring in polygon: + points.extend(ring) + distance = boundary.min_distance(points) + + new_props = { + "name": name, + "distance_m": int(round(distance)), + "area_m2": int(round(area)), + "osm_type": "manual", + "osm_id": f"nearby-{index}", + "city": str(props.get("city") or "").strip(), + "note": "市外・小平市境界から500m以内(手入力ポリゴン)", + "name_source": "manual", + } + existing = by_name.get(name) + if existing: + existing["properties"].update(new_props) + existing["geometry"] = geom + else: + feature = {"type": "Feature", "properties": new_props, "geometry": geom} + features.append(feature) + by_name[name] = feature + count += 1 + return count + + def geojson_geometry(polygons): def out_ring(ring): return [[round(x, 7), round(y, 7)] for x, y in ring] @@ -286,6 +453,25 @@ def main(): print("Overpass API から取得中...") elements = fetch_overpass() + # 名前のない公園ポリゴンに名前を付けるための、公園らしい名前のノード。 + named_nodes = [] + park_feature_names = set() + for el in elements: + if el.get("type") == "node": + tags = el.get("tags") or {} + name = (tags.get("name") or "").strip() + lat = el.get("lat") + lon = el.get("lon") + if len(name) < 3 or not PARK_NAME_RE.search(name): + continue + if lat is None or lon is None: + continue + named_nodes.append((name, lon, lat)) + else: + name = ((el.get("tags") or {}).get("name") or "").strip() + if name: + park_feature_names.add(name) + 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] @@ -297,19 +483,37 @@ def main(): boundary = BoundaryIndex(boundary_xy) skip = existing_keys() + exclusions = exclusion_polygons() features = [] skipped_inside = 0 + used_names = set(park_feature_names) for el in elements: - if (el.get("type"), el.get("id")) in skip: + if el.get("type") == "node": continue - tags = el.get("tags") or {} - name = (tags.get("name") or "").strip() - if not name: + if (el.get("type"), el.get("id")) in skip: continue polygons_ll = build_polygons(el) if not polygons_ll: continue + + # 小金井公園の内側にある広場・公園は、独立した近隣公園にしない。 + if exclusions: + vertices = [pt for polygon in polygons_ll for pt in polygon[0]] + cx_ll = sum(pt[0] for pt in vertices) / len(vertices) + cy_ll = sum(pt[1] for pt in vertices) / len(vertices) + if any(polygon_contains(poly, cx_ll, cy_ll) for poly in exclusions): + continue + + tags = el.get("tags") or {} + name = (tags.get("name") or "").strip() + name_source = "osm" + if not name: + name = name_from_named_node(polygons_ll, named_nodes, used_names) + name_source = "nearby_node" + if not name: + continue + used_names.add(name) polygons = [ [[to_xy(lon, lat) for lon, lat in ring] for ring in polygon] for polygon in polygons_ll @@ -353,11 +557,38 @@ def main(): "osm_id": el.get("id"), "city": (tags.get("addr:city") or "").strip(), "note": NOTE, + "name_source": name_source, }, "geometry": geojson_geometry(polygons_ll), } ) + existing_names = {f["properties"]["name"] for f in features} + for manual in MANUAL_FEATURES: + if manual["name"] in existing_names: + continue + features.append( + { + "type": "Feature", + "properties": { + "name": manual["name"], + "distance_m": manual["distance_m"], + "area_m2": manual["area_m2"], + "osm_type": manual["osm_type"], + "osm_id": manual["osm_id"], + "city": manual["city"], + "note": manual["note"], + "name_source": "manual", + }, + "geometry": { + "type": "Point", + "coordinates": [manual["lon"], manual["lat"]], + }, + } + ) + + manual_count = merge_nearby_manual(features, boundary, to_xy) + 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: @@ -368,10 +599,10 @@ def main(): dists = [f["properties"]["distance_m"] for f in features] print( f"features {len(features)} (distance {min(dists)}-{max(dists)}m, " - f"市内で除外 {skipped_inside}) -> {OUT}" + f"市内で除外 {skipped_inside}, 手入力 {manual_count}) -> {OUT}" ) else: - print(f"features 0 (市内で除外 {skipped_inside}) -> {OUT}") + print(f"features 0 (市内で除外 {skipped_inside}, 手入力 {manual_count}) -> {OUT}") if __name__ == "__main__": |
