Files
ita-ito-horaires/lib/screen/render.ts
T
vliaudatandClaude Opus 5 185610ee61 feat: move the week down the right-hand side of the panel
A bottom strip gave each day a 114px column, and that width was what
capped the type size. A side band gives 142px of usable width and a 68px
row, so the hours go from 19pt to 22pt, the day labels from 20 to 23,
and each day fits on one line — "Mar  11:00–18:00" reads at a glance
where a column had to be read downwards.

Right rather than left, for two reasons the renders make plain: on the
left the logo is pushed into the middle and loses its place as a
signature, and the eye meets the week before the day's status. OUVERT is
what has to be read first from the pavement.

The times stay whole. Only the spaces around the dash go, which is
enough to hold 22pt without abbreviating anything: a shop's opening hour
is not a detail to shorten.

The idea came from the shop owner, and testing it surfaced a defect that
had been in production all along. Rendering the worst case the data
model allows — three periods in a day, none on a round hour — showed the
day's hours wrapping, pushing the layout down, and the English line of
the banner falling off the bottom edge. A panel does not scroll and does
not reflow: what does not fit is lost.

Type size is now fitted to the space available, with a floor below which
it will not go, and nothing may leave its box. The width estimate is
deliberately crude: measuring glyphs would mean loading the font in that
module and would still be an estimate, since the renderer applies its
own kerning. Erring small costs a point of type, erring large costs a
broken screen.

Three periods a day will reportedly never happen. The protection stays
anyway — it costs nothing and removes the need to remember.

`npm run screen:fonts` renders every arrangement and both alternative
typefaces, including the worst case, because choosing type for a 1-bit
panel is done by looking at it thresholded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 23:07:06 +02:00

362 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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),
);
}
function nextChangeLine(payload: ScreenPayload): ElementNode | null {
const { next_change_fr: fr, next_change_en: en } = payload.today;
if (!fr && !en) {
return null;
}
return h(
'div',
{ style: { display: 'flex', alignItems: 'baseline', marginTop: 12 } },
fr ? h('div', { style: { display: 'flex', fontSize: 24 } }, fr) : null,
fr && en ? h('div', { style: { display: 'flex', fontSize: 24, paddingLeft: 10, paddingRight: 10 } }, '·') : null,
en ? h('div', { style: { display: 'flex', fontSize: 20 } }, 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<string> {
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(' – ', '–');
}