aboutsummaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:10:30 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-07 23:10:30 +0900
commita4840999cdd042179452e03ef831929dac5d3c6d (patch)
tree0dcaaa84af4943c05b29550ed7475980870da6b5 /scripts
parentbc2821adb11a30244fc4663f4fafb756877d9508 (diff)
park-editor: 公園データを編集するローカルツールを追加
Diffstat (limited to 'scripts')
-rw-r--r--scripts/park-editor.html148
-rw-r--r--scripts/park-editor.mjs54
2 files changed, 202 insertions, 0 deletions
diff --git a/scripts/park-editor.html b/scripts/park-editor.html
new file mode 100644
index 0000000..01df13f
--- /dev/null
+++ b/scripts/park-editor.html
@@ -0,0 +1,148 @@
+<!doctype html>
+<html lang="ja">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<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 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); }
+ 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; }
+ td { padding: 0; }
+ td input { width: 100%; box-sizing: border-box; border: none; padding: .25rem .35rem; font: inherit; background: transparent; color: inherit; }
+ td input:focus { outline: 2px solid #3b82f6; }
+ tr.dirty td { background: #ff9d0033; }
+ tr[hidden] { display: none; }
+ td.rownum { padding: 0 .4rem; text-align: right; opacity: .5; }
+ td.mapcell { text-align: center; }
+ td.mapcell a { text-decoration: none; padding: 0 .4rem; cursor: pointer; }
+</style>
+</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>
+</header>
+<div class="wrap"><table id="tbl"><thead></thead><tbody></tbody></table></div>
+<script>
+const FIELDS = ["公園名","住所","町名","面積㎡","500㎡以上","種別","南西部指定管理","トイレ","水飲み場","ベンチ・椅子","テーブル","防災用具","ボール遊び","遊具","備考"];
+let parks = [];
+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");
+
+function mapsUrl(name) {
+ return "https://www.google.com/maps/search/?api=1&query=" + encodeURIComponent("小平市" + (name || ""));
+}
+
+async function load() {
+ const res = await fetch("/api/parks", { cache: "no-store" });
+ parks = await res.json();
+ render();
+}
+
+function render() {
+ thead.innerHTML = "<tr>" + ["#", "地図"].concat(FIELDS).map(function (f) { return "<th>" + f + "</th>"; }).join("") + "</tr>";
+ tbody.innerHTML = "";
+ parks.forEach(function (p, r) {
+ const tr = document.createElement("tr");
+ const th = document.createElement("td");
+ th.className = "rownum";
+ th.textContent = String(r + 1);
+ tr.appendChild(th);
+ const mapTd = document.createElement("td");
+ mapTd.className = "mapcell";
+ const mapA = document.createElement("a");
+ mapA.textContent = "🗺";
+ mapA.title = "Googleマップで開く";
+ mapA.href = mapsUrl(p["公園名"] || "");
+ 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) {
+ const td = document.createElement("td");
+ const inp = document.createElement("input");
+ inp.value = p[f] != null ? p[f] : "";
+ inp.dataset.r = r;
+ inp.dataset.c = c;
+ inp.addEventListener("input", function () {
+ tr.classList.add("dirty");
+ status.textContent = "未保存の変更があります";
+ });
+ td.appendChild(inp);
+ tr.appendChild(td);
+ });
+ tr.dataset.search = (p["公園名"] || "") + " " + (p["町名"] || "") + " " + (p["住所"] || "");
+ tbody.appendChild(tr);
+ });
+ filter();
+}
+
+function filter() {
+ const s = q.value.trim();
+ let shown = 0;
+ Array.prototype.forEach.call(tbody.children, function (tr) {
+ const ok = !s || tr.dataset.search.indexOf(s) >= 0;
+ tr.hidden = !ok;
+ if (ok) shown++;
+ });
+ count.textContent = shown + " / " + parks.length + " 件";
+}
+
+function collect() {
+ Array.prototype.forEach.call(tbody.querySelectorAll("input"), function (inp) {
+ parks[Number(inp.dataset.r)][FIELDS[Number(inp.dataset.c)]] = inp.value;
+ });
+}
+
+document.getElementById("save").onclick = async function () {
+ collect();
+ status.textContent = "保存中…";
+ try {
+ const res = await fetch("/api/parks", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(parks),
+ });
+ if (res.ok) {
+ status.textContent = "保存しました(" + parks.length + "件)。git にコミットを忘れずに。";
+ Array.prototype.forEach.call(tbody.children, function (tr) { tr.classList.remove("dirty"); });
+ } else {
+ status.textContent = "保存に失敗: " + (await res.text());
+ }
+ } catch (e) {
+ status.textContent = "保存に失敗: " + e.message;
+ }
+};
+
+document.getElementById("revert").onclick = function () {
+ if (confirm("保存していない変更は破棄されます。よろしいですか?")) load();
+};
+
+q.addEventListener("input", filter);
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/park-editor.mjs b/scripts/park-editor.mjs
new file mode 100644
index 0000000..7a6c2fa
--- /dev/null
+++ b/scripts/park-editor.mjs
@@ -0,0 +1,54 @@
+// 公園データの簡易エディタ(ローカル専用)。
+//
+// 使い方:
+// node scripts/park-editor.mjs
+// → 表示された URL(既定 http://localhost:8787/)をブラウザで開く
+// → 表のセルを直接編集し、「保存」を押すと src/data/kodaira-parks.json を更新
+// → 反映確認後、git にコミットする
+//
+// 注意: ローカル(127.0.0.1)にのみ公開します。追加パッケージは不要です。
+import { createServer } from "node:http";
+import { readFile, writeFile } from "node:fs/promises";
+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);
+
+function send(res, code, type, body) {
+ res.writeHead(code, { "content-type": type });
+ res.end(body);
+}
+
+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") {
+ let body = "";
+ for await (const chunk of req) body += chunk;
+ const parks = JSON.parse(body);
+ if (!Array.isArray(parks) || parks.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");
+ }
+ } catch (e) {
+ console.error(e);
+ send(res, 500, "text/plain; charset=utf-8", String(e));
+ }
+});
+
+server.listen(PORT, "127.0.0.1", () => {
+ console.log(`公園データ編集: http://localhost:${PORT}/ (Ctrl+C で終了)`);
+});