1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
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>
|