diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:10:30 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-07 23:10:30 +0900 |
| commit | a4840999cdd042179452e03ef831929dac5d3c6d (patch) | |
| tree | 0dcaaa84af4943c05b29550ed7475980870da6b5 /scripts | |
| parent | bc2821adb11a30244fc4663f4fafb756877d9508 (diff) | |
park-editor: 公園データを編集するローカルツールを追加
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/park-editor.html | 148 | ||||
| -rw-r--r-- | scripts/park-editor.mjs | 54 |
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 で終了)`); +}); |
