Public Source Viewer

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

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

Redacted View
view/hinana/subway.ejs
공개 가능
1 <!DOCTYPE html>
2 <html lang="ko">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="color-scheme" content="light dark">
6 <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, maximum-scale=1.0">
7 <meta name="theme-color" content="<%= (typeof theme !== 'undefined' && theme === 'dark') ? '#0f141e' : '#f8f7f5' %>">
8 <meta property="og:image" content="/image/lounge1.png" />
9 <meta property="og:title" content="열차 위치 조회 — 비나래 라운지" />
10 <meta property="og:description" content="서울시 지하철 실시간 위치정보 API로 열차 열번의 현재 위치를 확인합니다." />
11 <title>열차 위치 조회 — 비나래 라운지</title>
12 <link rel='stylesheet' href='/vendors/bootstrap/css/bootstrap.min.css' />
13 <script src="/vendors/bootstrap/js/bootstrap.min.js"></script>
14 <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700&family=Montserrat:wght@300;400;700&display=swap" rel="stylesheet">
15 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
16 <script src="/js/popup.js"></script>
17 <style>
18 *, *::before, *::after { box-sizing: border-box; }
19 :root {
20 --bg-main: #f8f7f5; --bg-secondary: #ffffff;
21 --bg-tertiary: #1a2238; --bg-card: #ffffff; --bg-input: #f1f0ed;
22 --text-primary: #1a2238; --text-secondary: #5e6676;
23 --accent: #c5a059; --accent-dim: rgba(197,160,89,0.12);
24 --border: #e5e1da; --success: #16a34a; --danger: #dc2626;
25 --font: 'Noto Sans KR', sans-serif;
26 }
27 body.dark-mode {
28 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #111827;
29 --bg-card: #1e2a42; --bg-input: #243050;
30 --text-primary: #e7e5e4; --text-secondary: #94a3b8;
31 --border: #2e3a59;
32 }
33 html, body { margin:0; padding:0; background:var(--bg-main); color:var(--text-primary); font-family:var(--font); min-height:100vh; display:flex; flex-direction:column; }
34 a { text-decoration:none; color:inherit; }
35 .site-header { background:var(--bg-tertiary); height:60px; padding:0 24px; display:flex; align-items:center; justify-content:space-between; gap:14px; border-bottom:1px solid var(--border); position:sticky; top:0; z-index:100; }
36 .brand { display:flex; align-items:center; gap:12px; min-width:0; flex:1; font-size:.9rem; font-weight:700; color:#fff; }
37 .brand > span:last-child { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
38 .header-logo { height:26px; max-width:170px; object-fit:contain; }
39 .site-header nav { display:flex; align-items:center; gap:6px; flex:0 0 auto; }
40 .site-header nav a { font-size:.82rem; color:var(--text-secondary); margin-left:20px; transition:color .2s; }
41 .site-header nav a:hover { color:var(--accent); }
42 .page-body { flex:1; max-width:920px; width:100%; margin:0 auto; padding:40px 24px 0; }
43 .page-title { margin-bottom:28px; }
44 .page-title h1 { font-size:1.5rem; font-weight:700; margin:0 0 6px; }
45 .page-title p { font-size:.82rem; color:var(--text-secondary); margin:0; line-height:1.7; }
46 .search-card, .result-card, .info-box, .helper-card { background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-bottom:20px; }
47 .search-grid { display:grid; grid-template-columns: 1.05fr .85fr 1.1fr auto; gap:10px; align-items:end; }
48 .field-label { font-size:.7rem; font-weight:700; text-transform:uppercase; letter-spacing:.1em; color:var(--text-secondary); margin-bottom:8px; }
49 .field { width:100%; border:1px solid var(--border); border-radius:8px; background:var(--bg-input); color:var(--text-primary); font-family:var(--font); font-size:.95rem; padding:11px 13px; outline:none; }
50 .field:focus { border-color:var(--accent); }
51 .submit-btn { border:1px solid rgba(197,160,89,.55); background:var(--accent); color:#fff; border-radius:8px; padding:11px 18px; font-weight:700; font-family:var(--font); white-space:nowrap; }
52 .submit-btn:disabled { opacity:.65; cursor:not-allowed; }
53 .tracker-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); flex-wrap:wrap; }
54 .tracker-status { font-size:.78rem; color:var(--text-secondary); line-height:1.6; }
55 .tracker-actions { display:flex; gap:8px; flex-wrap:wrap; }
56 .push-guide { width:100%; margin-top:2px; font-size:.72rem; color:var(--text-secondary); line-height:1.6; }
57 .ghost-btn { border:1px solid var(--border); background:transparent; color:var(--text-secondary); border-radius:8px; padding:9px 12px; font-size:.82rem; font-weight:700; font-family:var(--font); }
58 .ghost-btn:hover { border-color:var(--accent); color:var(--accent); }
59 .ghost-btn:disabled { opacity:.5; cursor:not-allowed; }
60 .helper-title { font-weight:800; margin-bottom:6px; }
61 .helper-desc { font-size:.82rem; color:var(--text-secondary); line-height:1.7; margin-bottom:14px; }
62 .helper-grid { display:grid; grid-template-columns:1fr auto; gap:10px; align-items:end; }
63 .arrival-list { display:grid; gap:10px; margin-top:14px; }
64 .arrival-item { width:100%; text-align:left; border:1px solid var(--border); background:var(--bg-main); color:var(--text-primary); border-radius:10px; padding:12px 14px; font-family:var(--font); cursor:pointer; transition:border-color .15s, transform .15s; }
65 .arrival-item:hover { border-color:var(--accent); transform:translateY(-1px); }
66 .arrival-item.selected { border-color:var(--accent); background:var(--accent-dim); }
67 .arrival-main { display:flex; justify-content:space-between; gap:10px; align-items:center; margin-bottom:6px; }
68 .arrival-train { font-weight:800; color:var(--accent); font-size:1.05rem; }
69 .arrival-status { font-size:.75rem; border:1px solid rgba(197,160,89,.35); border-radius:999px; padding:3px 8px; color:var(--accent); white-space:nowrap; }
70 .arrival-meta { font-size:.78rem; color:var(--text-secondary); line-height:1.6; }
71 .result-card { display:none; }
72 .result-card.show { display:block; }
73 .result-head { display:flex; align-items:center; justify-content:space-between; gap:14px; padding-bottom:14px; margin-bottom:14px; border-bottom:1px solid var(--border); }
74 .result-title { font-weight:800; font-size:1.05rem; }
75 .source-label { font-size:.7rem; color:var(--text-secondary); }
76 .train-list { display:grid; gap:12px; }
77 .train-item { border:1px solid var(--border); background:var(--bg-main); border-radius:10px; padding:16px; }
78 .train-main { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; flex-wrap:wrap; }
79 .train-no { font-size:1.4rem; font-weight:800; color:var(--accent); font-feature-settings:'tnum'; }
80 .status-badge { display:inline-flex; align-items:center; gap:6px; border-radius:999px; padding:5px 10px; background:var(--accent-dim); color:var(--accent); font-size:.78rem; font-weight:800; }
81 .train-meta { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:10px; }
82 .meta-label { font-size:.68rem; color:var(--text-secondary); margin-bottom:3px; }
83 .meta-value { font-size:.95rem; font-weight:700; overflow-wrap:anywhere; }
84 .pill-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:10px; }
85 .pill { border:1px solid var(--border); border-radius:999px; padding:3px 8px; font-size:.72rem; color:var(--text-secondary); }
86 .route-card { display:none; background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:20px; margin-bottom:20px; }
87 .route-card.show { display:block; }
88 .route-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; margin-bottom:14px; }
89 .route-title { font-weight:800; }
90 .route-sub { font-size:.78rem; color:var(--text-secondary); line-height:1.6; }
91 .station-strip { --line-color: var(--accent); display:flex; flex-direction:column; gap:0; max-height:620px; overflow-y:auto; overflow-x:hidden; padding:8px 0; scroll-behavior:smooth; }
92 .station-node { position:relative; display:grid; grid-template-columns:260px minmax(0, 1fr); gap:22px; min-height:78px; color:var(--text-secondary); border-bottom:1px solid rgba(148,163,184,.16); }
93 .station-node::before, .station-node::after { content:''; position:absolute; top:0; bottom:0; width:4px; background:var(--line-color); opacity:.95; transform:translateX(-50%); }
94 .station-node::before { left:108px; }
95 .station-node::after { left:156px; }
96 .station-node:first-child::before { top:22px; }
97 .station-node:last-child::before { bottom:22px; }
98 .station-node:first-child::after { top:22px; }
99 .station-node:last-child::after { bottom:22px; }
100 .station-node.past::before, .station-node.past::after { background:#aeb4bd; opacity:.65; }
101 .station-rail { position:relative; min-height:56px; }
102 .station-dot { position:absolute; z-index:1; left:132px; top:28px; transform:translateX(-50%); width:18px; height:18px; border-radius:50%; background:var(--line-color); border:3px solid var(--bg-card); box-shadow:0 0 0 1px rgba(0,0,0,.12); }
103 .station-node.past .station-dot { background:#aeb4bd; box-shadow:0 0 0 1px rgba(148,163,184,.5); }
104 .station-node.current .station-dot { width:28px; height:28px; top:23px; background:var(--success); box-shadow:0 0 0 4px rgba(22,163,74,.16); }
105 .station-node.target .station-dot { background:#f59e0b; box-shadow:0 0 0 4px rgba(245,158,11,.16); }
106 .station-node.current.target .station-dot { background:var(--success); }
107 .train-badges { position:absolute; inset:0; z-index:3; pointer-events:none; }
108 .train-badge { position:absolute; top:11px; width:102px; min-height:38px; border:1px solid var(--border); border-radius:8px; background:var(--bg-card); color:var(--text-primary); box-shadow:0 3px 10px rgba(0,0,0,.08); display:flex; align-items:center; gap:6px; padding:5px 7px; font-size:.7rem; line-height:1.15; overflow:hidden; }
109 .train-badge.up { left:0; justify-content:flex-end; text-align:right; }
110 .train-badge.down { left:166px; }
111 .train-badge.status-enter.moving-down { transform:translateY(-12px); }
112 .train-badge.status-enter.moving-up { transform:translateY(12px); }
113 .train-badge.status-depart.moving-down { transform:translateY(12px); }
114 .train-badge.status-depart.moving-up { transform:translateY(-12px); }
115 .train-icon { position:relative; flex:0 0 auto; width:18px; height:30px; border:2px solid rgba(0,0,0,.45); background:#22c55e; overflow:hidden; box-shadow:inset 0 -4px 0 rgba(255,255,255,.28); }
116 .train-icon::before { content:''; position:absolute; left:4px; right:4px; height:6px; border-radius:4px; background:rgba(255,255,255,.82); box-shadow:0 0 0 1px rgba(255,255,255,.2); }
117 .train-icon::after { content:''; position:absolute; left:5px; right:5px; height:3px; border-radius:999px; background:rgba(0,0,0,.28); }
118 .train-badge.up .train-icon { order:2; background:#ea580c; }
119 .train-badge.moving-down .train-icon { border-radius:6px 6px 9px 9px; }
120 .train-badge.moving-down .train-icon::before { bottom:3px; }
121 .train-badge.moving-down .train-icon::after { top:3px; }
122 .train-badge.moving-up .train-icon { border-radius:9px 9px 6px 6px; box-shadow:inset 0 4px 0 rgba(255,255,255,.28); }
123 .train-badge.moving-up .train-icon::before { top:3px; }
124 .train-badge.moving-up .train-icon::after { bottom:3px; }
125 .train-badge.selected { border-color:var(--success); box-shadow:0 0 0 3px rgba(22,163,74,.16); }
126 .train-label { display:block; font-weight:800; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
127 .train-sub { display:none; }
128 .station-info { align-self:center; min-width:0; padding:12px 0; }
129 .station-name { font-size:.9rem; font-weight:800; line-height:1.35; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
130 .station-node.current .station-name { color:var(--success); }
131 .station-node.future .station-name, .station-node.target .station-name { color:var(--text-primary); }
132 .station-node.past .station-name { color:#8a94a3; }
133 .station-code { font-size:.7rem; color:var(--text-secondary); margin-left:6px; font-weight:500; }
134 .station-train-list { display:flex; gap:6px; flex-wrap:wrap; margin-top:6px; }
135 .station-train-chip { max-width:180px; border:1px solid var(--border); border-radius:999px; background:var(--bg-main); color:var(--text-secondary); padding:3px 8px; font-size:.68rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
136 .station-train-chip.selected { border-color:var(--success); color:var(--success); background:rgba(22,163,74,.08); }
137 .route-legend { display:flex; gap:10px; flex-wrap:wrap; font-size:.72rem; color:var(--text-secondary); }
138 .legend-dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:4px; vertical-align:-1px; }
139 .legend-past { background:#aeb4bd; }
140 .legend-current { background:var(--success); }
141 .legend-future { background:var(--accent); }
142 .legend-target { background:#f59e0b; }
143 .empty-message { color:var(--text-secondary); font-size:.9rem; line-height:1.8; margin:0; }
144 .info-box { font-size:.78rem; color:var(--text-secondary); line-height:1.8; margin-bottom:40px; }
145 .info-box strong { color:var(--text-primary); }
146 .footer-area { max-width:920px; width:100%; margin:48px auto 0; padding:28px 24px 40px; color:var(--text-secondary); font-size:.78rem; border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:20px; }
147 .footer-brand { display:flex; align-items:center; gap:12px; min-width:0; }
148 .footer-logo { width:44px; height:44px; object-fit:contain; opacity:.72; mix-blend-mode:multiply; flex:0 0 auto; }
149 body.dark-mode .footer-logo { mix-blend-mode:screen; opacity:.86; }
150 .footer-title { font-weight:800; color:var(--text-primary); font-size:.9rem; }
151 .footer-sub { margin-top:2px; }
152 .footer-links { display:flex; align-items:center; gap:12px; flex-wrap:wrap; justify-content:flex-end; }
153 .footer-links a { color:var(--text-secondary); font-weight:700; }
154 .footer-links a:hover { color:var(--accent); }
155 .footer-copy { width:100%; text-align:right; font-size:.72rem; }
156 .footer-version { display:inline-flex; align-items:center; min-height:20px; padding:0 8px; margin-right:8px; border:1px solid var(--border); border-radius:999px; color:var(--accent); font-weight:800; background:var(--bg-secondary); }
157 @media (max-width: 720px) {
158 .page-body { padding:24px 16px 0; }
159 .site-header { padding:0 12px; gap:8px; }
160 .brand { gap:8px; font-size:.82rem; }
161 .header-logo { max-width:132px; height:24px; }
162 .site-header nav a { margin-left:0; width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center; font-size:0; }
163 .site-header nav a i { font-size:1rem; margin:0 !important; }
164 .search-grid { grid-template-columns:1fr; }
165 .helper-grid { grid-template-columns:1fr; }
166 .train-meta { grid-template-columns:1fr; }
167 .result-head { align-items:flex-start; flex-direction:column; }
168 .station-strip { max-height:540px; }
169 .station-node { grid-template-columns:206px minmax(0, 1fr); gap:12px; min-height:72px; }
170 .station-node::before { left:82px; }
171 .station-node::after { left:124px; }
172 .station-dot { left:103px; top:27px; }
173 .station-node.current .station-dot { top:22px; }
174 .train-badge { width:82px; padding:4px 5px; font-size:.66rem; }
175 .train-badge.up { left:0; }
176 .train-badge.down { left:134px; }
177 .footer-area { flex-direction:column; align-items:flex-start; margin-top:36px; }
178 .footer-links { justify-content:flex-start; }
179 .footer-copy { text-align:left; }
180 }
181 </style>
182 </head>
183 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
184 <header class="site-header">
185 <div class="brand">
186 <a href="/hinana/lounge"><img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo"></a>
187 <span style="opacity:.4; font-weight:300; color:#fff;">|</span>
188 <span style="color:#fff;">열차 위치 조회</span>
189 </div>
190 <nav>
191 <a href="/hinana/subway/map"><i class="bi bi-map me-1"></i>실시간 노선도</a>
192 <a href="/hinana/lounge"><i class="bi bi-arrow-left me-1"></i>라운지로</a>
193 <form action="/toggle-theme" method="POST" style="margin:0; display:inline;">
194 <input type="hidden" name="redirect" value="/hinana/subway">
195 <button type="submit" style="background:none; border:none; color:var(--text-secondary); font-size:1rem; cursor:pointer; padding:4px 8px; margin-left:12px;" title="테마 변경">
196 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
197 </button>
198 </form>
199 </nav>
200 </header>
201
202 <main class="page-body">
203 <section class="page-title">
204 <h1><i class="bi bi-train-front me-2" style="color:var(--accent);"></i>열차 열번 위치 조회</h1>
205 <p>서울시 지하철 실시간 열차 위치정보 API로 열차가 현재 어느 역에 있고, 진입·도착·출발 중인지 확인합니다.</p>
206 </section>
207
208 <section class="search-card">
209 <form id="searchForm" class="search-grid">
210 <div>
211 <div class="field-label">Train No.</div>
212 <input id="trainNo" class="field" name="trainNo" placeholder="예: 1006" autocomplete="off" required>
213 </div>
214 <div>
215 <div class="field-label">Line</div>
216 <select id="line" class="field" name="line">
217 <option value="">전체 주요 호선 검색</option>
218 <% subwayLines.forEach(function(line) { %>
219 <option value="<%= line %>"><%= line %></option>
220 <% }); %>
221 </select>
222 </div>
223 <div>
224 <div class="field-label">Target Station</div>
225 <select id="targetStation" class="field" name="targetStation" disabled>
226 <option value="">호선을 먼저 선택해 주세요</option>
227 </select>
228 </div>
229 <button id="submitBtn" class="submit-btn" type="submit"><i class="bi bi-search me-1"></i>조회</button>
230 </form>
231 <div class="tracker-bar">
232 <div class="tracker-status" id="trackerStatus">목표역 도착 알림을 쓰려면 호선과 역을 선택한 뒤 추적을 시작하세요.</div>
233 <div class="tracker-actions">
234 <button id="startTrackBtn" class="ghost-btn" type="button"><i class="bi bi-bell me-1"></i>도착 알림 시작</button>
235 <button id="stopTrackBtn" class="ghost-btn" type="button" disabled><i class="bi bi-bell-slash me-1"></i>중지</button>
236 </div>
237 <div class="push-guide">
238 Android Chrome/Edge는 알림 권한을 허용하면 백그라운드에서도 받을 수 있습니다. iPhone은 iOS 16.4 이상에서 홈 화면에 추가한 뒤 실행해야 백그라운드 알림이 동작합니다.
239 </div>
240 </div>
241 </section>
242
243 <section class="helper-card">
244 <div class="helper-title">어떤 열번의 열차를 탔는지/탈 예정인지 모르시나요?</div>
245 <div class="helper-desc">
246 호선을 선택한 뒤 탑승한 역 또는 탑승할 역을 고르면, 그 역에 도착했거나 곧 도착할 열차 후보를 불러옵니다.
247 후보를 선택하면 열번 입력칸에 자동으로 들어가고, 같은 방식으로 조회와 도착 알림을 사용할 수 있어요.
248 </div>
249 <div class="helper-grid">
250 <div>
251 <div class="field-label">Boarding Station</div>
252 <select id="boardingStation" class="field" disabled>
253 <option value="">호선을 먼저 선택해 주세요</option>
254 </select>
255 </div>
256 <button id="loadArrivalsBtn" class="ghost-btn" type="button"><i class="bi bi-search me-1"></i>탑승 열차 찾기</button>
257 </div>
258 <div id="arrivalList" class="arrival-list"></div>
259 </section>
260
261 <section id="resultCard" class="result-card">
262 <div class="result-head">
263 <div>
264 <div class="result-title" id="resultTitle">조회 결과</div>
265 <div class="source-label" id="resultSource">서울시 지하철 실시간 열차 위치정보</div>
266 </div>
267 <div class="source-label" id="searchedLines"></div>
268 </div>
269 <div id="resultBody"></div>
270 </section>
271
272 <section id="routeCard" class="route-card">
273 <div class="route-head">
274 <div>
275 <div class="route-title" id="routeTitle">노선 진행도</div>
276 <div class="route-sub" id="routeSub">현재 열차 위치를 기준으로 대략적인 지나간 역과 앞으로 갈 역을 표시합니다.</div>
277 </div>
278 </div>
279 <div class="route-legend">
280 <span><span class="legend-dot legend-past"></span>지나간 역</span>
281 <span><span class="legend-dot legend-current"></span>현재 위치</span>
282 <span><span class="legend-dot legend-future"></span>앞으로 갈 역</span>
283 <span><span class="legend-dot legend-target"></span>목표역</span>
284 </div>
285 <div id="stationStrip" class="station-strip"></div>
286 </section>
287
288 <section class="info-box">
289 <strong>참고:</strong> 호선을 고르면 API 호출 수를 줄일 수 있습니다. 전체 검색은 여러 호선을 순차 조회하므로 시간이 조금 더 걸릴 수 있어요.
290 서울시 API의 실시간 위치 데이터와 노선 진행도는 현장/관제 갱신 상황, 지선 운행, 회차 방식에 따라 실제 위치와 약간 차이가 날 수 있습니다.
291 열차 위치 조회는 누구나 이용할 수 있습니다. 도착 알림(푸시 전송)은 로그인 후 등록할 수 있고, 서버가 5초마다 추적하므로 브라우저를 백그라운드에 두거나 다른 앱을 사용해도 알림을 받을 수 있습니다.
292 </section>
293 </main>
294
295 <footer class="footer-area">
296 <div class="footer-brand">
297 <img src="/image/sign.png" alt="비나래" class="footer-logo">
298 <div>
299 <div class="footer-title">비나래 라운지</div>
300 <div class="footer-sub">Archive My Lounge</div>
301 </div>
302 </div>
303 <div>
304 <div class="footer-links">
305 <a href="/hinana/index">Archive</a>
306 <a href="/hinana/lounge">Lounge</a>
307 <a href="/hinana/gallery">Gallery</a>
308 <a href="/hinana/plaza">Plaza</a>
309 <a href="https://x.com/NoctchillHinana">X</a>
310 </div>
311 <div class="footer-copy"><span class="footer-version">Ver. 8.3.1.0-Utsumi Aoba</span>ⓒ 2024~2026. 비나래 | hinana.moe</div>
312 </div>
313 </footer>
314
315 <script>
316 const PUSH_KEY = '<%= typeof vapidPublicKey !== "undefined" ? vapidPublicKey : "" %>';
317 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
318 const form = document.getElementById('searchForm');
319 const submitBtn = document.getElementById('submitBtn');
320 const lineSelect = document.getElementById('line');
321 const trainNoInput = document.getElementById('trainNo');
322 const targetStationSelect = document.getElementById('targetStation');
323 const boardingStationSelect = document.getElementById('boardingStation');
324 const loadArrivalsBtn = document.getElementById('loadArrivalsBtn');
325 const arrivalList = document.getElementById('arrivalList');
326 const startTrackBtn = document.getElementById('startTrackBtn');
327 const stopTrackBtn = document.getElementById('stopTrackBtn');
328 const trackerStatus = document.getElementById('trackerStatus');
329 const resultCard = document.getElementById('resultCard');
330 const resultTitle = document.getElementById('resultTitle');
331 const resultSource = document.getElementById('resultSource');
332 const searchedLines = document.getElementById('searchedLines');
333 const resultBody = document.getElementById('resultBody');
334 const routeCard = document.getElementById('routeCard');
335 const routeTitle = document.getElementById('routeTitle');
336 const routeSub = document.getElementById('routeSub');
337 const stationStrip = document.getElementById('stationStrip');
338 let trackingTimer = null;
339 let trackingActive = false;
340 let lastNotifiedKey = '';
341 let currentAlertId = '';
342 let serviceWorkerRegistration = null;
343 let currentStations = [];
344 let selectedBoardingStation = '';
345 let selectedDirectionCode = '';
346 const LINE_COLORS = {
347 '1호선': '#0052A4',
348 '2호선': '#00A84D',
349 '3호선': '#EF7C1C',
350 '4호선': '#00A5DE',
351 '5호선': '#996CAC',
352 '6호선': '#CD7C2F',
353 '7호선': '#747F00',
354 '8호선': '#E6186C',
355 '9호선': '#BDB092',
356 '경의중앙선': '#77C4A3',
357 '경춘선': '#0C8E72',
358 '수인분당선': '#F5A200',
359 '신분당선': '#D4003B',
360 '공항철도': '#0090D2',
361 '경강선': '#003DA5',
362 '서해선': '#8FC31F',
363 '우이신설선': '#B0CE18',
364 '신림선': '#6789CA',
365 'GTX-A': '#9A6292',
366 '인천선': '#7CA8D5',
367 '인천2호선': '#ED8B00',
368 '김포도시철도': '#A17800'
369 };
370
371 function escapeHtml(value) {
372 return String(value ?? '').replace(/[&<>"']/g, function(ch) {
373 return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch];
374 });
375 }
376
377 function normalizeStationName(name) {
378 return String(name || '').trim().replace(/\s+/g, '').replace(/역$/, '');
379 }
380
381 function stationNamesMatch(a, b) {
382 return normalizeStationName(a) === normalizeStationName(b);
383 }
384
385 function renderEmpty(message) {
386 resultBody.innerHTML = `<p class="empty-message">${escapeHtml(message)}</p>`;
387 }
388
389 async function fetchJson(url, options) {
390 const response = await fetch(url, {
391 credentials: 'same-origin',
392 ...(options || {})
393 });
394 const text = await response.text();
395 let data = null;
396
397 try {
398 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
399 } catch (_) {
400 throw new Error(text.trim().startsWith('<')
401 ? '지하철 API 라우트가 아직 서버에 반영되지 않았습니다. 최신 배포 후 다시 시도해 주세요.'
402 : '서버 응답을 읽지 못했습니다.');
403 }
404
405 if (!response.ok) {
406 throw new Error(data.message || data.error || '요청에 실패했습니다.');
407 }
408 return data;
409 }
410
411 function renderMatches(matches) {
412 if (!matches.length) {
413 renderEmpty('해당 열번의 열차를 찾지 못했습니다. 호선 선택이 맞는지, 운행 중인 열차인지 확인해 주세요.');
414 return;
415 }
416
417 resultBody.innerHTML = `<div class="train-list">${matches.map(function(item) {
418 const flags = [
419 item.express ? '급행' : '',
420 item.lastTrain ? '막차' : '',
421 item.receivedAt ? '수신: ' + item.receivedAt : ''
422 ].filter(Boolean);
423 return `
424 <article class="train-item">
425 <div class="train-main">
426 <div>
427 <div class="train-no">${escapeHtml(item.trainNo)}</div>
428 <div class="source-label">${escapeHtml(item.line)} ${item.subwayId ? '· ' + escapeHtml(item.subwayId) : ''}</div>
429 </div>
430 <div class="status-badge"><i class="bi bi-broadcast-pin"></i>${escapeHtml(item.status)}</div>
431 </div>
432 <div class="train-meta">
433 <div><div class="meta-label">현재 위치</div><div class="meta-value">${escapeHtml(item.stationName || '확인 불가')}</div></div>
434 <div><div class="meta-label">행선지</div><div class="meta-value">${escapeHtml(item.destination || '확인 불가')}</div></div>
435 <div><div class="meta-label">방향</div><div class="meta-value">${escapeHtml(item.direction || '확인 불가')}</div></div>
436 <div><div class="meta-label">상태 코드</div><div class="meta-value">${escapeHtml(item.statusCode)}</div></div>
437 </div>
438 ${flags.length ? `<div class="pill-row">${flags.map(flag => `<span class="pill">${escapeHtml(flag)}</span>`).join('')}</div>` : ''}
439 </article>
440 `;
441 }).join('')}</div>`;
442 }
443
444 function renderArrivals(arrivals) {
445 if (!arrivals.length) {
446 arrivalList.innerHTML = '<p class="empty-message">지금 선택한 역에서 확인되는 열차 후보가 없습니다.</p>';
447 return;
448 }
449
450 arrivalList.innerHTML = arrivals.map(function(item) {
451 return `
452 <button class="arrival-item" type="button"
453 data-train-no="${escapeHtml(item.trainNo)}"
454 data-direction="${escapeHtml(item.direction || '')}"
455 data-direction-code="${escapeHtml(item.directionCode || '')}"
456 data-terminal="${escapeHtml(item.terminalStation || '')}"
457 data-station="${escapeHtml(item.stationName || '')}">
458 <div class="arrival-main">
459 <span class="arrival-train">${escapeHtml(item.trainNo)} 열차</span>
460 <span class="arrival-status">${escapeHtml(item.status)}</span>
461 </div>
462 <div class="arrival-meta">
463 ${escapeHtml(item.message || '도착 정보 없음')}
464 ${item.trainLineName ? ` · ${escapeHtml(item.trainLineName)}` : ''}
465 ${item.terminalStation ? ` · ${escapeHtml(item.terminalStation)}행` : ''}
466 </div>
467 </button>
468 `;
469 }).join('');
470 }
471
472 function renderTargetStationOptions(stations) {
473 targetStationSelect.innerHTML = '<option value="">목표역 선택</option>' + stations.map(function(station) {
474 const label = station.frCode ? `${station.name} (${station.frCode})` : station.name;
475 return `<option value="${escapeHtml(station.name)}">${escapeHtml(label)}</option>`;
476 }).join('');
477 targetStationSelect.disabled = stations.length === 0;
478 }
479
480 function filterStationsByDirection(boardingStation, directionCode, terminalStation) {
481 if (!boardingStation || !currentStations.length) return currentStations;
482 const boardingIndex = currentStations.findIndex(function(station) {
483 return station.name === boardingStation;
484 });
485 if (boardingIndex === -1) return currentStations;
486
487 if (terminalStation) {
488 const terminalIndex = currentStations.findIndex(function(station) {
489 return stationNamesMatch(station.name, terminalStation);
490 });
491 if (terminalIndex !== -1 && terminalIndex !== boardingIndex) {
492 const start = Math.min(boardingIndex, terminalIndex);
493 const end = Math.max(boardingIndex, terminalIndex);
494 const segment = currentStations.slice(start, end + 1);
495 return terminalIndex > boardingIndex ? segment.slice(1) : segment.slice(0, -1).reverse();
496 }
497 }
498
499 if (directionCode === '0') return currentStations.slice(0, boardingIndex).reverse();
500 if (directionCode === '1') return currentStations.slice(boardingIndex + 1);
501 return currentStations;
502 }
503
504 function applySelectedTrainTargetFilter() {
505 const selectedTerminal = arrivalList.querySelector('.arrival-item.selected')?.dataset.terminal || '';
506 const filtered = filterStationsByDirection(selectedBoardingStation, selectedDirectionCode, selectedTerminal);
507 renderTargetStationOptions(filtered);
508 if (filtered.length === 0) {
509 trackerStatus.textContent = '선택한 열차의 진행 방향에서 고를 수 있는 목표역이 없습니다.';
510 } else if (selectedBoardingStation && selectedDirectionCode) {
511 trackerStatus.textContent = `${trainNoInput.value} 열차의 진행 방향에 있는 역만 목표역으로 표시했습니다.`;
512 }
513 }
514
515 async function loadStationArrivals() {
516 const line = lineSelect.value;
517 const station = boardingStationSelect.value;
518 if (!line || !station) {
519 if (typeof showAlert === 'function') await showAlert('호선과 탑승역을 선택해 주세요.');
520 return;
521 }
522
523 loadArrivalsBtn.disabled = true;
524 arrivalList.innerHTML = '<p class="empty-message">탑승 가능한 열차 후보를 불러오는 중입니다.</p>';
525 try {
526 const params = new URLSearchParams({ line, station });
527 const data = await fetchJson(`/api/subway/station-arrivals?${params.toString()}`);
528 if (!data.success) throw new Error(data.message || '도착 정보를 불러오지 못했습니다.');
529 renderArrivals(data.arrivals || []);
530 } catch (error) {
531 arrivalList.innerHTML = `<p class="empty-message">${escapeHtml(error.message || '도착 정보를 불러오지 못했습니다.')}</p>`;
532 } finally {
533 loadArrivalsBtn.disabled = false;
534 }
535 }
536
537 function renderTrainBadgesForStation(station, lineTrains, selectedTrainNo, displayDirectionCode) {
538 const trains = (lineTrains || []).filter(function(train) {
539 return stationNamesMatch(train.stationName, station.name);
540 }).slice(0, 4);
541
542 if (!trains.length) return '';
543
544 return trains.map(function(train) {
545 const directionCode = String(train.directionCode ?? '');
546 const sideClass = directionCode === '0' ? 'up' : 'down';
547 const movementClass = directionCode && directionCode === displayDirectionCode ? ' moving-down' : ' moving-up';
548 const statusClass = train.status === '진입' ? ' status-enter' : (train.status === '출발' ? ' status-depart' : ' status-arrive');
549 const selectedClass = String(train.trainNo) === String(selectedTrainNo) ? ' selected' : '';
550 const terminal = train.destination || '';
551 const title = `${terminal ? terminal + '행 ' : ''}${train.trainNo} · ${train.status || '상태 미확인'}`;
552 return `
553 <div class="train-badge ${sideClass}${movementClass}${statusClass}${selectedClass}" title="${escapeHtml(title)}">
554 <span class="train-icon"></span>
555 <span>
556 <span class="train-label">${escapeHtml(train.trainNo)}</span>
557 </span>
558 </div>
559 `;
560 }).join('');
561 }
562
563 function renderTrainChipsForStation(station, lineTrains, selectedTrainNo) {
564 const trains = (lineTrains || []).filter(function(train) {
565 return stationNamesMatch(train.stationName, station.name);
566 }).slice(0, 4);
567
568 if (!trains.length) return '';
569
570 return `<div class="station-train-list">${trains.map(function(train) {
571 const terminal = train.destination || '';
572 const selectedClass = String(train.trainNo) === String(selectedTrainNo) ? ' selected' : '';
573 const label = `${train.trainNo}${terminal ? ' · ' + terminal + '행' : ''}${train.express ? ' · 급행' : ''}${train.lastTrain ? ' · 막차' : ''}`;
574 return `<span class="station-train-chip${selectedClass}" title="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
575 }).join('')}</div>`;
576 }
577
578 function renderRouteMap(match, lineTrains) {
579 const line = lineSelect.value;
580 const targetStation = targetStationSelect.value;
581 if (!line || !currentStations.length || !match) {
582 routeCard.classList.remove('show');
583 stationStrip.innerHTML = '';
584 return;
585 }
586
587 const currentIndex = currentStations.findIndex(function(station) {
588 return stationNamesMatch(station.name, match.stationName);
589 });
590 if (currentIndex === -1) {
591 routeCard.classList.add('show');
592 routeTitle.textContent = `${line} 노선 진행도`;
593 routeSub.textContent = `${match.stationName || '현재역'} 위치가 역 목록과 정확히 매칭되지 않았습니다.`;
594 stationStrip.innerHTML = '';
595 return;
596 }
597
598 const directionCode = String(match.directionCode ?? '');
599 const displayStations = directionCode === '0' ? currentStations.slice().reverse() : currentStations.slice();
600 const displayCurrentIndex = displayStations.findIndex(function(station) {
601 return stationNamesMatch(station.name, match.stationName);
602 });
603
604 routeCard.classList.add('show');
605 routeTitle.textContent = `${line} ${match.trainNo} 진행도`;
606 routeSub.textContent = `${match.stationName}역 ${match.status} · ${match.direction || '방향 미확인'} · 목표역 ${targetStation || '미선택'}`;
607 stationStrip.style.setProperty('--line-color', LINE_COLORS[line] || 'var(--accent)');
608 stationStrip.innerHTML = displayStations.map(function(station, index) {
609 const isCurrent = stationNamesMatch(station.name, match.stationName);
610 const isTarget = targetStation && stationNamesMatch(station.name, targetStation);
611 const state = index < displayCurrentIndex ? 'past' : (isCurrent ? 'current' : 'future');
612 const title = `${station.name}${station.frCode ? ' (' + station.frCode + ')' : ''}`;
613 return `
614 <div class="station-node ${state}${isTarget ? ' target' : ''}" data-current="${isCurrent ? 'true' : 'false'}" title="${escapeHtml(title)}">
615 <div class="station-rail">
616 <div class="station-dot"></div>
617 <div class="train-badges">${renderTrainBadgesForStation(station, lineTrains || [], match.trainNo, directionCode)}</div>
618 </div>
619 <div class="station-info">
620 <div class="station-name">${escapeHtml(station.name)}${station.frCode ? `<span class="station-code">${escapeHtml(station.frCode)}</span>` : ''}</div>
621 ${renderTrainChipsForStation(station, lineTrains || [], match.trainNo)}
622 </div>
623 </div>
624 `;
625 }).join('');
626
627 const currentEl = stationStrip.querySelector('[data-current="true"]');
628 if (currentEl) {
629 requestAnimationFrame(function() {
630 currentEl.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
631 });
632 }
633 }
634
635 function getArrivalMessage(status) {
636 if (status === '진입') return '열차가 도착역에 진입했어요. 하차하세요.';
637 return '열차가 도착역에 도착했어요. 하차하세요.';
638 }
639
640 async function showBrowserNotification(title, body) {
641 if (!('Notification' in window) || Notification.permission !== 'granted') return;
642 if (!('serviceWorker' in navigator)) return;
643
644 const reg = serviceWorkerRegistration || await navigator.serviceWorker.register('/sw.js');
645 serviceWorkerRegistration = reg;
646 const readyRegistration = await navigator.serviceWorker.ready;
647 await readyRegistration.showNotification(title, {
648 body,
649 icon: '/image/title.png',
650 badge: '/image/title.png',
651 data: { url: '/hinana/subway' }
652 });
653 }
654
655 function notifyArrival(trainNo, status) {
656 const title = `${trainNo} 열차 ${status}`;
657 const body = getArrivalMessage(status);
658 showBrowserNotification(title, body).catch(function(error) {
659 console.warn('브라우저 알림 표시 실패:', error);
660 });
661 if (typeof showAlert === 'function') {
662 showAlert(body);
663 } else {
664 alert(body);
665 }
666 }
667
668 function urlBase64ToUint8Array(b64) {
669 const pad = '='.repeat((4 - b64.length % 4) % 4);
670 const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
671 const raw = atob(base64);
672 return Uint8Array.from(raw, c => c.charCodeAt(0));
673 }
674
675 function arrayBufferToBase64Url(buffer) {
676 const bytes = new Uint8Array(buffer);
677 let binary = '';
678 bytes.forEach(function(byte) { binary += String.fromCharCode(byte); });
679 return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
680 }
681
682 function subscriptionUsesCurrentKey(subscription) {
683 if (!subscription || !subscription.options || !subscription.options.applicationServerKey) return true;
684 return arrayBufferToBase64Url(subscription.options.applicationServerKey) === PUSH_KEY;
685 }
686
687 async function ensurePushSubscription() {
688 if (!PUSH_USER) throw new Error('서버 도착 알림은 로그인이 필요합니다.');
689 if (!PUSH_KEY) throw new Error('푸시 알림 키가 설정되어 있지 않습니다.');
690 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
691 throw new Error('이 브라우저는 푸시 알림을 지원하지 않습니다.');
692 }
693
694 const reg = serviceWorkerRegistration || await navigator.serviceWorker.register('/sw.js');
695 serviceWorkerRegistration = reg;
696 await navigator.serviceWorker.ready;
697
698 if ('Notification' in window && Notification.permission === 'default') {
699 const permission = await Notification.requestPermission();
700 if (permission !== 'granted') throw new Error('알림 권한이 허용되지 않았습니다.');
701 }
702 if ('Notification' in window && Notification.permission === 'denied') {
703 throw new Error('브라우저 알림 권한이 차단되어 있습니다.');
704 }
705
706 let subscription = await reg.pushManager.getSubscription();
707 if (subscription && !subscriptionUsesCurrentKey(subscription)) {
708 await subscription.unsubscribe();
709 subscription = null;
710 }
711 if (!subscription) {
712 subscription = await reg.pushManager.subscribe({
713 userVisibleOnly: true,
714 applicationServerKey: urlBase64ToUint8Array(PUSH_KEY)
715 });
716 }
717
718 await fetchJson('/api/push/subscribe', {
719 method: 'POST',
720 headers: { 'Content-Type': 'application/json' },
721 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
722 });
723 }
724
725 async function createServerAlert(trainNo, line, targetStation) {
726 const data = await fetchJson('/api/subway/alerts', {
727 method: 'POST',
728 headers: { 'Content-Type': 'application/json' },
729 [SECURITY REDACTED] 민감한 설정/인증/토큰 관련 코드입니다.
730 });
731 if (!data.success) throw new Error(data.message || '도착 알림을 등록하지 못했습니다.');
732 return data.alert;
733 }
734
735 async function cancelServerAlert() {
736 if (!currentAlertId) return;
737 await fetchJson(`/api/subway/alerts/${encodeURIComponent(currentAlertId)}`, { method: 'DELETE' }).catch(function() {});
738 currentAlertId = '';
739 }
740
741 async function loadStationsForLine(line) {
742 targetStationSelect.disabled = true;
743 boardingStationSelect.disabled = true;
744 selectedBoardingStation = '';
745 selectedDirectionCode = '';
746 targetStationSelect.innerHTML = '<option value="">역 목록을 불러오는 중...</option>';
747 boardingStationSelect.innerHTML = '<option value="">역 목록을 불러오는 중...</option>';
748 arrivalList.innerHTML = '';
749 if (!line) {
750 currentStations = [];
751 routeCard.classList.remove('show');
752 targetStationSelect.innerHTML = '<option value="">호선을 먼저 선택해 주세요</option>';
753 boardingStationSelect.innerHTML = '<option value="">호선을 먼저 선택해 주세요</option>';
754 return;
755 }
756 try {
757 const data = await fetchJson(`/api/subway/stations?line=${encodeURIComponent(line)}`);
758 if (!data.success) throw new Error(data.message || '역 목록을 불러오지 못했습니다.');
759 currentStations = data.stations || [];
760 const stationOptions = (data.stations || []).map(function(station) {
761 const label = station.frCode ? `${station.name} (${station.frCode})` : station.name;
762 return `<option value="${escapeHtml(station.name)}">${escapeHtml(label)}</option>`;
763 }).join('');
764 renderTargetStationOptions(data.stations || []);
765 boardingStationSelect.innerHTML = '<option value="">탑승역 선택</option>' + stationOptions;
766 boardingStationSelect.disabled = false;
767 trackerStatus.textContent = `${line} 역 목록을 불러왔습니다. 목표역을 선택하면 도착 알림을 시작할 수 있어요.`;
768 } catch (error) {
769 currentStations = [];
770 routeCard.classList.remove('show');
771 targetStationSelect.innerHTML = '<option value="">역 목록 로드 실패</option>';
772 boardingStationSelect.innerHTML = '<option value="">역 목록 로드 실패</option>';
773 trackerStatus.textContent = error.message || '역 목록을 불러오지 못했습니다.';
774 }
775 }
776
777 async function queryTrainPosition(options = {}) {
778 const trainNo = trainNoInput.value.trim();
779 const line = lineSelect.value;
780 if (!trainNo) return;
781
782 if (!options.silent) {
783 resultCard.classList.add('show');
784 resultTitle.textContent = `${trainNo} 조회 중...`;
785 resultSource.textContent = '서울시 지하철 실시간 열차 위치정보';
786 searchedLines.textContent = '';
787 renderEmpty('실시간 위치를 확인하고 있습니다.');
788 submitBtn.disabled = true;
789 }
790
791 try {
792 const params = new URLSearchParams({ trainNo });
793 if (line) params.set('line', line);
794 const data = await fetchJson(`/api/subway/train-position?${params.toString()}`);
795 if (!data.success) throw new Error(data.message || '조회에 실패했습니다.');
796
797 resultTitle.textContent = `${data.trainNo} 조회 결과`;
798 resultSource.textContent = data.source || '서울시 지하철 실시간 열차 위치정보';
799 searchedLines.textContent = `${data.searchedLines.length}개 호선 조회`;
800 renderMatches(data.matches || []);
801 renderRouteMap((data.matches || [])[0], data.lineTrains || []);
802 return data;
803 } catch (error) {
804 if (!options.silent) {
805 resultTitle.textContent = '조회 실패';
806 searchedLines.textContent = '';
807 renderEmpty(error.message || '잠시 후 다시 시도해 주세요.');
808 routeCard.classList.remove('show');
809 }
810 throw error;
811 } finally {
812 if (!options.silent) submitBtn.disabled = false;
813 }
814 }
815
816 async function pollTracking() {
817 if (!trackingActive) return;
818 const targetStation = targetStationSelect.value;
819 const line = lineSelect.value;
820 const trainNo = trainNoInput.value.trim();
821
822 try {
823 const data = await queryTrainPosition({ silent: true });
824 const match = (data.matches || []).find(function(item) {
825 return stationNamesMatch(item.stationName, targetStation) && (item.status === '진입' || item.status === '도착');
826 });
827 const current = (data.matches || [])[0];
828 renderRouteMap(current, data.lineTrains || []);
829 if (current) {
830 trackerStatus.textContent = `${trainNo} 추적 중 · 현재 ${current.line} ${current.stationName || '위치 미확인'} · ${current.status}`;
831 } else {
832 trackerStatus.textContent = `${trainNo} 추적 중 · 현재 운행 위치를 찾지 못했습니다.`;
833 }
834 if (match) {
835 const notifyKey = `${trainNo}:${line}:${targetStation}:${match.receivedAt || Date.now()}`;
836 if (notifyKey !== lastNotifiedKey) {
837 lastNotifiedKey = notifyKey;
838 trackerStatus.textContent = `${getArrivalMessage(match.status)} 서버 푸시 알림이 곧 전송됩니다.`;
839 }
840 }
841 } catch (error) {
842 trackerStatus.textContent = '화면 갱신에 실패했지만 서버 도착 알림은 계속 추적 중입니다.';
843 }
844 }
845
846 async function startTracking() {
847 const trainNo = trainNoInput.value.trim();
848 const line = lineSelect.value;
849 const targetStation = targetStationSelect.value;
850 if (!PUSH_USER) {
851 if (typeof showAlert === 'function') await showAlert('도착 알림 기능은 로그인 후 사용할 수 있습니다.');
852 return;
853 }
854 if (!trainNo || !line || !targetStation) {
855 if (typeof showAlert === 'function') await showAlert('열번, 호선, 목표역을 모두 선택해 주세요.');
856 return;
857 }
858
859 try {
860 await ensurePushSubscription();
861 const serverAlert = await createServerAlert(trainNo, line, targetStation);
862 currentAlertId = serverAlert.id;
863 } catch (error) {
864 if (typeof showAlert === 'function') await showAlert(error.message || '도착 알림을 등록하지 못했습니다.');
865 return;
866 }
867
868 trackingActive = true;
869 lastNotifiedKey = '';
870 startTrackBtn.disabled = true;
871 stopTrackBtn.disabled = false;
872 trackerStatus.textContent = `${trainNo} 열차가 ${targetStation}역에 도착하는지 서버에서 5초마다 확인 중입니다. 브라우저를 백그라운드에 둬도 푸시 알림이 전송됩니다.`;
873 await pollTracking();
874 if (trackingActive) trackingTimer = setInterval(pollTracking, 5 * 1000);
875 }
876
877 async function resumeTracking(alert) {
878 if (!alert || !alert.active) return;
879 currentAlertId = alert.id;
880 trainNoInput.value = alert.trainNo;
881 lineSelect.value = alert.line;
882 await loadStationsForLine(alert.line);
883 targetStationSelect.value = alert.targetStation;
884
885 trackingActive = true;
886 startTrackBtn.disabled = true;
887 stopTrackBtn.disabled = false;
888 trackerStatus.textContent = `${alert.trainNo} 열차가 ${alert.targetStation}역에 도착하는지 서버에서 추적 중입니다. 브라우저를 백그라운드에 둬도 푸시 알림이 전송됩니다.`;
889 await pollTracking();
890 if (trackingActive) trackingTimer = setInterval(pollTracking, 5 * 1000);
891 }
892
893 async function loadExistingAlert() {
894 if (!PUSH_USER) return;
895 try {
896 const data = await fetchJson('/api/subway/alerts');
897 if (!data.success) return;
898 const activeAlert = (data.alerts || []).find(function(alert) { return alert.active; });
899 if (activeAlert) await resumeTracking(activeAlert);
900 } catch (_) {}
901 }
902
903 async function stopTracking(showMessage = true) {
904 trackingActive = false;
905 if (trackingTimer) clearInterval(trackingTimer);
906 trackingTimer = null;
907 await cancelServerAlert();
908 startTrackBtn.disabled = false;
909 stopTrackBtn.disabled = true;
910 if (showMessage) trackerStatus.textContent = '도착 알림 추적을 중지했습니다.';
911 }
912
913 form.addEventListener('submit', async function(event) {
914 event.preventDefault();
915 queryTrainPosition().catch(function() {});
916 });
917
918 lineSelect.addEventListener('change', function() {
919 stopTracking(false);
920 loadStationsForLine(this.value);
921 });
922 loadArrivalsBtn.addEventListener('click', loadStationArrivals);
923 arrivalList.addEventListener('click', function(event) {
924 const item = event.target.closest('.arrival-item');
925 if (!item) return;
926 arrivalList.querySelectorAll('.arrival-item').forEach(function(el) { el.classList.remove('selected'); });
927 item.classList.add('selected');
928 trainNoInput.value = item.dataset.trainNo || '';
929 selectedBoardingStation = item.dataset.station || boardingStationSelect.value;
930 selectedDirectionCode = item.dataset.directionCode || '';
931 applySelectedTrainTargetFilter();
932 });
933 if (startTrackBtn) startTrackBtn.addEventListener('click', startTracking);
934 if (stopTrackBtn) stopTrackBtn.addEventListener('click', function() { stopTracking(true); });
935
936 (async function initServiceWorker() {
937 if (!('serviceWorker' in navigator)) return;
938 serviceWorkerRegistration = await navigator.serviceWorker.register('/sw.js').catch(function() { return null; });
939 })();
940 loadExistingAlert();
941 </script>
942 </body>
943 </html>
944