From a81bb98c971bd9470bbba0c415b6c111f5146ad3 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sun, 8 Nov 2026 00:48:32 +0900 Subject: 公園マップ: フィルタを分類・拡充し、地図の表示不具合を修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ParksFilters.astro: フィルタを「遊具/広場・休憩/設備/利用ルール」に 分類し、パーゴラ・ボール遊び禁止の表示なし・犬禁止の表示なしを追加。 冒頭にβ版の注意書きを追加 ParkMap.astro: 全画面時のズーム調整と、小数ズーム時のタイル読込の 不具合を修正 ParksTable.astro: 絞り込みトークンを追加し、確認済みの表示を反映 park-editor: 確認チェックの自動同期と文字化けを修正 kodaira-parks.json: 水飲み場表記の統一と確認済みを反映 park-icons.ts: パーゴラのアイコンを追加 parks.mdx: 説明文を更新 --- scripts/park-editor.html | 53 ++- scripts/park-editor.mjs | 36 +- src/components/ParkMap.astro | 152 ++++++--- src/components/ParkMapHelp.astro | 15 +- src/components/ParksFilters.astro | 173 +++++++--- src/components/ParksTable.astro | 564 +++++++++++++++++++++----------- src/content/docs/kodaira-data/parks.mdx | 6 +- src/data/kodaira-parks.json | 2 +- src/data/park-icons.ts | 4 +- 9 files changed, 705 insertions(+), 300 deletions(-) diff --git a/scripts/park-editor.html b/scripts/park-editor.html index 6c4605a..ce93b29 100644 --- a/scripts/park-editor.html +++ b/scripts/park-editor.html @@ -91,7 +91,7 @@ - このブラウザのみ + 自動反映 @@ -367,12 +367,44 @@ function setChecked(id, key, value) { updateCheckedCount(); } +// 確認チェックを、その場でJSONにも反映する(サイトの「未確認」用)。 +const syncCheck = async (index, checked) => { + try { + await fetch("/api/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: currentId, updates: [{ index, checked }] }), + }); + } catch { + // 反映に失敗しても編集は続行できるようにする。 + } +}; + +// このブラウザに付いているチェックを、開いたときにJSONへ同期する。 +function syncExistingChecks() { + const updates = []; + rows.forEach((row, r) => { + const props = isFC ? row.properties || {} : row; + const key = rowKey(props, currentId); + if (isChecked(currentId, key) && (props["確認"] || "") !== "済") { + updates.push({ index: r, checked: true }); + } + }); + if (!updates.length) return; + fetch("/api/check", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: currentId, updates }), + }).catch(() => {}); +} + function updateCheckedCount() { if (!currentId) return; let done = 0; rows.forEach((row) => { const props = isFC ? row.properties || {} : row; - if (isChecked(currentId, rowKey(props, currentId))) done++; + // ブラウザのチェックだけでなく、すでにJSONに書き込まれた「確認:済」も数える。 + if (isChecked(currentId, rowKey(props, currentId)) || (props["確認"] || "") === "済") done++; }); checkedCount.textContent = `確認済み ${done} / ${rows.length}件`; } @@ -461,6 +493,7 @@ async function load(id) { rows = isFC ? data.features : data; populateTypeFilter(); render(); + syncExistingChecks(); showCorruptKeys(); statusEl.textContent = `${DATASETS[id].label}: ${rows.length}件`; } catch (e) { @@ -534,14 +567,16 @@ function render() { cb.type = "checkbox"; cb.dataset.check = "1"; cb.dataset.key = key; - cb.checked = isChecked(currentId, key); - cb.title = "確認済み(このブラウザにのみ保存)"; + cb.dataset.r = String(r); + cb.checked = isChecked(currentId, key) || (props["確認"] || "") === "済"; + 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(); + syncCheck(r, cb.checked); }); checkTd.appendChild(cb); tr.appendChild(checkTd); @@ -754,6 +789,16 @@ document.getElementById("save").onclick = async function () { } } collect(); + // 確認チェックの状態をデータにも書き込む(サイトの「未確認」に反映)。 + const checkedByKey = new Map(); + tbody.querySelectorAll("input[data-check]").forEach((cb) => { + checkedByKey.set(cb.dataset.key, cb.checked); + }); + rows.forEach((row) => { + const props = isFC ? row.properties || {} : row; + const key = rowKey(props, currentId); + props["確認"] = checkedByKey.get(key) ? "済" : ""; + }); statusEl.textContent = "保存中…"; statusEl.classList.remove("has-warning"); try { diff --git a/scripts/park-editor.mjs b/scripts/park-editor.mjs index feeb059..b3a3e80 100644 --- a/scripts/park-editor.mjs +++ b/scripts/park-editor.mjs @@ -109,7 +109,7 @@ function canonicalKeysFrom(lists) { return [...keys]; } -// 「ベンチ・椅���」のような項目名の文字化けを、前後の文字から正しい項目名に戻す +// 「ベンチ・椅子」のような項目名の文字化けを、前後の文字から正しい項目名に戻す function normalizeRecordKeys(rec, canonicalKeys) { const src = rec && rec.properties ? rec.properties : rec; if (!src) return; @@ -241,6 +241,40 @@ const server = createServer(async (req, res) => { return; } + // 確認チェックの切り替えを、その場でJSONに反映する(サイトの「未確認」用)。 + if (req.method === "POST" && url === "/api/check") { + let body = ""; + for await (const chunk of req) body += chunk; + const payload = JSON.parse(body); + const dataset = DATASETS[payload.id]; + if (!dataset) { + send(res, 404, "text/plain; charset=utf-8", "not found"); + return; + } + const data = JSON.parse(await readFile(dataset.path, "utf-8")); + const list = payload.id === "nearbyParks" ? data.features : data; + const updates = Array.isArray(payload.updates) ? payload.updates : []; + for (const update of updates) { + const rec = list[Number(update.index)]; + if (!rec) continue; + const props = rec.properties || rec; + props["確認"] = update.checked ? "済" : ""; + } + const text = dataset.pretty + ? `${JSON.stringify(data, null, 2)}\n` + : `${JSON.stringify(data)}\n`; + const tmp = `${dataset.path}.tmp`; + await writeFile(tmp, text, "utf-8"); + await rename(tmp, dataset.path); + send( + res, + 200, + "application/json; charset=utf-8", + JSON.stringify({ ok: true, count: updates.length }), + ); + return; + } + const target = datasetFromUrl(url); if (!target) { send(res, 404, "text/plain; charset=utf-8", "not found"); diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 3a208db..b9aa641 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -24,6 +24,9 @@ width: 100%; border: 1px solid var(--sl-color-gray-5); border-radius: 8px; + /* タイルが未読込のとき、ダークテーマの背景色(青みのある濃色)が + 地図の領域に見えないよう、地図らしい下地色を敷いておく。 */ + background: #e8e6e1; } .park-map:fullscreen, .park-map:-webkit-full-screen { @@ -97,6 +100,9 @@ font-size: 0.7rem; font-weight: 700; } + :global(.park-map-filter-status[hidden]) { + display: none; + } :global(.park-map-filter-footer) { flex: none; display: flex; @@ -167,6 +173,11 @@ border-color: #e0e0e0; background: #fff; color: #6d4c41; + padding: 0; + } + :global(.park-popup-ico-pet svg) { + width: 100%; + height: 100%; } :global(.park-popup-ico-clock) { border-color: #ffe082; @@ -179,12 +190,6 @@ color: #fff; font-size: 8px; } - /* ボール遊びは可で試合形式だけ禁止のときに「試」バッジを並べる */ - :global(.park-popup-ico-ball) { - width: auto; - gap: 2px; - padding: 0 2px; - } :global(.park-popup-mini) { position: relative; display: inline-flex; @@ -264,6 +269,17 @@ color: #0b57d0; font-weight: 700; } + :global(.google-ico) { + display: inline-flex; + flex: none; + width: 15px; + height: 15px; + } + :global(.google-ico svg) { + display: block; + width: 100%; + height: 100%; + } :global(.park-name-marker) { background: none; @@ -851,7 +867,7 @@ dragging: !isTouch, touchZoom: true, scrollWheelZoom: true, - zoomSnap: 1, + zoomSnap: 0.1, zoomDelta: 1, zoomAnimation: false, fadeAnimation: false, @@ -923,7 +939,7 @@ // 帰属表示の「Leaflet」旗アイコンを消す(OSMの表示は残す)。 map.attributionControl.setPrefix(false); - const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap contributors', @@ -955,7 +971,7 @@ if (filterCaret) filterCaret.textContent = "▾"; }); filterFooter.appendChild(filterClose); - filterPanel.append(filterToggle, filterBody, filterFooter); + filterPanel.append(filterToggle, filterBody); L.DomEvent.disableClickPropagation(filterPanel); L.DomEvent.disableScrollPropagation(filterPanel); filterToggle.addEventListener("click", () => { @@ -974,6 +990,7 @@ next: controls.nextSibling, }; filterBody.append(controls); + filterBody.append(filterFooter); map.getContainer().appendChild(filterPanel); filterPanel.hidden = false; filterBody.hidden = false; @@ -999,6 +1016,30 @@ const target = map.getContainer(); const isCssMax = () => target.classList.contains("is-maximized"); + // 全画面の切替前のサイズ・ズーム。Leaflet はコンテナが大きくなっても + // ズームを自動では変えないため、切替後も同じ範囲が画面に収まるよう、 + // 画面の大きさの比に応じてズームを合わせ直す。 + let preSize: { x: number; y: number } | null = null; + let preZoom = 0; + let lastSize: { x: number; y: number } | null = null; + const captureNow = () => { + preSize = map.getSize(); + preZoom = map.getZoom(); + }; + const scaleZoomToFit = () => { + if (!preSize) return; + const now = map.getSize(); + // 面積比の平方根(縦横の拡大率の相乗平均)で拡大・縮小する。 + // 入るときと出るときで同じ倍率になり、行って戻ると元のズームに戻る。 + const ratio = Math.sqrt((now.x * now.y) / (preSize.x * preSize.y)); + if (!Number.isFinite(ratio) || ratio <= 0 || Math.abs(ratio - 1) < 0.02) return; + const z = preZoom + Math.log2(ratio); + map.setView( + map.getCenter(), + Math.max(map.getMinZoom(), Math.min(map.getMaxZoom(), z)), + { animate: false }, + ); + }; const update = () => { const active = document.fullscreenElement === target || isCssMax(); button.innerHTML = active ? "✕" : "⛶"; @@ -1010,8 +1051,24 @@ if (active) map.dragging.enable(); else map.dragging.disable(); } - window.setTimeout(() => map.invalidateSize(), 60); - window.setTimeout(() => map.invalidateSize(), 350); + // Esc などボタン操作以外で切り替わったときのために、直前の状態を控える。 + if (!preSize) { + preSize = lastSize || map.getSize(); + preZoom = map.getZoom(); + } + // 端末によってサイズが変わるタイミングが違うので、2回に分けて合わせる。 + window.setTimeout(() => { + map.invalidateSize(); + scaleZoomToFit(); + }, 60); + window.setTimeout(() => { + map.invalidateSize(); + scaleZoomToFit(); + }, 350); + window.setTimeout(() => { + lastSize = map.getSize(); + preSize = null; + }, 600); }; let maxHome: { parent: Node; next: Node | null } | null = null; const enterCssMax = () => { @@ -1035,6 +1092,8 @@ L.DomEvent.on(button, "click", (ev: Event) => { L.DomEvent.stop(ev); + // 切替後のズーム合わせ用に、切替前のサイズ・ズームを控えておく。 + captureNow(); if (document.fullscreenElement === target) { document.exitFullscreen?.().catch(() => exitCssMax()); return; @@ -1127,24 +1186,35 @@ .filter((s) => s && !s.includes("防火水槽") && !s.includes("消火栓")) .filter((s) => DISASTER_KEEP.test(s)) .join("、"); - const waterRaw = String(rec?.["水飲み場"] || "").trim(); - // 水飲み場は数量ではなく有無だけを、分かるように表示する。 - const water = waterRaw - ? /[??]/.test(waterRaw) - ? "水飲み場あり?" - : "水飲み場あり" - : "水飲み場なし"; + // 水飲み場は「水飲み場x1」のように、手洗いも含めて数を示す。 + const waterText = (value: string) => { + const text = String(value || "").trim(); + if (!text) return "水飲み場なし"; + if (/[??]/.test(text)) return "水飲み場?"; + let total = 0; + for (const part of text.split(/[、,]/)) { + const nums = [...part.matchAll(/[x×](\d+)/g)].map((m) => Number(m[1])); + if (nums.length) total += nums.reduce((a, b) => a * b, 1); + } + return total > 0 ? `水飲み場x${total}` : "水飲み場"; + }; + const water = waterText(String(rec?.["水飲み場"] || "")); // ○/〇 などの表記は「〜あり」にして意味が分かるようにする。 const presence = (label: string, value: unknown) => { const v = String(value ?? "").trim(); if (!v) return ""; return /^[○〇◯あり有1]+$/.test(v) ? `${label}あり` : v; }; - // ボール遊びは、試合形式の可否を括弧書きで添える。 + // ボール遊び禁止なら試合形式の記載は省く。試合形式だけ禁止ならそれだけを示す。 const ballText = String(rec?.["ボール遊び"] || "").trim(); const matchText = String(rec?.["試合形式のボール遊び"] || "").trim(); - const ball = - ballText && matchText ? `${ballText}(試合形式${matchText})` : ballText; + const ballBanned = /禁止/.test(ballText); + const matchBanned = /禁止/.test(matchText); + const ball = ballBanned + ? "ボール遊び禁止" + : matchBanned + ? "試合形式禁止" + : ballText; // 項目名は1文字の文字アイコンで示す(水・ベンチ・池は絵アイコン)。 const rowIcons: Record = { 遊具: "遊", @@ -1191,12 +1261,9 @@ const labelText = String(label); const valueText = String(value); const isBall = labelText === "ボール遊び"; - // ボール遊び全体が禁止ならボールに×、試合形式だけ禁止なら「試」に×を付ける。 + // ボール遊び全体が禁止のときだけボールに×を付ける。 const banned = isBall ? /禁止/.test(ballText) : /禁止/.test(valueText); - let iconHtml = rowIcons[labelText] || ""; - if (isBall && !banned && /禁止/.test(matchText)) { - iconHtml += '試×'; - } + const iconHtml = rowIcons[labelText] || ""; const icoClass = `park-popup-ico${ROW_MOD[labelText] || ""}${banned ? " is-banned" : ""}${isBall ? " park-popup-ico-ball" : ""}`; return `
${iconHtml}${escapePopup(valueText)}
`; }) @@ -1210,7 +1277,7 @@ rec?.["地図URL"] || `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; const route = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(dest)}&travelmode=walking`; - return `
${name}${owner ? `${escapePopup(owner)}` : ""}${addr || area || type ? `
${[addr, area, type].filter(Boolean).join(" ・ ")}
` : ""}${detailHtml}
`; + return `
${name}${owner ? `${escapePopup(owner)}` : ""}${addr || area || type ? `
${[addr, area, type].filter(Boolean).join(" ・ ")}
` : ""}${detailHtml}
`; }; const filterName = (name: string, scroll = false) => { @@ -1908,14 +1975,14 @@ const nearbyHit = nearbyEntries.find((entry) => entry.key === key); if (nearbyHit) { const group = L.featureGroup([nearbyHit.layer]); - map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 }); + map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() }); nearbyHit.layer.openPopup?.(); return true; } } const target = layersByName.get(name); if (target) { - map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: 17 }); + map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() }); const targetLayer = typeof target.getLayers === "function" ? target.getLayers()[0] : target; targetLayer?.openPopup?.(); @@ -1926,7 +1993,7 @@ .map((entry) => entry.layer); if (nearbyTargets.length) { const group = L.featureGroup(nearbyTargets); - map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 }); + map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: map.getMaxZoom() }); nearbyTargets[0].openPopup?.(); return true; } @@ -1950,30 +2017,39 @@ // フィルタの対象(複数ならその範囲)に地図をフォーカスする。 let filterFocusTimer = 0; - const focusOnParks = (names: Set | null) => { + const focusOnParks = (names: Set | null, nearbyNames: Set | null) => { const bounds = L.latLngBounds([]); const add = (lyr: any) => { const b = lyr?.getBounds?.(); if (b && b.isValid()) bounds.extend(b); }; + const addPoint = (ll: any) => { + if (ll) bounds.extend(ll); + }; if (names) { allLayers.forEach((lyr) => { if (names.has(lyr._parkName || "")) add(lyr); }); nearbyEntries.forEach(({ name, layer }) => { - if (names.has(name)) add(layer); + if (nearbyNames && nearbyNames.has(name)) add(layer); + }); + facilityMarkers.forEach(({ name, layer }) => { + if (names.has(name)) addPoint(layer.getLatLng?.()); }); } else { allLayers.forEach(add); + facilityMarkers.forEach(({ layer }) => addPoint(layer.getLatLng?.())); } if (!bounds.isValid()) return; - map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16, animate: true }); + map.fitBounds(bounds, { padding: [40, 40], maxZoom: map.getMaxZoom(), animate: true }); }; // 選択中の公園を強調するためのクラス(グロー表示) document.addEventListener("park:filter", (e: Event) => { const detail = (e as CustomEvent).detail || {}; const names = new Set(detail.names || []); + // 市外の近い公園は小平市内と同名でも別の公園なので、別セットで扱う。 + const nearbyNames = new Set(detail.nearbyNames ?? detail.names ?? []); const active = Boolean(detail.active); if (filterStatus) filterStatus.hidden = !active; const fromMap = Boolean(detail.fromMap); @@ -1997,7 +2073,7 @@ } }); nearbyEntries.forEach(({ name, layer: lyr }) => { - const selected = Boolean(name && names.has(name)); + const selected = Boolean(name && nearbyNames.has(name)); lyr.setStyle(selected ? NEARBY_SELECTED_STYLE : nearbyBaseStyle(lyr)); markSelected(lyr, selected); if (selected) lyr.bringToFront?.(); @@ -2037,7 +2113,7 @@ markBlink(lyr, true); return; } - if (name && names.has(name)) { + if (name && nearbyNames.has(name)) { lyr.setStyle(NEARBY_SELECTED_STYLE); markSelected(lyr, true); lyr.bringToFront?.(); @@ -2056,7 +2132,7 @@ // フィルタの対象にフォーカスする(キーワード入力は少し待ってから)。 if (detail.focus) { - const run = () => focusOnParks(active ? names : null); + const run = () => focusOnParks(active ? names : null, active ? nearbyNames : null); window.clearTimeout(filterFocusTimer); if (detail.focus === "later") { filterFocusTimer = window.setTimeout(run, 800); @@ -2223,7 +2299,9 @@ // レイアウト確定後にサイズを再計算し、タイルを再配置する。 const refresh = () => { map.invalidateSize(); - tileLayer.redraw(); + // redraw() は使わない。Leaflet 1.9 の redraw() は小数ズーム時に + // _tileZoom を小数(例: 12.9)にしてしまい、タイルが読めなくなる。 + // invalidateSize と fitAll だけでタイルは更新される。 fitAll(); }; requestAnimationFrame(refresh); diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro index 4402567..5bb0387 100644 --- a/src/components/ParkMapHelp.astro +++ b/src/components/ParkMapHelp.astro @@ -38,7 +38,7 @@ align-items: center; margin-top: 0.5rem; color: var(--sl-color-gray-2); - font-size: 0.8rem; + font-size: 1rem; } .park-map-legend span { display: inline-flex; @@ -47,8 +47,9 @@ } .swatch { display: inline-block; - width: 12px; - height: 12px; + box-sizing: border-box; + width: 18px; + height: 18px; border-radius: 2px; } .swatch-matched { @@ -122,7 +123,7 @@ .park-map-note { margin-top: 0.4rem; color: var(--sl-color-gray-2); - font-size: 0.8rem; + font-size: 1rem; } .park-play-legend { display: flex; @@ -130,7 +131,11 @@ gap: 0.35rem 0.8rem; margin: 0.5rem 0 0; color: var(--sl-color-gray-2); - font-size: 0.78rem; + font-size: 1rem; + } + .park-play-legend :global(.park-play) { + width: 18px; + height: 18px; } .park-play-legend-item { display: inline-flex; diff --git a/src/components/ParksFilters.astro b/src/components/ParksFilters.astro index 576c0ff..e93b4ed 100644 --- a/src/components/ParksFilters.astro +++ b/src/components/ParksFilters.astro @@ -55,57 +55,81 @@ const facilityTypes = [
設備・遊具で絞る - - - - - - - - - - - - - - - - - - +
+ 遊具 +
+ + + + + + + + + + + + +
+
+
+ 広場・休憩 +
+ + + + +
+
+
+ 設備 +
+ + + +
+
+
+ 利用ルール +
+ + + 利用ルールは実際と異なる場合があります。現地の看板などでご確認ください。 +
+