aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/KodairaNews.astro39
-rw-r--r--src/components/ParkMap.astro656
-rw-r--r--src/components/ParksTable.astro264
-rw-r--r--src/components/ScheduleInteractive.astro493
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:
+ '&copy; <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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>