@coldtea/pr-lens-renderer 0.4.0 → 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 (93) hide show
  1. package/README.md +15 -0
  2. package/dist/atlas.d.ts +6 -0
  3. package/dist/atlas.d.ts.map +1 -1
  4. package/dist/atlas.js +1 -1
  5. package/dist/atlas.js.map +1 -1
  6. package/dist/glob.d.ts.map +1 -1
  7. package/dist/glob.js +72 -9
  8. package/dist/glob.js.map +1 -1
  9. package/dist/index.d.ts +7 -3
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +7 -3
  12. package/dist/index.js.map +1 -1
  13. package/dist/manifest.d.ts +11 -3
  14. package/dist/manifest.d.ts.map +1 -1
  15. package/dist/manifest.js +10 -1
  16. package/dist/manifest.js.map +1 -1
  17. package/dist/render-all.d.ts +35 -0
  18. package/dist/render-all.d.ts.map +1 -0
  19. package/dist/render-all.js +110 -0
  20. package/dist/render-all.js.map +1 -0
  21. package/dist/render.d.ts +4 -31
  22. package/dist/render.d.ts.map +1 -1
  23. package/dist/render.js +5 -75
  24. package/dist/render.js.map +1 -1
  25. package/dist/svg/datamodel.d.ts.map +1 -1
  26. package/dist/svg/datamodel.js +8 -1
  27. package/dist/svg/datamodel.js.map +1 -1
  28. package/dist/svg/document.d.ts +8 -0
  29. package/dist/svg/document.d.ts.map +1 -1
  30. package/dist/svg/document.js +3 -2
  31. package/dist/svg/document.js.map +1 -1
  32. package/dist/theme.d.ts +2 -0
  33. package/dist/theme.d.ts.map +1 -1
  34. package/dist/theme.js +1 -0
  35. package/dist/theme.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/walkthrough/camera.d.ts +43 -0
  39. package/dist/walkthrough/camera.d.ts.map +1 -0
  40. package/dist/walkthrough/camera.js +101 -0
  41. package/dist/walkthrough/camera.js.map +1 -0
  42. package/dist/walkthrough/caption.d.ts +5 -0
  43. package/dist/walkthrough/caption.d.ts.map +1 -0
  44. package/dist/walkthrough/caption.js +36 -0
  45. package/dist/walkthrough/caption.js.map +1 -0
  46. package/dist/walkthrough/embed.d.ts +31 -0
  47. package/dist/walkthrough/embed.d.ts.map +1 -0
  48. package/dist/walkthrough/embed.js +297 -0
  49. package/dist/walkthrough/embed.js.map +1 -0
  50. package/dist/walkthrough/index.d.ts +8 -0
  51. package/dist/walkthrough/index.d.ts.map +1 -0
  52. package/dist/walkthrough/index.js +6 -0
  53. package/dist/walkthrough/index.js.map +1 -0
  54. package/dist/walkthrough/layout.d.ts +28 -0
  55. package/dist/walkthrough/layout.d.ts.map +1 -0
  56. package/dist/walkthrough/layout.js +31 -0
  57. package/dist/walkthrough/layout.js.map +1 -0
  58. package/dist/walkthrough/nest.d.ts +4 -0
  59. package/dist/walkthrough/nest.d.ts.map +1 -0
  60. package/dist/walkthrough/nest.js +4 -0
  61. package/dist/walkthrough/nest.js.map +1 -0
  62. package/dist/walkthrough/resolve.d.ts +45 -0
  63. package/dist/walkthrough/resolve.d.ts.map +1 -0
  64. package/dist/walkthrough/resolve.js +189 -0
  65. package/dist/walkthrough/resolve.js.map +1 -0
  66. package/dist/walkthrough/tile-ids.d.ts +8 -0
  67. package/dist/walkthrough/tile-ids.d.ts.map +1 -0
  68. package/dist/walkthrough/tile-ids.js +16 -0
  69. package/dist/walkthrough/tile-ids.js.map +1 -0
  70. package/dist/walkthrough/tiles.d.ts +15 -0
  71. package/dist/walkthrough/tiles.d.ts.map +1 -0
  72. package/dist/walkthrough/tiles.js +80 -0
  73. package/dist/walkthrough/tiles.js.map +1 -0
  74. package/package.json +7 -3
  75. package/src/atlas.ts +7 -1
  76. package/src/glob.ts +76 -11
  77. package/src/index.ts +23 -6
  78. package/src/manifest.ts +20 -3
  79. package/src/render-all.ts +195 -0
  80. package/src/render.ts +8 -116
  81. package/src/svg/datamodel.ts +13 -0
  82. package/src/svg/document.ts +3 -2
  83. package/src/theme.ts +4 -0
  84. package/src/version.ts +1 -1
  85. package/src/walkthrough/camera.ts +142 -0
  86. package/src/walkthrough/caption.ts +35 -0
  87. package/src/walkthrough/embed.ts +436 -0
  88. package/src/walkthrough/index.ts +42 -0
  89. package/src/walkthrough/layout.ts +46 -0
  90. package/src/walkthrough/nest.ts +6 -0
  91. package/src/walkthrough/resolve.ts +268 -0
  92. package/src/walkthrough/tile-ids.ts +17 -0
  93. package/src/walkthrough/tiles.ts +106 -0
@@ -0,0 +1,268 @@
1
+ import type { GraphDoc, StepFocus, StepStage, ViewScope, WalkthroughStep } from "@coldtea/pr-lens-schema";
2
+ import { assertNever } from "@coldtea/pr-lens-schema";
3
+ import type { RenderAtlas } from "../atlas.js";
4
+ import { inflate, union } from "../bounds.js";
5
+ import type { Box } from "../geometry.js";
6
+ import { flattenViews } from "../scope.js";
7
+ import { tileIdForFlow, tileIdForView } from "./tile-ids.js";
8
+
9
+ /** Which tile each step plays over and which rectangle it points at. Pure, so the canvas and the embed agree. */
10
+
11
+ /** The atlas is absent on a revision stored before one was kept. */
12
+ export type WalkthroughTile = { id: string; hero: boolean; atlas?: RenderAtlas };
13
+
14
+ /** Rects are in the tile's own viewBox units. The surface adds the tile's place on the strip. */
15
+ export type ResolvedFocus = { kind: "whole" } | { kind: "region"; rect: Box };
16
+
17
+ export type ResolvedStep = {
18
+ id: string;
19
+ heading: string;
20
+ body: string;
21
+ tileId: string;
22
+ focus: ResolvedFocus;
23
+ };
24
+
25
+ /** Why a step could not be matched. */
26
+ export type WalkthroughIssue = {
27
+ code: "STAGE_NOT_ON_CANVAS" | "FOCUS_NOT_DRAWN" | "ATLAS_MISSING";
28
+ stepId: string;
29
+ detail: string;
30
+ };
31
+
32
+ export type WalkthroughResolution =
33
+ | { type: "absent" }
34
+ | { type: "resolved"; steps: ResolvedStep[] }
35
+ | { type: "unresolvable"; issues: WalkthroughIssue[] };
36
+
37
+ /** Air around a focus. Message rows sit 38 units apart, so much more puts the rim over the row above. */
38
+ const FOCUS_PAD = 16;
39
+
40
+ /** A step with no stage plays over the tile that opens the strip. */
41
+ type StageChoice = { kind: "opening" } | StepStage;
42
+
43
+ /** Resolves all of the walkthrough or none of it, collecting every issue in step order. */
44
+ export function resolveWalkthrough(doc: GraphDoc, tiles: readonly WalkthroughTile[]): WalkthroughResolution {
45
+ if (doc.walkthrough === undefined) return { type: "absent" };
46
+
47
+ const steps: ResolvedStep[] = [];
48
+ const issues: WalkthroughIssue[] = [];
49
+
50
+ for (const step of doc.walkthrough.steps) {
51
+ const resolved = resolveStep(doc, step, tiles);
52
+ switch (resolved.type) {
53
+ case "step":
54
+ steps.push(resolved.step);
55
+ break;
56
+ case "issues":
57
+ issues.push(...resolved.issues);
58
+ break;
59
+ default:
60
+ return assertNever(resolved, "Unhandled step resolution");
61
+ }
62
+ }
63
+
64
+ return issues.length > 0 ? { type: "unresolvable", issues } : { type: "resolved", steps };
65
+ }
66
+
67
+ /** One issue as a sentence. */
68
+ const says = (issue: WalkthroughIssue): string => `Step "${issue.stepId}" ${issue.detail}.`;
69
+
70
+ /** Copy for a refused push: the first wrong step by name, and a count of the rest. */
71
+ export function walkthroughRefusal(issues: readonly WalkthroughIssue[]): string {
72
+ const first = issues[0];
73
+ if (first === undefined) return "This document's walkthrough does not fit the diagrams it draws";
74
+
75
+ const elsewhere = new Set(issues.map((issue) => issue.stepId));
76
+ elsewhere.delete(first.stepId);
77
+
78
+ const rest =
79
+ elsewhere.size === 0
80
+ ? ""
81
+ : elsewhere.size === 1
82
+ ? " One other step has the same trouble."
83
+ : ` ${elsewhere.size} other steps have the same trouble.`;
84
+
85
+ return `This document's walkthrough does not fit the diagrams it draws. ${says(first)}${rest}`;
86
+ }
87
+
88
+ type StepResolution = { type: "step"; step: ResolvedStep } | { type: "issues"; issues: WalkthroughIssue[] };
89
+
90
+ function resolveStep(doc: GraphDoc, step: WalkthroughStep, tiles: readonly WalkthroughTile[]): StepResolution {
91
+ const choice: StageChoice = step.stage ?? { kind: "opening" };
92
+ const staged = stageTile(doc, choice, tiles);
93
+
94
+ switch (staged.type) {
95
+ case "off_canvas":
96
+ return { type: "issues", issues: [{ code: "STAGE_NOT_ON_CANVAS", stepId: step.id, detail: staged.detail }] };
97
+ case "on_canvas":
98
+ break;
99
+ default:
100
+ return assertNever(staged, "Unhandled staged tile");
101
+ }
102
+
103
+ const focus = resolveFocus(doc, step, choice, staged.tile.atlas);
104
+
105
+ switch (focus.type) {
106
+ case "focus":
107
+ return {
108
+ type: "step",
109
+ step: { id: step.id, heading: step.heading, body: step.body, tileId: staged.tile.id, focus: focus.focus },
110
+ };
111
+ case "issues":
112
+ return focus;
113
+ default:
114
+ return assertNever(focus, "Unhandled focus resolution");
115
+ }
116
+ }
117
+
118
+ type StagedTile = { type: "on_canvas"; tile: WalkthroughTile } | { type: "off_canvas"; detail: string };
119
+
120
+ export function tileForStage<Tile extends WalkthroughTile>(
121
+ doc: GraphDoc,
122
+ stage: StepStage | undefined,
123
+ tiles: readonly Tile[],
124
+ ): Tile | undefined {
125
+ const staged = stageTile(doc, stage ?? { kind: "opening" }, tiles);
126
+ switch (staged.type) {
127
+ case "on_canvas":
128
+ return tiles.find((tile) => tile.id === staged.tile.id);
129
+ case "off_canvas":
130
+ return undefined;
131
+ default:
132
+ return assertNever(staged, "Unhandled staged tile");
133
+ }
134
+ }
135
+
136
+ /** A flow is a tile only when there are no data-flow views; otherwise the first view drawing it is the stage. */
137
+ function stageTile(doc: GraphDoc, choice: StageChoice, tiles: readonly WalkthroughTile[]): StagedTile {
138
+ switch (choice.kind) {
139
+ case "opening": {
140
+ const opening = tiles.find((tile) => tile.hero);
141
+ return opening === undefined
142
+ ? { type: "off_canvas", detail: "has no stage of its own, and this document draws no opening diagram" }
143
+ : { type: "on_canvas", tile: opening };
144
+ }
145
+ case "view":
146
+ return named(tiles, [tileIdForView(choice.view)], `plays over view "${choice.view}", which the canvas does not draw`);
147
+ case "flow":
148
+ return named(
149
+ tiles,
150
+ [tileIdForFlow(choice.flow), ...viewTilesDrawing(doc, choice.flow)],
151
+ `plays over flow "${choice.flow}", which the canvas does not draw`,
152
+ );
153
+ default:
154
+ return assertNever(choice, "Unhandled stage");
155
+ }
156
+ }
157
+
158
+ /** The first of these tiles in strip order, not in the order asked for. */
159
+ function named(tiles: readonly WalkthroughTile[], ids: readonly string[], detail: string): StagedTile {
160
+ const wanted = new Set(ids);
161
+ const tile = tiles.find((candidate) => wanted.has(candidate.id));
162
+ return tile === undefined ? { type: "off_canvas", detail } : { type: "on_canvas", tile };
163
+ }
164
+
165
+ const scopeTakesFlow = (scope: ViewScope, flowId: string): boolean => {
166
+ switch (scope.kind) {
167
+ case "all":
168
+ return true;
169
+ case "selection":
170
+ return scope.flows.includes(flowId);
171
+ default:
172
+ return assertNever(scope, "Unhandled view scope");
173
+ }
174
+ };
175
+
176
+ const viewTilesDrawing = (doc: GraphDoc, flowId: string): string[] =>
177
+ flattenViews(doc.views)
178
+ .filter((view) => view.lens === "data-flow" && scopeTakesFlow(view.scope, flowId))
179
+ .map((view) => tileIdForView(view.id));
180
+
181
+ type FocusResolution = { type: "focus"; focus: ResolvedFocus } | { type: "issues"; issues: WalkthroughIssue[] };
182
+
183
+ function resolveFocus(
184
+ doc: GraphDoc,
185
+ step: WalkthroughStep,
186
+ choice: StageChoice,
187
+ atlas: RenderAtlas | undefined,
188
+ ): FocusResolution {
189
+ switch (step.focus.kind) {
190
+ case "all":
191
+ return { type: "focus", focus: { kind: "whole" } };
192
+ case "selection":
193
+ return atlas === undefined
194
+ ? {
195
+ type: "issues",
196
+ issues: [
197
+ {
198
+ code: "ATLAS_MISSING",
199
+ stepId: step.id,
200
+ detail: "plays over a diagram drawn without the geometry a walkthrough needs",
201
+ },
202
+ ],
203
+ }
204
+ : selectionRect(doc, step.id, step.focus, choice, atlas);
205
+ default:
206
+ return assertNever(step.focus, "Unhandled step focus");
207
+ }
208
+ }
209
+
210
+ type Selection = Extract<StepFocus, { kind: "selection" }>;
211
+
212
+ /** The padded union of everything a step names. A member never drawn is an issue, not a skip. */
213
+ function selectionRect(
214
+ doc: GraphDoc,
215
+ stepId: string,
216
+ selection: Selection,
217
+ choice: StageChoice,
218
+ atlas: RenderAtlas,
219
+ ): FocusResolution {
220
+ const boxes: Box[] = [];
221
+ const issues: WalkthroughIssue[] = [];
222
+
223
+ const take = (kind: string, id: string, box: Box | undefined): void => {
224
+ if (box === undefined) {
225
+ issues.push({
226
+ code: "FOCUS_NOT_DRAWN",
227
+ stepId,
228
+ detail: `focuses ${kind} "${id}", which the diagram it plays over does not draw`,
229
+ });
230
+ return;
231
+ }
232
+ boxes.push(box);
233
+ };
234
+
235
+ for (const id of selection.lanes) take("lane", id, atlas.lanes[id]);
236
+ for (const id of selection.nodes) take("node", id, atlas.nodes[id]);
237
+ for (const id of selection.edges) take("edge", id, atlas.edges[id]);
238
+ for (const id of selection.messages) take("flow step", id, messageBox(doc, choice, atlas, id));
239
+ for (const id of selection.tables) take("table", id, atlas.tables[id]);
240
+
241
+ if (issues.length > 0) return { type: "issues", issues };
242
+
243
+ // A selection that names nothing does not parse, so one with no issues left has at least one box.
244
+ const covered = union(boxes);
245
+ if (covered === undefined) throw new Error("a selection with nothing drawn is reported, not framed");
246
+ return { type: "focus", focus: { kind: "region", rect: inflate(covered, FOCUS_PAD) } };
247
+ }
248
+
249
+ /** A flow step's id is unique only inside its flow, so a view stage asks the document's flows in order. */
250
+ function messageBox(doc: GraphDoc, choice: StageChoice, atlas: RenderAtlas, messageId: string): Box | undefined {
251
+ for (const flowId of flowsToAsk(doc, choice)) {
252
+ const box = atlas.messages[flowId]?.[messageId];
253
+ if (box !== undefined) return box;
254
+ }
255
+ return undefined;
256
+ }
257
+
258
+ const flowsToAsk = (doc: GraphDoc, choice: StageChoice): string[] => {
259
+ switch (choice.kind) {
260
+ case "flow":
261
+ return [choice.flow];
262
+ case "opening":
263
+ case "view":
264
+ return doc.flows.map((flow) => flow.id);
265
+ default:
266
+ return assertNever(choice, "Unhandled stage");
267
+ }
268
+ };
@@ -0,0 +1,17 @@
1
+ import type { StepStage } from "@coldtea/pr-lens-schema";
2
+
3
+ /** The one spelling of every tile id. Kept apart from the drawing so a browser can read one back. */
4
+ const VIEW_PREFIX = "view:";
5
+ const FLOW_PREFIX = "flow:";
6
+
7
+ export const tileIdForView = (viewId: string): string => `${VIEW_PREFIX}${viewId}`;
8
+ export const tileIdForFlow = (flowId: string): string => `${FLOW_PREFIX}${flowId}`;
9
+ export const WHOLE_MAP_TILE_ID = "lens:architecture";
10
+ export const WHOLE_MODEL_TILE_ID = "lens:data-model";
11
+
12
+ /** The stage a tile stands for. A whole lens drawn without a view has none. */
13
+ export function stageOfTile(tileId: string): StepStage | null {
14
+ if (tileId.startsWith(VIEW_PREFIX)) return { kind: "view", view: tileId.slice(VIEW_PREFIX.length) };
15
+ if (tileId.startsWith(FLOW_PREFIX)) return { kind: "flow", flow: tileId.slice(FLOW_PREFIX.length) };
16
+ return null;
17
+ }
@@ -0,0 +1,106 @@
1
+ import type { Flow, GraphDoc, Lens, View } from "@coldtea/pr-lens-schema";
2
+ import { render, type RenderedSvg } from "../render.js";
3
+ import { flattenViews } from "../scope.js";
4
+ import type { Theme } from "../theme.js";
5
+ import { tileIdForFlow, tileIdForView, WHOLE_MAP_TILE_ID, WHOLE_MODEL_TILE_ID } from "./tile-ids.js";
6
+
7
+ /** The strip's tiles in order: hero, other architecture views, data-flow views, data-model views. */
8
+ export type TilePlan = {
9
+ id: string;
10
+ title: string;
11
+ lens: Lens;
12
+ hero: boolean;
13
+ draw: (theme: Theme) => RenderedSvg;
14
+ };
15
+
16
+ const viewTile = (doc: GraphDoc, view: View, hero: boolean): TilePlan => ({
17
+ id: tileIdForView(view.id),
18
+ title: view.title,
19
+ lens: view.lens,
20
+ hero,
21
+ draw: (theme) => render(doc, { lens: view.lens, theme, view: view.id }),
22
+ });
23
+
24
+ const wholeMapTile = (doc: GraphDoc): TilePlan => ({
25
+ id: WHOLE_MAP_TILE_ID,
26
+ title: doc.title,
27
+ lens: "architecture",
28
+ hero: true,
29
+ draw: (theme) => render(doc, { lens: "architecture", theme }),
30
+ });
31
+
32
+ const flowTiles = (doc: GraphDoc): TilePlan[] =>
33
+ doc.flows.map((flow) => ({
34
+ id: tileIdForFlow(flow.id),
35
+ title: flow.title,
36
+ lens: "data-flow",
37
+ hero: false,
38
+ draw: (theme) => renderFlow(doc, flow, theme),
39
+ }));
40
+
41
+ const wholeModelTile = (doc: GraphDoc): TilePlan => ({
42
+ id: WHOLE_MODEL_TILE_ID,
43
+ title: doc.title,
44
+ lens: "data-model",
45
+ hero: false,
46
+ draw: (theme) => render(doc, { lens: "data-model", theme }),
47
+ });
48
+
49
+ export function planTiles(doc: GraphDoc): TilePlan[] {
50
+ const placed = flattenViews(doc.views);
51
+
52
+ const architecture = placed.filter((view) => view.lens === "architecture");
53
+ const heroIndex = Math.max(architecture.findIndex((view) => view.defaultOpen), 0);
54
+ const architectureTiles =
55
+ architecture.length > 0
56
+ ? [
57
+ ...architecture.slice(heroIndex, heroIndex + 1).map((view) => viewTile(doc, view, true)),
58
+ ...architecture.filter((_, index) => index !== heroIndex).map((view) => viewTile(doc, view, false)),
59
+ ]
60
+ : doc.lenses.includes("architecture")
61
+ ? [wholeMapTile(doc)]
62
+ : [];
63
+
64
+ const dataFlow = placed.filter((view) => view.lens === "data-flow");
65
+ const dataFlowTiles =
66
+ dataFlow.length > 0
67
+ ? dataFlow.map((view) => viewTile(doc, view, false))
68
+ : doc.lenses.includes("data-flow")
69
+ ? flowTiles(doc)
70
+ : [];
71
+
72
+ const dataModel = placed.filter((view) => view.lens === "data-model");
73
+ const dataModelTiles =
74
+ dataModel.length > 0
75
+ ? dataModel.map((view) => viewTile(doc, view, false))
76
+ : doc.lenses.includes("data-model") && doc.dataModel !== undefined
77
+ ? [wholeModelTile(doc)]
78
+ : [];
79
+
80
+ const tiles = [...architectureTiles, ...dataFlowTiles, ...dataModelTiles];
81
+ // A step without a stage plays over whatever leads the strip.
82
+ return tiles.map((tile, index) => ({ ...tile, hero: index === 0 }));
83
+ }
84
+
85
+ /** The only view on the copy it is attached to, so a fixed id cannot collide. */
86
+ const FLOW_VIEW_ID = "pr-lens-flow";
87
+
88
+ /** One sequence on its own. The renderer narrows to a flow only through a view scoped to it. */
89
+ export function renderFlow(doc: GraphDoc, flow: Flow, theme: Theme): RenderedSvg {
90
+ const scoped: GraphDoc = {
91
+ ...doc,
92
+ views: [
93
+ {
94
+ id: FLOW_VIEW_ID,
95
+ title: flow.title,
96
+ lens: "data-flow",
97
+ ...(flow.summary === undefined ? {} : { summary: flow.summary }),
98
+ scope: { kind: "selection", lanes: [], nodes: [], edges: [], flows: [flow.id], tables: [] },
99
+ defaultOpen: false,
100
+ children: [],
101
+ },
102
+ ],
103
+ };
104
+
105
+ return render(scoped, { lens: "data-flow", theme, view: FLOW_VIEW_ID });
106
+ }