Public Source Viewer

비나래아카이브 개발자 포털

실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.

Redacted View
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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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