feat: dashcam photo mode with telemetry frame, lens drift and postcard capture
This commit is contained in:
224
src/lib/dashcam-postcard.ts
Normal file
224
src/lib/dashcam-postcard.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { bucharestClock } from '@/lib/operations';
|
||||
import { maintenanceClock } from '@/journey/maintenance-progress';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export const routePercent = (progress: number): number =>
|
||||
Math.round(Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 0)) * 100);
|
||||
|
||||
export const chapterLabel = (chapter: string): string => chapter.replaceAll('-', ' ').toUpperCase();
|
||||
|
||||
/** 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()}`;
|
||||
|
||||
export const frameTopLeft = (t: DashcamTelemetry): string =>
|
||||
`DASHCAM · ${chapterLabel(t.chapter)} · 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 };
|
||||
}
|
||||
|
||||
/** 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');
|
||||
canvas.width = POSTCARD_WIDTH;
|
||||
canvas.height = POSTCARD_HEIGHT;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx === null) return Promise.resolve('failed');
|
||||
drawPostcard(ctx as unknown as PostcardContext, source, t);
|
||||
} 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user