@coldtea/pr-lens-renderer 0.4.1 → 0.5.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.
Files changed (84) hide show
  1. package/README.md +15 -0
  2. package/dist/glob.d.ts.map +1 -1
  3. package/dist/glob.js +72 -9
  4. package/dist/glob.js.map +1 -1
  5. package/dist/index.d.ts +7 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/manifest.d.ts +11 -3
  10. package/dist/manifest.d.ts.map +1 -1
  11. package/dist/manifest.js +10 -1
  12. package/dist/manifest.js.map +1 -1
  13. package/dist/render-all.d.ts +35 -0
  14. package/dist/render-all.d.ts.map +1 -0
  15. package/dist/render-all.js +110 -0
  16. package/dist/render-all.js.map +1 -0
  17. package/dist/render.d.ts +4 -31
  18. package/dist/render.d.ts.map +1 -1
  19. package/dist/render.js +5 -75
  20. package/dist/render.js.map +1 -1
  21. package/dist/svg/document.d.ts +8 -0
  22. package/dist/svg/document.d.ts.map +1 -1
  23. package/dist/svg/document.js +3 -2
  24. package/dist/svg/document.js.map +1 -1
  25. package/dist/theme.d.ts +2 -0
  26. package/dist/theme.d.ts.map +1 -1
  27. package/dist/theme.js +1 -0
  28. package/dist/theme.js.map +1 -1
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/dist/walkthrough/camera.d.ts +43 -0
  32. package/dist/walkthrough/camera.d.ts.map +1 -0
  33. package/dist/walkthrough/camera.js +101 -0
  34. package/dist/walkthrough/camera.js.map +1 -0
  35. package/dist/walkthrough/caption.d.ts +5 -0
  36. package/dist/walkthrough/caption.d.ts.map +1 -0
  37. package/dist/walkthrough/caption.js +36 -0
  38. package/dist/walkthrough/caption.js.map +1 -0
  39. package/dist/walkthrough/embed.d.ts +31 -0
  40. package/dist/walkthrough/embed.d.ts.map +1 -0
  41. package/dist/walkthrough/embed.js +297 -0
  42. package/dist/walkthrough/embed.js.map +1 -0
  43. package/dist/walkthrough/index.d.ts +8 -0
  44. package/dist/walkthrough/index.d.ts.map +1 -0
  45. package/dist/walkthrough/index.js +6 -0
  46. package/dist/walkthrough/index.js.map +1 -0
  47. package/dist/walkthrough/layout.d.ts +28 -0
  48. package/dist/walkthrough/layout.d.ts.map +1 -0
  49. package/dist/walkthrough/layout.js +31 -0
  50. package/dist/walkthrough/layout.js.map +1 -0
  51. package/dist/walkthrough/nest.d.ts +4 -0
  52. package/dist/walkthrough/nest.d.ts.map +1 -0
  53. package/dist/walkthrough/nest.js +4 -0
  54. package/dist/walkthrough/nest.js.map +1 -0
  55. package/dist/walkthrough/resolve.d.ts +45 -0
  56. package/dist/walkthrough/resolve.d.ts.map +1 -0
  57. package/dist/walkthrough/resolve.js +189 -0
  58. package/dist/walkthrough/resolve.js.map +1 -0
  59. package/dist/walkthrough/tile-ids.d.ts +8 -0
  60. package/dist/walkthrough/tile-ids.d.ts.map +1 -0
  61. package/dist/walkthrough/tile-ids.js +16 -0
  62. package/dist/walkthrough/tile-ids.js.map +1 -0
  63. package/dist/walkthrough/tiles.d.ts +15 -0
  64. package/dist/walkthrough/tiles.d.ts.map +1 -0
  65. package/dist/walkthrough/tiles.js +80 -0
  66. package/dist/walkthrough/tiles.js.map +1 -0
  67. package/package.json +7 -3
  68. package/src/glob.ts +76 -11
  69. package/src/index.ts +23 -6
  70. package/src/manifest.ts +20 -3
  71. package/src/render-all.ts +195 -0
  72. package/src/render.ts +8 -116
  73. package/src/svg/document.ts +3 -2
  74. package/src/theme.ts +4 -0
  75. package/src/version.ts +1 -1
  76. package/src/walkthrough/camera.ts +142 -0
  77. package/src/walkthrough/caption.ts +35 -0
  78. package/src/walkthrough/embed.ts +436 -0
  79. package/src/walkthrough/index.ts +42 -0
  80. package/src/walkthrough/layout.ts +46 -0
  81. package/src/walkthrough/nest.ts +6 -0
  82. package/src/walkthrough/resolve.ts +268 -0
  83. package/src/walkthrough/tile-ids.ts +17 -0
  84. package/src/walkthrough/tiles.ts +106 -0
@@ -0,0 +1,436 @@
1
+ import type { GraphDoc } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import type { Box } from "../geometry.js";
4
+ import type { RenderedSvg } from "../render.js";
5
+ import { markerDefs } from "../svg/document.js";
6
+ import { escapeXml } from "../svg/primitives.js";
7
+ import { SANS_STACK, truncate } from "../text.js";
8
+ import { paletteFor, type PairTheme } from "../theme.js";
9
+ import { balancedLines, words } from "./caption.js";
10
+ import {
11
+ easeInOut,
12
+ flightFor,
13
+ frame,
14
+ interpolateFlight,
15
+ lerpRect,
16
+ type FramingPolicy,
17
+ type Stage,
18
+ type Viewport,
19
+ } from "./camera.js";
20
+ import { stripLayout, tileRect, type StripBox } from "./layout.js";
21
+ import { nestedRender } from "./nest.js";
22
+ import { resolveWalkthrough, type ResolvedFocus, type ResolvedStep, type WalkthroughIssue } from "./resolve.js";
23
+ import { planTiles } from "./tiles.js";
24
+
25
+ /** The walkthrough as one animated SVG: CSS keyframes fly the canvas's own strip twice, then rest on the last step. */
26
+
27
+ export type WalkthroughEmbedOptions = {
28
+ theme: PairTheme;
29
+ /** Tiles the caller already drew in this theme, by tile id. */
30
+ tiles?: ReadonlyMap<string, RenderedSvg>;
31
+ };
32
+
33
+ export type WalkthroughEmbed = {
34
+ svg: string;
35
+ width: number;
36
+ height: number;
37
+ theme: PairTheme;
38
+ steps: number;
39
+ };
40
+
41
+ export type WalkthroughEmbedResult =
42
+ | { type: "absent" }
43
+ | { type: "unresolvable"; issues: WalkthroughIssue[] }
44
+ | { type: "rendered"; embed: WalkthroughEmbed };
45
+
46
+ export const EMBED_WIDTH = 870;
47
+ const STAGE_HEIGHT = 340;
48
+ const STAGE: Stage = { left: 0, top: 0, width: EMBED_WIDTH, height: STAGE_HEIGHT };
49
+
50
+ const GAP = 16;
51
+ const FEATHER = 28;
52
+ /** Room for the focus mask to cover past the strip at any zoom. */
53
+ const OVERSCAN = 4000;
54
+ const PASSES = 2;
55
+ /** Keyframes per flight. CSS interpolates linearly between them. */
56
+ const SAMPLES = 16;
57
+
58
+ const EASE_OUT = "cubic-bezier(0.23,1,0.32,1)";
59
+
60
+ const HEADING_SIZE = 17.5;
61
+ const BODY_SIZE = 16;
62
+ const LINE_HEIGHT = 22;
63
+ const BODY_X = 16;
64
+ const HEADING_X = 40;
65
+ const HEADING_Y = 26;
66
+ const BODY_Y = 52;
67
+ const CAPTION_FOOT = 12;
68
+ /** Short of the full width, since system fonts run up to 8% wider than the width table's face. */
69
+ const CAPTION_WIDTH = 770;
70
+
71
+ const PIP = { width: 18, gap: 5, height: 3, y: 19, right: EMBED_WIDTH - 2 };
72
+ const HEADING_AIR = 12;
73
+
74
+ /** The `git-pull-request` octicon. */
75
+ const PULL_REQUEST_ICON =
76
+ "M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z";
77
+
78
+ type Tokens = {
79
+ text: string;
80
+ accent: string;
81
+ track: string;
82
+ rim: string;
83
+ /** Opacity of the context outside the focus. */
84
+ dim: string;
85
+ /** A raised white card under the focus. Without it the light theme washes out. */
86
+ card: boolean;
87
+ };
88
+
89
+ const tokensFor = (theme: PairTheme): Tokens => {
90
+ switch (theme) {
91
+ case "dark":
92
+ return {
93
+ text: "#f0f6fc",
94
+ accent: "#3fb950",
95
+ track: "rgba(240,246,252,.14)",
96
+ rim: "rgba(230,237,243,.34)",
97
+ dim: ".24",
98
+ card: false,
99
+ };
100
+ case "light":
101
+ return {
102
+ text: "#1f2328",
103
+ accent: "#1a7f37",
104
+ track: "rgba(31,35,40,.14)",
105
+ rim: "rgba(31,35,40,.5)",
106
+ dim: ".42",
107
+ card: true,
108
+ };
109
+ default:
110
+ return assertNever(theme, "Unhandled embed theme");
111
+ }
112
+ };
113
+
114
+ /** `toFixed` with no sign on a zero. */
115
+ const fixed = (value: number, digits: number): string => {
116
+ const text = value.toFixed(digits);
117
+ return /^-0(?:\.0+)?$/.test(text) ? text.slice(1) : text;
118
+ };
119
+
120
+ type Frame = { camera: Viewport; rect: Box };
121
+
122
+ type Shot = { heading: string; body: string; tileId: string; rect: Box; camera: Viewport };
123
+
124
+ /** The flight into a shot from the one before, sampled. */
125
+ type Leg = { seconds: number; frames: Frame[] };
126
+
127
+ /** A whole tile is framed at reading distance. A region fills the stage. */
128
+ const target = (focus: ResolvedFocus, box: StripBox): { rect: Box; policy: FramingPolicy } => {
129
+ switch (focus.kind) {
130
+ case "whole":
131
+ return { rect: tileRect(box), policy: { kind: "diagram" } };
132
+ case "region": {
133
+ const { x, y, width, height } = focus.rect;
134
+ return { rect: { x: box.x + x, y: box.y + y, width, height }, policy: { kind: "selection" } };
135
+ }
136
+ default:
137
+ return assertNever(focus, "Unhandled focus");
138
+ }
139
+ };
140
+
141
+ const shotFor = (step: ResolvedStep, box: StripBox): Shot => {
142
+ const { rect, policy } = target(step.focus, box);
143
+ return { heading: step.heading, body: step.body, tileId: step.tileId, rect, camera: frame(policy, rect, STAGE) };
144
+ };
145
+
146
+ const legFor = (from: Shot, to: Shot): Leg => {
147
+ const path = interpolateFlight(from.camera, to.camera, STAGE);
148
+ const flight = flightFor(path.distance, false);
149
+ const seconds = (() => {
150
+ switch (flight.kind) {
151
+ case "smooth":
152
+ return flight.durationMs / 1000;
153
+ case "cut":
154
+ return 0;
155
+ default:
156
+ return assertNever(flight, "Unhandled flight");
157
+ }
158
+ })();
159
+
160
+ const frames = Array.from({ length: SAMPLES + 1 }, (_, index): Frame => {
161
+ const eased = easeInOut(index / SAMPLES);
162
+ return { camera: path.at(eased), rect: lerpRect(from.rect, to.rect, eased) };
163
+ });
164
+ return { seconds, frames };
165
+ };
166
+
167
+ /** The canvas's reading time, quickened for a picture nobody can pause, less the caption's fade out. */
168
+ const dwellFor = (shot: Shot): number => {
169
+ const count = words(`${shot.heading} ${shot.body}`).length;
170
+ return Math.min(Math.max(3.2 + count * 0.38, 8), 14) * 0.614 - 1.1;
171
+ };
172
+
173
+ type Clock = {
174
+ shots: Shot[];
175
+ legs: Leg[];
176
+ /** Each step's share of a pass, flight in included. */
177
+ spans: number[];
178
+ starts: number[];
179
+ total: number;
180
+ /** Where the embed rests. */
181
+ last: Frame;
182
+ /** A moment in one pass as a keyframe percentage. */
183
+ at: (seconds: number) => string;
184
+ };
185
+
186
+ const clockFor = (shots: Shot[]): Clock => {
187
+ const legs = shots.map((shot, index) => legFor(shots[(index + shots.length - 1) % shots.length] ?? shot, shot));
188
+ const spans = shots.map((shot, index) => dwellFor(shot) + (legs[index]?.seconds ?? 0));
189
+ const starts = spans.map((_, index) => spans.slice(0, index).reduce((sum, span) => sum + span, 0));
190
+ const total = spans.reduce((sum, span) => sum + span, 0);
191
+ const lastLeg = legs[legs.length - 1];
192
+ const last = lastLeg?.frames[lastLeg.frames.length - 1];
193
+ if (last === undefined) throw new Error("a walkthrough has at least two steps, so it has a flight");
194
+
195
+ return { shots, legs, spans, starts, total, last, at: (seconds) => `${fixed((seconds / total) * 100, 3)}%` };
196
+ };
197
+
198
+ /** Every flight's frames in a row, each last frame held through the dwell. */
199
+ const track = (clock: Clock, paint: (frame: Frame) => string): string =>
200
+ clock.legs
201
+ .map((leg, index) => {
202
+ const start = clock.starts[index] ?? 0;
203
+ const hold = clock.starts[index + 1] ?? clock.total;
204
+ const steps = leg.frames.map((frame, sample) => `${clock.at(start + (leg.seconds * sample) / SAMPLES)}{${paint(frame)}}`);
205
+ const resting = leg.frames[leg.frames.length - 1];
206
+ return `${steps.join("")}${resting === undefined ? "" : `${clock.at(hold)}{${paint(resting)}}`}`;
207
+ })
208
+ .join("");
209
+
210
+ const cameraRule = ({ camera }: Frame): string =>
211
+ `transform:translate(${fixed(camera.x, 2)}px,${fixed(camera.y, 2)}px) scale(${fixed(camera.scale, 4)})`;
212
+
213
+ const holeRule = ({ rect }: Frame): string =>
214
+ `x:${fixed(rect.x, 1)}px;y:${fixed(rect.y, 1)}px;width:${fixed(rect.width, 1)}px;height:${fixed(rect.height, 1)}px`;
215
+
216
+ /** The static values are the resting state, so reduced motion shows the picture the tour ends on. */
217
+ const baseCss = (clock: Clock): string[] => [
218
+ `.a{animation-duration:${fixed(clock.total, 2)}s;animation-iteration-count:${PASSES};animation-fill-mode:both;animation-timing-function:linear}`,
219
+ ".pip{transform-box:fill-box;transform-origin:left center}",
220
+ `@keyframes cam{${track(clock, cameraRule)}}`,
221
+ `@keyframes hole{${track(clock, holeRule)}}`,
222
+ `.cam{${cameraRule(clock.last)}}.moving{${holeRule(clock.last)}}`,
223
+ ];
224
+
225
+ /** In at 35% of the flight, out just before the step ends. The last caption stays up across the seam. */
226
+ const fadeCss = (clock: Clock, name: string, index: number, delay: number): string => {
227
+ const start = clock.starts[index] ?? 0;
228
+ const flight = clock.legs[index]?.seconds ?? 0;
229
+ const on = start + flight * 0.35 + delay;
230
+ const seam = index === clock.shots.length - 1;
231
+
232
+ if (seam) {
233
+ const out = (clock.legs[0]?.seconds ?? 0) * 0.2;
234
+ return (
235
+ `@keyframes ${name}{0%,${clock.at(out)}{opacity:1;transform:translateY(0)}` +
236
+ `${clock.at(out + 0.12)},${clock.at(on)}{opacity:0;transform:translateY(3px)}` +
237
+ `${clock.at(on + 0.22)},100%{opacity:1;transform:translateY(0)}}`
238
+ );
239
+ }
240
+
241
+ const end = start + (clock.spans[index] ?? 0);
242
+ return (
243
+ `@keyframes ${name}{0%,${clock.at(on)}{opacity:0;transform:translateY(3px)}` +
244
+ `${clock.at(on + 0.22)},${clock.at(end - 0.12)}{opacity:1;transform:translateY(0)}` +
245
+ `${clock.at(end)},100%{opacity:0;transform:translateY(0)}}`
246
+ );
247
+ };
248
+
249
+ type Pips = { markup: string; css: string[]; left: number };
250
+
251
+ /** One bar per step, each filling across its step. */
252
+ const pips = (clock: Clock, tokens: Tokens): Pips => {
253
+ const count = clock.shots.length;
254
+ const left = PIP.right - (count * PIP.width + (count - 1) * PIP.gap);
255
+ const css: string[] = [];
256
+ let markup = "";
257
+
258
+ clock.shots.forEach((_, index) => {
259
+ const start = clock.starts[index] ?? 0;
260
+ const end = start + (clock.spans[index] ?? 0);
261
+ const x = left + index * (PIP.width + PIP.gap);
262
+ css.push(`@keyframes p${index}{0%,${clock.at(start)}{transform:scaleX(0)}${clock.at(end)},100%{transform:scaleX(1)}}`);
263
+ markup +=
264
+ `<rect x="${x}" y="${PIP.y}" width="${PIP.width}" height="${PIP.height}" rx="1.5" fill="${tokens.track}"/>` +
265
+ `<rect class="a pip" style="animation-name:p${index}" x="${x}" y="${PIP.y}" width="${PIP.width}" height="${PIP.height}" rx="1.5" fill="${tokens.accent}"/>`;
266
+ });
267
+
268
+ return { markup, css, left };
269
+ };
270
+
271
+ type Caption = { heading: string; lines: string[] };
272
+
273
+ const captionHeight = (caption: Caption): number => BODY_Y + (caption.lines.length - 1) * LINE_HEIGHT + CAPTION_FOOT;
274
+
275
+ const captionFor = (heading: string, body: string, room: number): Caption => ({
276
+ heading: truncate(heading, "sans-bold", HEADING_SIZE, room),
277
+ lines: balancedLines(body, "sans", BODY_SIZE, CAPTION_WIDTH),
278
+ });
279
+
280
+ const headingMarkup = (caption: Caption, tokens: Tokens): string =>
281
+ `<text x="${HEADING_X}" y="${HEADING_Y}" font-size="${HEADING_SIZE}" font-weight="600" fill="${tokens.accent}">${escapeXml(caption.heading)}</text>`;
282
+
283
+ const bodyMarkup = (caption: Caption, tokens: Tokens): string =>
284
+ caption.lines
285
+ .map(
286
+ (line, index) =>
287
+ `<text x="${BODY_X}" y="${BODY_Y + index * LINE_HEIGHT}" font-size="${BODY_SIZE}" fill="${tokens.text}">${escapeXml(line)}</text>`,
288
+ )
289
+ .join("");
290
+
291
+ type Captions = { markup: string; css: string[]; height: number };
292
+
293
+ const captions = (clock: Clock, captionsByStep: Caption[], tokens: Tokens): Captions => {
294
+ const css: string[] = [];
295
+ let markup = "";
296
+
297
+ captionsByStep.forEach((caption, index) => {
298
+ const hidden = index === captionsByStep.length - 1 ? "" : ' opacity="0"';
299
+ css.push(fadeCss(clock, `h${index}`, index, 0), fadeCss(clock, `b${index}`, index, 0.05));
300
+ markup +=
301
+ `<g class="a" style="animation-name:h${index}"${hidden}>${headingMarkup(caption, tokens)}</g>` +
302
+ `<g class="a" style="animation-name:b${index}"${hidden}>${bodyMarkup(caption, tokens)}</g>`;
303
+ });
304
+
305
+ return { markup, css, height: Math.max(...captionsByStep.map(captionHeight)) };
306
+ };
307
+
308
+ /** The alert's bar follows the caption's height between steps. */
309
+ const bar = (clock: Clock, captionsByStep: Caption[], tokens: Tokens): { markup: string; css: string[] } => {
310
+ const heights = captionsByStep.map(captionHeight);
311
+ const lastHeight = heights[heights.length - 1] ?? BODY_Y + CAPTION_FOOT;
312
+
313
+ const keyframes = heights
314
+ .map((height, index) => {
315
+ const start = clock.starts[index] ?? 0;
316
+ const previous = heights[(index + heights.length - 1) % heights.length] ?? height;
317
+ const settled = start + (clock.legs[index]?.seconds ?? 0) * 0.35 + 0.22;
318
+ return `${clock.at(start)}{height:${previous}px;animation-timing-function:${EASE_OUT}}${clock.at(settled)}{height:${height}px}`;
319
+ })
320
+ .join("");
321
+
322
+ return {
323
+ markup: `<rect class="bar" width="3" height="${lastHeight}" rx="1.5" fill="${tokens.accent}"/>`,
324
+ css: [
325
+ `@keyframes bar{${keyframes}100%{height:${lastHeight}px}}`,
326
+ `.bar{animation:bar ${fixed(clock.total, 2)}s linear ${PASSES} both}`,
327
+ ],
328
+ };
329
+ };
330
+
331
+ /** Stage edges fading into the page. */
332
+ const FEATHER_DEFS =
333
+ `<linearGradient id="fx"><stop offset="0" stop-color="#000"/><stop offset="${fixed(FEATHER / EMBED_WIDTH, 4)}" stop-color="#fff"/><stop offset="${fixed(1 - FEATHER / EMBED_WIDTH, 4)}" stop-color="#fff"/><stop offset="1" stop-color="#000"/></linearGradient>` +
334
+ '<linearGradient id="ft" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>' +
335
+ '<linearGradient id="fb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000"/></linearGradient>' +
336
+ `<mask id="edge" maskUnits="userSpaceOnUse" x="0" y="0" width="${EMBED_WIDTH}" height="${STAGE_HEIGHT}">` +
337
+ `<rect width="${EMBED_WIDTH}" height="${STAGE_HEIGHT}" fill="url(#fx)"/>` +
338
+ `<rect width="${EMBED_WIDTH}" height="${FEATHER}" fill="url(#ft)"/>` +
339
+ `<rect y="${STAGE_HEIGHT - FEATHER}" width="${EMBED_WIDTH}" height="${FEATHER}" fill="url(#fb)"/></mask>` +
340
+ `<clipPath id="stage"><rect width="${EMBED_WIDTH}" height="${STAGE_HEIGHT}"/></clipPath>`;
341
+
342
+ type World = { defs: string; markup: string };
343
+
344
+ /** The tiles under the camera, the mask that dims everything outside the focus, and the rim. */
345
+ const world = (tiles: string, strip: { width: number; height: number }, tokens: Tokens): World => {
346
+ const cover = `x="${-OVERSCAN}" y="${-OVERSCAN}" width="${strip.width + OVERSCAN * 2}" height="${strip.height + OVERSCAN * 2}"`;
347
+ const lift = tokens.card
348
+ ? '<filter id="lift" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="12"/></filter>'
349
+ : "";
350
+ const card = tokens.card
351
+ ? '<rect class="a moving" style="animation-name:hole" rx="16" fill="rgba(31,35,40,.18)" filter="url(#lift)" transform="translate(0,4)"/>' +
352
+ '<rect class="a moving" style="animation-name:hole" rx="16" fill="#fff"/>'
353
+ : "";
354
+
355
+ return {
356
+ defs:
357
+ lift +
358
+ '<filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="8"/></filter>' +
359
+ `<mask id="focus" maskUnits="userSpaceOnUse" ${cover}><rect id="base" ${cover} fill="#fff" fill-opacity="${tokens.dim}"/>` +
360
+ '<rect class="a moving" style="animation-name:hole" rx="16" fill="#fff" filter="url(#soft)"/></mask>',
361
+ markup:
362
+ `<g class="a cam" style="animation-name:cam">${card}<g mask="url(#focus)">${tiles}</g>` +
363
+ `<rect class="a moving" style="animation-name:hole;vector-effect:non-scaling-stroke" rx="16" fill="none" stroke="${tokens.rim}" stroke-width="1.25"/></g>`,
364
+ };
365
+ };
366
+
367
+ const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce){.a,.bar{animation:none!important}}";
368
+
369
+ const STEP_WORDS = ["two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve"];
370
+
371
+ /** The embed's accessible name, and the alt text of any image showing it. */
372
+ export const walkthroughLabel = (title: string, steps: number): string =>
373
+ `Walkthrough of ${title}, in ${STEP_WORDS[steps - 2] ?? String(steps)} steps`;
374
+
375
+ export const renderWalkthroughEmbed = (doc: GraphDoc, options: WalkthroughEmbedOptions): WalkthroughEmbedResult => {
376
+ if (doc.walkthrough === undefined) return { type: "absent" };
377
+
378
+ const tokens = tokensFor(options.theme);
379
+ const drawn = planTiles(doc).map((plan) => ({ plan, rendered: options.tiles?.get(plan.id) ?? plan.draw(options.theme) }));
380
+ const resolution = resolveWalkthrough(
381
+ doc,
382
+ drawn.map(({ plan, rendered }) => ({ id: plan.id, hero: plan.hero, atlas: rendered.atlas })),
383
+ );
384
+
385
+ switch (resolution.type) {
386
+ case "absent":
387
+ case "unresolvable":
388
+ return resolution;
389
+ case "resolved":
390
+ break;
391
+ default:
392
+ return assertNever(resolution, "Unhandled walkthrough resolution");
393
+ }
394
+
395
+ const strip = stripLayout(drawn.map(({ plan, rendered }) => ({ id: plan.id, width: rendered.width, height: rendered.height })));
396
+ const boxes = new Map(strip.boxes.map((box) => [box.id, box]));
397
+ const shots = resolution.steps.map((step) => {
398
+ const box = boxes.get(step.tileId);
399
+ if (box === undefined) throw new Error(`a resolved step plays over a tile the strip lacks: ${step.tileId}`);
400
+ return shotFor(step, box);
401
+ });
402
+ const clock = clockFor(shots);
403
+
404
+ // Only the tiles the tour visits travel with the picture.
405
+ const visited = new Set(shots.map((shot) => shot.tileId));
406
+ const tiles = drawn
407
+ .map(({ plan, rendered }) => {
408
+ const box = boxes.get(plan.id);
409
+ return box === undefined || !visited.has(plan.id) ? "" : nestedRender(rendered, box.x, box.y);
410
+ })
411
+ .join("");
412
+
413
+ const progress = pips(clock, tokens);
414
+ const room = progress.left - HEADING_AIR - HEADING_X;
415
+ const captionsByStep = shots.map((shot) => captionFor(shot.heading, shot.body, room));
416
+ const caption = captions(clock, captionsByStep, tokens);
417
+ const alert = bar(clock, captionsByStep, tokens);
418
+ const scene = world(tiles, strip, tokens);
419
+
420
+ const css = [...baseCss(clock), ...progress.css, ...caption.css, ...alert.css, REDUCED_MOTION].join("");
421
+ const height = caption.height + GAP + STAGE_HEIGHT;
422
+ const label = walkthroughLabel(doc.title, shots.length);
423
+
424
+ const svg =
425
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${EMBED_WIDTH} ${height}" width="${EMBED_WIDTH}" height="${height}" role="img" aria-label="${escapeXml(label)}" font-family="${escapeXml(SANS_STACK)}">` +
426
+ `<style>${css}</style>` +
427
+ `<defs>${markerDefs(paletteFor(options.theme))}${scene.defs}${FEATHER_DEFS}</defs>` +
428
+ alert.markup +
429
+ `<g transform="translate(16,12)"><path d="${PULL_REQUEST_ICON}" fill="${tokens.accent}"/></g>` +
430
+ progress.markup +
431
+ caption.markup +
432
+ `<g transform="translate(0,${caption.height + GAP})"><g mask="url(#edge)"><g clip-path="url(#stage)">${scene.markup}</g></g></g>` +
433
+ "</svg>";
434
+
435
+ return { type: "rendered", embed: { svg, width: EMBED_WIDTH, height, theme: options.theme, steps: shots.length } };
436
+ };
@@ -0,0 +1,42 @@
1
+ /** The walkthrough's pure half. Nothing here draws or touches node, so a browser bundle can import it. */
2
+ export {
3
+ easeInOut,
4
+ flightFor,
5
+ frame,
6
+ interpolateFlight,
7
+ lerpRect,
8
+ type Flight,
9
+ type FlightPath,
10
+ type FramingPolicy,
11
+ type Stage,
12
+ type Viewport,
13
+ } from "./camera.js";
14
+
15
+ export {
16
+ CAPTION_ROOM,
17
+ MAX_SCALE,
18
+ MIN_SCALE,
19
+ stripLayout,
20
+ stripRect,
21
+ TILE_GAP,
22
+ tileRect,
23
+ type Sized,
24
+ type StripBox,
25
+ type StripLayout,
26
+ } from "./layout.js";
27
+
28
+ export {
29
+ resolveWalkthrough,
30
+ tileForStage,
31
+ walkthroughRefusal,
32
+ type ResolvedFocus,
33
+ type ResolvedStep,
34
+ type WalkthroughIssue,
35
+ type WalkthroughResolution,
36
+ type WalkthroughTile,
37
+ } from "./resolve.js";
38
+
39
+ export { stageOfTile, tileIdForFlow, tileIdForView, WHOLE_MAP_TILE_ID, WHOLE_MODEL_TILE_ID } from "./tile-ids.js";
40
+
41
+ export type { Box } from "../geometry.js";
42
+ export type { RenderAtlas } from "../atlas.js";
@@ -0,0 +1,46 @@
1
+ import type { Box } from "../geometry.js";
2
+
3
+ /** One row, left to right, never stored. The canvas and the embed share it, so their cameras agree. */
4
+
5
+ /** Wide enough that two tiles never read as one picture. */
6
+ export const TILE_GAP = 162;
7
+
8
+ /** Room above each tile for its caption. */
9
+ export const CAPTION_ROOM = 40;
10
+
11
+ export type Sized = { id: string; width: number; height: number };
12
+
13
+ export type StripBox = Box & { id: string };
14
+
15
+ export type StripLayout = { boxes: StripBox[]; width: number; height: number };
16
+
17
+ export function stripLayout(tiles: readonly Sized[]): StripLayout {
18
+ let cursor = 0;
19
+ const boxes = tiles.map((tile) => {
20
+ const box = { id: tile.id, x: cursor, y: CAPTION_ROOM, width: tile.width, height: tile.height };
21
+ cursor += tile.width + TILE_GAP;
22
+ return box;
23
+ });
24
+
25
+ return {
26
+ boxes,
27
+ width: Math.max(cursor - TILE_GAP, 0),
28
+ height: CAPTION_ROOM + Math.max(0, ...tiles.map((tile) => tile.height)),
29
+ };
30
+ }
31
+
32
+ /** A tile with the caption above it. */
33
+ export const tileRect = (box: StripBox): Box => ({
34
+ x: box.x,
35
+ y: box.y - CAPTION_ROOM,
36
+ width: box.width,
37
+ height: box.height + CAPTION_ROOM,
38
+ });
39
+
40
+ export const stripRect = (layout: StripLayout): Box => ({ x: 0, y: 0, width: layout.width, height: layout.height });
41
+
42
+ /** Enough to read a label on the widest render at a laptop's width. */
43
+ export const MAX_SCALE = 6;
44
+
45
+ /** Far enough out that thirty renders fit in a window. */
46
+ export const MIN_SCALE = 0.02;
@@ -0,0 +1,6 @@
1
+ import { coord } from "../geometry.js";
2
+ import type { RenderedSvg } from "../render.js";
3
+
4
+ /** A render's painting placed inside another SVG. The outer document defines the markers it uses. */
5
+ export const nestedRender = (rendered: RenderedSvg, x: number, y: number): string =>
6
+ `<svg x="${coord(x)}" y="${coord(y)}" width="${coord(rendered.width)}" height="${coord(rendered.height)}" viewBox="0 0 ${coord(rendered.width)} ${coord(rendered.height)}">${rendered.body}</svg>`;