diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-25 12:24:24 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-25 12:24:24 +0900 |
| commit | a01062f43d36112c00d5c007ac9d488c14d8fb0d (patch) | |
| tree | b99ddac9928e02b9cd07d372fa42842a348bfb61 /scripts/park-editor.html | |
| parent | a71727538f65ab4f10f5239550be82590f9d47d1 (diff) | |
parks: アイコン・フィルタ・編集画面を更新し、公開を一時停止
- 遊具アイコンをSVGRepo素材に差し替え、施設種別をアイコン表示
- フィルタに種類・遊具・ピクニックを追加し、適用時の地図移動を停止
- 現在地ボタンを追跡トグルに変更し、初回以外は地図を動かさない
- 編集画面を市内公園・周辺公園・公共施設の4タブに拡張
- 公園ページをドラフト化し、サイドバーとPWAの公開を停止
Diffstat (limited to 'scripts/park-editor.html')
| -rw-r--r-- | scripts/park-editor.html | 254 |
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> |
