242 lines
9.1 KiB
TypeScript
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());
|
|
}
|
|
});
|
|
}
|