diff options
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/KodairaNews.astro | 39 | ||||
| -rw-r--r-- | src/components/ParkMap.astro | 656 | ||||
| -rw-r--r-- | src/components/ParksTable.astro | 264 | ||||
| -rw-r--r-- | src/components/ScheduleInteractive.astro | 493 |
4 files changed, 1389 insertions, 63 deletions
diff --git a/src/components/KodairaNews.astro b/src/components/KodairaNews.astro index 2a49ab0..4ba2308 100644 --- a/src/components/KodairaNews.astro +++ b/src/components/KodairaNews.astro @@ -35,6 +35,45 @@ const LABEL: Record<CatId, string> = { const posts: Post[] = [ { + date: "10月20日", + cat: "other", + note: "市報こだいら10月20日号が発行されました(1面は小川パレットのオープン)。電子版も公開されています", + links: [ + { text: "市報こだいら10月20日号(市のX)", url: X("2112332973588500591") }, + { text: "市報こだいら電子版", url: "https://www.catapoke.com/search/?keyword=%E5%B0%8F%E5%B9%B3%E5%B8%82" }, + ], + }, + { + date: "10月19日", + cat: "other", + note: "ふるさと納税ポータルサイト「ふるさとチョイス」が使用できないため、当面は窓口または現金書留で受け付け", + links: [{ text: "クラウドファンディングの寄附方法の変更", url: X("2112139194940199015") }], + }, + { + date: "10月19日", + cat: "status", + note: "暫定サイトに「市からのお知らせ」「イベント情報」「電子申請一覧」「申請書ダウンロード」などのカテゴリ", + links: [{ text: "ホームページの障害状況について", url: X("2112091386333868246") }], + }, + { + date: "10月19日", + cat: "other", + note: "市役所西通り、10月19日・20日の午後10時から午前6時まで夜間通行止め(雨天順延)", + links: [{ text: "道路工事情報", url: X("2112015882972971208") }], + }, + { + date: "10月18日", + cat: "event", + note: "あかしあ通りと周辺道路の交通規制は午後4時30分に解除", + links: [{ text: "市民まつりは終了しました", url: X("2111717666654507344") }], + }, + { + date: "10月18日", + cat: "event", + note: "午前9時30分から午後4時30分まで", + links: [{ text: "小平市民まつり(本日開催)", url: X("2111593570079850757") }], + }, + { date: "10月16日", cat: "status", note: "暫定サイトに「市からのお知らせ」などのカテゴリを新設", diff --git a/src/components/ParkMap.astro b/src/components/ParkMap.astro new file mode 100644 index 0000000..8537af2 --- /dev/null +++ b/src/components/ParkMap.astro @@ -0,0 +1,656 @@ +--- +// 小平市の公園ポリゴン(OpenStreetMap)を地図上に色塗り表示する。 +// 一覧と一致した公園は赤、一致しない公園は薄い赤で表示する。 +--- + +<div class="park-map-wrap"> + <div + id="park-map" + class="park-map" + 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-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"> + 公園の範囲を赤で表示しています。地図を拡大すると見やすくなります。トイレと水飲み場のアイコンは、地図を拡大すると表示されます。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> + +<style> + .park-map-wrap { + position: relative; + z-index: 11; + margin: 1rem 0 1.5rem; + } + .park-map { + height: 480px; + width: 100%; + border: 1px solid var(--sl-color-gray-5); + border-radius: 8px; + } + .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-boundary { + height: 0; + border-top: 4px solid #6a1b9a; + border-radius: 0; + background: transparent; + } + .park-map-locate-status { + margin-top: 0.3rem; + color: var(--sl-color-gray-2); + font-size: 0.8rem; + } + :global(.park-locate) { + width: 34px; + height: 34px; + border: 0; + background: #fff; + color: #1e88e5; + font-size: 20px; + line-height: 1; + cursor: pointer; + } + :global(.park-locate:hover) { + background: #f0f7ff; + } + .park-map-note { + margin-top: 0.4rem; + color: var(--sl-color-gray-2); + font-size: 0.8rem; + } + :global(.park-map-selected) { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.1rem 0.55rem; + max-width: min(92vw, 340px); + padding: 0.28rem 0.55rem; + background: rgba(255, 255, 255, 0.95); + border: 1px solid var(--sl-color-gray-5); + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18); + color: #1e293b; + font-size: 0.8rem; + line-height: 1.45; + } + :global(.park-map-selected[hidden]) { + display: none; + } + :global(.park-map-selected-name) { + font-weight: 700; + } + :global(.park-map-selected-owner) { + padding: 0.02rem 0.3rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + opacity: 0.85; + } + :global(.park-map-selected-owner[hidden]) { + display: none; + } + :global(.park-map-selected-note) { + color: #666; + font-size: 0.72rem; + } + :global(.park-map-selected a) { + color: #0b57d0; + white-space: nowrap; + } + .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%; + } + :global(.park-fac-list) { + display: inline-flex; + gap: 2px; + transform: translate(-50%, -50%); + } + :global(.park-fac) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + font-size: 9px; + font-weight: 700; + line-height: 1; + } + :global(.park-fac-toilet) { + background: #455a64; + border: 1px solid #263238; + border-radius: 5px; + } + :global(.park-fac-water) { + background: #0277bd; + border: 1px solid #01579b; + border-radius: 50%; + } + :global(.park-fac svg) { + display: block; + } + /* StarlightのMarkdown余白ルールがLeafletの各ペインに margin-top:1rem を + 付けて地図を下へずらすため、地図内のペインは余白なしにする。 */ + .park-map :global(.leaflet-pane) { + margin: 0 !important; + } + /* Starlightの canvas/svg 向け max-width が Leaflet の描画領域を + 0幅にしてしまうため、地図内だけ上書きする。 */ + .park-map :global(.leaflet-overlay-pane canvas), + .park-map :global(.leaflet-overlay-pane svg) { + max-width: none !important; + max-height: none !important; + margin: 0 !important; + } + /* Starlightの画像用 margin がタイルに付くと行が16pxずれるため、0にする。 */ + .park-map :global(.leaflet-tile) { + margin: 0 !important; + } + @media (max-width: 640px) { + .park-map { + height: 360px; + } + } +</style> + +<script> + import L from "leaflet"; + import "leaflet/dist/leaflet.css"; + import geojson from "@/data/parks-geojson"; + import boundary from "@/data/kodaira-boundary.json"; + import parks from "@/data/kodaira-parks.json"; + + const MATCHED_STYLE = { + color: "#b71c1c", + weight: 2, + fillColor: "#e53935", + fillOpacity: 0.45, + }; + const UNMATCHED_STYLE = { + color: "#ef5350", + weight: 1.2, + fillColor: "#ffcdd2", + fillOpacity: 0.3, + }; + const DIM_STYLE = { + color: "#ef9a9a", + weight: 0.8, + fillColor: "#ffebee", + fillOpacity: 0.05, + opacity: 0.3, + }; + const SELECTED_STYLE = { + color: "#7f0000", + weight: 3, + fillColor: "#d32f2f", + fillOpacity: 0.6, + }; + + const el = document.getElementById("park-map"); + if (el && !el.dataset.initialized) { + el.dataset.initialized = "1"; + + const map = L.map(el, { + scrollWheelZoom: false, + zoomSnap: 1, + zoomDelta: 1, + zoomAnimation: false, + fadeAnimation: false, + markerZoomAnimation: false, + }).setView([35.728, 139.477], 13); + + // 帰属表示の「Leaflet」旗アイコンを消す(OSMの表示は残す)。 + map.attributionControl.setPrefix(false); + + const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: + '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', + }).addTo(map); + + const layersByName = new Map<string, any>(); + const allLayers: any[] = []; + const parkDataByName = new Map<string, any>(); + (parks as any[]).forEach((p) => parkDataByName.set(p["公園名"], p)); + const ownerByName = new Map<string, string>(); + parkDataByName.forEach((p, name) => { + const owner = (p["設置者"] || "").trim(); + if (owner && owner !== "小平市") ownerByName.set(name, owner); + }); + const ownerLabel = (owner: string) => + owner.includes("都営") + ? "都営" + : owner === "東京都" + ? "都立" + : owner.includes("UR") + ? "UR" + : owner.includes("民間") + ? "民間" + : owner; + + const filterName = (name: string, scroll = false) => { + document.dispatchEvent( + new CustomEvent("park:filter-name", { detail: { name, scroll } }), + ); + }; + + // 選択中の公園を地図左下に控えめに表示する(ポップアップは使わない)。 + let selectedBar: HTMLElement | null = null; + let selectedNameEl: HTMLElement | null = null; + let selectedMapsLink: HTMLAnchorElement | null = null; + let selectedTableLink: HTMLAnchorElement | null = null; + let selectedNoteEl: HTMLElement | null = null; + let selectedOwnerEl: HTMLElement | null = null; + + const showSelected = ( + ourName: string, + osmName: string, + listed = true, + owner = "", + ) => { + if (!selectedBar || !selectedNameEl || !selectedMapsLink || !selectedTableLink) + return; + selectedNameEl.textContent = ourName; + selectedNameEl.title = + osmName && osmName !== ourName ? `OSM: ${osmName}` : ""; + // 名前が「緑地(OSM …)」のような場合は名前検索が使えないため、 + // 座標があれば緯度経度で開く。 + const rec = parkDataByName.get(ourName); + const lat = rec?.["緯度"]; + const lng = rec?.["経度"]; + selectedMapsLink.href = + lat && lng + ? `https://www.google.com/maps/search/?api=1&query=${lat},${lng}` + : "https://www.google.com/maps/search/?api=1&query=" + + encodeURIComponent(`小平市${ourName}`); + selectedTableLink.dataset.parkRow = encodeURIComponent(ourName); + selectedTableLink.hidden = !listed; + if (selectedNoteEl) selectedNoteEl.hidden = listed; + if (selectedOwnerEl) { + selectedOwnerEl.textContent = owner ? ownerLabel(owner) : ""; + selectedOwnerEl.hidden = !owner; + } + selectedBar.hidden = false; + }; + + const SelectedControl = L.Control.extend({ + onAdd: () => { + const box = L.DomUtil.create("div", "park-map-selected"); + box.hidden = true; + box.innerHTML = + '<span class="park-map-selected-name"></span>' + + '<span class="park-map-selected-owner" hidden></span>' + + '<span class="park-map-selected-note" hidden>一覧にない緑地・公園</span>' + + '<a data-park-maps-link href="#" target="_blank" rel="noopener">Googleマップ</a>' + + '<a data-park-table-link href="#">一覧で見る</a>'; + selectedBar = box; + selectedNameEl = box.querySelector<HTMLElement>(".park-map-selected-name"); + selectedMapsLink = box.querySelector<HTMLAnchorElement>("[data-park-maps-link]"); + selectedTableLink = box.querySelector<HTMLAnchorElement>("[data-park-table-link]"); + selectedNoteEl = box.querySelector<HTMLElement>(".park-map-selected-note"); + selectedOwnerEl = box.querySelector<HTMLElement>(".park-map-selected-owner"); + selectedTableLink?.addEventListener("click", (ev: Event) => { + ev.preventDefault(); + const name = decodeURIComponent( + (selectedTableLink as HTMLElement).dataset.parkRow || "", + ); + if (name) filterName(name, true); + }); + L.DomEvent.disableClickPropagation(box); + return box; + }, + }); + new SelectedControl({ position: "bottomleft" }).addTo(map); + + const layer = L.geoJSON(geojson as any, { + style: (feature: any) => + feature?.properties?.matched ? MATCHED_STYLE : UNMATCHED_STYLE, + onEachFeature: (feature: any, lyr: any) => { + const p = feature.properties || {}; + const name = p.name || "名称未設定"; + const ourName = p.our_name || ""; + lyr._parkName = ourName; + lyr._osmName = name; + allLayers.push(lyr); + if (ourName) layersByName.set(ourName, lyr); + + if (ourName) { + lyr.on("add", () => { + const path = lyr.getElement?.(); + if (path) path.setAttribute("data-park", ourName); + }); + lyr.on("click", () => { + filterName(ourName, false); + showSelected(ourName, name, true, ownerByName.get(ourName) || ""); + }); + } else if (p.name) { + // 一覧にないOSM上の公園・緑地。名前があればバーに表示する。 + lyr.on("click", () => showSelected(name, name, false)); + } else { + // 名前のない緑地はクリックに反応させない。 + lyr.on("add", () => { + const path = lyr.getElement?.(); + if (path) path.classList.remove("leaflet-interactive"); + }); + } + }, + }).addTo(map); + + // 市外を薄く覆い、小平市内の部分だけを見せるマスク。 + // 境界線より先に追加して、境界線がマスクの上に描かれるようにする。 + const boundaryGeom = (boundary as any).features[0].geometry; + const boundaryRings = + boundaryGeom.type === "Polygon" + ? boundaryGeom.coordinates + : boundaryGeom.coordinates.map((p: any) => p[0]); + const WORLD_RING = [ + [-180, -85], + [180, -85], + [180, 85], + [-180, 85], + [-180, -85], + ]; + L.geoJSON( + { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [WORLD_RING, ...boundaryRings], + }, + } as any, + { + style: { fillColor: "#ffffff", fillOpacity: 0.78, stroke: false }, + interactive: false, + }, + ).addTo(map); + + // 小平市の境界線(塗りなし・太い実線) + const boundaryLayer = L.geoJSON(boundary as any, { + style: { + color: "#6a1b9a", + weight: 4, + opacity: 0.9, + fill: false, + }, + interactive: false, + }).addTo(map); + + // 拡大時だけ表示する設備アイコン(トイレ・水飲み場) + // 一覧と一致した公園のポリゴン内にだけ表示する。 + const facilityLayer = L.layerGroup(); + layersByName.forEach((lyr, name) => { + const p = parkDataByName.get(name); + if (!p) return; + const icons: string[] = []; + if ((p["トイレ"] || "").trim()) { + icons.push( + '<span class="park-fac park-fac-toilet" title="トイレあり" aria-label="トイレあり">WC</span>', + ); + } + if ((p["水飲み場"] || "").trim()) { + icons.push( + '<span class="park-fac park-fac-water" title="水飲み場あり" aria-label="水飲み場あり"><svg viewBox="0 0 24 24" width="12" height="12" 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></span>', + ); + } + if (!icons.length) return; + const center = lyr.getBounds().getCenter(); + L.marker([center.lat, center.lng], { + icon: L.divIcon({ + className: "park-fac-markers", + html: `<span class="park-fac-list">${icons.join("")}</span>`, + iconSize: null, + iconAnchor: [0, 0], + }), + riseOnHover: false, + }).addTo(facilityLayer); + }); + + const updateFacilities = () => { + if (map.getZoom() >= 16) { + if (!map.hasLayer(facilityLayer)) facilityLayer.addTo(map); + } else if (map.hasLayer(facilityLayer)) { + map.removeLayer(facilityLayer); + } + }; + map.on("zoomend", updateFacilities); + updateFacilities(); + + let userInteracted = false; + map.on("zoomstart dragstart", () => { + userInteracted = true; + }); + + const fitAll = () => { + if (userInteracted) return; + // 初期表示は小平市の市境だけに合わせる(市外にまたがる都立公園などで広がらないように)。 + const bounds = boundaryLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [20, 20], animate: false }); + return; + } + const parkBounds = layer.getBounds(); + if (parkBounds.isValid()) { + map.fitBounds(parkBounds, { padding: [20, 20], animate: false }); + } + }; + fitAll(); + + const focusPark = (name: string) => { + const target = layersByName.get(name); + if (!target) return false; + map.fitBounds(target.getBounds(), { padding: [40, 40], maxZoom: 17 }); + showSelected( + name, + target._osmName || name, + true, + ownerByName.get(name) || "", + ); + return true; + }; + + document.addEventListener("park:focus", (e: Event) => { + const name = (e as CustomEvent).detail?.name; + if (!name || !layersByName.has(name)) return; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + focusPark(name); + }); + + document.addEventListener("park:filter", (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + 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; + + // 地図のポリゴンクリックで表を1件に絞った場合は、 + // ほかの公園のポリゴンを残したまま、選択した公園だけ強調する。 + if (fromMap) { + allLayers.forEach((lyr) => { + const name = lyr._parkName || ""; + if (name && names.has(name)) { + lyr.setStyle(SELECTED_STYLE); + } else { + lyr.setStyle(name ? MATCHED_STYLE : UNMATCHED_STYLE); + } + }); + return; + } + + allLayers.forEach((lyr) => { + const name = lyr._parkName || ""; + if (!active) { + lyr.setStyle(name ? MATCHED_STYLE : UNMATCHED_STYLE); + return; + } + if (name && names.has(name)) { + lyr.setStyle(MATCHED_STYLE); + visible.push(lyr); + } else { + lyr.setStyle(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 }); + } + } + }); + + // 現在地表示(スマホでは自動で取得を試みる) + const statusEl = document.querySelector<HTMLElement>("[data-locate-status]"); + const setStatus = (msg: string) => { + if (!statusEl) return; + statusEl.textContent = msg; + statusEl.hidden = !msg; + }; + + let locationMarker: any = null; + let accuracyCircle: any = null; + let watchId: number | null = null; + + const locate = (center = false) => { + if (!navigator.geolocation) { + setStatus("このブラウザでは現在地を取得できません。"); + return; + } + setStatus("現在地を取得しています…"); + 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"); + }); + locationMarker.addTo(map); + } else { + locationMarker.setLatLng([latitude, longitude]); + } + if (!accuracyCircle) { + accuracyCircle = L.circle([latitude, longitude], { + radius: Math.min(accuracy, 1000), + color: "#1e88e5", + weight: 1, + fillColor: "#90caf9", + fillOpacity: 0.15, + interactive: false, + }).addTo(map); + } else { + accuracyCircle + .setLatLng([latitude, longitude]) + .setRadius(Math.min(accuracy, 1000)); + } + setStatus(`現在地を表示しています(精度 約${Math.round(accuracy)}m)。`); + if (center) map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + }; + const onError = () => { + setStatus("現在地を取得できませんでした。位置情報の利用を許可してください。"); + }; + if (watchId !== null) navigator.geolocation.clearWatch(watchId); + watchId = navigator.geolocation.watchPosition(onSuccess, onError, { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 30000, + }); + }; + + const LocateControl = L.Control.extend({ + onAdd: () => { + const btn = L.DomUtil.create("button", "leaflet-bar park-locate"); + btn.setAttribute("type", "button"); + btn.title = "現在地を表示"; + btn.setAttribute("aria-label", "現在地を表示"); + btn.innerHTML = '<span aria-hidden="true">◎</span>'; + L.DomEvent.disableClickPropagation(btn); + btn.addEventListener("click", () => locate(true)); + return btn; + }, + }); + new LocateControl({ position: "topright" }).addTo(map); + + if (window.matchMedia("(max-width: 640px)").matches) { + locate(true); + } + window.addEventListener("pagehide", () => { + if (watchId !== null) navigator.geolocation.clearWatch(watchId); + }); + + // レイアウト確定後にサイズを再計算し、タイルを再配置する。 + const refresh = () => { + map.invalidateSize(); + tileLayer.redraw(); + fitAll(); + }; + requestAnimationFrame(refresh); + window.setTimeout(refresh, 300); + window.setTimeout(refresh, 1000); + window.addEventListener("load", refresh); + window.addEventListener("resize", () => map.invalidateSize()); + } +</script> diff --git a/src/components/ParksTable.astro b/src/components/ParksTable.astro index 5dbaa9c..6f8fdbd 100644 --- a/src/components/ParksTable.astro +++ b/src/components/ParksTable.astro @@ -1,13 +1,21 @@ --- // 小平市の公園一覧(/kodaira-data/parks/)を表示する。 -// データは src/data/kodaira-parks.json(321件、市の公開資料から作成)。 +// データは src/data/kodaira-parks.json(市立332件+市立以外)。 // DOM を抑えるため、設備は1セルにまとめ、アイコンは CSS の擬似要素で描く。 // 絞り込み・並べ替え・カード表示は利用者の端末で実行する。 import parksData from "@/data/kodaira-parks.json"; +import osmData from "@/data/parks-geojson"; type Park = Record<string, string>; const parks = parksData as Park[]; +// OpenStreetMapにポリゴンがあり、地図から位置を表示できる公園名。 +const mappedNames = new Set<string>( + (osmData as any).features + .map((f: any) => f.properties?.our_name) + .filter(Boolean), +); + const EMPTY = "—"; // 種別の表示(色付きの丸+区分名。正式名は title / aria-label で補う)。 @@ -15,13 +23,38 @@ const TYPE_LABEL: Record<string, string> = { 街区: "街区公園", 近隣: "近隣公園", 運動: "運動公園", + 広域: "広域公園", + 広場: "広場", + 緑地: "緑地", + 団地内公園: "団地内公園", + 児童遊園: "児童遊園", }; const TYPE_CLASS: Record<string, string> = { 街区: "parks-type--block", 近隣: "parks-type--neighbor", 運動: "parks-type--sports", + 広域: "parks-type--wide", + 広場: "parks-type--open", + 緑地: "parks-type--green", + 団地内公園: "parks-type--danchi", + 児童遊園: "parks-type--jidou", }; +// 市立以外の公園に付けるバッジ(設置者が空欄なら市立として扱う)。 +function ownerLabel(owner: string): string { + if (owner.includes("都営")) return "都営"; + if (owner === "東京都") return "都立"; + if (owner.includes("UR")) return "UR"; + if (owner.includes("民間")) return "民間"; + return owner; +} +function ownerClass(owner: string): string { + if (owner.includes("都営")) return "parks-owner--toei"; + if (owner === "東京都") return "parks-owner--tokyo"; + if (owner === "未確認") return "parks-owner--unknown"; + return "parks-owner--other"; +} + // 設備バッジ(アイコンは CSS の ::before で描く)。 type Badge = { kind: string; label: string; title: string }; function facilityBadges(p: Park): Badge[] { @@ -73,6 +106,8 @@ const DETAIL_FIELDS: { key: string; icon: string }[] = [ { key: "テーブル", icon: "🍽" }, { key: "防災用具", icon: "🧯" }, { key: "遊具", icon: "🎠" }, + { key: "駐車場", icon: "🅿" }, + { key: "その他設備", icon: "🔧" }, { key: "備考", icon: "" }, ]; function detailText(p: Park): string { @@ -108,10 +143,15 @@ function sizeTitle(area: number): string { return "目安: 10,000㎡以上(広め)"; } -// 住所ではなく公園名で検索する(地番は地図側で正しく解決されないため)。 -// 「小平市」を付けないと別の自治体の同名公園に飛ぶので必ず付ける。 -function mapsUrl(name: string): string { - const query = encodeURIComponent(`小平市${name}`); +// 座標があればその位置を開く。ない場合は公園名で検索する(地番は地図側で正しく解決されないため)。 +// 公園名だけだと別の自治体の同名公園に飛ぶので「小平市」を必ず付ける。 +function mapsUrl(p: Park): string { + const lat = p["緯度"]; + const lng = p["経度"]; + if (lat && lng) { + return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`; + } + const query = encodeURIComponent(`小平市${p["公園名"]}`); return `https://www.google.com/maps/search/?api=1&query=${query}`; } @@ -122,6 +162,8 @@ function searchIndex(p: Park): string { p["住所"], p["町名"], p["種別"], + TYPE_LABEL[p["種別"]], + p["設置者"], p["トイレ"], p["水飲み場"], p["ボール遊び"], @@ -131,7 +173,7 @@ function searchIndex(p: Park): string { .toLowerCase(); } -const towns = [...new Set(parks.map((p) => p["町名"]))].sort((a, b) => +const towns = [...new Set(parks.map((p) => p["町名"]).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ja"), ); const total = parks.length; @@ -164,7 +206,7 @@ const total = parks.length; </fieldset> <span class="parks-count" data-parks-count aria-live="polite">{total}件</span> </div> - <p class="parks-hint">設備の情報は市の南西部地域の公園のみです。設備で絞ると、この情報がある公園だけが対象になります。</p> + <p class="parks-hint">設備の情報は、市の資料などに基づきます。設備で絞ると、この情報がある公園だけが対象になります。</p> <p class="parks-none" data-parks-none hidden>条件に合う公園が見つかりませんでした。</p> <div class="parks-scroll"> @@ -195,22 +237,39 @@ const total = parks.length; data-fac={facTokens(park)} data-search={searchIndex(park)} > - <td class="is-name" data-label="公園名">{park["公園名"]}</td> + <td class="is-name" data-label="公園名"> + {mappedNames.has(park["公園名"]) ? ( + <button + type="button" + class="park-name-link" + data-focus-park={park["公園名"]} + title="地図でこの公園の位置を表示" + >{park["公園名"]}</button> + ) : ( + park["公園名"] + )} + {park["設置者"] && park["設置者"] !== "小平市" ? ( + <span + class:list={["parks-owner", ownerClass(park["設置者"])]} + title={`設置者: ${park["設置者"]}`} + >{ownerLabel(park["設置者"])}</span> + ) : null} + </td> <td class="is-address" data-label="住所"> <a - href={mapsUrl(park["公園名"])} + href={mapsUrl(park)} target="_blank" rel="noopener" title={`Googleマップで「${park["公園名"]}」を開く`} - >{park["住所"]}</a> + >{park["住所"] || EMPTY}</a> </td> <td class="is-num" data-label="面積" - data-size={sizeLabel(areaNumber(park["面積㎡"]))} - title={sizeTitle(areaNumber(park["面積㎡"]))} + data-size={park["面積㎡"] ? sizeLabel(areaNumber(park["面積㎡"])) : ""} + title={park["面積㎡"] ? sizeTitle(areaNumber(park["面積㎡"])) : ""} > - {areaText(park["面積㎡"])}㎡ + {park["面積㎡"] ? `${areaText(park["面積㎡"])}㎡` : EMPTY} </td> <td class="is-type" data-label="種別"> {park["種別"] ? ( @@ -357,6 +416,49 @@ const total = parks.length; .parks .is-name { font-weight: 600; } + .parks-owner { + display: inline-block; + margin-left: 0.35rem; + padding: 0.02rem 0.35rem; + border: 1px solid currentColor; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 600; + line-height: 1.5; + vertical-align: 0.08em; + white-space: nowrap; + } + .parks-owner--tokyo { + color: #2e7d32; + } + .parks-owner--toei { + color: #1565c0; + } + .parks-owner--other { + color: #6d4c41; + } + .parks-owner--unknown { + color: #757575; + } + .park-name-link { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--sl-color-gray-4); + text-underline-offset: 2px; + } + .park-name-link:hover { + color: var(--sl-color-accent); + } + .parks tbody tr.is-highlighted td { + background: #fff59d !important; + } .parks .is-address { font-size: 0.8em; white-space: normal; @@ -417,6 +519,21 @@ const total = parks.length; .parks-type--sports { --type-color: #e8862a; } + .parks-type--wide { + --type-color: #7b1fa2; + } + .parks-type--open { + --type-color: #8d6e63; + } + .parks-type--green { + --type-color: #43a047; + } + .parks-type--danchi { + --type-color: #546e7a; + } + .parks-type--jidou { + --type-color: #d81b60; + } .parks .is-facility { white-space: normal; min-width: 12rem; @@ -463,54 +580,14 @@ const total = parks.length; opacity: 0.85; } - /* スマートフォンでは1件ずつカードで表示する。 */ + /* スマートフォンでも表形式を維持し、横スクロールで見られるようにする。 */ @media (max-width: 640px) { - .parks-scroll { - overflow-x: visible; - border: none; - border-radius: 0; + .parks table { + font-size: 0.75rem; } - .parks table, - .parks tbody, - .parks tr, + .parks th, .parks td { - display: block; - width: auto; - } - .parks thead { - display: none; - } - .parks tbody tr { - margin-bottom: 0.6rem; - padding: 0.4rem 0.6rem; - border: 1px solid var(--sl-color-hairline, #ddd); - border-radius: 0.4rem; - } - .parks tbody tr:nth-child(even) td { - background: transparent; - } - .parks td { - border: none; - padding: 0.1rem 0; - white-space: normal; - max-width: none; - } - .parks td::before { - content: attr(data-label) ":"; - display: inline-block; - min-width: 4.5em; - font-size: 0.85em; - opacity: 0.7; - } - .parks td.is-name { - font-size: 1rem; - } - .parks td.is-name::before { - content: none; - } - .parks td.is-num, - .parks td.is-type { - text-align: left; + padding: 0.3rem 0.4rem; } } </style> @@ -520,17 +597,26 @@ const total = parks.length; const select = root.querySelector<HTMLSelectElement>("[data-parks-town]"); const search = root.querySelector<HTMLInputElement>("[data-parks-search]"); const count = root.querySelector("[data-parks-count]"); - const none = root.querySelector("[data-parks-none]"); + const none = root.querySelector<HTMLElement>("[data-parks-none]"); const tbody = root.querySelector("tbody"); const checks = Array.from(root.querySelectorAll<HTMLInputElement>("[data-fac-filter]")); const sortButtons = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-sort]")); if (!tbody) return; + root.querySelectorAll<HTMLButtonElement>("[data-focus-park]").forEach((btn) => { + btn.addEventListener("click", () => { + const name = btn.dataset.focusPark; + if (!name) return; + document.dispatchEvent(new CustomEvent("park:focus", { detail: { name } })); + }); + }); + const allRows = () => Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr")); const total = allRows().length; let sortKey: string | null = null; let sortDir = 1; + let selectedName = ""; const applyFilter = () => { const town = select ? select.value : ""; @@ -542,12 +628,28 @@ const total = parks.length; const okTown = !town || row.dataset.town === town; const okText = !query || (row.dataset.search || "").includes(query); const okFac = wanted.every((token) => facs.includes(token)); - const ok = okTown && okText && okFac; + const okSelected = !selectedName || row.dataset.name === selectedName; + const ok = okTown && okText && okFac && okSelected; row.classList.toggle("is-hidden", !ok); if (ok) shown += 1; }); if (count) count.textContent = `${shown} / ${total}件`; if (none) none.hidden = shown !== 0; + // 地図側に絞り込み結果を通知する。 + const visibleNames = allRows() + .filter((row) => !row.classList.contains("is-hidden")) + .map((row) => row.dataset.name || "") + .filter(Boolean); + const fromMap = Boolean(selectedName && search?.value === selectedName); + document.dispatchEvent( + new CustomEvent("park:filter", { + detail: { + names: visibleNames, + active: Boolean(town || query || wanted.length || selectedName), + fromMap, + }, + }), + ); }; const applySort = () => { @@ -572,9 +674,45 @@ const total = parks.length; sorted.forEach((row) => tbody.appendChild(row)); }; - select?.addEventListener("change", applyFilter); - search?.addEventListener("input", applyFilter); - checks.forEach((c) => c.addEventListener("change", applyFilter)); + const clearSelected = () => { + selectedName = ""; + }; + select?.addEventListener("change", () => { + clearSelected(); + applyFilter(); + }); + search?.addEventListener("input", () => { + clearSelected(); + applyFilter(); + }); + checks.forEach((c) => + c.addEventListener("change", () => { + clearSelected(); + applyFilter(); + }), + ); + + // 地図のポリゴンクリックなどから、公園名で1件だけ絞り込む。 + document.addEventListener("park:filter-name", (e: Event) => { + const detail = (e as CustomEvent).detail || {}; + const name = detail.name; + if (!name) return; + selectedName = name; + if (search) search.value = name; + if (select) select.value = ""; + checks.forEach((c) => { + c.checked = false; + }); + applyFilter(); + const row = allRows().find((r) => r.dataset.name === name); + if (row) { + row.classList.add("is-highlighted"); + window.setTimeout(() => row.classList.remove("is-highlighted"), 2000); + if (detail.scroll) { + row.scrollIntoView({ behavior: "smooth", block: "center" }); + } + } + }); sortButtons.forEach((btn) => { btn.addEventListener("click", () => { const key = btn.dataset.sort; diff --git a/src/components/ScheduleInteractive.astro b/src/components/ScheduleInteractive.astro new file mode 100644 index 0000000..5fb058b --- /dev/null +++ b/src/components/ScheduleInteractive.astro @@ -0,0 +1,493 @@ +<script> + const CATEGORY_DEFS = [ + { icon: '👶', label: '子育て' }, + { icon: '🧓', label: '高齢者・介護' }, + { icon: '🏥', label: '健康' }, + { icon: '♿', label: '福祉・相談' }, + { icon: '🎨', label: '文化・公民館' }, + { icon: '🏃', label: 'スポーツ' }, + { icon: '🌳', label: '環境・自然' }, + { icon: '📚', label: '学習・講演' }, + { icon: '🤝', label: '交流・地域' }, + ]; + const MEETING_ICON = '🏛️'; + const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土']; + const WEEKDAY_MAP = { 日: 0, 月: 1, 火: 2, 水: 3, 木: 4, 金: 5, 土: 6 }; + + const normalizeDigits = (s) => + s.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)); + const pad2 = (n) => String(n).padStart(2, '0'); + const toKey = (y, m, d) => `${y}-${pad2(m)}-${pad2(d)}`; + const keyToDate = (key) => { + const [y, m, d] = key.split('-').map(Number); + return new Date(y, m - 1, d); + }; + const escapeHtml = (s) => + s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + function applyExclusions(dateSet, raw) { + const text = normalizeDigits(raw); + const re = /(\d{1,2})月(\d{1,2})日を除く/g; + let m; + while ((m = re.exec(text)) !== null) { + const month = Number(m[1]); + const day = Number(m[2]); + const year = text.slice(0, m.index).includes('令和9年') ? 2027 : month === 1 ? 2027 : 2026; + dateSet.delete(toKey(year, month, day)); + } + } + + // 日付セル・時間セルから YYYY-MM-DD を抽出する。 + // 期間は、7日以内なら全日、曜日指定があればその曜日、それ以外は開始日と終了日を拾う。 + function parseDates(raw) { + const text = normalizeDigits(raw); + const dates = new Set(); + const add = (y, m, d) => { + if (y && m >= 1 && m <= 12 && d >= 1 && d <= 31) dates.add(toKey(y, m, d)); + }; + const yearFor = (index, month) => { + if (text.slice(0, index).includes('令和9年')) return 2027; + return month === 1 ? 2027 : 2026; + }; + + let m; + const rangeRe = /(\d{1,2})月(\d{1,2})日[^〜]*〜\s*(?:(\d{1,2})月)?(\d{1,2})日/g; + while ((m = rangeRe.exec(text)) !== null) { + const startMonth = Number(m[1]); + const startDay = Number(m[2]); + const endMonth = m[3] ? Number(m[3]) : startMonth; + const endDay = Number(m[4]); + const startYear = yearFor(m.index, startMonth); + const endYear = startYear + (endMonth < startMonth ? 1 : 0); + const start = new Date(startYear, startMonth - 1, startDay); + const end = new Date(endYear, endMonth - 1, endDay); + if (end < start) continue; + + const lengthDays = Math.round((end - start) / 86400000) + 1; + const after = text.slice(m.index + m[0].length); + let weekdays = null; + + const noWeek = after.match(/の([月火水木金土日])曜日/); + if (noWeek) { + weekdays = [WEEKDAY_MAP[noWeek[1]]]; + } else { + const group = after.match(/(([月火水木金土日](?:・[月火水木金土日])*))/); + const hasStartWeekday = new RegExp(`${startMonth}月${startDay}日([月火水木金土日]`).test(text); + if (group && (group[1].includes('・') || !hasStartWeekday)) { + weekdays = group[1].split('・').map((w) => WEEKDAY_MAP[w]); + } else { + const startWeek = text.match(new RegExp(`${startMonth}月${startDay}日(([月火水木金土日])`)); + const endWeek = text.match(new RegExp(`(?:${endMonth}月)?${endDay}日(([月火水木金土日])`)); + if (startWeek && endWeek && startWeek[1] === endWeek[1]) { + weekdays = [WEEKDAY_MAP[startWeek[1]]]; + } + } + } + + if (lengthDays <= 7 || (!weekdays && lengthDays <= 14)) { + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + add(d.getFullYear(), d.getMonth() + 1, d.getDate()); + } + } else if (weekdays) { + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + if (weekdays.includes(d.getDay())) add(d.getFullYear(), d.getMonth() + 1, d.getDate()); + } + } else { + add(start.getFullYear(), start.getMonth() + 1, start.getDate()); + add(end.getFullYear(), end.getMonth() + 1, end.getDate()); + } + } + + const fullDates = []; + const fullRe = /(\d{1,2})月(\d{1,2})日/g; + while ((m = fullRe.exec(text)) !== null) { + const month = Number(m[1]); + const day = Number(m[2]); + fullDates.push({ index: m.index, month }); + add(yearFor(m.index, month), month, day); + } + + const bareRe = /(\d{1,2})日/g; + while ((m = bareRe.exec(text)) !== null) { + const index = m.index; + if (index > 0 && text[index - 1] === '月') continue; + let previous = null; + for (const fd of fullDates) { + if (fd.index < index) previous = fd; + else break; + } + if (!previous) continue; + const day = Number(m[1]); + add(yearFor(index, previous.month), previous.month, day); + } + + // 「(11月4日を除く)」などの除外日を反映する。 + applyExclusions(dates, text); + + return [...dates].sort(); + } + + function initSchedule() { + const calendarEl = document.getElementById('schedule-calendar'); + const detailsEl = document.getElementById('schedule-day-details'); + const filtersEl = document.getElementById('event-filters'); + const meetingsTable = document.querySelector('#meetings-table table'); + const eventsTable = document.querySelector('#events-table table'); + if (!calendarEl || !detailsEl || !filtersEl || !meetingsTable || !eventsTable) return; + if (calendarEl.dataset.initialized === 'true') return; + calendarEl.dataset.initialized = 'true'; + + const entries = []; + + function collect(table, type) { + table.querySelectorAll('tbody tr').forEach((row, i) => { + const cells = row.querySelectorAll('td'); + if (cells.length < 3) return; + const dateText = cells[0].textContent.trim(); + const name = cells[1].textContent.trim(); + const timeText = cells[2].textContent.trim(); + const rowId = `${type}-row-${i}`; + row.id = rowId; + + let category = MEETING_ICON; + let categoryLabel = '審議会等'; + const cat = CATEGORY_DEFS.find((c) => name.startsWith(c.icon)); + if (cat) { + category = cat.icon; + categoryLabel = cat.label; + } else if (type === 'event') { + category = '🤝'; + categoryLabel = '交流・地域'; + } + row.dataset.category = category; + + const dates = new Set(parseDates(dateText)); + // 審議会の「毎月1回(11月12日、…も開催)」のように、 + // 時間・会場欄に追加の開催日が書かれている場合がある。 + if (type === 'meeting') { + for (const key of parseDates(timeText)) dates.add(key); + } + // イベントの「(11月4日を除く)」を反映する。 + if (type === 'event') { + // 時間欄の「(11月4日を除く)」を日付セル側の集合に反映する。 + applyExclusions(dates, timeText); + } + + entries.push({ + type, + name, + time: timeText, + dates: [...dates], + category, + categoryLabel, + rowId, + dateText, + }); + }); + } + + collect(meetingsTable, 'meeting'); + collect(eventsTable, 'event'); + + const byDate = new Map(); + for (const entry of entries) { + for (const key of entry.dates) { + if (!byDate.has(key)) byDate.set(key, []); + byDate.get(key).push(entry); + } + } + + const dateKeys = [...byDate.keys()].sort(); + if (dateKeys.length === 0) { + calendarEl.innerHTML = '<p>表示できる予定がありません。</p>'; + return; + } + + const months = [...new Set(dateKeys.map((k) => k.slice(0, 7)))].sort(); + const today = new Date(); + const todayKey = toKey(today.getFullYear(), today.getMonth() + 1, today.getDate()); + let selectedDate = byDate.has(todayKey) ? todayKey : dateKeys[0]; + let currentMonth = selectedDate.slice(0, 7); + let activeCategory = 'all'; + + function matchesCategory(e) { + if (activeCategory === 'all') return true; + if (activeCategory === MEETING_ICON) return e.type === 'meeting'; + return e.category === activeCategory; + } + + function visibleEntries(key) { + const list = byDate.get(key) || []; + return list.filter(matchesCategory); + } + + function ensureVisibleSelection() { + if (selectedDate && visibleEntries(selectedDate).length > 0) return; + // 絞り込みで選択中の日付が空になったら、当月の最初の該当日へ移動する。 + // 当月に該当日がなければ、最初の該当日へ移動する。 + const inMonth = dateKeys.filter( + (k) => k.startsWith(currentMonth) && visibleEntries(k).length > 0 + ); + const any = dateKeys.filter((k) => visibleEntries(k).length > 0); + const fallback = inMonth[0] || any[0]; + if (!fallback) return; + selectedDate = fallback; + currentMonth = fallback.slice(0, 7); + } + + function renderCalendar() { + const index = months.indexOf(currentMonth); + const [year, month] = currentMonth.split('-').map(Number); + const first = new Date(year, month - 1, 1); + const startDay = first.getDay(); + const daysInMonth = new Date(year, month, 0).getDate(); + + const cells = []; + for (let i = 0; i < startDay; i++) { + cells.push('<div class="schedule-cal-day is-blank"></div>'); + } + for (let day = 1; day <= daysInMonth; day++) { + const key = toKey(year, month, day); + const list = visibleEntries(key); + const classes = ['schedule-cal-day']; + if (key === todayKey) classes.push('is-today'); + if (key === selectedDate) classes.push('is-selected'); + + if (list.length === 0) { + cells.push( + `<div class="${classes.join(' ')}"><span class="schedule-cal-daynum">${day}</span></div>` + ); + continue; + } + + const categories = [...new Set(list.map((e) => e.category))]; + const marks = categories + .slice(0, 3) + .map((icon) => `<span class="schedule-cal-mark">${icon}</span>`) + .join(''); + const more = + categories.length > 3 + ? `<span class="schedule-cal-more">+${categories.length - 3}</span>` + : ''; + const count = + list.length > 1 ? `<span class="schedule-cal-count">${list.length}</span>` : ''; + cells.push( + `<button type="button" class="${classes.join(' ')} has-events" data-date="${key}" aria-label="${month}月${day}日、${list.length}件の予定"><span class="schedule-cal-daynum">${day}${count}</span><span class="schedule-cal-marks">${marks}${more}</span></button>` + ); + } + + calendarEl.innerHTML = ` + <div class="schedule-cal-header"> + <button type="button" class="schedule-cal-nav" data-dir="-1" aria-label="前の月" ${index <= 0 ? 'disabled' : ''}>‹</button> + <div class="schedule-cal-title">${year}年${month}月</div> + <button type="button" class="schedule-cal-nav" data-dir="1" aria-label="次の月" ${index >= months.length - 1 ? 'disabled' : ''}>›</button> + </div> + <div class="schedule-cal-grid"> + ${WEEKDAYS.map( + (w, i) => + `<div class="schedule-cal-weekday${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}">${w}</div>` + ).join('')} + ${cells.join('')} + </div> + `; + + calendarEl.querySelectorAll('.schedule-cal-day[data-date]').forEach((btn) => { + btn.addEventListener('click', () => { + selectedDate = btn.dataset.date; + renderCalendar(); + showDayDetails(selectedDate); + }); + }); + + calendarEl.querySelectorAll('.schedule-cal-nav').forEach((btn) => { + btn.addEventListener('click', () => { + const next = months.indexOf(currentMonth) + Number(btn.dataset.dir); + if (next < 0 || next >= months.length) return; + currentMonth = months[next]; + selectedDate = null; + renderCalendar(); + detailsEl.innerHTML = + '<p class="schedule-day-placeholder">日付を選ぶと、その日の予定が表示されます。</p>'; + }); + }); + } + + function showDayDetails(key) { + if (!key) return; + const date = keyToDate(key); + const weekday = WEEKDAYS[date.getDay()]; + const list = visibleEntries(key) + .slice() + .sort((a, b) => { + if (a.type !== b.type) return a.type === 'meeting' ? -1 : 1; + return a.name.localeCompare(b.name, 'ja'); + }); + const title = `${date.getMonth() + 1}月${date.getDate()}日(${weekday})の予定`; + + if (list.length === 0) { + detailsEl.innerHTML = `<h3>${title}</h3><p>この日の予定はありません。</p>`; + return; + } + + const items = list + .map((e) => { + const displayName = e.name.startsWith(e.category) + ? e.name.slice(e.category.length).trim() + : e.name; + return `<li><a class="schedule-day-link" href="#${e.rowId}"><span class="schedule-day-icon">${e.category}</span>${escapeHtml(displayName)}</a><span class="schedule-day-meta">${escapeHtml(e.time)}</span></li>`; + }) + .join(''); + + detailsEl.innerHTML = `<h3>${title}</h3><ul>${items}</ul>`; + detailsEl.querySelectorAll('a.schedule-day-link').forEach((a) => { + a.addEventListener('click', () => { + const row = document.getElementById(a.getAttribute('href').slice(1)); + if (!row) return; + row.classList.add('schedule-row-highlight'); + window.setTimeout(() => row.classList.remove('schedule-row-highlight'), 3000); + }); + }); + } + + const meetingEntries = entries.filter((e) => e.type === 'meeting'); + const eventEntries = entries.filter((e) => e.type === 'event'); + const counts = {}; + for (const e of entries) { + counts[e.category] = (counts[e.category] || 0) + 1; + } + + function categoryLabel(category) { + if (category === MEETING_ICON) return '審議会等'; + const def = CATEGORY_DEFS.find((c) => c.icon === category); + return def ? def.label : '交流・地域'; + } + + function ensureStatus(container, type) { + const id = `${type}-filter-status`; + let status = document.getElementById(id); + if (!status) { + status = document.createElement('div'); + status.id = id; + status.className = 'schedule-table-status'; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + container.parentElement.insertBefore(status, container); + } + return status; + } + + const meetingStatus = ensureStatus(meetingsTable.parentElement, 'meeting'); + const eventStatus = ensureStatus(eventsTable.parentElement, 'event'); + + function updateTableStatus(status, type, total, visible) { + const tableLabel = type === 'meeting' ? '審議会等' : 'イベント'; + const filtered = activeCategory !== 'all'; + status.classList.toggle('is-filtered', filtered); + const text = filtered + ? `${tableLabel}:「${activeCategory} ${categoryLabel(activeCategory)}」で絞り込み中:${visible}件/全${total}件` + : `${tableLabel}:全${total}件を表示しています(絞り込みなし)`; + status.innerHTML = `<span class="schedule-table-status-text">${text}</span>`; + if (filtered) { + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'schedule-table-status-clear'; + clear.textContent = '絞り込みを解除'; + clear.addEventListener('click', () => setActiveCategory('all')); + status.appendChild(clear); + } + } + + function updateEmptyMessage(container, id, count, text) { + let empty = document.getElementById(id); + if (!empty) { + empty = document.createElement('p'); + empty.id = id; + empty.className = 'schedule-filter-empty'; + container.appendChild(empty); + } + empty.hidden = count > 0; + empty.textContent = text; + } + + function applyFilter() { + const visibleMeetings = meetingEntries.filter(matchesCategory).length; + const visibleEvents = eventEntries.filter(matchesCategory).length; + for (const e of entries) { + const row = document.getElementById(e.rowId); + if (row) { + const show = matchesCategory(e); + row.hidden = !show; + row.classList.toggle('is-filtered-out', !show); + } + } + updateTableStatus(meetingStatus, 'meeting', meetingEntries.length, visibleMeetings); + updateTableStatus(eventStatus, 'event', eventEntries.length, visibleEvents); + updateEmptyMessage( + meetingsTable.parentElement, + 'meeting-filter-empty', + visibleMeetings, + '該当する審議会等はありません。' + ); + updateEmptyMessage( + eventsTable.parentElement, + 'event-filter-empty', + visibleEvents, + '該当するイベントはありません。' + ); + } + + function setActiveCategory(category) { + activeCategory = category; + filtersEl.querySelectorAll('.schedule-filter-btn').forEach((b) => { + b.setAttribute('aria-pressed', String(b.dataset.category === activeCategory)); + }); + applyFilter(); + ensureVisibleSelection(); + renderCalendar(); + if (selectedDate) showDayDetails(selectedDate); + } + + function buildFilters() { + const buttonDefs = [ + { category: 'all', icon: '', label: 'すべて', count: entries.length }, + { + category: MEETING_ICON, + icon: MEETING_ICON, + label: '審議会等', + count: meetingEntries.length, + }, + ...CATEGORY_DEFS.map((c) => ({ + category: c.icon, + icon: c.icon, + label: c.label, + count: counts[c.icon] || 0, + })), + ]; + filtersEl.innerHTML = buttonDefs + .map( + (b) => + `<button type="button" class="schedule-filter-btn" data-category="${b.category}" aria-pressed="${b.category === activeCategory}">${b.icon ? `${b.icon} ` : ''}${b.label} (${b.count})</button>` + ) + .join(''); + filtersEl.querySelectorAll('.schedule-filter-btn').forEach((btn) => { + btn.addEventListener('click', () => setActiveCategory(btn.dataset.category)); + }); + } + + buildFilters(); + applyFilter(); + renderCalendar(); + if (selectedDate) showDayDetails(selectedDate); + } + + function boot() { + initSchedule(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } + document.addEventListener('astro:page-load', boot); +</script> |
