Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
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