aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts/park-editor.html
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/park-editor.html
parenta71727538f65ab4f10f5239550be82590f9d47d1 (diff)
parks: アイコン・フィルタ・編集画面を更新し、公開を一時停止
- 遊具アイコンをSVGRepo素材に差し替え、施設種別をアイコン表示 - フィルタに種類・遊具・ピクニックを追加し、適用時の地図移動を停止 - 現在地ボタンを追跡トグルに変更し、初回以外は地図を動かさない - 編集画面を市内公園・周辺公園・公共施設の4タブに拡張 - 公園ページをドラフト化し、サイドバーとPWAの公開を停止
Diffstat (limited to 'scripts/park-editor.html')
-rw-r--r--scripts/park-editor.html254
1 files changed, 202 insertions, 52 deletions
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>