Files
Portfolio/src/lib/dashcam-postcard.ts

242 lines
9.1 KiB
TypeScript

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<DashcamTelemetry, 'routePercent' | 'sectionLabel' | 'clock'>): 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<PostcardOutcome> {
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());
}
});
}