diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-11-11 08:21:26 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-11-11 08:21:26 +0900 |
| commit | 61803a9988cb826488905aeb86992a927377a94b (patch) | |
| tree | 5d2003d1a22a98896c575e1821fbdd43bdcf50d8 /src/components/ParkMap.astro | |
| parent | fe63bb9012587c9c65bc246dabbdabe7054fb0e5 (diff) | |
公園マップ: iPad全画面・フォーカス制御を修正し、リンクを更新
ParkMap.astro: iPadはCSS最大化にしてドラッグ解除を回避、アイコン枠の
ハイライト、ズームアウト下限、拡大中の自動フォーカス停止と
「該当する公園へズーム」の案内を追加
kodaira-parks.json: さくら・萩山・けやき公園の地図URLを更新し、
けやき公園の設置者を国分寺市に。文字化けした重複3行と項目名を整理
parks.mdx: ペットの「禁止」の意味を注記
Diffstat (limited to 'src/components/ParkMap.astro')
| -rw-r--r-- | src/components/ParkMap.astro | 227 |
1 files changed, 217 insertions, 10 deletions
diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 910f6d1..9be1661 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -51,6 +51,7 @@ } :global(body.park-map-maximized) { overflow: hidden; + overscroll-behavior: none; } :global(.park-map-filter-panel) { position: absolute; @@ -83,6 +84,7 @@ } :global(.park-map-filter-body) { overflow: auto; + overscroll-behavior: contain; padding: 0.5rem; } :global(.park-map-filter-body .parks-controls) { @@ -374,6 +376,57 @@ stroke-width: 4.5; filter: drop-shadow(0 0 7px rgba(191, 54, 12, 0.8)); } + /* 遊具・設備アイコンの枠も、選択中は同じ色で強調する */ + :global(.park-map[data-park-theme="standard"]) { + --park-sel-ring: #1b5e20; + } + :global(.park-map[data-park-theme="strong"]) { + --park-sel-ring: #111111; + } + :global(.park-map[data-park-theme="cvd"]) { + --park-sel-ring: #bf360c; + } + :global(.park-play-markers.is-park-selected), + :global(.park-fac-markers.is-park-selected) { + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 0 0 2px #fff, 0 0 0 5px var(--park-sel-ring, #1b5e20); + } + /* 表示範囲に該当公園がないときの案内 */ + :global(.park-map-notice) { + position: absolute; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + z-index: 1200; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + max-width: calc(100% - 24px); + padding: 0.5rem 0.7rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: 10px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18); + color: #1e293b; + font-size: 0.85rem; + } + :global(.park-map-notice[hidden]) { + display: none; + } + :global(.park-map-notice-btn) { + flex: none; + padding: 0.3rem 0.6rem; + border: 1px solid #1b5e20; + border-radius: 999px; + background: #1b5e20; + color: #fff; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + } @keyframes park-selected-pulse { 0%, 100% { @@ -875,12 +928,20 @@ opacity: 0.2, }; - // 選択中の公園のグロー表示 + // 選択中の公園のグロー表示。遊具・設備アイコンの枠も合わせて強調する。 + const selectedParkNames = new Set<string>(); + let iconHighlightHook: ((name: string, on: boolean) => void) | null = null; + let hideViewNotice: () => void = () => {}; const markSelected = (lyr: any, selected: boolean) => { const element = lyr?.getElement?.(); if (element && element.classList) { element.classList.toggle("park-selected", selected); } + const name = lyr?._parkName || ""; + if (!name) return; + if (selected) selectedParkNames.add(name); + else selectedParkNames.delete(name); + iconHighlightHook?.(name, selected); }; // フィルタがないときの位置点滅 const markBlink = (lyr: any, on: boolean) => { @@ -1167,8 +1228,12 @@ exitCssMax(); return; } - // Fullscreen API が使えない端末(iPhoneなど)はCSSで最大化する。 - if (document.fullscreenEnabled && target.requestFullscreen) { + // iPad はブラウザの全画面だと下方向のドラッグで解除されることがあるため、 + // あえて CSS で最大化する。それ以外は Fullscreen API を使う。 + const isIPad = + /iPad/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + if (!isIPad && document.fullscreenEnabled && target.requestFullscreen) { target.requestFullscreen().catch(() => enterCssMax()); } else { enterCssMax(); @@ -1225,6 +1290,51 @@ ? "民間" : owner; + // 公園を選んだときは、その公園だけを強調し、ほかは通常の点滅に戻す。 + // フキダシを閉じたら、現在の絞り込み状態の表示に戻す。 + let activePopupLayer: any = null; + let filterActiveNow = false; + let filterNamesNow = new Set<string>(); + let filterNearbyNamesNow = new Set<string>(); + const applyBaseStyle = (lyr: any) => { + if (lyr._isNearby) lyr.setStyle(nearbyBaseStyle(lyr)); + else lyr.setStyle(lyr._parkName ? MATCHED_STYLE : UNMATCHED_STYLE); + markSelected(lyr, false); + markBlink(lyr, true); + }; + // 選択中の公園以外を、通常の点滅表示に戻す。 + const clearOtherSelections = (keep: any) => { + allLayers.forEach((lyr) => { + if (lyr !== keep) applyBaseStyle(lyr); + }); + nearbyEntries.forEach(({ layer }) => { + if (layer !== keep) applyBaseStyle(layer); + }); + }; + // フキダシを閉じたときに、現在の絞り込み結果の強調表示に戻す。 + const applyFilterHighlight = () => { + allLayers.forEach((lyr) => { + const nm = lyr._parkName || ""; + if (filterActiveNow && filterNamesNow.has(nm)) { + lyr.setStyle(SELECTED_STYLE); + markSelected(lyr, true); + markBlink(lyr, false); + } else { + applyBaseStyle(lyr); + } + }); + nearbyEntries.forEach(({ name: nm, layer }) => { + if (filterActiveNow && filterNearbyNamesNow.has(nm)) { + layer.setStyle(NEARBY_SELECTED_STYLE); + markSelected(layer, true); + markBlink(layer, false); + } else { + applyBaseStyle(layer); + } + }); + applyIconFilter(filterActiveNow ? filterNamesNow : null); + }; + const escapePopup = (value: string) => value.replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch] as string, @@ -1462,15 +1572,17 @@ maxWidth: 300, }); lyr.on("popupopen", () => { + clearOtherSelections(lyr); + activePopupLayer = lyr; + hideViewNotice(); lyr.setStyle(SELECTED_STYLE); markSelected(lyr, true); markBlink(lyr, false); lyr.bringToFront?.(); }); lyr.on("popupclose", () => { - lyr.setStyle(MATCHED_STYLE); - markSelected(lyr, false); - markBlink(lyr, true); + if (activePopupLayer === lyr) activePopupLayer = null; + applyFilterHighlight(); }); lyr.on("click", (ev: any) => { if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent); @@ -1542,21 +1654,25 @@ const city = String(p.city || "").trim(); const key = `${p.osm_type || ""}/${p.osm_id || ""}`; lyr._isPoint = feature.geometry?.type === "Point"; + lyr._isNearby = true; + lyr._parkName = name; if (name) nearbyEntries.push({ name, layer: lyr, city, key }); if (name) { lyr.bindPopup(buildPopup(name, name, city ? `市外・${city}` : "", nearbyMapsQuery(name, city)), { maxWidth: 300, }); lyr.on("popupopen", () => { + clearOtherSelections(lyr); + activePopupLayer = lyr; + hideViewNotice(); lyr.setStyle(NEARBY_SELECTED_STYLE); markSelected(lyr, true); markBlink(lyr, false); lyr.bringToFront?.(); }); lyr.on("popupclose", () => { - lyr.setStyle(nearbyBaseStyle(lyr)); - markSelected(lyr, false); - markBlink(lyr, true); + if (activePopupLayer === lyr) activePopupLayer = null; + applyFilterHighlight(); }); } lyr.on("click", (ev: any) => { @@ -1597,6 +1713,7 @@ const el = marker.getElement?.(); if (!el || el.getAttribute("data-park") === name) return; el.setAttribute("data-park", name); + if (selectedParkNames.has(name)) el.classList.add("is-park-selected"); L.DomEvent.on(el, "click", (e: any) => { L.DomEvent.stopPropagation(e); openParkPopup(name); @@ -1683,6 +1800,15 @@ const facilityLayer = L.layerGroup(); const facilityMarkersByName = new Map<string, any[]>(); const playMarkersByName = new Map<string, any[]>(); + // 公園が選択されたら、その公園のアイコン枠にも強調用のクラスを付ける。 + iconHighlightHook = (name, on) => { + (facilityMarkersByName.get(name) || []).forEach((m: any) => { + m.getElement?.()?.classList.toggle("is-park-selected", on); + }); + (playMarkersByName.get(name) || []).forEach((m: any) => { + m.getElement?.()?.classList.toggle("is-park-selected", on); + }); + }; layersByName.forEach((lyr, name) => { const p = parkDataByName.get(name); if (!p) return; @@ -2115,6 +2241,70 @@ map.fitBounds(bounds, { padding: [40, 40], maxZoom: map.getMaxZoom(), animate: true }); }; + // 表示範囲に該当する公園がないときの案内 + const notice = document.createElement("div"); + notice.className = "park-map-notice"; + notice.hidden = true; + notice.innerHTML = + "<span>この表示範囲に該当する公園はありません</span>" + + '<button type="button" class="park-map-notice-btn">該当する公園へズーム</button>'; + map.getContainer().appendChild(notice); + L.DomEvent.disableClickPropagation(notice); + L.DomEvent.disableScrollPropagation(notice); + let noticeNames: Set<string> | null = null; + let noticeNearby: Set<string> | null = null; + const hideNotice = () => { + notice.hidden = true; + }; + hideViewNotice = hideNotice; + const showNotice = (n: Set<string>, nn: Set<string>) => { + noticeNames = n; + noticeNearby = nn; + notice.hidden = false; + }; + notice.querySelector("button")?.addEventListener("click", () => { + hideNotice(); + focusOnParks(noticeNames, noticeNearby); + }); + const anyMatchInView = (n: Set<string> | null, nn: Set<string> | null) => { + if (!n) return false; + const view = map.getBounds(); + let found = false; + allLayers.forEach((lyr) => { + if (found || !n.has(lyr._parkName || "")) return; + const b = lyr.getBounds?.(); + if (b && view.intersects(b)) found = true; + }); + if (!found) { + nearbyEntries.forEach(({ name, layer }) => { + if (found || !nn?.has(name)) return; + const b = layer.getBounds?.(); + if (b ? view.intersects(b) : view.contains(layer.getLatLng?.())) found = true; + }); + } + if (!found) { + facilityMarkers.forEach(({ name, layer }) => { + if (found || !n.has(name)) return; + const ll = layer.getLatLng?.(); + if (ll && view.contains(ll)) found = true; + }); + } + return found; + }; + const cityVisible = () => { + const b = boundaryLayer.getBounds(); + return b.isValid() && map.getBounds().contains(b); + }; + const updateViewNotice = () => { + if (!filterActiveNow || !filterNamesNow.size || cityVisible()) { + hideNotice(); + return; + } + if (anyMatchInView(filterNamesNow, filterNearbyNamesNow)) hideNotice(); + else showNotice(filterNamesNow, filterNearbyNamesNow); + }; + map.on("moveend zoomend", updateViewNotice); + // 選択中の公園を強調するためのクラス(グロー表示) document.addEventListener("park:filter", (e: Event) => { const detail = (e as CustomEvent).detail || {}; @@ -2122,6 +2312,9 @@ // 市外の近い公園は小平市内と同名でも別の公園なので、別セットで扱う。 const nearbyNames = new Set<string>(detail.nearbyNames ?? detail.names ?? []); const active = Boolean(detail.active); + filterActiveNow = active; + filterNamesNow = names; + filterNearbyNamesNow = nearbyNames; if (filterStatus) filterStatus.hidden = !active; const fromMap = Boolean(detail.fromMap); @@ -2202,8 +2395,15 @@ applyIconFilter(active ? new Set(names) : null); // フィルタの対象にフォーカスする(キーワード入力は少し待ってから)。 + // ただし、ユーザーが小平市全体が見えないほど拡大しているときは、 + // 勝手にズームせず、必要なら「該当公園へズーム」の案内を出す。 if (detail.focus) { - const run = () => focusOnParks(active ? names : null, active ? nearbyNames : null); + const run = () => { + if (cityVisible()) { + focusOnParks(active ? names : null, active ? nearbyNames : null); + } + updateViewNotice(); + }; window.clearTimeout(filterFocusTimer); if (detail.focus === "later") { filterFocusTimer = window.setTimeout(run, 800); @@ -2370,6 +2570,13 @@ // レイアウト確定後にサイズを再計算し、タイルを再配置する。 const refresh = () => { map.invalidateSize(); + // 小平市全体が収まるズームより外へは縮小できないようにする。 + const cityBounds = boundaryLayer.getBounds(); + const size = map.getSize(); + if (cityBounds.isValid() && size.x > 50 && size.y > 50) { + const z = map.getBoundsZoom(cityBounds, false, L.point(20, 20)); + if (Number.isFinite(z)) map.setMinZoom(z); + } // redraw() は使わない。Leaflet 1.9 の redraw() は小数ズーム時に // _tileZoom を小数(例: 12.9)にしてしまい、タイルが読めなくなる。 // invalidateSize と fitAll だけでタイルは更新される。 |
