@digithings-ai/hero-banner 0.0.0-stage → 0.1.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.
@@ -0,0 +1,153 @@
1
+ import { emptyFrame, litCells, mulberry32, setPixel } from "../layout";
2
+ import type { Animation, Frame, Layout } from "../types";
3
+
4
+ /**
5
+ * FETCH (digifetch). The word is downloaded, not drawn.
6
+ * LOADING: a request leaves first: one `glint` pixel with an `accent` tail runs
7
+ * left to right along the middle row and out of the right edge. Then the
8
+ * response streams back. Every lit cell of the word is a packet that enters at
9
+ * the right edge on its own row and travels left until it reaches its home
10
+ * cell; it lands as a short `glint` flash, then `ink`. Packets for the left
11
+ * columns leave first and travel furthest, so each row is a steady stream
12
+ * pulled in from off screen and the word fills from left to right.
13
+ * LIVE: the page is fetched again now and then. A request runs out along one
14
+ * row, and a small burst of packets returns on that row and its neighbours,
15
+ * each landing on a cell of the word with a `glint` flash.
16
+ */
17
+
18
+ export type FetchOptions = {
19
+ /** ms per pixel column for a request pixel. */
20
+ requestColMs: number;
21
+ /** ms per pixel column for a response packet. */
22
+ packetColMs: number;
23
+ /** LOADING: launch delay per home column (left columns launch first). */
24
+ launchColMs: number;
25
+ /** LOADING: random extra launch delay per packet, below `launchColMs`. */
26
+ jitterMs: number;
27
+ /** ms a landed packet shows `glint` before it settles to `ink`. */
28
+ flashMs: number;
29
+ /** `accent` alpha of the trail cells behind a moving pixel, nearest first. */
30
+ tail: number[];
31
+ /** LIVE: ms between fetches. */
32
+ everyMs: number;
33
+ /** LIVE: packets per fetch, [min, max]. */
34
+ burst: [number, number];
35
+ };
36
+
37
+ /** A packet: travels left along row `y` from the right edge to `x`. */
38
+ export type Packet = { x: number; y: number; launch: number; land: number };
39
+
40
+ export type FetchState = {
41
+ width: number;
42
+ height: number;
43
+ lit: Uint8Array;
44
+ cells: { x: number; y: number }[];
45
+ /** LOADING packets, one per lit cell. */
46
+ packets: Packet[];
47
+ /** LOADING: when the request pixel has left the right edge. */
48
+ requestMs: number;
49
+ seed: number;
50
+ options: FetchOptions;
51
+ ms: number;
52
+ };
53
+
54
+ export function fetchAnimation(options: FetchOptions): Animation<FetchState> {
55
+ return {
56
+ kind: "fetch",
57
+ liveTickMs: 40,
58
+ prepare(layout: Layout, def) {
59
+ const { width, height } = layout;
60
+ const rand = mulberry32(def.seed);
61
+ const lit = new Uint8Array(width * height);
62
+ const cells = litCells(layout).map(({ x, y }) => ({ x, y }));
63
+ for (const c of cells) lit[c.y * width + c.x] = 1;
64
+ const requestMs = (width + options.tail.length) * options.requestColMs;
65
+ const packets = cells.map(({ x, y }) => {
66
+ const launch = Math.round(requestMs + x * options.launchColMs + rand() * options.jitterMs);
67
+ return { x, y, launch, land: launch + travel(width, x, options.packetColMs) };
68
+ });
69
+ const ms = Math.max(...packets.map((p) => p.land)) + options.flashMs;
70
+ return { width, height, lit, cells, packets, requestMs, seed: def.seed, options, ms };
71
+ },
72
+ loadingMs: (state) => state.ms,
73
+ loading,
74
+ live,
75
+ still: settled,
76
+ };
77
+ }
78
+
79
+ /** ms a packet takes from the right edge to column `x`. */
80
+ const travel = (width: number, x: number, colMs: number) => (width - 1 - x) * colMs;
81
+
82
+ /** Paint a moving pixel at column `head` on row `y` with its tail on the side
83
+ * it came from (`dir` is +1 moving right, -1 moving left). Lit cells under the
84
+ * tail keep their ink. */
85
+ function paintMover(state: FetchState, frame: Frame, head: number, y: number, dir: 1 | -1): void {
86
+ const { width, lit, options } = state;
87
+ options.tail.forEach((alpha, i) => {
88
+ const x = head - dir * (i + 1);
89
+ if (x >= 0 && x < width && !lit[y * width + x]) setPixel(frame, x, y, { tone: "accent", alpha });
90
+ });
91
+ if (head >= 0 && head < width) setPixel(frame, head, y, { tone: "glint", alpha: 1 });
92
+ }
93
+
94
+ /** Paint packet `p` at `t`: moving, flashing on landing, or settled ink. */
95
+ function paintPacket(state: FetchState, frame: Frame, p: Packet, t: number): void {
96
+ if (t < p.launch) return;
97
+ if (t < p.land) {
98
+ paintMover(state, frame, state.width - 1 - Math.floor((t - p.launch) / state.options.packetColMs), p.y, -1);
99
+ } else {
100
+ const flash = t < p.land + state.options.flashMs;
101
+ setPixel(frame, p.x, p.y, { tone: flash ? "glint" : "ink", alpha: 1 });
102
+ }
103
+ }
104
+
105
+ function loading(state: FetchState, t: number): Frame {
106
+ const frame = emptyFrame(state.width, state.height);
107
+ const { requestColMs } = state.options;
108
+ if (t < state.requestMs) paintMover(state, frame, Math.floor(t / requestColMs), state.height >> 1, 1);
109
+ // Landed cells first, so packets in flight draw over them.
110
+ const order = [...state.packets].sort((a, b) => Number(t < a.land) - Number(t < b.land));
111
+ for (const p of order) paintPacket(state, frame, p, t);
112
+ return frame;
113
+ }
114
+
115
+ function settled(state: FetchState): Frame {
116
+ const frame = emptyFrame(state.width, state.height);
117
+ for (let i = 0; i < state.lit.length; i += 1) if (state.lit[i]) frame.pixels[i] = { tone: "ink", alpha: 1 };
118
+ return frame;
119
+ }
120
+
121
+ /** LIVE fetch `i`, seeded per index so any moment can be sampled: the request
122
+ * row, when it starts, and the packets of its response. */
123
+ export function liveFetch(state: FetchState, i: number): { y: number; at: number; packets: Packet[] } {
124
+ const rand = mulberry32((state.seed ^ Math.imul(i + 1, 0x9e3779b1)) >>> 0);
125
+ const { width, height, cells, options } = state;
126
+ const at = i * options.everyMs + Math.floor(rand() * options.everyMs * 0.3);
127
+ const y = 1 + Math.floor(rand() * Math.max(1, height - 2));
128
+ const back = at + (width + options.tail.length) * options.requestColMs;
129
+ const rows = cells.filter((c) => Math.abs(c.y - y) <= 1);
130
+ const n = options.burst[0] + Math.floor(rand() * (options.burst[1] - options.burst[0] + 1));
131
+ const packets: Packet[] = [];
132
+ for (let k = 0; k < n && rows.length > 0; k += 1) {
133
+ const c = rows[Math.floor(rand() * rows.length)];
134
+ const launch = back + Math.floor(rand() * 6) * options.packetColMs * 2;
135
+ packets.push({ x: c.x, y: c.y, launch, land: launch + travel(width, c.x, options.packetColMs) });
136
+ }
137
+ return { y, at, packets };
138
+ }
139
+
140
+ function live(state: FetchState, tLive: number): Frame {
141
+ const frame = settled(state);
142
+ const { width, options } = state;
143
+ const span = (width + options.tail.length) * options.requestColMs + 12 * options.packetColMs + width * options.packetColMs + options.flashMs;
144
+ const first = Math.max(0, Math.floor((tLive - span) / options.everyMs) - 1);
145
+ const last = Math.floor(tLive / options.everyMs);
146
+ for (let i = first; i <= last; i += 1) {
147
+ const f = liveFetch(state, i);
148
+ const head = Math.floor((tLive - f.at) / options.requestColMs);
149
+ if (tLive >= f.at && head < width + options.tail.length) paintMover(state, frame, head, f.y, 1);
150
+ for (const p of f.packets) if (tLive < p.land + options.flashMs) paintPacket(state, frame, p, tLive);
151
+ }
152
+ return frame;
153
+ }
@@ -0,0 +1,197 @@
1
+ import { emptyFrame, litCells, mulberry32, setPixel } from "../layout";
2
+ import type { Animation, Frame, Layout, Pixel } from "../types";
3
+
4
+ /**
5
+ * PAGES (digibook). The word is a flipbook.
6
+ * LOADING: `pages` pages turn right to left, one after another and overlapping
7
+ * like a riffle. Each turn is a fold line that leans (the bottom corner leads)
8
+ * with the lifted page, a flap of `accent` paper, curling over the old page
9
+ * just ahead of it. Behind the fold the next page shows. Page k of N carries
10
+ * the lit cells whose seeded rank is below k/N, in faint `accent`; the last
11
+ * page is the whole word in ink, so the riffle draws the word up and the last,
12
+ * slowest turn lays it down.
13
+ * LIVE: a reader. Every so often one page turns over the settled word (the
14
+ * fold in `glint`, a short shadow on the page it uncovers); one in four is a
15
+ * riffle of three quick pages, one in five turns back (left to right).
16
+ */
17
+
18
+ export type PagesOptions = {
19
+ /** LOADING: number of pages riffled. The last one carries the full word. */
20
+ pages: number;
21
+ /** LOADING: ms each page takes to turn, first to last. Cycled if shorter than `pages`. */
22
+ turnMs: number[];
23
+ /** LOADING: fraction of a turn after which the next page starts (riffle overlap). */
24
+ overlap: number;
25
+ /** Widest the flap (lifted page) gets, in pixels, at mid-turn. */
26
+ curlMax: number;
27
+ /** How far the bottom of the fold leads the top, in pixels. */
28
+ lean: number;
29
+ /** LIVE: ms between page events. */
30
+ everyMs: number;
31
+ /** LIVE: ms for one page turn. */
32
+ liveTurnMs: number;
33
+ /** LIVE: ms between the pages of a riffle. */
34
+ riffleGapMs: number;
35
+ };
36
+
37
+ export type PageTurn = { at: number; ms: number; back: boolean };
38
+
39
+ export type PagesState = {
40
+ width: number;
41
+ height: number;
42
+ /** Rank of each lit cell in [0, 1); -1 for an empty cell. */
43
+ rank: Float32Array;
44
+ /** LOADING turns, in order. Turn k uncovers page k + 1. */
45
+ turns: PageTurn[];
46
+ seed: number;
47
+ options: PagesOptions;
48
+ ms: number;
49
+ };
50
+
51
+ export function pagesAnimation(options: PagesOptions): Animation<PagesState> {
52
+ return {
53
+ kind: "pages",
54
+ liveTickMs: 40,
55
+ prepare(layout: Layout, def) {
56
+ const { width, height } = layout;
57
+ const rand = mulberry32(def.seed);
58
+ const rank = new Float32Array(width * height).fill(-1);
59
+ for (const cell of litCells(layout)) rank[cell.y * width + cell.x] = rand();
60
+ const turns: PageTurn[] = [];
61
+ let at = 0;
62
+ for (let k = 0; k < options.pages; k += 1) {
63
+ const ms = options.turnMs[k % options.turnMs.length];
64
+ turns.push({ at, ms, back: false });
65
+ at += Math.round(ms * options.overlap);
66
+ }
67
+ const ms = Math.max(...turns.map((turn) => turn.at + turn.ms));
68
+ return { width, height, rank, turns, seed: def.seed, options, ms };
69
+ },
70
+ loadingMs: (state) => state.ms,
71
+ loading,
72
+ live,
73
+ still: settled,
74
+ };
75
+ }
76
+
77
+ /** Ease in and out: the page lifts slowly, crosses fast, settles slowly. */
78
+ const ease = (p: number) => (p < 0.5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2);
79
+
80
+ /** Fold geometry of a turn at progress `p` (0..1), in the turn's own direction
81
+ * (x counted from the side the page starts on). `edge(y)` is the first column
82
+ * of the uncovered page; the flap covers the `flap` columns before it. */
83
+ function fold(state: PagesState, p: number) {
84
+ const { width, height } = state;
85
+ const { curlMax, lean } = state.options;
86
+ const u = ease(Math.min(1, Math.max(0, p)));
87
+ const from = width + lean;
88
+ const to = -curlMax - 1;
89
+ const base = from - u * (from - to);
90
+ const flap = u > 0 && u < 1 ? Math.max(1, Math.round(curlMax * Math.sin(Math.PI * u))) : 0;
91
+ const edge = (y: number) => Math.round(base - (lean * y) / Math.max(1, height - 1));
92
+ return { edge, flap };
93
+ }
94
+
95
+ type Side = "new" | "flap" | "fold" | "shadow" | "old";
96
+
97
+ /** Where column `x` of row `y` sits relative to a turn at progress `p`. */
98
+ function sideOf(state: PagesState, p: number, back: boolean, x: number, y: number): { side: Side; depth: number } {
99
+ if (p <= 0) return { side: "old", depth: 0 };
100
+ if (p >= 1) return { side: "new", depth: 0 };
101
+ const { edge, flap } = fold(state, p);
102
+ // A forward turn runs right to left; a back turn mirrors it.
103
+ const fx = back ? state.width - 1 - x : x;
104
+ const e = edge(y);
105
+ if (fx >= e) return { side: fx - e < 2 ? "shadow" : "new", depth: fx - e };
106
+ const d = e - 1 - fx;
107
+ if (d === 0 && flap > 0) return { side: "fold", depth: 0 };
108
+ if (d < flap) return { side: "flap", depth: d };
109
+ return { side: "old", depth: 0 };
110
+ }
111
+
112
+ /** Paper of the flap: brightest at the fold, falling off toward its free edge. */
113
+ function paper(side: Side, depth: number, flap: number): Pixel {
114
+ if (side === "fold") return { tone: "glint", alpha: 1 };
115
+ return { tone: "accent", alpha: 0.85 - (0.45 * depth) / Math.max(1, flap) };
116
+ }
117
+
118
+ /** Cell (x, y) of LOADING page `k` (0 is blank, `pages` is the full word). */
119
+ function pageCell(state: PagesState, k: number, x: number, y: number): Pixel | null {
120
+ const r = state.rank[y * state.width + x];
121
+ if (r < 0 || k <= 0) return null;
122
+ const n = state.options.pages;
123
+ if (k >= n) return { tone: "ink", alpha: 1 };
124
+ if (r >= k / n) return null;
125
+ return { tone: "accent", alpha: 0.3 + (0.35 * k) / n };
126
+ }
127
+
128
+ const dim = (pixel: Pixel | null): Pixel | null => (pixel ? { ...pixel, alpha: pixel.alpha * 0.45 } : null);
129
+
130
+ function loading(state: PagesState, t: number): Frame {
131
+ const frame = emptyFrame(state.width, state.height);
132
+ for (let y = 0; y < state.height; y += 1) {
133
+ for (let x = 0; x < state.width; x += 1) {
134
+ // The page showing here is the count of turns whose fold has passed it.
135
+ // A flap of an active turn covers whatever is under it.
136
+ let page = 0;
137
+ let top: Pixel | null = null;
138
+ let shadow = false;
139
+ for (const turn of state.turns) {
140
+ const p = (t - turn.at) / turn.ms;
141
+ const { side, depth } = sideOf(state, p, turn.back, x, y);
142
+ if (side === "new" || side === "shadow") page += 1;
143
+ if (side === "shadow") shadow = true;
144
+ if (side === "flap" || side === "fold") top = paper(side, depth, fold(state, p).flap);
145
+ }
146
+ const cell = pageCell(state, page, x, y);
147
+ setPixel(frame, x, y, top ?? (shadow ? dim(cell) : cell));
148
+ }
149
+ }
150
+ return frame;
151
+ }
152
+
153
+ function settled(state: PagesState): Frame {
154
+ const frame = emptyFrame(state.width, state.height);
155
+ for (let i = 0; i < state.rank.length; i += 1) {
156
+ if (state.rank[i] >= 0) frame.pixels[i] = { tone: "ink", alpha: 1 };
157
+ }
158
+ return frame;
159
+ }
160
+
161
+ /** Page event `i` of the LIVE schedule: one turn, or a riffle of three. Seeded
162
+ * per index, so any moment can be sampled. */
163
+ export function liveEvent(state: PagesState, i: number): PageTurn[] {
164
+ const rand = mulberry32((state.seed ^ Math.imul(i + 1, 0x9e3779b1)) >>> 0);
165
+ const { everyMs, liveTurnMs, riffleGapMs } = state.options;
166
+ const at = i * everyMs + Math.floor(rand() * everyMs * 0.4);
167
+ const back = rand() < 1 / 5;
168
+ const count = rand() < 1 / 4 ? 3 : 1;
169
+ return Array.from({ length: count }, (_, k) => ({
170
+ at: at + k * riffleGapMs,
171
+ ms: count > 1 ? Math.round(liveTurnMs * 0.7) : liveTurnMs,
172
+ back,
173
+ }));
174
+ }
175
+
176
+ function live(state: PagesState, tLive: number): Frame {
177
+ const frame = settled(state);
178
+ const { everyMs, liveTurnMs, riffleGapMs } = state.options;
179
+ const span = liveTurnMs + 2 * riffleGapMs;
180
+ const first = Math.max(0, Math.floor((tLive - span) / everyMs) - 1);
181
+ const last = Math.floor(tLive / everyMs);
182
+ for (let i = first; i <= last; i += 1) {
183
+ for (const turn of liveEvent(state, i)) {
184
+ const p = (tLive - turn.at) / turn.ms;
185
+ if (p <= 0 || p >= 1) continue;
186
+ const { flap } = fold(state, p);
187
+ for (let y = 0; y < state.height; y += 1) {
188
+ for (let x = 0; x < state.width; x += 1) {
189
+ const { side, depth } = sideOf(state, p, turn.back, x, y);
190
+ if (side === "flap" || side === "fold") setPixel(frame, x, y, paper(side, depth, flap));
191
+ else if (side === "shadow") setPixel(frame, x, y, dim(frame.pixels[y * state.width + x]));
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return frame;
197
+ }
@@ -0,0 +1,157 @@
1
+ import { emptyFrame, litCells, mulberry32, setPixel } from "../layout";
2
+ import type { Animation, Frame, Layout } from "../types";
3
+
4
+ /**
5
+ * RISE (digiquant). LOADING: cells grow up from the baseline, column by column
6
+ * left to right, like bars building the word, in the up and down tones, then
7
+ * settle to ink; strays blink once. LIVE: a few cells glint and about a third
8
+ * of the strays keep blinking, each in its own up or down tone, so both colours
9
+ * stay alive after the build (never a single-colour accent).
10
+ * Port of apps/digiquant-web/app/_chrome/QuantWordmark.tsx (#4986 + #5416) and
11
+ * its dq-rise / dq-stray / dq-glint / dq-blink keyframes: same seed, same draw order.
12
+ */
13
+
14
+ export type RiseOptions = {
15
+ /** Wordmark column timing, shared with the digiquant hero chart sweep. */
16
+ startMs: number;
17
+ columnMs: number;
18
+ glintChance: number;
19
+ strays: number;
20
+ glintDuty: number;
21
+ /** Share of the rise spent in the up/down tone before it turns to ink. */
22
+ toneUntil: number;
23
+ /** Share of strays that keep blinking after the build. */
24
+ blinkChance: number;
25
+ blinkDuty: number;
26
+ blinkAlpha: number;
27
+ /** Earliest blink: every cell has finished rising (hero-build BUILD_DONE_MS). */
28
+ buildDoneMs: number;
29
+ };
30
+
31
+ type RiseCell = {
32
+ x: number;
33
+ y: number;
34
+ delay: number;
35
+ dur: number;
36
+ tone: "up" | "down";
37
+ glint: { period: number; offset: number } | null;
38
+ };
39
+
40
+ type Stray = {
41
+ x: number;
42
+ y: number;
43
+ delay: number;
44
+ dur: number;
45
+ tone: "up" | "down";
46
+ blink: { period: number; offset: number } | null;
47
+ };
48
+
49
+ export type RiseState = { width: number; height: number; cells: RiseCell[]; strays: Stray[]; options: RiseOptions; ms: number };
50
+
51
+ /** cubic-bezier(0.2, 0.7, 0.2, 1), solved for y at time x. */
52
+ export function riseEase(x: number): number {
53
+ if (x <= 0) return 0;
54
+ if (x >= 1) return 1;
55
+ const p1x = 0.2, p1y = 0.7, p2x = 0.2, p2y = 1;
56
+ const bx = (s: number) => 3 * (1 - s) * (1 - s) * s * p1x + 3 * (1 - s) * s * s * p2x + s * s * s;
57
+ const by = (s: number) => 3 * (1 - s) * (1 - s) * s * p1y + 3 * (1 - s) * s * s * p2y + s * s * s;
58
+ let lo = 0, hi = 1;
59
+ for (let i = 0; i < 24; i += 1) {
60
+ const mid = (lo + hi) / 2;
61
+ if (bx(mid) < x) lo = mid;
62
+ else hi = mid;
63
+ }
64
+ return by((lo + hi) / 2);
65
+ }
66
+
67
+ /** Each set (rise, glint, blink) must show both sides of the pair, not a run of one tone. */
68
+ function mixDirections(cells: { tone: "up" | "down" }[]): void {
69
+ if (cells.length < 2) return;
70
+ if (!cells.some((c) => c.tone === "up")) cells[0]!.tone = "up";
71
+ if (!cells.some((c) => c.tone === "down")) cells[1]!.tone = "down";
72
+ }
73
+
74
+ export function riseAnimation(options: RiseOptions): Animation<RiseState> {
75
+ const columnDelay = (col: number) => options.startMs + col * options.columnMs;
76
+ return {
77
+ kind: "rise",
78
+ liveTickMs: 100,
79
+ prepare(layout: Layout, def) {
80
+ const rand = mulberry32(def.seed);
81
+ const used = new Set<string>();
82
+ const cells: RiseCell[] = [];
83
+ for (const cell of litCells(layout)) {
84
+ used.add(`${cell.x},${cell.y}`);
85
+ const glint = rand() < options.glintChance;
86
+ const delay = columnDelay(cell.x) + (layout.height - 1 - cell.y) * 9 + Math.floor(rand() * 140);
87
+ const dur = 260 + Math.floor(rand() * 220);
88
+ const g = glint ? { period: 7000 + Math.floor(rand() * 8000), offset: 3500 + Math.floor(rand() * 6000) } : null;
89
+ cells.push({ x: cell.x, y: cell.y, delay, dur, tone: rand() < 0.5 ? "up" : "down", glint: g });
90
+ }
91
+ mixDirections(cells);
92
+ mixDirections(cells.filter((c) => c.glint));
93
+ const strays: Stray[] = [];
94
+ while (strays.length < options.strays) {
95
+ const x = Math.floor(rand() * layout.width);
96
+ const y = Math.floor(rand() * layout.height);
97
+ const key = `${x},${y}`;
98
+ if (used.has(key)) continue;
99
+ used.add(key);
100
+ const dur = 60 + Math.floor(rand() * 140);
101
+ const delay = Math.floor(rand() * 1400);
102
+ const blinks = rand() < options.blinkChance;
103
+ const period = 4000 + Math.floor(rand() * 6000);
104
+ const offset = options.buildDoneMs + 400 + Math.floor(rand() * 5000);
105
+ strays.push({ x, y, delay, dur, tone: rand() < 0.5 ? "up" : "down", blink: blinks ? { period, offset } : null });
106
+ }
107
+ mixDirections(strays);
108
+ mixDirections(strays.filter((s) => s.blink));
109
+ const ms = Math.max(0, ...cells.map((c) => c.delay + c.dur), ...strays.map((s) => s.delay + s.dur));
110
+ return { width: layout.width, height: layout.height, cells, strays, options, ms };
111
+ },
112
+ loadingMs: (state) => state.ms,
113
+ loading(state, t) {
114
+ const frame = emptyFrame(state.width, state.height);
115
+ for (const cell of state.cells) {
116
+ const p = (t - cell.delay) / cell.dur;
117
+ if (p <= 0) continue;
118
+ // A terminal cell cannot scale on Y, so the rise reads as opacity.
119
+ const alpha = riseEase(p);
120
+ setPixel(frame, cell.x, cell.y, { tone: p < state.options.toneUntil ? cell.tone : "ink", alpha });
121
+ }
122
+ for (const stray of state.strays) {
123
+ if (t >= stray.delay && t < stray.delay + stray.dur) {
124
+ setPixel(frame, stray.x, stray.y, { tone: stray.tone, alpha: 0.25 });
125
+ }
126
+ }
127
+ paintAfterglow(frame, state, t);
128
+ return frame;
129
+ },
130
+ live(state, _tLive, tMount) {
131
+ const frame = settled(state);
132
+ paintAfterglow(frame, state, tMount);
133
+ return frame;
134
+ },
135
+ still: settled,
136
+ };
137
+ }
138
+
139
+ function settled(state: RiseState): Frame {
140
+ const frame = emptyFrame(state.width, state.height);
141
+ for (const cell of state.cells) setPixel(frame, cell.x, cell.y, { tone: "ink", alpha: 1 });
142
+ return frame;
143
+ }
144
+
145
+ const on = (t: number, cycle: { period: number; offset: number }, duty: number) =>
146
+ t >= cycle.offset && (t - cycle.offset) % cycle.period < cycle.period * duty;
147
+
148
+ /** Glints (letters) and blinks (strays), each in its own up or down tone. */
149
+ function paintAfterglow(frame: Frame, state: RiseState, t: number): void {
150
+ const { glintDuty, blinkDuty, blinkAlpha } = state.options;
151
+ for (const cell of state.cells) {
152
+ if (cell.glint && on(t, cell.glint, glintDuty)) setPixel(frame, cell.x, cell.y, { tone: cell.tone, alpha: 1 });
153
+ }
154
+ for (const stray of state.strays) {
155
+ if (stray.blink && on(t, stray.blink, blinkDuty)) setPixel(frame, stray.x, stray.y, { tone: stray.tone, alpha: blinkAlpha });
156
+ }
157
+ }