Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
public/js/subway-map.js
공개 가능
1
/* 실시간 노선도: 노선 지오메트리(subway-map-data.json) + 전체 열차 위치(/api/subway/map-trains) */
2
(function () {
3
'use strict';
4
5
var SVG_NS = 'http://www.w3.org/2000/svg';
6
var REFRESH_MS = 5000;
7
8
var svg = document.getElementById('mapSvg');
9
var statusEl = document.getElementById('mapStatus');
10
var legendEl = document.getElementById('legend');
11
var loadingEl = document.getElementById('mapLoading');
12
var infoPanel = document.getElementById('infoPanel');
13
var infoBody = document.getElementById('infoBody');
14
var searchInput = document.getElementById('stationSearch');
15
var stationListEl = document.getElementById('stationList');
16
var favoritesBtn = document.getElementById('favoritesBtn');
17
var favoriteCountEl = document.getElementById('favoriteCount');
18
var favoritesPanel = document.getElementById('favoritesPanel');
19
var favoritesBody = document.getElementById('favoritesBody');
20
var locationBtn = document.getElementById('locationBtn');
21
var FAVORITES_KEY = 'hinanaSubwayStationFavoritesV1';
22
var TRAIN_VOICE_ENABLED_KEY = 'hinanaSubwayTrainVoiceEnabledV1';
23
var TRANSFER_MUSIC_ENABLED_KEY = 'hinanaSubwayTransferMusicEnabledV1';
24
var TRANSFER_MUSIC_SELECTION_KEY = 'hinanaSubwayTransferMusicSelectionV1';
25
var TRANSFER_MUSIC_URLS = {
26
pungnyeon: '/sound/subway/transfer/pungnyeon.mp3?v=1',
27
eolssiguya: '/sound/subway/transfer/eolssiguya.mp3?v=1'
28
};
29
var TRANSFER_ANNOUNCEMENT_DELAY_SECONDS = 4;
30
31
var MAP_URLS = { geo: '/js/subway-map-data.json', schematic: '/js/subway-map-schematic.json' };
32
var DISTRICT_URL = '/js/seoul-districts.json';
33
var RIVER_URL = '/js/seoul-river.json?v=2';
34
var SCHEMATIC_WATER_URL = '/js/subway-schematic-water.json?v=3';
35
var mapStyle = (function () {
36
try { return localStorage.getItem('subwayMapStyle') === 'schematic' ? 'schematic' : 'geo'; }
37
catch (e) { return 'geo'; }
38
})();
39
var mapDataCache = {};
40
var districtData = null;
41
var districtRequest = null;
42
var riverData = null;
43
var riverRequest = null;
44
var schematicWaterData = null;
45
var schematicWaterRequest = null;
46
var mapData = null;
47
var trains = [];
48
var trainErrors = [];
49
var updatedAt = null;
50
var hiddenLines = new Set();
51
var selectedTrainKey = null;
52
var stationIndexByLine = {}; // lineId -> Map(normName -> {x,y,name})
53
var stationIndexGlobal = new Map(); // normName -> {x,y,name}
54
var trainCountByLine = {};
55
var placedTrains = [];
56
var trackedTrainKey = null;
57
var mapGestureActive = false;
58
var pendingTrainRender = false;
59
var trainVoiceContext = null;
60
var trainVoiceManifestPromise = null;
61
var trainVoiceBuffers = new Map();
62
var activeTrainVoiceSource = null;
63
var activeTransferMusicSource = null;
64
var trainVoiceEnabled = loadTrainVoiceEnabled();
65
var transferMusicEnabled = loadTransferMusicEnabled();
66
var selectedTransferMusic = loadTransferMusicSelection();
67
var trackedVoiceKey = null;
68
var trackedVoiceState = null;
69
var trackedVoiceStation = null;
70
var lineSegments = {}; // lineId -> [{ax,ay,bx,by,pi}] 노선 경로 구간 (아이콘 회전용)
71
var loopInfo = {}; // lineId -> {pi, cx, cy} 순환 본선 정보 (2호선 내선/외선 판정용)
72
var refreshTimer = null;
73
var countdownTimer = null;
74
var nextRefreshAt = 0;
75
var trainRequest = null;
76
var stationInfoRequestId = 0;
77
var selectedStation = null;
78
var favorites = loadFavorites();
79
var stationCoordinates = null;
80
var stationCoordinateRequest = null;
81
var locatedStation = null;
82
83
var vb = { x: 0, y: 0, w: 2200, h: 2200 };
84
var fullVb = null;
85
var zoomStyleEl = document.createElement('style');
86
document.head.appendChild(zoomStyleEl);
87
88
var layers = {};
89
90
function norm(name) {
91
var normalized = String(name || '')
92
.replace(/\(.*?\)/g, '')
93
.replace(/\s|·/g, '')
94
.replace(/역$/, '');
95
// realtimePosition의 지선 구역명·개정 전 역명을 현재 노선도 역명에 맞춘다.
96
return {
97
'성수지선': '성수',
98
'성수종착': '성수',
99
'신도림지선': '신도림',
100
'뚝섬유원지': '자양',
101
'총신대입구': '이수',
102
'세종왕릉': '세종대왕릉'
103
}[normalized] || normalized;
104
}
105
106
function el(tag, attrs, parent) {
107
var node = document.createElementNS(SVG_NS, tag);
108
if (attrs) Object.keys(attrs).forEach(function (k) { node.setAttribute(k, attrs[k]); });
109
if (parent) parent.appendChild(node);
110
return node;
111
}
112
113
function escapeHtml(value) {
114
return String(value == null ? '' : value).replace(/[&<>"']/g, function (ch) {
115
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
116
});
117
}
118
119
function favoriteKey(lineId, stationName) {
120
return String(lineId || '') + '|' + norm(stationName);
121
}
122
123
function loadFavorites() {
124
try {
125
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
126
if (!Array.isArray(parsed)) return [];
127
var seen = new Set();
128
return parsed.filter(function (item) {
129
if (!item || typeof item.lineId !== 'string' || typeof item.stationName !== 'string') return false;
130
var key = favoriteKey(item.lineId, item.stationName);
131
if (!item.lineId || !norm(item.stationName) || seen.has(key)) return false;
132
seen.add(key);
133
return true;
134
}).map(function (item) {
135
return { lineId: item.lineId, stationName: item.stationName };
136
});
137
} catch (e) {
138
return [];
139
}
140
}
141
142
function loadTrainVoiceEnabled() {
143
try { return localStorage.getItem(TRAIN_VOICE_ENABLED_KEY) !== 'false'; }
144
catch (_) { return true; }
145
}
146
147
function saveTrainVoiceEnabled() {
148
try { localStorage.setItem(TRAIN_VOICE_ENABLED_KEY, String(trainVoiceEnabled)); }
149
catch (_) { /* ignore */ }
150
}
151
152
function loadTransferMusicEnabled() {
153
try { return localStorage.getItem(TRANSFER_MUSIC_ENABLED_KEY) !== 'false'; }
154
catch (_) { return true; }
155
}
156
157
function saveTransferMusicEnabled() {
158
try { localStorage.setItem(TRANSFER_MUSIC_ENABLED_KEY, String(transferMusicEnabled)); }
159
catch (_) { /* ignore */ }
160
}
161
162
function loadTransferMusicSelection() {
163
try {
164
var selection = localStorage.getItem(TRANSFER_MUSIC_SELECTION_KEY);
165
return TRANSFER_MUSIC_URLS[selection] ? selection : 'pungnyeon';
166
} catch (_) {
167
return 'pungnyeon';
168
}
169
}
170
171
function saveTransferMusicSelection() {
172
try { localStorage.setItem(TRANSFER_MUSIC_SELECTION_KEY, selectedTransferMusic); }
173
catch (_) { /* ignore */ }
174
}
175
176
function saveFavorites() {
177
[SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
178
}
179
180
function isFavorite(lineId, stationName) {
181
var key = favoriteKey(lineId, stationName);
182
return favorites.some(function (item) { return favoriteKey(item.lineId, item.stationName) === key; });
183
}
184
185
function updateFavoriteDots() {
186
document.querySelectorAll('.station-dot[data-favorite-key]').forEach(function (dot) {
187
var key = dot.getAttribute('data-favorite-key');
188
dot.classList.toggle('favorite', favorites.some(function (item) {
189
return favoriteKey(item.lineId, item.stationName) === key;
190
}));
191
});
192
}
193
194
function findStation(lineId, stationName) {
195
if (!mapData) return null;
196
var line = mapData.lines.find(function (item) { return item.id === lineId; });
197
if (!line) return null;
198
var key = norm(stationName);
199
var station = line.stations.find(function (item) { return norm(item.n) === key; });
200
return station ? { station: station, line: line } : null;
201
}
202
203
function renderFavorites() {
204
favoriteCountEl.textContent = String(favorites.length);
205
favoritesBtn.classList.toggle('active', favoritesPanel.classList.contains('show'));
206
if (!favorites.length) {
207
favoritesBody.innerHTML = '<p class="favorites-empty">역을 선택한 뒤 별 버튼을 누르면 여기에 저장됩니다.</p>';
208
updateFavoriteDots();
209
return;
210
}
211
favoritesBody.innerHTML = '<div class="favorite-list">' + favorites.map(function (item) {
212
return '<button type="button" class="favorite-item" data-favorite-key="' + escapeHtml(favoriteKey(item.lineId, item.stationName)) + '">' +
213
'<span class="line-dot" style="background:' + lineColor(item.lineId) + '"></span>' +
214
'<span class="station-name">' + escapeHtml(item.stationName) + '</span>' +
215
'<span class="line-name">' + escapeHtml(lineDisplayName(item.lineId)) + '</span>' +
216
'<i class="bi bi-chevron-right"></i></button>';
217
}).join('') + '</div>';
218
favoritesBody.querySelectorAll('.favorite-item').forEach(function (button) {
219
button.addEventListener('click', function () {
220
var key = button.getAttribute('data-favorite-key');
221
var item = favorites.find(function (favorite) {
222
return favoriteKey(favorite.lineId, favorite.stationName) === key;
223
});
224
var found = item && findStation(item.lineId, item.stationName);
225
if (!found) return;
226
favoritesPanel.classList.remove('show');
227
favoritesBtn.setAttribute('aria-expanded', 'false');
228
zoomToPoint(found.station.x, found.station.y, 420);
229
showStationInfo(found.station, found.line);
230
renderFavorites();
231
});
232
});
233
updateFavoriteDots();
234
}
235
236
function toggleFavorite(st, line) {
237
var key = favoriteKey(line.id, st.n);
238
var index = favorites.findIndex(function (item) {
239
return favoriteKey(item.lineId, item.stationName) === key;
240
});
241
if (index >= 0) favorites.splice(index, 1);
242
else favorites.push({ lineId: line.id, stationName: st.n });
243
saveFavorites();
244
renderFavorites();
245
if (selectedStation && favoriteKey(selectedStation.line.id, selectedStation.station.n) === key) {
246
var button = infoBody.querySelector('.station-favorite-btn');
247
if (button) {
248
var active = isFavorite(line.id, st.n);
249
button.classList.toggle('active', active);
250
button.title = active ? '즐겨찾기 해제' : '즐겨찾기 추가';
251
button.innerHTML = '<i class="bi ' + (active ? 'bi-star-fill' : 'bi-star') + '"></i>';
252
}
253
}
254
}
255
256
function fetchStationCoordinates() {
257
if (stationCoordinates) return Promise.resolve(stationCoordinates);
258
if (stationCoordinateRequest) return stationCoordinateRequest;
259
stationCoordinateRequest = fetch('/api/subway/station-coordinates', { headers: { 'Accept': 'application/json' } })
260
.then(function (res) {
261
if (!res.ok) return res.json().catch(function () { return {}; }).then(function (data) {
262
throw new Error(data.message || '역 좌표를 불러오지 못했습니다.');
263
});
264
return res.json();
265
})
266
.then(function (data) {
267
if (!data.success || !Array.isArray(data.stations)) throw new Error(data.message || '역 좌표를 불러오지 못했습니다.');
268
stationCoordinates = data.stations;
269
return stationCoordinates;
270
})
271
.finally(function () { stationCoordinateRequest = null; });
272
return stationCoordinateRequest;
273
}
274
275
function getCurrentPosition() {
276
return new Promise(function (resolve, reject) {
277
if (!navigator.geolocation) {
278
reject(new Error('이 브라우저에서는 현재 위치를 확인할 수 없습니다.'));
279
return;
280
}
281
navigator.geolocation.getCurrentPosition(resolve, reject, {
282
enableHighAccuracy: true,
283
timeout: 12000,
284
maximumAge: 60000
285
});
286
});
287
}
288
289
function distanceMeters(lat1, lng1, lat2, lng2) {
290
var rad = Math.PI / 180;
291
var dLat = (lat2 - lat1) * rad;
292
var dLng = (lng2 - lng1) * rad;
293
var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
294
Math.cos(lat1 * rad) * Math.cos(lat2 * rad) *
295
Math.sin(dLng / 2) * Math.sin(dLng / 2);
296
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
297
}
298
299
function formatDistance(meters) {
300
if (meters < 1000) return Math.max(1, Math.round(meters)) + 'm';
301
return (meters / 1000).toFixed(meters < 10000 ? 1 : 0) + 'km';
302
}
303
304
function findAnyMapStation(stationName) {
305
var key = norm(stationName);
306
for (var i = 0; i < mapData.lines.length; i++) {
307
var line = mapData.lines[i];
308
var station = line.stations.find(function (item) { return norm(item.n) === key; });
309
if (station) return { station: station, line: line };
310
}
311
return null;
312
}
313
314
function findNearestStation(position, coordinates) {
315
var latitude = Number(position.coords.latitude);
316
var longitude = Number(position.coords.longitude);
317
var nearest = null;
318
coordinates.forEach(function (item) {
319
var found = findStation(item.line, item.station) || findAnyMapStation(item.station);
320
if (!found) return;
321
var distance = distanceMeters(latitude, longitude, Number(item.latitude), Number(item.longitude));
322
if (!nearest || distance < nearest.distance) {
323
nearest = { station: found.station, line: found.line, distance: distance };
324
}
325
});
326
return nearest;
327
}
328
329
function renderLocationMarker() {
330
if (!layers.location) return;
331
layers.location.innerHTML = '';
332
if (!locatedStation) return;
333
var found = findStation(locatedStation.lineId, locatedStation.stationName) || findAnyMapStation(locatedStation.stationName);
334
if (!found) return;
335
var marker = el('g', {
336
'class': 'location-marker',
337
transform: 'translate(' + found.station.x + ',' + found.station.y + ')'
338
}, layers.location);
339
el('circle', { cx: 0, cy: 0, 'class': 'location-ring' }, marker);
340
el('circle', { cx: 0, cy: 0, 'class': 'location-core' }, marker);
341
}
342
343
function showLocationError(error) {
344
var message = error && error.message ? error.message : '현재 위치를 확인하지 못했습니다.';
345
if (error && error.code === 1) message = '위치 권한이 거부되었습니다. 브라우저의 사이트 권한에서 위치 접근을 허용해 주세요.';
346
if (error && error.code === 2) message = '현재 위치를 확인할 수 없습니다. 잠시 후 다시 시도해 주세요.';
347
if (error && error.code === 3) message = '위치 확인 시간이 초과되었습니다. GPS 또는 Wi-Fi 상태를 확인해 주세요.';
348
showPanel('<h3><i class="bi bi-crosshair"></i> 현재 위치</h3><div class="muted arrival-error">' + escapeHtml(message) + '</div>');
349
}
350
351
function locateNearestStation() {
352
locationBtn.disabled = true;
353
locationBtn.classList.add('loading');
354
Promise.all([getCurrentPosition(), fetchStationCoordinates()])
355
.then(function (result) {
356
var position = result[0];
357
var nearest = findNearestStation(position, result[1]);
358
if (!nearest) throw new Error('현재 노선도에서 가까운 역을 찾지 못했습니다.');
359
locatedStation = {
360
stationName: nearest.station.n,
361
lineId: nearest.line.id,
362
distance: nearest.distance,
363
accuracy: Number(position.coords.accuracy || 0)
364
};
365
locationBtn.classList.add('active');
366
zoomToPoint(nearest.station.x, nearest.station.y, 420);
367
renderLocationMarker();
368
showStationInfo(nearest.station, nearest.line);
369
var heading = infoBody.querySelector('.station-heading');
370
if (heading) {
371
heading.insertAdjacentHTML('afterend', '<div class="location-summary">현재 위치에서 <b>약 ' +
372
escapeHtml(formatDistance(nearest.distance)) + '</b> · 위치 정확도 ±' +
373
escapeHtml(formatDistance(Number(position.coords.accuracy || 0))) + '</div>');
374
}
375
})
376
.catch(showLocationError)
377
.finally(function () {
378
locationBtn.disabled = false;
379
locationBtn.classList.remove('loading');
380
});
381
}
382
383
// ---------- 뷰포트 ----------
384
function applyViewBox() {
385
svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h);
386
svg.classList.toggle('z-mid', vb.w < 1750);
387
svg.classList.toggle('z-in', vb.w < 800);
388
// unit = 화면 1px 당 map 단위. 화면 기준 크기가 일정하도록 map 단위 크기를 역산.
389
var unit = vb.w / Math.max(svg.clientWidth || 1, 1);
390
function u(screenPx, minU, maxU) {
391
return Math.max(minU, Math.min(screenPx * unit, maxU));
392
}
393
// 열차 아이콘은 nominal 20단위 높이로 그려지며 scale로 화면 약 21px 유지
394
var mscale = Math.max(0.1, Math.min(1.05 * unit, 2.2));
395
var labelScreenPx = vb.w < 800 ? 13 : (vb.w < 1750 ? 11 : 9.5);
396
zoomStyleEl.textContent =
397
'.station-hit{r:' + u(13, 3, 34) + 'px}' +
398
'.station-dot{r:' + u(2.6, 0.5, 8) + 'px}' +
399
'.station-dot.transfer{r:' + u(4, 0.8, 12) + 'px}' +
400
'.transfer-shell{stroke-width:' + u(9, 2, 24) + 'px}' +
401
'.transfer-core{stroke-width:' + u(6.2, 1.4, 18) + 'px}' +
402
'.transfer-bead{r:' + u(1.55, 0.45, 4.5) + 'px}' +
403
'.location-marker .location-ring{r:' + u(13, 3, 34) + 'px;stroke-width:' + u(2, 0.5, 5) + 'px}' +
404
'.location-marker .location-core{r:' + u(4, 1, 10) + 'px;stroke-width:' + u(2, 0.5, 5) + 'px}' +
405
'#layer-trains{--mscale:' + mscale + '}' +
406
'.station-label{font-size:' + u(labelScreenPx, 2, 28) + 'px;stroke-width:' + u(3, 0.5, 7) + 'px}' +
407
'.line-path{stroke-width:' + u(3.2, 0.7, 9) + 'px}';
408
}
409
410
function fitAll() {
411
if (!fullVb) return;
412
var rect = svg.getBoundingClientRect();
413
var aspect = rect.width / Math.max(rect.height, 1);
414
var w = fullVb.w, h = fullVb.h;
415
if (w / h < aspect) w = h * aspect; else h = w / aspect;
416
vb = { x: fullVb.x - (w - fullVb.w) / 2, y: fullVb.y - (h - fullVb.h) / 2, w: w, h: h };
417
applyViewBox();
418
}
419
420
function zoomAt(cx, cy, factor) {
421
var newW = Math.max(120, Math.min(vb.w * factor, (fullVb ? fullVb.w : 2200) * 2.5));
422
var scale = newW / vb.w;
423
vb = { x: cx - (cx - vb.x) * scale, y: cy - (cy - vb.y) * scale, w: newW, h: vb.h * scale };
424
applyViewBox();
425
}
426
427
function clientToMap(clientX, clientY) {
428
var rect = svg.getBoundingClientRect();
429
return {
430
x: vb.x + (clientX - rect.left) / rect.width * vb.w,
431
y: vb.y + (clientY - rect.top) / rect.height * vb.h
432
};
433
}
434
435
function zoomToPoint(x, y, targetW) {
436
var rect = svg.getBoundingClientRect();
437
var aspect = rect.width / Math.max(rect.height, 1);
438
var w = targetW, h = targetW / aspect;
439
vb = { x: x - w / 2, y: y - h / 2, w: w, h: h };
440
applyViewBox();
441
}
442
443
function resizeViewBox() {
444
var rect = svg.getBoundingClientRect();
445
if (!rect.width || !rect.height) return;
446
var cx = vb.x + vb.w / 2;
447
var cy = vb.y + vb.h / 2;
448
var h = vb.w / (rect.width / rect.height);
449
vb = { x: cx - vb.w / 2, y: cy - h / 2, w: vb.w, h: h };
450
applyViewBox();
451
}
452
453
// ---------- 지도 렌더링 ----------
454
function segmentIntersection(a, b, c, d) {
455
var rx = b[0] - a[0], ry = b[1] - a[1];
456
var sx = d[0] - c[0], sy = d[1] - c[1];
457
var denominator = rx * sy - ry * sx;
458
if (Math.abs(denominator) < 0.000001) return null;
459
var qx = c[0] - a[0], qy = c[1] - a[1];
460
var t = (qx * sy - qy * sx) / denominator;
461
var u = (qx * ry - qy * rx) / denominator;
462
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
463
return [a[0] + t * rx, a[1] + t * ry];
464
}
465
466
function lineIntersections(lineA, lineB) {
467
var intersections = [];
468
lineA.paths.forEach(function (pathA) {
469
lineB.paths.forEach(function (pathB) {
470
for (var ai = 1; ai < pathA.length; ai++) {
471
for (var bi = 1; bi < pathB.length; bi++) {
472
var point = segmentIntersection(pathA[ai - 1], pathA[ai], pathB[bi - 1], pathB[bi]);
473
if (point) intersections.push(point);
474
}
475
}
476
});
477
});
478
return intersections;
479
}
480
481
function schematicTransferKey(name) {
482
if (name === '\uC774\uC218' || name === '\uCD1D\uC2E0\uB300\uC785\uAD6C') return 'isu-chongshin';
483
return name;
484
}
485
486
function removeSchematicLegendArtifacts(data) {
487
data.lines.forEach(function (line) {
488
line.paths = line.paths.filter(function (path) {
489
if (path.length !== 2) return true;
490
var length = Math.hypot(path[1][0] - path[0][0], path[1][1] - path[0][1]);
491
if (length >= 50) return true;
492
var nearestStationDistance = Infinity;
493
line.stations.forEach(function (station) {
494
path.forEach(function (point) {
495
nearestStationDistance = Math.min(
496
nearestStationDistance,
497
Math.hypot(station.x - point[0], station.y - point[1])
498
);
499
});
500
});
501
return nearestStationDistance <= 100;
502
});
503
});
504
}
505
506
function correctSchematicTopology(data) {
507
var lineOne = data.lines.find(function (line) { return line.id === '1\uD638\uC120'; });
508
var lineFour = data.lines.find(function (line) { return line.id === '4\uD638\uC120'; });
509
var uiLine = data.lines.find(function (line) { return line.id === '\uC6B0\uC774\uC2E0\uC124\uC120'; });
510
var bundangLine = data.lines.find(function (line) { return line.id === '\uC218\uC778\uBD84\uB2F9\uC120'; });
511
var everLine = data.lines.find(function (line) { return line.id === '\uC6A9\uC778\uACBD\uC804\uCCA0'; });
512
var gyeonguiLine = data.lines.find(function (line) { return line.id === '\uACBD\uC758\uC911\uC559\uC120'; });
513
var seohaeLine = data.lines.find(function (line) { return line.id === '\uC11C\uD574\uC120'; });
514
if (lineOne) {
515
var dongducheon = lineOne.stations.find(function (item) { return item.n === '\uB3D9\uB450\uCC9C'; });
516
if (dongducheon) dongducheon.x = 1939;
517
518
var guro = lineOne.stations.find(function (item) { return item.n === '\uAD6C\uB85C'; });
519
var onsu = lineOne.stations.find(function (item) { return item.n === '\uC628\uC218'; });
520
if (guro && onsu) {
521
var stationNames = ['\uAD6C\uC77C', '\uAC1C\uBD09', '\uC624\uB958\uB3D9'];
522
var interval = (guro.x - onsu.x) / 4;
523
stationNames.forEach(function (name, index) {
524
var station = lineOne.stations.find(function (item) { return item.n === name; });
525
if (station) {
526
station.x = Math.round((guro.x - interval * (index + 1)) * 10) / 10;
527
station.y = guro.y;
528
}
529
});
530
}
531
}
532
if (lineFour && uiLine && uiLine.paths[0]) {
533
var sungshinPoint = [1245.5, 576.4];
534
[lineFour, uiLine].forEach(function (line) {
535
var station = line.stations.find(function (item) { return item.n === '\uC131\uC2E0\uC5EC\uB300\uC785\uAD6C'; });
536
if (station) {
537
station.x = sungshinPoint[0];
538
station.y = sungshinPoint[1];
539
}
540
});
541
uiLine.paths[0] = [
542
[1369, 630.1],
543
[1321.8, 583.7],
544
sungshinPoint.slice(),
545
[1250.2, 567.8],
546
[1276.6, 541.4],
547
[1279.5, 535.8],
548
[1280.2, 532.5],
549
[1280.2, 253.8]
550
];
551
}
552
[gyeonguiLine, seohaeLine].forEach(function (line) {
553
if (!line) return;
554
var neunggok = line.stations.find(function (item) { return item.n === '\uB2A5\uACE1'; });
555
if (neunggok) neunggok.y = 505.2;
556
});
557
if (!bundangLine || !everLine || !everLine.paths[0]) return;
558
559
var transferPoint = [1480.8, 1975.2];
560
[bundangLine, everLine].forEach(function (line) {
561
var station = line.stations.find(function (item) { return item.n === '\uAE30\uD765'; });
562
if (station) {
563
station.x = transferPoint[0];
564
station.y = transferPoint[1];
565
}
566
});
567
everLine.paths[0][0] = transferPoint.slice();
568
}
569
570
function nearestPointOnLine(line, x, y) {
571
var best = null;
572
line.paths.forEach(function (path) {
573
for (var i = 1; i < path.length; i++) {
574
var start = path[i - 1], end = path[i];
575
var dx = end[0] - start[0], dy = end[1] - start[1];
576
var lengthSquared = dx * dx + dy * dy;
577
var ratio = lengthSquared ? ((x - start[0]) * dx + (y - start[1]) * dy) / lengthSquared : 0;
578
ratio = Math.max(0, Math.min(1, ratio));
579
var pointX = start[0] + ratio * dx;
580
var pointY = start[1] + ratio * dy;
581
var distance = Math.hypot(x - pointX, y - pointY);
582
if (!best || distance < best.distance) best = { x: pointX, y: pointY, distance: distance };
583
}
584
});
585
return best;
586
}
587
588
function snapNearbyStationsToLines(data) {
589
data.lines.forEach(function (line) {
590
line.stations.forEach(function (station) {
591
var nearest = nearestPointOnLine(line, station.x, station.y);
592
if (!nearest || nearest.distance > 20) return;
593
station.x = Math.round(nearest.x * 10) / 10;
594
station.y = Math.round(nearest.y * 10) / 10;
595
});
596
});
597
}
598
599
function routeLineThroughPoint(line, sourceX, sourceY, targetX, targetY) {
600
var nearestVertex = null;
601
line.paths.forEach(function (path) {
602
path.forEach(function (point) {
603
var distance = Math.hypot(point[0] - sourceX, point[1] - sourceY);
604
if (!nearestVertex || distance < nearestVertex.distance) {
605
nearestVertex = { point: point, distance: distance };
606
}
607
});
608
});
609
if (nearestVertex && nearestVertex.distance <= 24) {
610
nearestVertex.point[0] = targetX;
611
nearestVertex.point[1] = targetY;
612
return;
613
}
614
615
var nearestSegment = null;
616
line.paths.forEach(function (path) {
617
for (var i = 1; i < path.length; i++) {
618
var start = path[i - 1], end = path[i];
619
var dx = end[0] - start[0], dy = end[1] - start[1];
620
var lengthSquared = dx * dx + dy * dy;
621
var ratio = lengthSquared ? ((sourceX - start[0]) * dx + (sourceY - start[1]) * dy) / lengthSquared : 0;
622
ratio = Math.max(0, Math.min(1, ratio));
623
var pointX = start[0] + ratio * dx;
624
var pointY = start[1] + ratio * dy;
625
var distance = Math.hypot(sourceX - pointX, sourceY - pointY);
626
if (!nearestSegment || distance < nearestSegment.distance) {
627
nearestSegment = { path: path, index: i, distance: distance };
628
}
629
}
630
});
631
if (nearestSegment && nearestSegment.distance <= 24) {
632
nearestSegment.path.splice(nearestSegment.index, 0, [targetX, targetY]);
633
}
634
}
635
636
function normalizeSchematicTransfers(data) {
637
if (!data || data._transferGeometryNormalized) return;
638
Object.defineProperty(data, '_transferGeometryNormalized', { value: true });
639
removeSchematicLegendArtifacts(data);
640
correctSchematicTopology(data);
641
snapNearbyStationsToLines(data);
642
var groups = new Map();
643
data.lines.forEach(function (line) {
644
line.stations.forEach(function (station) {
645
var groupKey = schematicTransferKey(station.n);
646
var aliased = groupKey !== station.n;
647
if (!station.t && !aliased) return;
648
if (aliased) {
649
station._labelX = station.x;
650
station._labelY = station.y;
651
station.t = 1;
652
}
653
var group = groups.get(groupKey) || [];
654
group.push({ line: line, station: station });
655
groups.set(groupKey, group);
656
});
657
});
658
var alignedTransfers = new Set([
659
'\uB3D9\uB300\uBB38\uC5ED\uC0AC\uBB38\uD654\uACF5\uC6D0',
660
'\uC655\uC2ED\uB9AC',
661
'\uCCAD\uB7C9\uB9AC'
662
]);
663
groups.forEach(function (group) {
664
var groupKey = schematicTransferKey(group[0].station.n);
665
if (group.length >= 3 && alignedTransfers.has(groupKey)) {
666
var axisX = Math.round(group.reduce(function (sum, entry) {
667
return sum + entry.station.x;
668
}, 0) / group.length * 10) / 10;
669
var centerY = Math.round(group.reduce(function (sum, entry) {
670
return sum + entry.station.y;
671
}, 0) / group.length * 10) / 10;
672
var spacing = 11.4;
673
group.slice().sort(function (a, b) {
674
return a.station.y - b.station.y || a.line.id.localeCompare(b.line.id);
675
}).forEach(function (entry, index) {
676
var targetY = Math.round((centerY + (index - (group.length - 1) / 2) * spacing) * 10) / 10;
677
routeLineThroughPoint(
678
entry.line,
679
entry.station.x,
680
entry.station.y,
681
axisX,
682
targetY
683
);
684
entry.station.x = axisX;
685
entry.station.y = targetY;
686
});
687
return;
688
}
689
if (group.length !== 2) return;
690
var requiresIntersectionCorrection = groupKey === '\uC11D\uCD0C';
691
var first = group[0], second = group[1];
692
var dx = Math.abs(first.station.x - second.station.x);
693
var dy = Math.abs(first.station.y - second.station.y);
694
var stationGap = Math.hypot(dx, dy);
695
var correctionRange = requiresIntersectionCorrection ? 90 : (stationGap >= 25 ? 60 : 20);
696
var best = null;
697
lineIntersections(first.line, second.line).forEach(function (point) {
698
var firstDistance = Math.hypot(point[0] - first.station.x, point[1] - first.station.y);
699
var secondDistance = Math.hypot(point[0] - second.station.x, point[1] - second.station.y);
700
var maxDistance = Math.max(firstDistance, secondDistance);
701
var score = firstDistance + secondDistance;
702
if (maxDistance <= correctionRange && (!best || score < best.score)) best = { point: point, score: score };
703
});
704
if (!best) return;
705
group.forEach(function (entry) {
706
entry.station.x = Math.round(best.point[0] * 10) / 10;
707
entry.station.y = Math.round(best.point[1] * 10) / 10;
708
});
709
});
710
}
711
712
function renderBase() {
713
stationIndexByLine = {};
714
stationIndexGlobal = new Map();
715
svg.innerHTML = '';
716
layers.districts = el('g', { id: 'layer-districts' }, svg);
717
layers.lines = el('g', { id: 'layer-lines' }, svg);
718
layers.stations = el('g', { id: 'layer-stations' }, svg);
719
layers.transfers = el('g', { id: 'layer-transfers' }, svg);
720
layers.labels = el('g', { id: 'layer-labels' }, svg);
721
layers.location = el('g', { id: 'layer-location' }, svg);
722
layers.trains = el('g', { id: 'layer-trains' }, svg);
723
724
var names = new Set();
725
var placedLabels = {}; // name -> [{x,y}] 같은 역명 라벨 중복 방지
726
727
lineSegments = {};
728
loopInfo = {};
729
renderDistrictBackground();
730
mapData.lines.forEach(function (line) {
731
var g = el('g', { 'class': 'line-group', 'data-line': line.id }, layers.lines);
732
lineSegments[line.id] = [];
733
line.paths.forEach(function (path, pi) {
734
var d = 'M' + path.map(function (p) { return p[0] + ',' + p[1]; }).join(' L');
735
var pathLength = 0;
736
for (var p = 1; p < path.length; p++) {
737
pathLength += Math.hypot(path[p][0] - path[p - 1][0], path[p][1] - path[p - 1][1]);
738
}
739
var isDirectionArrow = mapStyle === 'schematic' &&
740
line.id === '6\uD638\uC120' && path.length === 2 && pathLength < 20;
741
var pathAttrs = {
742
d: d,
743
'class': 'line-path' + (isDirectionArrow ? ' direction-arrow' : ''),
744
stroke: line.color
745
};
746
el('path', pathAttrs, g);
747
if (isDirectionArrow) {
748
var arrowStart = path[0], arrowTip = path[1];
749
var arrowLength = Math.max(pathLength, 1);
750
var unitX = (arrowTip[0] - arrowStart[0]) / arrowLength;
751
var unitY = (arrowTip[1] - arrowStart[1]) / arrowLength;
752
var baseX = arrowTip[0] - unitX * 8;
753
var baseY = arrowTip[1] - unitY * 8;
754
var normalX = -unitY * 4.5;
755
var normalY = unitX * 4.5;
756
el('polygon', {
757
points: arrowTip[0] + ',' + arrowTip[1] + ' ' +
758
(baseX + normalX) + ',' + (baseY + normalY) + ' ' +
759
(baseX - normalX) + ',' + (baseY - normalY),
760
fill: line.color,
761
'class': 'direction-arrow-head'
762
}, g);
763
return;
764
}
765
for (var i = 1; i < path.length; i++) {
766
lineSegments[line.id].push({
767
ax: path[i-1][0], ay: path[i-1][1],
768
bx: path[i][0], by: path[i][1], pi: pi
769
});
770
}
771
});
772
// 2호선 순환 본선: 닫힌(시작=끝) 경로 중 가장 긴 것과 그 중심을 기억
773
if (line.id === '2호선') {
774
var bestPi = -1;
775
line.paths.forEach(function (path, pi) {
776
var a = path[0], b = path[path.length - 1];
777
var closed = Math.hypot(a[0] - b[0], a[1] - b[1]) < 6;
778
if (closed && (bestPi < 0 || path.length > line.paths[bestPi].length)) bestPi = pi;
779
});
780
if (bestPi < 0) {
781
line.paths.forEach(function (path, pi) {
782
if (bestPi < 0 || path.length > line.paths[bestPi].length) bestPi = pi;
783
});
784
}
785
if (bestPi >= 0) {
786
var loop = line.paths[bestPi];
787
var cx = 0, cy = 0;
788
loop.forEach(function (p) { cx += p[0]; cy += p[1]; });
789
loopInfo[line.id] = { pi: bestPi, cx: cx / loop.length, cy: cy / loop.length };
790
}
791
}
792
793
var sg = el('g', { 'class': 'line-group', 'data-line': line.id }, layers.stations);
794
var lg = el('g', { 'class': 'line-group', 'data-line': line.id }, layers.labels);
795
var index = new Map();
796
line.stations.forEach(function (st, stationOrder) {
797
var cls = 'station-dot' + (st.t ? ' transfer' : '');
798
function openStation(ev) {
799
ev.stopPropagation();
800
showStationInfo(st, line);
801
}
802
function openStationByKeyboard(ev) {
803
if (ev.key === 'Enter' || ev.key === ' ') {
804
ev.preventDefault();
805
openStation(ev);
806
}
807
}
808
var hit = el('circle', {
809
cx: st.x, cy: st.y, 'class': 'station-hit',
810
role: 'button',
811
'aria-label': line.name + ' ' + st.n + ' 정보 보기'
812
}, sg);
813
hit.addEventListener('click', openStation);
814
var dot = el('circle', {
815
cx: st.x, cy: st.y, 'class': cls,
816
fill: st.t ? 'var(--bg-card)' : line.color,
817
'stroke-width': st.t ? 1.6 : 1,
818
'data-favorite-key': favoriteKey(line.id, st.n),
819
tabindex: 0,
820
role: 'button',
821
'aria-label': line.name + ' ' + st.n + ' 정보 보기'
822
}, sg);
823
el('title', null, dot).textContent = line.name + ' ' + st.n;
824
dot.addEventListener('click', openStation);
825
dot.addEventListener('keydown', openStationByKeyboard);
826
// 환승역은 노선마다 같은 이름이 겹쳐 찍히므로 근접 중복 라벨은 생략
827
var near = (placedLabels[st.n] || []).some(function (p) {
828
return (p.x - st.x) * (p.x - st.x) + (p.y - st.y) * (p.y - st.y) < 45 * 45;
829
});
830
if (!near) {
831
var overviewLabel = st.t || stationOrder % 7 === 0;
832
var labelX = typeof st._labelX === 'number' ? st._labelX : st.x;
833
var labelY = typeof st._labelY === 'number' ? st._labelY : st.y;
834
var text = el('text', {
835
x: labelX + 7, y: labelY - 6,
836
'class': 'station-label' + (st.t ? ' transfer-label' : '') + (overviewLabel ? ' overview-label' : '')
837
}, lg);
838
text.textContent = st.n;
839
text.setAttribute('role', 'button');
840
text.setAttribute('aria-label', line.name + ' ' + st.n + ' 정보 보기');
841
text.addEventListener('click', openStation);
842
(placedLabels[st.n] = placedLabels[st.n] || []).push({ x: st.x, y: st.y });
843
}
844
845
var key = norm(st.n);
846
if (!index.has(key)) index.set(key, { x: st.x, y: st.y, name: st.n });
847
if (!stationIndexGlobal.has(key)) stationIndexGlobal.set(key, { x: st.x, y: st.y, name: st.n });
848
names.add(st.n);
849
});
850
stationIndexByLine[line.id] = index;
851
});
852
853
renderTransferMarkers();
854
855
stationListEl.innerHTML = '';
856
Array.from(names).sort().forEach(function (n) {
857
var opt = document.createElement('option');
858
opt.value = n;
859
stationListEl.appendChild(opt);
860
});
861
renderFavorites();
862
renderLocationMarker();
863
}
864
865
function renderTransferMarkers() {
866
if (mapStyle !== 'schematic' || !layers.transfers) return;
867
var groups = new Map();
868
mapData.lines.forEach(function (line) {
869
line.stations.forEach(function (station) {
870
if (!station.t) return;
871
var groupKey = schematicTransferKey(station.n);
872
var group = groups.get(groupKey) || [];
873
group.push({ line: line, station: station });
874
groups.set(groupKey, group);
875
});
876
});
877
groups.forEach(function (group) {
878
if (group.length < 2) return;
879
var stationName = Array.from(new Set(group.map(function (entry) { return entry.station.n; }))).join(' / ');
880
var xs = group.map(function (entry) { return entry.station.x; });
881
var ys = group.map(function (entry) { return entry.station.y; });
882
var minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs);
883
var minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys);
884
var horizontal = maxX - minX >= maxY - minY;
885
var centerX = xs.reduce(function (sum, value) { return sum + value; }, 0) / xs.length;
886
var centerY = ys.reduce(function (sum, value) { return sum + value; }, 0) / ys.length;
887
var spread = horizontal ? maxX - minX : maxY - minY;
888
var beads = [];
889
if (spread < 6) {
890
var spacing = 3.4;
891
group.forEach(function (entry, index) {
892
var offset = (index - (group.length - 1) / 2) * spacing;
893
beads.push({ entry: entry, x: centerX + (horizontal ? offset : 0), y: centerY + (horizontal ? 0 : offset) });
894
});
895
} else {
896
group.forEach(function (entry) {
897
beads.push({
898
entry: entry,
899
x: horizontal ? entry.station.x : centerX,
900
y: horizontal ? centerY : entry.station.y
901
});
902
});
903
}
904
var projections = beads.map(function (bead) { return horizontal ? bead.x : bead.y; });
905
var start = Math.min.apply(null, projections) - 3;
906
var end = Math.max.apply(null, projections) + 3;
907
var marker = el('g', {
908
'class': 'transfer-marker',
909
role: 'button',
910
tabindex: 0,
911
'aria-label': stationName
912
}, layers.transfers);
913
var lineAttrs = horizontal
914
? { x1: start, y1: centerY, x2: end, y2: centerY }
915
: { x1: centerX, y1: start, x2: centerX, y2: end };
916
el('line', Object.assign({ 'class': 'transfer-shell' }, lineAttrs), marker);
917
el('line', Object.assign({ 'class': 'transfer-core' }, lineAttrs), marker);
918
beads.forEach(function (bead) {
919
var beadElement = el('circle', {
920
cx: bead.x,
921
cy: bead.y,
922
fill: bead.entry.line.color,
923
'class': 'transfer-bead',
924
role: 'button',
925
'aria-label': stationName + ' ' + bead.entry.line.name
926
}, marker);
927
el('title', null, beadElement).textContent = bead.entry.line.name;
928
beadElement.addEventListener('click', function (ev) {
929
ev.stopPropagation();
930
showStationInfo(bead.entry.station, bead.entry.line);
931
});
932
});
933
el('title', null, marker).textContent = stationName;
934
function openTransfer(ev) {
935
ev.stopPropagation();
936
var selected = beads[0];
937
if (typeof ev.clientX === 'number' && typeof ev.clientY === 'number') {
938
var point = clientToMap(ev.clientX, ev.clientY);
939
beads.forEach(function (bead) {
940
var distance = Math.hypot(point.x - bead.x, point.y - bead.y);
941
var selectedDistance = Math.hypot(point.x - selected.x, point.y - selected.y);
942
if (distance < selectedDistance) selected = bead;
943
});
944
}
945
showStationInfo(selected.entry.station, selected.entry.line);
946
}
947
marker.addEventListener('click', openTransfer);
948
marker.addEventListener('keydown', function (ev) {
949
if (ev.key === 'Enter' || ev.key === ' ') {
950
ev.preventDefault();
951
openTransfer(ev);
952
}
953
});
954
});
955
}
956
957
function renderDistrictBackground() {
958
if (!layers.districts) return;
959
if (mapStyle === 'geo' && districtData && Array.isArray(districtData.paths)) {
960
districtData.paths.forEach(function (district) {
961
var path = el('path', { d: district.d, 'class': 'district-shape' }, layers.districts);
962
el('title', null, path).textContent = district.name;
963
});
964
if (riverData && Array.isArray(riverData.rivers)) {
965
riverData.rivers.forEach(function (river) {
966
el('path', { d: river.d, 'class': 'district-river' }, layers.districts);
967
});
968
}
969
}
970
if (mapStyle === 'schematic' && schematicWaterData && Array.isArray(schematicWaterData.paths)) {
971
schematicWaterData.paths.forEach(function (water) {
972
el('path', { d: water.d, 'class': 'schematic-river' }, layers.districts);
973
});
974
}
975
}
976
977
function renderLegend() {
978
legendEl.innerHTML = '';
979
[['#ea580c', '상행·내선'], ['#22c55e', '하행·외선'], ['#64748b', '급행 ≫']].forEach(function (pair) {
980
var chip = document.createElement('span');
981
chip.className = 'legend-chip guide';
982
chip.innerHTML = '<span class="dot" style="background:' + pair[0] + '"></span>' + pair[1];
983
legendEl.appendChild(chip);
984
});
985
var sep = document.createElement('span');
986
sep.className = 'legend-sep';
987
legendEl.appendChild(sep);
988
mapData.lines.forEach(function (line) {
989
var chip = document.createElement('button');
990
chip.type = 'button';
991
chip.className = 'legend-chip' + (hiddenLines.has(line.id) ? ' off' : '');
992
chip.title = line.live ? line.name : line.name + ' (실시간 미제공)';
993
var cnt = trainCountByLine[line.id];
994
chip.innerHTML = '<span class="dot" style="background:' + line.color + '"></span>' +
995
escapeHtml(line.name) +
996
(line.live && cnt != null ? ' <span class="cnt">' + cnt + '</span>' : '') +
997
(!line.live ? ' <span class="cnt">미제공</span>' : '');
998
chip.addEventListener('click', function () {
999
if (hiddenLines.has(line.id)) hiddenLines.delete(line.id); else hiddenLines.add(line.id);
1000
applyLineVisibility();
1001
renderLegend();
1002
});
1003
legendEl.appendChild(chip);
1004
});
1005
}
1006
1007
function applyLineVisibility() {
1008
document.querySelectorAll('.line-group').forEach(function (g) {
1009
g.classList.toggle('dimmed', hiddenLines.has(g.getAttribute('data-line')));
1010
});
1011
renderTrains();
1012
}
1013
1014
// ---------- 열차 렌더링 ----------
1015
// 역 위치에서 노선 경로의 접선 각도·진행 방향·진행 상태 오프셋을 계산
1016
function computeTrainGeometry(train, pos) {
1017
var segs = lineSegments[train.line];
1018
if (!segs || !segs.length) return { angle: 0, ox: 0, oy: 0 };
1019
var best = null;
1020
for (var i = 0; i < segs.length; i++) {
1021
var s = segs[i];
1022
var dx = s.bx - s.ax, dy = s.by - s.ay;
1023
var L2 = dx * dx + dy * dy;
1024
if (!L2) continue;
1025
var t = Math.max(0, Math.min(1, ((pos.x - s.ax) * dx + (pos.y - s.ay) * dy) / L2));
1026
var qx = s.ax + t * dx - pos.x, qy = s.ay + t * dy - pos.y;
1027
var d2 = qx * qx + qy * qy;
1028
if (!best || d2 < best.d2) best = { d2: d2, dx: dx, dy: dy, len: Math.sqrt(L2), pi: s.pi };
1029
}
1030
if (!best || best.d2 > 40 * 40) return { angle: 0, ox: 0, oy: 0 };
1031
var tx = best.dx / best.len, ty = best.dy / best.len;
1032
1033
var oriented = false;
1034
var loop = loopInfo[train.line];
1035
if (loop && best.pi === loop.pi) {
1036
// 순환 본선: 내선순환 = 시계방향, 외선순환 = 반시계방향.
1037
// 화면 좌표(y 아래)에서 시계방향 ⇔ cross(반지름, 접선) > 0
1038
var rx = pos.x - loop.cx, ry = pos.y - loop.cy;
1039
var cross = rx * ty - ry * tx;
1040
var wantClockwise = isUpDirection(train); // 내선(0)
1041
if ((cross > 0) !== wantClockwise) { tx = -tx; ty = -ty; }
1042
oriented = true;
1043
} else {
1044
// 진행 방향: 행선지 역이 있는 쪽으로 접선 부호 결정
1045
var destPos = null;
1046
if (train.destination) {
1047
var dk = norm(train.destination);
1048
var idx = stationIndexByLine[train.line];
1049
destPos = (idx && idx.get(dk)) || stationIndexGlobal.get(dk) || null;
1050
}
1051
if (destPos) {
1052
var dot = (destPos.x - pos.x) * tx + (destPos.y - pos.y) * ty;
1053
if (dot < 0) { tx = -tx; ty = -ty; }
1054
if (dot !== 0) oriented = true;
1055
}
1056
}
1057
if (!oriented) return { angle: 0, ox: 0, oy: 0 };
1058
1059
// 진행 상태별 오프셋: 전역출발(-50%) → 진입(-28%) → 도착(0) → 출발(+20%)
1060
var frac = { '3': -0.5, '0': -0.28, '1': 0, '2': 0.2 }[String(train.statusCode)] || 0;
1061
var dist = frac * Math.min(best.len, 55);
1062
return {
1063
angle: Math.atan2(ty, tx) * 180 / Math.PI + 90, // 아이콘 기본 방향은 위쪽(-y)
1064
ox: tx * dist, oy: ty * dist
1065
};
1066
}
1067
1068
function placeTrains() {
1069
placedTrains = [];
1070
var byCoord = {};
1071
trainCountByLine = {};
1072
trains.forEach(function (train) {
1073
if (!trainCountByLine[train.line]) trainCountByLine[train.line] = 0;
1074
trainCountByLine[train.line] += 1;
1075
var index = stationIndexByLine[train.line];
1076
var key = norm(train.stationName);
1077
var pos = (index && index.get(key)) || stationIndexGlobal.get(key);
1078
if (!pos) return;
1079
var geom = computeTrainGeometry(train, pos);
1080
var fx = pos.x + geom.ox, fy = pos.y + geom.oy;
1081
var ck = Math.round(fx) + ':' + Math.round(fy);
1082
if (!byCoord[ck]) byCoord[ck] = [];
1083
byCoord[ck].push({ train: train, x: fx, y: fy, angle: geom.angle });
1084
});
1085
Object.keys(byCoord).forEach(function (ck) {
1086
var group = byCoord[ck];
1087
group.forEach(function (item, i) {
1088
// 같은 역의 열차는 아이콘 크기(nominal) 기준으로 원형 분산 — 마커와 함께 스케일됨
1089
item.dx = 0; item.dy = 0;
1090
if (group.length > 1) {
1091
var angle = (2 * Math.PI * i) / group.length - Math.PI / 2;
1092
item.dx = Math.cos(angle) * 15;
1093
item.dy = Math.sin(angle) * 15;
1094
}
1095
placedTrains.push(item);
1096
});
1097
});
1098
}
1099
1100
function isUpDirection(train) {
1101
if (train.directionCode === '0' || train.directionCode === 0) return true;
1102
if (train.directionCode === '1' || train.directionCode === 1) return false;
1103
var dir = String(train.direction || '');
1104
return dir.indexOf('상') !== -1 || dir.indexOf('내선') !== -1;
1105
}
1106
1107
// 진행도와 동일한 문법의 열차 아이콘 (nominal 12x20, 원점 중앙)
1108
// 항상 위쪽이 진행 방향인 기준 형태로 그리고, 마커에서 선로 각도만큼 회전시킨다.
1109
// 진행 방향 쪽이 더 둥글고 창(흰 줄)이 그쪽에 위치. 급행은 겹화살표.
1110
function buildTrainIcon(g, train, lineStroke) {
1111
var bodyColor = isUpDirection(train) ? '#ea580c' : '#22c55e';
1112
el('rect', { x: -14, y: -18, width: 28, height: 36, rx: 8, 'class': 'train-hit' }, g);
1113
el('rect', { x: -8, y: -12, width: 16, height: 24, rx: 6, 'class': 'sel-ring' }, g);
1114
el('path', {
1115
d: 'M-6,-5 Q-6,-10 -1,-10 L1,-10 Q6,-10 6,-5 L6,7.5 Q6,10 3.5,10 L-3.5,10 Q-6,10 -6,7.5 Z',
1116
fill: bodyColor, stroke: lineStroke, 'stroke-width': 1.3
1117
}, g);
1118
if (train.express) {
1119
el('path', {
1120
d: 'M-3,0.5 L0,-2.5 L3,0.5 M-3,5 L0,2 L3,5',
1121
fill: 'none', stroke: '#fff', 'stroke-width': 1.7,
1122
'stroke-linecap': 'round', 'stroke-linejoin': 'round'
1123
}, g);
1124
} else {
1125
el('rect', { x: -3.5, y: -7, width: 7, height: 3, rx: 1.5, fill: 'rgba(255,255,255,.85)' }, g);
1126
}
1127
el('rect', { x: -4, y: 6.2, width: 8, height: 2, rx: 1, fill: 'rgba(0,0,0,.28)' }, g);
1128
}
1129
1130
function lineColor(lineId) {
1131
var line = mapData.lines.find(function (l) { return l.id === lineId; });
1132
return line ? line.color : '#888';
1133
}
1134
1135
function renderTrains() {
1136
layers.trains.innerHTML = '';
1137
placedTrains.forEach(function (item) {
1138
var train = item.train;
1139
if (hiddenLines.has(train.line)) return;
1140
var key = train.line + ':' + train.trainNo;
1141
var markerClass = 'train-marker' + (key === selectedTrainKey ? ' selected' : '') +
1142
(key === trackedTrainKey ? ' tracked' : '');
1143
var g = el('g', { 'class': markerClass }, layers.trains);
1144
g.style.setProperty('--tx', item.x + 'px');
1145
g.style.setProperty('--ty', item.y + 'px');
1146
var inner = el('g', { transform: 'translate(' + item.dx + ',' + item.dy + ')' }, g);
1147
// 아이콘만 선로 각도로 회전, 텍스트는 수평 유지
1148
var rot = el('g', { transform: 'rotate(' + (item.angle || 0).toFixed(1) + ')' }, inner);
1149
buildTrainIcon(rot, train, lineColor(train.line));
1150
var labelStyle = 'fill:var(--map-label); paint-order:stroke; stroke:var(--map-label-halo); stroke-width:2.5px;';
1151
var no = el('text', { x: 0, y: 19, 'text-anchor': 'middle', 'class': 'train-no', 'font-size': 9 }, inner);
1152
no.setAttribute('style', labelStyle);
1153
no.textContent = train.trainNo;
1154
if (train.destination) {
1155
var dest = el('text', { x: 0, y: -14.5, 'text-anchor': 'middle', 'class': 'train-no train-dest', 'font-size': 8.5 }, inner);
1156
dest.setAttribute('style', labelStyle);
1157
dest.textContent = train.destination + '행';
1158
}
1159
g.addEventListener('click', function (ev) {
1160
ev.stopPropagation();
1161
selectedTrainKey = key;
1162
renderTrains();
1163
showTrainInfo(train);
1164
});
1165
});
1166
}
1167
1168
// ---------- 정보 패널 ----------
1169
function showPanel(html) {
1170
infoBody.innerHTML = html;
1171
delete infoPanel.dataset.panelType;
1172
delete infoPanel.dataset.trainKey;
1173
infoPanel.classList.add('show');
1174
}
1175
1176
function badge(lineId) {
1177
return '<span class="line-badge" style="background:' + lineColor(lineId) + '">' + escapeHtml(lineDisplayName(lineId)) + '</span>';
1178
}
1179
1180
function lineDisplayName(lineId) {
1181
var line = mapData.lines.find(function (l) { return l.id === lineId; });
1182
return line ? line.name : lineId;
1183
}
1184
1185
function serviceBadge(express) {
1186
return '<span class="service-badge ' + (express ? 'express' : 'local') + '">' + (express ? '급행' : '일반') + '</span>';
1187
}
1188
1189
function isExpressArrival(arrival) {
1190
var type = String(arrival.trainType || '').trim();
1191
if (type) return type.indexOf('급행') >= 0;
1192
return /급행/.test(String(arrival.trainLineName || ''));
1193
}
1194
1195
function findPlacedTrain(key) {
1196
return placedTrains.find(function (item) {
1197
return item.train.line + ':' + item.train.trainNo === key;
1198
});
1199
}
1200
1201
function prepareTrainVoice() {
1202
var AudioContextClass = window.AudioContext || window.webkitAudioContext;
1203
if (!AudioContextClass) return Promise.reject(new Error('Web Audio API unavailable'));
1204
if (!trainVoiceContext) trainVoiceContext = new AudioContextClass();
1205
var resume = trainVoiceContext.state === 'suspended'
1206
? trainVoiceContext.resume()
1207
: Promise.resolve();
1208
if (!trainVoiceManifestPromise) {
1209
trainVoiceManifestPromise = fetch('/sound/subway/manifest.json?v=3', { cache: 'force-cache' })
1210
.then(function (response) {
1211
if (!response.ok) throw new Error('HTTP ' + response.status);
1212
return response.json();
1213
});
1214
}
1215
return Promise.all([resume, trainVoiceManifestPromise]);
1216
}
1217
1218
function stopTransferMusic() {
1219
if (!activeTransferMusicSource) return;
1220
try { activeTransferMusicSource.stop(); } catch (_) { /* already stopped */ }
1221
activeTransferMusicSource = null;
1222
}
1223
1224
function stopAnnouncementVoice() {
1225
if (activeTrainVoiceSource) {
1226
try { activeTrainVoiceSource.stop(); } catch (_) { /* already stopped */ }
1227
activeTrainVoiceSource = null;
1228
}
1229
}
1230
1231
function stopTrainVoice() {
1232
stopTransferMusic();
1233
stopAnnouncementVoice();
1234
}
1235
1236
function loadTrainAudioBuffer(audioUrl) {
1237
if (!trainVoiceBuffers.has(audioUrl)) {
1238
trainVoiceBuffers.set(audioUrl, fetch(audioUrl, { cache: 'force-cache' })
1239
.then(function (response) {
1240
if (!response.ok) throw new Error('HTTP ' + response.status);
1241
return response.arrayBuffer();
1242
})
1243
.then(function (buffer) { return trainVoiceContext.decodeAudioData(buffer); }));
1244
}
1245
return trainVoiceBuffers.get(audioUrl);
1246
}
1247
1248
function playTrainVoice(lineId, stationName) {
1249
if (!trainVoiceEnabled && !transferMusicEnabled) return;
1250
prepareTrainVoice().then(function (result) {
1251
var manifest = result[1];
1252
var lineManifest = (manifest.lines || {})[lineId] || {};
1253
var announcementManifest = (manifest.announcements || {})[lineId] || {};
1254
var audioUrl = lineManifest[stationName];
1255
var manifestName = stationName;
1256
if (!audioUrl) {
1257
var stationKey = norm(stationName);
1258
manifestName = Object.keys(lineManifest).find(function (name) { return norm(name) === stationKey; });
1259
if (manifestName) audioUrl = lineManifest[manifestName];
1260
}
1261
if (!audioUrl) return null;
1262
var announcement = announcementManifest[manifestName] || {};
1263
var playTransferMusic = transferMusicEnabled &&
1264
Array.isArray(announcement.transfers) && announcement.transfers.length > 0;
1265
var voiceBufferPromise = trainVoiceEnabled
1266
? loadTrainAudioBuffer(audioUrl)
1267
: Promise.resolve(null);
1268
var musicBufferPromise = playTransferMusic
1269
? loadTrainAudioBuffer(TRANSFER_MUSIC_URLS[selectedTransferMusic])
1270
: Promise.resolve(null);
1271
if (!trainVoiceEnabled && !playTransferMusic) return null;
1272
return Promise.all([voiceBufferPromise, musicBufferPromise]).then(function (buffers) {
1273
stopTrainVoice();
1274
var startAt = trainVoiceContext.currentTime;
1275
if (buffers[1]) {
1276
var musicSource = trainVoiceContext.createBufferSource();
1277
var musicGain = trainVoiceContext.createGain();
1278
musicSource.buffer = buffers[1];
1279
musicGain.gain.value = 0.55;
1280
musicSource.connect(musicGain);
1281
musicGain.connect(trainVoiceContext.destination);
1282
musicSource.onended = function () {
1283
if (activeTransferMusicSource === musicSource) activeTransferMusicSource = null;
1284
};
1285
activeTransferMusicSource = musicSource;
1286
musicSource.start(startAt);
1287
}
1288
if (buffers[0]) {
1289
var voiceSource = trainVoiceContext.createBufferSource();
1290
voiceSource.buffer = buffers[0];
1291
voiceSource.connect(trainVoiceContext.destination);
1292
voiceSource.onended = function () {
1293
if (activeTrainVoiceSource === voiceSource) activeTrainVoiceSource = null;
1294
};
1295
activeTrainVoiceSource = voiceSource;
1296
voiceSource.start(startAt + (buffers[1] ? TRANSFER_ANNOUNCEMENT_DELAY_SECONDS : 0));
1297
}
1298
return null;
1299
});
1300
}).catch(function (error) {
1301
console.warn('[subway-voice] announcement failed:', error.message || error);
1302
});
1303
}
1304
1305
function resetTrackedVoiceState(key) {
1306
if (trackedVoiceKey && trackedVoiceKey !== key) stopTrainVoice();
1307
trackedVoiceKey = key || null;
1308
trackedVoiceState = null;
1309
trackedVoiceStation = null;
1310
if (!key) return;
1311
var item = findPlacedTrain(key);
1312
if (!item) return;
1313
var statusCode = String(item.train.statusCode == null ? '' : item.train.statusCode);
1314
trackedVoiceState = norm(item.train.stationName) + ':' + statusCode;
1315
if (statusCode === '0' || statusCode === '1') {
1316
trackedVoiceStation = norm(item.train.stationName);
1317
}
1318
}
1319
1320
function processTrackedTrainVoice() {
1321
if (!trackedTrainKey) {
1322
resetTrackedVoiceState(null);
1323
return;
1324
}
1325
if (trackedVoiceKey !== trackedTrainKey) {
1326
resetTrackedVoiceState(trackedTrainKey);
1327
return;
1328
}
1329
if (!trainVoiceEnabled && !transferMusicEnabled) return;
1330
var item = findPlacedTrain(trackedTrainKey);
1331
if (!item || hiddenLines.has(item.train.line)) return;
1332
var statusCode = String(item.train.statusCode == null ? '' : item.train.statusCode);
1333
var stationKey = norm(item.train.stationName);
1334
var state = stationKey + ':' + statusCode;
1335
if (!stationKey || state === trackedVoiceState) return;
1336
trackedVoiceState = state;
1337
if ((statusCode === '0' || statusCode === '1') && stationKey !== trackedVoiceStation) {
1338
trackedVoiceStation = stationKey;
1339
playTrainVoice(item.train.line, item.train.stationName);
1340
}
1341
}
1342
1343
function centerTrackedTrain(refreshPanel, initial) {
1344
if (!trackedTrainKey) return false;
1345
var item = findPlacedTrain(trackedTrainKey);
1346
if (!item || hiddenLines.has(item.train.line)) return false;
1347
var x = item.x + (item.dx || 0);
1348
var y = item.y + (item.dy || 0);
1349
if (initial && vb.w > 520) {
1350
zoomToPoint(x, y, 520);
1351
} else {
1352
vb = { x: x - vb.w / 2, y: y - vb.h / 2, w: vb.w, h: vb.h };
1353
applyViewBox();
1354
}
1355
if (refreshPanel && infoPanel.classList.contains('show') &&
1356
infoPanel.dataset.panelType === 'train' && infoPanel.dataset.trainKey === trackedTrainKey) {
1357
showTrainInfo(item.train);
1358
}
1359
return true;
1360
}
1361
1362
function showTrainInfo(train) {
1363
var key = train.line + ':' + train.trainNo;
1364
var tracking = trackedTrainKey === key;
1365
var voiceControl = '<div class="train-voice-setting"><span class="train-voice-label"><i class="bi bi-volume-up"></i> 안내방송</span>' +
1366
'<label class="train-voice-toggle"><input type="checkbox" class="train-voice-checkbox" aria-label="안내방송"' + (trainVoiceEnabled ? ' checked' : '') + '>' +
1367
'<span class="train-voice-switch" aria-hidden="true"></span><strong>' + (trainVoiceEnabled ? 'ON' : 'OFF') + '</strong></label></div>';
1368
var transferMusicControl =
1369
'<div class="train-voice-setting"><span class="train-voice-label"><i class="bi bi-music-note-beamed"></i> 환승음악</span>' +
1370
'<label class="train-voice-toggle"><input type="checkbox" class="transfer-music-checkbox" aria-label="환승음악"' + (transferMusicEnabled ? ' checked' : '') + '>' +
1371
'<span class="train-voice-switch" aria-hidden="true"></span><strong>' + (transferMusicEnabled ? 'ON' : 'OFF') + '</strong></label></div>' +
1372
'<fieldset class="transfer-music-choice" aria-label="환승음악 선택"' + (transferMusicEnabled ? '' : ' disabled') + '>' +
1373
'<label><input type="radio" name="transfer-music" value="pungnyeon"' + (selectedTransferMusic === 'pungnyeon' ? ' checked' : '') + '><span>풍년</span></label>' +
1374
'<label><input type="radio" name="transfer-music" value="eolssiguya"' + (selectedTransferMusic === 'eolssiguya' ? ' checked' : '') + '><span>얼씨구야</span></label>' +
1375
'</fieldset>';
1376
var extras = [];
1377
if (train.express) extras.push('<b style="color:var(--danger)">급행</b>');
1378
if (train.lastTrain) extras.push('<b>막차</b>');
1379
showPanel(
1380
'<h3>' + badge(train.line) + ' 열차 ' + escapeHtml(train.trainNo) + '</h3>' +
1381
'<div>' + escapeHtml(train.destination ? train.destination + ' 행' : '행선지 정보 없음') +
1382
(extras.length ? ' · ' + extras.join(' · ') : '') + '</div>' +
1383
'<div>현재 <b>' + escapeHtml(train.stationName) + '</b> ' + escapeHtml(train.status || '') +
1384
(train.direction ? ' (' + escapeHtml(train.direction) + ')' : '') + '</div>' +
1385
'<div class="muted">수신: ' + escapeHtml(train.receivedAt || '-') + '</div>' +
1386
'<button type="button" class="train-track-btn' + (tracking ? ' active' : '') + '" aria-pressed="' + tracking + '">' +
1387
'<i class="bi ' + (tracking ? 'bi-stop-circle' : 'bi-crosshair') + '"></i>' +
1388
'<span>' + (tracking ? '추적 중지' : '열차 추적하기') + '</span></button>' + voiceControl + transferMusicControl
1389
);
1390
infoPanel.dataset.panelType = 'train';
1391
infoPanel.dataset.trainKey = key;
1392
infoBody.querySelector('.train-track-btn').addEventListener('click', function () {
1393
var enable = trackedTrainKey !== key;
1394
trackedTrainKey = enable ? key : null;
1395
if (enable && (trainVoiceEnabled || transferMusicEnabled)) prepareTrainVoice().catch(function () { /* handled on playback */ });
1396
resetTrackedVoiceState(trackedTrainKey);
1397
selectedTrainKey = key;
1398
renderTrains();
1399
if (enable) centerTrackedTrain(false, true);
1400
showTrainInfo(train);
1401
});
1402
var voiceCheckbox = infoBody.querySelector('.train-voice-checkbox');
1403
if (voiceCheckbox) {
1404
voiceCheckbox.addEventListener('change', function () {
1405
trainVoiceEnabled = voiceCheckbox.checked;
1406
saveTrainVoiceEnabled();
1407
if (!trainVoiceEnabled) {
1408
stopAnnouncementVoice();
1409
} else if (trackedTrainKey) {
1410
resetTrackedVoiceState(trackedTrainKey);
1411
prepareTrainVoice().catch(function () { /* handled on playback */ });
1412
}
1413
showTrainInfo(train);
1414
});
1415
}
1416
var transferMusicCheckbox = infoBody.querySelector('.transfer-music-checkbox');
1417
if (transferMusicCheckbox) {
1418
transferMusicCheckbox.addEventListener('change', function () {
1419
transferMusicEnabled = transferMusicCheckbox.checked;
1420
saveTransferMusicEnabled();
1421
if (!transferMusicEnabled) {
1422
stopTransferMusic();
1423
} else if (trackedTrainKey) {
1424
prepareTrainVoice().catch(function () { /* handled on playback */ });
1425
}
1426
showTrainInfo(train);
1427
});
1428
}
1429
infoBody.querySelectorAll('input[name="transfer-music"]').forEach(function (musicRadio) {
1430
musicRadio.addEventListener('change', function () {
1431
if (!musicRadio.checked || !TRANSFER_MUSIC_URLS[musicRadio.value]) return;
1432
selectedTransferMusic = musicRadio.value;
1433
saveTransferMusicSelection();
1434
stopTransferMusic();
1435
});
1436
});
1437
}
1438
1439
function showStationInfo(st, line) {
1440
selectedStation = { station: st, line: line };
1441
var requestId = ++stationInfoRequestId;
1442
var key = norm(st.n);
1443
var here = placedTrains.filter(function (item) {
1444
return norm(item.train.stationName) === key && !hiddenLines.has(item.train.line);
1445
});
1446
var items = here.map(function (item) {
1447
var t = item.train;
1448
return '<li data-key="' + escapeHtml(t.line + ':' + t.trainNo) + '">' +
1449
'<span class="line-dot" style="background:' + lineColor(t.line) + '"></span>' +
1450
'<span><b>' + escapeHtml(t.trainNo) + '</b> ' + escapeHtml(t.destination || '') + '행 · ' +
1451
escapeHtml(t.status || '') + ' ' + serviceBadge(Boolean(t.express)) + '</span></li>';
1452
}).join('');
1453
var favorite = isFavorite(line.id, st.n);
1454
showPanel(
1455
'<h3 class="station-heading"><span class="station-title">' + badge(line.id) + ' ' + escapeHtml(st.n) + '</span>' +
1456
'<button type="button" class="station-favorite-btn' + (favorite ? ' active' : '') + '" title="' + (favorite ? '즐겨찾기 해제' : '즐겨찾기 추가') + '"><i class="bi ' + (favorite ? 'bi-star-fill' : 'bi-star') + '"></i></button></h3>' +
1457
(items ? '<div class="muted">이 역의 열차 ' + here.length + '대</div><ul>' + items + '</ul>'
1458
: '<div class="muted">현재 이 역에 있는 열차가 없습니다.</div>') +
1459
'<div class="arrival-heading"><span>다음 열차</span><button type="button" class="arrival-refresh" title="도착 정보 새로고침"><i class="bi bi-arrow-clockwise"></i></button></div>' +
1460
'<div id="stationArrivals"><div class="muted">도착 정보를 불러오는 중입니다.</div></div>'
1461
);
1462
infoBody.querySelector('.station-favorite-btn').addEventListener('click', function () {
1463
toggleFavorite(st, line);
1464
});
1465
infoBody.querySelector('.arrival-refresh').addEventListener('click', function () {
1466
loadStationArrivals(st, line, ++stationInfoRequestId);
1467
});
1468
infoBody.querySelectorAll('li[data-key]').forEach(function (li) {
1469
li.addEventListener('click', function () {
1470
var found = placedTrains.find(function (item) {
1471
return item.train.line + ':' + item.train.trainNo === li.getAttribute('data-key');
1472
});
1473
if (found) {
1474
selectedTrainKey = li.getAttribute('data-key');
1475
renderTrains();
1476
showTrainInfo(found.train);
1477
}
1478
});
1479
});
1480
loadStationArrivals(st, line, requestId);
1481
}
1482
1483
function loadStationArrivals(st, line, requestId) {
1484
var target = document.getElementById('stationArrivals');
1485
if (!target) return;
1486
target.innerHTML = '<div class="muted">도착 정보를 불러오는 중입니다.</div>';
1487
if (!line.live) {
1488
target.innerHTML = '<div class="muted">이 노선은 실시간 도착 정보가 제공되지 않습니다.</div>';
1489
return;
1490
}
1491
var params = new URLSearchParams({ line: line.id, station: st.n });
1492
fetch('/api/subway/station-arrivals?' + params.toString(), { headers: { 'Accept': 'application/json' } })
1493
.then(function (res) {
1494
if (!res.ok) return res.json().catch(function () { return {}; }).then(function (data) {
1495
throw new Error(data.message || '도착 정보를 불러오지 못했습니다.');
1496
});
1497
return res.json();
1498
})
1499
.then(function (data) {
1500
if (requestId !== stationInfoRequestId || !selectedStation ||
1501
favoriteKey(selectedStation.line.id, selectedStation.station.n) !== favoriteKey(line.id, st.n)) return;
1502
var currentTarget = document.getElementById('stationArrivals');
1503
if (!currentTarget) return;
1504
var currentTrainNos = new Set(placedTrains.filter(function (item) {
1505
return item.train.line === line.id && norm(item.train.stationName) === norm(st.n);
1506
}).map(function (item) { return String(item.train.trainNo || ''); }));
1507
var arrivals = (Array.isArray(data.arrivals) ? data.arrivals : []).filter(function (item) {
1508
return !currentTrainNos.has(String(item.trainNo || ''));
1509
}).slice(0, 8);
1510
if (!arrivals.length) {
1511
currentTarget.innerHTML = '<div class="muted">현재 열차 이후 확인되는 도착 예정 열차가 없습니다.</div>';
1512
return;
1513
}
1514
currentTarget.innerHTML = '<ul class="arrival-list">' + arrivals.map(function (item) {
1515
var destination = item.terminalStation ? item.terminalStation + '행' : (item.direction || '방향 정보 없음');
1516
return '<li><span class="line-dot" style="background:' + lineColor(line.id) + '"></span>' +
1517
'<span class="arrival-copy"><span class="arrival-message">' + escapeHtml(item.message || item.status || '도착 정보 없음') + '</span>' +
1518
'<span class="arrival-meta">' + serviceBadge(isExpressArrival(item)) + '<span>' + escapeHtml(destination) + (item.trainNo ? ' · ' + escapeHtml(item.trainNo) + ' 열차' : '') + '</span></span></span></li>';
1519
}).join('') + '</ul>';
1520
})
1521
.catch(function (err) {
1522
if (requestId !== stationInfoRequestId) return;
1523
var currentTarget = document.getElementById('stationArrivals');
1524
if (currentTarget) currentTarget.innerHTML = '<div class="muted arrival-error">' + escapeHtml(err.message || '도착 정보를 불러오지 못했습니다.') + '</div>';
1525
});
1526
}
1527
1528
// ---------- 상태 표시 ----------
1529
function updateStatus() {
1530
if (!mapData) return;
1531
var placed = placedTrains.length;
1532
var total = trains.length;
1533
var parts = ['열차 <b>' + total + '</b>대'];
1534
if (total - placed > 0) parts.push('위치 미표시 ' + (total - placed) + '대');
1535
if (updatedAt) {
1536
var t = new Date(updatedAt);
1537
parts.push('갱신 ' + t.toLocaleTimeString('ko-KR', { hour12: false }));
1538
}
1539
var remain = Math.max(0, Math.ceil((nextRefreshAt - Date.now()) / 1000));
1540
parts.push('다음 갱신 ' + remain + '초');
1541
if (trainErrors.length) parts.push('<span style="color:var(--danger)">조회 실패: ' + escapeHtml(trainErrors.join(', ')) + '</span>');
1542
statusEl.innerHTML = parts.join(' · ');
1543
}
1544
1545
// ---------- 데이터 로드 ----------
1546
function fetchTrains() {
1547
if (trainRequest) return trainRequest;
1548
trainRequest = fetch('/api/subway/map-trains', { headers: { 'Accept': 'application/json' } })
1549
.then(function (res) {
1550
if (res.status === 401) { window.location.href = '/login?redirect=' + encodeURIComponent('/hinana/subway/map'); throw new Error('unauthorized'); }
1551
if (!res.ok) throw new Error('HTTP ' + res.status);
1552
return res.json();
1553
})
1554
.then(function (data) {
1555
if (!data.success) throw new Error(data.message || '조회 실패');
1556
trains = data.trains || [];
1557
trainErrors = data.errors || [];
1558
updatedAt = data.updatedAt;
1559
placeTrains();
1560
processTrackedTrainVoice();
1561
if (mapGestureActive) {
1562
pendingTrainRender = true;
1563
} else {
1564
renderTrains();
1565
centerTrackedTrain(true, false);
1566
}
1567
renderLegend();
1568
updateStatus();
1569
})
1570
.catch(function (err) {
1571
if (err.message !== 'unauthorized') {
1572
statusEl.innerHTML = '<span style="color:var(--danger)">열차 정보를 불러오지 못했습니다.</span>';
1573
}
1574
})
1575
.finally(function () {
1576
trainRequest = null;
1577
});
1578
return trainRequest;
1579
}
1580
1581
function scheduleRefresh() {
1582
if (refreshTimer) clearInterval(refreshTimer);
1583
if (countdownTimer) clearInterval(countdownTimer);
1584
nextRefreshAt = Date.now() + REFRESH_MS;
1585
refreshTimer = setInterval(function () {
1586
if (document.hidden) return;
1587
nextRefreshAt = Date.now() + REFRESH_MS;
1588
fetchTrains();
1589
}, REFRESH_MS);
1590
countdownTimer = setInterval(updateStatus, 1000);
1591
}
1592
1593
// ---------- 입력 처리 ----------
1594
function setupInteractions() {
1595
var pointers = new Map();
1596
var pinchStart = null;
1597
var gestureMoved = false;
1598
var suppressClickUntil = 0;
1599
1600
function capturePointer(pointer) {
1601
if (pointer.captured) return;
1602
try {
1603
svg.setPointerCapture(pointer.id);
1604
pointer.captured = true;
1605
} catch (_) {
1606
// The pointer may already have ended between events.
1607
}
1608
}
1609
1610
svg.addEventListener('pointerdown', function (ev) {
1611
var interactiveTarget = ev.target.closest('.station-hit, .station-dot, .station-label, .transfer-marker, .train-marker');
1612
if (pointers.size === 0) {
1613
gestureMoved = false;
1614
suppressClickUntil = 0;
1615
}
1616
var pointer = {
1617
id: ev.pointerId,
1618
type: ev.pointerType,
1619
x: ev.clientX,
1620
y: ev.clientY,
1621
startX: ev.clientX,
1622
startY: ev.clientY,
1623
interactive: Boolean(interactiveTarget),
1624
captured: false
1625
};
1626
pointers.set(ev.pointerId, pointer);
1627
mapGestureActive = true;
1628
if (ev.pointerType === 'mouse' && !interactiveTarget) capturePointer(pointer);
1629
if (pointers.size === 2) {
1630
var pts = Array.from(pointers.values());
1631
pts.forEach(capturePointer);
1632
var centerX = (pts[0].x + pts[1].x) / 2;
1633
var centerY = (pts[0].y + pts[1].y) / 2;
1634
pinchStart = {
1635
dist: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y),
1636
viewBox: { x: vb.x, y: vb.y, w: vb.w, h: vb.h },
1637
centerMap: clientToMap(centerX, centerY)
1638
};
1639
gestureMoved = true;
1640
svg.classList.add('dragging');
1641
}
1642
});
1643
svg.addEventListener('pointermove', function (ev) {
1644
if (!pointers.has(ev.pointerId)) return;
1645
var prev = pointers.get(ev.pointerId);
1646
pointers.set(ev.pointerId, {
1647
id: prev.id,
1648
type: prev.type,
1649
x: ev.clientX,
1650
y: ev.clientY,
1651
startX: prev.startX,
1652
startY: prev.startY,
1653
interactive: prev.interactive,
1654
captured: prev.captured
1655
});
1656
var dragThreshold = prev.interactive ? 14 : 7;
1657
if (Math.hypot(ev.clientX - prev.startX, ev.clientY - prev.startY) > dragThreshold) {
1658
gestureMoved = true;
1659
capturePointer(pointers.get(ev.pointerId));
1660
svg.classList.add('dragging');
1661
}
1662
if (pointers.size === 1) {
1663
if (!gestureMoved) return;
1664
var rect = svg.getBoundingClientRect();
1665
vb.x -= (ev.clientX - prev.x) / rect.width * vb.w;
1666
vb.y -= (ev.clientY - prev.y) / rect.height * vb.h;
1667
applyViewBox();
1668
} else if (pointers.size === 2 && pinchStart) {
1669
var pts = Array.from(pointers.values());
1670
var dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
1671
if (dist > 10) {
1672
var rect = svg.getBoundingClientRect();
1673
var centerX = (pts[0].x + pts[1].x) / 2;
1674
var centerY = (pts[0].y + pts[1].y) / 2;
1675
var maxW = (fullVb ? fullVb.w : 2200) * 2.5;
1676
var targetW = Math.max(120, Math.min(pinchStart.viewBox.w * pinchStart.dist / dist, maxW));
1677
var scale = targetW / pinchStart.viewBox.w;
1678
var targetH = pinchStart.viewBox.h * scale;
1679
vb = {
1680
x: pinchStart.centerMap.x - (centerX - rect.left) / rect.width * targetW,
1681
y: pinchStart.centerMap.y - (centerY - rect.top) / rect.height * targetH,
1682
w: targetW,
1683
h: targetH
1684
};
1685
applyViewBox();
1686
}
1687
}
1688
ev.preventDefault();
1689
}, { passive: false });
1690
function endPointer(ev) {
1691
pointers.delete(ev.pointerId);
1692
if (pointers.size < 2) pinchStart = null;
1693
if (pointers.size === 0) {
1694
mapGestureActive = false;
1695
svg.classList.remove('dragging');
1696
if (gestureMoved) suppressClickUntil = Date.now() + 350;
1697
if (pendingTrainRender) {
1698
window.setTimeout(function () {
1699
if (mapGestureActive || !pendingTrainRender) return;
1700
pendingTrainRender = false;
1701
renderTrains();
1702
centerTrackedTrain(true, false);
1703
}, 0);
1704
}
1705
}
1706
}
1707
svg.addEventListener('pointerup', endPointer);
1708
svg.addEventListener('pointercancel', endPointer);
1709
1710
svg.addEventListener('click', function (ev) {
1711
if (Date.now() >= suppressClickUntil) return;
1712
ev.preventDefault();
1713
ev.stopImmediatePropagation();
1714
}, true);
1715
1716
svg.addEventListener('wheel', function (ev) {
1717
ev.preventDefault();
1718
var center = clientToMap(ev.clientX, ev.clientY);
1719
zoomAt(center.x, center.y, ev.deltaY > 0 ? 1.18 : 1 / 1.18);
1720
}, { passive: false });
1721
1722
svg.addEventListener('dblclick', function (ev) {
1723
var center = clientToMap(ev.clientX, ev.clientY);
1724
zoomAt(center.x, center.y, 1 / 1.6);
1725
});
1726
1727
svg.addEventListener('click', function () {
1728
infoPanel.classList.remove('show');
1729
selectedStation = null;
1730
selectedTrainKey = null;
1731
stationInfoRequestId++;
1732
renderTrains();
1733
});
1734
1735
document.getElementById('zoomInBtn').addEventListener('click', function () {
1736
zoomAt(vb.x + vb.w / 2, vb.y + vb.h / 2, 1 / 1.35);
1737
});
1738
document.getElementById('zoomOutBtn').addEventListener('click', function () {
1739
zoomAt(vb.x + vb.w / 2, vb.y + vb.h / 2, 1.35);
1740
});
1741
document.getElementById('fitBtn').addEventListener('click', fitAll);
1742
document.getElementById('refreshBtn').addEventListener('click', function () {
1743
nextRefreshAt = Date.now() + REFRESH_MS;
1744
scheduleRefresh();
1745
fetchTrains();
1746
});
1747
document.getElementById('legendBtn').addEventListener('click', function () {
1748
var open = legendEl.classList.toggle('mobile-open');
1749
this.classList.toggle('active', open);
1750
this.setAttribute('aria-expanded', String(open));
1751
});
1752
locationBtn.addEventListener('click', locateNearestStation);
1753
document.getElementById('infoClose').addEventListener('click', function () {
1754
infoPanel.classList.remove('show');
1755
selectedStation = null;
1756
stationInfoRequestId++;
1757
});
1758
favoritesBtn.addEventListener('click', function () {
1759
var show = !favoritesPanel.classList.contains('show');
1760
favoritesPanel.classList.toggle('show', show);
1761
favoritesBtn.setAttribute('aria-expanded', String(show));
1762
renderFavorites();
1763
});
1764
document.getElementById('favoritesClose').addEventListener('click', function () {
1765
favoritesPanel.classList.remove('show');
1766
favoritesBtn.setAttribute('aria-expanded', 'false');
1767
renderFavorites();
1768
});
1769
1770
function goToStation() {
1771
var key = norm(searchInput.value);
1772
if (!key) return;
1773
var pos = stationIndexGlobal.get(key);
1774
if (pos) zoomToPoint(pos.x, pos.y, 420);
1775
}
1776
searchInput.addEventListener('change', goToStation);
1777
searchInput.addEventListener('keydown', function (ev) {
1778
if (ev.key === 'Enter') { ev.preventDefault(); goToStation(); }
1779
});
1780
1781
window.addEventListener('resize', resizeViewBox);
1782
document.addEventListener('visibilitychange', function () {
1783
if (!document.hidden) {
1784
nextRefreshAt = Date.now() + REFRESH_MS;
1785
scheduleRefresh();
1786
fetchTrains();
1787
}
1788
});
1789
}
1790
1791
// ---------- 지도 스타일(지리형/노선도형) 전환 ----------
1792
function updateStyleBtn() {
1793
var label = document.getElementById('styleBtnLabel');
1794
if (label) label.textContent = mapStyle === 'geo' ? '노선도형' : '지도형';
1795
}
1796
1797
function loadDistrictData() {
1798
if (districtData) return Promise.resolve(districtData);
1799
if (districtRequest) return districtRequest;
1800
districtRequest = fetch(DISTRICT_URL, { cache: 'force-cache' })
1801
.then(function (res) {
1802
if (!res.ok) throw new Error('HTTP ' + res.status);
1803
return res.json();
1804
})
1805
.then(function (data) {
1806
if (!data || !Array.isArray(data.paths)) throw new Error('invalid district data');
1807
districtData = data;
1808
return districtData;
1809
})
1810
.finally(function () { districtRequest = null; });
1811
return districtRequest;
1812
}
1813
1814
function loadRiverData() {
1815
if (riverData) return Promise.resolve(riverData);
1816
if (riverRequest) return riverRequest;
1817
riverRequest = fetch(RIVER_URL, { cache: 'force-cache' })
1818
.then(function (res) {
1819
if (!res.ok) throw new Error('HTTP ' + res.status);
1820
return res.json();
1821
})
1822
.then(function (data) {
1823
if (!data || !Array.isArray(data.rivers)) throw new Error('invalid river data');
1824
riverData = data;
1825
return riverData;
1826
})
1827
.finally(function () { riverRequest = null; });
1828
return riverRequest;
1829
}
1830
1831
function loadSchematicWaterData() {
1832
if (schematicWaterData) return Promise.resolve(schematicWaterData);
1833
if (schematicWaterRequest) return schematicWaterRequest;
1834
schematicWaterRequest = fetch(SCHEMATIC_WATER_URL, { cache: 'force-cache' })
1835
.then(function (res) {
1836
if (!res.ok) throw new Error('HTTP ' + res.status);
1837
return res.json();
1838
})
1839
.then(function (data) {
1840
if (!data || !Array.isArray(data.paths)) throw new Error('invalid schematic water data');
1841
schematicWaterData = data;
1842
return schematicWaterData;
1843
})
1844
.finally(function () { schematicWaterRequest = null; });
1845
return schematicWaterRequest;
1846
}
1847
1848
function loadStyle(style) {
1849
var cached = mapDataCache[style];
1850
var promise = cached ? Promise.resolve(cached)
1851
: fetch(MAP_URLS[style], { cache: 'no-cache' }).then(function (res) {
1852
if (!res.ok) throw new Error('HTTP ' + res.status);
1853
return res.json();
1854
}).then(function (data) {
1855
if (!data || !Array.isArray(data.lines) || !data.width || !data.height) {
1856
throw new Error('invalid map data');
1857
}
1858
mapDataCache[style] = data;
1859
return data;
1860
});
1861
var districtPromise = style === 'geo' ? loadDistrictData() : Promise.resolve(null);
1862
var riverPromise = style === 'geo' ? loadRiverData() : Promise.resolve(null);
1863
var schematicWaterPromise = style === 'schematic' ? loadSchematicWaterData() : Promise.resolve(null);
1864
return Promise.all([promise, districtPromise, riverPromise, schematicWaterPromise]).then(function (result) {
1865
var data = result[0];
1866
mapStyle = style;
1867
if (style === 'schematic') normalizeSchematicTransfers(data);
1868
svg.classList.toggle('schematic-map', style === 'schematic');
1869
try { localStorage.setItem('subwayMapStyle', style); } catch (e) { /* ignore */ }
1870
mapData = data;
1871
fullVb = { x: 0, y: 0, w: data.width, h: data.height };
1872
vb = { x: 0, y: 0, w: data.width, h: data.height };
1873
renderBase();
1874
placeTrains();
1875
renderTrains();
1876
renderLegend();
1877
fitAll();
1878
updateStyleBtn();
1879
updateStatus();
1880
});
1881
}
1882
1883
// ---------- 초기화 ----------
1884
loadStyle(mapStyle)
1885
.then(function () {
1886
setupInteractions();
1887
document.getElementById('styleBtn').addEventListener('click', function () {
1888
loadStyle(mapStyle === 'geo' ? 'schematic' : 'geo').catch(function () {
1889
statusEl.innerHTML = '<span style="color:var(--danger)">노선도 데이터를 불러오지 못했습니다.</span>';
1890
});
1891
});
1892
loadingEl.style.display = 'none';
1893
scheduleRefresh();
1894
return fetchTrains();
1895
})
1896
.catch(function () {
1897
loadingEl.innerHTML = '<div style="color:var(--danger)">노선도 데이터를 불러오지 못했습니다.</div>';
1898
});
1899
})();
1900