aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/components
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-01 18:57:48 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-11-01 18:57:48 +0900
commitbae1df8dd99c26d5a74dd6dbc63d24aa0ab8f441 (patch)
tree0f0ac074a02b172909ab3aad8d65b58e70f10da9 /src/components
parent7cfcaf0de8017f89033c786bffbbfd00f207ac11 (diff)
hp-archive: 会場の地図を追加し、公園マップを非公開に
- カレンダーで選んだ日の会場を地図に表示(市境・現在地ボタン付き) - 会場の座標データを追加し、1つの予定を複数会場に表示できるようにした - マナーアップキャンペーンと筋力アップ講座を日付・曜日ごとの行に分割 - こだいら公園さんぽマップを draft にして非公開にした
Diffstat (limited to 'src/components')
-rw-r--r--src/components/ScheduleInteractive.astro13
-rw-r--r--src/components/ScheduleMap.astro386
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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:
+ '&copy; <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>