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