@anvpx/motion-scenes 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.
- package/LICENSE +21 -0
- package/README.md +39 -2
- package/dist/catalog.d.ts +17 -0
- package/dist/catalog.js +51 -0
- package/dist/custom.d.ts +86 -0
- package/dist/custom.js +170 -0
- package/dist/define-scene.d.ts +31 -0
- package/dist/define-scene.js +128 -0
- package/dist/html.d.ts +5 -0
- package/dist/html.js +14 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/position.d.ts +21 -0
- package/dist/position.js +16 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +68 -0
- package/dist/runtime/easing.d.ts +14 -0
- package/dist/runtime/easing.js +45 -0
- package/dist/runtime/mount.d.ts +50 -0
- package/dist/runtime/mount.js +153 -0
- package/dist/runtime/timeline.d.ts +147 -0
- package/dist/runtime/timeline.js +199 -0
- package/dist/runtime-files.d.ts +2 -0
- package/dist/runtime-files.js +15 -0
- package/dist/scene-contract.d.ts +7 -0
- package/dist/scene-contract.js +104 -0
- package/dist/scene3d/post.d.ts +12 -0
- package/dist/scene3d/post.js +16 -0
- package/dist/scene3d/reflector.d.ts +9 -0
- package/dist/scene3d/reflector.js +17 -0
- package/dist/scene3d/textures.d.ts +9 -0
- package/dist/scene3d/textures.js +11 -0
- package/dist/scene3d/volumetric.d.ts +9 -0
- package/dist/scene3d/volumetric.js +16 -0
- package/dist/scenes/bug/index.d.ts +1 -0
- package/dist/scenes/bug/index.js +45 -0
- package/dist/scenes/bug/scene.d.ts +2 -0
- package/dist/scenes/bug/scene.js +6 -0
- package/dist/scenes/bug/style.css +30 -0
- package/dist/scenes/bullets/index.d.ts +1 -0
- package/dist/scenes/bullets/index.js +128 -0
- package/dist/scenes/bullets/scene.d.ts +2 -0
- package/dist/scenes/bullets/scene.js +6 -0
- package/dist/scenes/bullets/style.css +91 -0
- package/dist/scenes/callout/index.d.ts +9 -0
- package/dist/scenes/callout/index.js +95 -0
- package/dist/scenes/callout/scene.d.ts +2 -0
- package/dist/scenes/callout/scene.js +43 -0
- package/dist/scenes/callout/style.css +97 -0
- package/dist/scenes/caption/index.d.ts +1 -0
- package/dist/scenes/caption/index.js +72 -0
- package/dist/scenes/caption/scene.d.ts +11 -0
- package/dist/scenes/caption/scene.js +33 -0
- package/dist/scenes/caption/style.css +33 -0
- package/dist/scenes/chapter/index.d.ts +1 -0
- package/dist/scenes/chapter/index.js +29 -0
- package/dist/scenes/chapter/scene.d.ts +2 -0
- package/dist/scenes/chapter/scene.js +6 -0
- package/dist/scenes/chapter/style.css +25 -0
- package/dist/scenes/chart/index.d.ts +1 -0
- package/dist/scenes/chart/index.js +24 -0
- package/dist/scenes/chart/render.d.ts +4 -0
- package/dist/scenes/chart/render.js +257 -0
- package/dist/scenes/chart/scale.d.ts +11 -0
- package/dist/scenes/chart/scale.js +53 -0
- package/dist/scenes/chart/scene.d.ts +2 -0
- package/dist/scenes/chart/scene.js +73 -0
- package/dist/scenes/chart/schema.d.ts +36 -0
- package/dist/scenes/chart/schema.js +109 -0
- package/dist/scenes/chart/style.css +425 -0
- package/dist/scenes/code/index.d.ts +1 -0
- package/dist/scenes/code/index.js +109 -0
- package/dist/scenes/code/scene.d.ts +2 -0
- package/dist/scenes/code/scene.js +108 -0
- package/dist/scenes/code/style.css +155 -0
- package/dist/scenes/code/tokenize.d.ts +8 -0
- package/dist/scenes/code/tokenize.js +145 -0
- package/dist/scenes/compare/index.d.ts +1 -0
- package/dist/scenes/compare/index.js +128 -0
- package/dist/scenes/compare/scene.d.ts +2 -0
- package/dist/scenes/compare/scene.js +58 -0
- package/dist/scenes/compare/style.css +187 -0
- package/dist/scenes/countdown/index.d.ts +1 -0
- package/dist/scenes/countdown/index.js +46 -0
- package/dist/scenes/countdown/scene.d.ts +2 -0
- package/dist/scenes/countdown/scene.js +21 -0
- package/dist/scenes/countdown/style.css +32 -0
- package/dist/scenes/flow/index.d.ts +1 -0
- package/dist/scenes/flow/index.js +66 -0
- package/dist/scenes/flow/inspect.d.ts +26 -0
- package/dist/scenes/flow/inspect.js +25 -0
- package/dist/scenes/flow/layout.d.ts +83 -0
- package/dist/scenes/flow/layout.js +222 -0
- package/dist/scenes/flow/measure.d.ts +15 -0
- package/dist/scenes/flow/measure.js +33 -0
- package/dist/scenes/flow/plan.d.ts +42 -0
- package/dist/scenes/flow/plan.js +117 -0
- package/dist/scenes/flow/scene.d.ts +2 -0
- package/dist/scenes/flow/scene.js +6 -0
- package/dist/scenes/flow/schema.d.ts +49 -0
- package/dist/scenes/flow/schema.js +60 -0
- package/dist/scenes/flow/style.css +54 -0
- package/dist/scenes/hero/index.d.ts +3 -0
- package/dist/scenes/hero/index.js +77 -0
- package/dist/scenes/hero/scene.d.ts +2 -0
- package/dist/scenes/hero/scene.js +6 -0
- package/dist/scenes/hero/style.css +143 -0
- package/dist/scenes/icon-grid/index.d.ts +1 -0
- package/dist/scenes/icon-grid/index.js +69 -0
- package/dist/scenes/icon-grid/scene.d.ts +2 -0
- package/dist/scenes/icon-grid/scene.js +6 -0
- package/dist/scenes/icon-grid/style.css +74 -0
- package/dist/scenes/kinetic/index.d.ts +1 -0
- package/dist/scenes/kinetic/index.js +62 -0
- package/dist/scenes/kinetic/scene.d.ts +2 -0
- package/dist/scenes/kinetic/scene.js +98 -0
- package/dist/scenes/kinetic/style.css +94 -0
- package/dist/scenes/kpi-grid/index.d.ts +1 -0
- package/dist/scenes/kpi-grid/index.js +161 -0
- package/dist/scenes/kpi-grid/scene.d.ts +2 -0
- package/dist/scenes/kpi-grid/scene.js +6 -0
- package/dist/scenes/kpi-grid/style.css +141 -0
- package/dist/scenes/lower-third/index.d.ts +1 -0
- package/dist/scenes/lower-third/index.js +33 -0
- package/dist/scenes/lower-third/scene.d.ts +2 -0
- package/dist/scenes/lower-third/scene.js +6 -0
- package/dist/scenes/lower-third/style.css +45 -0
- package/dist/scenes/media-frame/index.d.ts +1 -0
- package/dist/scenes/media-frame/index.js +99 -0
- package/dist/scenes/media-frame/scene.d.ts +2 -0
- package/dist/scenes/media-frame/scene.js +85 -0
- package/dist/scenes/media-frame/style.css +275 -0
- package/dist/scenes/outro/index.d.ts +1 -0
- package/dist/scenes/outro/index.js +54 -0
- package/dist/scenes/outro/scene.d.ts +2 -0
- package/dist/scenes/outro/scene.js +6 -0
- package/dist/scenes/outro/style.css +92 -0
- package/dist/scenes/poster/index.d.ts +3 -0
- package/dist/scenes/poster/index.js +62 -0
- package/dist/scenes/poster/scene.d.ts +2 -0
- package/dist/scenes/poster/scene.js +6 -0
- package/dist/scenes/poster/style.css +102 -0
- package/dist/scenes/progress/index.d.ts +1 -0
- package/dist/scenes/progress/index.js +40 -0
- package/dist/scenes/progress/scene.d.ts +2 -0
- package/dist/scenes/progress/scene.js +17 -0
- package/dist/scenes/progress/style.css +31 -0
- package/dist/scenes/quote/index.d.ts +3 -0
- package/dist/scenes/quote/index.js +69 -0
- package/dist/scenes/quote/scene.d.ts +2 -0
- package/dist/scenes/quote/scene.js +6 -0
- package/dist/scenes/quote/style.css +86 -0
- package/dist/scenes/scene3d/index.d.ts +1 -0
- package/dist/scenes/scene3d/index.js +58 -0
- package/dist/scenes/scene3d/scene.d.ts +2 -0
- package/dist/scenes/scene3d/scene.js +413 -0
- package/dist/scenes/scene3d/style.css +43 -0
- package/dist/scenes/stat/index.d.ts +4 -0
- package/dist/scenes/stat/index.js +72 -0
- package/dist/scenes/stat/scene.d.ts +2 -0
- package/dist/scenes/stat/scene.js +6 -0
- package/dist/scenes/stat/style.css +95 -0
- package/dist/scenes/steps/index.d.ts +1 -0
- package/dist/scenes/steps/index.js +147 -0
- package/dist/scenes/steps/scene.d.ts +2 -0
- package/dist/scenes/steps/scene.js +6 -0
- package/dist/scenes/steps/style.css +190 -0
- package/dist/scenes/sticker/index.d.ts +1 -0
- package/dist/scenes/sticker/index.js +48 -0
- package/dist/scenes/sticker/scene.d.ts +2 -0
- package/dist/scenes/sticker/scene.js +57 -0
- package/dist/scenes/sticker/style.css +88 -0
- package/dist/scenes/table/index.d.ts +1 -0
- package/dist/scenes/table/index.js +131 -0
- package/dist/scenes/table/scene.d.ts +2 -0
- package/dist/scenes/table/scene.js +6 -0
- package/dist/scenes/table/style.css +102 -0
- package/dist/scenes/timeline/index.d.ts +1 -0
- package/dist/scenes/timeline/index.js +68 -0
- package/dist/scenes/timeline/scene.d.ts +2 -0
- package/dist/scenes/timeline/scene.js +6 -0
- package/dist/scenes/timeline/style.css +161 -0
- package/dist/scenes/title/index.d.ts +1 -0
- package/dist/scenes/title/index.js +52 -0
- package/dist/scenes/title/scene.d.ts +2 -0
- package/dist/scenes/title/scene.js +6 -0
- package/dist/scenes/title/style.css +80 -0
- package/dist/scenes/waveform/index.d.ts +1 -0
- package/dist/scenes/waveform/index.js +39 -0
- package/dist/scenes/waveform/scene.d.ts +2 -0
- package/dist/scenes/waveform/scene.js +35 -0
- package/dist/scenes/waveform/style.css +44 -0
- package/dist/shared/fit-text.d.ts +24 -0
- package/dist/shared/fit-text.js +82 -0
- package/dist/shared/format.d.ts +24 -0
- package/dist/shared/format.js +59 -0
- package/dist/shared/frame.d.ts +14 -0
- package/dist/shared/frame.js +21 -0
- package/dist/shared/icon-search.d.ts +9 -0
- package/dist/shared/icon-search.js +26 -0
- package/dist/shared/icon.d.ts +15 -0
- package/dist/shared/icon.js +28 -0
- package/dist/shared/index.d.ts +11 -0
- package/dist/shared/index.js +7 -0
- package/dist/shared/markup.d.ts +12 -0
- package/dist/shared/markup.js +43 -0
- package/dist/shared/measure.d.ts +7 -0
- package/dist/shared/measure.js +34 -0
- package/dist/shared/motion.d.ts +94 -0
- package/dist/shared/motion.js +117 -0
- package/dist/source-hash.d.ts +4 -0
- package/dist/source-hash.js +26 -0
- package/dist/test-fixtures.d.ts +9 -0
- package/dist/test-fixtures.js +22 -0
- package/dist/text-file.d.ts +1 -0
- package/dist/text-file.js +3 -0
- package/dist/words.d.ts +11 -0
- package/dist/words.js +93 -0
- package/package.json +36 -3
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineScene } from "../../define-scene.js";
|
|
3
|
+
import { animAttrs, esc } from "../../html.js";
|
|
4
|
+
import { positionClass, positionCss, positionSchema } from "../../position.js";
|
|
5
|
+
import { readText } from "../../text-file.js";
|
|
6
|
+
import { text } from "../../words.js";
|
|
7
|
+
const MAX_STEPS = 60;
|
|
8
|
+
const count = z.number().int().min(0).max(999);
|
|
9
|
+
const configSchema = z
|
|
10
|
+
.strictObject({
|
|
11
|
+
from: count,
|
|
12
|
+
to: count,
|
|
13
|
+
label: text.optional(),
|
|
14
|
+
position: positionSchema.default("center"),
|
|
15
|
+
})
|
|
16
|
+
.superRefine((props, ctx) => {
|
|
17
|
+
const steps = Math.abs(props.from - props.to) + 1;
|
|
18
|
+
if (steps > MAX_STEPS) {
|
|
19
|
+
ctx.addIssue({
|
|
20
|
+
code: "custom",
|
|
21
|
+
path: ["to"],
|
|
22
|
+
message: `${steps} steps; max ${MAX_STEPS}`,
|
|
23
|
+
params: { suggestion: `keep |from - to| < ${MAX_STEPS}` },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const render = (props) => {
|
|
28
|
+
const label = props.label ? `\n<p class="mk-label">${esc(props.label)}</p>` : "";
|
|
29
|
+
return [
|
|
30
|
+
`<div class="mk-count ${positionClass(props.position)}" ${animAttrs("fade", 0)}>`,
|
|
31
|
+
`<span class="mk-dial"><span class="mk-number" data-from="${props.from}" data-to="${props.to}">${props.from}</span></span>${label}`,
|
|
32
|
+
"</div>",
|
|
33
|
+
].join("\n");
|
|
34
|
+
};
|
|
35
|
+
export const countdownScene = defineScene({
|
|
36
|
+
type: "countdown",
|
|
37
|
+
layer: "overlay",
|
|
38
|
+
configSchema,
|
|
39
|
+
meta: {
|
|
40
|
+
purpose: "Integer count between from and to, one step per equal slice of the duration.",
|
|
41
|
+
example: { from: 3, to: 1 },
|
|
42
|
+
},
|
|
43
|
+
style: readText("./style.css", import.meta.url),
|
|
44
|
+
script: readText("./scene.js", import.meta.url),
|
|
45
|
+
render: (props, _ctx, root) => ({ body: render(props), css: positionCss(root) }),
|
|
46
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { mountOverlay } from "../../runtime/mount.js";
|
|
3
|
+
import { countdownState } from "../../runtime/timeline.js";
|
|
4
|
+
/** @type {import("../../runtime/mount.js").CreateScene} */
|
|
5
|
+
export function createScene(root, config, ctx) {
|
|
6
|
+
const number = /** @type {HTMLElement} */ (root.querySelector("[data-from]"));
|
|
7
|
+
const from = Number(number.dataset.from);
|
|
8
|
+
const to = Number(number.dataset.to);
|
|
9
|
+
const duration = ctx.duration ?? Number(root.dataset.duration);
|
|
10
|
+
return mountOverlay(root, config, ctx, { baseDelay: 0, staggerFrames: 3, rise: 0 }, {
|
|
11
|
+
update: (t) => {
|
|
12
|
+
const state = countdownState(t, duration, from, to);
|
|
13
|
+
number.textContent = String(state.value);
|
|
14
|
+
number.style.transform = `scale(${state.scale})`;
|
|
15
|
+
},
|
|
16
|
+
reset: () => {
|
|
17
|
+
number.textContent = String(from);
|
|
18
|
+
number.style.removeProperty("transform");
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#scene-root .mk-count {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: var(--mk-space-md);
|
|
6
|
+
}
|
|
7
|
+
#scene-root .mk-dial {
|
|
8
|
+
display: grid;
|
|
9
|
+
place-items: center;
|
|
10
|
+
width: 280px;
|
|
11
|
+
height: 280px;
|
|
12
|
+
border-radius: 50%;
|
|
13
|
+
border: 4px solid var(--mk-color-accent);
|
|
14
|
+
background: linear-gradient(155deg, var(--mk-color-deep) 0%, var(--mk-color-deepEnd) 100%);
|
|
15
|
+
color: var(--mk-color-onDeep);
|
|
16
|
+
}
|
|
17
|
+
#scene-root .mk-number {
|
|
18
|
+
font-family: var(--mk-font-display, var(--mk-font-sans));
|
|
19
|
+
font-size: var(--mk-font-size-display);
|
|
20
|
+
font-weight: 800;
|
|
21
|
+
line-height: 1;
|
|
22
|
+
font-variant-numeric: var(--mk-numeric, tabular-nums);
|
|
23
|
+
}
|
|
24
|
+
#scene-root .mk-label {
|
|
25
|
+
padding: var(--mk-space-sm) var(--mk-space-lg);
|
|
26
|
+
border-radius: var(--mk-radius-full);
|
|
27
|
+
background: var(--mk-color-card);
|
|
28
|
+
border: var(--mk-stroke-card, 2px) solid var(--mk-color-divider);
|
|
29
|
+
font-size: var(--mk-font-size-md);
|
|
30
|
+
font-weight: 700;
|
|
31
|
+
color: var(--mk-color-fg);
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const flowScene: import("@anvpx/motion-schema").ScenePlugin;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineScene } from "../../define-scene.js";
|
|
2
|
+
import { animAttrs, esc } from "../../html.js";
|
|
3
|
+
import { readText } from "../../text-file.js";
|
|
4
|
+
import { arrowPoints, DEFAULT_FONTS } from "./layout.js";
|
|
5
|
+
import { planFlow } from "./plan.js";
|
|
6
|
+
import { configSchema } from "./schema.js";
|
|
7
|
+
/** Steps between an edge starting to draw and its head and label appearing. */
|
|
8
|
+
const HEAD_DELAY_STEPS = 3;
|
|
9
|
+
const fontsOf = (ctx) => ({
|
|
10
|
+
sm: ctx.theme.fontSizes.sm ?? DEFAULT_FONTS.sm,
|
|
11
|
+
md: ctx.theme.fontSizes.md ?? DEFAULT_FONTS.md,
|
|
12
|
+
lg: ctx.theme.fontSizes.lg ?? DEFAULT_FONTS.lg,
|
|
13
|
+
});
|
|
14
|
+
const nodeHtml = (node, index) => {
|
|
15
|
+
const attrs = animAttrs("rise", index, node.id);
|
|
16
|
+
const source = node.layer === 0 ? " mk-src" : "";
|
|
17
|
+
const lines = node.lines.map((line) => `<span class="mk-line">${esc(line)}</span>`).join("");
|
|
18
|
+
return `<div class="mk-node mk-n${index}${source}" ${attrs}>${lines}</div>`;
|
|
19
|
+
};
|
|
20
|
+
const nodeCss = (root, node, index) => `${root} .mk-n${index} { left: ${node.x}px; top: ${node.y}px; width: ${node.w}px; height: ${node.h}px; font-size: var(--mk-font-size-${node.step}); }\n`;
|
|
21
|
+
const edgeHtml = (row, placed) => {
|
|
22
|
+
const { edge, geometry, label } = row;
|
|
23
|
+
const from = placed.get(edge.from);
|
|
24
|
+
if (!from)
|
|
25
|
+
return "";
|
|
26
|
+
const keys = `${edge.from} ${edge.to}`;
|
|
27
|
+
const drawStep = from.step + 1;
|
|
28
|
+
const headAttrs = animAttrs("fade", drawStep + HEAD_DELAY_STEPS, keys);
|
|
29
|
+
const text = label
|
|
30
|
+
? `<text class="mk-edge-label" x="${label.x}" y="${label.y}" ${headAttrs}>${esc(label.text)}</text>`
|
|
31
|
+
: "";
|
|
32
|
+
return [
|
|
33
|
+
`<path class="mk-edge-line" d="${geometry.path}" pathLength="1" ${animAttrs("draw", drawStep, keys)}/>`,
|
|
34
|
+
`<polygon class="mk-edge-head" points="${arrowPoints(geometry.tip, geometry.direction)}" ${headAttrs}/>`,
|
|
35
|
+
text,
|
|
36
|
+
]
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join("\n");
|
|
39
|
+
};
|
|
40
|
+
const render = (props, ctx, root) => {
|
|
41
|
+
const plan = planFlow(props, ctx, fontsOf(ctx));
|
|
42
|
+
const placed = new Map(plan.nodes.map((node, step) => [node.id, { node, step }]));
|
|
43
|
+
const edges = plan.edges.map((row) => edgeHtml(row, placed));
|
|
44
|
+
const svg = `<svg class="mk-edges" width="${ctx.width}" height="${ctx.height}" viewBox="0 0 ${ctx.width} ${ctx.height}" aria-hidden="true">\n${edges.join("\n")}\n</svg>`;
|
|
45
|
+
return {
|
|
46
|
+
body: [svg, ...plan.nodes.map(nodeHtml)].join("\n"),
|
|
47
|
+
css: plan.nodes.map((node, index) => nodeCss(root, node, index)).join(""),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export const flowScene = defineScene({
|
|
51
|
+
type: "flow",
|
|
52
|
+
configSchema,
|
|
53
|
+
meta: {
|
|
54
|
+
purpose: "Node-and-arrow diagram (2-8 nodes) laid out left-right or top-bottom.",
|
|
55
|
+
example: {
|
|
56
|
+
nodes: [
|
|
57
|
+
{ id: "a", label: "A" },
|
|
58
|
+
{ id: "b", label: "B" },
|
|
59
|
+
],
|
|
60
|
+
edges: [{ from: "a", to: "b" }],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
style: readText("./style.css", import.meta.url),
|
|
64
|
+
script: readText("./scene.js", import.meta.url),
|
|
65
|
+
render,
|
|
66
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type FlowFonts } from "./layout.ts";
|
|
2
|
+
/** What lint needs to know about a flow scene's layout; indexes are positions in props. */
|
|
3
|
+
export interface FlowReport {
|
|
4
|
+
/** Node label size (px). */
|
|
5
|
+
readonly font: number;
|
|
6
|
+
/** Labels do not fit the cards even at the smallest theme text step (too many or too long nodes). */
|
|
7
|
+
readonly crowded: boolean;
|
|
8
|
+
/** Nodes with a word wider than the card's text area (it would break mid-word). */
|
|
9
|
+
readonly wordBreaks: readonly {
|
|
10
|
+
readonly node: number;
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly word: string;
|
|
13
|
+
}[];
|
|
14
|
+
/** Edge labels still touching a card, another label or another edge. */
|
|
15
|
+
readonly labelOverlaps: readonly {
|
|
16
|
+
readonly edge: number;
|
|
17
|
+
readonly with: readonly string[];
|
|
18
|
+
/** Touches a card or another label (not just a crossing edge). */
|
|
19
|
+
readonly hard: boolean;
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
/** Lays the props out as the scene would; `undefined` when they are not a valid flow graph. */
|
|
23
|
+
export declare const inspectFlow: (props: unknown, size: {
|
|
24
|
+
readonly width: number;
|
|
25
|
+
readonly height: number;
|
|
26
|
+
}, fonts?: FlowFonts) => FlowReport | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DEFAULT_FONTS } from "./layout.js";
|
|
2
|
+
import { planFlow } from "./plan.js";
|
|
3
|
+
import { flowGraphSchema } from "./schema.js";
|
|
4
|
+
/** Lays the props out as the scene would; `undefined` when they are not a valid flow graph. */
|
|
5
|
+
export const inspectFlow = (props, size, fonts = DEFAULT_FONTS) => {
|
|
6
|
+
const parsed = flowGraphSchema.safeParse(props);
|
|
7
|
+
if (!parsed.success)
|
|
8
|
+
return undefined;
|
|
9
|
+
const plan = planFlow(parsed.data, size, fonts);
|
|
10
|
+
const index = new Map(parsed.data.nodes.map((node, i) => [node.id, i]));
|
|
11
|
+
return {
|
|
12
|
+
font: plan.font,
|
|
13
|
+
crowded: plan.nodes.some((node) => node.crowded),
|
|
14
|
+
wordBreaks: plan.nodes.flatMap((node) => node.overlong.map((word) => ({ node: index.get(node.id) ?? 0, id: node.id, word }))),
|
|
15
|
+
labelOverlaps: plan.edges.flatMap((row, i) => row.label !== undefined && row.label.overlaps.length > 0
|
|
16
|
+
? [
|
|
17
|
+
{
|
|
18
|
+
edge: i,
|
|
19
|
+
with: row.label.overlaps,
|
|
20
|
+
hard: row.label.overlaps.some((o) => !o.startsWith("edge ")),
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
: []),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type Direction = "lr" | "tb";
|
|
2
|
+
export interface FlowNode {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly label: string;
|
|
5
|
+
}
|
|
6
|
+
/** Theme font sizes (px) the scene uses; the defaults match the built-in themes. */
|
|
7
|
+
export interface FlowFonts {
|
|
8
|
+
readonly sm: number;
|
|
9
|
+
readonly md: number;
|
|
10
|
+
readonly lg: number;
|
|
11
|
+
}
|
|
12
|
+
export declare const DEFAULT_FONTS: FlowFonts;
|
|
13
|
+
export type FontStep = keyof FlowFonts;
|
|
14
|
+
export interface FlowEdge {
|
|
15
|
+
readonly from: string;
|
|
16
|
+
readonly to: string;
|
|
17
|
+
}
|
|
18
|
+
export interface Point {
|
|
19
|
+
readonly x: number;
|
|
20
|
+
readonly y: number;
|
|
21
|
+
}
|
|
22
|
+
/** A node with its layer rank and top-left box in canvas px. */
|
|
23
|
+
export interface PlacedNode extends FlowNode {
|
|
24
|
+
readonly layer: number;
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
readonly w: number;
|
|
28
|
+
readonly h: number;
|
|
29
|
+
/** Label font size (px), shared by every node of a diagram. */
|
|
30
|
+
readonly font: number;
|
|
31
|
+
/** Theme font-size step the label uses (the CSS var `--mk-font-size-<step>`). */
|
|
32
|
+
readonly step: FontStep;
|
|
33
|
+
/** The label text does not fit the card even at the smallest step: too many or too long nodes. */
|
|
34
|
+
readonly crowded: boolean;
|
|
35
|
+
/** Label lines, split at spaces only. */
|
|
36
|
+
readonly lines: readonly string[];
|
|
37
|
+
/** Words wider than the text area of the card (they would have to break). */
|
|
38
|
+
readonly overlong: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
export interface EdgeGeometry {
|
|
41
|
+
readonly path: string;
|
|
42
|
+
/** Where the arrow head points. */
|
|
43
|
+
readonly tip: Point;
|
|
44
|
+
/** Unit vector the arrow head points along. */
|
|
45
|
+
readonly direction: Point;
|
|
46
|
+
readonly mid: Point;
|
|
47
|
+
/** Evenly spaced points along the edge, tail to tip (`EDGE_SAMPLES + 1` of them). */
|
|
48
|
+
readonly samples: readonly Point[];
|
|
49
|
+
}
|
|
50
|
+
export declare const EDGE_SAMPLES = 40;
|
|
51
|
+
/**
|
|
52
|
+
* Longest-path layering: a node sits one layer after its deepest predecessor.
|
|
53
|
+
* Self edges are ignored; cycles terminate because layers are capped at n-1.
|
|
54
|
+
*/
|
|
55
|
+
export declare const assignLayers: (ids: readonly string[], edges: readonly FlowEdge[]) => Map<string, number>;
|
|
56
|
+
/** Cards roomy enough for the larger label size. */
|
|
57
|
+
export declare const isLargeNode: (node: {
|
|
58
|
+
readonly w: number;
|
|
59
|
+
readonly h: number;
|
|
60
|
+
}) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Gap (px) a layer boundary needs so an edge label sits between the cards, not on them and can
|
|
63
|
+
* slide along its edge: 2.2x the longest label's width (lr) or height (tb) plus padding; 0 when no edge has a label.
|
|
64
|
+
*/
|
|
65
|
+
export declare const edgeLabelRoom: (edges: readonly {
|
|
66
|
+
readonly label?: string | undefined;
|
|
67
|
+
}[], direction: Direction, fontSize: number, slide?: number) => number;
|
|
68
|
+
/** Padding (px) between a card's edge and its text, each side. */
|
|
69
|
+
export declare const NODE_PADDING = 24;
|
|
70
|
+
/**
|
|
71
|
+
* Hand-coded layered layout inside the 5% safe area; deterministic and integer-valued.
|
|
72
|
+
* `labelRoom` (see `edgeLabelRoom`) is the gap between layers the cards leave when they can; cards
|
|
73
|
+
* widen for their text down to `minLabelRoom` (default: `labelRoom`).
|
|
74
|
+
* Card width follows the label text (estimated, see `measure.ts`); labels wrap at spaces only.
|
|
75
|
+
*/
|
|
76
|
+
export declare const layoutFlow: (nodes: readonly FlowNode[], edges: readonly FlowEdge[], direction: Direction, size: {
|
|
77
|
+
readonly width: number;
|
|
78
|
+
readonly height: number;
|
|
79
|
+
}, labelRoom?: number, fonts?: FlowFonts, minLabelRoom?: number) => PlacedNode[];
|
|
80
|
+
/** Forward edges are S-curves between facing sides; other edges are straight and clipped to the boxes. */
|
|
81
|
+
export declare const edgeGeometry: (from: PlacedNode, to: PlacedNode, direction: Direction, forward: boolean) => EdgeGeometry;
|
|
82
|
+
/** SVG polygon points of an arrow head ending at `tip`. */
|
|
83
|
+
export declare const arrowPoints: (tip: Point, direction: Point) => string;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { minLabelWidth, textWidth, wrapLabel } from "./measure.js";
|
|
2
|
+
export const DEFAULT_FONTS = { sm: 28, md: 32, lg: 48 };
|
|
3
|
+
export const EDGE_SAMPLES = 40;
|
|
4
|
+
const SAFE_MARGIN = 0.05;
|
|
5
|
+
const MAX_NODE_WIDTH = 520;
|
|
6
|
+
/** Widest a card may grow to fit its label. */
|
|
7
|
+
const MAX_FIT_WIDTH = 760;
|
|
8
|
+
const MAX_NODE_HEIGHT = 260;
|
|
9
|
+
const NODE_ASPECT = 0.75;
|
|
10
|
+
const LARGE_NODE_WIDTH = 380;
|
|
11
|
+
const LARGE_NODE_HEIGHT = 200;
|
|
12
|
+
const MAIN_FILL = 0.74;
|
|
13
|
+
const CROSS_FILL = 0.62;
|
|
14
|
+
const TB_WIDTH_FILL = 0.8;
|
|
15
|
+
const TB_HEIGHT_FILL = 0.55;
|
|
16
|
+
const ARROW_LENGTH = 24;
|
|
17
|
+
const ARROW_HALF_WIDTH = 12;
|
|
18
|
+
const round = (value) => Math.round(value);
|
|
19
|
+
const snap = (value) => Math.round(value * 10) / 10;
|
|
20
|
+
const fmt = (value) => String(snap(value));
|
|
21
|
+
/**
|
|
22
|
+
* Longest-path layering: a node sits one layer after its deepest predecessor.
|
|
23
|
+
* Self edges are ignored; cycles terminate because layers are capped at n-1.
|
|
24
|
+
*/
|
|
25
|
+
export const assignLayers = (ids, edges) => {
|
|
26
|
+
const layer = new Map(ids.map((id) => [id, 0]));
|
|
27
|
+
const cap = ids.length - 1;
|
|
28
|
+
for (let pass = 0; pass < ids.length; pass++) {
|
|
29
|
+
for (const { from, to } of edges) {
|
|
30
|
+
const next = Math.min((layer.get(from) ?? 0) + 1, cap);
|
|
31
|
+
if (from !== to && next > (layer.get(to) ?? 0))
|
|
32
|
+
layer.set(to, next);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return layer;
|
|
36
|
+
};
|
|
37
|
+
/** Dense ranks (0..k-1) in ascending layer order. */
|
|
38
|
+
const rankOf = (layers) => {
|
|
39
|
+
const distinct = [...new Set(layers.values())].sort((a, b) => a - b);
|
|
40
|
+
return new Map([...layers].map(([id, layer]) => [id, distinct.indexOf(layer)]));
|
|
41
|
+
};
|
|
42
|
+
const groupByRank = (nodes, ranks) => {
|
|
43
|
+
const count = Math.max(...ranks.values()) + 1;
|
|
44
|
+
return Array.from({ length: count }, (_, rank) => nodes.filter((node) => ranks.get(node.id) === rank));
|
|
45
|
+
};
|
|
46
|
+
/** Cards roomy enough for the larger label size. */
|
|
47
|
+
export const isLargeNode = (node) => node.w >= LARGE_NODE_WIDTH && node.h >= LARGE_NODE_HEIGHT;
|
|
48
|
+
/** Smallest card width the label room may squeeze a node to. */
|
|
49
|
+
const MIN_NODE_WIDTH = 160;
|
|
50
|
+
/** Average glyph width of a label, in em, and the padding that keeps its halo off the cards. */
|
|
51
|
+
const LABEL_LINE_EM = 1.2;
|
|
52
|
+
/** A label gets this many times its own width, so it can slide along its edge past crossing edges. */
|
|
53
|
+
const LABEL_SLIDE = 2.2;
|
|
54
|
+
const LABEL_PADDING = 32;
|
|
55
|
+
/**
|
|
56
|
+
* Gap (px) a layer boundary needs so an edge label sits between the cards, not on them and can
|
|
57
|
+
* slide along its edge: 2.2x the longest label's width (lr) or height (tb) plus padding; 0 when no edge has a label.
|
|
58
|
+
*/
|
|
59
|
+
export const edgeLabelRoom = (edges, direction, fontSize, slide = LABEL_SLIDE) => {
|
|
60
|
+
const widest = Math.max(0, ...edges.map((edge) => textWidth(edge.label ?? "", fontSize)));
|
|
61
|
+
if (widest === 0)
|
|
62
|
+
return 0;
|
|
63
|
+
const extent = direction === "lr" ? widest : fontSize * LABEL_LINE_EM;
|
|
64
|
+
return Math.ceil(extent * slide) + LABEL_PADDING;
|
|
65
|
+
};
|
|
66
|
+
const nodeSize = (horizontal, mainPitch, crossPitch, gap) => {
|
|
67
|
+
const mainFill = (fill) => Math.min(mainPitch * fill, mainPitch - gap);
|
|
68
|
+
const w = Math.min(MAX_NODE_WIDTH, Math.max(MIN_NODE_WIDTH, horizontal ? mainFill(MAIN_FILL) : crossPitch * TB_WIDTH_FILL));
|
|
69
|
+
const h = Math.min(MAX_NODE_HEIGHT, w * NODE_ASPECT, horizontal ? crossPitch * CROSS_FILL : mainFill(TB_HEIGHT_FILL));
|
|
70
|
+
return { w, h };
|
|
71
|
+
};
|
|
72
|
+
/** Padding (px) between a card's edge and its text, each side. */
|
|
73
|
+
export const NODE_PADDING = 24;
|
|
74
|
+
const LINE_HEIGHT = 1.2;
|
|
75
|
+
const VERTICAL_PADDING = 16;
|
|
76
|
+
/** Gap kept between neighbouring cards on the main axis, and between stacked cards. */
|
|
77
|
+
const MIN_GAP = 48;
|
|
78
|
+
const CROSS_GAP = 32;
|
|
79
|
+
const wrapAll = (nodes, w, step, font, crowded) => {
|
|
80
|
+
const inner = w - 2 * NODE_PADDING;
|
|
81
|
+
const wrapped = nodes.map((node) => [node.id, wrapLabel(node.label, inner, font)]);
|
|
82
|
+
return {
|
|
83
|
+
step,
|
|
84
|
+
font,
|
|
85
|
+
w,
|
|
86
|
+
crowded,
|
|
87
|
+
lines: new Map(wrapped.map(([id, row]) => [id, row.lines])),
|
|
88
|
+
overlong: new Map(wrapped.map(([id, row]) => [id, row.overlong])),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const neededWidth = (nodes, font) => Math.max(...nodes.map((node) => minLabelWidth(node.label, font))) + 2 * NODE_PADDING;
|
|
92
|
+
/**
|
|
93
|
+
* Largest theme font step (from `large` or `md` down to `sm`) whose text fits the cards: a card
|
|
94
|
+
* widens up to `maxW` when a label needs it, and its height must hold the wrapped lines. A word
|
|
95
|
+
* is never broken; when even `sm` does not fit, the result is flagged `crowded`.
|
|
96
|
+
*/
|
|
97
|
+
const fitLabels = (nodes, box, maxW, fonts, large) => {
|
|
98
|
+
const steps = large ? ["lg", "md", "sm"] : ["md", "sm"];
|
|
99
|
+
for (const step of steps) {
|
|
100
|
+
const font = fonts[step];
|
|
101
|
+
const w = Math.max(box.w, neededWidth(nodes, font));
|
|
102
|
+
if (w > maxW)
|
|
103
|
+
continue;
|
|
104
|
+
const fitted = wrapAll(nodes, w, step, font, false);
|
|
105
|
+
const rows = Math.max(...[...fitted.lines.values()].map((lines) => lines.length));
|
|
106
|
+
if (rows * font * LINE_HEIGHT + 2 * VERTICAL_PADDING <= box.h)
|
|
107
|
+
return fitted;
|
|
108
|
+
}
|
|
109
|
+
const w = Math.min(maxW, Math.max(box.w, neededWidth(nodes, fonts.sm)));
|
|
110
|
+
return wrapAll(nodes, w, "sm", fonts.sm, true);
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Hand-coded layered layout inside the 5% safe area; deterministic and integer-valued.
|
|
114
|
+
* `labelRoom` (see `edgeLabelRoom`) is the gap between layers the cards leave when they can; cards
|
|
115
|
+
* widen for their text down to `minLabelRoom` (default: `labelRoom`).
|
|
116
|
+
* Card width follows the label text (estimated, see `measure.ts`); labels wrap at spaces only.
|
|
117
|
+
*/
|
|
118
|
+
export const layoutFlow = (nodes, edges, direction, size, labelRoom = 0, fonts = DEFAULT_FONTS, minLabelRoom = labelRoom) => {
|
|
119
|
+
const layers = assignLayers(nodes.map((node) => node.id), edges);
|
|
120
|
+
const columns = groupByRank(nodes, rankOf(layers));
|
|
121
|
+
const left = round(size.width * SAFE_MARGIN);
|
|
122
|
+
const top = round(size.height * SAFE_MARGIN);
|
|
123
|
+
const width = size.width - 2 * left;
|
|
124
|
+
const height = size.height - 2 * top;
|
|
125
|
+
const horizontal = direction === "lr";
|
|
126
|
+
const mainPitch = (horizontal ? width : height) / columns.length;
|
|
127
|
+
const crossLength = horizontal ? height : width;
|
|
128
|
+
const crossPitch = crossLength / Math.max(...columns.map((column) => column.length));
|
|
129
|
+
const box = nodeSize(horizontal, mainPitch, crossPitch, labelRoom);
|
|
130
|
+
const maxW = Math.max(box.w, Math.min(MAX_FIT_WIDTH, horizontal ? mainPitch - Math.max(minLabelRoom, MIN_GAP) : crossPitch - CROSS_GAP));
|
|
131
|
+
const fitted = fitLabels(nodes, box, maxW, fonts, isLargeNode(box));
|
|
132
|
+
return columns.flatMap((column, rank) => column.map((node, index) => {
|
|
133
|
+
const main = (rank + 0.5) * mainPitch;
|
|
134
|
+
const cross = crossLength / 2 + (index - (column.length - 1) / 2) * crossPitch;
|
|
135
|
+
const cx = left + (horizontal ? main : cross);
|
|
136
|
+
const cy = top + (horizontal ? cross : main);
|
|
137
|
+
return {
|
|
138
|
+
...node,
|
|
139
|
+
layer: rank,
|
|
140
|
+
x: round(cx - fitted.w / 2),
|
|
141
|
+
y: round(cy - box.h / 2),
|
|
142
|
+
w: round(fitted.w),
|
|
143
|
+
h: round(box.h),
|
|
144
|
+
font: fitted.font,
|
|
145
|
+
step: fitted.step,
|
|
146
|
+
crowded: fitted.crowded,
|
|
147
|
+
lines: fitted.lines.get(node.id) ?? [node.label],
|
|
148
|
+
overlong: fitted.overlong.get(node.id) ?? [],
|
|
149
|
+
};
|
|
150
|
+
}));
|
|
151
|
+
};
|
|
152
|
+
const mid = (a, b) => ({ x: snap((a.x + b.x) / 2), y: snap((a.y + b.y) / 2) });
|
|
153
|
+
const bezier = (p1, c1, c2, p2, t) => {
|
|
154
|
+
const u = 1 - t;
|
|
155
|
+
const a = u * u * u;
|
|
156
|
+
const b = 3 * u * u * t;
|
|
157
|
+
const c = 3 * u * t * t;
|
|
158
|
+
const d = t * t * t;
|
|
159
|
+
return {
|
|
160
|
+
x: snap(a * p1.x + b * c1.x + c * c2.x + d * p2.x),
|
|
161
|
+
y: snap(a * p1.y + b * c1.y + c * c2.y + d * p2.y),
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
const sampled = (at) => Array.from({ length: EDGE_SAMPLES + 1 }, (_, i) => at(i / EDGE_SAMPLES));
|
|
165
|
+
const curve = (p1, p2, direction) => {
|
|
166
|
+
const horizontal = direction.x !== 0;
|
|
167
|
+
const c = mid(p1, p2);
|
|
168
|
+
const c1 = horizontal ? { x: c.x, y: p1.y } : { x: p1.x, y: c.y };
|
|
169
|
+
const c2 = horizontal ? { x: c.x, y: p2.y } : { x: p2.x, y: c.y };
|
|
170
|
+
const path = `M ${fmt(p1.x)} ${fmt(p1.y)} C ${fmt(c1.x)} ${fmt(c1.y)} ${fmt(c2.x)} ${fmt(c2.y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
|
|
171
|
+
return {
|
|
172
|
+
path,
|
|
173
|
+
tip: p2,
|
|
174
|
+
direction,
|
|
175
|
+
mid: c,
|
|
176
|
+
samples: sampled((t) => bezier(p1, c1, c2, p2, t)),
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
const center = (node) => ({ x: node.x + node.w / 2, y: node.y + node.h / 2 });
|
|
180
|
+
/** Point where the ray from the node centre along `unit` leaves the node box. */
|
|
181
|
+
const boundary = (node, unit) => {
|
|
182
|
+
const c = center(node);
|
|
183
|
+
const reach = Math.min(node.w / 2 / Math.abs(unit.x), node.h / 2 / Math.abs(unit.y));
|
|
184
|
+
return { x: c.x + unit.x * reach, y: c.y + unit.y * reach };
|
|
185
|
+
};
|
|
186
|
+
const straight = (from, to) => {
|
|
187
|
+
const a = center(from);
|
|
188
|
+
const b = center(to);
|
|
189
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
|
190
|
+
const unit = length === 0 ? { x: 1, y: 0 } : { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
|
|
191
|
+
const p1 = boundary(from, unit);
|
|
192
|
+
const p2 = boundary(to, { x: -unit.x, y: -unit.y });
|
|
193
|
+
return {
|
|
194
|
+
path: `M ${fmt(p1.x)} ${fmt(p1.y)} L ${fmt(p2.x)} ${fmt(p2.y)}`,
|
|
195
|
+
tip: p2,
|
|
196
|
+
direction: unit,
|
|
197
|
+
mid: mid(p1, p2),
|
|
198
|
+
samples: sampled((t) => ({
|
|
199
|
+
x: snap(p1.x + (p2.x - p1.x) * t),
|
|
200
|
+
y: snap(p1.y + (p2.y - p1.y) * t),
|
|
201
|
+
})),
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
/** Forward edges are S-curves between facing sides; other edges are straight and clipped to the boxes. */
|
|
205
|
+
export const edgeGeometry = (from, to, direction, forward) => {
|
|
206
|
+
if (!forward)
|
|
207
|
+
return straight(from, to);
|
|
208
|
+
return direction === "lr"
|
|
209
|
+
? curve({ x: from.x + from.w, y: from.y + from.h / 2 }, { x: to.x, y: to.y + to.h / 2 }, { x: 1, y: 0 })
|
|
210
|
+
: curve({ x: from.x + from.w / 2, y: from.y + from.h }, { x: to.x + to.w / 2, y: to.y }, { x: 0, y: 1 });
|
|
211
|
+
};
|
|
212
|
+
/** SVG polygon points of an arrow head ending at `tip`. */
|
|
213
|
+
export const arrowPoints = (tip, direction) => {
|
|
214
|
+
const base = { x: tip.x - direction.x * ARROW_LENGTH, y: tip.y - direction.y * ARROW_LENGTH };
|
|
215
|
+
const side = { x: -direction.y * ARROW_HALF_WIDTH, y: direction.x * ARROW_HALF_WIDTH };
|
|
216
|
+
const corners = [
|
|
217
|
+
tip,
|
|
218
|
+
{ x: base.x + side.x, y: base.y + side.y },
|
|
219
|
+
{ x: base.x - side.x, y: base.y - side.y },
|
|
220
|
+
];
|
|
221
|
+
return corners.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(" ");
|
|
222
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { textWidth } from "../../shared/measure.ts";
|
|
2
|
+
export { textWidth };
|
|
3
|
+
export declare const MAX_LABEL_LINES = 2;
|
|
4
|
+
export interface Wrapped {
|
|
5
|
+
/** Lines, split only at spaces (a single word is never broken). */
|
|
6
|
+
readonly lines: readonly string[];
|
|
7
|
+
/** Every line is at most `inner` px wide. */
|
|
8
|
+
readonly fits: boolean;
|
|
9
|
+
/** Words wider than `inner` on their own. */
|
|
10
|
+
readonly overlong: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
/** Narrowest width (px) a label can be set in: its widest word, or the best two-line split. */
|
|
13
|
+
export declare const minLabelWidth: (label: string, fontPx: number) => number;
|
|
14
|
+
/** Wraps a label into one line when it fits `inner` px, else the best two-line split. */
|
|
15
|
+
export declare const wrapLabel: (label: string, inner: number, fontPx: number) => Wrapped;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { textWidth } from "../../shared/measure.js";
|
|
2
|
+
export { textWidth };
|
|
3
|
+
const wordsOf = (label) => label.split(/\s+/).filter(Boolean);
|
|
4
|
+
export const MAX_LABEL_LINES = 2;
|
|
5
|
+
/** The two-line split with the narrowest widest line (ties: earliest split). */
|
|
6
|
+
const bestSplit = (words, fontPx) => {
|
|
7
|
+
let best = [words.join(" ")];
|
|
8
|
+
let bestWidth = Number.POSITIVE_INFINITY;
|
|
9
|
+
for (let cut = 1; cut < words.length; cut++) {
|
|
10
|
+
const lines = [words.slice(0, cut).join(" "), words.slice(cut).join(" ")];
|
|
11
|
+
const widest = Math.max(...lines.map((line) => textWidth(line, fontPx)));
|
|
12
|
+
if (widest < bestWidth) {
|
|
13
|
+
best = lines;
|
|
14
|
+
bestWidth = widest;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return best;
|
|
18
|
+
};
|
|
19
|
+
/** Narrowest width (px) a label can be set in: its widest word, or the best two-line split. */
|
|
20
|
+
export const minLabelWidth = (label, fontPx) => {
|
|
21
|
+
const words = wordsOf(label);
|
|
22
|
+
const lines = words.length <= 1 ? words : bestSplit(words, fontPx);
|
|
23
|
+
return Math.max(0, ...lines.map((line) => textWidth(line, fontPx)));
|
|
24
|
+
};
|
|
25
|
+
/** Wraps a label into one line when it fits `inner` px, else the best two-line split. */
|
|
26
|
+
export const wrapLabel = (label, inner, fontPx) => {
|
|
27
|
+
const words = wordsOf(label);
|
|
28
|
+
const whole = words.join(" ");
|
|
29
|
+
const lines = textWidth(whole, fontPx) <= inner ? [whole] : bestSplit(words, fontPx);
|
|
30
|
+
const overlong = words.filter((word) => textWidth(word, fontPx) > inner);
|
|
31
|
+
const fits = lines.every((line) => textWidth(line, fontPx) <= inner);
|
|
32
|
+
return { lines, fits: fits && overlong.length === 0, overlong };
|
|
33
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Direction, type EdgeGeometry, type FlowEdge, type FlowFonts, type FlowNode, type PlacedNode } from "./layout.ts";
|
|
2
|
+
export interface FlowGraph {
|
|
3
|
+
readonly nodes: readonly FlowNode[];
|
|
4
|
+
readonly edges: readonly (FlowEdge & {
|
|
5
|
+
readonly label?: string | undefined;
|
|
6
|
+
})[];
|
|
7
|
+
readonly direction: Direction;
|
|
8
|
+
}
|
|
9
|
+
export interface Box {
|
|
10
|
+
/** Centre. */
|
|
11
|
+
readonly x: number;
|
|
12
|
+
readonly y: number;
|
|
13
|
+
readonly w: number;
|
|
14
|
+
readonly h: number;
|
|
15
|
+
}
|
|
16
|
+
export interface PlacedLabel extends Box {
|
|
17
|
+
readonly text: string;
|
|
18
|
+
/** What the label still overlaps after the best placement was chosen (empty = clean). */
|
|
19
|
+
readonly overlaps: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
export interface PlannedEdge {
|
|
22
|
+
readonly edge: FlowGraph["edges"][number];
|
|
23
|
+
readonly geometry: EdgeGeometry;
|
|
24
|
+
readonly label?: PlacedLabel;
|
|
25
|
+
}
|
|
26
|
+
export interface FlowPlan {
|
|
27
|
+
readonly nodes: readonly PlacedNode[];
|
|
28
|
+
readonly edges: readonly PlannedEdge[];
|
|
29
|
+
/** Node label font size (px). */
|
|
30
|
+
readonly font: number;
|
|
31
|
+
/** Edge label font size (px). */
|
|
32
|
+
readonly edgeFont: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The whole diagram layout: cards sized from their labels, edge geometry, and edge labels
|
|
36
|
+
* placed along their edges so they avoid cards, other edges and each other. Pure and
|
|
37
|
+
* deterministic; the scene renders it and `mk validate` lints it.
|
|
38
|
+
*/
|
|
39
|
+
export declare const planFlow: (graph: FlowGraph, size: {
|
|
40
|
+
readonly width: number;
|
|
41
|
+
readonly height: number;
|
|
42
|
+
}, fonts?: FlowFonts) => FlowPlan;
|