@elabs-ai/components-flow 4.2.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 +236 -1
- package/dist/index.js +167 -2311
- 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 +13 -6
- 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 +10 -7
- 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/templates-flow-workspace.stories.tsx +7 -2
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createEdgePathSampler, flattenEdgePath } from "./edge-path-sampler";
|
|
3
|
+
|
|
4
|
+
function expectPoint(actual: { x: number; y: number }, x: number, y: number, digits = 3) {
|
|
5
|
+
expect(actual.x).toBeCloseTo(x, digits);
|
|
6
|
+
expect(actual.y).toBeCloseTo(y, digits);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("createEdgePathSampler", () => {
|
|
10
|
+
it("returns null for a string that describes no geometry", () => {
|
|
11
|
+
expect(createEdgePathSampler("")).toBeNull();
|
|
12
|
+
expect(createEdgePathSampler("not a path")).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("measures and samples a straight line by arc length", () => {
|
|
16
|
+
const s = createEdgePathSampler("M0,0 L100,0")!;
|
|
17
|
+
expect(s.length).toBe(100);
|
|
18
|
+
expectPoint(s.pointAt(0), 0, 0);
|
|
19
|
+
expectPoint(s.pointAt(0.25), 25, 0);
|
|
20
|
+
expectPoint(s.pointAt(1), 100, 0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("clamps out-of-range progress and pins non-finite progress to the start", () => {
|
|
24
|
+
const s = createEdgePathSampler("M0,0 L100,0")!;
|
|
25
|
+
expectPoint(s.pointAt(-1), 0, 0);
|
|
26
|
+
expectPoint(s.pointAt(2), 100, 0);
|
|
27
|
+
expectPoint(s.pointAt(Number.NaN), 0, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("walks a polyline at an even speed across corners", () => {
|
|
31
|
+
// 100 across, then 100 down: halfway along is the corner itself.
|
|
32
|
+
const s = createEdgePathSampler("M 0 0 H 100 V 100")!;
|
|
33
|
+
expect(s.length).toBe(200);
|
|
34
|
+
expectPoint(s.pointAt(0.5), 100, 0);
|
|
35
|
+
expectPoint(s.pointAt(0.75), 100, 50);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resolves relative commands and implicit line-tos after M", () => {
|
|
39
|
+
const s = createEdgePathSampler("m10 10 20 0 l0 20")!;
|
|
40
|
+
expect(s.length).toBe(40);
|
|
41
|
+
expectPoint(s.pointAt(1), 30, 30);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("parses React Flow's compact bezier spelling and lands on the curve midpoint", () => {
|
|
45
|
+
// Symmetric cubic: its t=0.5 point is also its arc-length midpoint.
|
|
46
|
+
// B(0.5) = (P0 + 3·C1 + 3·C2 + P3) / 8
|
|
47
|
+
const s = createEdgePathSampler("M0,0 C0,50 100,50 100,0")!;
|
|
48
|
+
expectPoint(s.pointAt(0.5), 50, 37.5, 1);
|
|
49
|
+
expectPoint(s.pointAt(1), 100, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("parses React Flow's smoothstep spelling (`L` glued to the previous pair, `Q` bends)", () => {
|
|
53
|
+
const d = "M 0,0L 0,40Q 0,50 10,50L 90,50Q 100,50 100,60L 100,100";
|
|
54
|
+
const s = createEdgePathSampler(d)!;
|
|
55
|
+
expectPoint(s.pointAt(0), 0, 0);
|
|
56
|
+
expectPoint(s.pointAt(1), 100, 100);
|
|
57
|
+
// Symmetric route: the midpoint is the middle of the horizontal run.
|
|
58
|
+
expectPoint(s.pointAt(0.5), 50, 50, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("follows an elliptical arc (a half circle of radius 50)", () => {
|
|
62
|
+
const s = createEdgePathSampler("M0,0 A50,50 0 0 1 100,0")!;
|
|
63
|
+
// Half circumference, within the polyline's chord error.
|
|
64
|
+
expect(s.length).toBeCloseTo(Math.PI * 50, 0);
|
|
65
|
+
// Sweep flag 1 runs clockwise in SVG's y-down space: over the top (negative y).
|
|
66
|
+
expectPoint(s.pointAt(0.5), 50, -50, 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads compact arc flags (`0 01`)", () => {
|
|
70
|
+
const a = createEdgePathSampler("M0,0 A50,50 0 0 1 100,0")!;
|
|
71
|
+
const b = createEdgePathSampler("M0,0 A50,50 0 01100,0")!;
|
|
72
|
+
expectPoint(b.pointAt(0.5), a.pointAt(0.5).x, a.pointAt(0.5).y);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reflects the control point for S and T", () => {
|
|
76
|
+
const viaS = createEdgePathSampler("M0,0 C0,50 50,50 50,0 S100,-50 100,0")!;
|
|
77
|
+
const viaC = createEdgePathSampler("M0,0 C0,50 50,50 50,0 C50,-50 100,-50 100,0")!;
|
|
78
|
+
expectPoint(viaS.pointAt(0.8), viaC.pointAt(0.8).x, viaC.pointAt(0.8).y);
|
|
79
|
+
const viaT = createEdgePathSampler("M0,0 Q25,50 50,0 T100,0")!;
|
|
80
|
+
const viaQ = createEdgePathSampler("M0,0 Q25,50 50,0 Q75,-50 100,0")!;
|
|
81
|
+
expectPoint(viaT.pointAt(0.8), viaQ.pointAt(0.8).x, viaQ.pointAt(0.8).y);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("closes a subpath with Z and never counts the jump to the next M", () => {
|
|
85
|
+
const s = createEdgePathSampler("M0,0 L10,0 L10,10 Z M100,100 L110,100")!;
|
|
86
|
+
// 10 + 10 + √200 for the triangle, + 10 for the second subpath; the M jump is free.
|
|
87
|
+
expect(s.length).toBeCloseTo(30 + Math.sqrt(200), 6);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps everything before a malformed argument and never throws", () => {
|
|
91
|
+
// The shape the edge unit tests' path mocks produce: a C with one coordinate pair.
|
|
92
|
+
expect(() => createEdgePathSampler("M0,0 C100,100")).not.toThrow();
|
|
93
|
+
const s = createEdgePathSampler("M0,0 L10,0 C100,100")!;
|
|
94
|
+
expect(s.length).toBe(10);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("samples a single point to that point", () => {
|
|
98
|
+
const s = createEdgePathSampler("M5,7")!;
|
|
99
|
+
expect(s.length).toBe(0);
|
|
100
|
+
expectPoint(s.pointAt(0.6), 5, 7);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("flattenEdgePath", () => {
|
|
105
|
+
it("splits subpaths at each M", () => {
|
|
106
|
+
expect(flattenEdgePath("M0,0 L1,0 M5,5 L6,5")).toHaveLength(2);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure SVG path sampling — the position maths behind `FlowEdgeTokens`.
|
|
3
|
+
*
|
|
4
|
+
* `SVGGeometryElement.getPointAtLength` would do the same job in a browser, but
|
|
5
|
+
* it needs a mounted element (so a layout effect and a second render) and jsdom
|
|
6
|
+
* implements none of it. Parsing the `d` string here keeps a token's position a
|
|
7
|
+
* plain function of `(path, progress)`: identical on the server, in jsdom and in
|
|
8
|
+
* every browser, and testable without a DOM.
|
|
9
|
+
*
|
|
10
|
+
* Supports every path command (`M L H V C S Q T A Z`, absolute and relative) in
|
|
11
|
+
* the spellings React Flow's path helpers emit (`M0,0 C…`, `M 0,0L …`, `M0 0`).
|
|
12
|
+
* Curves and arcs are flattened into a polyline fine enough that a token sits on
|
|
13
|
+
* the drawn stroke; progress is measured along that polyline's arc length, so a
|
|
14
|
+
* token moves at an even speed regardless of how the curve's control points are
|
|
15
|
+
* spaced.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface EdgePathPoint {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EdgePathSampler {
|
|
24
|
+
/** Total arc length of the path, in px. */
|
|
25
|
+
length: number;
|
|
26
|
+
/** The point at `progress` (0..1) of the path's arc length. Out-of-range or non-finite progress is clamped. */
|
|
27
|
+
pointAt: (progress: number) => EdgePathPoint;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Polyline segments per cubic/quadratic curve — enough that the chord error stays sub-pixel at edge scale. */
|
|
31
|
+
const CURVE_STEPS = 32;
|
|
32
|
+
/** Maximum angle, in radians, spanned by one polyline segment of an elliptical arc. */
|
|
33
|
+
const ARC_STEP = Math.PI / 32;
|
|
34
|
+
|
|
35
|
+
const COMMAND = /[MmLlHhVvCcSsQqTtAaZz]/;
|
|
36
|
+
|
|
37
|
+
/** Cursor-based scanner: SVG path grammar allows `1-2`, `.5.5` and compact arc flags (`0 01`). */
|
|
38
|
+
class PathScanner {
|
|
39
|
+
private i = 0;
|
|
40
|
+
constructor(private readonly d: string) {}
|
|
41
|
+
|
|
42
|
+
private skipSeparators() {
|
|
43
|
+
while (this.i < this.d.length && /[\s,]/.test(this.d[this.i]!)) this.i++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
peekCommand(): string | null {
|
|
47
|
+
this.skipSeparators();
|
|
48
|
+
const ch = this.d[this.i];
|
|
49
|
+
return ch !== undefined && COMMAND.test(ch) ? ch : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
readCommand(): string {
|
|
53
|
+
const cmd = this.d[this.i]!;
|
|
54
|
+
this.i++;
|
|
55
|
+
return cmd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
atEnd(): boolean {
|
|
59
|
+
this.skipSeparators();
|
|
60
|
+
return this.i >= this.d.length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hasNumber(): boolean {
|
|
64
|
+
this.skipSeparators();
|
|
65
|
+
const ch = this.d[this.i];
|
|
66
|
+
return ch !== undefined && /[-+.\d]/.test(ch);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
readNumber(): number {
|
|
70
|
+
this.skipSeparators();
|
|
71
|
+
const m = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/.exec(this.d.slice(this.i));
|
|
72
|
+
if (!m) return Number.NaN;
|
|
73
|
+
this.i += m[0].length;
|
|
74
|
+
return Number(m[0]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
readFlag(): number {
|
|
78
|
+
this.skipSeparators();
|
|
79
|
+
const ch = this.d[this.i];
|
|
80
|
+
if (ch === "0" || ch === "1") {
|
|
81
|
+
this.i++;
|
|
82
|
+
return Number(ch);
|
|
83
|
+
}
|
|
84
|
+
return Number.NaN;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Skip one unparseable character so a malformed path can never loop forever. */
|
|
88
|
+
skip() {
|
|
89
|
+
this.i++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cubicAt(p0: number, p1: number, p2: number, p3: number, t: number): number {
|
|
94
|
+
const u = 1 - t;
|
|
95
|
+
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function quadAt(p0: number, p1: number, p2: number, t: number): number {
|
|
99
|
+
const u = 1 - t;
|
|
100
|
+
return u * u * p0 + 2 * u * t * p1 + t * t * p2;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function vectorAngle(ux: number, uy: number, vx: number, vy: number): number {
|
|
104
|
+
const sign = ux * vy - uy * vx < 0 ? -1 : 1;
|
|
105
|
+
const dot = ux * vx + uy * vy;
|
|
106
|
+
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
|
|
107
|
+
return sign * Math.acos(Math.min(1, Math.max(-1, dot / len)));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Flatten an endpoint-parameterised elliptical arc (SVG 1.1 §F.6.5) into points after the start. */
|
|
111
|
+
function arcPoints(
|
|
112
|
+
x1: number,
|
|
113
|
+
y1: number,
|
|
114
|
+
rxIn: number,
|
|
115
|
+
ryIn: number,
|
|
116
|
+
rotationDeg: number,
|
|
117
|
+
largeArc: number,
|
|
118
|
+
sweep: number,
|
|
119
|
+
x2: number,
|
|
120
|
+
y2: number,
|
|
121
|
+
): EdgePathPoint[] {
|
|
122
|
+
let rx = Math.abs(rxIn);
|
|
123
|
+
let ry = Math.abs(ryIn);
|
|
124
|
+
if (rx === 0 || ry === 0 || (x1 === x2 && y1 === y2)) return [{ x: x2, y: y2 }];
|
|
125
|
+
|
|
126
|
+
const phi = (rotationDeg * Math.PI) / 180;
|
|
127
|
+
const cos = Math.cos(phi);
|
|
128
|
+
const sin = Math.sin(phi);
|
|
129
|
+
const dx = (x1 - x2) / 2;
|
|
130
|
+
const dy = (y1 - y2) / 2;
|
|
131
|
+
const x1p = cos * dx + sin * dy;
|
|
132
|
+
const y1p = -sin * dx + cos * dy;
|
|
133
|
+
|
|
134
|
+
const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
|
|
135
|
+
if (lambda > 1) {
|
|
136
|
+
const s = Math.sqrt(lambda);
|
|
137
|
+
rx *= s;
|
|
138
|
+
ry *= s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
|
|
142
|
+
const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
|
|
143
|
+
const coef = (largeArc === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
|
|
144
|
+
const cxp = (coef * rx * y1p) / ry;
|
|
145
|
+
const cyp = (-coef * ry * x1p) / rx;
|
|
146
|
+
const cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
|
|
147
|
+
const cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
|
|
148
|
+
|
|
149
|
+
const theta1 = vectorAngle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
|
|
150
|
+
let delta = vectorAngle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
|
|
151
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI;
|
|
152
|
+
if (sweep && delta < 0) delta += 2 * Math.PI;
|
|
153
|
+
|
|
154
|
+
const steps = Math.max(1, Math.ceil(Math.abs(delta) / ARC_STEP));
|
|
155
|
+
const out: EdgePathPoint[] = [];
|
|
156
|
+
for (let s = 1; s <= steps; s++) {
|
|
157
|
+
if (s === steps) {
|
|
158
|
+
out.push({ x: x2, y: y2 });
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const a = theta1 + (delta * s) / steps;
|
|
162
|
+
const ex = rx * Math.cos(a);
|
|
163
|
+
const ey = ry * Math.sin(a);
|
|
164
|
+
out.push({ x: cos * ex - sin * ey + cx, y: sin * ex + cos * ey + cy });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Flatten a path `d` string into subpath polylines. Each subpath starts at its
|
|
171
|
+
* `M`. Parsing stops at the first malformed argument; everything before it is kept.
|
|
172
|
+
*/
|
|
173
|
+
export function flattenEdgePath(d: string): EdgePathPoint[][] {
|
|
174
|
+
const scan = new PathScanner(d);
|
|
175
|
+
const subpaths: EdgePathPoint[][] = [];
|
|
176
|
+
let current: EdgePathPoint[] | null = null;
|
|
177
|
+
let x = 0;
|
|
178
|
+
let y = 0;
|
|
179
|
+
let startX = 0;
|
|
180
|
+
let startY = 0;
|
|
181
|
+
// Reflected control point for S/T; reset by any other command.
|
|
182
|
+
let lastCubic: EdgePathPoint | null = null;
|
|
183
|
+
let lastQuad: EdgePathPoint | null = null;
|
|
184
|
+
let cmd: string | null = null;
|
|
185
|
+
|
|
186
|
+
const lineTo = (nx: number, ny: number) => {
|
|
187
|
+
if (!current) {
|
|
188
|
+
current = [{ x, y }];
|
|
189
|
+
subpaths.push(current);
|
|
190
|
+
}
|
|
191
|
+
current.push({ x: nx, y: ny });
|
|
192
|
+
x = nx;
|
|
193
|
+
y = ny;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
while (!scan.atEnd()) {
|
|
197
|
+
const next = scan.peekCommand();
|
|
198
|
+
if (next) {
|
|
199
|
+
cmd = scan.readCommand();
|
|
200
|
+
} else if (!cmd || !scan.hasNumber()) {
|
|
201
|
+
scan.skip();
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// After an `M`, further coordinate pairs are implicit line-tos.
|
|
205
|
+
const active: string = cmd!;
|
|
206
|
+
const rel = active === active.toLowerCase();
|
|
207
|
+
const ox = rel ? x : 0;
|
|
208
|
+
const oy = rel ? y : 0;
|
|
209
|
+
const upper = active.toUpperCase();
|
|
210
|
+
|
|
211
|
+
if (upper === "Z") {
|
|
212
|
+
lineTo(startX, startY);
|
|
213
|
+
current = null;
|
|
214
|
+
lastCubic = lastQuad = null;
|
|
215
|
+
cmd = null;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const n: number[] = [];
|
|
220
|
+
const arity = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7 }[upper] ?? 0;
|
|
221
|
+
for (let k = 0; k < arity; k++) {
|
|
222
|
+
n.push(upper === "A" && (k === 3 || k === 4) ? scan.readFlag() : scan.readNumber());
|
|
223
|
+
}
|
|
224
|
+
if (n.some((v) => !Number.isFinite(v))) break;
|
|
225
|
+
|
|
226
|
+
switch (upper) {
|
|
227
|
+
case "M": {
|
|
228
|
+
x = n[0]! + ox;
|
|
229
|
+
y = n[1]! + oy;
|
|
230
|
+
startX = x;
|
|
231
|
+
startY = y;
|
|
232
|
+
current = [{ x, y }];
|
|
233
|
+
subpaths.push(current);
|
|
234
|
+
cmd = rel ? "l" : "L";
|
|
235
|
+
lastCubic = lastQuad = null;
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case "L":
|
|
239
|
+
lineTo(n[0]! + ox, n[1]! + oy);
|
|
240
|
+
lastCubic = lastQuad = null;
|
|
241
|
+
break;
|
|
242
|
+
case "H":
|
|
243
|
+
lineTo(n[0]! + ox, y);
|
|
244
|
+
lastCubic = lastQuad = null;
|
|
245
|
+
break;
|
|
246
|
+
case "V":
|
|
247
|
+
lineTo(x, n[0]! + oy);
|
|
248
|
+
lastCubic = lastQuad = null;
|
|
249
|
+
break;
|
|
250
|
+
case "C":
|
|
251
|
+
case "S": {
|
|
252
|
+
const reflected: EdgePathPoint | null = lastCubic;
|
|
253
|
+
let c1x = x;
|
|
254
|
+
let c1y = y;
|
|
255
|
+
if (upper === "C") {
|
|
256
|
+
c1x = n[0]! + ox;
|
|
257
|
+
c1y = n[1]! + oy;
|
|
258
|
+
} else if (reflected) {
|
|
259
|
+
c1x = 2 * x - reflected.x;
|
|
260
|
+
c1y = 2 * y - reflected.y;
|
|
261
|
+
}
|
|
262
|
+
const rest = upper === "C" ? n.slice(2) : n;
|
|
263
|
+
const c2x = rest[0]! + ox;
|
|
264
|
+
const c2y = rest[1]! + oy;
|
|
265
|
+
const ex = rest[2]! + ox;
|
|
266
|
+
const ey = rest[3]! + oy;
|
|
267
|
+
const x0 = x;
|
|
268
|
+
const y0 = y;
|
|
269
|
+
for (let s = 1; s <= CURVE_STEPS; s++) {
|
|
270
|
+
const t = s / CURVE_STEPS;
|
|
271
|
+
lineTo(cubicAt(x0, c1x, c2x, ex, t), cubicAt(y0, c1y, c2y, ey, t));
|
|
272
|
+
}
|
|
273
|
+
lastCubic = { x: c2x, y: c2y };
|
|
274
|
+
lastQuad = null;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "Q":
|
|
278
|
+
case "T": {
|
|
279
|
+
const reflected: EdgePathPoint | null = lastQuad;
|
|
280
|
+
let qx = x;
|
|
281
|
+
let qy = y;
|
|
282
|
+
if (upper === "Q") {
|
|
283
|
+
qx = n[0]! + ox;
|
|
284
|
+
qy = n[1]! + oy;
|
|
285
|
+
} else if (reflected) {
|
|
286
|
+
qx = 2 * x - reflected.x;
|
|
287
|
+
qy = 2 * y - reflected.y;
|
|
288
|
+
}
|
|
289
|
+
const rest = upper === "Q" ? n.slice(2) : n;
|
|
290
|
+
const ex = rest[0]! + ox;
|
|
291
|
+
const ey = rest[1]! + oy;
|
|
292
|
+
const x0 = x;
|
|
293
|
+
const y0 = y;
|
|
294
|
+
for (let s = 1; s <= CURVE_STEPS; s++) {
|
|
295
|
+
const t = s / CURVE_STEPS;
|
|
296
|
+
lineTo(quadAt(x0, qx, ex, t), quadAt(y0, qy, ey, t));
|
|
297
|
+
}
|
|
298
|
+
lastQuad = { x: qx, y: qy };
|
|
299
|
+
lastCubic = null;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "A": {
|
|
303
|
+
const pts = arcPoints(x, y, n[0]!, n[1]!, n[2]!, n[3]!, n[4]!, n[5]! + ox, n[6]! + oy);
|
|
304
|
+
for (const p of pts) lineTo(p.x, p.y);
|
|
305
|
+
lastCubic = lastQuad = null;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return subpaths;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function clamp01(t: number): number {
|
|
314
|
+
if (!Number.isFinite(t)) return 0;
|
|
315
|
+
return Math.min(1, Math.max(0, t));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Build a sampler for a path `d` string, or `null` when the string describes no
|
|
320
|
+
* geometry at all (empty, or not a path). A path that is a single point has
|
|
321
|
+
* length 0 and samples to that point for every progress.
|
|
322
|
+
*
|
|
323
|
+
* Subpaths are walked in order and the jump between them is not counted — a
|
|
324
|
+
* token never travels through the gap an `M` leaves.
|
|
325
|
+
*/
|
|
326
|
+
export function createEdgePathSampler(d: string): EdgePathSampler | null {
|
|
327
|
+
const points: EdgePathPoint[] = [];
|
|
328
|
+
const cumulative: number[] = [];
|
|
329
|
+
let total = 0;
|
|
330
|
+
for (const sub of flattenEdgePath(d)) {
|
|
331
|
+
sub.forEach((p, idx) => {
|
|
332
|
+
if (idx > 0) {
|
|
333
|
+
const prev = sub[idx - 1]!;
|
|
334
|
+
total += Math.hypot(p.x - prev.x, p.y - prev.y);
|
|
335
|
+
}
|
|
336
|
+
points.push(p);
|
|
337
|
+
cumulative.push(total);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (points.length === 0) return null;
|
|
341
|
+
|
|
342
|
+
const pointAt = (progress: number): EdgePathPoint => {
|
|
343
|
+
const target = clamp01(progress) * total;
|
|
344
|
+
// Binary search for the first vertex at or past `target`.
|
|
345
|
+
let lo = 0;
|
|
346
|
+
let hi = cumulative.length - 1;
|
|
347
|
+
while (lo < hi) {
|
|
348
|
+
const mid = (lo + hi) >> 1;
|
|
349
|
+
if (cumulative[mid]! < target) lo = mid + 1;
|
|
350
|
+
else hi = mid;
|
|
351
|
+
}
|
|
352
|
+
if (lo === 0) return { ...points[0]! };
|
|
353
|
+
const segStart = cumulative[lo - 1]!;
|
|
354
|
+
const segLen = cumulative[lo]! - segStart;
|
|
355
|
+
const a = points[lo - 1]!;
|
|
356
|
+
const b = points[lo]!;
|
|
357
|
+
const t = segLen === 0 ? 1 : (target - segStart) / segLen;
|
|
358
|
+
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { length: total, pointAt };
|
|
362
|
+
}
|