import { bucharestClock } from '@/lib/operations'; import { maintenanceClock } from '@/journey/maintenance-progress'; import { quantiseRoute } from '@/journey/tab-feed'; /** * DASHCAM telemetry and the postcard (lazy dashcam chunk only). Every value * is real: the route progress, the chapter, the exit, the Bucharest clock * (the 03:12 window's own clock inside Infrastructure) and the identity. */ export interface DashcamTelemetry { sectionId: string; /** Navigation label, e.g. "Infrastructure". */ sectionLabel: string; /** Scene chapter, e.g. "pre-dawn". */ chapter: string; /** Exit marker, e.g. "04". */ exit: string; /** Whole percent of the route travelled. */ routePercent: number; /** HH:MM. */ clock: string; name: string; city: string; } /** Bucharest, as the dash plate shows it. */ export const DASHCAM_COORDINATES = "N44°26' E26°06'"; export const POSTCARD_WIDTH = 1600; export const POSTCARD_HEIGHT = 900; /** Whole percent on the tab title's 5% step, so the frame, the dashcam's * title and the route title never disagree (47% vs 45%). */ export const routePercent = (progress: number): number => Math.round(quantiseRoute(progress) * 100); /** Live Bucharest time; inside Infrastructure the maintenance window's clock. */ export function dashcamClock(sectionId: string, maintenance: number | null, now: Date): string { const live = (): string => bucharestClock(now); return sectionId === 'infrastructure' && maintenance !== null ? maintenanceClock(maintenance, live) : live(); } /** document.title while open, e.g. "DASHCAM · 47% · INFRASTRUCTURE · 03:51". */ export const dashcamTitle = (t: Pick): string => `DASHCAM · ${t.routePercent}% · ${t.sectionLabel.toUpperCase()} · ${t.clock}`; /** e.g. "night-shift-infrastructure-0351.png". */ export function postcardFilename(sectionId: string, clock: string): string { const section = sectionId.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '') || 'route'; const digits = clock.replace(/\D/gu, '').slice(0, 4).padStart(4, '0'); return `night-shift-${section}-${digits}.png`; } export const postcardCredit = (name: string, city: string): string => `NIGHT SHIFT · ${name.toUpperCase()} · ${city.toUpperCase()}`; /* One vocabulary in the frame: the navigation's exit and chapter name (the scene's internal chapter ids, e.g. "pre-dawn", are not shown). */ export const frameTopLeft = (t: DashcamTelemetry): string => `DASHCAM · EXIT ${t.exit}`; export const frameBottomLeft = (t: DashcamTelemetry): string => `ROUTE ${t.routePercent}% · ${t.sectionLabel.toUpperCase()}`; /** The source rectangle that covers a dw x dh frame from a sw x sh image, centred. */ export function coverCrop(sw: number, sh: number, dw: number, dh: number): { sx: number; sy: number; sw: number; sh: number } { if (!(sw > 0 && sh > 0 && dw > 0 && dh > 0)) return { sx: 0, sy: 0, sw: Math.max(0, sw), sh: Math.max(0, sh) }; const scale = Math.max(dw / sw, dh / sh); const cropW = dw / scale; const cropH = dh / scale; return { sx: (sw - cropW) / 2, sy: (sh - cropH) / 2, sw: cropW, sh: cropH }; } /** Widest postcard written: a 2x buffer at 1440 is downsampled to this. */ export const POSTCARD_MAX_WIDTH = 2560; /** * The postcard's size for a sw x sh drawing buffer: the largest 16:9 crop of * the buffer at its own pixels, never upscaled (a 1440 x 900 buffer stretched * to 1600 read soft), downsampled only beyond POSTCARD_MAX_WIDTH. */ export function postcardSize(sw: number, sh: number): { w: number; h: number } { if (!(sw > 0 && sh > 0)) return { w: POSTCARD_WIDTH, h: POSTCARD_HEIGHT }; const w = Math.min(POSTCARD_MAX_WIDTH, sw, (sh * 16) / 9); return { w: Math.round(w), h: Math.round((w * 9) / 16) }; } /** Frame chrome geometry for a w x h postcard (proportional to 1600 x 900). */ export function postcardLayout(w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT) { const unit = Math.min(w / POSTCARD_WIDTH, h / POSTCARD_HEIGHT); const inset = Math.round(44 * unit); const pad = Math.round(22 * unit); const font = Math.round(21 * unit); return { inset, bracket: Math.round(70 * unit), stroke: Math.max(1, Math.round(3 * unit)), font, small: Math.round(15 * unit), left: inset + pad, right: w - inset - pad, top: inset + pad + font, bottom: h - inset - pad, lineGap: Math.round(font * 1.35), dot: Math.round(7 * unit), credit: h - Math.round(16 * unit), }; } /** The 2D context surface the postcard uses (a stub in tests). */ export type PostcardContext = Pick< CanvasRenderingContext2D, 'drawImage' | 'fillRect' | 'fillText' | 'beginPath' | 'moveTo' | 'lineTo' | 'stroke' | 'arc' | 'fill' | 'createLinearGradient' | 'save' | 'restore' > & { fillStyle: unknown; strokeStyle: unknown; lineWidth: number; font: string; textAlign: CanvasTextAlign; textBaseline: CanvasTextBaseline; shadowColor?: string; shadowBlur?: number; }; type Source = CanvasImageSource & { width: number; height: number }; const MONO = "'Geist Mono Variable', ui-monospace, monospace"; export function drawPostcard(ctx: PostcardContext, source: Source, t: DashcamTelemetry, w = POSTCARD_WIDTH, h = POSTCARD_HEIGHT): void { const crop = coverCrop(source.width, source.height, w, h); ctx.fillStyle = '#05030a'; ctx.fillRect(0, 0, w, h); if (crop.sw > 0 && crop.sh > 0) ctx.drawImage(source, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, w, h); const l = postcardLayout(w, h); // A soft fall-off at the top and bottom keeps the telemetry legible. for (const [y0, y1] of [[0, h * 0.2], [h, h * 0.8]] as const) { const shade = ctx.createLinearGradient(0, y0, 0, y1); shade.addColorStop(0, 'rgba(4, 2, 10, 0.55)'); shade.addColorStop(1, 'rgba(4, 2, 10, 0)'); ctx.fillStyle = shade; ctx.fillRect(0, Math.min(y0, y1), w, Math.abs(y1 - y0)); } // Corner brackets. ctx.strokeStyle = 'rgba(244, 238, 255, 0.92)'; ctx.lineWidth = l.stroke; const a = l.inset; const b = l.bracket; for (const [x, y, dx, dy] of [[a, a, 1, 1], [w - a, a, -1, 1], [a, h - a, 1, -1], [w - a, h - a, -1, -1]] as const) { ctx.beginPath(); ctx.moveTo(x, y + dy * b); ctx.lineTo(x, y); ctx.lineTo(x + dx * b, y); ctx.stroke(); } ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.6)'; ctx.shadowBlur = Math.round(l.font * 0.4); ctx.font = `500 ${l.font}px ${MONO}`; ctx.textBaseline = 'alphabetic'; // REC dot, then the top-left plate. ctx.fillStyle = '#ff3b4e'; ctx.beginPath(); ctx.arc(l.left + l.dot, l.top - l.font * 0.35, l.dot, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#f4eeff'; ctx.textAlign = 'left'; ctx.fillText(`REC ${frameTopLeft(t)}`, l.left + l.dot * 3, l.top); ctx.fillText(frameBottomLeft(t), l.left, l.bottom); ctx.textAlign = 'right'; ctx.fillText(t.clock, l.right, l.top); ctx.fillText(t.name.toUpperCase(), l.right, l.bottom); ctx.fillText(DASHCAM_COORDINATES, l.right, l.bottom - l.lineGap); ctx.font = `500 ${l.small}px ${MONO}`; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(255, 214, 147, 0.95)'; ctx.fillText(postcardCredit(t.name, t.city), w / 2, l.credit); ctx.restore(); } function openInTab(url: string): boolean { // Browsers refuse top-level navigation to data: URLs; show it in a blank tab. const tab = window.open('', '_blank'); if (tab === null) return false; const image = tab.document.createElement('img'); image.src = url; image.alt = 'Night Shift dashcam postcard'; tab.document.body.append(image); return true; } export type PostcardOutcome = 'download' | 'tab' | 'failed'; /** * Draws the postcard synchronously (the WebGL buffer is only valid in the * capture task), then encodes and downloads it. Never throws. */ export function savePostcard(source: Source, t: DashcamTelemetry): Promise { let canvas: HTMLCanvasElement; try { canvas = document.createElement('canvas'); const { w, h } = postcardSize(source.width, source.height); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); if (ctx === null) return Promise.resolve('failed'); ctx.imageSmoothingQuality = 'high'; drawPostcard(ctx as unknown as PostcardContext, source, t, w, h); } catch { return Promise.resolve('failed'); } const fallback = (): PostcardOutcome => { try { return openInTab(canvas.toDataURL('image/png')) ? 'tab' : 'failed'; } catch { return 'failed'; } }; return new Promise((resolve) => { try { canvas.toBlob((blob) => { if (blob === null) { resolve(fallback()); return; } try { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = postcardFilename(t.sectionId, t.clock); link.hidden = true; document.body.append(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 30_000); resolve('download'); } catch { resolve(fallback()); } }, 'image/png'); } catch { resolve(fallback()); } }); }