@openvidstudio/styles 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/depth.ts ADDED
@@ -0,0 +1,335 @@
1
+ import { createRng } from "./rng";
2
+ import { ACCENTS, hexToRgb, VOID, withAlpha } from "./tokens";
3
+ import type { Sprite, DrawSpriteOptions } from "./sprites";
4
+ import { drawSprite } from "./sprites";
5
+ import type { Rect } from "./layout";
6
+
7
+ type AnyCanvas = HTMLCanvasElement | OffscreenCanvas;
8
+
9
+ function makeCanvas(w: number, h: number): { canvas: AnyCanvas; ctx: CanvasRenderingContext2D } {
10
+ const canvas: AnyCanvas =
11
+ typeof document !== "undefined" ? Object.assign(document.createElement("canvas"), { width: w, height: h }) : new OffscreenCanvas(w, h);
12
+ const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
13
+ return { canvas, ctx };
14
+ }
15
+
16
+ const clamp01 = (v: number): number => Math.max(0, Math.min(1, v));
17
+
18
+ // ---------------------------------------------------------------- camera
19
+
20
+ export interface Camera {
21
+ /** 1 is no zoom. */
22
+ scale: number;
23
+ /** Pixel offset of the view centre. */
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ export const NO_CAMERA: Camera = { scale: 1, x: 0, y: 0 };
29
+
30
+ export interface DollyOptions {
31
+ from?: Camera;
32
+ to: Camera;
33
+ /** Seconds the move starts at (default 0) and how long it takes. */
34
+ start?: number;
35
+ duration: number;
36
+ /** Easing of the 0..1 progress; default is a smooth ease in and out. */
37
+ ease?: (p: number) => number;
38
+ }
39
+
40
+ export const easeInOut = (p: number): number => p * p * (3 - 2 * p);
41
+
42
+ /** The camera at time t: scale and offset move from one pose to another. A pure function of t. */
43
+ export function dolly(t: number, o: DollyOptions): Camera {
44
+ const from = o.from ?? NO_CAMERA;
45
+ const p = clamp01((t - (o.start ?? 0)) / o.duration);
46
+ const e = (o.ease ?? easeInOut)(p);
47
+ return {
48
+ scale: from.scale + (o.to.scale - from.scale) * e,
49
+ x: from.x + (o.to.x - from.x) * e,
50
+ y: from.y + (o.to.y - from.y) * e,
51
+ };
52
+ }
53
+
54
+ /** Applies a camera to the context (zoom about the canvas centre, then offset). Call inside save/restore. */
55
+ export function applyCamera(ctx: CanvasRenderingContext2D, cam: Camera, width: number, height: number): void {
56
+ ctx.translate(width / 2, height / 2);
57
+ ctx.scale(cam.scale, cam.scale);
58
+ ctx.translate(-width / 2 - cam.x, -height / 2 - cam.y);
59
+ }
60
+
61
+ // ---------------------------------------------------------------- parallax
62
+
63
+ export type LayerKind = "stars" | "hills" | "towers" | "dots";
64
+
65
+ export interface LayerSpec {
66
+ kind: LayerKind;
67
+ color: string;
68
+ /** 0 is the far plane, 1 the nearest. Near layers move faster and zoom more with the camera. */
69
+ depth: number;
70
+ /** Blur strength for far layers, in px of the final image; 0 or omitted is sharp. */
71
+ blur?: number;
72
+ /** Items in one tile (stars, towers, dots). */
73
+ count?: number;
74
+ /** How tall the layer's shapes are, as a share of the height. */
75
+ amplitude?: number;
76
+ }
77
+
78
+ export interface ParallaxOptions {
79
+ width: number;
80
+ height: number;
81
+ seed?: number;
82
+ /** Three to five layers, drawn far to near. Defaults to a four-layer set in the given accent. */
83
+ layers?: LayerSpec[];
84
+ /** Where the ground line sits, as a share of the height (default 0.62). */
85
+ horizon?: number;
86
+ /** Scroll speed of the nearest layer, px per second (default 40). */
87
+ speed?: number;
88
+ accent?: string;
89
+ }
90
+
91
+ interface Built {
92
+ spec: LayerSpec;
93
+ canvas: AnyCanvas;
94
+ }
95
+
96
+ export function defaultLayers(accent: string = ACCENTS.math): LayerSpec[] {
97
+ const [r, g, b] = hexToRgb(accent);
98
+ const tint = (k: number, a = 1): string =>
99
+ `rgba(${Math.round(11 + (r - 11) * k)},${Math.round(13 + (g - 13) * k)},${Math.round(20 + (b - 20) * k)},${a})`;
100
+ return [
101
+ { kind: "stars", color: tint(0.55), depth: 0.08, count: 90, blur: 2 },
102
+ { kind: "hills", color: tint(0.16), depth: 0.25, amplitude: 0.16, blur: 6 },
103
+ { kind: "towers", color: tint(0.26), depth: 0.5, count: 12, amplitude: 0.22, blur: 2 },
104
+ { kind: "hills", color: tint(0.38), depth: 0.8, amplitude: 0.1 },
105
+ ];
106
+ }
107
+
108
+ /**
109
+ * A layered background that scrolls at different rates. Layers are pre-rendered once (far ones
110
+ * blurred) into tiles that wrap, and draw() is then a handful of drawImage calls. What a frame shows is
111
+ * a function of (options, t, camera) only.
112
+ */
113
+ export class Parallax {
114
+ readonly width: number;
115
+ readonly height: number;
116
+ readonly horizon: number;
117
+ readonly speed: number;
118
+ readonly tileWidth: number;
119
+ private layers: Built[];
120
+
121
+ constructor(o: ParallaxOptions) {
122
+ this.width = o.width;
123
+ this.height = o.height;
124
+ this.horizon = Math.round(o.height * (o.horizon ?? 0.62));
125
+ this.speed = o.speed ?? 40;
126
+ this.tileWidth = o.width * 2;
127
+ const specs = (o.layers ?? defaultLayers(o.accent)).slice().sort((a, b) => a.depth - b.depth);
128
+ if (specs.length < 3 || specs.length > 5) throw new Error(`Parallax takes three to five layers; got ${specs.length}.`);
129
+ const rng = createRng(o.seed ?? 1);
130
+ this.layers = specs.map((spec) => ({ spec, canvas: this.render(spec, createRng(Math.floor(rng.next() * 2 ** 31))) }));
131
+ }
132
+
133
+ private render(spec: LayerSpec, rng: ReturnType<typeof createRng>): AnyCanvas {
134
+ const W = this.tileWidth;
135
+ const H = this.height;
136
+ const { canvas, ctx } = makeCanvas(W, H);
137
+ ctx.fillStyle = spec.color;
138
+ const amp = (spec.amplitude ?? 0.15) * H;
139
+ const base = this.horizon;
140
+ if (spec.kind === "stars") {
141
+ for (let i = 0; i < (spec.count ?? 80); i++) {
142
+ const size = rng.int(1, 3);
143
+ ctx.fillRect(rng.int(0, W - size), rng.int(0, Math.max(1, base - 20)), size, size);
144
+ }
145
+ } else if (spec.kind === "dots") {
146
+ for (let i = 0; i < (spec.count ?? 40); i++) {
147
+ const r = rng.int(2, 6);
148
+ ctx.beginPath();
149
+ ctx.arc(rng.range(0, W), rng.range(base * 0.4, base), r, 0, Math.PI * 2);
150
+ ctx.fill();
151
+ }
152
+ } else if (spec.kind === "hills") {
153
+ // Whole numbers of periods across the tile, so the right edge meets the left edge.
154
+ const waves = [1 + rng.int(1, 2), 3 + rng.int(0, 2), 7 + rng.int(0, 3)].map((n, i) => ({
155
+ n,
156
+ phase: rng.range(0, Math.PI * 2),
157
+ a: amp * [0.5, 0.3, 0.12][i],
158
+ }));
159
+ ctx.beginPath();
160
+ ctx.moveTo(0, H);
161
+ for (let x = 0; x <= W; x += 4) {
162
+ let y = base - amp * 0.5;
163
+ for (const w of waves) y -= w.a * Math.sin((x / W) * Math.PI * 2 * w.n + w.phase);
164
+ ctx.lineTo(x, y);
165
+ }
166
+ ctx.lineTo(W, H);
167
+ ctx.closePath();
168
+ ctx.fill();
169
+ } else {
170
+ const n = spec.count ?? 10;
171
+ const slot = W / n;
172
+ for (let i = 0; i < n; i++) {
173
+ const w = Math.round(slot * rng.range(0.4, 0.8));
174
+ const h = Math.round(amp * rng.range(0.35, 1));
175
+ const x = Math.round(i * slot + (slot - w) / 2);
176
+ ctx.fillRect(x, base - h, w, H - (base - h));
177
+ }
178
+ }
179
+ const blur = spec.blur ?? 0;
180
+ return blur > 0 ? this.blurred(canvas, blur) : canvas;
181
+ }
182
+
183
+ /** Blur by shrinking and enlarging with smoothing on: the same pixels everywhere, no filter support needed. */
184
+ private blurred(src: AnyCanvas, blur: number): AnyCanvas {
185
+ const f = 1 + blur;
186
+ const sw = Math.max(1, Math.round(src.width / f));
187
+ const sh = Math.max(1, Math.round(src.height / f));
188
+ const small = makeCanvas(sw, sh);
189
+ small.ctx.imageSmoothingEnabled = true;
190
+ small.ctx.imageSmoothingQuality = "high";
191
+ small.ctx.drawImage(src, 0, 0, sw, sh);
192
+ const out = makeCanvas(src.width, src.height);
193
+ out.ctx.imageSmoothingEnabled = true;
194
+ out.ctx.imageSmoothingQuality = "high";
195
+ out.ctx.drawImage(small.canvas, 0, 0, src.width, src.height);
196
+ return out.canvas;
197
+ }
198
+
199
+ get layerCount(): number {
200
+ return this.layers.length;
201
+ }
202
+
203
+ /** Draws every layer, far to near, at time t (seconds). The camera shifts and zooms each by its depth. */
204
+ draw(ctx: CanvasRenderingContext2D, t: number, camera: Camera = NO_CAMERA): void {
205
+ const W = this.width;
206
+ ctx.save();
207
+ for (const { spec, canvas } of this.layers) {
208
+ const k = 0.25 + spec.depth * 0.75;
209
+ const zoom = 1 + (camera.scale - 1) * spec.depth;
210
+ const scroll = (((t * this.speed * k + camera.x * spec.depth) % this.tileWidth) + this.tileWidth) % this.tileWidth;
211
+ ctx.save();
212
+ // Zoom about the horizon line at the screen centre, so near layers rush past and far ones barely move.
213
+ ctx.translate(W / 2, this.horizon);
214
+ ctx.scale(zoom, zoom);
215
+ ctx.translate(-W / 2, -this.horizon - camera.y * spec.depth);
216
+ ctx.drawImage(canvas, -scroll, 0);
217
+ ctx.drawImage(canvas, this.tileWidth - scroll, 0);
218
+ ctx.restore();
219
+ }
220
+ ctx.restore();
221
+ }
222
+ }
223
+
224
+ // ---------------------------------------------------------------- floor grid, fog, shadows
225
+
226
+ export interface FloorGridOptions {
227
+ /** y of the horizon, px. */
228
+ horizon: number;
229
+ /** x the lines converge at (default: the middle of the area). */
230
+ vanishX?: number;
231
+ color?: string;
232
+ /** Horizontal lines on screen at once (default 14) and radiating lines (default 16). */
233
+ rows?: number;
234
+ cols?: number;
235
+ /** Rows the grid scrolls toward the viewer per second (default 0.6). */
236
+ speed?: number;
237
+ lineWidth?: number;
238
+ /** 0..1: how strongly distant lines fade into the horizon (default 0.85). */
239
+ fog?: number;
240
+ }
241
+
242
+ /** A perspective floor: lines that converge at the horizon, scrolling toward the viewer with t. */
243
+ export function drawFloorGrid(ctx: CanvasRenderingContext2D, area: Rect, t: number, o: FloorGridOptions): void {
244
+ const color = o.color ?? ACCENTS.math;
245
+ const rows = o.rows ?? 14;
246
+ const cols = o.cols ?? 16;
247
+ const bottom = area.y + area.h;
248
+ const vx = o.vanishX ?? area.x + area.w / 2;
249
+ const fog = o.fog ?? 0.85;
250
+ const span = bottom - o.horizon;
251
+ const phase = (((t * (o.speed ?? 0.6)) % 1) + 1) % 1;
252
+ ctx.save();
253
+ ctx.beginPath();
254
+ ctx.rect(area.x, o.horizon, area.w, span);
255
+ ctx.clip();
256
+ ctx.lineWidth = o.lineWidth ?? 2;
257
+ for (let i = 0; i < rows; i++) {
258
+ const p = (i + phase) / rows;
259
+ const y = o.horizon + span * p * p;
260
+ ctx.strokeStyle = withAlpha(color, clamp01(p * 1.4) * (1 - fog) + clamp01(p) * fog);
261
+ ctx.beginPath();
262
+ ctx.moveTo(area.x, y);
263
+ ctx.lineTo(area.x + area.w, y);
264
+ ctx.stroke();
265
+ }
266
+ const spread = area.w * 1.6;
267
+ for (let i = 0; i <= cols; i++) {
268
+ const bx = vx + (i / cols - 0.5) * spread;
269
+ const g = ctx.createLinearGradient(0, o.horizon, 0, bottom);
270
+ g.addColorStop(0, withAlpha(color, 1 - fog));
271
+ g.addColorStop(1, withAlpha(color, 0.9));
272
+ ctx.strokeStyle = g;
273
+ ctx.beginPath();
274
+ ctx.moveTo(vx, o.horizon);
275
+ ctx.lineTo(bx, bottom);
276
+ ctx.stroke();
277
+ }
278
+ ctx.restore();
279
+ }
280
+
281
+ /** Depth fog: a band at the horizon that fades into the ground colour, so far things recede. */
282
+ export function drawFog(
283
+ ctx: CanvasRenderingContext2D,
284
+ area: Rect,
285
+ horizon: number,
286
+ o: { color?: string; strength?: number; reach?: number } = {},
287
+ ): void {
288
+ const color = o.color ?? VOID;
289
+ const strength = o.strength ?? 0.9;
290
+ const reach = (o.reach ?? 0.35) * (area.y + area.h - horizon);
291
+ const above = reach * 0.6;
292
+ const g = ctx.createLinearGradient(0, horizon - above, 0, horizon + reach);
293
+ g.addColorStop(0, withAlpha(color, 0));
294
+ g.addColorStop(above / (above + reach), withAlpha(color, strength));
295
+ g.addColorStop(1, withAlpha(color, 0));
296
+ ctx.save();
297
+ ctx.fillStyle = g;
298
+ ctx.fillRect(area.x, horizon - above, area.w, above + reach);
299
+ ctx.restore();
300
+ }
301
+
302
+ /** A hard-edged pixel ellipse on the ground: stacked fillRects, so it matches the sprites. */
303
+ export function drawShadow(
304
+ ctx: CanvasRenderingContext2D,
305
+ cx: number,
306
+ cy: number,
307
+ rx: number,
308
+ ry: number,
309
+ alpha = 0.4,
310
+ pixel = 2,
311
+ ): void {
312
+ const p = Math.max(1, Math.round(pixel));
313
+ ctx.save();
314
+ ctx.fillStyle = `rgba(0,0,0,${clamp01(alpha)})`;
315
+ for (let dy = -ry; dy < ry; dy += p) {
316
+ const k = Math.sqrt(Math.max(0, 1 - ((dy + p / 2) / ry) ** 2));
317
+ const half = Math.round((rx * k) / p) * p;
318
+ if (half > 0) ctx.fillRect(Math.round(cx - half), Math.round(cy + dy), half * 2, p);
319
+ }
320
+ ctx.restore();
321
+ }
322
+
323
+ /** A sprite's drop shadow: its silhouette offset down and right in a translucent black. Draw it before the sprite. */
324
+ export function drawSpriteShadow(
325
+ ctx: CanvasRenderingContext2D,
326
+ sprite: Sprite,
327
+ x: number,
328
+ y: number,
329
+ scale: number,
330
+ frame = 0,
331
+ o: { dx?: number; dy?: number; alpha?: number } & DrawSpriteOptions = {},
332
+ ): void {
333
+ const s = Math.max(1, Math.round(scale));
334
+ drawSprite(ctx, sprite, x + (o.dx ?? s * 2), y + (o.dy ?? s * 2), scale, frame, { ...o, tint: `rgba(0,0,0,${o.alpha ?? 0.35})` });
335
+ }
package/src/font.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The pixel font. Two paths, both offline-safe by default:
3
+ *
4
+ * 1. BITMAP_FONT: a 5x7 font authored for this package and drawn with fillRect (drawPixelText), so
5
+ * it needs no file and no network and is as crisp as the sprites. It is Apache-2.0 like the package.
6
+ * 2. PIXEL_FONT: the name and Google Fonts URL of "Press Start 2P" (SIL Open Font License 1.1) for text
7
+ * that goes through the normal font stack (fitText, captions). Nothing is vendored or fetched by
8
+ * this package: a lesson that wants it adds the stylesheet link, and text falls back to
9
+ * PIXEL_FONT.fallback when it is absent.
10
+ */
11
+ export const PIXEL_FONT = {
12
+ family: "Press Start 2P",
13
+ fallback: "monospace",
14
+ stack: '"Press Start 2P", monospace',
15
+ stylesheet: "https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap",
16
+ license: "SIL Open Font License 1.1",
17
+ } as const;
18
+
19
+ export const GLYPH_W = 5;
20
+ export const GLYPH_H = 7;
21
+
22
+ const SRC: Record<string, string> = {
23
+ A: ".###./#...#/#...#/#####/#...#/#...#/#...#",
24
+ B: "####./#...#/#...#/####./#...#/#...#/####.",
25
+ C: ".###./#...#/#..../#..../#..../#...#/.###.",
26
+ D: "####./#...#/#...#/#...#/#...#/#...#/####.",
27
+ E: "#####/#..../#..../####./#..../#..../#####",
28
+ F: "#####/#..../#..../####./#..../#..../#....",
29
+ G: ".###./#...#/#..../#.###/#...#/#...#/.###.",
30
+ H: "#...#/#...#/#...#/#####/#...#/#...#/#...#",
31
+ I: ".###./..#../..#../..#../..#../..#../.###.",
32
+ J: "..###/...#./...#./...#./...#./#..#./.##..",
33
+ K: "#...#/#..#./#.#../##.../#.#../#..#./#...#",
34
+ L: "#..../#..../#..../#..../#..../#..../#####",
35
+ M: "#...#/##.##/#.#.#/#.#.#/#...#/#...#/#...#",
36
+ N: "#...#/##..#/#.#.#/#..##/#...#/#...#/#...#",
37
+ O: ".###./#...#/#...#/#...#/#...#/#...#/.###.",
38
+ P: "####./#...#/#...#/####./#..../#..../#....",
39
+ Q: ".###./#...#/#...#/#...#/#.#.#/#..#./.##.#",
40
+ R: "####./#...#/#...#/####./#.#../#..#./#...#",
41
+ S: ".####/#..../#..../.###./....#/....#/####.",
42
+ T: "#####/..#../..#../..#../..#../..#../..#..",
43
+ U: "#...#/#...#/#...#/#...#/#...#/#...#/.###.",
44
+ V: "#...#/#...#/#...#/#...#/#...#/.#.#./..#..",
45
+ W: "#...#/#...#/#...#/#.#.#/#.#.#/##.##/#...#",
46
+ X: "#...#/#...#/.#.#./..#../.#.#./#...#/#...#",
47
+ Y: "#...#/#...#/.#.#./..#../..#../..#../..#..",
48
+ Z: "#####/....#/...#./..#../.#.../#..../#####",
49
+ "0": ".###./#...#/#..##/#.#.#/##..#/#...#/.###.",
50
+ "1": "..#../.##../..#../..#../..#../..#../.###.",
51
+ "2": ".###./#...#/....#/...#./..#../.#.../#####",
52
+ "3": "####./....#/....#/.###./....#/....#/####.",
53
+ "4": "#...#/#...#/#...#/#####/....#/....#/....#",
54
+ "5": "#####/#..../####./....#/....#/#...#/.###.",
55
+ "6": ".###./#..../#..../####./#...#/#...#/.###.",
56
+ "7": "#####/....#/...#./..#../.#.../.#.../.#...",
57
+ "8": ".###./#...#/#...#/.###./#...#/#...#/.###.",
58
+ "9": ".###./#...#/#...#/.####/....#/....#/.###.",
59
+ ".": "...../...../...../...../...../.##../.##..",
60
+ ",": "...../...../...../...../.##../..#../.#...",
61
+ ":": "...../.##../.##../...../.##../.##../.....",
62
+ "!": "..#../..#../..#../..#../..#../...../..#..",
63
+ "?": ".###./#...#/....#/...#./..#../...../..#..",
64
+ "-": "...../...../...../#####/...../...../.....",
65
+ "+": "...../..#../..#../#####/..#../..#../.....",
66
+ "=": "...../...../#####/...../#####/...../.....",
67
+ "/": "....#/....#/...#./..#../.#.../#..../#....",
68
+ "(": "...#./..#../.#.../.#.../.#.../..#../...#.",
69
+ ")": ".#.../..#../...#./...#./...#./..#../.#...",
70
+ "%": "##..#/##..#/...#./..#../.#.../#..##/#..##",
71
+ "'": "..#../..#../.#.../...../...../...../.....",
72
+ " ": "...../...../...../...../...../...../.....",
73
+ };
74
+
75
+ /** Glyph rows by character (uppercase, digits and basic punctuation), each GLYPH_H strings of GLYPH_W. */
76
+ export const BITMAP_FONT: Readonly<Record<string, readonly string[]>> = Object.fromEntries(
77
+ Object.entries(SRC).map(([ch, rows]) => [ch, rows.split("/")]),
78
+ );
79
+
80
+ function glyphFor(ch: string): readonly string[] {
81
+ return BITMAP_FONT[ch.toUpperCase()] ?? BITMAP_FONT["?"];
82
+ }
83
+
84
+ /** Width in px of text drawn by drawPixelText at a scale. */
85
+ export function measurePixelText(text: string, scale: number): number {
86
+ const s = Math.max(1, Math.round(scale));
87
+ return text.length === 0 ? 0 : (text.length * (GLYPH_W + 1) - 1) * s;
88
+ }
89
+
90
+ /** Draws text in the built-in bitmap font with fillRect. (x, y) is the top-left. Returns the width drawn. */
91
+ export function drawPixelText(
92
+ ctx: CanvasRenderingContext2D,
93
+ text: string,
94
+ x: number,
95
+ y: number,
96
+ scale: number,
97
+ color: string,
98
+ ): number {
99
+ const s = Math.max(1, Math.round(scale));
100
+ const ox = Math.round(x);
101
+ const oy = Math.round(y);
102
+ ctx.save();
103
+ ctx.fillStyle = color;
104
+ let cx = ox;
105
+ for (const ch of text) {
106
+ const rows = glyphFor(ch);
107
+ for (let r = 0; r < GLYPH_H; r++) {
108
+ for (let c = 0; c < GLYPH_W; c++) {
109
+ if (rows[r][c] === "#") ctx.fillRect(cx + c * s, oy + r * s, s, s);
110
+ }
111
+ }
112
+ cx += (GLYPH_W + 1) * s;
113
+ }
114
+ ctx.restore();
115
+ return measurePixelText(text, s);
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./rng";
2
+ export * from "./tokens";
3
+ export * from "./sprites";
4
+ export * from "./narrator";
5
+ export * from "./props";
6
+ export * from "./depth";
7
+ export * from "./font";
8
+ export * from "./audio";
9
+ export * from "./layout";
package/src/layout.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Vertical-first layout helpers. The logical canvas for a Reel is 720x1280; the unsafe bands (the top
3
+ * 14% and the bottom 22%) belong to the platform's own UI. A landscape layout is never cropped to
4
+ * vertical: reframe-by-cropping is deliberately not offered, because it cuts layouts. Design
5
+ * the vertical frame with these helpers instead.
6
+ */
7
+ export const LOGICAL_WIDTH = 720;
8
+ export const LOGICAL_HEIGHT = 1280;
9
+ export const LOGICAL_SIZE = { width: LOGICAL_WIDTH, height: LOGICAL_HEIGHT } as const;
10
+
11
+ /** Share of the height kept clear at the top and bottom. */
12
+ export const SAFE_TOP = 0.14;
13
+ export const SAFE_BOTTOM = 0.22;
14
+ /** Share of the width kept clear on each side, so text never touches the edge. */
15
+ export const SAFE_SIDE = 0.05;
16
+
17
+ export interface Rect {
18
+ x: number;
19
+ y: number;
20
+ w: number;
21
+ h: number;
22
+ }
23
+
24
+ /** The usable rectangle for content, in whole pixels. At 720x1280: x 36, y 179, w 648, h 819. */
25
+ export function safeArea(width: number, height: number): Rect {
26
+ const x = Math.round(width * SAFE_SIDE);
27
+ const top = Math.round(height * SAFE_TOP);
28
+ const bottom = height - Math.round(height * SAFE_BOTTOM);
29
+ return { x, y: top, w: width - 2 * x, h: bottom - top };
30
+ }
31
+
32
+ /** True when a rectangle lies entirely inside the safe area. */
33
+ export function insideSafeArea(width: number, height: number, r: Rect): boolean {
34
+ const s = safeArea(width, height);
35
+ return r.x >= s.x && r.y >= s.y && r.x + r.w <= s.x + s.w && r.y + r.h <= s.y + s.h;
36
+ }
37
+
38
+ export type TextRole = "headline" | "label" | "number";
39
+
40
+ /** The smallest size, in logical px, each role may be drawn at. */
41
+ export const ROLE_MIN_SIZE: Record<TextRole, number> = { headline: 44, label: 28, number: 64 };
42
+
43
+ export interface FitResult {
44
+ /** The size to draw at: the largest that fits, and never below the role minimum. */
45
+ size: number;
46
+ lines: string[];
47
+ /** False when even the role minimum overflows: shorten the text or widen the box. */
48
+ fits: boolean;
49
+ }
50
+
51
+ function wrap(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
52
+ const words = text.split(/\s+/).filter(Boolean);
53
+ const lines: string[] = [];
54
+ let line = "";
55
+ for (const word of words) {
56
+ const next = line ? `${line} ${word}` : word;
57
+ if (line && ctx.measureText(next).width > maxWidth) {
58
+ lines.push(line);
59
+ line = word;
60
+ } else {
61
+ line = next;
62
+ }
63
+ }
64
+ if (line) lines.push(line);
65
+ return lines;
66
+ }
67
+
68
+ /** fitText with the wrapped lines and an overflow flag. Sets ctx.font to the size it returns. */
69
+ export function fitTextLayout(
70
+ ctx: CanvasRenderingContext2D,
71
+ text: string,
72
+ maxWidth: number,
73
+ maxLines: number,
74
+ startSize: number,
75
+ role: TextRole = "headline",
76
+ family = "sans-serif",
77
+ weight = "700",
78
+ ): FitResult {
79
+ const min = ROLE_MIN_SIZE[role];
80
+ const start = Math.max(Math.floor(startSize), min);
81
+ for (let size = start; size >= min; size--) {
82
+ ctx.font = `${weight} ${size}px ${family}`;
83
+ const lines = wrap(ctx, text, maxWidth);
84
+ if (lines.length <= maxLines && lines.every((l) => ctx.measureText(l).width <= maxWidth)) {
85
+ return { size, lines, fits: true };
86
+ }
87
+ }
88
+ ctx.font = `${weight} ${min}px ${family}`;
89
+ return { size: min, lines: wrap(ctx, text, maxWidth), fits: false };
90
+ }
91
+
92
+ /** The largest font size at which the text fits in maxWidth over at most maxLines lines, never below the role minimum. */
93
+ export function fitText(
94
+ ctx: CanvasRenderingContext2D,
95
+ text: string,
96
+ maxWidth: number,
97
+ maxLines: number,
98
+ startSize: number,
99
+ role: TextRole = "headline",
100
+ family = "sans-serif",
101
+ weight = "700",
102
+ ): number {
103
+ return fitTextLayout(ctx, text, maxWidth, maxLines, startSize, role, family, weight).size;
104
+ }
105
+
106
+ export const CAPTION_LINES = 2;
107
+ export const CAPTION_START_SIZE = 36;
108
+ const CAPTION_PAD = 16;
109
+
110
+ /** The caption band: full safe width, sitting on the bottom edge of the safe area, room for two lines. */
111
+ export function captionBand(width: number, height: number): Rect {
112
+ const s = safeArea(width, height);
113
+ const h = Math.round(CAPTION_START_SIZE * 1.25 * CAPTION_LINES + CAPTION_PAD * 2);
114
+ return { x: s.x, y: s.y + s.h - h, w: s.w, h };
115
+ }
116
+
117
+ export interface CaptionStyle {
118
+ color?: string;
119
+ background?: string;
120
+ family?: string;
121
+ }
122
+
123
+ /** Draws a caption centred in the caption band, shrinking to fit two lines (never below the label minimum). */
124
+ export function drawCaption(
125
+ ctx: CanvasRenderingContext2D,
126
+ text: string,
127
+ width: number,
128
+ height: number,
129
+ style: CaptionStyle = {},
130
+ ): FitResult & { rect: Rect } {
131
+ const rect = captionBand(width, height);
132
+ const fit = fitTextLayout(ctx, text, rect.w - CAPTION_PAD * 2, CAPTION_LINES, CAPTION_START_SIZE, "label", style.family);
133
+ const lineH = Math.round(fit.size * 1.25);
134
+ const blockH = lineH * fit.lines.length + CAPTION_PAD * 2;
135
+ const top = rect.y + rect.h - blockH;
136
+ ctx.save();
137
+ ctx.fillStyle = style.background ?? "rgba(11,13,20,0.82)";
138
+ ctx.fillRect(rect.x, top, rect.w, blockH);
139
+ ctx.fillStyle = style.color ?? "#FFFFFF";
140
+ ctx.textAlign = "center";
141
+ ctx.textBaseline = "middle";
142
+ fit.lines.forEach((line, i) => ctx.fillText(line, rect.x + rect.w / 2, top + CAPTION_PAD + lineH * i + lineH / 2));
143
+ ctx.restore();
144
+ return { ...fit, rect: { x: rect.x, y: top, w: rect.w, h: blockH } };
145
+ }