@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.
- package/README.md +15 -0
- package/dist/atlas.d.ts +6 -0
- package/dist/atlas.d.ts.map +1 -1
- package/dist/atlas.js +1 -1
- package/dist/atlas.js.map +1 -1
- package/dist/glob.d.ts.map +1 -1
- package/dist/glob.js +72 -9
- package/dist/glob.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +11 -3
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render-all.d.ts +35 -0
- package/dist/render-all.d.ts.map +1 -0
- package/dist/render-all.js +110 -0
- package/dist/render-all.js.map +1 -0
- package/dist/render.d.ts +4 -31
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +5 -75
- package/dist/render.js.map +1 -1
- package/dist/svg/datamodel.d.ts.map +1 -1
- package/dist/svg/datamodel.js +8 -1
- package/dist/svg/datamodel.js.map +1 -1
- package/dist/svg/document.d.ts +8 -0
- package/dist/svg/document.d.ts.map +1 -1
- package/dist/svg/document.js +3 -2
- package/dist/svg/document.js.map +1 -1
- package/dist/theme.d.ts +2 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +1 -0
- package/dist/theme.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walkthrough/camera.d.ts +43 -0
- package/dist/walkthrough/camera.d.ts.map +1 -0
- package/dist/walkthrough/camera.js +101 -0
- package/dist/walkthrough/camera.js.map +1 -0
- package/dist/walkthrough/caption.d.ts +5 -0
- package/dist/walkthrough/caption.d.ts.map +1 -0
- package/dist/walkthrough/caption.js +36 -0
- package/dist/walkthrough/caption.js.map +1 -0
- package/dist/walkthrough/embed.d.ts +31 -0
- package/dist/walkthrough/embed.d.ts.map +1 -0
- package/dist/walkthrough/embed.js +297 -0
- package/dist/walkthrough/embed.js.map +1 -0
- package/dist/walkthrough/index.d.ts +8 -0
- package/dist/walkthrough/index.d.ts.map +1 -0
- package/dist/walkthrough/index.js +6 -0
- package/dist/walkthrough/index.js.map +1 -0
- package/dist/walkthrough/layout.d.ts +28 -0
- package/dist/walkthrough/layout.d.ts.map +1 -0
- package/dist/walkthrough/layout.js +31 -0
- package/dist/walkthrough/layout.js.map +1 -0
- package/dist/walkthrough/nest.d.ts +4 -0
- package/dist/walkthrough/nest.d.ts.map +1 -0
- package/dist/walkthrough/nest.js +4 -0
- package/dist/walkthrough/nest.js.map +1 -0
- package/dist/walkthrough/resolve.d.ts +45 -0
- package/dist/walkthrough/resolve.d.ts.map +1 -0
- package/dist/walkthrough/resolve.js +189 -0
- package/dist/walkthrough/resolve.js.map +1 -0
- package/dist/walkthrough/tile-ids.d.ts +8 -0
- package/dist/walkthrough/tile-ids.d.ts.map +1 -0
- package/dist/walkthrough/tile-ids.js +16 -0
- package/dist/walkthrough/tile-ids.js.map +1 -0
- package/dist/walkthrough/tiles.d.ts +15 -0
- package/dist/walkthrough/tiles.d.ts.map +1 -0
- package/dist/walkthrough/tiles.js +80 -0
- package/dist/walkthrough/tiles.js.map +1 -0
- package/package.json +7 -3
- package/src/atlas.ts +7 -1
- package/src/glob.ts +76 -11
- package/src/index.ts +23 -6
- package/src/manifest.ts +20 -3
- package/src/render-all.ts +195 -0
- package/src/render.ts +8 -116
- package/src/svg/datamodel.ts +13 -0
- package/src/svg/document.ts +3 -2
- package/src/theme.ts +4 -0
- package/src/version.ts +1 -1
- package/src/walkthrough/camera.ts +142 -0
- package/src/walkthrough/caption.ts +35 -0
- package/src/walkthrough/embed.ts +436 -0
- package/src/walkthrough/index.ts +42 -0
- package/src/walkthrough/layout.ts +46 -0
- package/src/walkthrough/nest.ts +6 -0
- package/src/walkthrough/resolve.ts +268 -0
- package/src/walkthrough/tile-ids.ts +17 -0
- package/src/walkthrough/tiles.ts +106 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Config,
|
|
3
|
+
GraphDoc,
|
|
4
|
+
Lens,
|
|
5
|
+
RenderAsset,
|
|
6
|
+
RenderManifest,
|
|
7
|
+
View,
|
|
8
|
+
WalkthroughAsset,
|
|
9
|
+
} from "@coldtea/pr-lens-schema";
|
|
10
|
+
import { assertNever, MAX_RENDER_ASSETS } from "@coldtea/pr-lens-schema";
|
|
11
|
+
import { PrLensRenderError } from "./errors.js";
|
|
12
|
+
import {
|
|
13
|
+
assetIdentity,
|
|
14
|
+
buildManifest,
|
|
15
|
+
renderAssetFileName,
|
|
16
|
+
renderAssetId,
|
|
17
|
+
walkthroughAssetFileName,
|
|
18
|
+
walkthroughAssetId,
|
|
19
|
+
} from "./manifest.js";
|
|
20
|
+
import { prepare, render, type RenderedSvg } from "./render.js";
|
|
21
|
+
import { flattenViews } from "./scope.js";
|
|
22
|
+
import { isPairTheme, THEME_PAIR } from "./theme.js";
|
|
23
|
+
import {
|
|
24
|
+
renderWalkthroughEmbed,
|
|
25
|
+
type WalkthroughEmbed,
|
|
26
|
+
} from "./walkthrough/embed.js";
|
|
27
|
+
import type { WalkthroughIssue } from "./walkthrough/resolve.js";
|
|
28
|
+
import { tileIdForView } from "./walkthrough/tile-ids.js";
|
|
29
|
+
|
|
30
|
+
/** The pair, one half of it, or neutral. Never all three: the view cap assumes two assets per view. */
|
|
31
|
+
export type RenderThemes =
|
|
32
|
+
| readonly ["light", "dark"]
|
|
33
|
+
| readonly ["light"]
|
|
34
|
+
| readonly ["dark"]
|
|
35
|
+
| readonly ["neutral"];
|
|
36
|
+
|
|
37
|
+
export type RenderAllOptions = {
|
|
38
|
+
config?: Config;
|
|
39
|
+
/** The pair by default. */
|
|
40
|
+
themes?: RenderThemes;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type RenderedAsset = RenderedSvg & { asset: RenderAsset };
|
|
44
|
+
|
|
45
|
+
export type RenderedWalkthrough = WalkthroughEmbed & {
|
|
46
|
+
asset: WalkthroughAsset;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** `absent` for a neutral render too: the embed has no single-image form. */
|
|
50
|
+
export type RenderedWalkthroughs =
|
|
51
|
+
| { type: "absent" }
|
|
52
|
+
| { type: "unresolvable"; issues: WalkthroughIssue[] }
|
|
53
|
+
| { type: "rendered"; assets: RenderedWalkthrough[] };
|
|
54
|
+
|
|
55
|
+
export type RenderAllResult = {
|
|
56
|
+
assets: RenderedAsset[];
|
|
57
|
+
walkthrough: RenderedWalkthroughs;
|
|
58
|
+
manifest: RenderManifest;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Every drill-down section in every theme asked for, or one diagram per lens when there are none. */
|
|
62
|
+
export const renderAll = (
|
|
63
|
+
doc: GraphDoc,
|
|
64
|
+
options: RenderAllOptions = {},
|
|
65
|
+
): RenderAllResult => {
|
|
66
|
+
const prepared = prepare(doc, options.config);
|
|
67
|
+
const themes = options.themes ?? THEME_PAIR;
|
|
68
|
+
|
|
69
|
+
const targets: { lens: Lens; view: View | undefined }[] =
|
|
70
|
+
prepared.views.length > 0
|
|
71
|
+
? flattenViews(prepared.views)
|
|
72
|
+
.filter((view) => prepared.lenses.includes(view.lens))
|
|
73
|
+
.map((view) => ({ lens: view.lens, view }))
|
|
74
|
+
: prepared.lenses
|
|
75
|
+
.filter((lens) => {
|
|
76
|
+
switch (lens) {
|
|
77
|
+
case "architecture":
|
|
78
|
+
return true;
|
|
79
|
+
case "data-flow":
|
|
80
|
+
return prepared.flows.length > 0;
|
|
81
|
+
case "data-model":
|
|
82
|
+
return prepared.dataModel !== undefined;
|
|
83
|
+
default:
|
|
84
|
+
return assertNever(lens, "Unhandled lens");
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.map((lens) => ({ lens, view: undefined }));
|
|
88
|
+
|
|
89
|
+
// Only a hand-built document can reach this: the contract's cap is a refinement, which the type does not carry.
|
|
90
|
+
if (targets.length * themes.length > MAX_RENDER_ASSETS)
|
|
91
|
+
throw new PrLensRenderError(
|
|
92
|
+
"TOO_MANY_ASSETS",
|
|
93
|
+
`this document asks for ${targets.length * themes.length} pictures across ${targets.length} views ` +
|
|
94
|
+
`and ${themes.length} themes, and a render manifest carries at most ${MAX_RENDER_ASSETS}`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const assets: RenderedAsset[] = [];
|
|
98
|
+
|
|
99
|
+
for (const target of targets)
|
|
100
|
+
for (const theme of themes) {
|
|
101
|
+
const rendered = render(prepared, {
|
|
102
|
+
lens: target.lens,
|
|
103
|
+
theme,
|
|
104
|
+
view: target.view?.id,
|
|
105
|
+
});
|
|
106
|
+
const address = { lens: target.lens, theme, view: target.view?.id };
|
|
107
|
+
const identity = assetIdentity(rendered.svg);
|
|
108
|
+
|
|
109
|
+
assets.push({
|
|
110
|
+
...rendered,
|
|
111
|
+
asset: {
|
|
112
|
+
id: renderAssetId(address),
|
|
113
|
+
lens: target.lens,
|
|
114
|
+
theme,
|
|
115
|
+
view: target.view?.id,
|
|
116
|
+
mediaType: "image/svg+xml",
|
|
117
|
+
...identity,
|
|
118
|
+
width: rendered.width,
|
|
119
|
+
height: rendered.height,
|
|
120
|
+
animated: rendered.animated,
|
|
121
|
+
path: renderAssetFileName(address, identity.contentHash),
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (assets.length === 0)
|
|
127
|
+
throw new PrLensRenderError(
|
|
128
|
+
"NOTHING_TO_RENDER",
|
|
129
|
+
"this document declares no renderable view",
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const walkthrough = renderWalkthroughs(prepared, themes, assets);
|
|
133
|
+
const walkthroughAssets =
|
|
134
|
+
walkthrough.type === "rendered"
|
|
135
|
+
? walkthrough.assets.map(({ asset }) => asset)
|
|
136
|
+
: [];
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
assets,
|
|
140
|
+
walkthrough,
|
|
141
|
+
manifest: buildManifest(
|
|
142
|
+
prepared,
|
|
143
|
+
assets.map(({ asset }) => asset),
|
|
144
|
+
walkthroughAssets,
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Hands the views just drawn to the embed, which then draws only flow and whole-model tiles. */
|
|
150
|
+
const renderWalkthroughs = (
|
|
151
|
+
doc: GraphDoc,
|
|
152
|
+
themes: RenderThemes,
|
|
153
|
+
drawn: readonly RenderedAsset[],
|
|
154
|
+
): RenderedWalkthroughs => {
|
|
155
|
+
const assets: RenderedWalkthrough[] = [];
|
|
156
|
+
|
|
157
|
+
for (const theme of themes.filter(isPairTheme)) {
|
|
158
|
+
const tiles = new Map(
|
|
159
|
+
drawn.flatMap((asset) =>
|
|
160
|
+
asset.theme === theme && asset.view !== undefined
|
|
161
|
+
? [[tileIdForView(asset.view), asset]]
|
|
162
|
+
: [],
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const result = renderWalkthroughEmbed(doc, { theme, tiles });
|
|
166
|
+
switch (result.type) {
|
|
167
|
+
case "absent":
|
|
168
|
+
case "unresolvable":
|
|
169
|
+
return result;
|
|
170
|
+
case "rendered": {
|
|
171
|
+
const identity = assetIdentity(result.embed.svg);
|
|
172
|
+
assets.push({
|
|
173
|
+
...result.embed,
|
|
174
|
+
asset: {
|
|
175
|
+
id: walkthroughAssetId(theme),
|
|
176
|
+
theme,
|
|
177
|
+
mediaType: "image/svg+xml",
|
|
178
|
+
...identity,
|
|
179
|
+
width: result.embed.width,
|
|
180
|
+
height: result.embed.height,
|
|
181
|
+
steps: result.embed.steps,
|
|
182
|
+
path: walkthroughAssetFileName(theme, identity.contentHash),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
default:
|
|
188
|
+
return assertNever(result, "Unhandled walkthrough embed result");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return assets.length === 0
|
|
193
|
+
? { type: "absent" }
|
|
194
|
+
: { type: "rendered", assets };
|
|
195
|
+
};
|
package/src/render.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import type { Config, GraphDoc, Lens,
|
|
2
|
-
import { assertNever
|
|
1
|
+
import type { Config, GraphDoc, Lens, View, ViewScope } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
3
3
|
import type { RenderAtlas } from "./atlas.js";
|
|
4
4
|
import { applyCorrections } from "./corrections.js";
|
|
5
5
|
import { PrLensRenderError } from "./errors.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { paletteFor, THEME_PAIR, type Theme } from "./theme.js";
|
|
6
|
+
import { findView, resolveScope, type ScopedGraph } from "./scope.js";
|
|
7
|
+
import { paletteFor, type Theme } from "./theme.js";
|
|
9
8
|
import { paintArchitecture } from "./svg/architecture.js";
|
|
10
9
|
import { paintDataFlow } from "./svg/dataflow.js";
|
|
11
10
|
import { paintDataModel } from "./svg/datamodel.js";
|
|
@@ -22,6 +21,8 @@ export type RenderOptions = {
|
|
|
22
21
|
|
|
23
22
|
export type RenderedSvg = {
|
|
24
23
|
svg: string;
|
|
24
|
+
/** The painting alone, for a document that nests this picture under its own defs and ground. */
|
|
25
|
+
body: string;
|
|
25
26
|
width: number;
|
|
26
27
|
height: number;
|
|
27
28
|
lens: Lens;
|
|
@@ -34,7 +35,7 @@ export type RenderedSvg = {
|
|
|
34
35
|
|
|
35
36
|
const WHOLE_DOCUMENT: ViewScope = { kind: "all" };
|
|
36
37
|
|
|
37
|
-
const prepare = (doc: GraphDoc, config: Config | undefined): GraphDoc =>
|
|
38
|
+
export const prepare = (doc: GraphDoc, config: Config | undefined): GraphDoc =>
|
|
38
39
|
config === undefined ? doc : applyCorrections(doc, config.map);
|
|
39
40
|
|
|
40
41
|
const paint = (
|
|
@@ -111,6 +112,7 @@ export const render = (doc: GraphDoc, options: RenderOptions): RenderedSvg => {
|
|
|
111
112
|
|
|
112
113
|
return {
|
|
113
114
|
svg,
|
|
115
|
+
body,
|
|
114
116
|
width,
|
|
115
117
|
height,
|
|
116
118
|
lens: options.lens,
|
|
@@ -127,113 +129,3 @@ const requireView = (views: readonly View[], id: string): View => {
|
|
|
127
129
|
throw new PrLensRenderError("UNKNOWN_VIEW", `this document has no view '${id}'`);
|
|
128
130
|
return view;
|
|
129
131
|
};
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* What a render may be asked for: the `<picture>` pair, one half of it, or
|
|
133
|
-
* the single self-contained render.
|
|
134
|
-
*
|
|
135
|
-
* Not `readonly Theme[]`, which would admit all three at once — three assets
|
|
136
|
-
* per view against a view cap derived from two, so a document that validates
|
|
137
|
-
* could produce a manifest that does not. The contract's promise is that if
|
|
138
|
-
* it parses, it renders; this is the boundary that keeps it true.
|
|
139
|
-
*/
|
|
140
|
-
export type RenderThemes =
|
|
141
|
-
| readonly ["light", "dark"]
|
|
142
|
-
| readonly ["light"]
|
|
143
|
-
| readonly ["dark"]
|
|
144
|
-
| readonly ["neutral"];
|
|
145
|
-
|
|
146
|
-
export type RenderAllOptions = {
|
|
147
|
-
config?: Config;
|
|
148
|
-
/** The `<picture>` pair by default; `['neutral']` for a single-image surface. */
|
|
149
|
-
themes?: RenderThemes;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
export type RenderedAsset = RenderedSvg & { asset: RenderAsset };
|
|
153
|
-
|
|
154
|
-
export type RenderAllResult = {
|
|
155
|
-
assets: RenderedAsset[];
|
|
156
|
-
manifest: RenderManifest;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Every drill-down section a comment will show, in both themes.
|
|
161
|
-
*
|
|
162
|
-
* A document with no sections still renders: it gets one diagram per lens it
|
|
163
|
-
* declares, which is what a comment falls back to when extraction found
|
|
164
|
-
* nothing worth splitting into a tree.
|
|
165
|
-
*/
|
|
166
|
-
export const renderAll = (doc: GraphDoc, options: RenderAllOptions = {}): RenderAllResult => {
|
|
167
|
-
const prepared = prepare(doc, options.config);
|
|
168
|
-
const themes = options.themes ?? THEME_PAIR;
|
|
169
|
-
|
|
170
|
-
const targets: { lens: Lens; view: View | undefined }[] =
|
|
171
|
-
prepared.views.length > 0
|
|
172
|
-
? flattenViews(prepared.views)
|
|
173
|
-
.filter((view) => prepared.lenses.includes(view.lens))
|
|
174
|
-
.map((view) => ({ lens: view.lens, view }))
|
|
175
|
-
: prepared.lenses
|
|
176
|
-
.filter((lens) => {
|
|
177
|
-
switch (lens) {
|
|
178
|
-
case "architecture":
|
|
179
|
-
return true;
|
|
180
|
-
case "data-flow":
|
|
181
|
-
return prepared.flows.length > 0;
|
|
182
|
-
case "data-model":
|
|
183
|
-
return prepared.dataModel !== undefined;
|
|
184
|
-
default:
|
|
185
|
-
return assertNever(lens, "Unhandled lens");
|
|
186
|
-
}
|
|
187
|
-
})
|
|
188
|
-
.map((lens) => ({ lens, view: undefined }));
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The contract caps a view tree at the number of views a two-theme render
|
|
192
|
-
* fits inside a manifest, so a parsed document cannot reach this. A
|
|
193
|
-
* hand-built one can: the cap lives in a refinement, and a refinement does
|
|
194
|
-
* not survive into the inferred type. This is a postcondition on what is
|
|
195
|
-
* about to be produced rather than a re-reading of what came in — the count
|
|
196
|
-
* depends on how many themes the caller asked for, which no document knows.
|
|
197
|
-
*/
|
|
198
|
-
if (targets.length * themes.length > MAX_RENDER_ASSETS)
|
|
199
|
-
throw new PrLensRenderError(
|
|
200
|
-
"TOO_MANY_ASSETS",
|
|
201
|
-
`this document asks for ${targets.length * themes.length} pictures across ${targets.length} views ` +
|
|
202
|
-
`and ${themes.length} themes, and a render manifest carries at most ${MAX_RENDER_ASSETS}`,
|
|
203
|
-
);
|
|
204
|
-
|
|
205
|
-
const assets: RenderedAsset[] = [];
|
|
206
|
-
|
|
207
|
-
for (const target of targets)
|
|
208
|
-
for (const theme of themes) {
|
|
209
|
-
const rendered = render(prepared, {
|
|
210
|
-
lens: target.lens,
|
|
211
|
-
theme,
|
|
212
|
-
view: target.view?.id,
|
|
213
|
-
});
|
|
214
|
-
const address = { lens: target.lens, theme, view: target.view?.id };
|
|
215
|
-
const hash = contentHash(rendered.svg);
|
|
216
|
-
|
|
217
|
-
assets.push({
|
|
218
|
-
...rendered,
|
|
219
|
-
asset: {
|
|
220
|
-
id: renderAssetId(address),
|
|
221
|
-
lens: target.lens,
|
|
222
|
-
theme,
|
|
223
|
-
view: target.view?.id,
|
|
224
|
-
mediaType: "image/svg+xml",
|
|
225
|
-
contentHash: hash,
|
|
226
|
-
bytes: Buffer.byteLength(rendered.svg, "utf8"),
|
|
227
|
-
width: rendered.width,
|
|
228
|
-
height: rendered.height,
|
|
229
|
-
animated: rendered.animated,
|
|
230
|
-
path: renderAssetFileName(address, hash),
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (assets.length === 0)
|
|
236
|
-
throw new PrLensRenderError("NOTHING_TO_RENDER", "this document declares no renderable view");
|
|
237
|
-
|
|
238
|
-
return { assets, manifest: buildManifest(prepared, assets.map(({ asset }) => asset)) };
|
|
239
|
-
};
|
package/src/svg/datamodel.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
LANE_RADIUS,
|
|
36
36
|
LANE_TOP,
|
|
37
37
|
SUBTITLE_SIZE,
|
|
38
|
+
TABLE_ROW_HEIGHT,
|
|
38
39
|
TABLE_ROW_TEXT_SIZE,
|
|
39
40
|
TABLE_ROW_TYPE_SIZE,
|
|
40
41
|
} from "../design.js";
|
|
@@ -335,6 +336,18 @@ export const paintDataModel = (graph: ScopedGraph, palette: Palette): DataModelP
|
|
|
335
336
|
...emptyAtlas(),
|
|
336
337
|
lanes: atlasBoxes(layout.stores.map(({ store, box }) => ({ id: store.id, box })), canvas),
|
|
337
338
|
tables: atlasBoxes(layout.tables.map(({ table, box }) => ({ id: table.id, box })), canvas),
|
|
339
|
+
columns: Object.fromEntries(
|
|
340
|
+
layout.tables.map(({ table, box, columns }) => [
|
|
341
|
+
table.id,
|
|
342
|
+
atlasBoxes(
|
|
343
|
+
columns.map(({ column, top }) => ({
|
|
344
|
+
id: column.name,
|
|
345
|
+
box: { x: box.x, y: top, width: box.width, height: TABLE_ROW_HEIGHT },
|
|
346
|
+
})),
|
|
347
|
+
canvas,
|
|
348
|
+
),
|
|
349
|
+
]),
|
|
350
|
+
),
|
|
338
351
|
edges: atlasBoxes(
|
|
339
352
|
routed.map(({ relation, curve }) => ({ id: relation.id, box: curveBounds(curve) })),
|
|
340
353
|
canvas,
|
package/src/svg/document.ts
CHANGED
|
@@ -51,7 +51,8 @@ const TONES = ["added", "modified", "removed", "neutral"] as const satisfies rea
|
|
|
51
51
|
* fires and forgets. The open head anchors at its tip so the line runs all
|
|
52
52
|
* the way into the point, where a filled head covers its own line end.
|
|
53
53
|
*/
|
|
54
|
-
|
|
54
|
+
/** Exported so a document nesting several pictures can define the arrowheads once. */
|
|
55
|
+
export const markerDefs = (palette: Palette): string =>
|
|
55
56
|
TONES.map(
|
|
56
57
|
(tone) =>
|
|
57
58
|
wrap(
|
|
@@ -120,7 +121,7 @@ export const svgDocument = (input: {
|
|
|
120
121
|
"pattern",
|
|
121
122
|
{ id: "dots", width: DOT_PITCH, height: DOT_PITCH, patternUnits: "userSpaceOnUse" },
|
|
122
123
|
tag("circle", { cx: 1.5, cy: 1.5, r: 1, fill: palette.dot }),
|
|
123
|
-
) +
|
|
124
|
+
) + markerDefs(palette),
|
|
124
125
|
);
|
|
125
126
|
|
|
126
127
|
return lines([
|
package/src/theme.ts
CHANGED
|
@@ -12,6 +12,10 @@ import { assertNever, THEMES, THEME_PAIR, type Theme } from "@coldtea/pr-lens-sc
|
|
|
12
12
|
*/
|
|
13
13
|
export { THEMES, THEME_PAIR, type Theme };
|
|
14
14
|
|
|
15
|
+
export type PairTheme = (typeof THEME_PAIR)[number];
|
|
16
|
+
|
|
17
|
+
export const isPairTheme = (theme: Theme): theme is PairTheme => THEME_PAIR.some((half) => half === theme);
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
20
|
* Every colour the renderer can paint, resolved to a literal before it
|
|
17
21
|
* reaches the document. CSS custom properties would be shorter, but GitHub
|
package/src/version.ts
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import type { Box } from "../geometry.js";
|
|
3
|
+
import { MAX_SCALE, MIN_SCALE } from "./layout.js";
|
|
4
|
+
|
|
5
|
+
/** The canvas and the embed both frame steps here, so a step lands the same way in both. */
|
|
6
|
+
export type FramingPolicy = { kind: "strip" } | { kind: "diagram" } | { kind: "selection" } | { kind: "component" };
|
|
7
|
+
|
|
8
|
+
export type Flight = { kind: "cut" } | { kind: "smooth"; durationMs: number };
|
|
9
|
+
|
|
10
|
+
/** The transform a stage applies to the strip. Translate first, then scale. */
|
|
11
|
+
export type Viewport = { x: number; y: number; scale: number };
|
|
12
|
+
|
|
13
|
+
/** The part of the window the pictures get. */
|
|
14
|
+
export type Stage = { left: number; top: number; width: number; height: number };
|
|
15
|
+
|
|
16
|
+
type Framing = { maxScale: number; padding: number };
|
|
17
|
+
|
|
18
|
+
const framing = (policy: FramingPolicy): Framing => {
|
|
19
|
+
switch (policy.kind) {
|
|
20
|
+
// A small render is never enlarged to fill the window.
|
|
21
|
+
case "strip":
|
|
22
|
+
case "diagram":
|
|
23
|
+
return { maxScale: 1, padding: 80 };
|
|
24
|
+
// A selection fills the stage. It is already padded, so it gets less air.
|
|
25
|
+
case "selection":
|
|
26
|
+
return { maxScale: 2, padding: 56 };
|
|
27
|
+
// One component with its neighbours still in view.
|
|
28
|
+
case "component":
|
|
29
|
+
return { maxScale: 1, padding: 220 };
|
|
30
|
+
default:
|
|
31
|
+
return assertNever(policy, "Unhandled framing policy");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The transform that shows `rect` whole and centred in the stage. */
|
|
36
|
+
export function frame(policy: FramingPolicy, rect: Box, stage: Stage): Viewport {
|
|
37
|
+
const { maxScale, padding } = framing(policy);
|
|
38
|
+
// On a stage smaller than the padding, the padding shrinks instead of the picture.
|
|
39
|
+
const air = Math.min(padding, stage.width / 6, stage.height / 6);
|
|
40
|
+
const fitted = Math.min(
|
|
41
|
+
(stage.width - air * 2) / rect.width,
|
|
42
|
+
(stage.height - air * 2) / rect.height,
|
|
43
|
+
maxScale,
|
|
44
|
+
);
|
|
45
|
+
const scale = Math.min(Math.max(fitted, MIN_SCALE), MAX_SCALE);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
x: stage.left + (stage.width - rect.width * scale) / 2 - rect.x * scale,
|
|
49
|
+
y: stage.top + (stage.height - rect.height * scale) / 2 - rect.y * scale,
|
|
50
|
+
scale,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Short enough not to feel like a wait, long enough that a jump across the strip reads as travel. */
|
|
55
|
+
const MIN_FLIGHT_MS = 560;
|
|
56
|
+
const MAX_FLIGHT_MS = 1500;
|
|
57
|
+
const MS_PER_UNIT = 420;
|
|
58
|
+
|
|
59
|
+
export function flightFor(distance: number, reducedMotion: boolean): Flight {
|
|
60
|
+
if (reducedMotion) return { kind: "cut" };
|
|
61
|
+
return { kind: "smooth", durationMs: Math.min(Math.max(distance * MS_PER_UNIT, MIN_FLIGHT_MS), MAX_FLIGHT_MS) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const easeInOut = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
65
|
+
|
|
66
|
+
export const lerpRect = (from: Box, to: Box, t: number): Box => ({
|
|
67
|
+
x: from.x + (to.x - from.x) * t,
|
|
68
|
+
y: from.y + (to.y - from.y) * t,
|
|
69
|
+
width: from.width + (to.width - from.width) * t,
|
|
70
|
+
height: from.height + (to.height - from.height) * t,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** A camera as the interpolation sees it: where it looks, and how much world the stage spans. */
|
|
74
|
+
type View = [x: number, y: number, span: number];
|
|
75
|
+
|
|
76
|
+
const centre = (stage: Stage) => ({ x: stage.left + stage.width / 2, y: stage.top + stage.height / 2 });
|
|
77
|
+
|
|
78
|
+
const toView = (viewport: Viewport, stage: Stage): View => {
|
|
79
|
+
const middle = centre(stage);
|
|
80
|
+
return [
|
|
81
|
+
(middle.x - viewport.x) / viewport.scale,
|
|
82
|
+
(middle.y - viewport.y) / viewport.scale,
|
|
83
|
+
stage.width / viewport.scale,
|
|
84
|
+
];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const fromView = (view: View, stage: Stage): Viewport => {
|
|
88
|
+
const middle = centre(stage);
|
|
89
|
+
const scale = stage.width / view[2];
|
|
90
|
+
return { x: middle.x - view[0] * scale, y: middle.y - view[1] * scale, scale };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type FlightPath = {
|
|
94
|
+
at: (t: number) => Viewport;
|
|
95
|
+
/** The path's length in the interpolation's units. It sets the duration. */
|
|
96
|
+
distance: number;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const cosh = (x: number) => (Math.exp(x) + Math.exp(-x)) / 2;
|
|
100
|
+
const sinh = (x: number) => (Math.exp(x) - Math.exp(-x)) / 2;
|
|
101
|
+
const tanh = (x: number) => {
|
|
102
|
+
const e = Math.exp(2 * x);
|
|
103
|
+
return (e - 1) / (e + 1);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const RHO = Math.SQRT2;
|
|
107
|
+
|
|
108
|
+
/** The van Wijk and Nuij path, as d3-interpolate spells it out: out far enough to see both ends, then back in. */
|
|
109
|
+
export function interpolateFlight(from: Viewport, to: Viewport, stage: Stage): FlightPath {
|
|
110
|
+
const [ux0, uy0, w0] = toView(from, stage);
|
|
111
|
+
const [ux1, uy1, w1] = toView(to, stage);
|
|
112
|
+
const dx = ux1 - ux0;
|
|
113
|
+
const dy = uy1 - uy0;
|
|
114
|
+
const d2 = dx * dx + dy * dy;
|
|
115
|
+
const rho2 = RHO * RHO;
|
|
116
|
+
const rho4 = rho2 * rho2;
|
|
117
|
+
|
|
118
|
+
if (d2 < 1e-12) {
|
|
119
|
+
const distance = Math.abs(Math.log(w1 / w0) / RHO);
|
|
120
|
+
return {
|
|
121
|
+
distance,
|
|
122
|
+
at: (t) => fromView([ux0 + t * dx, uy0 + t * dy, w0 * Math.exp(RHO * t * (Math.log(w1 / w0) / RHO))], stage),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const d1 = Math.sqrt(d2);
|
|
127
|
+
const b0 = (w1 * w1 - w0 * w0 + rho4 * d2) / (2 * w0 * rho2 * d1);
|
|
128
|
+
const b1 = (w1 * w1 - w0 * w0 - rho4 * d2) / (2 * w1 * rho2 * d1);
|
|
129
|
+
const r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0);
|
|
130
|
+
const r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1);
|
|
131
|
+
const S = (r1 - r0) / RHO;
|
|
132
|
+
const c0 = cosh(r0);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
distance: Math.abs(S),
|
|
136
|
+
at: (t) => {
|
|
137
|
+
const s = t * S;
|
|
138
|
+
const u = (w0 / (rho2 * d1)) * (c0 * tanh(RHO * s + r0) - sinh(r0));
|
|
139
|
+
return fromView([ux0 + u * dx, uy0 + u * dy, (w0 * c0) / cosh(RHO * s + r0)], stage);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { measure, type Face } from "../text.js";
|
|
2
|
+
|
|
3
|
+
export const words = (text: string): string[] => text.split(/\s+/).filter((word) => word.length > 0);
|
|
4
|
+
|
|
5
|
+
/** Greedy wrapping, which is also the fewest lines any wrapping needs. */
|
|
6
|
+
const wrapAt = (parts: readonly string[], face: Face, size: number, width: number): string[] => {
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
let line = "";
|
|
9
|
+
for (const word of parts) {
|
|
10
|
+
const joined = line === "" ? word : `${line} ${word}`;
|
|
11
|
+
if (line !== "" && measure(joined, face, size) > width) {
|
|
12
|
+
lines.push(line);
|
|
13
|
+
line = word;
|
|
14
|
+
} else {
|
|
15
|
+
line = joined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return line === "" ? lines : [...lines, line];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The fewest lines that fit, evened out by narrowing the column as far as it goes without adding a line. */
|
|
22
|
+
export const balancedLines = (text: string, face: Face, size: number, maxWidth: number): string[] => {
|
|
23
|
+
const parts = words(text);
|
|
24
|
+
const widest = wrapAt(parts, face, size, maxWidth);
|
|
25
|
+
if (widest.length < 2) return widest;
|
|
26
|
+
|
|
27
|
+
let low = 1;
|
|
28
|
+
let high = maxWidth;
|
|
29
|
+
while (low < high) {
|
|
30
|
+
const middle = Math.floor((low + high) / 2);
|
|
31
|
+
if (wrapAt(parts, face, size, middle).length === widest.length) high = middle;
|
|
32
|
+
else low = middle + 1;
|
|
33
|
+
}
|
|
34
|
+
return wrapAt(parts, face, size, low);
|
|
35
|
+
};
|