diff options
Diffstat (limited to 'src/components/ParkMap.astro')
| -rw-r--r-- | src/components/ParkMap.astro | 446 |
1 files changed, 362 insertions, 84 deletions
diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro index 1c75bee..e4f226b 100644 --- a/src/components/ParkMap.astro +++ b/src/components/ParkMap.astro @@ -10,22 +10,6 @@ role="region" aria-label="小平市の公園の位置(OpenStreetMap)" ></div> - <div class="park-map-legend" aria-hidden="true"> - <span><i class="swatch swatch-matched"></i>一覧と一致</span> - <span><i class="swatch swatch-unmatched"></i>一覧にない公園・緑地</span> - <span><i class="swatch swatch-nearby"></i>市外の近い公園(500m以内)</span> - <span><i class="swatch swatch-facility"></i>公共施設</span> - <span><i class="swatch swatch-boundary"></i>小平市の境界</span> - <span><i class="swatch-fac swatch-fac-toilet">WC</i>トイレ</span> - <span><i class="swatch-fac swatch-fac-water"><svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M12 2.7c-3.4 4.2-6 8.1-6 11.3a6 6 0 0 0 12 0c0-3.2-2.6-7.1-6-11.3z" /></svg></i>水飲み場(拡大時)</span> - </div> - <p class="park-map-note"> - 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園と公共施設は別レイヤーで表示し、右上のレイヤ切替でON/OFFできます。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a - href="https://www.openstreetmap.org/copyright" - target="_blank" - rel="noopener">OpenStreetMap</a - > contributors - </p> <p class="park-map-locate-status" data-locate-status hidden></p> </div> @@ -94,6 +78,10 @@ background: #8e24aa; border-radius: 50%; } + .swatch-facility-nearby { + background: #fb8c00; + border-radius: 50%; + } .swatch-boundary { height: 0; border-top: 4px solid #6a1b9a; @@ -106,18 +94,46 @@ font-size: 0.8rem; } :global(.park-locate) { - width: 34px; - height: 34px; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; border: 0; + border-radius: 50%; background: #fff; color: #1e88e5; - font-size: 20px; - line-height: 1; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); cursor: pointer; } :global(.park-locate:hover) { background: #f0f7ff; } + :global(.park-locate.is-tracking) { + background: #1e88e5; + color: #fff; + animation: park-locate-pulse 1.8s infinite; + } + :global(.park-locate.is-tracking:hover) { + background: #1565c0; + } + @keyframes park-locate-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(30, 136, 229, 0.45); + } + 70% { + box-shadow: 0 0 0 9px rgba(30, 136, 229, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(30, 136, 229, 0); + } + } + :global(.park-locate svg) { + display: block; + width: 20px; + height: 20px; + } .park-map-note { margin-top: 0.4rem; color: var(--sl-color-gray-2); @@ -218,6 +234,122 @@ :global(.park-fac svg) { display: block; } + :global(.park-facility-marker) { + background: none; + border: 0; + } + :global(.park-facility-emoji) { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 22px; + height: 22px; + border: 2px solid #8e24aa; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + font-size: 13px; + line-height: 1; + } + :global(.park-facility-marker.is-nearby .park-facility-emoji) { + border-color: #fb8c00; + } + :global(.park-play-markers), + :global(.park-play-list) { + pointer-events: none; + } + :global(.park-play-list) { + display: inline-flex; + gap: 2px; + transform: translate(-50%, -50%) translateY(-24px); + } + :global(.park-play) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid currentColor; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + font-size: 9px; + font-weight: 700; + line-height: 1; + } + :global(.park-play-plane) { + color: #c62828; + } + :global(.park-play-swing) { + color: #ef6c00; + } + :global(.park-play-seesaw) { + color: #6d4c41; + } + :global(.park-play-spring) { + color: #d81b60; + } + :global(.park-play-slide) { + color: #2e7d32; + } + :global(.park-play-jungle) { + color: #6a1b9a; + } + :global(.park-play-sand) { + color: #3e2723; + } + :global(.park-play-sand svg *) { + vector-effect: non-scaling-stroke; + } + :global(.park-play-complex) { + color: #0277bd; + } + :global(.park-play-ball) { + color: #1b5e20; + } + :global(.park-play-rope) { + color: #00838f; + } + :global(.park-locate-marker) { + border: 0; + background: none; + } + :global(.park-locate-arrow) { + display: block; + transform-origin: 50% 50%; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35)); + } + .park-map-help { + margin-top: 0.4rem; + } + .park-map-help summary { + cursor: pointer; + color: var(--sl-color-text-accent); + font-size: 0.85rem; + } + .park-play-legend { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.8rem; + margin: 0.5rem 0 0; + color: var(--sl-color-gray-2); + font-size: 0.78rem; + } + .park-play-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + :global(.park-play svg) { + display: block; + width: 100%; + height: 100%; + } + :global(.park-play-swing svg) { + width: 70%; + height: 70%; + } /* StarlightのMarkdown余白ルールがLeafletの各ペインに margin-top:1rem を 付けて地図を下へずらすため、地図内のペインは余白なしにする。 */ .park-map :global(.leaflet-pane) { @@ -252,6 +384,8 @@ import boundary from "@/data/kodaira-boundary.json"; import nearbyData from "@/data/kodaira-nearby-parks.json"; import facilities from "@/data/kodaira-facilities.json"; + import nearbyFacilities from "@/data/kodaira-nearby-facilities.json"; + import { PARK_ICONS } from "@/data/park-icons"; import parks from "@/data/kodaira-parks.json"; const MATCHED_STYLE = { @@ -285,6 +419,13 @@ fillColor: "#42a5f5", fillOpacity: 0.25, }; + const NEARBY_POINT_STYLE = { + radius: 7, + color: "#0d47a1", + weight: 2, + fillColor: "#42a5f5", + fillOpacity: 0.85, + }; const NEARBY_SELECTED_STYLE = { color: "#0d47a1", weight: 3, @@ -365,7 +506,9 @@ if (name) nearbyInfoByName.set(name, p); }); const nearbyMapsQuery = (name: string, city = "") => - name ? (city ? `${city}${name}` : name) : ""; + name ? (city && !name.includes(city) ? `${city}${name}` : name) : ""; + const nearbyBaseStyle = (lyr: any) => + lyr._isPoint ? NEARBY_POINT_STYLE : NEARBY_STYLE; const parkDataByName = new Map<string, any>(); (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p)); const ownerByName = new Map<string, string>(); @@ -536,12 +679,17 @@ // 市外の近い公園(500m以内)は別レイヤーにする。 // 小平市内の公園と同じく、クリックで一覧を絞り込み、左下に情報を表示する。 const nearbyLayer = L.geoJSON(nearbyData as any, { - style: NEARBY_STYLE, + // 範囲が取得できない公園は点(丸)で表示する。 + pointToLayer: (_feature: any, latlng: any) => + L.circleMarker(latlng, NEARBY_POINT_STYLE), + style: (feature: any) => + feature?.geometry?.type === "Point" ? NEARBY_POINT_STYLE : NEARBY_STYLE, onEachFeature: (feature: any, lyr: any) => { const p = feature.properties || {}; const name = String(p.name || "").trim(); const city = String(p.city || "").trim(); const key = `${p.osm_type || ""}/${p.osm_id || ""}`; + lyr._isPoint = feature.geometry?.type === "Point"; if (name) nearbyEntries.push({ name, layer: lyr, city, key }); lyr.on("click", (ev: any) => { if (ev?.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent); @@ -552,35 +700,51 @@ }, }).addTo(map); - // 地域センター・公民館・図書館・市役所支所。 + // 地域センター・公民館・図書館・市役所支所・文化施設。 + // 市外の近隣施設はオレンジで表示する。 const publicFacilityLayer = L.layerGroup(); - (facilities as any[]).forEach((f) => { - if (f.lat == null || f.lon == null) return; - L.circleMarker([f.lat, f.lon], { - radius: 6, - color: "#4a148c", - weight: 1.5, - fillColor: "#8e24aa", - fillOpacity: 0.85, - }) - .bindPopup( - `<strong>${f.name}</strong><br>${f.type}<br>${f.hours || ""}${f.closed ? `<br>休: ${f.closed}` : ""}<br>トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`, - ) - .addTo(publicFacilityLayer); - }); + const facilityMarkers: { name: string; layer: any }[] = []; + const FACILITY_ICON: Record<string, string> = { + 地域センター: "📍", + 公民館: "🏛", + 児童館: "🧒", + 図書館: "📚", + 市役所支所: "🏢", + 文化施設: "🎭", + }; + const setFacilityOpacity = (layer: any, show: boolean) => { + if (typeof layer.setOpacity === "function") { + layer.setOpacity(show ? 1 : 0.2); + } else if (typeof layer.setStyle === "function") { + layer.setStyle({ + opacity: show ? 1 : 0.15, + fillOpacity: show ? 0.85 : 0.1, + }); + } + }; + const addFacilities = (list: any[], nearby: boolean) => { + list.forEach((f) => { + if (f.lat == null || f.lon == null) return; + const icon = FACILITY_ICON[f.type] || ""; + const marker = L.marker([f.lat, f.lon], { + icon: L.divIcon({ + className: `park-facility-marker${nearby ? " is-nearby" : ""}`, + html: `<span class="park-facility-emoji">${icon || "🏢"}</span>`, + iconSize: [22, 22], + iconAnchor: [11, 11], + }), + }) + .bindPopup( + `<strong>${icon} ${f.name}</strong>${nearby ? "(市外)" : ""}<br>${f.type}<br>${f.hours || ""}${f.closed ? `<br>休: ${f.closed}` : ""}<br>トイレ: ${f.toilet || "未確認"} / 給水: ${f.water || "未確認"}`, + ) + .addTo(publicFacilityLayer); + facilityMarkers.push({ name: String(f.name || ""), layer: marker }); + }); + }; + addFacilities(facilities as any[], false); + addFacilities(nearbyFacilities as any[], true); publicFacilityLayer.addTo(map); - L.control - .layers( - null, - { - "市外の近い公園(500m以内)": nearbyLayer, - "公共施設": publicFacilityLayer, - }, - { collapsed: false, position: "topright" }, - ) - .addTo(map); - // 拡大時だけ表示する設備アイコン(トイレ・水飲み場) // 一覧と一致した公園のポリゴン内にだけ表示する。 const facilityLayer = L.layerGroup(); @@ -612,12 +776,76 @@ }).addTo(facilityLayer); }); + // 拡大時だけ表示する遊具アイコン。 + // 公園ポリゴンの中心に、遊具の種類を絵アイコンで表示する。 + const PLAY_DEFS = [ + { tokens: ["ヒコーキ", "飛行機"], key: "airplane", title: PARK_ICONS.airplane.title, cls: "park-play-plane", icon: PARK_ICONS.airplane.svg }, + { tokens: ["ジャングル"], key: "climbing", title: PARK_ICONS.climbing.title, cls: "park-play-jungle", icon: PARK_ICONS.climbing.svg }, + { tokens: ["ブランコ"], key: "swing", title: PARK_ICONS.swing.title, cls: "park-play-swing", icon: PARK_ICONS.swing.svg }, + { tokens: ["シーソー"], key: "seesaw", title: PARK_ICONS.teeter.title, cls: "park-play-seesaw", icon: PARK_ICONS.teeter.svg }, + { tokens: ["スプリング"], key: "spring", title: PARK_ICONS.spring.title, cls: "park-play-spring", icon: PARK_ICONS.spring.svg }, + { tokens: ["滑り台", "滑台"], key: "slide", title: PARK_ICONS.slide.title, cls: "park-play-slide", icon: PARK_ICONS.slide.svg }, + { tokens: ["ロープウェイ"], key: "flyingfox", title: PARK_ICONS.flyingfox.title, cls: "park-play-rope", icon: PARK_ICONS.flyingfox.svg }, + { tokens: ["砂場"], key: "sand", title: PARK_ICONS.sand.title, cls: "park-play-sand", icon: PARK_ICONS.sand.svg }, + { tokens: ["複合遊具"], key: "playground", title: PARK_ICONS.playground.title, cls: "park-play-complex", icon: PARK_ICONS.playground.svg }, + { tokens: [], key: "ball", title: PARK_ICONS.ball.title, cls: "park-play-ball", icon: PARK_ICONS.ball.svg }, + ]; + const playLegend = document.querySelector<HTMLElement>("[data-play-legend]"); + if (playLegend) { + playLegend.innerHTML = PLAY_DEFS.map( + (def) => + `<span class="park-play-legend-item"><span class="park-play ${def.cls}" aria-hidden="true">${def.icon}</span>${def.title}</span>`, + ).join(""); + } + const playLayer = L.layerGroup(); + layersByName.forEach((lyr, name) => { + const p = parkDataByName.get(name); + if (!p) return; + const text = (p["遊具"] || "").trim(); + const ballText = (p["ボール遊び"] || "").trim(); + const hasBall = Boolean(ballText && ballText !== "禁止"); + if (!text && !hasBall) return; + const seen = new Set<string>(); + const icons: string[] = []; + for (const def of PLAY_DEFS) { + const matched = + def.key === "ball" + ? hasBall + : def.tokens.some((token) => text.includes(token)); + if (!matched) continue; + if (seen.has(def.key)) continue; + // ヒコーキジャングルはジャングルジムの一種なので、飛があればジは出さない。 + if (def.key === "climbing" && seen.has("airplane")) continue; + seen.add(def.key); + icons.push( + `<span class="park-play ${def.cls}" title="${def.title}" aria-label="${def.title}">${def.icon}</span>`, + ); + } + if (!icons.length) return; + const center = lyr.getBounds().getCenter(); + L.marker([center.lat, center.lng], { + icon: L.divIcon({ + className: "park-play-markers", + html: `<span class="park-play-list">${icons.join("")}</span>`, + iconSize: null, + iconAnchor: [0, 0], + }), + riseOnHover: false, + interactive: false, + }).addTo(playLayer); + }); + const updateFacilities = () => { if (map.getZoom() >= 16) { if (!map.hasLayer(facilityLayer)) facilityLayer.addTo(map); } else if (map.hasLayer(facilityLayer)) { map.removeLayer(facilityLayer); } + if (map.getZoom() >= 17) { + if (!map.hasLayer(playLayer)) playLayer.addTo(map); + } else if (map.hasLayer(playLayer)) { + map.removeLayer(playLayer); + } }; map.on("zoomend", updateFacilities); updateFacilities(); @@ -646,7 +874,8 @@ if (key) { const nearbyHit = nearbyEntries.find((entry) => entry.key === key); if (nearbyHit) { - map.fitBounds(nearbyHit.layer.getBounds(), { padding: [40, 40], maxZoom: 17 }); + const group = L.featureGroup([nearbyHit.layer]); + map.fitBounds(group.getBounds(), { padding: [40, 40], maxZoom: 17 }); showSelected( name, name, @@ -702,7 +931,6 @@ const names = new Set<string>(detail.names || []); const active = Boolean(detail.active); const fromMap = Boolean(detail.fromMap); - const visible: any[] = []; if (!fromMap && selectedBar) selectedBar.hidden = true; @@ -718,7 +946,12 @@ } }); nearbyEntries.forEach(({ name, layer: lyr }) => { - lyr.setStyle(name && names.has(name) ? NEARBY_SELECTED_STYLE : NEARBY_STYLE); + lyr.setStyle( + name && names.has(name) ? NEARBY_SELECTED_STYLE : nearbyBaseStyle(lyr), + ); + }); + facilityMarkers.forEach(({ layer }) => { + setFacilityOpacity(layer, true); }); return; } @@ -731,7 +964,6 @@ } if (name && names.has(name)) { lyr.setStyle(MATCHED_STYLE); - visible.push(lyr); } else { lyr.setStyle(DIM_STYLE); } @@ -739,26 +971,23 @@ nearbyEntries.forEach(({ name, layer: lyr }) => { if (!active) { - lyr.setStyle(NEARBY_STYLE); + lyr.setStyle(nearbyBaseStyle(lyr)); return; } if (name && names.has(name)) { - lyr.setStyle(NEARBY_STYLE); - visible.push(lyr); + lyr.setStyle(nearbyBaseStyle(lyr)); } else { lyr.setStyle(NEARBY_DIM_STYLE); } }); - if (active && visible.length && visible.length < 30) { - const bounds = L.featureGroup(visible).getBounds(); - if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }); - } - } + // 公共施設のマーカーも絞り込みに合わせて表示する。 + facilityMarkers.forEach(({ name, layer }) => { + setFacilityOpacity(layer, !active || names.has(name)); + }); }); - // 現在地表示(スマホでは自動で取得を試みる) + // 現在地表示(ボタン操作時のみ取得) const statusEl = document.querySelector<HTMLElement>("[data-locate-status]"); const setStatus = (msg: string) => { if (!statusEl) return; @@ -769,30 +998,72 @@ let locationMarker: any = null; let accuracyCircle: any = null; let watchId: number | null = null; + let tracking = false; + let firstFix = true; + let locateButton: HTMLButtonElement | null = null; + + const LOCATE_ICON = + '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><path d="M12 2.2 20 21.5 12 16.8 4 21.5Z" fill="#1e88e5" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/></svg></span>'; + const TRACKING_ICON = + '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="3.5" fill="currentColor"/><circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>'; + + const updateLocateButton = () => { + if (!locateButton) return; + locateButton.classList.toggle("is-tracking", tracking); + locateButton.setAttribute("aria-pressed", tracking ? "true" : "false"); + const label = tracking ? "現在地の追跡を停止" : "現在地を表示"; + locateButton.title = label; + locateButton.setAttribute("aria-label", label); + locateButton.innerHTML = tracking ? TRACKING_ICON : LOCATE_ICON; + }; + + const stopTracking = () => { + if (watchId !== null) { + navigator.geolocation.clearWatch(watchId); + watchId = null; + } + tracking = false; + updateLocateButton(); + }; - const locate = (center = false) => { + const locate = () => { + if (tracking) { + stopTracking(); + setStatus("現在地の追跡を停止しました。"); + return; + } if (!navigator.geolocation) { setStatus("このブラウザでは現在地を取得できません。"); return; } setStatus("現在地を取得しています…"); + firstFix = true; const onSuccess = (pos: GeolocationPosition) => { const { latitude, longitude, accuracy } = pos.coords; if (!locationMarker) { - locationMarker = L.circleMarker([latitude, longitude], { - radius: 7, - color: "#ffffff", - weight: 2, - fillColor: "#1e88e5", - fillOpacity: 1, - }).bindPopup("現在地", { autoPan: false }); - locationMarker.on("add", () => { - locationMarker.getElement()?.setAttribute("data-locate", "true"); + const icon = L.divIcon({ + className: "park-locate-marker", + html: '<span class="park-locate-arrow"><svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true"><path d="M12 2.2 20 21.5 12 16.8 4 21.5Z" fill="#1e88e5" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/></svg></span>', + iconSize: [24, 24], + iconAnchor: [12, 12], }); - locationMarker.addTo(map); + locationMarker = L.marker([latitude, longitude], { + icon, + interactive: false, + keyboard: false, + }).addTo(map); } else { locationMarker.setLatLng([latitude, longitude]); } + const heading = + typeof pos.coords.heading === "number" && + Number.isFinite(pos.coords.heading) + ? pos.coords.heading + : 0; + const arrow = locationMarker + .getElement() + ?.querySelector<HTMLElement>(".park-locate-arrow"); + if (arrow) arrow.style.transform = `rotate(${heading}deg)`; if (!accuracyCircle) { accuracyCircle = L.circle([latitude, longitude], { radius: Math.min(accuracy, 1000), @@ -807,13 +1078,21 @@ .setLatLng([latitude, longitude]) .setRadius(Math.min(accuracy, 1000)); } - setStatus(`現在地を表示しています(精度 約${Math.round(accuracy)}m)。`); - if (center) map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + if (firstFix) { + map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + firstFix = false; + } + setStatus( + `現在地を追跡中です(精度 約${Math.round(accuracy)}m)。もう一度押すと停止します。`, + ); }; const onError = () => { setStatus("現在地を取得できませんでした。位置情報の利用を許可してください。"); + stopTracking(); }; if (watchId !== null) navigator.geolocation.clearWatch(watchId); + tracking = true; + updateLocateButton(); watchId = navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: true, timeout: 10000, @@ -823,23 +1102,22 @@ const LocateControl = L.Control.extend({ onAdd: () => { - const btn = L.DomUtil.create("button", "leaflet-bar park-locate"); + const btn = L.DomUtil.create("button", "park-locate") as HTMLButtonElement; btn.setAttribute("type", "button"); - btn.title = "現在地を表示"; - btn.setAttribute("aria-label", "現在地を表示"); - btn.innerHTML = '<span aria-hidden="true">◎</span>'; + locateButton = btn; L.DomEvent.disableClickPropagation(btn); - btn.addEventListener("click", () => locate(true)); + btn.addEventListener("click", () => locate()); + updateLocateButton(); return btn; }, }); new LocateControl({ position: "topright" }).addTo(map); - if (window.matchMedia("(max-width: 640px)").matches) { - locate(true); - } + // 位置情報の許可確認は、ボタン操作(ユーザー操作)のときだけ出す。 + // ページ読み込み時に自動で watchPosition を呼ぶと、ブラウザによっては + // 許可ダイアログが出ずに失敗することがあるため。 window.addEventListener("pagehide", () => { - if (watchId !== null) navigator.geolocation.clearWatch(watchId); + stopTracking(); }); // レイアウト確定後にサイズを再計算し、タイルを再配置する。 |
