@kahitsan/ksui 0.21.0 → 0.23.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/package.json +1 -1
- package/src/components/composite/FlowGraph.test.tsx +140 -0
- package/src/components/composite/FlowGraph.tsx +338 -0
- package/src/index.ts +19 -0
- package/src/utils/graph.ts +154 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// FlowGraph tests: the pure layout (lanes, dimensions, bipartite/layered) and
|
|
2
|
+
// the SVG render (nodes, edges, empty state, node selection).
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { render, fireEvent } from "@solidjs/testing-library";
|
|
5
|
+
import FlowGraph from "./FlowGraph";
|
|
6
|
+
import { layoutGraph, DEFAULT_METRICS, type GraphEdge, type GraphNode } from "../../utils/graph";
|
|
7
|
+
|
|
8
|
+
const { nodeW, gapX, pad } = DEFAULT_METRICS;
|
|
9
|
+
|
|
10
|
+
describe("layoutGraph", () => {
|
|
11
|
+
it("layers roots→leaves by longest path", () => {
|
|
12
|
+
const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
|
|
13
|
+
const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "c" }];
|
|
14
|
+
const { byId } = layoutGraph(nodes, edges, "layered");
|
|
15
|
+
expect(byId.get("a")!.lane).toBe(0);
|
|
16
|
+
expect(byId.get("b")!.lane).toBe(1);
|
|
17
|
+
expect(byId.get("c")!.lane).toBe(2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("uses the longest path when a node has two incoming depths", () => {
|
|
21
|
+
// a→c and a→b→c: c must sit past b, not just past a.
|
|
22
|
+
const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
|
|
23
|
+
const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "a", to: "c" }];
|
|
24
|
+
const { byId } = layoutGraph(nodes, edges, "layered");
|
|
25
|
+
expect(byId.get("c")!.lane).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("splits bipartite by incoming degree, honoring explicit lanes for isolated sinks", () => {
|
|
29
|
+
const nodes: GraphNode[] = [
|
|
30
|
+
{ id: "role", label: "admin" },
|
|
31
|
+
{ id: "p1", label: "view" },
|
|
32
|
+
{ id: "p2", label: "delete", lane: 1 }, // ungranted permission, no edge
|
|
33
|
+
];
|
|
34
|
+
const edges: GraphEdge[] = [{ from: "role", to: "p1" }];
|
|
35
|
+
const { byId } = layoutGraph(nodes, edges, "bipartite");
|
|
36
|
+
expect(byId.get("role")!.lane).toBe(0);
|
|
37
|
+
expect(byId.get("p1")!.lane).toBe(1);
|
|
38
|
+
expect(byId.get("p2")!.lane).toBe(1); // pinned, despite no incoming edge
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not spin forever on a cycle", () => {
|
|
42
|
+
const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
|
|
43
|
+
const edges: GraphEdge[] = [{ from: "a", to: "b" }, { from: "b", to: "a" }];
|
|
44
|
+
const { nodes: out } = layoutGraph(nodes, edges, "layered");
|
|
45
|
+
expect(out).toHaveLength(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("positions the second lane one node-width + gap past the first", () => {
|
|
49
|
+
const nodes: GraphNode[] = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
|
|
50
|
+
const { byId } = layoutGraph(nodes, [{ from: "a", to: "b" }], "layered");
|
|
51
|
+
expect(byId.get("a")!.x).toBe(pad);
|
|
52
|
+
expect(byId.get("b")!.x).toBe(pad + nodeW + gapX);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores edges that dangle off the node set", () => {
|
|
56
|
+
const { nodes } = layoutGraph([{ id: "a", label: "A" }], [{ from: "a", to: "ghost" }], "layered");
|
|
57
|
+
expect(nodes).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("FlowGraph", () => {
|
|
62
|
+
it("renders a node per input node and an svg", () => {
|
|
63
|
+
const { getByTestId } = render(() => (
|
|
64
|
+
<FlowGraph
|
|
65
|
+
testId="fg"
|
|
66
|
+
nodes={[{ id: "a", label: "Alpha", sublabel: "base" }, { id: "b", label: "Beta" }]}
|
|
67
|
+
edges={[{ from: "a", to: "b" }]}
|
|
68
|
+
/>
|
|
69
|
+
));
|
|
70
|
+
expect(getByTestId("fg-svg")).toBeTruthy();
|
|
71
|
+
expect(getByTestId("fg-node-a")).toBeTruthy();
|
|
72
|
+
expect(getByTestId("fg-node-b")).toBeTruthy();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows the empty state when there are no nodes", () => {
|
|
76
|
+
const { getByTestId, queryByTestId } = render(() => (
|
|
77
|
+
<FlowGraph testId="fg" nodes={[]} edges={[]} emptyLabel="No connections" />
|
|
78
|
+
));
|
|
79
|
+
expect(getByTestId("fg-empty").textContent).toContain("No connections");
|
|
80
|
+
expect(queryByTestId("fg-svg")).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("makes nodes interactive only when onNodeSelect is supplied", () => {
|
|
84
|
+
const onNodeSelect = vi.fn();
|
|
85
|
+
const { getByTestId } = render(() => (
|
|
86
|
+
<FlowGraph
|
|
87
|
+
testId="fg"
|
|
88
|
+
nodes={[{ id: "a", label: "Alpha" }]}
|
|
89
|
+
edges={[]}
|
|
90
|
+
onNodeSelect={onNodeSelect}
|
|
91
|
+
/>
|
|
92
|
+
));
|
|
93
|
+
const node = getByTestId("fg-node-a");
|
|
94
|
+
expect(node.getAttribute("role")).toBe("button");
|
|
95
|
+
fireEvent.click(node);
|
|
96
|
+
expect(onNodeSelect).toHaveBeenCalledWith("a");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not mark nodes as buttons without a handler", () => {
|
|
100
|
+
const { getByTestId } = render(() => (
|
|
101
|
+
<FlowGraph testId="fg" nodes={[{ id: "a", label: "Alpha" }]} edges={[]} />
|
|
102
|
+
));
|
|
103
|
+
expect(getByTestId("fg-node-a").getAttribute("role")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("renders pan/zoom controls only when interactive", () => {
|
|
107
|
+
const staticGraph = render(() => (
|
|
108
|
+
<FlowGraph testId="s" nodes={[{ id: "a", label: "A" }]} edges={[]} />
|
|
109
|
+
));
|
|
110
|
+
expect(staticGraph.queryByTestId("s-controls")).toBeNull();
|
|
111
|
+
|
|
112
|
+
const canvas = render(() => (
|
|
113
|
+
<FlowGraph testId="c" interactive nodes={[{ id: "a", label: "A" }]} edges={[]} />
|
|
114
|
+
));
|
|
115
|
+
expect(canvas.getByTestId("c-controls")).toBeTruthy();
|
|
116
|
+
expect(canvas.getByTestId("c-reset")).toBeTruthy();
|
|
117
|
+
// interactive svg fills the viewport rather than sizing to content
|
|
118
|
+
expect(canvas.getByTestId("c-svg").getAttribute("width")).toBe("100%");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("animates edges with the flow class only when animated", () => {
|
|
122
|
+
const { container } = render(() => (
|
|
123
|
+
<FlowGraph
|
|
124
|
+
testId="fg"
|
|
125
|
+
animated
|
|
126
|
+
nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
|
|
127
|
+
edges={[{ from: "a", to: "b" }]}
|
|
128
|
+
/>
|
|
129
|
+
));
|
|
130
|
+
expect(container.querySelector(".ksui-fg-edge.flow")).toBeTruthy();
|
|
131
|
+
|
|
132
|
+
const plain = render(() => (
|
|
133
|
+
<FlowGraph
|
|
134
|
+
nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
|
|
135
|
+
edges={[{ from: "a", to: "b" }]}
|
|
136
|
+
/>
|
|
137
|
+
));
|
|
138
|
+
expect(plain.container.querySelector(".ksui-fg-edge.flow")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// FlowGraph (Vision §9 companion to FlowRunner): a renderer for a DECLARATIVE
|
|
2
|
+
// node graph. Where FlowRunner *executes* a server-driven flow, FlowGraph
|
|
3
|
+
// *draws* it — a flow's trigger→form→call→effect chain, a plugin-connection
|
|
4
|
+
// map, any directed graph. With `interactive` it becomes a pan/zoom CANVAS;
|
|
5
|
+
// with `animated` the edges show flow direction as marching dashes.
|
|
6
|
+
//
|
|
7
|
+
// Composite because it composes the pure graph model (utils/graph) with SVG
|
|
8
|
+
// layout + interaction. Domain-free: it knows nothing about plugins or roles;
|
|
9
|
+
// the host supplies typed nodes/edges and optional handlers. Self-contained CSS
|
|
10
|
+
// (ksui-fg-* unscoped classes + CSS custom props); no Tailwind, no host-brand
|
|
11
|
+
// classes (standalone-library rule). No graph/canvas library — pan/zoom is a
|
|
12
|
+
// plain SVG group transform, animation is a CSS keyframe.
|
|
13
|
+
|
|
14
|
+
import type { Component, JSX } from "solid-js";
|
|
15
|
+
import { For, Show, createMemo, createSignal } from "solid-js";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_METRICS,
|
|
18
|
+
layoutGraph,
|
|
19
|
+
type GraphEdge,
|
|
20
|
+
type GraphLayout,
|
|
21
|
+
type GraphNode,
|
|
22
|
+
type PositionedNode,
|
|
23
|
+
} from "../../utils/graph";
|
|
24
|
+
|
|
25
|
+
const STYLE_ID = "ksui-flow-graph-style";
|
|
26
|
+
|
|
27
|
+
function ensureStyle(): void {
|
|
28
|
+
if (typeof document === "undefined") return;
|
|
29
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
30
|
+
const style = document.createElement("style");
|
|
31
|
+
style.id = STYLE_ID;
|
|
32
|
+
style.textContent = `
|
|
33
|
+
.ksui-fg-wrap{width:100%;overflow:auto;position:relative;}
|
|
34
|
+
.ksui-fg-wrap.interactive{overflow:hidden;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.12));border-radius:8px;background:var(--ksui-fg-canvas,rgba(0,0,0,0.18));cursor:grab;touch-action:none;}
|
|
35
|
+
.ksui-fg-wrap.interactive.grabbing{cursor:grabbing;}
|
|
36
|
+
.ksui-fg-svg{display:block;max-width:100%;height:auto;font-family:inherit;}
|
|
37
|
+
.ksui-fg-wrap.interactive .ksui-fg-svg{max-width:none;width:100%;height:100%;}
|
|
38
|
+
.ksui-fg-edge{fill:none;stroke:var(--ksui-fg-edge,rgba(255,255,255,0.22));stroke-width:1.5;}
|
|
39
|
+
.ksui-fg-edge.dashed{stroke-dasharray:4 4;}
|
|
40
|
+
.ksui-fg-edge.primary{stroke:var(--ksui-fg-primary,#c9a961);}
|
|
41
|
+
.ksui-fg-edge.info{stroke:#3b82f6;}
|
|
42
|
+
.ksui-fg-edge.success{stroke:#22c55e;}
|
|
43
|
+
.ksui-fg-edge.danger{stroke:#ef4444;}
|
|
44
|
+
.ksui-fg-edge.muted{stroke:rgba(255,255,255,0.14);}
|
|
45
|
+
.ksui-fg-edge.flow{stroke-dasharray:5 5;animation:ksui-fg-march .7s linear infinite;}
|
|
46
|
+
@keyframes ksui-fg-march{to{stroke-dashoffset:-10;}}
|
|
47
|
+
@media (prefers-reduced-motion:reduce){.ksui-fg-edge.flow{animation:none;}}
|
|
48
|
+
.ksui-fg-elabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.7));font-size:9px;}
|
|
49
|
+
.ksui-fg-elabel-bg{fill:var(--ksui-fg-bg,#18181b);opacity:0.82;}
|
|
50
|
+
.ksui-fg-box{fill:var(--ksui-fg-node-bg,rgba(255,255,255,0.04));stroke:var(--ksui-fg-node-border,rgba(255,255,255,0.16));stroke-width:1;}
|
|
51
|
+
.ksui-fg-node.primary .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);fill:rgba(201,169,97,0.08);}
|
|
52
|
+
.ksui-fg-node.info .ksui-fg-box{stroke:#3b82f6;fill:rgba(59,130,246,0.08);}
|
|
53
|
+
.ksui-fg-node.success .ksui-fg-box{stroke:#22c55e;fill:rgba(34,197,94,0.08);}
|
|
54
|
+
.ksui-fg-node.danger .ksui-fg-box{stroke:#ef4444;fill:rgba(239,68,68,0.08);}
|
|
55
|
+
.ksui-fg-node.muted .ksui-fg-box{stroke:rgba(255,255,255,0.16);fill:rgba(255,255,255,0.02);}
|
|
56
|
+
.ksui-fg-node.clickable{cursor:pointer;}
|
|
57
|
+
.ksui-fg-node.clickable:hover .ksui-fg-box{fill:rgba(255,255,255,0.10);}
|
|
58
|
+
.ksui-fg-node.clickable:focus{outline:none;}
|
|
59
|
+
.ksui-fg-node.clickable:focus-visible .ksui-fg-box{stroke:var(--ksui-fg-primary,#c9a961);stroke-width:2;}
|
|
60
|
+
.ksui-fg-label{fill:var(--ksui-fg-fg,#e4e4e7);font-size:12px;font-weight:600;}
|
|
61
|
+
.ksui-fg-sublabel{fill:var(--ksui-fg-muted,rgba(255,255,255,0.55));font-size:9.5px;text-transform:uppercase;letter-spacing:0.04em;}
|
|
62
|
+
.ksui-fg-empty{padding:1.75rem 1rem;text-align:center;font-size:0.82rem;color:var(--ksui-fg-muted,rgba(255,255,255,0.55));}
|
|
63
|
+
.ksui-fg-controls{position:absolute;right:8px;bottom:8px;display:flex;gap:4px;z-index:1;}
|
|
64
|
+
.ksui-fg-ctrl{width:24px;height:24px;display:grid;place-items:center;border-radius:5px;border:1px solid var(--ksui-fg-node-border,rgba(255,255,255,0.18));background:var(--ksui-fg-bg,#18181b);color:var(--ksui-fg-fg,#e4e4e7);font-size:14px;line-height:1;cursor:pointer;user-select:none;}
|
|
65
|
+
.ksui-fg-ctrl:hover{background:rgba(255,255,255,0.08);}
|
|
66
|
+
.ksui-fg-hint{position:absolute;left:8px;bottom:8px;font-size:9.5px;color:var(--ksui-fg-muted,rgba(255,255,255,0.4));pointer-events:none;}
|
|
67
|
+
`;
|
|
68
|
+
document.head.appendChild(style);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface FlowGraphProps {
|
|
72
|
+
nodes: GraphNode[];
|
|
73
|
+
edges: GraphEdge[];
|
|
74
|
+
/** "layered" (default) flows roots→leaves; "bipartite" splits source/sink. */
|
|
75
|
+
layout?: GraphLayout;
|
|
76
|
+
/** Shown when there are no nodes to draw. */
|
|
77
|
+
emptyLabel?: string;
|
|
78
|
+
/** Accessible description of the whole graph (the svg's aria-label). */
|
|
79
|
+
ariaLabel?: string;
|
|
80
|
+
/** When supplied, nodes become buttons that fire this with the node id. */
|
|
81
|
+
onNodeSelect?: (id: string) => void;
|
|
82
|
+
/** Turn the graph into a pan (drag) + zoom (wheel/buttons) canvas. */
|
|
83
|
+
interactive?: boolean;
|
|
84
|
+
/** Animate edges as marching dashes flowing toward the arrowhead. */
|
|
85
|
+
animated?: boolean;
|
|
86
|
+
/** Canvas viewport height in px when interactive (default 360). */
|
|
87
|
+
height?: number;
|
|
88
|
+
testId?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** SVG has no text overflow; trim to keep labels inside the node box. */
|
|
92
|
+
function clip(text: string, max: number): string {
|
|
93
|
+
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { nodeW, nodeH } = DEFAULT_METRICS;
|
|
97
|
+
const ZOOM_MIN = 0.3;
|
|
98
|
+
const ZOOM_MAX = 3;
|
|
99
|
+
|
|
100
|
+
export const FlowGraph: Component<FlowGraphProps> = (props) => {
|
|
101
|
+
ensureStyle();
|
|
102
|
+
const tid = (s: string) => (props.testId ? `${props.testId}-${s}` : undefined);
|
|
103
|
+
|
|
104
|
+
const laid = createMemo(() => layoutGraph(props.nodes, props.edges, props.layout ?? "layered"));
|
|
105
|
+
|
|
106
|
+
// Pan/zoom view transform (interactive mode only).
|
|
107
|
+
const [view, setView] = createSignal({ x: 0, y: 0, k: 1 });
|
|
108
|
+
const [grabbing, setGrabbing] = createSignal(false);
|
|
109
|
+
let svgRef: SVGSVGElement | undefined;
|
|
110
|
+
let dragging = false;
|
|
111
|
+
let lastX = 0;
|
|
112
|
+
let lastY = 0;
|
|
113
|
+
let moved = false; // distinguishes a pan from a node click
|
|
114
|
+
|
|
115
|
+
const clampK = (k: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k));
|
|
116
|
+
|
|
117
|
+
const zoomBy = (factor: number, cx?: number, cy?: number) => {
|
|
118
|
+
const v = view();
|
|
119
|
+
const k = clampK(v.k * factor);
|
|
120
|
+
// Keep the point (cx,cy) under the cursor fixed while zooming.
|
|
121
|
+
const px = cx ?? (svgRef?.clientWidth ?? 0) / 2;
|
|
122
|
+
const py = cy ?? (svgRef?.clientHeight ?? 0) / 2;
|
|
123
|
+
setView({ x: px - (px - v.x) * (k / v.k), y: py - (py - v.y) * (k / v.k), k });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onWheel = (e: WheelEvent) => {
|
|
127
|
+
if (!props.interactive) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const rect = svgRef?.getBoundingClientRect();
|
|
130
|
+
zoomBy(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - (rect?.left ?? 0), e.clientY - (rect?.top ?? 0));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
134
|
+
if (!props.interactive) return;
|
|
135
|
+
dragging = true;
|
|
136
|
+
moved = false;
|
|
137
|
+
setGrabbing(true);
|
|
138
|
+
lastX = e.clientX;
|
|
139
|
+
lastY = e.clientY;
|
|
140
|
+
svgRef?.setPointerCapture(e.pointerId);
|
|
141
|
+
};
|
|
142
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
143
|
+
if (!dragging) return;
|
|
144
|
+
const dx = e.clientX - lastX;
|
|
145
|
+
const dy = e.clientY - lastY;
|
|
146
|
+
if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
|
|
147
|
+
lastX = e.clientX;
|
|
148
|
+
lastY = e.clientY;
|
|
149
|
+
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
|
150
|
+
};
|
|
151
|
+
const endDrag = (e: PointerEvent) => {
|
|
152
|
+
dragging = false;
|
|
153
|
+
setGrabbing(false);
|
|
154
|
+
try {
|
|
155
|
+
svgRef?.releasePointerCapture(e.pointerId);
|
|
156
|
+
} catch {
|
|
157
|
+
/* pointer already released */
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// A cubic bezier from a source node's right edge to a target's left edge.
|
|
162
|
+
const edgePath = (s: PositionedNode, t: PositionedNode): string => {
|
|
163
|
+
const x1 = s.x + nodeW;
|
|
164
|
+
const y1 = s.y + nodeH / 2;
|
|
165
|
+
const x2 = t.x;
|
|
166
|
+
const y2 = t.y + nodeH / 2;
|
|
167
|
+
const dx = Math.max(36, (x2 - x1) / 2);
|
|
168
|
+
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const selectNode = (id: string) => {
|
|
172
|
+
if (moved) return; // a pan ended over the node — don't treat it as a click
|
|
173
|
+
props.onNodeSelect?.(id);
|
|
174
|
+
};
|
|
175
|
+
const activate = (e: KeyboardEvent, id: string) => {
|
|
176
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
props.onNodeSelect?.(id);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const groupTransform = () =>
|
|
183
|
+
props.interactive ? `translate(${view().x} ${view().y}) scale(${view().k})` : undefined;
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
class="ksui-fg-wrap"
|
|
188
|
+
classList={{ interactive: !!props.interactive, grabbing: grabbing() }}
|
|
189
|
+
style={props.interactive ? { height: `${props.height ?? 360}px` } : undefined}
|
|
190
|
+
data-testid={tid("root")}
|
|
191
|
+
>
|
|
192
|
+
<Show
|
|
193
|
+
when={laid().nodes.length > 0}
|
|
194
|
+
fallback={
|
|
195
|
+
<p class="ksui-fg-empty" data-testid={tid("empty")}>
|
|
196
|
+
{props.emptyLabel ?? "Nothing to show yet."}
|
|
197
|
+
</p>
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
<svg
|
|
201
|
+
ref={svgRef}
|
|
202
|
+
class="ksui-fg-svg"
|
|
203
|
+
viewBox={props.interactive ? undefined : `0 0 ${laid().width} ${laid().height}`}
|
|
204
|
+
width={props.interactive ? "100%" : laid().width}
|
|
205
|
+
height={props.interactive ? "100%" : laid().height}
|
|
206
|
+
role="img"
|
|
207
|
+
aria-label={props.ariaLabel ?? "Relationship graph"}
|
|
208
|
+
data-testid={tid("svg")}
|
|
209
|
+
onWheel={onWheel}
|
|
210
|
+
onPointerDown={onPointerDown}
|
|
211
|
+
onPointerMove={onPointerMove}
|
|
212
|
+
onPointerUp={endDrag}
|
|
213
|
+
onPointerCancel={endDrag}
|
|
214
|
+
>
|
|
215
|
+
<defs>
|
|
216
|
+
<marker
|
|
217
|
+
id="ksui-fg-arrow"
|
|
218
|
+
viewBox="0 0 8 8"
|
|
219
|
+
refX="7"
|
|
220
|
+
refY="4"
|
|
221
|
+
markerWidth="6"
|
|
222
|
+
markerHeight="6"
|
|
223
|
+
orient="auto-start-reverse"
|
|
224
|
+
>
|
|
225
|
+
<path d="M0 0 L8 4 L0 8 z" fill="var(--ksui-fg-edge,rgba(255,255,255,0.35))" />
|
|
226
|
+
</marker>
|
|
227
|
+
</defs>
|
|
228
|
+
|
|
229
|
+
<g transform={groupTransform()}>
|
|
230
|
+
{/* Edges first so nodes paint on top of the connectors. */}
|
|
231
|
+
<For each={props.edges}>
|
|
232
|
+
{(e) => {
|
|
233
|
+
const s = () => laid().byId.get(e.from);
|
|
234
|
+
const t = () => laid().byId.get(e.to);
|
|
235
|
+
return (
|
|
236
|
+
<Show when={s() && t()}>
|
|
237
|
+
{(() => {
|
|
238
|
+
const src = s() as PositionedNode;
|
|
239
|
+
const dst = t() as PositionedNode;
|
|
240
|
+
const mx = (src.x + nodeW + dst.x) / 2;
|
|
241
|
+
const my = (src.y + dst.y) / 2 + nodeH / 2;
|
|
242
|
+
return (
|
|
243
|
+
<g>
|
|
244
|
+
<path
|
|
245
|
+
class={`ksui-fg-edge ${e.accent ?? ""} ${e.dashed ? "dashed" : ""} ${
|
|
246
|
+
props.animated ? "flow" : ""
|
|
247
|
+
}`}
|
|
248
|
+
d={edgePath(src, dst)}
|
|
249
|
+
marker-end="url(#ksui-fg-arrow)"
|
|
250
|
+
/>
|
|
251
|
+
<Show when={e.label}>
|
|
252
|
+
<rect
|
|
253
|
+
class="ksui-fg-elabel-bg"
|
|
254
|
+
x={mx - clip(e.label!, 18).length * 2.6 - 3}
|
|
255
|
+
y={my - 7}
|
|
256
|
+
width={clip(e.label!, 18).length * 5.2 + 6}
|
|
257
|
+
height={12}
|
|
258
|
+
rx={2}
|
|
259
|
+
/>
|
|
260
|
+
<text class="ksui-fg-elabel" x={mx} y={my + 2} text-anchor="middle">
|
|
261
|
+
{clip(e.label!, 18)}
|
|
262
|
+
</text>
|
|
263
|
+
</Show>
|
|
264
|
+
</g>
|
|
265
|
+
);
|
|
266
|
+
})()}
|
|
267
|
+
</Show>
|
|
268
|
+
);
|
|
269
|
+
}}
|
|
270
|
+
</For>
|
|
271
|
+
|
|
272
|
+
{/* Nodes */}
|
|
273
|
+
<For each={laid().nodes}>
|
|
274
|
+
{(n) => {
|
|
275
|
+
const interactiveNode = () => typeof props.onNodeSelect === "function";
|
|
276
|
+
return (
|
|
277
|
+
<g
|
|
278
|
+
class={`ksui-fg-node ${n.accent ?? ""} ${interactiveNode() ? "clickable" : ""}`}
|
|
279
|
+
transform={`translate(${n.x} ${n.y})`}
|
|
280
|
+
data-testid={tid(`node-${n.id}`)}
|
|
281
|
+
role={interactiveNode() ? "button" : undefined}
|
|
282
|
+
tabindex={interactiveNode() ? 0 : undefined}
|
|
283
|
+
aria-label={n.sublabel ? `${n.label} — ${n.sublabel}` : n.label}
|
|
284
|
+
onClick={interactiveNode() ? () => selectNode(n.id) : undefined}
|
|
285
|
+
onKeyDown={interactiveNode() ? (ev) => activate(ev, n.id) : undefined}
|
|
286
|
+
>
|
|
287
|
+
<rect class="ksui-fg-box" width={nodeW} height={nodeH} rx={8} />
|
|
288
|
+
<text class="ksui-fg-label" x={12} y={n.sublabel ? 20 : nodeH / 2 + 4}>
|
|
289
|
+
{clip(n.label, 24)}
|
|
290
|
+
</text>
|
|
291
|
+
<Show when={n.sublabel}>
|
|
292
|
+
<text class="ksui-fg-sublabel" x={12} y={34}>
|
|
293
|
+
{clip(n.sublabel!, 28)}
|
|
294
|
+
</text>
|
|
295
|
+
</Show>
|
|
296
|
+
</g>
|
|
297
|
+
);
|
|
298
|
+
}}
|
|
299
|
+
</For>
|
|
300
|
+
</g>
|
|
301
|
+
</svg>
|
|
302
|
+
|
|
303
|
+
<Show when={props.interactive}>
|
|
304
|
+
<div class="ksui-fg-controls" data-testid={tid("controls")}>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
class="ksui-fg-ctrl"
|
|
308
|
+
aria-label="Zoom in"
|
|
309
|
+
onClick={() => zoomBy(1.2)}
|
|
310
|
+
>
|
|
311
|
+
+
|
|
312
|
+
</button>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
class="ksui-fg-ctrl"
|
|
316
|
+
aria-label="Zoom out"
|
|
317
|
+
onClick={() => zoomBy(1 / 1.2)}
|
|
318
|
+
>
|
|
319
|
+
−
|
|
320
|
+
</button>
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
class="ksui-fg-ctrl"
|
|
324
|
+
aria-label="Reset view"
|
|
325
|
+
data-testid={tid("reset")}
|
|
326
|
+
onClick={() => setView({ x: 0, y: 0, k: 1 })}
|
|
327
|
+
>
|
|
328
|
+
⤢
|
|
329
|
+
</button>
|
|
330
|
+
</div>
|
|
331
|
+
<span class="ksui-fg-hint">drag to pan · scroll to zoom</span>
|
|
332
|
+
</Show>
|
|
333
|
+
</Show>
|
|
334
|
+
</div>
|
|
335
|
+
) as JSX.Element;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export default FlowGraph;
|
package/src/index.ts
CHANGED
|
@@ -173,6 +173,11 @@ export { default as FlowRunner, type FlowRunnerProps } from "./components/compos
|
|
|
173
173
|
// renders it with validated props, falling back safely on miss/mismatch.
|
|
174
174
|
export { default as CustomRenderer, type CustomRendererProps } from "./components/composite/CustomRenderer";
|
|
175
175
|
|
|
176
|
+
// FlowGraph — read-only renderer for a declarative directed graph (the static
|
|
177
|
+
// companion to FlowRunner). Domain-free: host supplies typed nodes/edges; used
|
|
178
|
+
// for plugin-connection and role→permission visualizations.
|
|
179
|
+
export { default as FlowGraph, type FlowGraphProps } from "./components/composite/FlowGraph";
|
|
180
|
+
|
|
176
181
|
// ---------------------------------------------------------------------------
|
|
177
182
|
// Utils (not components)
|
|
178
183
|
// ---------------------------------------------------------------------------
|
|
@@ -234,6 +239,20 @@ export type {
|
|
|
234
239
|
FlowInput,
|
|
235
240
|
} from "./utils/flow";
|
|
236
241
|
|
|
242
|
+
// FlowGraph model (pure): the node/edge types + the dependency-free layout the
|
|
243
|
+
// renderer uses. Exported so hosts can type their graph data and, if needed,
|
|
244
|
+
// pre-compute layout off the DOM.
|
|
245
|
+
export { layoutGraph, DEFAULT_METRICS } from "./utils/graph";
|
|
246
|
+
export type {
|
|
247
|
+
GraphNode,
|
|
248
|
+
GraphEdge,
|
|
249
|
+
GraphLayout,
|
|
250
|
+
GraphAccent,
|
|
251
|
+
GraphMetrics,
|
|
252
|
+
PositionedNode,
|
|
253
|
+
GraphLayoutResult,
|
|
254
|
+
} from "./utils/graph";
|
|
255
|
+
|
|
237
256
|
// U8 — in-process, build-time custom renderer registry (no eval/remote code) and
|
|
238
257
|
// its consumes-schema validator. Hosts register renderers at startup.
|
|
239
258
|
export {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Domain-free directed-graph model + a deterministic, dependency-free layout.
|
|
2
|
+
// Powers the FlowGraph renderer. No DOM, no solid — pure functions so the
|
|
3
|
+
// layout is unit-testable in isolation. ksui ships no graph library (solid +
|
|
4
|
+
// lucide only), so layering is a small longest-path pass, not dagre/d3.
|
|
5
|
+
|
|
6
|
+
export type GraphAccent = "primary" | "info" | "success" | "danger" | "muted";
|
|
7
|
+
|
|
8
|
+
export interface GraphNode {
|
|
9
|
+
/** Stable unique id; edges reference nodes by this. */
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/** Secondary line under the label (e.g. a tier, a category, a count). */
|
|
13
|
+
sublabel?: string;
|
|
14
|
+
accent?: GraphAccent;
|
|
15
|
+
/**
|
|
16
|
+
* Explicit column index. Overrides automatic layering when set — required for
|
|
17
|
+
* a clean bipartite split when a sink node has no edges (e.g. an ungranted
|
|
18
|
+
* permission still belongs in the right column).
|
|
19
|
+
*/
|
|
20
|
+
lane?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GraphEdge {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Render the connector dashed — e.g. "requires" vs a solid "provides", or a
|
|
29
|
+
* denied grant vs an allowed one.
|
|
30
|
+
*/
|
|
31
|
+
dashed?: boolean;
|
|
32
|
+
accent?: GraphAccent;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type GraphLayout = "layered" | "bipartite";
|
|
36
|
+
|
|
37
|
+
export interface PositionedNode extends GraphNode {
|
|
38
|
+
lane: number;
|
|
39
|
+
row: number;
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GraphLayoutResult {
|
|
45
|
+
nodes: PositionedNode[];
|
|
46
|
+
/** id → positioned node, for edge endpoint lookup. */
|
|
47
|
+
byId: Map<string, PositionedNode>;
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface GraphMetrics {
|
|
53
|
+
nodeW: number;
|
|
54
|
+
nodeH: number;
|
|
55
|
+
gapX: number;
|
|
56
|
+
gapY: number;
|
|
57
|
+
pad: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_METRICS: GraphMetrics = {
|
|
61
|
+
nodeW: 168,
|
|
62
|
+
nodeH: 48,
|
|
63
|
+
gapX: 72,
|
|
64
|
+
gapY: 18,
|
|
65
|
+
pad: 16,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Count incoming edges per node id, ignoring edges that dangle off the set. */
|
|
69
|
+
function incomingDegree(ids: Set<string>, edges: GraphEdge[]): Map<string, number> {
|
|
70
|
+
const incoming = new Map<string, number>();
|
|
71
|
+
for (const id of ids) incoming.set(id, 0);
|
|
72
|
+
for (const e of edges) {
|
|
73
|
+
if (ids.has(e.from) && ids.has(e.to)) incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
return incoming;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Assign each node a lane (column index). Explicit `node.lane` always wins. */
|
|
79
|
+
function assignLanes(
|
|
80
|
+
nodes: GraphNode[],
|
|
81
|
+
edges: GraphEdge[],
|
|
82
|
+
layout: GraphLayout,
|
|
83
|
+
): Map<string, number> {
|
|
84
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
85
|
+
const pinned = new Set(nodes.filter((n) => typeof n.lane === "number").map((n) => n.id));
|
|
86
|
+
const lane = new Map<string, number>();
|
|
87
|
+
for (const n of nodes) if (typeof n.lane === "number") lane.set(n.id, n.lane);
|
|
88
|
+
|
|
89
|
+
const incoming = incomingDegree(ids, edges);
|
|
90
|
+
|
|
91
|
+
if (layout === "bipartite") {
|
|
92
|
+
// Sources (nothing points at them) on the left, sinks on the right.
|
|
93
|
+
for (const n of nodes) {
|
|
94
|
+
if (lane.has(n.id)) continue;
|
|
95
|
+
lane.set(n.id, (incoming.get(n.id) ?? 0) > 0 ? 1 : 0);
|
|
96
|
+
}
|
|
97
|
+
return lane;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Layered: longest-path layering. Roots (no incoming) start at 0; relax each
|
|
101
|
+
// edge so a target sits at least one lane past its source. Cap the passes at
|
|
102
|
+
// node-count so a cycle can't spin forever.
|
|
103
|
+
for (const n of nodes) if (!lane.has(n.id)) lane.set(n.id, 0);
|
|
104
|
+
const live = edges.filter((e) => ids.has(e.from) && ids.has(e.to));
|
|
105
|
+
for (let pass = 0; pass < nodes.length; pass++) {
|
|
106
|
+
let changed = false;
|
|
107
|
+
for (const e of live) {
|
|
108
|
+
if (pinned.has(e.to)) continue; // don't move a caller-pinned node
|
|
109
|
+
const want = (lane.get(e.from) ?? 0) + 1;
|
|
110
|
+
if (want > (lane.get(e.to) ?? 0)) {
|
|
111
|
+
lane.set(e.to, want);
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!changed) break;
|
|
116
|
+
}
|
|
117
|
+
return lane;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compute node positions for the graph. Lanes flow left→right; within a lane,
|
|
122
|
+
* nodes stack top-down in input order (stable, so the render is deterministic).
|
|
123
|
+
*/
|
|
124
|
+
export function layoutGraph(
|
|
125
|
+
nodes: GraphNode[],
|
|
126
|
+
edges: GraphEdge[],
|
|
127
|
+
layout: GraphLayout = "layered",
|
|
128
|
+
metrics: GraphMetrics = DEFAULT_METRICS,
|
|
129
|
+
): GraphLayoutResult {
|
|
130
|
+
const { nodeW, nodeH, gapX, gapY, pad } = metrics;
|
|
131
|
+
const lane = assignLanes(nodes, edges, layout);
|
|
132
|
+
|
|
133
|
+
const nextRow = new Map<number, number>(); // lane → next free row
|
|
134
|
+
const positioned: PositionedNode[] = nodes.map((n) => {
|
|
135
|
+
const l = lane.get(n.id) ?? 0;
|
|
136
|
+
const r = nextRow.get(l) ?? 0;
|
|
137
|
+
nextRow.set(l, r + 1);
|
|
138
|
+
return {
|
|
139
|
+
...n,
|
|
140
|
+
lane: l,
|
|
141
|
+
row: r,
|
|
142
|
+
x: pad + l * (nodeW + gapX),
|
|
143
|
+
y: pad + r * (nodeH + gapY),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const lanes = positioned.reduce((m, n) => Math.max(m, n.lane + 1), 0);
|
|
148
|
+
const maxRows = [...nextRow.values()].reduce((m, v) => Math.max(m, v), 0);
|
|
149
|
+
const width = lanes > 0 ? pad * 2 + lanes * nodeW + (lanes - 1) * gapX : pad * 2;
|
|
150
|
+
const height = maxRows > 0 ? pad * 2 + maxRows * nodeH + (maxRows - 1) * gapY : pad * 2;
|
|
151
|
+
|
|
152
|
+
const byId = new Map(positioned.map((n) => [n.id, n]));
|
|
153
|
+
return { nodes: positioned, byId, width, height };
|
|
154
|
+
}
|