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
<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