@elabs-ai/components-flow 4.1.0 → 5.0.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 +2 -1
- package/dist/canvas-shell/canvas-shell.js +13 -0
- package/dist/canvas-shell/canvas-shell.js.map +1 -0
- package/dist/canvas-shell/clamped-fit-offset.js +8 -0
- package/dist/canvas-shell/clamped-fit-offset.js.map +1 -0
- package/dist/canvas-shell/index.js +14 -0
- package/dist/canvas-shell/index.js.map +1 -0
- package/dist/canvas-shell/use-measured-nodes.js +8 -0
- package/dist/canvas-shell/use-measured-nodes.js.map +1 -0
- package/dist/chunk-3BTU2VWZ.js +42 -0
- package/dist/chunk-3BTU2VWZ.js.map +1 -0
- package/dist/chunk-3LK6OOXE.js +2 -0
- package/dist/chunk-3LK6OOXE.js.map +1 -0
- package/dist/chunk-3M3FQAD4.js +105 -0
- package/dist/chunk-3M3FQAD4.js.map +1 -0
- package/dist/chunk-3Y5TCGC3.js +2 -0
- package/dist/chunk-3Y5TCGC3.js.map +1 -0
- package/dist/chunk-4VYHZM3S.js +108 -0
- package/dist/chunk-4VYHZM3S.js.map +1 -0
- package/dist/chunk-5TVVM4GR.js +45 -0
- package/dist/chunk-5TVVM4GR.js.map +1 -0
- package/dist/chunk-5XT73MQQ.js +2 -0
- package/dist/chunk-5XT73MQQ.js.map +1 -0
- package/dist/chunk-6LQD5Y5R.js +24 -0
- package/dist/chunk-6LQD5Y5R.js.map +1 -0
- package/dist/chunk-6SR26QY2.js +18 -0
- package/dist/chunk-6SR26QY2.js.map +1 -0
- package/dist/chunk-75JJK6BH.js +157 -0
- package/dist/chunk-75JJK6BH.js.map +1 -0
- package/dist/chunk-A66JBW4X.js +2 -0
- package/dist/chunk-A66JBW4X.js.map +1 -0
- package/dist/chunk-A7AFM3LI.js +72 -0
- package/dist/chunk-A7AFM3LI.js.map +1 -0
- package/dist/chunk-ADHLQF56.js +2 -0
- package/dist/chunk-ADHLQF56.js.map +1 -0
- package/dist/chunk-ATO4LXJF.js +75 -0
- package/dist/chunk-ATO4LXJF.js.map +1 -0
- package/dist/chunk-ATXVXTMZ.js +227 -0
- package/dist/chunk-ATXVXTMZ.js.map +1 -0
- package/dist/chunk-B6IUPZU7.js +2 -0
- package/dist/chunk-B6IUPZU7.js.map +1 -0
- package/dist/chunk-BJ7UMCD3.js +53 -0
- package/dist/chunk-BJ7UMCD3.js.map +1 -0
- package/dist/chunk-BWGYV4JZ.js +2 -0
- package/dist/chunk-BWGYV4JZ.js.map +1 -0
- package/dist/chunk-CWNO63LQ.js +109 -0
- package/dist/chunk-CWNO63LQ.js.map +1 -0
- package/dist/chunk-DHPUXKCZ.js +76 -0
- package/dist/chunk-DHPUXKCZ.js.map +1 -0
- package/dist/chunk-DNX7G3KA.js +185 -0
- package/dist/chunk-DNX7G3KA.js.map +1 -0
- package/dist/chunk-DNZRAJAQ.js +41 -0
- package/dist/chunk-DNZRAJAQ.js.map +1 -0
- package/dist/chunk-ELCLRVD4.js +2 -0
- package/dist/chunk-ELCLRVD4.js.map +1 -0
- package/dist/chunk-ELOADZSX.js +2 -0
- package/dist/chunk-ELOADZSX.js.map +1 -0
- package/dist/chunk-EUNV2MR5.js +21 -0
- package/dist/chunk-EUNV2MR5.js.map +1 -0
- package/dist/chunk-FAFQ5KLB.js +43 -0
- package/dist/chunk-FAFQ5KLB.js.map +1 -0
- package/dist/chunk-FIRO6Y3R.js +128 -0
- package/dist/chunk-FIRO6Y3R.js.map +1 -0
- package/dist/chunk-GE73WQUK.js +2 -0
- package/dist/chunk-GE73WQUK.js.map +1 -0
- package/dist/chunk-GIARAOU6.js +39 -0
- package/dist/chunk-GIARAOU6.js.map +1 -0
- package/dist/chunk-HTLTCCFA.js +73 -0
- package/dist/chunk-HTLTCCFA.js.map +1 -0
- package/dist/chunk-I53W3CFN.js +56 -0
- package/dist/chunk-I53W3CFN.js.map +1 -0
- package/dist/chunk-IJVFPUXO.js +2 -0
- package/dist/chunk-IJVFPUXO.js.map +1 -0
- package/dist/chunk-JUPMOISE.js +51 -0
- package/dist/chunk-JUPMOISE.js.map +1 -0
- package/dist/chunk-K5RPSVO6.js +167 -0
- package/dist/chunk-K5RPSVO6.js.map +1 -0
- package/dist/chunk-K6U27YJ6.js +78 -0
- package/dist/chunk-K6U27YJ6.js.map +1 -0
- package/dist/chunk-KDVMGMAK.js +2 -0
- package/dist/chunk-KDVMGMAK.js.map +1 -0
- package/dist/chunk-LQOZCOK3.js +74 -0
- package/dist/chunk-LQOZCOK3.js.map +1 -0
- package/dist/chunk-NAQWNX2A.js +102 -0
- package/dist/chunk-NAQWNX2A.js.map +1 -0
- package/dist/chunk-NVJVWGUS.js +152 -0
- package/dist/chunk-NVJVWGUS.js.map +1 -0
- package/dist/chunk-O2F3R3PV.js +2 -0
- package/dist/chunk-O2F3R3PV.js.map +1 -0
- package/dist/chunk-OMFQHMA6.js +2 -0
- package/dist/chunk-OMFQHMA6.js.map +1 -0
- package/dist/chunk-PE6JXVOX.js +2 -0
- package/dist/chunk-PE6JXVOX.js.map +1 -0
- package/dist/chunk-ROJEIIA4.js +52 -0
- package/dist/chunk-ROJEIIA4.js.map +1 -0
- package/dist/chunk-ROYAQKJA.js +9 -0
- package/dist/chunk-ROYAQKJA.js.map +1 -0
- package/dist/chunk-SHCZK4DO.js +45 -0
- package/dist/chunk-SHCZK4DO.js.map +1 -0
- package/dist/chunk-SXXJV6YJ.js +79 -0
- package/dist/chunk-SXXJV6YJ.js.map +1 -0
- package/dist/chunk-T7S7NJFD.js +287 -0
- package/dist/chunk-T7S7NJFD.js.map +1 -0
- package/dist/chunk-TEZ3OXAY.js +63 -0
- package/dist/chunk-TEZ3OXAY.js.map +1 -0
- package/dist/chunk-TGWPM2CG.js +199 -0
- package/dist/chunk-TGWPM2CG.js.map +1 -0
- package/dist/chunk-TSB6RE7L.js +2 -0
- package/dist/chunk-TSB6RE7L.js.map +1 -0
- package/dist/chunk-UPV2MT4O.js +2 -0
- package/dist/chunk-UPV2MT4O.js.map +1 -0
- package/dist/chunk-UZJZY4A5.js +2 -0
- package/dist/chunk-UZJZY4A5.js.map +1 -0
- package/dist/chunk-W3TQMUGX.js +2 -0
- package/dist/chunk-W3TQMUGX.js.map +1 -0
- package/dist/chunk-WN46RIOC.js +29 -0
- package/dist/chunk-WN46RIOC.js.map +1 -0
- package/dist/chunk-XZFE2YYH.js +70 -0
- package/dist/chunk-XZFE2YYH.js.map +1 -0
- package/dist/chunk-YG3KXTZW.js +2 -0
- package/dist/chunk-YG3KXTZW.js.map +1 -0
- package/dist/chunk-ZATVF42E.js +65 -0
- package/dist/chunk-ZATVF42E.js.map +1 -0
- package/dist/chunk-ZXZTLQIX.js +19 -0
- package/dist/chunk-ZXZTLQIX.js.map +1 -0
- package/dist/flow-button-edge/flow-button-edge.js +10 -0
- package/dist/flow-button-edge/flow-button-edge.js.map +1 -0
- package/dist/flow-button-edge/index.js +11 -0
- package/dist/flow-button-edge/index.js.map +1 -0
- package/dist/flow-edge/flow-edge.js +10 -0
- package/dist/flow-edge/flow-edge.js.map +1 -0
- package/dist/flow-edge/index.js +11 -0
- package/dist/flow-edge/index.js.map +1 -0
- package/dist/flow-edge-path/flow-edge-path.js +12 -0
- package/dist/flow-edge-path/flow-edge-path.js.map +1 -0
- package/dist/flow-edge-path/index.js +13 -0
- package/dist/flow-edge-path/index.js.map +1 -0
- package/dist/flow-edge-tokens/edge-path-sampler.js +10 -0
- package/dist/flow-edge-tokens/edge-path-sampler.js.map +1 -0
- package/dist/flow-edge-tokens/flow-edge-tokens.js +12 -0
- package/dist/flow-edge-tokens/flow-edge-tokens.js.map +1 -0
- package/dist/flow-edge-tokens/index.js +17 -0
- package/dist/flow-edge-tokens/index.js.map +1 -0
- package/dist/flow-floating-edge/floating-edge-geometry.js +8 -0
- package/dist/flow-floating-edge/floating-edge-geometry.js.map +1 -0
- package/dist/flow-floating-edge/flow-floating-edge.js +11 -0
- package/dist/flow-floating-edge/flow-floating-edge.js.map +1 -0
- package/dist/flow-floating-edge/index.js +15 -0
- package/dist/flow-floating-edge/index.js.map +1 -0
- package/dist/flow-group-node/flow-group-node.js +12 -0
- package/dist/flow-group-node/flow-group-node.js.map +1 -0
- package/dist/flow-group-node/index.js +13 -0
- package/dist/flow-group-node/index.js.map +1 -0
- package/dist/flow-handle/flow-handle-anchor.js +8 -0
- package/dist/flow-handle/flow-handle-anchor.js.map +1 -0
- package/dist/flow-handle/index.js +9 -0
- package/dist/flow-handle/index.js.map +1 -0
- package/dist/flow-layout/backbone.js +8 -0
- package/dist/flow-layout/backbone.js.map +1 -0
- package/dist/flow-layout/flow-layout.js +10 -0
- package/dist/flow-layout/flow-layout.js.map +1 -0
- package/dist/flow-layout/index.js +21 -0
- package/dist/flow-layout/index.js.map +1 -0
- package/dist/flow-layout/layout-flow-elk.js +9 -0
- package/dist/flow-layout/layout-flow-elk.js.map +1 -0
- package/dist/flow-layout/layout-graph.js +9 -0
- package/dist/flow-layout/layout-graph.js.map +1 -0
- package/dist/flow-layout/use-auto-layout.js +10 -0
- package/dist/flow-layout/use-auto-layout.js.map +1 -0
- package/dist/flow-layout/use-flow-layout.js +9 -0
- package/dist/flow-layout/use-flow-layout.js.map +1 -0
- package/dist/flow-mini-map/flow-mini-map.js +8 -0
- package/dist/flow-mini-map/flow-mini-map.js.map +1 -0
- package/dist/flow-mini-map/index.js +9 -0
- package/dist/flow-mini-map/index.js.map +1 -0
- package/dist/flow-node/flow-node.js +11 -0
- package/dist/flow-node/flow-node.js.map +1 -0
- package/dist/flow-node/index.js +12 -0
- package/dist/flow-node/index.js.map +1 -0
- package/dist/flow-placeholder-node/flow-placeholder-node.js +9 -0
- package/dist/flow-placeholder-node/flow-placeholder-node.js.map +1 -0
- package/dist/flow-placeholder-node/index.js +10 -0
- package/dist/flow-placeholder-node/index.js.map +1 -0
- package/dist/flow-self-loop-edge/flow-self-loop-edge.js +20 -0
- package/dist/flow-self-loop-edge/flow-self-loop-edge.js.map +1 -0
- package/dist/flow-self-loop-edge/index.js +28 -0
- package/dist/flow-self-loop-edge/index.js.map +1 -0
- package/dist/flow-self-loop-edge/self-loop-geometry.js +12 -0
- package/dist/flow-self-loop-edge/self-loop-geometry.js.map +1 -0
- package/dist/flow-smart-edge/flow-smart-edge.js +11 -0
- package/dist/flow-smart-edge/flow-smart-edge.js.map +1 -0
- package/dist/flow-smart-edge/index.js +25 -0
- package/dist/flow-smart-edge/index.js.map +1 -0
- package/dist/flow-smart-edge/smart-edge-geometry.js +18 -0
- package/dist/flow-smart-edge/smart-edge-geometry.js.map +1 -0
- package/dist/flow-weighted-edge/back-edge-geometry.js +8 -0
- package/dist/flow-weighted-edge/back-edge-geometry.js.map +1 -0
- package/dist/flow-weighted-edge/edge-aria.js +10 -0
- package/dist/flow-weighted-edge/edge-aria.js.map +1 -0
- package/dist/flow-weighted-edge/edge-label-pill.js +8 -0
- package/dist/flow-weighted-edge/edge-label-pill.js.map +1 -0
- package/dist/flow-weighted-edge/flow-weighted-edge.js +16 -0
- package/dist/flow-weighted-edge/flow-weighted-edge.js.map +1 -0
- package/dist/flow-weighted-edge/index.js +34 -0
- package/dist/flow-weighted-edge/index.js.map +1 -0
- package/dist/flow-weighted-edge/weight-scale.js +10 -0
- package/dist/flow-weighted-edge/weight-scale.js.map +1 -0
- package/dist/helper-lines/get-helper-lines.js +8 -0
- package/dist/helper-lines/get-helper-lines.js.map +1 -0
- package/dist/helper-lines/helper-lines.js +9 -0
- package/dist/helper-lines/helper-lines.js.map +1 -0
- package/dist/helper-lines/index.js +17 -0
- package/dist/helper-lines/index.js.map +1 -0
- package/dist/helper-lines/use-helper-lines.js +10 -0
- package/dist/helper-lines/use-helper-lines.js.map +1 -0
- package/dist/index.d.ts +241 -2
- package/dist/index.js +167 -2306
- package/dist/index.js.map +1 -1
- package/dist/inspector-panel/index.js +9 -0
- package/dist/inspector-panel/index.js.map +1 -0
- package/dist/inspector-panel/inspector-panel.js +8 -0
- package/dist/inspector-panel/inspector-panel.js.map +1 -0
- package/dist/legend/index.js +10 -0
- package/dist/legend/index.js.map +1 -0
- package/dist/legend/legend.js +9 -0
- package/dist/legend/legend.js.map +1 -0
- package/dist/use-flow-groups/group-operations.js +20 -0
- package/dist/use-flow-groups/group-operations.js.map +1 -0
- package/dist/use-flow-groups/index.js +25 -0
- package/dist/use-flow-groups/index.js.map +1 -0
- package/dist/use-flow-groups/use-flow-groups.js +9 -0
- package/dist/use-flow-groups/use-flow-groups.js.map +1 -0
- package/dist/zoom-controls/index.js +9 -0
- package/dist/zoom-controls/index.js.map +1 -0
- package/dist/zoom-controls/zoom-controls.js +8 -0
- package/dist/zoom-controls/zoom-controls.js.map +1 -0
- package/package.json +14 -7
- package/src/__contract__/inspector-panel.contract.test.tsx +49 -0
- package/src/__contract__/legend.contract.test.tsx +49 -0
- package/src/canvas-shell/canvas-shell.stories.tsx +1 -0
- package/src/canvas-shell/canvas-shell.tsx +51 -4
- package/src/canvas-shell/clamped-fit-offset.test.ts +25 -0
- package/src/canvas-shell/clamped-fit-offset.ts +30 -0
- package/src/custom-nodes/custom-nodes.stories.tsx +718 -0
- package/src/flow-edge-tokens/edge-path-sampler.test.ts +108 -0
- package/src/flow-edge-tokens/edge-path-sampler.ts +362 -0
- package/src/flow-edge-tokens/flow-edge-tokens.stories.tsx +312 -0
- package/src/flow-edge-tokens/flow-edge-tokens.test.tsx +115 -0
- package/src/flow-edge-tokens/flow-edge-tokens.tsx +94 -0
- package/src/flow-edge-tokens/index.ts +12 -0
- package/src/flow-layout/backbone.test.ts +88 -0
- package/src/flow-layout/backbone.ts +58 -0
- package/src/flow-layout/layout-flow-elk.stories.tsx +154 -0
- package/src/flow-layout/layout-flow-elk.test.ts +152 -0
- package/src/flow-layout/layout-flow-elk.ts +399 -0
- package/src/flow-mini-map/flow-mini-map.stories.tsx +20 -0
- package/src/flow-node/flow-node.stories.tsx +40 -1
- package/src/flow-self-loop-edge/flow-self-loop-edge.test.tsx +38 -0
- package/src/flow-self-loop-edge/flow-self-loop-edge.tsx +7 -0
- package/src/flow-weighted-edge/edge-label-pill.test.tsx +20 -0
- package/src/flow-weighted-edge/flow-weighted-edge.stories.tsx +1 -0
- package/src/flow-weighted-edge/flow-weighted-edge.test.tsx +40 -0
- package/src/flow-weighted-edge/flow-weighted-edge.tsx +7 -0
- package/src/index.ts +13 -0
- package/src/inspector-panel/inspector-panel.tsx +9 -6
- package/src/legend/legend.test.tsx +11 -0
- package/src/legend/legend.tsx +15 -7
- package/src/templates-flow-workspace.stories.tsx +7 -2
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import "@xyflow/react/dist/style.css";
|
|
4
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
5
|
+
import { Button, Slider } from "@elabs-ai/components-ui";
|
|
6
|
+
import { CanvasShell } from "../canvas-shell";
|
|
7
|
+
import { FlowNode, type BrandFlowNode } from "../flow-node";
|
|
8
|
+
import { FlowWeightedEdge, type BrandFlowWeightedEdge } from "../flow-weighted-edge";
|
|
9
|
+
import { FlowSelfLoopEdge, type BrandFlowSelfLoopEdge } from "../flow-self-loop-edge";
|
|
10
|
+
import { FlowEdgeTokens } from "./flow-edge-tokens";
|
|
11
|
+
|
|
12
|
+
const nodeTypes = { brand: FlowNode };
|
|
13
|
+
const edgeTypes = { weighted: FlowWeightedEdge, "self-loop": FlowSelfLoopEdge };
|
|
14
|
+
|
|
15
|
+
type ProcessEdge = BrandFlowWeightedEdge | BrandFlowSelfLoopEdge;
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
title: "Flow/FlowEdgeTokens",
|
|
19
|
+
component: FlowEdgeTokens,
|
|
20
|
+
tags: ["autodocs"],
|
|
21
|
+
parameters: {
|
|
22
|
+
layout: "fullscreen",
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"Markers (“tokens”) that sit along an edge’s path at a `progress` between 0 and 1 — " +
|
|
27
|
+
"the primitive behind process replay. The parent drives `progress`; the part keeps no " +
|
|
28
|
+
"clock. `FlowWeightedEdge` and `FlowSelfLoopEdge` draw them from `data.tokens` on their " +
|
|
29
|
+
"own computed path; a custom edge renders `FlowEdgeTokens` with the same `path` it hands " +
|
|
30
|
+
"to `FlowEdgePath`. Tokens are hidden from assistive technology, so pair them with a " +
|
|
31
|
+
"text alternative such as a live case count.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
} satisfies Meta<typeof FlowEdgeTokens>;
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof meta>;
|
|
38
|
+
|
|
39
|
+
function chainNodes(): BrandFlowNode[] {
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
id: "a",
|
|
43
|
+
type: "brand",
|
|
44
|
+
position: { x: 0, y: 0 },
|
|
45
|
+
data: { kind: "Start", title: "Received" },
|
|
46
|
+
},
|
|
47
|
+
{ id: "b", type: "brand", position: { x: 0, y: 200 }, data: { kind: "Step", title: "Review" } },
|
|
48
|
+
{
|
|
49
|
+
id: "c",
|
|
50
|
+
type: "brand",
|
|
51
|
+
position: { x: 0, y: 400 },
|
|
52
|
+
data: { kind: "End", title: "Approved" },
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tokenEls = (root: HTMLElement) =>
|
|
58
|
+
Array.from(root.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'));
|
|
59
|
+
|
|
60
|
+
/** `translate(12.5px, 40px)` → `[12.5, 40]`. */
|
|
61
|
+
function tokenPosition(token: SVGCircleElement): [number, number] {
|
|
62
|
+
const m = /translate\(([-\d.e]+)px,\s*([-\d.e]+)px\)/.exec(token.style.transform);
|
|
63
|
+
if (!m) throw new Error(`unexpected token transform: ${token.style.transform}`);
|
|
64
|
+
return [Number(m[1]), Number(m[2])];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The browser's own `getPointAtLength` on the edge's rendered path is the oracle: every token
|
|
69
|
+
* must sit within a pixel and a half of it, which checks the pure sampler against the real engine.
|
|
70
|
+
*/
|
|
71
|
+
function expectOnPath(token: SVGCircleElement, path: SVGPathElement, progress: number) {
|
|
72
|
+
const point = path.getPointAtLength(progress * path.getTotalLength());
|
|
73
|
+
const [x, y] = tokenPosition(token);
|
|
74
|
+
expect(Math.hypot(x - point.x, y - point.y)).toBeLessThan(1.5);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Canvas({ edges, caption }: { edges: ProcessEdge[]; caption: string }) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-2 p-4">
|
|
80
|
+
{/* The tokens are aria-hidden; this is the text alternative a consumer owes. */}
|
|
81
|
+
<p className="text-caption text-muted-foreground">{caption}</p>
|
|
82
|
+
<div className="h-[560px]">
|
|
83
|
+
<CanvasShell
|
|
84
|
+
nodes={chainNodes()}
|
|
85
|
+
edges={edges}
|
|
86
|
+
nodeTypes={nodeTypes}
|
|
87
|
+
edgeTypes={edgeTypes}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const STATIC_PROGRESS = [0, 0.25, 0.5, 0.75, 1];
|
|
95
|
+
|
|
96
|
+
/** Five tokens pinned at 0, ¼, ½, ¾ and 1 of a bezier edge, and a smoothstep edge carrying one at ½. */
|
|
97
|
+
export const StaticPositions: Story = {
|
|
98
|
+
args: { path: "", tokens: [] },
|
|
99
|
+
render: () => (
|
|
100
|
+
<Canvas
|
|
101
|
+
caption="Five cases between Received and Review; one case between Review and Approved."
|
|
102
|
+
edges={[
|
|
103
|
+
{
|
|
104
|
+
id: "e-ab",
|
|
105
|
+
source: "a",
|
|
106
|
+
target: "b",
|
|
107
|
+
type: "weighted",
|
|
108
|
+
data: {
|
|
109
|
+
weight: 5,
|
|
110
|
+
tokens: STATIC_PROGRESS.map((progress) => ({ id: `ab-${progress}`, progress })),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "e-bc",
|
|
115
|
+
source: "b",
|
|
116
|
+
target: "c",
|
|
117
|
+
type: "weighted",
|
|
118
|
+
data: {
|
|
119
|
+
weight: 1,
|
|
120
|
+
path: "smoothstep",
|
|
121
|
+
tokens: [{ id: "bc-half", progress: 0.5, radius: 6 }],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
]}
|
|
125
|
+
/>
|
|
126
|
+
),
|
|
127
|
+
play: async ({ canvasElement }) => {
|
|
128
|
+
await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(6));
|
|
129
|
+
const [ab, bc] = Array.from(
|
|
130
|
+
canvasElement.querySelectorAll<SVGPathElement>('[data-slot="flow-weighted-edge"]'),
|
|
131
|
+
);
|
|
132
|
+
const tokens = tokenEls(canvasElement);
|
|
133
|
+
STATIC_PROGRESS.forEach((progress, i) => expectOnPath(tokens[i]!, ab!, progress));
|
|
134
|
+
expectOnPath(tokens[5]!, bc!, 0.5);
|
|
135
|
+
await expect(tokens[5]).toHaveAttribute("r", "6");
|
|
136
|
+
// Decorative to assistive technology: the caption carries the meaning.
|
|
137
|
+
for (const group of canvasElement.querySelectorAll('[data-slot="flow-edge-tokens"]')) {
|
|
138
|
+
await expect(group).toHaveAttribute("aria-hidden", "true");
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function ScrubbedEdge() {
|
|
144
|
+
const [percent, setPercent] = useState(10);
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex flex-col gap-2 p-4">
|
|
147
|
+
<div className="flex max-w-sm flex-col gap-2">
|
|
148
|
+
<p className="text-caption text-muted-foreground">
|
|
149
|
+
Case 1042 is {percent}% of the way to Review.
|
|
150
|
+
</p>
|
|
151
|
+
<Slider
|
|
152
|
+
aria-label="Case 1042 progress"
|
|
153
|
+
min={0}
|
|
154
|
+
max={100}
|
|
155
|
+
step={10}
|
|
156
|
+
value={[percent]}
|
|
157
|
+
onValueChange={(v) => setPercent(v[0] ?? 0)}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="h-[520px]">
|
|
161
|
+
<CanvasShell
|
|
162
|
+
nodes={chainNodes().slice(0, 2)}
|
|
163
|
+
edges={[
|
|
164
|
+
{
|
|
165
|
+
id: "e-ab",
|
|
166
|
+
source: "a",
|
|
167
|
+
target: "b",
|
|
168
|
+
type: "weighted",
|
|
169
|
+
data: { weight: 3, tokens: [{ id: "case-1042", progress: percent / 100 }] },
|
|
170
|
+
},
|
|
171
|
+
]}
|
|
172
|
+
nodeTypes={nodeTypes}
|
|
173
|
+
edgeTypes={edgeTypes}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The parent owns `progress`: drag or arrow-key the slider and the token follows the edge. */
|
|
181
|
+
export const ParentDrivenProgress: Story = {
|
|
182
|
+
args: { path: "", tokens: [] },
|
|
183
|
+
render: () => <ScrubbedEdge />,
|
|
184
|
+
play: async ({ canvasElement }) => {
|
|
185
|
+
const canvas = within(canvasElement);
|
|
186
|
+
await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(1));
|
|
187
|
+
const path = canvasElement.querySelector<SVGPathElement>('[data-slot="flow-weighted-edge"]')!;
|
|
188
|
+
const token = tokenEls(canvasElement)[0]!;
|
|
189
|
+
expectOnPath(token, path, 0.1);
|
|
190
|
+
|
|
191
|
+
const thumb = canvas.getByRole("slider", { name: "Case 1042 progress" });
|
|
192
|
+
thumb.focus();
|
|
193
|
+
await userEvent.keyboard("{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}");
|
|
194
|
+
await canvas.findByText("Case 1042 is 50% of the way to Review.");
|
|
195
|
+
// Same element — keyed by token id — now at the new progress.
|
|
196
|
+
await waitFor(() => expectOnPath(tokenEls(canvasElement)[0]!, path, 0.5));
|
|
197
|
+
await expect(tokenEls(canvasElement)[0]).toBe(token);
|
|
198
|
+
|
|
199
|
+
await userEvent.keyboard("{End}");
|
|
200
|
+
await waitFor(() => expectOnPath(tokenEls(canvasElement)[0]!, path, 1));
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
function SteppedEdge() {
|
|
205
|
+
const [progress, setProgress] = useState(0);
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex flex-col gap-2 p-4">
|
|
208
|
+
<div className="flex items-center gap-2">
|
|
209
|
+
<Button
|
|
210
|
+
size="sm"
|
|
211
|
+
variant="outline"
|
|
212
|
+
onClick={() => setProgress((p) => (p >= 1 ? 0 : p + 0.5))}
|
|
213
|
+
>
|
|
214
|
+
Advance case
|
|
215
|
+
</Button>
|
|
216
|
+
<p className="text-caption text-muted-foreground">Case 7 at {progress * 100}%.</p>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="h-[520px]">
|
|
219
|
+
<CanvasShell
|
|
220
|
+
nodes={chainNodes().slice(0, 2)}
|
|
221
|
+
edges={[
|
|
222
|
+
{
|
|
223
|
+
id: "e-ab",
|
|
224
|
+
source: "a",
|
|
225
|
+
target: "b",
|
|
226
|
+
type: "weighted",
|
|
227
|
+
data: { weight: 3, tokens: [{ id: "case-7", progress }] },
|
|
228
|
+
},
|
|
229
|
+
]}
|
|
230
|
+
nodeTypes={nodeTypes}
|
|
231
|
+
edgeTypes={edgeTypes}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Under reduced motion a token jumps straight to its new position: the motion gate collapses the
|
|
240
|
+
* transform transition to `--motion-min`, and a reduced `useReducedMotion()` drops the transition
|
|
241
|
+
* class outright.
|
|
242
|
+
*/
|
|
243
|
+
export const ReducedMotion: Story = {
|
|
244
|
+
args: { path: "", tokens: [] },
|
|
245
|
+
globals: { motionPref: "reduced" },
|
|
246
|
+
render: () => <SteppedEdge />,
|
|
247
|
+
play: async ({ canvasElement }) => {
|
|
248
|
+
const canvas = within(canvasElement);
|
|
249
|
+
await waitFor(() => expect(tokenEls(canvasElement)).toHaveLength(1));
|
|
250
|
+
|
|
251
|
+
await userEvent.click(canvas.getByRole("button", { name: "Advance case" }));
|
|
252
|
+
await canvas.findByText("Case 7 at 50%.");
|
|
253
|
+
const token = tokenEls(canvasElement)[0]!;
|
|
254
|
+
// No perceptible transition: either the class is gone (a reduced `useReducedMotion()`) or the
|
|
255
|
+
// motion gate has collapsed `duration-fast` to a few hundredths of a millisecond.
|
|
256
|
+
const durations = getComputedStyle(token)
|
|
257
|
+
.transitionDuration.split(",")
|
|
258
|
+
.map((d) => parseFloat(d) * (d.trim().endsWith("ms") ? 1 : 1000));
|
|
259
|
+
await expect(Math.max(...durations)).toBeLessThan(1);
|
|
260
|
+
// The painted position is the target one once the canvas settles. The path is re-queried
|
|
261
|
+
// on every attempt: React Flow may re-render the edge (a late fitView on a slow CI runner),
|
|
262
|
+
// and a path element read before that would still report the old geometry.
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
const path = canvasElement.querySelector<SVGPathElement>('[data-slot="flow-weighted-edge"]')!;
|
|
265
|
+
const point = path.getPointAtLength(0.5 * path.getTotalLength());
|
|
266
|
+
const matrix = new DOMMatrixReadOnly(getComputedStyle(tokenEls(canvasElement)[0]!).transform);
|
|
267
|
+
expect(Math.hypot(matrix.e - point.x, matrix.f - point.y)).toBeLessThan(1.5);
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Tokens travel the self-loop’s own arc, from the source handle round to the target handle. */
|
|
273
|
+
export const SelfLoop: Story = {
|
|
274
|
+
args: { path: "", tokens: [] },
|
|
275
|
+
render: () => (
|
|
276
|
+
<Canvas
|
|
277
|
+
caption="Three cases repeating Review."
|
|
278
|
+
edges={[
|
|
279
|
+
{ id: "e-ab", source: "a", target: "b", type: "weighted", data: { weight: 6 } },
|
|
280
|
+
{ id: "e-bc", source: "b", target: "c", type: "weighted", data: { weight: 4 } },
|
|
281
|
+
{
|
|
282
|
+
id: "e-bb",
|
|
283
|
+
source: "b",
|
|
284
|
+
target: "b",
|
|
285
|
+
type: "self-loop",
|
|
286
|
+
data: {
|
|
287
|
+
weight: 2,
|
|
288
|
+
tokens: [
|
|
289
|
+
{ id: "r1", progress: 0.2 },
|
|
290
|
+
{ id: "r2", progress: 0.5 },
|
|
291
|
+
{ id: "r3", progress: 0.8 },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
]}
|
|
296
|
+
/>
|
|
297
|
+
),
|
|
298
|
+
play: async ({ canvasElement }) => {
|
|
299
|
+
await waitFor(() => {
|
|
300
|
+
expect(tokenEls(canvasElement)).toHaveLength(3);
|
|
301
|
+
expect(canvasElement.querySelector('[data-slot="flow-self-loop-edge"]')).not.toBeNull();
|
|
302
|
+
});
|
|
303
|
+
// The arc is recomputed once the node is measured — read it after it settles.
|
|
304
|
+
await waitFor(() => {
|
|
305
|
+
const loop = canvasElement.querySelector<SVGPathElement>(
|
|
306
|
+
'[data-slot="flow-self-loop-edge"]',
|
|
307
|
+
)!;
|
|
308
|
+
const tokens = tokenEls(canvasElement);
|
|
309
|
+
[0.2, 0.5, 0.8].forEach((progress, i) => expectOnPath(tokens[i]!, loop, progress));
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { cleanup, render } from "@testing-library/react";
|
|
2
|
+
import { createRef, type ReactElement } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { DEFAULT_EDGE_TOKEN_RADIUS, FlowEdgeTokens } from "./flow-edge-tokens";
|
|
5
|
+
|
|
6
|
+
afterEach(cleanup);
|
|
7
|
+
|
|
8
|
+
function renderTokens(ui: ReactElement) {
|
|
9
|
+
return render(<svg>{ui}</svg>);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const tokenEls = (root: HTMLElement) =>
|
|
13
|
+
Array.from(root.querySelectorAll<SVGCircleElement>('[data-slot="flow-edge-tokens-token"]'));
|
|
14
|
+
|
|
15
|
+
describe("FlowEdgeTokens", () => {
|
|
16
|
+
it("places each token at its progress along the path", () => {
|
|
17
|
+
const { container } = renderTokens(
|
|
18
|
+
<FlowEdgeTokens
|
|
19
|
+
path="M0,0 L200,0"
|
|
20
|
+
tokens={[
|
|
21
|
+
{ id: "a", progress: 0 },
|
|
22
|
+
{ id: "b", progress: 0.5 },
|
|
23
|
+
{ id: "c", progress: 1 },
|
|
24
|
+
]}
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
const [a, b, c] = tokenEls(container);
|
|
28
|
+
expect(a!.style.transform).toBe("translate(0px, 0px)");
|
|
29
|
+
expect(b!.style.transform).toBe("translate(100px, 0px)");
|
|
30
|
+
expect(c!.style.transform).toBe("translate(200px, 0px)");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("moves a token when the parent changes its progress, keeping the same element", () => {
|
|
34
|
+
const { container, rerender } = renderTokens(
|
|
35
|
+
<FlowEdgeTokens path="M0,0 L200,0" tokens={[{ id: "a", progress: 0.25 }]} />,
|
|
36
|
+
);
|
|
37
|
+
const before = tokenEls(container)[0]!;
|
|
38
|
+
expect(before.style.transform).toBe("translate(50px, 0px)");
|
|
39
|
+
rerender(
|
|
40
|
+
<svg>
|
|
41
|
+
<FlowEdgeTokens path="M0,0 L200,0" tokens={[{ id: "a", progress: 0.75 }]} />
|
|
42
|
+
</svg>,
|
|
43
|
+
);
|
|
44
|
+
const after = tokenEls(container)[0]!;
|
|
45
|
+
expect(after).toBe(before);
|
|
46
|
+
expect(after.style.transform).toBe("translate(150px, 0px)");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is decorative to assistive technology and carries its slots", () => {
|
|
50
|
+
const { container } = renderTokens(
|
|
51
|
+
<FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "case-1", progress: 0.2 }]} />,
|
|
52
|
+
);
|
|
53
|
+
const root = container.querySelector('[data-slot="flow-edge-tokens"]')!;
|
|
54
|
+
expect(root).toHaveAttribute("aria-hidden", "true");
|
|
55
|
+
expect(tokenEls(container)[0]).toHaveAttribute("data-token-id", "case-1");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses the default radius and honours a per-token radius", () => {
|
|
59
|
+
const { container } = renderTokens(
|
|
60
|
+
<FlowEdgeTokens
|
|
61
|
+
path="M0,0 L10,0"
|
|
62
|
+
tokens={[
|
|
63
|
+
{ id: "a", progress: 0 },
|
|
64
|
+
{ id: "b", progress: 0, radius: 9 },
|
|
65
|
+
]}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
const [a, b] = tokenEls(container);
|
|
69
|
+
expect(a).toHaveAttribute("r", String(DEFAULT_EDGE_TOKEN_RADIUS));
|
|
70
|
+
expect(b).toHaveAttribute("r", "9");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("paints with semantic tokens only", () => {
|
|
74
|
+
const { container } = renderTokens(
|
|
75
|
+
<FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "a", progress: 0 }]} />,
|
|
76
|
+
);
|
|
77
|
+
const cls = tokenEls(container)[0]!.getAttribute("class")!;
|
|
78
|
+
expect(cls).toContain("fill-primary");
|
|
79
|
+
expect(cls).toContain("stroke-background");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("transitions between positions when motion is allowed (jsdom has no reduced-motion signal)", () => {
|
|
83
|
+
const { container } = renderTokens(
|
|
84
|
+
<FlowEdgeTokens path="M0,0 L10,0" tokens={[{ id: "a", progress: 0 }]} />,
|
|
85
|
+
);
|
|
86
|
+
expect(container.querySelector('[data-slot="flow-edge-tokens"]')).toHaveAttribute(
|
|
87
|
+
"data-motion",
|
|
88
|
+
"animated",
|
|
89
|
+
);
|
|
90
|
+
expect(tokenEls(container)[0]!.getAttribute("class")).toContain("transition-transform");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("renders no tokens for a path with no geometry", () => {
|
|
94
|
+
const { container } = renderTokens(
|
|
95
|
+
<FlowEdgeTokens path="" tokens={[{ id: "a", progress: 0.5 }]} />,
|
|
96
|
+
);
|
|
97
|
+
expect(tokenEls(container)).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("merges className, spreads props and forwards its ref", () => {
|
|
101
|
+
const ref = createRef<SVGGElement>();
|
|
102
|
+
const { container } = renderTokens(
|
|
103
|
+
<FlowEdgeTokens
|
|
104
|
+
ref={ref}
|
|
105
|
+
path="M0,0 L10,0"
|
|
106
|
+
tokens={[]}
|
|
107
|
+
className="opacity-50"
|
|
108
|
+
data-testid="tokens"
|
|
109
|
+
/>,
|
|
110
|
+
);
|
|
111
|
+
const root = container.querySelector('[data-testid="tokens"]')!;
|
|
112
|
+
expect(ref.current).toBe(root);
|
|
113
|
+
expect(root.getAttribute("class")).toBe("pointer-events-none opacity-50");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useMemo, type SVGProps } from "react";
|
|
4
|
+
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
5
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
6
|
+
import { createEdgePathSampler } from "./edge-path-sampler";
|
|
7
|
+
|
|
8
|
+
/** Default token radius, in px. */
|
|
9
|
+
export const DEFAULT_EDGE_TOKEN_RADIUS = 4;
|
|
10
|
+
|
|
11
|
+
export interface FlowEdgeToken {
|
|
12
|
+
/** Stable identity (e.g. a case id). Keys the rendered marker, so a token keeps its element — and its transition — as it moves. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Position along the path's arc length: `0` = source end, `1` = target end. Clamped; a non-finite value sits at `0`. */
|
|
15
|
+
progress: number;
|
|
16
|
+
/** Marker radius in px — e.g. scaled by congestion. @default 4 */
|
|
17
|
+
radius?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FlowEdgeTokensProps extends Omit<SVGProps<SVGGElement>, "ref" | "children"> {
|
|
21
|
+
/** SVG path `d` the tokens travel along — the same string the edge draws. */
|
|
22
|
+
path: string;
|
|
23
|
+
/** Tokens to place. The parent owns `progress`; this part keeps no clock. */
|
|
24
|
+
tokens: readonly FlowEdgeToken[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Markers ("tokens") placed along an edge's path — the presentational primitive
|
|
29
|
+
* behind process replay. Each token is a circle at `progress` (0..1) of the
|
|
30
|
+
* path's arc length.
|
|
31
|
+
*
|
|
32
|
+
* **Parent-driven, no internal clock.** Nothing here animates on its own: the
|
|
33
|
+
* parent re-renders with new `progress` values (from a slider, a playback loop,
|
|
34
|
+
* a play function). Position is a pure function of `(path, progress)` — see
|
|
35
|
+
* `createEdgePathSampler` — so every render is deterministic and identical in
|
|
36
|
+
* jsdom, on the server and in the browser.
|
|
37
|
+
*
|
|
38
|
+
* Between two renders a token glides with a short `duration-fast` transform
|
|
39
|
+
* transition, so a parent stepping at a coarse rate still reads as motion. Under
|
|
40
|
+
* reduced motion (`useReducedMotion()`: the ThemeProvider preference, else the
|
|
41
|
+
* OS setting) the transition class is not rendered at all and a token jumps
|
|
42
|
+
* straight to its new position; the `duration-fast` token also collapses to
|
|
43
|
+
* `--motion-min` through the global motion gate (`data-motion-pref`).
|
|
44
|
+
*
|
|
45
|
+
* **Decorative to assistive technology.** The group is `aria-hidden` and ignores
|
|
46
|
+
* pointer events: a moving dot has no stable meaning to announce. The consumer
|
|
47
|
+
* owns the text alternative (a live case count, a summary). Colour is the one
|
|
48
|
+
* `--primary` fill for every token, with a `--background` halo so a token stays
|
|
49
|
+
* distinct from the edge stroke beneath it.
|
|
50
|
+
*
|
|
51
|
+
* Existing brand edges render this for you from `data.tokens`
|
|
52
|
+
* (`FlowWeightedEdge`, `FlowSelfLoopEdge`); a custom edge renders it after its
|
|
53
|
+
* `FlowEdgePath` with the same `path` string.
|
|
54
|
+
*/
|
|
55
|
+
export const FlowEdgeTokens = forwardRef<SVGGElement, FlowEdgeTokensProps>(function FlowEdgeTokens(
|
|
56
|
+
{ path, tokens, className, ...props },
|
|
57
|
+
ref,
|
|
58
|
+
) {
|
|
59
|
+
const reducedMotion = useReducedMotion();
|
|
60
|
+
const sampler = useMemo(() => createEdgePathSampler(path), [path]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<g
|
|
64
|
+
ref={ref}
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
data-slot="flow-edge-tokens"
|
|
67
|
+
data-motion={reducedMotion ? "reduced" : "animated"}
|
|
68
|
+
className={cn("pointer-events-none", className)}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
{sampler
|
|
72
|
+
? tokens.map((token) => {
|
|
73
|
+
const { x, y } = sampler.pointAt(token.progress);
|
|
74
|
+
return (
|
|
75
|
+
<circle
|
|
76
|
+
key={token.id}
|
|
77
|
+
data-slot="flow-edge-tokens-token"
|
|
78
|
+
data-token-id={token.id}
|
|
79
|
+
cx={0}
|
|
80
|
+
cy={0}
|
|
81
|
+
r={token.radius ?? DEFAULT_EDGE_TOKEN_RADIUS}
|
|
82
|
+
strokeWidth={1.5}
|
|
83
|
+
className={cn(
|
|
84
|
+
"fill-primary stroke-background",
|
|
85
|
+
!reducedMotion && "transition-transform duration-fast ease-standard",
|
|
86
|
+
)}
|
|
87
|
+
style={{ transform: `translate(${x}px, ${y}px)` }}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
})
|
|
91
|
+
: null}
|
|
92
|
+
</g>
|
|
93
|
+
);
|
|
94
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FlowEdgeTokens,
|
|
3
|
+
DEFAULT_EDGE_TOKEN_RADIUS,
|
|
4
|
+
type FlowEdgeToken,
|
|
5
|
+
type FlowEdgeTokensProps,
|
|
6
|
+
} from "./flow-edge-tokens";
|
|
7
|
+
export {
|
|
8
|
+
createEdgePathSampler,
|
|
9
|
+
flattenEdgePath,
|
|
10
|
+
type EdgePathPoint,
|
|
11
|
+
type EdgePathSampler,
|
|
12
|
+
} from "./edge-path-sampler";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Edge, Node } from "@xyflow/react";
|
|
3
|
+
import { pinBackbone } from "./backbone";
|
|
4
|
+
import { layoutFlowElk } from "./layout-flow-elk";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A 3-variant process: the top variant runs start → register → check → approve → pay → end;
|
|
8
|
+
* a second skips `check`; a third reworks `check` via `request info`.
|
|
9
|
+
*/
|
|
10
|
+
function threeVariantGraph(): { nodes: Node[]; edges: Edge[] } {
|
|
11
|
+
const ids = ["start", "register", "check", "info", "approve", "reject", "pay", "end"];
|
|
12
|
+
const nodes: Node[] = ids.map((id) => ({ id, position: { x: 0, y: 0 }, data: {} }));
|
|
13
|
+
const pairs: [string, string][] = [
|
|
14
|
+
["start", "register"],
|
|
15
|
+
["register", "check"],
|
|
16
|
+
["check", "approve"],
|
|
17
|
+
["approve", "pay"],
|
|
18
|
+
["pay", "end"],
|
|
19
|
+
["register", "approve"],
|
|
20
|
+
["check", "info"],
|
|
21
|
+
["info", "check"],
|
|
22
|
+
["check", "reject"],
|
|
23
|
+
["reject", "end"],
|
|
24
|
+
];
|
|
25
|
+
const edges: Edge[] = pairs.map(([source, target]) => ({
|
|
26
|
+
id: `${source}-${target}`,
|
|
27
|
+
source,
|
|
28
|
+
target,
|
|
29
|
+
}));
|
|
30
|
+
return { nodes, edges };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOP_VARIANT = ["register", "check", "approve", "pay"];
|
|
34
|
+
|
|
35
|
+
describe("pinBackbone", () => {
|
|
36
|
+
it("pins the variant's nodes and the edges that join them, in order", () => {
|
|
37
|
+
const { nodes, edges } = threeVariantGraph();
|
|
38
|
+
const backbone = pinBackbone(nodes, edges, [...TOP_VARIANT, "missing", "check"]);
|
|
39
|
+
expect(backbone.nodeIds).toEqual(TOP_VARIANT);
|
|
40
|
+
expect(backbone.edgeIds).toEqual(["register-check", "check-approve", "approve-pay"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("lays the top variant's four activities on one straight row (LR)", async () => {
|
|
44
|
+
const { nodes, edges } = threeVariantGraph();
|
|
45
|
+
const backbone = pinBackbone(nodes, edges, TOP_VARIANT);
|
|
46
|
+
const result = await layoutFlowElk(nodes, edges, { direction: "LR", backbone });
|
|
47
|
+
expect(result.engine).toBe("elk");
|
|
48
|
+
|
|
49
|
+
const byId = new Map(result.nodes.map((n) => [n.id, n]));
|
|
50
|
+
const rows = TOP_VARIANT.map((id) => byId.get(id)!.position.y);
|
|
51
|
+
expect(new Set(rows).size).toBe(1);
|
|
52
|
+
const columns = TOP_VARIANT.map((id) => byId.get(id)!.position.x);
|
|
53
|
+
expect(columns).toEqual([...columns].sort((p, q) => p - q));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("lays it on one straight column (TB) and every node still connects start to end", async () => {
|
|
57
|
+
const { nodes, edges } = threeVariantGraph();
|
|
58
|
+
const backbone = pinBackbone(nodes, edges, TOP_VARIANT);
|
|
59
|
+
const result = await layoutFlowElk(nodes, edges, { direction: "TB", backbone });
|
|
60
|
+
|
|
61
|
+
const byId = new Map(result.nodes.map((n) => [n.id, n]));
|
|
62
|
+
expect(new Set(TOP_VARIANT.map((id) => byId.get(id)!.position.x)).size).toBe(1);
|
|
63
|
+
|
|
64
|
+
// Connectivity invariant: every node is reachable from start and reaches end.
|
|
65
|
+
const reach = (from: string, forward: boolean): Set<string> => {
|
|
66
|
+
const seen = new Set([from]);
|
|
67
|
+
const queue = [from];
|
|
68
|
+
while (queue.length > 0) {
|
|
69
|
+
const id = queue.pop()!;
|
|
70
|
+
for (const edge of result.edges) {
|
|
71
|
+
const [a, b] = forward ? [edge.source, edge.target] : [edge.target, edge.source];
|
|
72
|
+
if (a === id && !seen.has(b)) {
|
|
73
|
+
seen.add(b);
|
|
74
|
+
queue.push(b);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return seen;
|
|
79
|
+
};
|
|
80
|
+
const fromStart = reach("start", true);
|
|
81
|
+
const toEnd = reach("end", false);
|
|
82
|
+
for (const node of result.nodes) {
|
|
83
|
+
expect(fromStart.has(node.id)).toBe(true);
|
|
84
|
+
expect(toEnd.has(node.id)).toBe(true);
|
|
85
|
+
expect(Number.isFinite(node.position.x) && Number.isFinite(node.position.y)).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Edge, Node } from "@xyflow/react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One path through the graph to lay out on a single straight line (RM-067).
|
|
5
|
+
*
|
|
6
|
+
* Produced by {@link pinBackbone}, consumed by `layoutFlowElk`'s `backbone` option. Plain
|
|
7
|
+
* data, so it clones, serializes and memoizes like any other layout input.
|
|
8
|
+
*/
|
|
9
|
+
export interface FlowElkBackbone {
|
|
10
|
+
/** Node ids on the pinned path, in path order. */
|
|
11
|
+
nodeIds: string[];
|
|
12
|
+
/** Ids of the edges joining consecutive pinned nodes, in path order. */
|
|
13
|
+
edgeIds: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pin a variant's activity sequence to one straight row (LR/RL) or column (TB/BT) — the
|
|
18
|
+
* Apromore-style "backbone" layout (RM-067).
|
|
19
|
+
*
|
|
20
|
+
* A pre-pass, not a layout: hand the result to `layoutFlowElk` as `backbone` and the rest of
|
|
21
|
+
* the graph lays out around it. ELK gets the joining edges at the highest straightness
|
|
22
|
+
* priority, then every layer holding a pinned node is translated as a whole along the
|
|
23
|
+
* cross axis so the pinned nodes share one line — a whole-layer shift, so no two nodes of
|
|
24
|
+
* a layer can ever be pushed onto each other.
|
|
25
|
+
*
|
|
26
|
+
* `variant` is the sequence of NODE IDS — for a process graph, the most frequent variant's
|
|
27
|
+
* activities, which are the activity nodes' ids. An id with no node is skipped; a repeated
|
|
28
|
+
* activity (rework inside the variant) is pinned once, at its first occurrence, because a
|
|
29
|
+
* node cannot sit on a straight line twice.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const backbone = pinBackbone(nodes, edges, variants[0].activities);
|
|
34
|
+
* const { nodes: laidOut } = await layoutFlowElk(nodes, edges, { direction: "LR", backbone });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function pinBackbone(
|
|
38
|
+
nodes: readonly Node[],
|
|
39
|
+
edges: readonly Edge[],
|
|
40
|
+
variant: readonly string[],
|
|
41
|
+
): FlowElkBackbone {
|
|
42
|
+
const known = new Set(nodes.map((node) => node.id));
|
|
43
|
+
const nodeIds: string[] = [];
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
for (const id of variant) {
|
|
46
|
+
if (!known.has(id) || seen.has(id)) continue;
|
|
47
|
+
seen.add(id);
|
|
48
|
+
nodeIds.push(id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const edgeIds: string[] = [];
|
|
52
|
+
for (let i = 0; i < nodeIds.length - 1; i += 1) {
|
|
53
|
+
const edge = edges.find((e) => e.source === nodeIds[i] && e.target === nodeIds[i + 1]);
|
|
54
|
+
if (edge) edgeIds.push(edge.id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { nodeIds, edgeIds };
|
|
58
|
+
}
|