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 </head>
241 <body class="<%= (typeof theme !== 'undefined' && theme === 'dark') ? 'dark-mode' : '' %>">
242
243 <header class="site-header">
244 <div class="brand">
245 <a href="/hinana/lounge">
246 <img src="/image/lounge1.png" alt="비나래 라운지" class="header-logo">
247 </a>
248 <span style="opacity:0.4; font-weight:300; color:#fff;">|</span>
249 <span style="color:#fff;">키보토스광역급행철도 LCD</span>
250 </div>
251 <nav>
252 <a href="/hinana/lounge"><i class="bi bi-arrow-left me-1"></i>라운지로</a>
253 <form action="/toggle-theme" method="POST" style="margin:0; display:inline;">
254 <input type="hidden" name="redirect" value="/hinana/lcd">
255 <button type="submit" style="background:none; border:none; color:var(--text-secondary); font-size:1rem; cursor:pointer; padding:4px 8px; margin-left:12px;" title="테마 변경">
256 <i class="bi <%= (typeof theme !== 'undefined' && theme === 'dark') ? 'bi-moon-stars-fill' : 'bi-sun-fill' %>"></i>
257 </button>
258 </form>
259 </nav>
260 </header>
261
262 <!-- LCD 섹션 (항상 다크) -->
263 <div class="lcd-section">
264
265 <div class="lcd-section-label">Kivotos Metropolitan Subway · LCD Display Simulator</div>
266
267 <!-- 노선 선택 -->
268 <div class="line-selector" id="lineSelector"></div>
269
270 <!-- LCD 패널 -->
271 <div class="lcd" id="lcd">
272 <div class="top-bar">
273 <div class="loop-pill" id="loopPill">내선순환</div>
274 <div class="dest-pill">종착: <b id="destName">시타델</b></div>
275 <div class="phase-tag" id="phaseTag">접근 중</div>
276 </div>
277 <div class="main-row" id="mainPanel">
278 <div class="brand-col">
279 <div>
280 <div class="brand-ko">키보토스<br>광역도시철도</div>
281 <div class="brand-en">Kivotos Metropolitan<br>Subway</div>
282 </div>
283 <div class="line-badge" id="lineBadge">T Line</div>
284 </div>
285 <div class="station-col">
286 <div class="next-lbl" id="nextLbl">다음은</div>
287 <div class="big-name" id="bigName">총학생회 사무국</div>
288 </div>
289 <div class="num-col">
290 <div class="num-ring" id="numRing">3</div>
291 <div class="num-hint">역 번호<br>Station No.</div>
292 <div class="door-tag door-close" id="doorTag">문 닫힘</div>
293 </div>
294 </div>
295 <div class="ticker">
296 <div class="ticker-tag" id="tickerTag" style="background:#0055bb">안내</div>
297 <div class="ticker-scroll">
298 <span class="ticker-inner" id="tickerInner">키보토스 광역도시철도를 이용해 주셔서 감사합니다.</span>
299 </div>
300 </div>
301 <div class="route-wrap" id="routeWrap">
302 <div class="rail"></div>
303 <div class="rail-done" id="railDone"></div>
304 <div class="stn-row" id="stnRow"></div>
305 </div>
306 <div class="ad-bar">
307 <div class="ad-side">광 고</div>
308 <div class="ad-stage" id="adStage"></div>
309 </div>
310 </div>
311
312 <!-- 컨트롤 -->
313 <div class="controls">
314 <button class="btn" id="btnPrev">◀ 이전</button>
315 <button class="btn on" id="btnAuto">⏵ 자동운행</button>
316 <button class="btn" id="btnNext">다음 ▶</button>
317 <span class="sep">|</span>
318 <button class="btn" id="btnDoor">🚪 문 열기/닫기</button>
319 <span class="sep">|</span>
320 <button class="btn" id="btnTts">🔊 음성 안내 OFF</button>
321 </div>
322
323 </div>
324
325 <footer>
326 <img src="/image/sign.png" class="footer-sign" style="width: 160px; opacity: 0.7;">
327 <div style="font-size: 0.7rem; color: var(--text-secondary); line-height: 1.8; margin-top: 16px;">
328 <strong>비나래 라운지</strong><br>
329 X - @NoctchillHinana<br>
330 ⓒ 2024~2026. 비나래 | hinana.moe
331 </div>
332 </footer>
333
334 <script>
335 const LINES = {
336 T: {
337 name: 'T Line', label: 'T', color: '#0077dd', bgColor: '#0d1b2e',
338 loop: '내선순환', dest: '시타델', direction: '시타델 방면',
339 tickerColor: '#0055bb', adColor: '#002266',
340 stations: [
341 { num:1, name:'시타델\n서문', en:'Citadel West Gate', transfer:null, closed:false },
342 { num:2, name:'생텀타워', en:'Sanctum Tower', transfer:'환승 A', closed:false },
343 { num:3, name:'총학생회\n사무국', en:'Gen.Student Council', transfer:null, closed:false },
344 { num:4, name:'연방수사\n동아리', en:'SCHALE HQ', transfer:null, closed:false },
345 { num:5, name:'하이랜더\n철도학원',en:'Highlander Railroad', transfer:null, closed:false },
346 { num:6, name:'제7대교\n북단', en:'7th Bridge North', transfer:null, closed:false },
347 { num:7, name:'백귀야행\n연합학원',en:'Hyakkiyako Academy', transfer:null, closed:false },
348 { num:8, name:'하루하바라', en:'Haruhara District', transfer:null, closed:false },
349 { num:9, name:'밀레니엄\n사이언스',en:'Millennium Sci.', transfer:'환승 G', closed:false },
350 { num:10, name:'D.U.\n지구', en:'D.U. District', transfer:null, closed:false },
351 { num:11, name:'트리니티\n종합학원',en:'Trinity General', transfer:'환승 G', closed:false },
352 { num:12, name:'성당광장', en:'Cathedral Plaza', transfer:null, closed:false },
353 { num:13, name:'게헨나\n학원', en:'Gehenna Academy', transfer:'환승 A', closed:false },
354 { num:14, name:'선도부\n검문소', en:'Disciplinary Gate', transfer:null, closed:false },
355 { num:15, name:'무법자\n해변', en:'Outlaw Beach', transfer:null, closed:false },
356 { num:16, name:'제7대교\n남단', en:'7th Bridge South', transfer:null, closed:false },
357 { num:17, name:'자치구\n제3분가', en:'District 3-B', transfer:null, closed:false },
358 { num:18, name:'구공정', en:'Old Factory', transfer:null, closed:false },
359 { num:19, name:'시계탑\n광장', en:'Clock Tower Sq.', transfer:null, closed:false },
360 { num:20, name:'시타델\n정문', en:'Citadel Main Gate', transfer:null, closed:false },
361 ],
362 ads: [
363 { bg:'linear-gradient(100deg,#001133,#002266,#001133)',
364 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>` },
365 { bg:'linear-gradient(100deg,#0a0020,#150040,#0a0020)',
366 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>` },
367 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
368 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>` },
369 { bg:'linear-gradient(100deg,#1a1000,#2a1800,#1a1000)',
370 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>` },
371 ]
372 },
373 A: {
374 name: 'A Line', label: 'A', color: '#cc8800', bgColor: '#1e1208',
375 loop: '직통', dest: '아비도스', direction: '아비도스 방면',
376 tickerColor: '#885500', adColor: '#331100',
377 stations: [
378 { num:1, name:'생텀타워', en:'Sanctum Tower', transfer:'환승 T', closed:false },
379 { num:2, name:'카이저\n코퍼레이션',en:'Kaiser Corp.', transfer:null, closed:false },
380 { num:3, name:'흥신소\n68', en:'Fixers Inc. 68', transfer:null, closed:false },
381 { num:4, name:'아리우스\n분교', en:'Arius Branch', transfer:null, closed:true },
382 { num:5, name:'오아시스\n지구', en:'Oasis District', transfer:null, closed:false },
383 { num:6, name:'사막\n검문소', en:'Desert Checkpoint', transfer:null, closed:false },
384 { num:7, name:'채무\n관리소', en:'Debt Office', transfer:null, closed:false },
385 { num:8, name:'구 아비도스\n역', en:'Old Abydos Sta.', transfer:null, closed:true },
386 { num:9, name:'아비도스\n사막역', en:'Abydos Desert', transfer:null, closed:false },
387 { num:10, name:'아비도스\n고등학교',en:'Abydos High School', transfer:null, closed:false },
388 ],
389 ads: [
390 { bg:'linear-gradient(100deg,#1a0e00,#2a1800,#1a0e00)',
391 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>` },
392 { bg:'linear-gradient(100deg,#0d0800,#1a1000,#0d0800)',
393 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>` },
394 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
395 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>` },
396 ]
397 },
398 G: {
399 name: 'G Line', label: 'G', color: '#cc2200', bgColor: '#1e0a08',
400 loop: '직통', dest: '게헨나', direction: '게헨나 방면',
401 tickerColor: '#881100', adColor: '#330000',
402 stations: [
403 { num:1, name:'트리니티\n종합학원',en:'Trinity General', transfer:'환승 T', closed:false },
404 { num:2, name:'성당광장\n북문', en:'Cathedral North', transfer:null, closed:false },
405 { num:3, name:'발키리\n경찰학교', en:'Valkyrie Police', transfer:null, closed:false },
406 { num:4, name:'SRT\n특수학원', en:'SRT Academy', transfer:null, closed:false },
407 { num:5, name:'풍기위원회\n본부', en:'Disciplinary HQ', transfer:null, closed:false },
408 { num:6, name:'연방수사\n본부', en:'Federal HQ', transfer:null, closed:false },
409 { num:7, name:'밀레니엄\n이공구역',en:'Millennium Tech Zone', transfer:'환승 T', closed:false },
410 { num:8, name:'블랙마켓\n입구', en:'Black Market Entrance',transfer:null, closed:false },
411 { num:9, name:'아리우스\n인근', en:'Near Arius', transfer:null, closed:true },
412 { num:10, name:'게헨나\n외곽', en:'Gehenna Outskirts', transfer:null, closed:false },
413 { num:11, name:'게헨나\n학원', en:'Gehenna Academy', transfer:'환승 T', closed:false },
414 ],
415 ads: [
416 { bg:'linear-gradient(100deg,#1a0000,#2a0000,#1a0000)',
417 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>` },
418 { bg:'linear-gradient(100deg,#1a0000,#330000,#1a0000)',
419 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>` },
420 { bg:'linear-gradient(100deg,#001a10,#002a18,#001a10)',
421 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>` },
422 ]
423 }
424 };
425
426 let curLine = 'T', curIdx = 0, phase = 0;
427 let autoOn = true, autoTimer = null, doorOpen = false, ttsOn = false;
428 let adIdx = 0, adTimer = null;
429
430 const PHASE_MS = [6000, 2000, 5000, 2000];
431 const PHASE_LABEL = ['접근 중','도착','문 열림','출발'];
432
433 function buildSelector() {
434 const el = document.getElementById('lineSelector');
435 el.innerHTML = '';
436 Object.entries(LINES).forEach(([key, line]) => {
437 const btn = document.createElement('button');
438 btn.className = 'line-btn' + (key === curLine ? ' sel' : '');
439 btn.style.background = line.color;
440 btn.style.color = '#fff';
441 btn.textContent = `${line.name} (${line.loop === '내선순환' ? '순환' : line.dest + ' 방면'})`;
442 btn.onclick = () => {
443 stopAuto(); curLine = key; curIdx = 0; phase = 0;
444 switchLine(); buildSelector(); renderMap(); updateDisplay();
445 setTimeout(startAuto, 800);
446 };
447 el.appendChild(btn);
448 });
449 }
450
451 function switchLine() {
452 const L = LINES[curLine];
453 document.getElementById('lcd').style.background = L.bgColor;
454 document.getElementById('mainPanel').style.background = L.bgColor;
455 document.getElementById('lineBadge').style.background = L.color;
456 document.getElementById('lineBadge').textContent = L.name;
457 document.getElementById('loopPill').style.background = L.color;
458 document.getElementById('loopPill').textContent = L.loop;
459 document.getElementById('destName').textContent = L.dest;
460 document.getElementById('tickerTag').style.background = L.tickerColor;
461 buildAds();
462 }
463
464 function buildAds() {
465 const stage = document.getElementById('adStage');
466 stage.innerHTML = '';
467 LINES[curLine].ads.forEach((ad, i) => {
468 const d = document.createElement('div');
469 d.className = 'ad' + (i === 0 ? ' show' : '');
470 d.style.background = ad.bg;
471 d.innerHTML = ad.html;
472 stage.appendChild(d);
473 });
474 adIdx = 0;
475 clearInterval(adTimer);
476 adTimer = setInterval(() => {
477 const ads = document.querySelectorAll('#adStage .ad');
478 if (!ads.length) return;
479 ads[adIdx].classList.remove('show');
480 adIdx = (adIdx + 1) % ads.length;
481 ads[adIdx].classList.add('show');
482 }, 5500);
483 }
484
485 function renderMap() {
486 const L = LINES[curLine];
487 const stnArr = L.stations;
488 const VISIBLE = 9;
489 let wStart = Math.max(0, curIdx - 3);
490 let wEnd = wStart + VISIBLE;
491 if (wEnd > stnArr.length) { wEnd = stnArr.length; wStart = Math.max(0, wEnd - VISIBLE); }
492 const vis = stnArr.slice(wStart, wEnd);
493
494 const row = document.getElementById('stnRow');
495 row.innerHTML = '';
496 vis.forEach((s, i) => {
497 const gi = wStart + i;
498 const el = document.createElement('div');
499 el.className = 'stn';
500 if (gi < curIdx) el.classList.add('passed');
501 else if (gi === curIdx) el.classList.add('current');
502 if (s.closed) el.classList.add('closed');
503
504 const xferHtml = s.transfer ? `<div class="xfer" style="background:${gi < curIdx ? '#7a9aaa' : L.color}">${s.transfer}</div>` : '';
505 const closedHtml = s.closed ? `<div class="closed-x">✕</div>` : '';
506 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>`;
507 row.appendChild(el);
508 });
509
510 const passedCount = Math.max(0, curIdx - wStart);
511 const ratio = vis.length > 1 ? passedCount / (vis.length - 1) : 0;
512 const railW = document.getElementById('routeWrap').clientWidth - 32;
513 const railEl = document.getElementById('railDone');
514 railEl.style.width = (ratio * railW) + 'px';
515 railEl.style.background = L.color;
516
517 document.querySelectorAll('.stn.current .dot').forEach(d => {
518 d.style.background = L.color;
519 d.style.borderColor = L.color;
520 });
521 }
522
523 function updateDisplay() {
524 const L = LINES[curLine];
525 const s = L.stations[curIdx];
526
527 const bigEl = document.getElementById('bigName');
528 bigEl.classList.add('hide');
529 setTimeout(() => { bigEl.textContent = s.name.replace('\n',' '); bigEl.classList.remove('hide'); }, 240);
530
531 document.getElementById('numRing').textContent = s.num;
532 document.getElementById('phaseTag').textContent = PHASE_LABEL[phase];
533
534 const arriving = (phase === 1 || phase === 2);
535 document.getElementById('nextLbl').textContent = arriving ? '현재 역' : '다음은';
536
537 doorOpen = (phase === 2);
538 const doorEl = document.getElementById('doorTag');
539 doorEl.textContent = doorOpen ? '문 열림' : '문 닫힘';
540 doorEl.className = 'door-tag ' + (doorOpen ? 'door-open' : 'door-close');
541 document.getElementById('numRing').classList.toggle('pulse', arriving);
542
543 const next = L.stations[curIdx + 1];
544 const nextName = next ? next.name.replace('\n',' ') : '종착역';
545 document.getElementById('tickerInner').textContent =
546 `이 열차는 키보토스 광역도시철도 ${L.name} ${L.direction}입니다. · ` +
547 (arriving
548 ? `현재 역은 ${s.name.replace('\n',' ')} 역입니다. 내리실 문은 오른쪽입니다. · `
549 : `다음 역은 ${nextName} 역입니다. · `) +
550 `소지품을 잘 챙기시고 안전하게 이용해 주시기 바랍니다. · Kivotos Metropolitan Subway ${L.name}`;
551
552 renderMap();
553 }
554
555 function speakLines(lines, onDone) {
556 if (!window.speechSynthesis || !lines.length) { onDone && onDone(); return; }
557 window.speechSynthesis.cancel();
558 let i = 0;
559 function next() {
560 if (i >= lines.length) { setTimeout(() => onDone && onDone(), 400); return; }
561 const utt = new SpeechSynthesisUtterance(lines[i]);
562 utt.lang = 'ko-KR'; utt.rate = 0.88; utt.pitch = 1.05;
563 utt.onend = () => { i++; setTimeout(next, 320); };
564 utt.onerror = () => { i++; setTimeout(next, 300); };
565 window.speechSynthesis.speak(utt);
566 }
567 next();
568 }
569
570 function getTtsLines(s, L) {
571 const stnName = s.name.replace('\n', ' ');
572 const nextStn = L.stations[curIdx + 1];
573 const nextName = nextStn ? nextStn.name.replace('\n', ' ') : null;
574 const firstName = L.stations[0].name.replace('\n', ' ');
575 const lastName = L.stations[L.stations.length - 1].name.replace('\n', ' ');
576 const isFirst = curIdx === 0, isLast = curIdx === L.stations.length - 1;
577 const isLoop = L.loop === '내선순환';
578 const xferInfo = s.transfer ? `이번 역에서 ${s.transfer.replace('환승 ','')} 라인으로 환승하실 수 있습니다.` : null;
579
580 if (phase === 0) {
581 const lines = [];
582 if (isFirst) {
583 lines.push(`안녕하세요. 키보토스 광역도시철도를 이용해 주셔서 감사합니다.`);
584 lines.push(isLoop
585 ? `이 열차는 ${L.name} 내선순환 열차입니다.`
586 : `이 열차는 키보토스 광역도시철도 ${L.name}, ${firstName}에서 ${lastName}행 직통 열차입니다.`);
587 } else {
588 lines.push(`이 열차는 키보토스 광역도시철도 ${L.name}, ${L.direction}입니다.`);
589 }
590 if (nextName) {
591 lines.push(`다음 역은, ${nextName} 역입니다.`);
592 if (nextStn && nextStn.closed) lines.push(`${nextName} 역은 현재 운행이 중단된 역으로, 해당 역에서는 승하차하실 수 없습니다.`);
593 } else {
594 lines.push(`다음 역은 이 열차의 종착역입니다.`);
595 }
596 lines.push(`내리실 문은 오른쪽입니다.`);
597 return lines;
598 }
599 if (phase === 1) {
600 const lines = [`${stnName}, ${stnName} 역입니다.`];
601 if (xferInfo) lines.push(xferInfo);
602 lines.push(`내리실 문은 오른쪽입니다.`);
603 if (isLast) lines.push(`이번 역은 종착역입니다. 이 열차의 운행이 종료되오니, 모든 승객께서는 내려 주시기 바랍니다.`);
604 return lines;
605 }
606 if (phase === 2) {
607 const lines = [`이번 역은 ${stnName} 역입니다.`];
608 if (xferInfo) lines.push(xferInfo);
609 if (isLast) {
610 lines.push(`종착역이오니 내리실 문은 오른쪽입니다.`);
611 lines.push(`오늘도 키보토스 광역도시철도를 이용해 주셔서 감사합니다.`);
612 } else {
613 lines.push(`승하차에 주의해 주시기 바랍니다.`);
614 lines.push(`소지품을 잘 챙기시고, 안전하게 이용해 주시기 바랍니다.`);
615 }
616 return lines;
617 }
618 return [];
619 }
620
621 let audioCtx = null;
622 function getAudio() {
623 if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
624 return audioCtx;
625 }
626 function playChime(type) {
627 try {
628 const ctx = getAudio();
629 if (type === 'arrive') {
630 [[587.3,0,0.18],[440,0.22,0.18]].forEach(([freq,start,dur]) => {
631 const osc = ctx.createOscillator(), gain = ctx.createGain();
632 osc.connect(gain); gain.connect(ctx.destination);
633 osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
634 gain.gain.setValueAtTime(0, ctx.currentTime + start);
635 gain.gain.linearRampToValueAtTime(0.28, ctx.currentTime + start + 0.03);
636 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
637 osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur + 0.05);
638 });
639 } else if (type === 'depart') {
640 [[440,0,0.14],[523.3,0.16,0.14],[659.3,0.32,0.2]].forEach(([freq,start,dur]) => {
641 const osc = ctx.createOscillator(), gain = ctx.createGain();
642 osc.connect(gain); gain.connect(ctx.destination);
643 osc.type = 'sine'; osc.frequency.setValueAtTime(freq, ctx.currentTime + start);
644 gain.gain.setValueAtTime(0, ctx.currentTime + start);
645 gain.gain.linearRampToValueAtTime(0.22, ctx.currentTime + start + 0.02);
646 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur);
647 osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur + 0.05);
648 });
649 } else if (type === 'door_open' || type === 'door_close') {
650 const osc = ctx.createOscillator(), gain = ctx.createGain();
651 osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine';
652 osc.frequency.setValueAtTime(type === 'door_open' ? 400 : 600, ctx.currentTime);
653 osc.frequency.linearRampToValueAtTime(type === 'door_open' ? 600 : 400, ctx.currentTime + 0.25);
654 gain.gain.setValueAtTime(0.18, ctx.currentTime);
655 gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
656 osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.35);
657 }
658 } catch(e) {}
659 }
660
661 function autoStep() {
662 if (!autoOn) return;
663 if (phase === 1) playChime('arrive');
664 if (phase === 2) playChime('door_open');
665 if (phase === 3) playChime('door_close');
666 updateDisplay();
667
668 const L = LINES[curLine];
669 const s = L.stations[curIdx];
670 const minDur = PHASE_MS[phase];
671 const hasTts = ttsOn && (phase === 0 || phase === 1 || phase === 2);
672 const thisPhase = phase;
673 phase = (phase + 1) % 4;
674 if (phase === 0) { curIdx = (curIdx + 1) % L.stations.length; playChime('depart'); }
675
676 if (hasTts) {
677 let timerDone = false, ttsDone = false, proceeded = false;
678 function tryProceed() {
679 if (proceeded) return;
680 if (timerDone && ttsDone) { proceeded = true; autoTimer = setTimeout(autoStep, 300); }
681 }
682 const minWait = thisPhase === 0 ? 4000 : thisPhase === 2 ? 3000 : 2000;
683 setTimeout(() => { timerDone = true; tryProceed(); }, minWait);
684 speakLines(getTtsLines(s, L), () => { ttsDone = true; tryProceed(); });
685 } else {
686 autoTimer = setTimeout(autoStep, minDur);
687 }
688 }
689
690 function startAuto() {
691 clearTimeout(autoTimer); autoOn = true;
692 document.getElementById('btnAuto').classList.add('on');
693 phase = 0; autoStep();
694 }
695 function stopAuto() {
696 clearTimeout(autoTimer); autoOn = false;
697 document.getElementById('btnAuto').classList.remove('on');
698 }
699
700 document.getElementById('btnPrev').onclick = () => {
701 stopAuto(); phase = 0; if (curIdx > 0) curIdx--; updateDisplay();
702 };
703 document.getElementById('btnNext').onclick = () => {
704 stopAuto(); phase = 0;
705 if (curIdx < LINES[curLine].stations.length - 1) curIdx++;
706 updateDisplay();
707 };
708 document.getElementById('btnAuto').onclick = () => { autoOn ? stopAuto() : startAuto(); };
709 document.getElementById('btnDoor').onclick = () => { stopAuto(); phase = doorOpen ? 3 : 2; updateDisplay(); };
710 document.getElementById('btnTts').onclick = () => {
711 ttsOn = !ttsOn;
712 const btn = document.getElementById('btnTts');
713 btn.textContent = ttsOn ? '🔊 음성 안내 ON' : '🔊 음성 안내 OFF';
714 btn.classList.toggle('tts-on', ttsOn);
715 if (!ttsOn && window.speechSynthesis) window.speechSynthesis.cancel();
716 };
717
718 buildSelector(); switchLine(); buildAds(); renderMap(); updateDisplay();
719 setTimeout(startAuto, 1000);
720 </script>
721 </body>
722 </html>
723