본문으로 건너뛰기
Public Source Viewer

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

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

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