Public Source Viewer

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

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

Redacted View
view/hinana/subwayMap.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="수도권 전철 전 노선의 열차 위치를 노선도 위에서 한눈에 확인합니다." />
11 <title>실시간 노선도 — 비나래 라운지</title>
12 <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">
13 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css">
14 <style>
15 *, *::before, *::after { box-sizing: border-box; }
16 :root {
17 --bg-main: #f8f7f5; --bg-secondary: #ffffff;
18 --bg-tertiary: #1a2238; --bg-card: #ffffff; --bg-input: #f1f0ed;
19 --text-primary: #1a2238; --text-secondary: #5e6676;
20 --accent: #c5a059; --accent-dim: rgba(197,160,89,0.12);
21 --border: #e5e1da; --success: #16a34a; --danger: #dc2626;
22 --map-station-stroke: #3a4152; --map-label: #3a4152; --map-label-halo: rgba(248,247,245,.9);
23 --map-land: #ebe9e4; --map-boundary: #d0ccc3; --map-water: #dceff8; --map-water-edge: #b5d7e7;
24 --font: 'Noto Sans KR', sans-serif;
25 }
26 body.dark-mode {
27 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #111827;
28 --bg-card: #16203a; --bg-input: #243050;
29 --text-primary: #e7e5e4; --text-secondary: #94a3b8;
30 --border: #2e3a59;
31 --map-station-stroke: #cbd5e1; --map-label: #d7dce6; --map-label-halo: rgba(15,20,30,.9);
32 --map-land: #182237; --map-boundary: #34415a; --map-water: #13283a; --map-water-edge: #29465b;
33 }
34 html, body { margin:0; padding:0; background:var(--bg-main); color:var(--text-primary); font-family:var(--font); height:100%; }
35 body { display:flex; flex-direction:column; height:100dvh; overflow:hidden; }
36 a { text-decoration:none; color:inherit; }
37 .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); flex:0 0 auto; z-index:100; }
38 .brand { display:flex; align-items:center; gap:12px; min-width:0; flex:1; font-size:.9rem; font-weight:700; color:#fff; }
39 .brand > span:last-child { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
40 .header-logo { height:26px; max-width:170px; object-fit:contain; }
41 .site-header nav { display:flex; align-items:center; gap:6px; flex:0 0 auto; }
42 .site-header nav a { font-size:.82rem; color:var(--text-secondary); margin-left:20px; transition:color .2s; }
43 .site-header nav a:hover { color:var(--accent); }
44
45 .map-wrap { position:relative; flex:1; min-height:0; }
46 #mapSvg { position:absolute; inset:0; width:100%; height:100%; touch-action:none; overscroll-behavior:contain; -webkit-user-select:none; user-select:none; cursor:grab; background:var(--bg-main); }
47 #mapSvg.dragging { cursor:grabbing; }
48
49 .map-topbar { position:absolute; top:10px; left:10px; right:10px; display:flex; gap:8px; align-items:flex-start; justify-content:space-between; pointer-events:none; z-index:20; }
50 .map-status { pointer-events:auto; background:var(--bg-card); border:1px solid var(--border); border-radius:10px; padding:8px 12px; font-size:.74rem; color:var(--text-secondary); line-height:1.55; box-shadow:0 2px 10px rgba(0,0,0,.08); max-width:min(420px, 70vw); }
51 .map-status b { color:var(--text-primary); }
52 .map-tools { pointer-events:auto; display:flex; flex-direction:column; gap:6px; align-items:flex-end; }
53 .tool-row { display:flex; gap:6px; }
54 .map-btn { border:1px solid var(--border); background:var(--bg-card); color:var(--text-primary); border-radius:8px; width:34px; height:34px; font-size:1rem; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; box-shadow:0 2px 8px rgba(0,0,0,.08); }
55 .map-btn:hover { border-color:var(--accent); color:var(--accent); }
56 .map-btn.active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
57 .map-btn.loading i { animation:spin 1s linear infinite; }
58 .favorite-count { min-width:16px; height:16px; padding:0 4px; border-radius:999px; background:var(--accent); color:#fff; font-size:.62rem; line-height:16px; font-weight:700; }
59 .station-search { border:1px solid var(--border); background:var(--bg-card); color:var(--text-primary); border-radius:8px; height:34px; padding:0 10px; font-family:var(--font); font-size:.8rem; width:150px; outline:none; box-shadow:0 2px 8px rgba(0,0,0,.08); }
60 .station-search:focus { border-color:var(--accent); }
61
62 .favorites-panel { position:absolute; top:92px; right:10px; width:min(300px, calc(100% - 20px)); max-height:min(420px, calc(100% - 170px)); overflow:auto; background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:12px; z-index:29; box-shadow:0 6px 24px rgba(0,0,0,.14); display:none; }
63 .favorites-panel.show { display:block; }
64 .favorites-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
65 .favorites-head strong { font-size:.86rem; }
66 .favorites-close { border:0; background:none; color:var(--text-secondary); cursor:pointer; padding:3px; }
67 .favorites-empty { margin:8px 2px 2px; color:var(--text-secondary); font-size:.74rem; line-height:1.55; }
68 .favorite-list { display:flex; flex-direction:column; gap:6px; }
69 .favorite-item { width:100%; border:1px solid var(--border); border-radius:8px; background:var(--bg-card); color:var(--text-primary); padding:8px 10px; display:flex; align-items:center; gap:8px; text-align:left; cursor:pointer; font-family:var(--font); }
70 .favorite-item:hover { border-color:var(--accent); background:var(--accent-dim); }
71 .favorite-item .line-dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; }
72 .favorite-item .station-name { flex:1; min-width:0; font-size:.78rem; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
73 .favorite-item .line-name { color:var(--text-secondary); font-size:.68rem; white-space:nowrap; }
74
75 .legend { position:absolute; left:10px; bottom:10px; max-width:calc(100% - 20px); display:flex; flex-wrap:wrap; gap:5px; z-index:20; }
76 .legend-chip { display:inline-flex; align-items:center; gap:6px; border:1px solid var(--border); background:var(--bg-card); border-radius:999px; padding:4px 10px 4px 6px; font-size:.7rem; font-weight:700; cursor:pointer; user-select:none; box-shadow:0 1px 5px rgba(0,0,0,.06); }
77 .legend-chip .dot { width:10px; height:10px; border-radius:50%; flex:0 0 auto; }
78 .legend-chip.off { opacity:.35; }
79 .legend-chip .cnt { font-weight:400; color:var(--text-secondary); }
80
81 .info-panel { position:absolute; right:10px; bottom:10px; width:min(320px, calc(100% - 20px)); background:var(--bg-card); border:1px solid var(--border); border-radius:12px; padding:14px 16px; z-index:30; box-shadow:0 6px 24px rgba(0,0,0,.14); display:none; font-size:.82rem; line-height:1.7; }
82 .info-panel.show { display:block; }
83 .info-panel h3 { margin:0 0 8px; font-size:.95rem; display:flex; align-items:center; gap:8px; }
84 .info-panel h3 .line-badge { font-size:.68rem; color:#fff; border-radius:999px; padding:2px 9px; white-space:nowrap; }
85 .info-panel .station-heading { padding-right:32px; justify-content:space-between; align-items:flex-start; }
86 .info-panel .station-title { display:flex; align-items:center; gap:8px; min-width:0; }
87 .station-favorite-btn { flex:0 0 auto; border:1px solid var(--border); background:var(--bg-card); color:var(--text-secondary); border-radius:8px; width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
88 .station-favorite-btn:hover, .station-favorite-btn.active { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
89 .arrival-heading { margin-top:12px; padding-top:10px; border-top:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:.78rem; }
90 .arrival-refresh { border:0; background:none; color:var(--text-secondary); cursor:pointer; padding:2px 4px; }
91 .arrival-refresh:hover { color:var(--accent); }
92 .arrival-list li { cursor:default; align-items:flex-start; }
93 .arrival-list li:hover { background:transparent; }
94 .arrival-list .arrival-copy { min-width:0; flex:1; }
95 .arrival-list .arrival-message { display:block; font-weight:700; color:var(--text-primary); }
96 .arrival-list .arrival-meta { display:flex; align-items:center; flex-wrap:wrap; gap:4px; color:var(--text-secondary); font-size:.7rem; margin-top:2px; }
97 .service-badge { display:inline-flex; align-items:center; min-height:18px; padding:1px 6px; border-radius:999px; font-size:.64rem; font-weight:700; line-height:1.2; white-space:nowrap; }
98 .service-badge.express { color:#fff; background:var(--danger); }
99 .service-badge.local { color:var(--text-secondary); background:var(--bg-input); border:1px solid var(--border); }
100 .location-summary { margin:0 0 10px; padding:8px 10px; border-left:3px solid var(--accent); background:var(--accent-dim); color:var(--text-secondary); font-size:.73rem; line-height:1.55; }
101 .location-summary b { color:var(--text-primary); }
102 .arrival-error { color:var(--danger); }
103 .info-panel .close-btn { position:absolute; top:8px; right:10px; border:none; background:none; color:var(--text-secondary); font-size:1rem; cursor:pointer; }
104 .info-panel .muted { color:var(--text-secondary); font-size:.74rem; }
105 .train-track-btn { width:100%; min-height:38px; margin-top:12px; border:1px solid var(--border); border-radius:8px; background:var(--bg-card); color:var(--text-primary); display:flex; align-items:center; justify-content:center; gap:7px; font-family:var(--font); font-size:.78rem; font-weight:700; cursor:pointer; }
106 .train-track-btn:hover { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
107 .train-track-btn.active { border-color:var(--accent); background:var(--accent); color:#fff; }
108 .train-voice-setting { min-height:36px; margin-top:8px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
109 .train-voice-label { display:inline-flex; align-items:center; gap:7px; color:var(--text-secondary); font-size:.76rem; font-weight:700; }
110 .train-voice-toggle { position:relative; display:inline-flex; align-items:center; gap:7px; cursor:pointer; user-select:none; }
111 .train-voice-toggle input { position:absolute; opacity:0; pointer-events:none; }
112 .train-voice-switch { position:relative; width:36px; height:20px; border-radius:10px; background:var(--border); transition:background .18s; }
113 .train-voice-switch::after { content:''; position:absolute; top:3px; left:3px; width:14px; height:14px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(0,0,0,.2); transition:transform .18s; }
114 .train-voice-toggle input:checked + .train-voice-switch { background:var(--accent); }
115 .train-voice-toggle input:checked + .train-voice-switch::after { transform:translateX(16px); }
116 .train-voice-toggle input:focus-visible + .train-voice-switch { outline:2px solid var(--accent); outline-offset:2px; }
117 .train-voice-toggle strong { width:24px; color:var(--text-secondary); font-size:.68rem; text-align:right; }
118 .transfer-music-choice { margin:2px 0 0; padding:3px; border:1px solid var(--border); border-radius:7px; display:grid; grid-template-columns:1fr 1fr; gap:3px; background:var(--bg-input); }
119 .transfer-music-choice label { position:relative; min-width:0; cursor:pointer; }
120 .transfer-music-choice input { position:absolute; opacity:0; pointer-events:none; }
121 .transfer-music-choice span { min-height:28px; padding:4px 8px; border-radius:5px; display:flex; align-items:center; justify-content:center; color:var(--text-secondary); font-size:.7rem; font-weight:700; }
122 .transfer-music-choice input:checked + span { color:var(--accent); background:var(--bg-card); box-shadow:0 1px 3px rgba(0,0,0,.12); }
123 .transfer-music-choice input:focus-visible + span { outline:2px solid var(--accent); outline-offset:1px; }
124 .transfer-music-choice:disabled { opacity:.45; }
125 .transfer-music-choice:disabled label { cursor:not-allowed; }
126 .info-panel ul { margin:6px 0 0; padding:0; list-style:none; max-height:200px; overflow-y:auto; }
127 .info-panel li { padding:6px 8px; border-radius:8px; cursor:pointer; display:flex; gap:8px; align-items:center; }
128 .info-panel li:hover { background:var(--accent-dim); }
129 .info-panel li .line-dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; }
130
131 .map-loading { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; z-index:40; background:var(--bg-main); font-size:.9rem; color:var(--text-secondary); flex-direction:column; gap:10px; }
132 .map-loading .spinner { width:34px; height:34px; border:3px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 1s linear infinite; }
133 @keyframes spin { to { transform:rotate(360deg); } }
134
135 /* SVG 내부 요소 */
136 .district-shape { fill:var(--map-land); stroke:var(--map-boundary); stroke-width:1.2px; vector-effect:non-scaling-stroke; }
137 .district-river { fill:var(--map-water); fill-rule:evenodd; stroke:none; }
138 .schematic-river { fill:var(--map-water); stroke:var(--map-water-edge); stroke-width:1.2px; pointer-events:none; vector-effect:non-scaling-stroke; }
139 .line-path { fill:none; stroke-linejoin:round; stroke-linecap:round; }
140 .direction-arrow, .direction-arrow-head { pointer-events:none; }
141 .line-group.dimmed { opacity:.13; }
142 .station-hit { fill:transparent; stroke:none; cursor:pointer; pointer-events:all; }
143 .station-dot { stroke:var(--map-station-stroke); cursor:pointer; }
144 .station-dot.favorite { stroke:var(--accent); stroke-width:2.5px; }
145 svg.schematic-map .station-dot.transfer { opacity:0; }
146 .transfer-marker { cursor:pointer; }
147 .transfer-marker:focus { outline:none; }
148 .transfer-marker:focus-visible .transfer-shell { stroke:var(--accent); }
149 .transfer-shell, .transfer-core { fill:none; stroke-linecap:round; }
150 .transfer-shell { stroke:var(--map-station-stroke); }
151 .transfer-core { stroke:var(--bg-card); }
152 .transfer-bead { stroke:none; pointer-events:all; cursor:pointer; }
153 .station-label { fill:var(--map-label); paint-order:stroke; stroke:var(--map-label-halo); stroke-width:3px; font-weight:700; pointer-events:all; cursor:pointer; display:none; }
154 .station-label.overview-label { display:block; }
155 .station-hit:focus, .station-dot:focus, .station-label:focus { outline:none; }
156 .station-dot:focus-visible { stroke:var(--accent); stroke-width:2px; }
157 svg.z-mid .station-label { display:block; }
158 svg.z-in .station-label { display:block; }
159 .train-marker { cursor:pointer; transform:translate(var(--tx), var(--ty)) scale(var(--mscale, 1)); }
160 .train-marker .train-hit { fill:transparent; stroke:none; pointer-events:all; }
161 .train-marker .sel-ring { fill:none; stroke:transparent; stroke-width:2; }
162 .train-marker.selected .sel-ring { stroke:var(--accent); }
163 .train-marker.tracked .sel-ring { stroke:var(--accent); stroke-width:3; stroke-dasharray:3 2; }
164 .train-marker text { font-weight:800; pointer-events:none; }
165 .train-no { display:none; }
166 svg.z-in .train-no { display:block; }
167 .location-marker { pointer-events:none; }
168 .location-marker .location-ring { fill:none; stroke:var(--accent); stroke-width:2px; stroke-dasharray:4 3; }
169 .location-marker .location-core { display:none; }
170 .location-marker text { fill:var(--text-primary); paint-order:stroke; stroke:var(--map-label-halo); stroke-width:3px; font-weight:700; }
171 .legend-chip.guide { cursor:default; }
172 .legend-chip.guide:hover { border-color:var(--border); }
173 .legend-sep { width:1px; align-self:stretch; background:var(--border); margin:2px 3px; }
174 .legend-toggle { display:none; }
175 .legend-toggle.active { border-color:var(--accent); background:var(--accent); color:#fff; }
176
177 @media (max-width: 640px) {
178 .site-header { padding:0 12px; gap:8px; }
179 .site-header nav a { margin-left:0; width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center; font-size:0; }
180 .site-header nav a i { font-size:1rem; margin:0 !important; }
181 .map-topbar { flex-direction:column; align-items:stretch; }
182 .map-status { box-sizing:border-box; width:100%; max-width:none; padding:6px 10px; font-size:.68rem; line-height:1.45; word-break:keep-all; }
183 .map-tools { width:100%; }
184 .legend-toggle { display:inline-flex; }
185 .station-search { width:120px; }
186 .legend { display:none; left:10px; right:10px; bottom:10px; max-width:none; max-height:34dvh; overflow-y:auto; align-content:flex-start; padding:10px; background:var(--bg-card); border:1px solid var(--border); border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,.14); }
187 .legend.mobile-open { display:flex; }
188 .legend-chip { flex:0 0 auto; }
189 .favorites-panel { top:128px; max-height:calc(100% - 200px); }
190 .info-panel { max-height:55%; overflow:auto; }
191 }
192 @media (max-width: 390px) {
193 #styleBtn { width:34px !important; padding:0 !important; }
194 #styleBtnLabel { display:none; }
195 }
196 </style>
197 </head>
198 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
199 <header class="site-header">
200 <div class="brand">
201 <a href="/hinana/lounge"><img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo"></a>
202 <span style="opacity:.4; font-weight:300; color:#fff;">|</span>
203 <span style="color:#fff;">실시간 노선도</span>
204 </div>
205 <nav>
206 <a href="/hinana/subway"><i class="bi bi-search me-1"></i>열차 조회</a>
207 <a href="/hinana/lounge"><i class="bi bi-arrow-left me-1"></i>라운지로</a>
208 <form action="/toggle-theme" method="POST" style="margin:0; display:inline;">
209 <input type="hidden" name="redirect" value="/hinana/subway/map">
210 <button type="submit" style="background:none; border:none; color:var(--text-secondary); font-size:1rem; cursor:pointer; padding:4px 8px; margin-left:12px;" title="테마 변경">
211 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
212 </button>
213 </form>
214 </nav>
215 </header>
216
217 <div class="map-wrap">
218 <svg id="mapSvg" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="수도권 전철 실시간 열차 노선도"></svg>
219
220 <div class="map-topbar">
221 <div class="map-status" id="mapStatus">노선도 불러오는 중…</div>
222 <div class="map-tools">
223 <div class="tool-row">
224 <input type="text" class="station-search" id="stationSearch" list="stationList" placeholder="역 검색" autocomplete="off">
225 <datalist id="stationList"></datalist>
226 <button type="button" class="map-btn legend-toggle" id="legendBtn" title="노선 필터" aria-label="노선 필터" aria-expanded="false"><i class="bi bi-signpost-split"></i></button>
227 </div>
228 <div class="tool-row">
229 <button type="button" class="map-btn" id="locationBtn" title="현재 위치에서 가까운 역" aria-label="현재 위치에서 가까운 역"><i class="bi bi-crosshair"></i></button>
230 <button type="button" class="map-btn" id="favoritesBtn" title="즐겨찾는 역" aria-expanded="false" style="width:auto; padding:0 9px; gap:5px;"><i class="bi bi-star-fill"></i><span class="favorite-count" id="favoriteCount">0</span></button>
231 <button type="button" class="map-btn" id="styleBtn" title="노선도형/지도형 전환" style="width:auto; padding:0 10px; font-size:.75rem; font-weight:700; gap:5px;"><i class="bi bi-bounding-box-circles"></i><span id="styleBtnLabel">노선도형</span></button>
232 <button type="button" class="map-btn" id="zoomInBtn" title="확대"><i class="bi bi-plus-lg"></i></button>
233 <button type="button" class="map-btn" id="zoomOutBtn" title="축소"><i class="bi bi-dash-lg"></i></button>
234 <button type="button" class="map-btn" id="fitBtn" title="전체 보기"><i class="bi bi-arrows-fullscreen"></i></button>
235 <button type="button" class="map-btn" id="refreshBtn" title="새로고침"><i class="bi bi-arrow-clockwise"></i></button>
236 </div>
237 </div>
238 </div>
239
240 <div class="legend" id="legend"></div>
241
242 <div class="favorites-panel" id="favoritesPanel">
243 <div class="favorites-head">
244 <strong><i class="bi bi-star-fill" style="color:var(--accent);"></i> 즐겨찾는 역</strong>
245 <button type="button" class="favorites-close" id="favoritesClose" title="닫기"><i class="bi bi-x-lg"></i></button>
246 </div>
247 <div id="favoritesBody"></div>
248 </div>
249
250 <div class="info-panel" id="infoPanel">
251 <button type="button" class="close-btn" id="infoClose"><i class="bi bi-x-lg"></i></button>
252 <div id="infoBody"></div>
253 </div>
254
255 <div style="position:absolute; right:10px; bottom:2px; z-index:15; font-size:.62rem; color:var(--text-secondary); opacity:.75; pointer-events:none;">
256 좌표: © OpenStreetMap contributors · 경계: KOSTAT · 위치정보: 서울시 열린데이터광장
257 </div>
258
259 <div class="map-loading" id="mapLoading">
260 <div class="spinner"></div>
261 <div>노선도 데이터를 불러오는 중입니다…</div>
262 </div>
263 </div>
264
265 <script src="/js/subway-map.js?v=31"></script>
266 </body>
267 </html>
268