본문으로 건너뛰기
Public Source Viewer

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

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

Redacted View
view/hinana/lcd.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="키보토스광역급행철도 LCD — 비나래 라운지" />
10 <meta property="og:description" content="키보토스 광역급행철도 열차 내 LCD 안내판을 재현합니다." />
11 <title>키보토스광역급행철도 LCD — 비나래 라운지</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;500;700;900&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-input: #f1f0ed;
22 --text-primary: #1a2238; --text-secondary: #5e6676;
23 --accent: #c5a059; --border: #e5e1da;
24 --font: 'Noto Sans KR', sans-serif;
25 }
26 body.dark-mode {
27 --bg-main: #0f141e; --bg-secondary: #1a2238; --bg-tertiary: #111827;
28 --bg-input: #243050;
29 --text-primary: #e7e5e4; --text-secondary: #94a3b8;
30 --border: #2e3a59;
31 }
32 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; transition: background 0.2s, color 0.2s; }
33 a { text-decoration: none; color: inherit; }
34 .footer-sign { mix-blend-mode: multiply; }
35 body.dark-mode .footer-sign { filter: invert(1); mix-blend-mode: screen; }
36
37 /* 헤더 */
38 .site-header {
39 background: var(--bg-tertiary); height: 60px; padding: 0 24px;
40 display: flex; align-items: center; justify-content: space-between;
41 border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100;
42 }
43 .site-header .brand { display: flex; align-items: center; gap: 12px; font-size: 0.9rem; font-weight: 700; color: #fff; }
44 .header-logo { height: 26px; }
45 .site-header nav a { font-size: 0.82rem; color: var(--text-secondary); margin-left: 20px; transition: color 0.2s; }
46 .site-header nav a:hover { color: var(--accent); }
47
48 /* LCD 섹션 — 항상 다크 */
49 .lcd-section {
50 flex: 1;
51 background: #07111c;
52 display: flex; flex-direction: column;
53 align-items: center;
54 padding: 28px 10px 36px;
55 gap: 18px;
56 }
57
58 /* 섹션 레이블 */
59 .lcd-section-label {
60 font-size: 0.65rem; color: #3a6080; letter-spacing: 0.2em;
61 text-transform: uppercase; font-weight: 700;
62 }
63
64 /* ── LINE SELECTOR ── */
65 .line-selector {
66 display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: center;
67 }
68 .line-btn {
69 padding: 8px 22px; border-radius: 20px; border: 2px solid transparent;
70 font-family: 'Noto Sans KR', sans-serif; font-size: 13px; font-weight: 700;
71 cursor: pointer; transition: all 0.2s; letter-spacing: 0.5px;
72 }
73 .line-btn:hover { filter: brightness(1.2); }
74 .line-btn.sel { border-color: #fff; box-shadow: 0 0 12px rgba(255,255,255,0.3); }
75
76 /* ── LCD SHELL ── */
77 .lcd {
78 width: min(1100px, 98vw);
79 border-radius: 14px; overflow: hidden;
80 box-shadow: 0 0 0 3px #1a2e44, 0 20px 80px rgba(0,100,200,0.15);
81 display: flex; flex-direction: column;
82 }
83
84 /* TOP BAR */
85 .top-bar {
86 height: 36px; background: #050e18;
87 display: flex; align-items: center; padding: 0 16px; gap: 10px;
88 border-bottom: 1px solid #1a3050;
89 }
90 .loop-pill { color: #fff; font-size: 12px; font-weight: 700; padding: 3px 12px; border-radius: 4px; letter-spacing: 0.5px; }
91 .dest-pill { font-size: 14px; font-weight: 600; color: #c8e4ff; }
92 .dest-pill b { font-weight: 900; color: #fff; }
93 .phase-tag { margin-left: auto; font-size: 11px; color: #3a6080; font-weight: 500; }
94
95 /* MAIN ROW */
96 .main-row { display: flex; height: 165px; border-bottom: 1px solid #1a3050; }
97 .brand-col {
98 width: 148px; flex-shrink: 0;
99 background: #060f1c; border-right: 1px solid #1a3050;
100 display: flex; flex-direction: column;
101 align-items: flex-start; justify-content: center;
102 padding: 14px; gap: 9px;
103 }
104 .brand-ko { font-size: 13px; font-weight: 700; color: #c8dff5; line-height: 1.4; }
105 .brand-en { font-size: 9px; color: #3a6070; line-height: 1.5; }
106 .line-badge { color: #fff; font-size: 17px; font-weight: 900; padding: 5px 13px; border-radius: 5px; letter-spacing: 1.5px; }
107
108 .station-col { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 12px 22px; gap: 3px; overflow: hidden; }
109 .next-lbl { font-size: 19px; font-weight: 400; color: #5a90bb; }
110 .big-name {
111 font-size: 60px; font-weight: 900; color: #fff;
112 letter-spacing: -1.5px; line-height: 1.0;
113 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
114 text-shadow: 0 0 30px rgba(0,160,255,0.2);
115 transition: opacity 0.25s, transform 0.25s;
116 }
117 .big-name.hide { opacity: 0; transform: translateY(-8px); }
118
119 .num-col {
120 width: 96px; flex-shrink: 0; border-left: 1px solid #1a3050;
121 display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px;
122 }
123 .num-ring {
124 width: 56px; height: 56px; border-radius: 50%;
125 border: 3px solid #0099ff; background: rgba(0,100,180,0.1);
126 display: flex; align-items: center; justify-content: center;
127 font-size: 26px; font-weight: 900; color: #fff; transition: all 0.4s;
128 }
129 .num-ring.pulse { box-shadow: 0 0 0 6px rgba(0,153,255,0.2); }
130 .num-hint { font-size: 9px; color: #2a5070; text-align: center; line-height: 1.4; }
131 .door-tag { font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 3px; transition: all 0.3s; }
132 .door-open { background: rgba(0,190,80,0.12); color: #00cc55; border: 1px solid #00cc55; }
133 .door-close { background: rgba(200,50,50,0.1); color: #cc3333; border: 1px solid #cc3333; }
134
135 /* TICKER */
136 .ticker {
137 height: 26px; background: #040d16;
138 display: flex; align-items: center;
139 border-bottom: 1px solid #1a3050; overflow: hidden;
140 }
141 .ticker-tag {
142 color: #fff; font-size: 10px; font-weight: 700;
143 padding: 0 12px; height: 100%;
144 display: flex; align-items: center; flex-shrink: 0; letter-spacing: 1px;
145 }
146 .ticker-scroll { flex: 1; overflow: hidden; height: 100%; display: flex; align-items: center; }
147 .ticker-inner {
148 white-space: nowrap; font-size: 11px; color: #6a9abf;
149 display: inline-block;
150 animation: scroll-left 35s linear infinite;
151 }
152 @keyframes scroll-left {
153 from { transform: translateX(110%); }
154 to { transform: translateX(-100%); }
155 }
156
157 /* ROUTE MAP */
158 .route-wrap { background: #edf3f9; padding: 0 16px; height: 148px; position: relative; overflow: hidden; }
159 .rail { position: absolute; top: 48px; left: 16px; right: 16px; height: 5px; background: #b8d0e5; border-radius: 3px; }
160 .rail-done { position: absolute; top: 48px; left: 16px; height: 5px; border-radius: 3px; transition: width 0.6s ease; }
161 .stn-row { position: absolute; top: 0; left: 16px; right: 16px; height: 100%; display: flex; align-items: flex-start; }
162 .stn { flex: 1; display: flex; flex-direction: column; align-items: center; position: relative; }
163 .dot-zone { height: 62px; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 5px; position: relative; width: 100%; }
164 .dot { width: 15px; height: 15px; border-radius: 50%; border: 3px solid #0099ff; background: #fff; flex-shrink: 0; position: relative; z-index: 2; transition: all 0.4s; }
165 .stn.passed .dot { background: #b8d0e5; border-color: #b8d0e5; }
166 .stn.current .dot { width: 24px; height: 24px; background: #0099ff; border-color: #0044cc; border-width: 4px; box-shadow: 0 0 0 5px rgba(0,153,255,0.2); }
167 .stn.closed .dot { border-color: #cc2222; }
168 .closed-x { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); font-size: 13px; font-weight: 900; color: #cc2222; z-index: 5; line-height: 1; }
169 .stn-num { font-size: 10px; font-weight: 700; color: #5a8aaa; line-height: 1; margin-top: 3px; }
170 .stn.passed .stn-num { color: #9abccc; }
171 .stn.current .stn-num { color: #0055bb; font-size: 11px; }
172 .stn-name {
173 font-size: 10px; font-weight: 500; color: #2a4a6a; line-height: 1.3; text-align: center;
174 margin-top: 3px; max-width: 100%; overflow: hidden;
175 display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
176 padding: 0 2px; word-break: keep-all;
177 }
178 .stn.passed .stn-name { color: #8aaabb; }
179 .stn.current .stn-name { font-weight: 900; color: #002a80; font-size: 11.5px; }
180 .xfer {
181 position: absolute; top: 5px; left: 50%; transform: translateX(-50%);
182 color: #fff; font-size: 7.5px; font-weight: 700;
183 padding: 1px 4px; border-radius: 2px; white-space: nowrap; z-index: 10;
184 }
185
186 /* AD BANNER */
187 .ad-bar { height: 68px; background: #06101a; border-top: 2px solid #0050aa; display: flex; overflow: hidden; }
188 .ad-side {
189 background: #002266; color: #fff; font-size: 10px; font-weight: 700;
190 writing-mode: vertical-rl; text-orientation: mixed;
191 width: 28px; flex-shrink: 0;
192 display: flex; align-items: center; justify-content: center; letter-spacing: 2px;
193 }
194 .ad-stage { flex: 1; position: relative; overflow: hidden; }
195 .ad { position: absolute; inset: 0; display: flex; align-items: center; padding: 0 28px; gap: 18px; opacity: 0; transition: opacity 0.7s ease; }
196 .ad.show { opacity: 1; }
197
198 /* CONTROLS */
199 .controls { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; }
200 .btn {
201 background: #0d1e30; color: #6a9abe; border: 1px solid #1e3a55;
202 padding: 7px 16px; border-radius: 6px;
203 font-family: 'Noto Sans KR', sans-serif; font-size: 12px;
204 cursor: pointer; transition: all 0.15s;
205 }
206 .btn:hover { background: #1a3050; color: #c8e4ff; }
207 .btn.on { background: #0055cc; color: #fff; border-color: #0088ff; }
208 .btn.tts-on { background: #006633; border-color: #00aa55; color: #fff; }
209 .sep { color: #1e3a55; font-size: 18px; }
210
211 /* 푸터 */
212 footer { text-align: center; padding: 40px 20px; border-top: 1px solid var(--border); background: var(--bg-secondary); }
213
214 /* 모바일 */
215 @media (max-width: 600px) {
216 .lcd-section { padding: 16px 6px 24px; gap: 12px; }
217 .line-btn { font-size: 11px; padding: 6px 14px; }
218 .main-row { height: auto; flex-wrap: wrap; }
219 .brand-col { width: 100%; flex-direction: row; align-items: center; justify-content: space-between; padding: 8px 14px; border-right: none; border-bottom: 1px solid #1a3050; }
220 .brand-ko { font-size: 11px; }
221 .brand-en { display: none; }
222 .line-badge { font-size: 14px; padding: 4px 10px; }
223 .station-col { padding: 10px 14px; gap: 2px; }
224 .next-lbl { font-size: 15px; }
225 .big-name { font-size: 36px; letter-spacing: -0.5px; }
226 .num-col { width: 80px; border-left: 1px solid #1a3050; }
227 .num-ring { width: 44px; height: 44px; font-size: 20px; }
228 .route-wrap { height: 130px; }
229 .dot-zone { height: 55px; }
230 .rail, .rail-done { top: 42px; }
231 .stn-name { font-size: 9px; }
232 .stn-num { font-size: 9px; }
233 .ad-bar { height: 58px; }
234 .ad { padding: 0 14px; gap: 10px; }
235 .controls { gap: 7px; }
236 .btn { padding: 6px 11px; font-size: 11px; }
237 .sep { display: none; }
238 }
239 </style>
240 <link rel="stylesheet" href="/css/site-ui.css?v=3">
241 <link rel="stylesheet" href="/css/site-app.css?v=1">
242 </head>
243 <body data-ui-page="lcd" class="ui-shell <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
244
245 <%- include('partials/site-header', { uiPage: 'lcd' }) %>
246 <div id="ui-content" tabindex="-1" class="ui-content-anchor"></div>
247
248 <!-- LCD 섹션 (항상 다크) -->
249 <div class="lcd-section">
250
251 <div class="lcd-section-label">Kivotos Metropolitan Subway · LCD Display Simulator</div>
252
253 <!-- 노선 선택 -->
254 <div class="line-selector" id="lineSelector"></div>
255
256 <!-- LCD 패널 -->
257 <div class="lcd" id="lcd">
258 <div class="top-bar">
259 <div class="loop-pill" id="loopPill">내선순환</div>
260 <div class="dest-pill">종착: <b id="destName">시타델</b></div>
261 <div class="phase-tag" id="phaseTag">접근 중</div>
262 </div>
263 <div class="main-row" id="mainPanel">
264 <div class="brand-col">
265 <div>
266 <div class="brand-ko">키보토스<br>광역도시철도</div>
267 <div class="brand-en">Kivotos Metropolitan<br>Subway</div>
268 </div>
269 <div class="line-badge" id="lineBadge">T Line</div>
270 </div>
271 <div class="station-col">
272 <div class="next-lbl" id="nextLbl">다음은</div>
273 <div class="big-name" id="bigName">총학생회 사무국</div>
274 </div>
275 <div class="num-col">
276 <div class="num-ring" id="numRing">3</div>
277 <div class="num-hint">역 번호<br>Station No.</div>
278 <div class="door-tag door-close" id="doorTag">문 닫힘</div>
279 </div>
280 </div>
281 <div class="ticker">
282 <div class="ticker-tag" id="tickerTag" style="background:#0055bb">안내</div>
283 <div class="ticker-scroll">
284 <span class="ticker-inner" id="tickerInner">키보토스 광역도시철도를 이용해 주셔서 감사합니다.</span>
285 </div>
286 </div>
287 <div class="route-wrap" id="routeWrap">
288 <div class="rail"></div>
289 <div class="rail-done" id="railDone"></div>
290 <div class="stn-row" id="stnRow"></div>
291 </div>
292 <div class="ad-bar">
293 <div class="ad-side">광 고</div>
294 <div class="ad-stage" id="adStage"></div>
295 </div>
296 </div>
297
298 <!-- 컨트롤 -->
299 <div class="controls">
300 <button class="btn" id="btnPrev">◀ 이전</button>
301 <button class="btn on" id="btnAuto">⏵ 자동운행</button>
302 <button class="btn" id="btnNext">다음 ▶</button>
303 <span class="sep">|</span>
304 <button class="btn" id="btnDoor">🚪 문 열기/닫기</button>
305 <span class="sep">|</span>
306 <button class="btn" id="btnTts">🔊 음성 안내 OFF</button>
307 </div>
308
309 </div>
310
311 <footer>
312 <img src="/image/sign.png" class="footer-sign" style="width: 160px; opacity: 0.7;">
313 <div style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8; margin-top: 16px;">
314 <strong>비나래 라운지</strong><br>
315 X - @NoctchillHinana<br>
316 ⓒ 2024~2026. 비나래 | hinana.moe
317 </div>
318 </footer>
319
320 <script>
321 const LINES = {
322 T: {
323 name: 'T Line', label: 'T', color: '#0077dd', bgColor: '#0d1b2e',
324 loop: '내선순환', dest: '시타델', direction: '시타델 방면',
325 tickerColor: '#0055bb', adColor: '#002266',
326 stations: [
327 { num:1, name:'시타델\n서문', en:'Citadel West Gate', transfer:null, closed:false },
328 { num:2, name:'생텀타워', en:'Sanctum Tower', transfer:'환승 A', closed:false },
329 { num:3, name:'총학생회\n사무국', en:'Gen.Student Council', transfer:null, closed:false },
330 { num:4, name:'연방수사\n동아리', en:'SCHALE HQ', transfer:null, closed:false },
331 { num:5, name:'하이랜더\n철도학원',en:'Highlander Railroad', transfer:null, closed:false },
332 { num:6, name:'제7대교\n북단', en:'7th Bridge North', transfer:null, closed:false },
333 { num:7, name:'백귀야행\n연합학원',en:'Hyakkiyako Academy', transfer:null, closed:false },
334 { num:8, name:'하루하바라', en:'Haruhara District', transfer:null, closed:false },
335 { num:9, name:'밀레니엄\n사이언스',en:'Millennium Sci.', transfer:'환승 G', closed:false },
336 { num:10, name:'D.U.\n지구', en:'D.U. District', transfer:null, closed:false },
337 { num:11, name:'트리니티\n종합학원',en:'Trinity General', transfer:'환승 G', closed:false },
338 { num:12, name:'성당광장', en:'Cathedral Plaza', transfer:null, closed:false },
339 { num:13, name:'게헨나\n학원', en:'Gehenna Academy', transfer:'환승 A', closed:false },
340 { num:14, name:'선도부\n검문소', en:'Disciplinary Gate', transfer:null, closed:false },
341 { num:15, name:'무법자\n해변', en:'Outlaw Beach', transfer:null, closed:false },
342 { num:16, name:'제7대교\n남단', en:'7th Bridge South', transfer:null, closed:false },
343 { num:17, name:'자치구\n제3분가', en:'District 3-B', transfer:null, closed:false },
344 { num:18, name:'구공정', en:'Old Factory', transfer:null, closed:false },
345 { num:19, name:'시계탑\n광장', en:'Clock Tower Sq.', transfer:null, closed:false },
346 { num:20, name:'시타델\n정문', en:'Citadel Main Gate', transfer:null, closed:false },
347 ],
348 ads: [
349 { bg:'linear-gradient(100deg,#001133,#002266,#001133)',
350 html:`<div style="display:flex;flex-direction:column;gap:2px"><div style="font-size:10px;font-weight:700;color:#6699ff;letter-spacing:2px">KIVOTOS STUDENT POLICE DEPARTMENT</div><div style="font-size:30px;font-weight:900;color:#fff;letter-spacing:-1px">전입생 모집중!</div></div><div style="background:#ffcc00;color:#001;font-size:11px;font-weight:900;padding:5px 14px;border-radius:4px;white-space:nowrap;flex-shrink:0">멋진 교복을 입을 수 있어요!</div>` },
351 { bg:'linear-gradient(100deg,#0a0020,#150040,#0a0020)',
352 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#eeddff">밀레니엄 사이언스 스쿨 오픈 캠퍼스</div><div style="font-size:12px;color:#aa77ee">최첨단 연구시설 견학 · 전공별 체험 프로그램 · 매주 토요일 10:00~</div></div>` },
353 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
354 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#99ffcc">키보토스 교통공사 안전 캠페인</div><div style="font-size:12px;color:#55bb88">열차 문이 닫히기 전 여유있게 승하차하세요 · Be safe on the platform</div></div>` },
355 { bg:'linear-gradient(100deg,#1a1000,#2a1800,#1a1000)',
356 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:10px;font-weight:700;color:#ffaa44;letter-spacing:2px">TRINITY GENERAL SCHOOL</div><div style="font-size:24px;font-weight:900;color:#fff9ee">성 에덴 봄 축제 — 티켓 예매 중</div></div>` },
357 ]
358 },
359 A: {
360 name: 'A Line', label: 'A', color: '#cc8800', bgColor: '#1e1208',
361 loop: '직통', dest: '아비도스', direction: '아비도스 방면',
362 tickerColor: '#885500', adColor: '#331100',
363 stations: [
364 { num:1, name:'생텀타워', en:'Sanctum Tower', transfer:'환승 T', closed:false },
365 { num:2, name:'카이저\n코퍼레이션',en:'Kaiser Corp.', transfer:null, closed:false },
366 { num:3, name:'흥신소\n68', en:'Fixers Inc. 68', transfer:null, closed:false },
367 { num:4, name:'아리우스\n분교', en:'Arius Branch', transfer:null, closed:true },
368 { num:5, name:'오아시스\n지구', en:'Oasis District', transfer:null, closed:false },
369 { num:6, name:'사막\n검문소', en:'Desert Checkpoint', transfer:null, closed:false },
370 { num:7, name:'채무\n관리소', en:'Debt Office', transfer:null, closed:false },
371 { num:8, name:'구 아비도스\n역', en:'Old Abydos Sta.', transfer:null, closed:true },
372 { num:9, name:'아비도스\n사막역', en:'Abydos Desert', transfer:null, closed:false },
373 { num:10, name:'아비도스\n고등학교',en:'Abydos High School', transfer:null, closed:false },
374 ],
375 ads: [
376 { bg:'linear-gradient(100deg,#1a0e00,#2a1800,#1a0e00)',
377 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:10px;font-weight:700;color:#ffaa44;letter-spacing:2px">ABYDOS HIGH SCHOOL</div><div style="font-size:26px;font-weight:900;color:#fff8ee">아비도스를 지켜라! 대출 상환 캠페인</div></div>` },
378 { bg:'linear-gradient(100deg,#0d0800,#1a1000,#0d0800)',
379 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#ffddaa">카이저 론 — 빠른 대출, 편리한 상환</div><div style="font-size:11px;color:#cc8833">*연 이자율 9.8%. 연체 시 카이저 PMC 출동. 계약 전 약관을 꼼꼼히 확인하세요.</div></div>` },
380 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
381 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#99ffcc">키보토스 교통공사 안전 캠페인</div><div style="font-size:12px;color:#55bb88">아비도스선 구간 모래바람 주의 · Sand storm advisory in effect</div></div>` },
382 ]
383 },
384 G: {
385 name: 'G Line', label: 'G', color: '#cc2200', bgColor: '#1e0a08',
386 loop: '직통', dest: '게헨나', direction: '게헨나 방면',
387 tickerColor: '#881100', adColor: '#330000',
388 stations: [
389 { num:1, name:'트리니티\n종합학원',en:'Trinity General', transfer:'환승 T', closed:false },
390 { num:2, name:'성당광장\n북문', en:'Cathedral North', transfer:null, closed:false },
391 { num:3, name:'발키리\n경찰학교', en:'Valkyrie Police', transfer:null, closed:false },
392 { num:4, name:'SRT\n특수학원', en:'SRT Academy', transfer:null, closed:false },
393 { num:5, name:'풍기위원회\n본부', en:'Disciplinary HQ', transfer:null, closed:false },
394 { num:6, name:'연방수사\n본부', en:'Federal HQ', transfer:null, closed:false },
395 { num:7, name:'밀레니엄\n이공구역',en:'Millennium Tech Zone', transfer:'환승 T', closed:false },
396 { num:8, name:'블랙마켓\n입구', en:'Black Market Entrance',transfer:null, closed:false },
397 { num:9, name:'아리우스\n인근', en:'Near Arius', transfer:null, closed:true },
398 { num:10, name:'게헨나\n외곽', en:'Gehenna Outskirts', transfer:null, closed:false },
399 { num:11, name:'게헨나\n학원', en:'Gehenna Academy', transfer:'환승 T', closed:false },
400 ],
401 ads: [
402 { bg:'linear-gradient(100deg,#1a0000,#2a0000,#1a0000)',
403 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:10px;font-weight:700;color:#ff6644;letter-spacing:2px">GEHENNA ACADEMY — 선도부</div><div style="font-size:26px;font-weight:900;color:#fff">교내 규율 위반 즉시 신고하세요</div></div>` },
404 { bg:'linear-gradient(100deg,#1a0000,#330000,#1a0000)',
405 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#ffaaaa">게헨나 학원 풍기위원회 주의 공지</div><div style="font-size:11px;color:#cc5533">차내 폭발물 소지 절대 금지 · 위반 시 즉시 체포됩니다</div></div>` },
406 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
407 html:`<div style="display:flex;flex-direction:column;gap:3px"><div style="font-size:22px;font-weight:900;color:#99ffcc">키보토스 교통공사 안전 캠페인</div><div style="font-size:12px;color:#55bb88">열차 내 총기 안전장치를 반드시 체결하세요</div></div>` },
408 ]
409 }
410 };
411
412 let curLine = 'T', curIdx = 0, phase = 0;
413 let autoOn = true, autoTimer = null, doorOpen = false, ttsOn = false;
414 let adIdx = 0, adTimer = null;
415
416 const PHASE_MS = [6000, 2000, 5000, 2000];
417 const PHASE_LABEL = ['접근 중','도착','문 열림','출발'];
418
419 function buildSelector() {
420 const el = document.getElementById('lineSelector');
421 el.innerHTML = '';
422 Object.entries(LINES).forEach(([key, line]) => {
423 const btn = document.createElement('button');
424 btn.className = 'line-btn' + (key === curLine ? ' sel' : '');
425 btn.style.background = line.color;
426 btn.style.color = '#fff';
427 btn.textContent = `${line.name} (${line.loop === '내선순환' ? '순환' : line.dest + ' 방면'})`;
428 btn.onclick = () => {
429 stopAuto(); curLine = key; curIdx = 0; phase = 0;
430 switchLine(); buildSelector(); renderMap(); updateDisplay();
431 setTimeout(startAuto, 800);
432 };
433 el.appendChild(btn);
434 });
435 }
436
437 function switchLine() {
438 const L = LINES[curLine];
439 document.getElementById('lcd').style.background = L.bgColor;
440 document.getElementById('mainPanel').style.background = L.bgColor;
441 document.getElementById('lineBadge').style.background = L.color;
442 document.getElementById('lineBadge').textContent = L.name;
443 document.getElementById('loopPill').style.background = L.color;
444 document.getElementById('loopPill').textContent = L.loop;
445 document.getElementById('destName').textContent = L.dest;
446 document.getElementById('tickerTag').style.background = L.tickerColor;
447 buildAds();
448 }
449
450 function buildAds() {
451 const stage = document.getElementById('adStage');
452 stage.innerHTML = '';
453 LINES[curLine].ads.forEach((ad, i) => {
454 const d = document.createElement('div');
455 d.className = 'ad' + (i === 0 ? ' show' : '');
456 d.style.background = ad.bg;
457 d.innerHTML = ad.html;
458 stage.appendChild(d);
459 });
460 adIdx = 0;
461 clearInterval(adTimer);
462 adTimer = setInterval(() => {
463 const ads = document.querySelectorAll('#adStage .ad');
464 if (!ads.length) return;
465 ads[adIdx].classList.remove('show');
466 adIdx = (adIdx + 1) % ads.length;
467 ads[adIdx].classList.add('show');
468 }, 5500);
469 }
470
471 function renderMap() {
472 const L = LINES[curLine];
473 const stnArr = L.stations;
474 const VISIBLE = 9;
475 let wStart = Math.max(0, curIdx - 3);
476 let wEnd = wStart + VISIBLE;
477 if (wEnd > stnArr.length) { wEnd = stnArr.length; wStart = Math.max(0, wEnd - VISIBLE); }
478 const vis = stnArr.slice(wStart, wEnd);
479
480 const row = document.getElementById('stnRow');
481 row.innerHTML = '';
482 vis.forEach((s, i) => {
483 const gi = wStart + i;
484 const el = document.createElement('div');
485 el.className = 'stn';
486 if (gi < curIdx) el.classList.add('passed');
487 else if (gi === curIdx) el.classList.add('current');
488 if (s.closed) el.classList.add('closed');
489
490 const xferHtml = s.transfer ? `<div class="xfer" style="background:${gi < curIdx ? '#7a9aaa' : L.color}">${s.transfer}</div>` : '';
491 const closedHtml = s.closed ? `<div class="closed-x">✕</div>` : '';
492 el.innerHTML = `<div class="dot-zone">${xferHtml}${closedHtml}<div class="dot"></div></div><div class="stn-num">${s.num}</div><div class="stn-name">${s.name.replace('\n','<br>')}</div>`;
493 row.appendChild(el);
494 });
495
496 const passedCount = Math.max(0, curIdx - wStart);
497 const ratio = vis.length > 1 ? passedCount / (vis.length - 1) : 0;
498 const railW = document.getElementById('routeWrap').clientWidth - 32;
499 const railEl = document.getElementById('railDone');
500 railEl.style.width = (ratio * railW) + 'px';
501 railEl.style.background = L.color;
502
503 document.querySelectorAll('.stn.current .dot').forEach(d => {
504 d.style.background = L.color;
505 d.style.borderColor = L.color;
506 });
507 }
508
509 function updateDisplay() {
510 const L = LINES[curLine];
511 const s = L.stations[curIdx];
512
513 const bigEl = document.getElementById('bigName');
514 bigEl.classList.add('hide');
515 setTimeout(() => { bigEl.textContent = s.name.replace('\n',' '); bigEl.classList.remove('hide'); }, 240);
516
517 document.getElementById('numRing').textContent = s.num;
518 document.getElementById('phaseTag').textContent = PHASE_LABEL[phase];
519
520 const arriving = (phase === 1 || phase === 2);
521 document.getElementById('nextLbl').textContent = arriving ? '현재 역' : '다음은';
522
523 doorOpen = (phase === 2);
524 const doorEl = document.getElementById('doorTag');
525 doorEl.textContent = doorOpen ? '문 열림' : '문 닫힘';
526 doorEl.className = 'door-tag ' + (doorOpen ? 'door-open' : 'door-close');
527 document.getElementById('numRing').classList.toggle('pulse', arriving);
528
529 const next = L.stations[curIdx + 1];
530 const nextName = next ? next.name.replace('\n',' ') : '종착역';
531 document.getElementById('tickerInner').textContent =
532 `이 열차는 키보토스 광역도시철도 ${L.name} ${L.direction}입니다. · ` +
533 (arriving
534 ? `현재 역은 ${s.name.replace('\n',' ')} 역입니다. 내리실 문은 오른쪽입니다. · `
535 : `다음 역은 ${nextName} 역입니다. · `) +
536 `소지품을 잘 챙기시고 안전하게 이용해 주시기 바랍니다. · Kivotos Metropolitan Subway ${L.name}`;
537
538 renderMap();
539 }
540
541 function speakLines(lines, onDone) {
542 if (!window.speechSynthesis || !lines.length) { onDone && onDone(); return; }
543 window.speechSynthesis.cancel();
544 let i = 0;
545 function next() {
546 if (i >= lines.length) { setTimeout(() => onDone && onDone(), 400); return; }
547 const utt = new SpeechSynthesisUtterance(lines[i]);
548 utt.lang = 'ko-KR'; utt.rate = 0.88; utt.pitch = 1.05;
549 utt.onend = () => { i++; setTimeout(next, 320); };
550 utt.onerror = () => { i++; setTimeout(next, 300); };
551 window.speechSynthesis.speak(utt);
552 }
553 next();
554 }
555
556 function getTtsLines(s, L) {
557 const stnName = s.name.replace('\n', ' ');
558 const nextStn = L.stations[curIdx + 1];
559 const nextName = nextStn ? nextStn.name.replace('\n', ' ') : null;
560 const firstName = L.stations[0].name.replace('\n', ' ');
561 const lastName = L.stations[L.stations.length - 1].name.replace('\n', ' ');
562 const isFirst = curIdx === 0, isLast = curIdx === L.stations.length - 1;
563 const isLoop = L.loop === '내선순환';
564 const xferInfo = s.transfer ? `이번 역에서 ${s.transfer.replace('환승 ','')} 라인으로 환승하실 수 있습니다.` : null;
565
566 if (phase === 0) {
567 const lines = [];
568 if (isFirst) {
569 lines.push(`안녕하세요. 키보토스 광역도시철도를 이용해 주셔서 감사합니다.`);
570 lines.push(isLoop
571 ? `이 열차는 ${L.name} 내선순환 열차입니다.`
572 : `이 열차는 키보토스 광역도시철도 ${L.name}, ${firstName}에서 ${lastName}행 직통 열차입니다.`);
573 } else {
574 lines.push(`이 열차는 키보토스 광역도시철도 ${L.name}, ${L.direction}입니다.`);
575 }
576 if (nextName) {
577 lines.push(`다음 역은, ${nextName} 역입니다.`);
578 if (nextStn && nextStn.closed) lines.push(`${nextName} 역은 현재 운행이 중단된 역으로, 해당 역에서는 승하차하실 수 없습니다.`);
579 } else {
580 lines.push(`다음 역은 이 열차의 종착역입니다.`);
581 }
582 lines.push(`내리실 문은 오른쪽입니다.`);
583 return lines;
584 }
585 if (phase === 1) {
586 const lines = [`${stnName}, ${stnName} 역입니다.`];
587 if (xferInfo) lines.push(xferInfo);
588 lines.push(`내리실 문은 오른쪽입니다.`);
589 if (isLast) lines.push(`이번 역은 종착역입니다. 이 열차의 운행이 종료되오니, 모든 승객께서는 내려 주시기 바랍니다.`);
590 return lines;
591 }
592 if (phase === 2) {
593 const lines = [`이번 역은 ${stnName} 역입니다.`];
594 if (xferInfo) lines.push(xferInfo);
595 if (isLast) {
596 lines.push(`종착역이오니 내리실 문은 오른쪽입니다.`);
597 lines.push(`오늘도 키보토스 광역도시철도를 이용해 주셔서 감사합니다.`);
598 } else {
599 lines.push(`승하차에 주의해 주시기 바랍니다.`);
600 lines.push(`소지품을 잘 챙기시고, 안전하게 이용해 주시기 바랍니다.`);
601 }
602 return lines;
603 }
604 return [];
605 }
606
607 let audioCtx = null;
608 function getAudio() {
609 if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
610 return audioCtx;
611 }
612 function playChime(type) {
613 try {
614 const ctx = getAudio();
615 if (type === 'arrive') {
616 [[587.3,0,0.18],[440,0.22,0.18]].forEach(([freq,start,dur]) => {
617 const osc = ctx.createOscillator(), gain = ctx.createGain();
618 osc.connect(gain); gain.connect(ctx.destination);
619 osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
620 gain.gain.setValueAtTime(0, ctx.currentTime + start);
621 gain.gain.linearRampToValueAtTime(0.28, ctx.currentTime + start + 0.03);
622 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
623 osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur + 0.05);
624 });
625 } else if (type === 'depart') {
626 [[440,0,0.14],[523.3,0.16,0.14],[659.3,0.32,0.2]].forEach(([freq,start,dur]) => {
627 const osc = ctx.createOscillator(), gain = ctx.createGain();
628 osc.connect(gain); gain.connect(ctx.destination);
629 osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
630 gain.gain.setValueAtTime(0, ctx.currentTime + start);
631 gain.gain.linearRampToValueAtTime(0.22, ctx.currentTime + start + 0.02);
632 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
633 osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur + 0.05);
634 });
635 } else if (type === 'door_open' || type === 'door_close') {
636 const osc = ctx.createOscillator(), gain = ctx.createGain();
637 osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine';
638 osc.frequency.setValueAtTime(type === 'door_open' ? 400 : 600, ctx.currentTime);
639 osc.frequency.linearRampToValueAtTime(type === 'door_open' ? 600 : 400, ctx.currentTime + 0.25);
640 gain.gain.setValueAtTime(0.18, ctx.currentTime);
641 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
642 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.35);
643 }
644 } catch(e) {}
645 }
646
647 function autoStep() {
648 if (!autoOn) return;
649 if (phase === 1) playChime('arrive');
650 if (phase === 2) playChime('door_open');
651 if (phase === 3) playChime('door_close');
652 updateDisplay();
653
654 const L = LINES[curLine];
655 const s = L.stations[curIdx];
656 const minDur = PHASE_MS[phase];
657 const hasTts = ttsOn && (phase === 0 || phase === 1 || phase === 2);
658 const thisPhase = phase;
659 phase = (phase + 1) % 4;
660 if (phase === 0) { curIdx = (curIdx + 1) % L.stations.length; playChime('depart'); }
661
662 if (hasTts) {
663 let timerDone = false, ttsDone = false, proceeded = false;
664 function tryProceed() {
665 if (proceeded) return;
666 if (timerDone && ttsDone) { proceeded = true; autoTimer = setTimeout(autoStep, 300); }
667 }
668 const minWait = thisPhase === 0 ? 4000 : thisPhase === 2 ? 3000 : 2000;
669 setTimeout(() => { timerDone = true; tryProceed(); }, minWait);
670 speakLines(getTtsLines(s, L), () => { ttsDone = true; tryProceed(); });
671 } else {
672 autoTimer = setTimeout(autoStep, minDur);
673 }
674 }
675
676 function startAuto() {
677 clearTimeout(autoTimer); autoOn = true;
678 document.getElementById('btnAuto').classList.add('on');
679 phase = 0; autoStep();
680 }
681 function stopAuto() {
682 clearTimeout(autoTimer); autoOn = false;
683 document.getElementById('btnAuto').classList.remove('on');
684 }
685
686 document.getElementById('btnPrev').onclick = () => {
687 stopAuto(); phase = 0; if (curIdx > 0) curIdx--; updateDisplay();
688 };
689 document.getElementById('btnNext').onclick = () => {
690 stopAuto(); phase = 0;
691 if (curIdx < LINES[curLine].stations.length - 1) curIdx++;
692 updateDisplay();
693 };
694 document.getElementById('btnAuto').onclick = () => { autoOn ? stopAuto() : startAuto(); };
695 document.getElementById('btnDoor').onclick = () => { stopAuto(); phase = doorOpen ? 3 : 2; updateDisplay(); };
696 document.getElementById('btnTts').onclick = () => {
697 ttsOn = !ttsOn;
698 const btn = document.getElementById('btnTts');
699 btn.textContent = ttsOn ? '🔊 음성 안내 ON' : '🔊 음성 안내 OFF';
700 btn.classList.toggle('tts-on', ttsOn);
701 if (!ttsOn && window.speechSynthesis) window.speechSynthesis.cancel();
702 };
703
704 buildSelector(); switchLine(); buildAds(); renderMap(); updateDisplay();
705 setTimeout(startAuto, 1000);
706 </script>
707 </body>
708 </html>
709