diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-25 21:29:16 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-25 21:29:16 +0900 |
| commit | fa73b6a9f36268986af68192f65978539f4d059c (patch) | |
| tree | 6a4f27d54823d35da29f68ef37b93bba37f71cf2 /scripts/park-editor.html | |
| parent | a01062f43d36112c00d5c007ac9d488c14d8fb0d (diff) | |
parks: シェルター等の統合と編集画面の刷新
パーゴラ・東屋を「シェルター等」に統合し、編集画面に確認チェック・遊具別入力・時計・選択行ハイライトを追加した。周辺公園と周辺施設の自治体名を補完し、公園データの文字化けを修正した。
Diffstat (limited to 'scripts/park-editor.html')
| -rw-r--r-- | scripts/park-editor.html | 311 |
1 files changed, 288 insertions, 23 deletions
diff --git a/scripts/park-editor.html b/scripts/park-editor.html index 868a510..32e91c4 100644 --- a/scripts/park-editor.html +++ b/scripts/park-editor.html @@ -23,11 +23,26 @@ 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; } + td input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; border: none; padding: .25rem .35rem; font: inherit; background: transparent; color: inherit; } + td input:not([type="checkbox"]):focus { outline: 2px solid #3b82f6; } tr.dirty td { background: #ff9d0033; } + tbody tr.is-focused td { background: #3b82f622 !important; } + tbody tr.is-focused td.rownum { color: #1d4ed8; font-weight: 700; opacity: 1; } + tbody tr.is-focused td:first-child { box-shadow: inset 3px 0 0 #2563eb; } + tbody tr.is-focused td:last-child { box-shadow: inset -3px 0 0 #2563eb; } + tbody tr.is-focused.dirty td.rownum::after { + content: " ●"; + color: #f59e0b; + font-size: 0.8em; + } tr[hidden] { display: none; } td.rownum { padding: 0 .4rem; text-align: right; opacity: .5; } + td.checkcell { padding: 0 .35rem; text-align: center; white-space: nowrap; } + td.checkcell input[type="checkbox"] { width: auto; height: auto; margin: 0; padding: 0; } + #check-filter { padding: .3rem .4rem; } + .checked-count { font-size: .82rem; opacity: .85; } + .check-note { font-size: .72rem; opacity: .6; } + #clear-checks { padding: .3rem .6rem; } td.mapcell { text-align: center; } td.mapcell a { text-decoration: none; padding: 0 .4rem; cursor: pointer; } </style> @@ -38,6 +53,14 @@ <h1>公園・施設データ編集</h1> <input id="q" placeholder="絞り込み(名称・住所・備考など)" size="24"> <span id="count"></span> + <select id="check-filter" title="確認状態で絞り込む"> + <option value="">確認: すべて</option> + <option value="done">確認済み</option> + <option value="todo">未確認</option> + </select> + <span id="checked-count" class="checked-count"></span> + <span class="check-note" title="確認チェックはこのブラウザにのみ保存され、JSONには入りません">このブラウザのみ</span> + <button id="clear-checks" title="このデータの確認チェックをすべて外す">確認をクリア</button> <button id="save">保存</button> <button id="revert">再読込</button> <span id="status"></span> @@ -49,16 +72,37 @@ <script> const FIELD = (key, label) => ({ key, label }); +const TOY_ORDER = [ + ["swing", "ブランコ"], + ["slide", "滑台"], + ["sand", "砂場"], + ["bar", "鉄棒"], + ["spring", "スプリング遊具"], + ["complex", "複合遊具"], + ["other", "その他遊具"], +]; + +const TOY_TOKENS = { + swing: ["ブランコ", "ぶらんこ"], + slide: ["滑台", "滑り台", "すべり台"], + sand: ["砂場"], + bar: ["鉄棒"], + spring: ["スプリング"], + complex: ["複合遊具"], +}; + 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("東屋", "東屋"), - FIELD("防災用具", "防災用具"), FIELD("ボール遊び", "ボール遊び"), FIELD("遊具", "遊具"), + FIELD("テーブル", "テーブル"), FIELD("シェルター等", "シェルター・パーゴラ・東屋"), + FIELD("防災用具", "防災用具"), FIELD("ボール遊び", "ボール遊び"), + ...TOY_ORDER.map(([toy, label]) => ({ key: `__toy_${toy}`, label, toy })), + FIELD("時計", "時計"), FIELD("緯度", "緯度"), FIELD("経度", "経度"), FIELD("備考", "備考"), ], }, @@ -67,7 +111,7 @@ const DATASETS = { warning: "周辺の公園は update-nearby-parks.py で再生成すると上書きされます。編集内容は別途保存してください。", fields: [ FIELD("name", "名称"), FIELD("city", "市"), FIELD("distance_m", "距離(m)"), - FIELD("area_m2", "面積(㎡)"), FIELD("note", "備考"), + FIELD("area_m2", "面積(㎡)"), FIELD("時計", "時計"), FIELD("note", "備考"), FIELD("osm_type", "OSM種別"), FIELD("osm_id", "OSM ID"), FIELD("name_source", "名称の出所"), ], @@ -102,6 +146,97 @@ const NUMERIC = { nearbyFacilities: new Set(["lat", "lon", "distance_m"]), }; +function isInsideParentheses(text, index) { + let depth = 0; + for (let i = 0; i < index; i += 1) { + const ch = text[i]; + if (ch === "(" || ch === "(") depth += 1; + else if (ch === ")" || ch === ")") depth = Math.max(0, depth - 1); + } + return depth > 0; +} + +function splitToySegment(segment) { + const hits = []; + for (const [key, tokens] of Object.entries(TOY_TOKENS)) { + for (const token of tokens) { + let index = segment.indexOf(token); + while (index !== -1) { + if (!isInsideParentheses(segment, index)) hits.push({ index, key, token }); + index = segment.indexOf(token, index + token.length); + } + } + } + hits.sort((a, b) => a.index - b.index || b.token.length - a.token.length); + + const unique = []; + for (const hit of hits) { + if (unique.length && hit.index === unique[unique.length - 1].index) continue; + unique.push(hit); + } + + if (unique.length <= 1) { + return [{ key: unique[0]?.key || "other", text: segment }]; + } + + const parts = []; + if (unique[0].index > 0) { + parts.push({ key: "other", text: segment.slice(0, unique[0].index).trim() }); + } + unique.forEach((hit, i) => { + const end = i + 1 < unique.length ? unique[i + 1].index : segment.length; + const text = segment.slice(hit.index, end).trim(); + if (text) parts.push({ key: hit.key, text }); + }); + return parts; +} + +function splitToySegments(text) { + const segments = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "(" || ch === "(") depth += 1; + else if (ch === ")" || ch === ")") depth = Math.max(0, depth - 1); + else if (depth === 0 && (ch === "、" || ch === "," || ch === ",")) { + segments.push(text.slice(start, i)); + start = i + 1; + } + } + segments.push(text.slice(start)); + return segments; +} + +function parseToyString(value) { + const parts = Object.fromEntries(TOY_ORDER.map(([key]) => [key, []])); + const text = String(value || "").trim(); + if (!text) return parts; + + for (const segment of splitToySegments(text)) { + const trimmed = segment.trim(); + if (!trimmed) continue; + for (const part of splitToySegment(trimmed)) { + parts[part.key].push(part.text); + } + } + return parts; +} + +function toyValuesFor(value) { + const parsed = parseToyString(value); + const out = {}; + for (const [key] of TOY_ORDER) out[key] = parsed[key].join("、"); + return out; +} + +function combineToyParts(parts) { + return TOY_ORDER + .map(([key]) => String(parts[key] || "").trim()) + .filter(Boolean) + .join("、"); +} + const tbl = document.getElementById("tbl"); const thead = tbl.querySelector("thead"); const tbody = tbl.querySelector("tbody"); @@ -110,17 +245,70 @@ const count = document.getElementById("count"); const statusEl = document.getElementById("status"); const warningEl = document.getElementById("warning"); const tabsEl = document.getElementById("tabs"); +const checkFilter = document.getElementById("check-filter"); +const checkedCount = document.getElementById("checked-count"); +const clearChecksBtn = document.getElementById("clear-checks"); +const STORAGE_KEY = "park-editor-checks-v1"; let currentId = null; let data = null; let rows = []; let isFC = false; let fields = []; +let checks = {}; function hasDirty() { return Boolean(tbody.querySelector("tr.dirty")); } +function loadChecks() { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); + checks = parsed && typeof parsed === "object" ? parsed : {}; + } catch { + checks = {}; + } +} + +function saveChecks() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(checks)); + } catch { + // ブラウザの保存が使えない場合でも編集自体は続行できるようにする。 + } +} + +function rowKey(props, id) { + if (id === "nearbyParks" || id === "nearbyFacilities") { + if (props.osm_type && props.osm_id) return `${props.osm_type}/${props.osm_id}`; + return `${props.name || ""}|${props.city || ""}|${props.address || ""}`; + } + if (id === "facilities") return `${props.name || ""}|${props.address || ""}`; + return `${props["公園名"] || ""}|${props["住所"] || ""}`; +} + +function isChecked(id, key) { + return Boolean(checks[id] && checks[id][key]); +} + +function setChecked(id, key, value) { + if (!checks[id]) checks[id] = {}; + if (value) checks[id][key] = true; + else delete checks[id][key]; + saveChecks(); + updateCheckedCount(); +} + +function updateCheckedCount() { + if (!currentId) return; + let done = 0; + rows.forEach((row) => { + const props = isFC ? row.properties || {} : row; + if (isChecked(currentId, rowKey(props, currentId))) done++; + }); + checkedCount.textContent = `確認済み ${done} / ${rows.length}件`; +} + function featureCentroid(feature) { const g = feature && feature.geometry; if (!g) return null; @@ -137,18 +325,33 @@ function featureCentroid(feature) { } function mapsUrl(row, props) { - const lat = props["緯度"] != null && props["緯度"] !== "" ? props["緯度"] : props.lat; - const lon = props["経度"] != null && props["経度"] !== "" ? props["経度"] : props.lon; + const fixed = String(props["地図URL"] || "").trim(); + if (fixed) return fixed; + const name = String(props["公園名"] || props.name || "").trim(); + const city = + currentId === "parks" || currentId === "facilities" + ? "小平市" + : String(props.city || "").trim(); 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]}`; + if (name) { + query = city && !name.startsWith(city) ? `${city}${name}` : name; + } + if (!query) { + const address = String(props["住所"] || props.address || "").trim(); + if (address) { + query = city && !address.startsWith(city) ? `${city}${address}` : address; + } + } + if (!query) { + const lat = props["緯度"] != null && props["緯度"] !== "" ? props["緯度"] : props.lat; + const lon = props["経度"] != null && props["経度"] !== "" ? props["経度"] : props.lon; + if (lat != null && lat !== "" && lon != null && lon !== "") { + query = `${lat},${lon}`; + } else if (isFC) { + const c = featureCentroid(row); + if (c) query = `${c[0]},${c[1]}`; + } } - 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); } @@ -196,16 +399,36 @@ async function load(id) { } function render() { - thead.innerHTML = "<tr>" + ["#", "地図"].concat(fields.map((f) => f.label)).map((label) => "<th>" + label + "</th>").join("") + "</tr>"; + thead.innerHTML = "<tr>" + ["#", "確認", "地図"].concat(fields.map((f) => f.label)).map((label) => "<th>" + label + "</th>").join("") + "</tr>"; tbody.innerHTML = ""; rows.forEach((row, r) => { const props = isFC ? row.properties || {} : row; + const toys = toyValuesFor(props["遊具"]); const tr = document.createElement("tr"); const rowNum = document.createElement("td"); rowNum.className = "rownum"; rowNum.textContent = String(r + 1); tr.appendChild(rowNum); + const key = rowKey(props, currentId); + const checkTd = document.createElement("td"); + checkTd.className = "checkcell"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.check = "1"; + cb.dataset.key = key; + cb.checked = isChecked(currentId, key); + cb.title = "確認済み(このブラウザにのみ保存)"; + cb.addEventListener("change", () => { + setChecked(currentId, key, cb.checked); + tbody.querySelectorAll("input[data-check]").forEach((other) => { + if (other.dataset.key === key) other.checked = cb.checked; + }); + filter(); + }); + checkTd.appendChild(cb); + tr.appendChild(checkTd); + const mapTd = document.createElement("td"); mapTd.className = "mapcell"; const mapA = document.createElement("a"); @@ -220,7 +443,7 @@ function render() { fields.forEach((field, c) => { const td = document.createElement("td"); const inp = document.createElement("input"); - inp.value = props[field.key] != null ? props[field.key] : ""; + inp.value = field.toy ? toys[field.toy] : (props[field.key] != null ? props[field.key] : ""); inp.dataset.r = r; inp.dataset.c = c; inp.addEventListener("input", () => { @@ -231,17 +454,23 @@ function render() { tr.appendChild(td); }); - tr.dataset.search = fields.map((f) => String(props[f.key] != null ? props[f.key] : "")).join(" "); + tr.dataset.search = fields.map((f) => f.toy ? toys[f.toy] : String(props[f.key] != null ? props[f.key] : "")).join(" ") + " " + String(props["遊具"] || ""); tbody.appendChild(tr); }); filter(); + updateCheckedCount(); } function filter() { const s = q.value.trim().toLowerCase(); + const checkMode = checkFilter ? checkFilter.value : ""; let shown = 0; Array.prototype.forEach.call(tbody.children, (tr) => { - const ok = !s || tr.dataset.search.toLowerCase().indexOf(s) >= 0; + const cb = tr.querySelector("input[data-check]"); + const checked = Boolean(cb && cb.checked); + const okCheck = !checkMode || (checkMode === "done" ? checked : !checked); + const okText = !s || tr.dataset.search.toLowerCase().indexOf(s) >= 0; + const ok = okText && okCheck; tr.hidden = !ok; if (ok) shown++; }); @@ -249,10 +478,20 @@ function filter() { } function collect() { - Array.prototype.forEach.call(tbody.querySelectorAll("input"), (inp) => { + const toyValues = new Map(); + Array.prototype.forEach.call(tbody.querySelectorAll("input[data-r][data-c]"), (inp) => { const row = rows[Number(inp.dataset.r)]; const props = isFC ? row.properties || (row.properties = {}) : row; const field = fields[Number(inp.dataset.c)]; + if (field.toy) { + let parts = toyValues.get(props); + if (!parts) { + parts = {}; + toyValues.set(props, parts); + } + parts[field.toy] = inp.value; + return; + } let value = inp.value; if (NUMERIC[currentId].has(field.key)) { const t = value.trim(); @@ -265,6 +504,9 @@ function collect() { } props[field.key] = value; }); + toyValues.forEach((parts, props) => { + props["遊具"] = combineToyParts(parts); + }); } document.getElementById("save").onclick = async function () { @@ -294,6 +536,29 @@ document.getElementById("revert").onclick = function () { }; q.addEventListener("input", filter); +checkFilter.addEventListener("change", filter); +tbody.addEventListener("focusin", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + const tr = target.closest("tr"); + if (!tr || !tbody.contains(tr)) return; + tbody.querySelectorAll("tr.is-focused").forEach((row) => { + row.classList.remove("is-focused"); + }); + tr.classList.add("is-focused"); +}); +clearChecksBtn.addEventListener("click", () => { + if (!currentId) return; + if (!confirm(`${DATASETS[currentId].label}の確認チェックをすべて外しますか?`)) return; + checks[currentId] = {}; + saveChecks(); + tbody.querySelectorAll("input[data-check]").forEach((cb) => { + cb.checked = false; + }); + updateCheckedCount(); + filter(); +}); +loadChecks(); renderTabs(); load("parks"); </script> |
