diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-11-01 18:57:48 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-11-01 18:57:48 +0900 |
| commit | bae1df8dd99c26d5a74dd6dbc63d24aa0ab8f441 (patch) | |
| tree | 0f0ac074a02b172909ab3aad8d65b58e70f10da9 /src/components | |
| parent | 7cfcaf0de8017f89033c786bffbbfd00f207ac11 (diff) | |
hp-archive: 会場の地図を追加し、公園マップを非公開に
- カレンダーで選んだ日の会場を地図に表示(市境・現在地ボタン付き)
- 会場の座標データを追加し、1つの予定を複数会場に表示できるようにした
- マナーアップキャンペーンと筋力アップ講座を日付・曜日ごとの行に分割
- こだいら公園さんぽマップを draft にして非公開にした
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/ScheduleInteractive.astro | 13 | ||||
| -rw-r--r-- | src/components/ScheduleMap.astro | 386 |
2 files changed, 399 insertions, 0 deletions
diff --git a/src/components/ScheduleInteractive.astro b/src/components/ScheduleInteractive.astro index 955977d..3295125 100644 --- a/src/components/ScheduleInteractive.astro +++ b/src/components/ScheduleInteractive.astro @@ -354,6 +354,19 @@ }); const title = `${date.getMonth() + 1}月${date.getDate()}日(${weekday})の予定`; + // 会場の地図(ScheduleMap)に、その日の予定を知らせる。 + const detail = { + date: key, + entries: list.map((e) => ({ + name: e.name, + time: e.time, + category: e.category, + rowId: e.rowId, + })), + }; + (window as any).__scheduleDay = detail; + document.dispatchEvent(new CustomEvent('schedule:day', { detail })); + if (list.length === 0) { detailsEl.innerHTML = `<h3>${title}</h3><p>この日の予定はありません。</p>`; return; diff --git a/src/components/ScheduleMap.astro b/src/components/ScheduleMap.astro new file mode 100644 index 0000000..bf84dd7 --- /dev/null +++ b/src/components/ScheduleMap.astro @@ -0,0 +1,386 @@ +--- +// カレンダーで選んだ日に開催される予定の会場を地図に表示する。 +// 会場名から緯度・経度を引くため、公共施設のデータと、イベント会場の追加データを使う。 +--- + +<div class="schedule-map-block"> + <div id="schedule-map" class="schedule-map" role="region" aria-label="選択した日の会場の地図"></div> + <p id="schedule-map-note" class="schedule-map-note" aria-live="polite"></p> + <p id="schedule-map-locate-status" class="schedule-map-locate-status" aria-live="polite" hidden></p> +</div> + +<script> + import L from 'leaflet'; + import 'leaflet/dist/leaflet.css'; + import facilities from '@/data/kodaira-facilities.json'; + import extraVenues from '@/data/kodaira-event-venues.json'; + import boundary from '@/data/kodaira-boundary.json'; + + type Venue = { name: string; alias: string; lat: number; lon: number }; + type Entry = { name: string; time: string; category: string; rowId: string }; + + function buildVenues(): Venue[] { + const out: Venue[] = []; + // 「小川町一丁目地域センター」と「小川町1丁目」のような漢数字・算用数字の違いを吸収する。 + const toAsciiDigits = (s: string) => + s.replace(/[一二三四五六七八九]/g, (c) => String('一二三四五六七八九'.indexOf(c) + 1)); + + const pushName = (name: string, lat: number, lon: number) => { + out.push({ name, alias: name, lat, lon }); + const ascii = toAsciiDigits(name); + if (ascii !== name) out.push({ name, alias: ascii, lat, lon }); + // 「ルネこだいら(小平市民文化会館)」のような正式名は、括弧の前でも照合する。 + const short = name.replace(/(.*?)/g, '').trim(); + if (short && short !== name) { + out.push({ name, alias: short, lat, lon }); + const shortAscii = toAsciiDigits(short); + if (shortAscii !== short) out.push({ name, alias: shortAscii, lat, lon }); + } + }; + + for (const f of facilities as any[]) { + const lat = Number(f.lat); + const lon = Number(f.lon); + if (!lat || !lon) continue; + pushName(String(f.name), lat, lon); + } + for (const v of extraVenues as any[]) { + for (const alias of (v.aliases ?? [v.name]) as string[]) { + out.push({ name: v.name, alias, lat: Number(v.lat), lon: Number(v.lon) }); + } + } + // 長い別名から照合し、「東部市民センター」が短い別名で誤って一致しないようにする。 + return out.sort((a, b) => b.alias.length - a.alias.length); + } + + const venues = buildVenues(); + const compact = (s: string) => s.replace(/[\s ]/g, ''); + + // 文章中の会場名を、左から順に・長い名前を優先して拾う。 + // 「マナーアップキャンペーン」のように複数会場を並べた予定は、そのすべてを返す。 + // 「新小平駅」を「小平駅」と取り違えないよう、一致した範囲は読み飛ばす。 + function findVenues(text: string): Venue[] { + const t = compact(text); + const found: Venue[] = []; + let from = 0; + while (from < t.length) { + let best: { index: number; alias: string; venue: Venue } | null = null; + for (const v of venues) { + const alias = compact(v.alias); + const index = t.indexOf(alias, from); + if (index === -1) continue; + if ( + !best || + index < best.index || + (index === best.index && alias.length > best.alias.length) + ) { + best = { index, alias, venue: v }; + } + } + if (!best) break; + const picked = best; + if (!found.some((f) => f.lat === picked.venue.lat && f.lon === picked.venue.lon)) { + found.push(picked.venue); + } + from = picked.index + picked.alias.length; + } + return found; + } + + const escapeHtml = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); + + const el = document.getElementById('schedule-map'); + const noteEl = document.getElementById('schedule-map-note'); + + if (el && noteEl && !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); + + 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 boundaryLayer = L.geoJSON(boundary as any, { + style: { + color: '#6a1b9a', + weight: 4, + opacity: 0.9, + fill: false, + }, + interactive: false, + }).addTo(map); + + const fitToBoundary = () => { + const bounds = boundaryLayer.getBounds(); + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [20, 20], animate: false }); + } else { + map.setView([35.728, 139.477], 13); + } + }; + + // レイアウト確定後にサイズを再計算し、タイルを再配置する。 + // 初回は表示範囲も合わせ直す(タイルの行ずれを防ぐ)。 + const refreshMap = (refit = false) => { + map.invalidateSize(); + tileLayer.redraw(); + if (!refit) return; + if (currentBounds.length === 0) fitToBoundary(); + else if (currentBounds.length === 1) map.setView(currentBounds[0], 15); + else map.fitBounds(currentBounds, { padding: [40, 40], maxZoom: 16 }); + }; + + let currentBounds: L.LatLngTuple[] = []; + + let markers: L.LayerGroup | null = null; + + // 現在地表示。位置情報はボタンを押したときだけ取得する。 + const locateStatusEl = document.getElementById('schedule-map-locate-status'); + const setLocateStatus = (message: string) => { + if (!locateStatusEl) return; + locateStatusEl.textContent = message; + locateStatusEl.hidden = !message; + }; + + const LOCATE_ICON = + '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12" r="2.5" fill="currentColor"/><path d="M12 1.5v3M12 19.5v3M1.5 12h3M19.5 12h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>'; + const ACTIVE_ICON = + '<span aria-hidden="true"><svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="2.5" fill="currentColor"/><circle cx="12" cy="12" r="7" fill="none" stroke="currentColor" stroke-width="2"/></svg></span>'; + + let locationMarker: L.Marker | null = null; + let accuracyCircle: L.Circle | null = null; + let locateButton: HTMLButtonElement | null = null; + + const updateLocateButton = () => { + if (!locateButton) return; + const active = Boolean(locationMarker); + locateButton.classList.toggle('is-active', active); + locateButton.setAttribute('aria-pressed', active ? 'true' : 'false'); + const label = active ? '現在地の表示を消す' : '現在地を表示'; + locateButton.title = label; + locateButton.setAttribute('aria-label', label); + locateButton.innerHTML = active ? ACTIVE_ICON : LOCATE_ICON; + }; + + const clearLocation = () => { + if (locationMarker) { + locationMarker.remove(); + locationMarker = null; + } + if (accuracyCircle) { + accuracyCircle.remove(); + accuracyCircle = null; + } + updateLocateButton(); + }; + + const showLocation = (latitude: number, longitude: number, accuracy: number) => { + if (!locationMarker) { + const icon = L.divIcon({ + className: 'schedule-locate-marker', + html: '<span class="schedule-locate-dot"></span>', + iconSize: [18, 18], + iconAnchor: [9, 9], + }); + locationMarker = L.marker([latitude, longitude], { + icon, + interactive: false, + keyboard: false, + }).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)); + } + + map.setView([latitude, longitude], Math.max(map.getZoom(), 15)); + updateLocateButton(); + setLocateStatus( + `現在地を表示しています(精度 約${Math.round(accuracy)}m)。もう一度押すと表示を消します。` + ); + }; + + const locate = async () => { + if (locationMarker) { + clearLocation(); + setLocateStatus('現在地の表示を消しました。'); + return; + } + + if (!navigator.geolocation) { + setLocateStatus('このブラウザでは現在地を取得できません。'); + window.alert( + 'このブラウザでは現在地を取得できません。端末やアプリの設定をご確認ください。' + ); + return; + } + + // すでに「ブロック」されている場合は、ブラウザがもう許可を聞いてくれない。 + let state = 'unknown'; + try { + if (navigator.permissions?.query) { + state = (await navigator.permissions.query({ name: 'geolocation' as PermissionName })).state; + } + } catch { + state = 'unknown'; + } + + if (state === 'denied') { + setLocateStatus('位置情報がブロックされています。設定で許可してください。'); + window.alert( + '位置情報の利用がブロックされています。お使いのブラウザやアプリ(ホーム画面に追加したアプリを含む)の設定で、このサイトの位置情報を許可してから、もう一度お試しください。' + ); + return; + } + + setLocateStatus('現在地を取得しています…'); + navigator.geolocation.getCurrentPosition( + (position) => { + const { latitude, longitude, accuracy } = position.coords; + showLocation(latitude, longitude, accuracy); + }, + (error) => { + let message = '現在地を取得できませんでした。'; + if (error.code === error.PERMISSION_DENIED) { + message = + '位置情報の利用が許可されていません。ブラウザやアプリの設定で、このサイトの位置情報を許可してください。'; + } else if (error.code === error.POSITION_UNAVAILABLE) { + message = + '現在地を取得できませんでした。端末の位置情報(GPS)が有効か、電波の状況をご確認ください。'; + } else if (error.code === error.TIMEOUT) { + message = '現在地の取得に時間がかかっています。もう一度お試しください。'; + } + setLocateStatus(message); + window.alert(message); + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } + ); + }; + + const LocateControl = L.Control.extend({ + onAdd: () => { + const btn = L.DomUtil.create('button', 'schedule-locate') as HTMLButtonElement; + btn.setAttribute('type', 'button'); + locateButton = btn; + L.DomEvent.disableClickPropagation(btn); + L.DomEvent.disableScrollPropagation(btn); + btn.addEventListener('click', () => { + void locate(); + }); + updateLocateButton(); + return btn; + }, + }); + + new LocateControl({ position: 'topright' }).addTo(map); + + function render(entries: Entry[]) { + if (markers) markers.remove(); + markers = L.layerGroup().addTo(map); + + const groups = new Map<string, { venue: Venue; items: Entry[] }>(); + let unmapped = 0; + + for (const entry of entries) { + const matched = findVenues(`${entry.time} ${entry.name}`); + if (matched.length === 0) { + unmapped += 1; + continue; + } + for (const venue of matched) { + const key = `${venue.lat},${venue.lon}`; + const group = groups.get(key) ?? { venue, items: [] }; + group.items.push(entry); + groups.set(key, group); + } + } + + if (groups.size === 0) { + currentBounds = []; + fitToBoundary(); + noteEl.textContent = + entries.length === 0 + ? 'この日の予定はありません。' + : 'この日の予定の会場は、地図に出せる場所として登録されていません。'; + return; + } + + const bounds: L.LatLngTuple[] = []; + + for (const { venue, items } of groups.values()) { + bounds.push([venue.lat, venue.lon]); + const icon = L.divIcon({ + className: 'schedule-map-marker', + html: `<span class="schedule-map-pin">${escapeHtml(items[0].category || '📍')}</span>`, + iconSize: [30, 30], + iconAnchor: [15, 15], + }); + const list = items + .map((item) => { + const name = item.name.startsWith(item.category) + ? item.name.slice(item.category.length).trim() + : item.name; + const time = item.time.split('/')[0]; + return `<li><a href="#${escapeHtml(item.rowId)}">${escapeHtml(name)}</a><span>${escapeHtml(time)}</span></li>`; + }) + .join(''); + + L.marker([venue.lat, venue.lon], { icon, title: venue.name }) + .addTo(markers) + .bindPopup( + `<strong class="schedule-map-popup-title">${escapeHtml(venue.name)}</strong><ul class="schedule-map-popup-list">${list}</ul>` + ); + } + + if (bounds.length === 1) { + map.setView(bounds[0], 15); + } else { + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }); + } + currentBounds = bounds; + + const suffix = + unmapped > 0 ? `(会場が地図に未登録の予定${unmapped}件は表示していません)` : ''; + noteEl.textContent = `${groups.size}会場を表示しています。${suffix}`; + } + + document.addEventListener('schedule:day', (event) => { + const detail = (event as CustomEvent).detail as { entries?: Entry[] } | undefined; + render(detail?.entries ?? []); + }); + + // 読み込み順にかかわらず、すでに選択されている日の内容を反映する。 + const initial = (window as any).__scheduleDay as { entries?: Entry[] } | undefined; + if (initial) render(initial.entries ?? []); + else noteEl.textContent = 'カレンダーで日付を選ぶと、その日の会場を地図に表示します。'; + + // レイアウト確定後にサイズを再計算し、タイルの行ずれを防ぐ。 + requestAnimationFrame(() => refreshMap(true)); + window.setTimeout(() => refreshMap(true), 300); + window.addEventListener('resize', () => refreshMap(false)); + } +</script> |
