aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-25 12:24:24 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-25 12:24:24 +0900
commita01062f43d36112c00d5c007ac9d488c14d8fb0d (patch)
treeb99ddac9928e02b9cd07d372fa42842a348bfb61 /scripts
parenta71727538f65ab4f10f5239550be82590f9d47d1 (diff)
parks: アイコン・フィルタ・編集画面を更新し、公開を一時停止
- 遊具アイコンをSVGRepo素材に差し替え、施設種別をアイコン表示 - フィルタに種類・遊具・ピクニックを追加し、適用時の地図移動を停止 - 現在地ボタンを追跡トグルに変更し、初回以外は地図を動かさない - 編集画面を市内公園・周辺公園・公共施設の4タブに拡張 - 公園ページをドラフト化し、サイドバーとPWAの公開を停止
Diffstat (limited to 'scripts')
-rw-r--r--scripts/make-park-qgis-kit.py65
-rw-r--r--scripts/park-editor.html254
-rw-r--r--scripts/park-editor.mjs106
-rw-r--r--scripts/update-nearby-facilities.py179
-rw-r--r--scripts/update-nearby-parks.py243
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__":