diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/FacilitiesTable.astro | 70 | ||||
| -rw-r--r-- | src/components/ParkMap.astro | 446 | ||||
| -rw-r--r-- | src/components/ParkMapHelp.astro | 111 | ||||
| -rw-r--r-- | src/components/ParksFilters.astro | 184 | ||||
| -rw-r--r-- | src/components/ParksTable.astro | 260 |
5 files changed, 851 insertions, 220 deletions
diff --git a/src/components/FacilitiesTable.astro b/src/components/FacilitiesTable.astro index 2974581..797bb1a 100644 --- a/src/components/FacilitiesTable.astro +++ b/src/components/FacilitiesTable.astro @@ -1,7 +1,9 @@ --- -// 地域センター・公民館・図書館・市役所支所・文化施設の一覧。 +// 地域センター・公民館・児童館・図書館・市役所支所・文化施設の一覧。 +// 小平市の施設に加えて、市境500m以内の市外施設(図書館・公民館・児童館)も表示する。 // トイレは市の公共施設として基本的に利用できる。給水は公式ページに明記が少ないため「未確認」が多い。 import facilitiesData from "@/data/kodaira-facilities.json"; +import nearbyFacilitiesData from "@/data/kodaira-nearby-facilities.json"; type Facility = { name: string; @@ -13,12 +15,32 @@ type Facility = { water: string; note: string; source: string; + nearby?: boolean; + city?: string; + distance_m?: number; }; -const facilities = facilitiesData as Facility[]; -const types = ["地域センター", "公民館", "図書館", "市役所支所", "文化施設"]; -const mapsUrl = (name: string) => - `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`小平市${name}`)}`; +const facilities = [ + ...(facilitiesData as Facility[]), + ...(nearbyFacilitiesData as Facility[]), +]; +const types = [ + "地域センター", + "公民館", + "児童館", + "図書館", + "市役所支所", + "文化施設", +]; +const mapsUrl = (f: Facility) => { + // 市外の施設は「小平市」を付けない。名前が市名を含む場合は二重にしない。 + const query = f.nearby + ? f.city && !f.name.includes(f.city) + ? `${f.city}${f.name}` + : f.name + : `小平市${f.name}`; + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +}; --- <div class="facilities" data-fac-root> @@ -60,7 +82,7 @@ const mapsUrl = (name: string) => data-type={f.type} data-toilet={f.toilet || ""} data-water={f.water || ""} - data-search={`${f.name} ${f.type} ${f.address} ${f.note}`.toLowerCase()} + data-search={`${f.name} ${f.type} ${f.address} ${f.city || ""} ${f.note}`.toLowerCase()} > <td> {f.source ? ( @@ -68,10 +90,13 @@ const mapsUrl = (name: string) => ) : ( f.name )} + {f.nearby ? ( + <span class="fac-nearby" title="小平市外の施設">市外</span> + ) : null} </td> <td>{f.type}</td> <td> - <a href={mapsUrl(f.name)} target="_blank" rel="noopener"> + <a href={mapsUrl(f)} target="_blank" rel="noopener"> {f.address || "—"} </a> </td> @@ -96,9 +121,12 @@ const mapsUrl = (name: string) => </table> </div> - <p class="fac-note"> - トイレは市の公共施設として基本的に利用できます(○)。給水は公式ページに明記が少ないため「未確認」が多いです。最新の情報は各施設の公式ページで確認してください。地図の位置は住所・名称からの概略です。 - </p> + <details class="fac-help"> + <summary>設備情報について</summary> + <p class="fac-note"> + 小平市の施設は、公共施設のためトイレを利用できます(○)。市外の近隣施設は情報がないため「未確認」としています。給水は公式ページに明記が少ないため「未確認」が多いです。最新の情報は各施設の公式ページで確認してください。市外施設の情報はOpenStreetMapによるもので、地図の位置は住所・名称からの概略です。 + </p> + </details> </div> <script> @@ -178,9 +206,29 @@ const mapsUrl = (name: string) => .fac-status.is-no { color: #b71c1c; } - .fac-note { + .fac-nearby { + display: inline-block; + margin-left: 0.35rem; + padding: 0.02rem 0.35rem; + border: 1px solid currentColor; + border-radius: 999px; + color: #6d4c41; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.5; + vertical-align: 0.08em; + white-space: nowrap; + } + .fac-help { margin-top: 0.5rem; color: var(--sl-color-gray-2); font-size: 0.82em; } + .fac-help summary { + cursor: pointer; + color: var(--sl-color-text-accent); + } + .fac-note { + margin: 0.35rem 0 0; + } </style> 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(); }); // レイアウト確定後にサイズを再計算し、タイルを再配置する。 diff --git a/src/components/ParkMapHelp.astro b/src/components/ParkMapHelp.astro new file mode 100644 index 0000000..26d9917 --- /dev/null +++ b/src/components/ParkMapHelp.astro @@ -0,0 +1,111 @@ +--- +// 地図の見方・凡例。地図と一覧表を近づけるため、一覧表の下に表示する。 +// 遊具アイコンの凡例は ParkMap.astro のスクリプトが [data-play-legend] に流し込む。 +--- + +<details class="park-map-help"> + <summary>地図の見方・凡例</summary> + <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-facility-nearby"></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> + <div class="park-play-legend" data-play-legend></div> + <p class="park-map-note"> + 公園の範囲を赤、市外の近い公園を青、公共施設を紫(市外はオレンジ)で表示しています。地図を拡大すると見やすくなります。PCでは地図上でスクロールするとズームできます。市外の近い公園のうち、範囲が取得できないものは丸で表示しています。トイレと水飲み場のアイコンは拡大すると表示されます。さらに拡大すると、遊具アイコン(上の凡例)が表示されます。OpenStreetMapに公園として登録されている範囲を表示しているため、すべての公園を網羅しているわけではありません。現在地は端末内でのみ利用し、サーバーには送信しません。地図データ © <a + href="https://www.openstreetmap.org/copyright" + target="_blank" + rel="noopener">OpenStreetMap</a + > contributors + </p> +</details> + +<style> + .park-map-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1rem; + align-items: center; + margin-top: 0.5rem; + color: var(--sl-color-gray-2); + font-size: 0.8rem; + } + .park-map-legend span { + display: inline-flex; + align-items: center; + gap: 0.35rem; + } + .swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + } + .swatch-matched { + background: #e53935; + } + .swatch-unmatched { + background: #ffcdd2; + } + .swatch-nearby { + background: #42a5f5; + } + .swatch-facility { + background: #8e24aa; + border-radius: 50%; + } + .swatch-facility-nearby { + background: #fb8c00; + border-radius: 50%; + } + .swatch-boundary { + height: 0; + border-top: 4px solid #6a1b9a; + border-radius: 0; + background: transparent; + } + .swatch-fac { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-right: 0.3rem; + color: #fff; + font-style: normal; + font-size: 7px; + font-weight: 700; + line-height: 1; + } + .swatch-fac-toilet { + background: #455a64; + border-radius: 4px; + } + .swatch-fac-water { + background: #0277bd; + border-radius: 50%; + } + .park-map-note { + margin-top: 0.4rem; + color: var(--sl-color-gray-2); + font-size: 0.8rem; + } + .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; + } +</style> diff --git a/src/components/ParksFilters.astro b/src/components/ParksFilters.astro new file mode 100644 index 0000000..bf65d6e --- /dev/null +++ b/src/components/ParksFilters.astro @@ -0,0 +1,184 @@ +--- +// 公園・公共施設の一覧を絞り込むコントロール。地図の上に表示する。 +// 実際の絞り込みは ParksTable.astro のスクリプトが行う。 +import { parks, towns, total } from "@/data/parks-rows"; +import { PARK_ICONS } from "@/data/park-icons"; + +const facilityTypes = [ + "図書館", + "公民館", + "児童館", + "地域センター", + "市役所支所", + "文化施設", +].filter((type) => + parks.some((p) => p["施設"] === "1" && p["種別"] === type), +); +--- + +<div class="parks-controls" data-parks-filters> + <label> + <span>種類</span> + <select data-parks-kind> + <option value="">すべて</option> + <option value="park">公園</option> + {facilityTypes.map((type) => ( + <option value={type}>{type}</option> + ))} + </select> + </label> + <label> + <span>町名</span> + <select data-parks-town> + <option value="">すべての町名</option> + {towns.map((town) => ( + <option value={town}>{town}</option> + ))} + </select> + </label> + <label> + <span>キーワード</span> + <input type="search" data-parks-search placeholder="名称・住所・設備など" /> + </label> + <fieldset class="parks-facilities"> + <legend>設備・遊具で絞る</legend> + <label><input type="checkbox" data-fac-filter value="toilet" />トイレ</label> + <label><input type="checkbox" data-fac-filter value="water" />水飲み場・給水</label> + <label> + <input type="checkbox" data-fac-filter value="picnic" /> + <span class="filter-icon" set:html={PARK_ICONS.picnic.svg} />ピクニック・広場 + </label> + <label><input type="checkbox" data-fac-filter value="toy" />遊具あり</label> + <label> + <input type="checkbox" data-fac-filter value="sand" /> + <span class="filter-icon" set:html={PARK_ICONS.sand.svg} />砂場 + </label> + <label> + <input type="checkbox" data-fac-filter value="swing" /> + <span class="filter-icon" set:html={PARK_ICONS.swing.svg} />ブランコ + </label> + <label> + <input type="checkbox" data-fac-filter value="seesaw" /> + <span class="filter-icon" set:html={PARK_ICONS.teeter.svg} />シーソー + </label> + <label> + <input type="checkbox" data-fac-filter value="spring" /> + <span class="filter-icon" set:html={PARK_ICONS.spring.svg} />スプリング遊具 + </label> + <label> + <input type="checkbox" data-fac-filter value="jungle" /> + <span class="filter-icon" set:html={PARK_ICONS.climbing.svg} />ジャングルジム + </label> + <label> + <input type="checkbox" data-fac-filter value="plane" /> + <span class="filter-icon" set:html={PARK_ICONS.airplane.svg} />ヒコーキ + </label> + <label> + <input type="checkbox" data-fac-filter value="slide" /> + <span class="filter-icon" set:html={PARK_ICONS.slide.svg} />滑り台 + </label> + <label> + <input type="checkbox" data-fac-filter value="complex" /> + <span class="filter-icon" set:html={PARK_ICONS.playground.svg} />複合遊具 + </label> + <label> + <input type="checkbox" data-fac-filter value="rope" /> + <span class="filter-icon" set:html={PARK_ICONS.flyingfox.svg} />ロープウェイ + </label> + <label> + <input type="checkbox" data-fac-filter value="ball" /> + <span class="filter-icon" set:html={PARK_ICONS.ball.svg} />ボール遊び + </label> + </fieldset> + <span class="parks-count" data-parks-count aria-live="polite">{total}件</span> +</div> + +<p class="parks-disclaimer"> + 情報には誤りが含まれる場合があります。最新の情報は各施設の公式ページでご確認ください。 +</p> + +<style> + .parks-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.9rem; + align-items: center; + margin: 0 0 0.55rem; + } + .parks-controls label { + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-height: 2.1rem; + font-size: 0.9rem; + line-height: 1.2; + } + .parks-controls label > span { + line-height: 1.2; + } + .parks-controls select, + .parks-controls input[type="search"] { + height: 2.1rem; + box-sizing: border-box; + padding: 0.25rem 0.5rem; + border: 1px solid var(--sl-color-hairline, #ccc); + border-radius: 0.3rem; + background: var(--sl-color-bg, #fff); + color: var(--sl-color-text, inherit); + font: inherit; + line-height: 1.2; + } + .parks-facilities { + display: flex; + flex-wrap: wrap; + gap: 0.1rem 0.6rem; + align-items: center; + margin: 0; + padding: 0.2rem 0.5rem; + border: 1px solid var(--sl-color-hairline, #ccc); + border-radius: 0.3rem; + } + .parks-facilities legend { + padding: 0 0.2rem; + font-size: 0.78em; + color: inherit; + opacity: 0.7; + } + .parks-facilities label { + display: inline-flex; + align-items: center; + gap: 0.2rem; + min-height: 1.5rem; + font-size: 0.8rem; + line-height: 1.2; + } + .parks-facilities input[type="checkbox"] { + width: 0.9em; + height: 0.9em; + margin: 0; + } + .filter-icon { + display: inline-flex; + width: 18px; + height: 18px; + flex: none; + } + .filter-icon :global(svg) { + display: block; + width: 100%; + height: 100%; + } + :global(.filter-icon svg *) { + vector-effect: non-scaling-stroke; + } + .parks-count { + margin-left: auto; + font-size: 0.9rem; + opacity: 0.75; + } + .parks-disclaimer { + margin: 0 0 0.55rem; + color: var(--sl-color-gray-2); + font-size: 0.78rem; + } +</style> diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index bea8ff2..65b8412 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -1,78 +1,9 @@ --- -// 小平市の公園一覧(/kodaira-data/parks/)を表示する。 -// データは src/data/kodaira-parks.json(市立332件+市立以外)。 +// 小平市の公園・周辺の公園・公共施設の一覧(/kodaira-data/parks/)を表示する。 +// データは src/data/parks-rows.ts で組み立てる。 // DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。 // 絞り込み・並べ替え・カード表示は利用者の端末で実行する。 -import parksData from "@/data/kodaira-parks.json"; -import osmData from "@/data/parks-geojson"; -import nearbyData from "@/data/kodaira-nearby-parks.json"; - -type Park = Record<string, string>; - -function nearbyCentroid(feature: any): [number, number] { - const g = feature.geometry; - const ring = g.type === "Polygon" ? g.coordinates[0] : g.coordinates[0][0]; - if (!ring?.length) return [0, 0]; - let lat = 0; - let lon = 0; - for (const [x, y] of ring) { - lon += x; - lat += y; - } - return [lat / ring.length, lon / ring.length]; -} - -// 市境500m以内の市外公園。小平市の公園と同じ表に「市外」バッジ付きで並べる。 -const nearbyRows: Park[] = ((nearbyData as any).features ?? []).map((f: any) => { - const p = f.properties || {}; - const [lat, lon] = nearbyCentroid(f); - const area = Math.round(Number(p.area_m2) || 0); - const name = String(p.name || "").trim(); - // 市外の公園も公園名で開く。市名が分かる場合は市名を付ける。 - const mapsQuery = name - ? p.city - ? `${p.city}${name}` - : name - : `${lat},${lon}`; - return { - "公園名": name, - "住所": p.city ? `${p.city}(市外)` : "市外", - "町名": "市外", - "面積㎡": area ? String(area) : "", - "500㎡以上": "", - "種別": "市外", - "南西部指定管理": "", - "トイレ": "", - "水飲み場": "", - "ベンチ・椅子": "", - "テーブル": "", - "パーゴラ": "", - "東屋": "", - "防災用具": "", - "ボール遊び": "", - "遊具": "", - "駐車場": "", - "その他設備": "", - "緯度": String(lat), - "経度": String(lon), - "備考": `市外・小平市境から約${Math.round(Number(p.distance_m) || 0)}m`, - "設置者": "市外", - "状態": "", - "地図キー": `${p.osm_type || ""}/${p.osm_id || ""}`, - "地図URL": `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapsQuery)}`, - }; -}); - -const parks = [...(parksData as Park[]), ...nearbyRows]; - -// OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。 -// 市外の近隣公園も地図に表示しているので、同じように地図で見られる。 -const mappedNames = new Set<string>([ - ...(osmData as any).features.map((f: any) => f.properties?.our_name).filter(Boolean), - ...((nearbyData as any).features ?? []) - .map((f: any) => String(f.properties?.name || "").trim()) - .filter(Boolean), -]); +import { parks, mappedNames, type Park } from "@/data/parks-rows"; const EMPTY = "—"; @@ -87,6 +18,12 @@ const TYPE_LABEL: Record<string, string> = { 団地内公園: "団地内公園", 児童遊園: "児童遊園", 市外: "市外の近隣公園", + 地域センター: "地域センター", + 公民館: "公民館", + 児童館: "児童館", + 図書館: "図書館", + 市役所支所: "市役所支所", + 文化施設: "文化施設", }; const TYPE_CLASS: Record<string, string> = { 街区: "parks-type--block", @@ -98,6 +35,30 @@ const TYPE_CLASS: Record<string, string> = { 団地内公園: "parks-type--danchi", 児童遊園: "parks-type--jidou", 市外: "parks-type--green", + 地域センター: "parks-type--facility", + 公民館: "parks-type--facility", + 児童館: "parks-type--facility", + 図書館: "parks-type--facility", + 市役所支所: "parks-type--facility", + 文化施設: "parks-type--facility", +}; +// 種別は列幅を小さくするため、漢字1文字の記号で表示する。 +const TYPE_MARK: Record<string, string> = { + 街区: "街", + 近隣: "近", + 運動: "運", + 広域: "広", + 広場: "場", + 緑地: "緑", + 団地内公園: "団", + 児童遊園: "児", + 市外: "外", + 地域センター: "📍", + 公民館: "🏛", + 児童館: "🧒", + 図書館: "📚", + 市役所支所: "🏢", + 文化施設: "🎭", }; // 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。 @@ -125,6 +86,9 @@ function facilityBadges(p: Park): Badge[] { if (p["水飲み場"]) { badges.push({ kind: "water", label: "水飲み場", title: `水飲み場: ${p["水飲み場"]}` }); } + if (p["給水"]) { + badges.push({ kind: "water", label: "給水", title: `給水: ${p["給水"]}` }); + } if (p["遊具"]) { badges.push({ kind: "toy", label: "遊具", title: `遊具: ${p["遊具"]}` }); } @@ -150,12 +114,34 @@ function facTokens(p: Park): string { const tokens: string[] = []; if (p["トイレ"]) tokens.push("toilet"); if (p["水飲み場"]) tokens.push("water"); + if (p["給水"]) tokens.push("water"); if (p["遊具"]) tokens.push("toy"); + const play = p["遊具"] || ""; + if (play.includes("砂場")) tokens.push("sand"); + if (play.includes("ブランコ")) tokens.push("swing"); + if (play.includes("シーソー")) tokens.push("seesaw"); + if (play.includes("スプリング")) tokens.push("spring"); + if (play.includes("ジャングル")) tokens.push("jungle"); + if (play.includes("ヒコーキ") || play.includes("飛行機")) tokens.push("plane"); + if (play.includes("滑り台") || play.includes("滑台")) tokens.push("slide"); + if (play.includes("複合遊具")) tokens.push("complex"); + if (play.includes("ロープウェイ")) tokens.push("rope"); if (p["パーゴラ"]) tokens.push("pergola"); if (p["東屋"]) tokens.push("azumaya"); const ball = p["ボール遊び"]; if (ball && ball !== "禁止") tokens.push("ball"); if (p["南西部指定管理"]) tokens.push("cm"); + // ピクニック広場・芝生広場・テーブルのある公園。 + const picnicText = `${p["公園名"] || ""} ${p["種別"] || ""} ${p["その他設備"] || ""} ${p["備考"] || ""}`; + if ( + (p["テーブル"] || "").trim() || + p["種別"] === "広場" || + picnicText.includes("芝生") || + picnicText.includes("ピクニック") || + picnicText.includes("広場") + ) { + tokens.push("picnic"); + } return tokens.join(" "); } @@ -228,56 +214,32 @@ function searchIndex(p: Park): string { p["状態"], p["トイレ"], p["水飲み場"], + p["給水"], p["ボール遊び"], ...DETAIL_FIELDS.map((f) => p[f.key]), ] .join(" ") .toLowerCase(); } - -const towns = [...new Set(parks.map((p) => p["町名"]).filter(Boolean))].sort((a, b) => - a.localeCompare(b, "ja"), -); -const total = parks.length; --- <div class="parks" data-parks> - <div class="parks-controls"> - <label> - <span>町名</span> - <select data-parks-town> - <option value="">すべての町名</option> - {towns.map((town) => ( - <option value={town}>{town}</option> - ))} - </select> - </label> - <label> - <span>キーワード</span> - <input type="search" data-parks-search placeholder="公園名・住所・遊具など" /> - </label> - <fieldset class="parks-facilities"> - <legend>設備・管理で絞る</legend> - <label><input type="checkbox" data-fac-filter value="toilet" />トイレ</label> - <label><input type="checkbox" data-fac-filter value="water" />水飲み場</label> - <label><input type="checkbox" data-fac-filter value="toy" />遊具</label> - <label><input type="checkbox" data-fac-filter value="pergola" />パーゴラ</label> - <label><input type="checkbox" data-fac-filter value="azumaya" />東屋</label> - <label><input type="checkbox" data-fac-filter value="ball" />ボール遊び</label> - <label><input type="checkbox" data-fac-filter value="cm" />指定管理</label> - </fieldset> - <span class="parks-count" data-parks-count aria-live="polite">{total}件</span> - </div> - <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p> - <p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p> + <p class="parks-none" data-parks-none hidden>条件に合う公園・施設が見つかりませんでした。</p> <div class="parks-scroll"> <table> + <colgroup> + <col class="col-name" /> + <col class="col-address" /> + <col class="col-area" /> + <col class="col-type" /> + <col class="col-facility" /> + </colgroup> <thead> <tr> <th scope="col" aria-sort="none"> <button type="button" class="parks-sort" data-sort="name"> - 公園名<span class="parks-sort-mark" aria-hidden="true"></span> + 名称<span class="parks-sort-mark" aria-hidden="true"></span> </button> </th> <th scope="col">住所</th> @@ -293,13 +255,14 @@ const total = parks.length; <tbody> {parks.map((park) => ( <tr + data-kind={park["施設"] === "1" ? park["種別"] : "park"} data-town={park["町名"]} data-area={park["面積㎡"]} data-name={park["公園名"]} data-fac={facTokens(park)} data-search={searchIndex(park)} > - <td class="is-name" data-label="公園名"> + <td class="is-name" data-label="名称"> {mappedNames.has(park["公園名"]) ? ( <button type="button" @@ -308,6 +271,14 @@ const total = parks.length; data-focus-key={park["地図キー"] || ""} title="地図でこの公園の位置を表示" >{park["公園名"]}</button> + ) : park["公式URL"] ? ( + <a + class="park-name-link" + href={park["公式URL"]} + target="_blank" + rel="noopener" + title="公式ページを開く" + >{park["公園名"]}</a> ) : ( park["公園名"] )} @@ -344,7 +315,7 @@ const total = parks.length; title={TYPE_LABEL[park["種別"]]} aria-label={TYPE_LABEL[park["種別"]]} > - {park["種別"]} + <span aria-hidden="true">{TYPE_MARK[park["種別"]] || park["種別"].slice(0, 1)}</span> </span> ) : ( EMPTY @@ -424,16 +395,35 @@ const total = parks.length; opacity: 1; } .parks-scroll { - overflow-x: auto; + max-height: 70vh; + overflow: auto; + overscroll-behavior: contain; -webkit-overflow-scrolling: touch; border: 1px solid var(--sl-color-hairline, #ddd); border-radius: 0.3rem; } .parks table { width: 100%; + min-width: 760px; + table-layout: fixed; border-collapse: collapse; font-size: 0.85rem; } + .parks col.col-name { + width: 24%; + } + .parks col.col-address { + width: 18%; + } + .parks col.col-area { + width: 11%; + } + .parks col.col-type { + width: 7%; + } + .parks col.col-facility { + width: 40%; + } .parks th, .parks td { border-bottom: 1px solid var(--sl-color-hairline, #ddd); @@ -481,6 +471,9 @@ const total = parks.length; } .parks .is-name { font-weight: 600; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.5; } .parks-owner { display: inline-block; @@ -521,6 +514,8 @@ const total = parks.length; white-space: nowrap; } .park-name-link { + display: inline-block; + max-width: 100%; padding: 0; border: 0; background: none; @@ -532,6 +527,8 @@ const total = parks.length; text-decoration: underline; text-decoration-color: var(--sl-color-gray-4); text-underline-offset: 2px; + white-space: normal; + overflow-wrap: anywhere; } .park-name-link:hover { color: var(--sl-color-accent); @@ -579,16 +576,15 @@ const total = parks.length; .parks-type { display: inline-flex; align-items: center; - gap: 0.3em; - font-size: 0.9em; - } - .parks-type::before { - content: ""; - width: 0.7em; - height: 0.7em; - border-radius: 50%; - background: var(--type-color, #888); - flex: none; + justify-content: center; + width: 1.5em; + height: 1.5em; + border-radius: 0.35em; + background: color-mix(in srgb, var(--type-color, #888) 18%, transparent); + color: var(--type-color, #888); + font-size: 0.8em; + font-weight: 700; + line-height: 1; } .parks-type--block { --type-color: #2e9e4f; @@ -614,6 +610,10 @@ const total = parks.length; .parks-type--jidou { --type-color: #d81b60; } + .parks-type--facility { + --type-color: #00838f; + font-size: 1em; + } .parks .is-facility { white-space: normal; min-width: 12rem; @@ -674,12 +674,15 @@ const total = parks.length; <script> document.querySelectorAll("[data-parks]").forEach((root) => { - const select = root.querySelector<HTMLSelectElement>("[data-parks-town]"); - const search = root.querySelector<HTMLInputElement>("[data-parks-search]"); - const count = root.querySelector("[data-parks-count]"); + const select = document.querySelector<HTMLSelectElement>("[data-parks-town]"); + const kindSelect = document.querySelector<HTMLSelectElement>("[data-parks-kind]"); + const search = document.querySelector<HTMLInputElement>("[data-parks-search]"); + const count = document.querySelector("[data-parks-count]"); const none = root.querySelector<HTMLElement>("[data-parks-none]"); const tbody = root.querySelector("tbody"); - const checks = Array.from(root.querySelectorAll<HTMLInputElement>("[data-fac-filter]")); + const checks = Array.from( + document.querySelectorAll<HTMLInputElement>("[data-fac-filter]"), + ); const sortButtons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-sort]")); if (!tbody) return; @@ -703,17 +706,19 @@ const total = parks.length; let selectedName = ""; const applyFilter = () => { + const kind = kindSelect ? kindSelect.value : ""; const town = select ? select.value : ""; const query = search ? search.value.trim().toLowerCase() : ""; const wanted = checks.filter((c) => c.checked).map((c) => c.value); let shown = 0; allRows().forEach((row) => { const facs = (row.dataset.fac || "").split(" "); + const okKind = !kind || row.dataset.kind === kind; const okTown = !town || row.dataset.town === town; const okText = !query || (row.dataset.search || "").includes(query); const okFac = wanted.every((token) => facs.includes(token)); const okSelected = !selectedName || row.dataset.name === selectedName; - const ok = okTown && okText && okFac && okSelected; + const ok = okKind && okTown && okText && okFac && okSelected; row.classList.toggle("is-hidden", !ok); if (ok) shown += 1; }); @@ -729,7 +734,7 @@ const total = parks.length; new CustomEvent("park:filter", { detail: { names: visibleNames, - active: Boolean(town || query || wanted.length || selectedName), + active: Boolean(kind || town || query || wanted.length || selectedName), fromMap, }, }), @@ -765,6 +770,10 @@ const total = parks.length; clearSelected(); applyFilter(); }); + kindSelect?.addEventListener("change", () => { + clearSelected(); + applyFilter(); + }); search?.addEventListener("input", () => { clearSelected(); applyFilter(); @@ -784,6 +793,7 @@ const total = parks.length; selectedName = name; if (search) search.value = name; if (select) select.value = ""; + if (kindSelect) kindSelect.value = ""; checks.forEach((c) => { c.checked = false; }); |
