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: 説明文を更新 --- src/components/ParkMap.astro | 152 ++++++++++++++++++++++++++++++++----------- 1 file changed, 115 insertions(+), 37 deletions(-) (limited to 'src/components/ParkMap.astro') 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); -- cgit v1.3.1