Public Source Viewer
비나래아카이브 개발자 포털
실제 서비스 구조를 살펴볼 수 있는 공개용 코드 뷰어입니다. 인증, 세션, 외부 연동, 토큰, 관리자 식별 등 보안상 민감한 구현은 파일 단위 또는 줄 단위로 검열됩니다.
public/js/hinana-rail-ticket.js
공개 가능
1
(function (global) {
2
const width = 1600, height = 800;
3
const fontFamily = '"Noto Sans KR", system-ui, sans-serif';
4
const value = (text, fallback = '미기록') => typeof text === 'string' && text.trim() ? text.trim() : fallback;
5
function summary(trip) {
6
return `${value(trip.trainType)} ${value(trip.trainNumber, '')}\n${value(trip.departure)} ${value(trip.departureTime)} → ${value(trip.arrival)} ${value(trip.arrivalTime)}\n${value(trip.seatClass)} · ${value(trip.date).replace(/-/g, '.')}`;
7
}
8
function filename(trip) {
9
const safe = text => value(text, '').replace(/[\\/:*?"<>|\x00-\x1f]/g, '').slice(0, 40);
10
return `HinanaRail-${safe(trip.date)}-${safe(trip.departure)}-${safe(trip.arrival)}.png`;
11
}
12
function render(canvas, trip, brand) {
13
canvas.width = width; canvas.height = height;
14
const ctx = canvas.getContext('2d');
15
if (!ctx) throw new Error('기념 승차권을 그릴 수 없습니다.');
16
ctx.textBaseline = 'top';
17
const ink = '#18202b', gold = '#c5a059', muted = '#687586', paper = '#f7f9fb';
18
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height);
19
ctx.fillStyle = ink; ctx.fillRect(1160, 0, 440, height);
20
ctx.fillStyle = gold; ctx.fillRect(0, 0, width, 8);
21
ctx.strokeStyle = '#dce1e7'; ctx.lineWidth = 2; ctx.strokeRect(1, 9, width - 2, height - 10);
22
// Fit measured text into fixed regions so unusually long station names stay inside the ticket.
23
function write(text, x, y, maxWidth, size, color = ink, weight = 700, maxLines = 1) {
24
let lines;
25
const wrap = () => {
26
ctx.font = `${weight} ${size}px ${fontFamily}`;
27
const result = []; let line = '';
28
for (const char of Array.from(String(text))) {
29
if (line && ctx.measureText(line + char).width > maxWidth) { result.push(line); line = ''; }
30
line += char;
31
}
32
result.push(line); return result;
33
};
34
do { lines = wrap(); if (lines.length <= maxLines) break; size -= 1; } while (size > 6);
35
ctx.fillStyle = color;
36
lines.slice(0, maxLines).forEach((line, i) => ctx.fillText(line, x, y + i * size * 1.25));
37
}
38
if (brand?.naturalWidth) ctx.drawImage(brand, 64, 49, 340, 340 * brand.naturalHeight / brand.naturalWidth);
39
else write('비나래 아카이브', 64, 51, 500, 34);
40
write('히나나레일 / JOURNEY COLLECTION', 64, 117, 700, 18, muted, 500);
41
write('기념 승차권', 864, 60, 230, 24, muted, 500);
42
ctx.fillStyle = gold; ctx.fillRect(64, 172, 52, 4);
43
write(`${value(trip.trainType)} ${value(trip.trainNumber, '')}`.trim(), 64, 199, 1030, 44);
44
write('출발 / DEPARTURE', 64, 289, 440, 19, muted, 500);
45
write('도착 / ARRIVAL', 648, 289, 446, 19, muted, 500);
46
write(value(trip.departure), 64, 335, 440, 88, ink, 700, 2);
47
write('→', 542, 353, 84, 60, gold);
48
write(value(trip.arrival), 648, 335, 446, 88, ink, 700, 2);
49
write(value(trip.departureTime), 64, 536, 440, 54);
50
write(value(trip.arrivalTime), 648, 536, 446, 54);
51
ctx.fillStyle = paper; ctx.fillRect(64, 634, 1030, 91);
52
ctx.fillStyle = gold; ctx.fillRect(64, 634, 4, 91);
53
write(`${value(trip.seatClass)} · ${value(trip.date).replace(/-/g, '.')}`, 92, 658, 974, 32);
54
write('한 번의 여행, 하나의 기록.', 64, 753, 750, 18, muted, 500);
55
write('HINANA.MOE', 888, 753, 206, 16, muted, 500);
56
ctx.setLineDash([6, 10]); ctx.strokeStyle = '#8c7955'; ctx.lineWidth = 2;
57
ctx.beginPath(); ctx.moveTo(1160, 24); ctx.lineTo(1160, 776); ctx.stroke(); ctx.setLineDash([]);
58
write('JOURNEY ARCHIVE', 1220, 57, 320, 19, '#d4b47a', 500);
59
write('기념 승차권', 1220, 105, 320, 38, '#ffffff');
60
ctx.fillStyle = gold; ctx.fillRect(1220, 183, 320, 2);
61
write('이용 날짜', 1220, 223, 320, 19, '#a3afbd', 500);
62
write(value(trip.date).replace(/-/g, '.'), 1220, 262, 320, 35, '#ffffff');
63
write('좌석 등급', 1220, 355, 320, 19, '#a3afbd', 500);
64
write(value(trip.seatClass), 1220, 394, 320, 42, '#d4b47a', 700, 2);
65
write('호차 · 좌석', 1220, 530, 320, 19, '#a3afbd', 500);
66
write(value(trip.seat), 1220, 569, 320, 28, '#ffffff', 700, 2);
67
ctx.fillStyle = '#384250'; ctx.fillRect(1220, 672, 320, 1);
68
write('탑승 불가 · 기념용', 1220, 701, 320, 24, '#d4b47a');
69
write('NOT VALID FOR TRAVEL', 1220, 752, 320, 16, '#a3afbd', 500);
70
canvas.setAttribute('aria-label', summary(trip) + '\n기념용, 실제 승차권 아님');
71
}
72
const api = { render, summary, filename };
73
if (typeof module !== 'undefined' && module.exports) module.exports = api;
74
else global.HinanaRailTicket = api;
75
})(globalThis);
76