/** * Draws the screen payload as an 800x480 SVG. * * This module draws and nothing else. Every string it places has already been * computed and translated by the view model, so there is no business logic to * disagree about — which is also why its output can be snapshotted as text * rather than compared as pixels. * * e-ink discipline: no greys, no shadows, no gradients, no hairlines. * Hierarchy comes from size, weight and solid inversion. */ import satori from 'satori'; import type { ScreenPayload, ScreenWeekDay } from './contract'; import { loadScreenFonts, type SansFamily } from './fonts'; import { fitFontSize } from './fit'; import { h, type ElementNode } from './h'; import { LOGO_HEIGHT, LOGO_WIDTH, loadLogoDataUri } from './logo'; import { BANNER_HEIGHT, BLACK, SIDE_BAND_WIDTH, HEADER_HEIGHT, PADDING, RULE, SANS, SCREEN_HEIGHT, SCREEN_WIDTH, SERIF, WEEK_HEIGHT, WHITE, type WeekPlacement, } from './layout'; export function screenTree( payload: ScreenPayload, logo: string | null = null, placement: WeekPlacement = 'right', ): ElementNode { const root = { display: 'flex', width: SCREEN_WIDTH, height: SCREEN_HEIGHT, backgroundColor: WHITE, color: BLACK, fontFamily: SANS, } as const; if (placement === 'bottom') { return h( 'div', { style: { ...root, flexDirection: 'column' } }, header(payload, logo), main(payload, SCREEN_WIDTH), weekStrip(payload.week), banner(payload), ); } const column = h( 'div', { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, flexBasis: 0 } }, header(payload, logo), main(payload, SCREEN_WIDTH - SIDE_BAND_WIDTH), banner(payload), ); const band = weekBand(payload.week, placement); return h( 'div', { style: { ...root, flexDirection: 'row' } }, ...(placement === 'left' ? [band, column] : [column, band]), ); } /** * The week as a side band: one row per day, the label and the hours on the * same line. A row is 68px tall and the band 232px wide, so the type can be * a third larger than the bottom strip allows. */ function weekBand(week: ScreenWeekDay[], placement: 'right' | 'left'): ElementNode { return h( 'div', { style: { display: 'flex', flexDirection: 'column', width: SIDE_BAND_WIDTH, ...(placement === 'right' ? { borderLeft: `${RULE}px solid ${BLACK}` } : { borderRight: `${RULE}px solid ${BLACK}` }), }, }, ...week.map((day) => weekRow(day)), ); } function weekRow(day: ScreenWeekDay): ElementNode { const inverted = day.is_today; const lines = day.is_closed ? ['—'] : day.hours_text.split(' · ').map(tightHours); return h( 'div', { style: { display: 'flex', alignItems: 'center', flexGrow: 1, flexBasis: 0, paddingLeft: 14, paddingRight: 14, backgroundColor: inverted ? BLACK : WHITE, color: inverted ? WHITE : BLACK, overflow: 'hidden', }, }, h( 'div', { style: { display: 'flex', width: 62, fontSize: 23, fontWeight: 700 } }, `${day.label_fr}${day.is_exception ? '*' : ''}`, ), h( 'div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'flex-end', flexGrow: 1, }, }, ...lines.map((line) => h( 'div', { style: { display: 'flex', // A row is 68px tall; three lines have to share it. fontSize: Math.min( lines.length >= 3 ? 17 : 22, fitFontSize(line, { available: 142, preferred: 22, minimum: 14 }), ), lineHeight: 1.2, }, }, line, ), ), ), ); } function header(payload: ScreenPayload, logo: string | null): ElementNode { return h( 'div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', height: HEADER_HEIGHT, paddingLeft: PADDING, paddingRight: PADDING, borderBottom: `${RULE}px solid ${BLACK}`, }, }, logo ? h('img', { src: logo, width: LOGO_WIDTH, height: LOGO_HEIGHT, style: { display: 'flex' }, }) : h( 'div', { style: { display: 'flex', fontFamily: SERIF, fontSize: 34, letterSpacing: 6 } }, payload.shop_name, ), h('div', { style: { display: 'flex', fontSize: 22 } }, payload.today.date_fr), ); } function main(payload: ScreenPayload, width: number): ElementNode { const { today } = payload; return h( 'div', { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', paddingLeft: PADDING, paddingRight: PADDING, }, }, h( 'div', { style: { display: 'flex', fontSize: 92, fontWeight: 700, lineHeight: 1 } }, today.status_fr, ), today.hours_text ? h( 'div', { style: { display: 'flex', // Three periods with no round hour would otherwise wrap and push // the banner off the bottom edge. fontSize: fitFontSize(today.hours_text, { available: width - 2 * PADDING, preferred: 36, minimum: 22, }), fontWeight: 600, marginTop: 10, }, }, today.hours_text, ) : null, nextChangeLine(payload, width), ); } /** * "Ferme à 18:30", then the English underneath. * * Stacked rather than side by side: the main block is narrower now that the * week runs down the right, and a row put the two languages in competition * for the width — the French wrapped and left the separator stranded on the * next line with half the English. Two lines also match the banner, where * French above English is already the pattern. */ function nextChangeLine(payload: ScreenPayload, width: number): ElementNode | null { const { next_change_fr: fr, next_change_en: en } = payload.today; if (!fr && !en) { return null; } const available = width - 2 * PADDING; return h( 'div', { style: { display: 'flex', flexDirection: 'column', marginTop: 12 } }, fr ? h( 'div', { style: { display: 'flex', fontSize: fitFontSize(fr, { available, preferred: 24, minimum: 18 }), }, }, fr, ) : null, en ? h( 'div', { style: { display: 'flex', fontSize: fitFontSize(en, { available, preferred: 20, minimum: 15 }), marginTop: 2, }, }, en, ) : null, ); } function weekStrip(week: ScreenWeekDay[]): ElementNode { return h( 'div', { style: { display: 'flex', height: WEEK_HEIGHT, borderTop: `${RULE}px solid ${BLACK}`, }, }, ...week.map((day) => weekColumn(day)), ); } function weekColumn(day: ScreenWeekDay): ElementNode { // Today is inverted rather than tinted: a solid block is the only emphasis // a 1-bit panel renders cleanly. const inverted = day.is_today; // A column is only 114px wide. The spaced en dash reads better in the main // block, but here it is the difference between fitting and clipping. const lines = day.is_closed ? ['—'] : day.hours_text.split(' · ').map(tightHours); return h( 'div', { style: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flexGrow: 1, flexBasis: 0, paddingTop: 8, paddingBottom: 8, paddingLeft: 6, paddingRight: 6, backgroundColor: inverted ? BLACK : WHITE, color: inverted ? WHITE : BLACK, }, }, h( 'div', { style: { display: 'flex', fontSize: 20, fontWeight: 700, marginBottom: 5 } }, `${day.label_fr}${day.is_exception ? '*' : ''}`, ), ...lines.map((line) => h( 'div', { style: { display: 'flex', fontSize: fitFontSize(line, { available: 102, preferred: 19, minimum: 14 }), lineHeight: 1.3, }, }, line, ), ), ); } function banner(payload: ScreenPayload): ElementNode | null { if (!payload.banner) { return null; } const { text_fr: fr, text_en: en } = payload.banner; return h( 'div', { style: { display: 'flex', flexDirection: 'column', justifyContent: 'center', height: BANNER_HEIGHT, paddingLeft: PADDING, paddingRight: PADDING, backgroundColor: BLACK, color: WHITE, }, }, h('div', { style: { display: 'flex', fontSize: 25, fontWeight: 600 } }, fr), en ? h('div', { style: { display: 'flex', fontSize: 19, marginTop: 4 } }, en) : null, ); } export type RenderOptions = { /** Which sans family to set the panel in; see lib/screen/fonts.ts. */ family?: SansFamily; /** Where the week sits; see lib/screen/layout.ts. */ placement?: WeekPlacement; }; /** Renders the payload to an SVG string, exactly 800x480. */ export async function renderScreenSvg( payload: ScreenPayload, options: RenderOptions = {}, ): Promise { const [fonts, logo] = await Promise.all([ loadScreenFonts(options.family), loadLogoDataUri(), ]); return satori(screenTree(payload, logo, options.placement) as unknown as React.ReactNode, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT, fonts, }); } /** * The week's own spelling of an opening period. * * Only the spaces around the dash go: "10:00–18:30" instead of * "10:00 – 18:30". The times themselves stay whole — a shop's opening hour is * not a detail to abbreviate — and losing two spaces is enough to hold 22pt * in a 142px column. */ function tightHours(slot: string): string { return slot.replace(' – ', '–'); }