@zvk/graphs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE.md +1 -0
- package/README.md +86 -0
- package/dist/algorithms.d.ts +27 -0
- package/dist/algorithms.js +232 -0
- package/dist/diagnostics.d.ts +19 -0
- package/dist/diagnostics.js +109 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/layout/dag.d.ts +10 -0
- package/dist/layout/dag.js +69 -0
- package/dist/layout/index.d.ts +4 -0
- package/dist/layout/index.js +4 -0
- package/dist/layout/manual.d.ts +7 -0
- package/dist/layout/manual.js +39 -0
- package/dist/layout/tree.d.ts +10 -0
- package/dist/layout/tree.js +68 -0
- package/dist/layout/types.d.ts +21 -0
- package/dist/layout/types.js +19 -0
- package/dist/model.d.ts +41 -0
- package/dist/model.js +3 -0
- package/dist/styles.css +116 -0
- package/dist/svg.d.ts +32 -0
- package/dist/svg.js +54 -0
- package/package.json +84 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @zvk/graphs Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial package scaffold for accessible SVG-first graph model, diagnostics,
|
|
6
|
+
algorithms, deterministic layout, static SVG rendering, and graph stylesheet
|
|
7
|
+
contracts.
|
|
8
|
+
- Documented zero-runtime-dependency, SSR-safe, accessibility-first package
|
|
9
|
+
boundaries and repo-local `use-zvk-graphs` maintenance guidance.
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
See the repository license for usage terms.
|
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @zvk/graphs
|
|
2
|
+
|
|
3
|
+
Accessible SVG-first node and edge graph primitives for ZVK applications.
|
|
4
|
+
|
|
5
|
+
`@zvk/graphs` is for relationship visualizations: dependency graphs, workflows,
|
|
6
|
+
state machines, lineage views, and topology maps. It is not a charting package
|
|
7
|
+
and should stay deterministic, SSR-safe, and zero-runtime-dependency.
|
|
8
|
+
|
|
9
|
+
## Public Imports
|
|
10
|
+
|
|
11
|
+
Use the package root and documented subpaths only:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import "@zvk/graphs/styles.css";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Public subpaths are `@zvk/graphs`, `@zvk/graphs/model`,
|
|
18
|
+
`@zvk/graphs/diagnostics`, `@zvk/graphs/algorithms`, `@zvk/graphs/layout`,
|
|
19
|
+
`@zvk/graphs/svg`, `@zvk/graphs/styles.css`, and
|
|
20
|
+
`@zvk/graphs/package.json`.
|
|
21
|
+
|
|
22
|
+
Do not import from `src`, `dist`, package internals, or private relative paths.
|
|
23
|
+
Treat `packages/graphs/package.json` `exports` as the public API contract.
|
|
24
|
+
|
|
25
|
+
## Styles
|
|
26
|
+
|
|
27
|
+
Import graph styles once from the public stylesheet:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import "@zvk/graphs/styles.css";
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When an app also uses `@zvk/ui`, import UI styles first so graph CSS variables
|
|
34
|
+
can inherit UI tokens:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import "@zvk/ui/styles.css";
|
|
38
|
+
import "@zvk/graphs/styles.css";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The graph package should own `--zvk-graphs-*` variables, `.zvk-graphs*` classes,
|
|
42
|
+
and graph-specific `data-*` attributes. It may fall back to `--zvk-ui-*` tokens
|
|
43
|
+
when present, but package source must not import `@zvk/ui` JavaScript or
|
|
44
|
+
component internals for styling.
|
|
45
|
+
|
|
46
|
+
## Accessibility And SSR
|
|
47
|
+
|
|
48
|
+
- Require a graph title, node labels, and meaningful edge labels or derivable
|
|
49
|
+
edge descriptions.
|
|
50
|
+
- Render SVG as a complex image by default: stable title/description IDs,
|
|
51
|
+
`aria-labelledby`/`aria-describedby`, and structured fallback content.
|
|
52
|
+
- Do not rely on color alone for status, selection, errors, or direction.
|
|
53
|
+
- Keep focus, selection, and keyboard behavior opt-in and fully documented.
|
|
54
|
+
- Keep root imports and static SVG rendering SSR-safe. Do not read `window`,
|
|
55
|
+
`document`, `ResizeObserver`, SVG measurements, or browser layout at module
|
|
56
|
+
initialization.
|
|
57
|
+
- Use deterministic IDs and layout output. Do not use random IDs, timestamps, or
|
|
58
|
+
DOM measurement for default layout.
|
|
59
|
+
|
|
60
|
+
## Package Boundaries
|
|
61
|
+
|
|
62
|
+
`@zvk/graphs` owns:
|
|
63
|
+
|
|
64
|
+
- serializable graph model types;
|
|
65
|
+
- graph diagnostics and validation reports;
|
|
66
|
+
- pure graph algorithms such as adjacency, degree, cycle, topological,
|
|
67
|
+
reachability, component, and tree helpers;
|
|
68
|
+
- deterministic manual, tree, and narrow DAG layout utilities;
|
|
69
|
+
- static React/SVG graph rendering;
|
|
70
|
+
- graph CSS and token inheritance contracts.
|
|
71
|
+
|
|
72
|
+
Applications own product data, filtering, search, detail panels, routing to
|
|
73
|
+
domain entities, and any editor or viewport state. Use `@zvk/charts` for axes,
|
|
74
|
+
scales, numeric series, legends, bars, lines, areas, scatterplots, sparklines,
|
|
75
|
+
pie/donut charts, and other quantitative visualization.
|
|
76
|
+
|
|
77
|
+
## Anti-Goals
|
|
78
|
+
|
|
79
|
+
- No runtime dependencies.
|
|
80
|
+
- No D3, Dagre, ELK, Cytoscape, React Flow, Mermaid, Graphviz, Pixi, canvas,
|
|
81
|
+
WebGL, CSS-in-JS, Tailwind-only assumptions, or runtime class helpers.
|
|
82
|
+
- No graph editor, drag editing, pan/zoom, minimap, force simulation,
|
|
83
|
+
orthogonal routing, edge bundling, compound-node layout, or `foreignObject`
|
|
84
|
+
node rendering in the core package.
|
|
85
|
+
- No unlabeled graph nodes, inaccessible edge relationships, color-only states,
|
|
86
|
+
or browser-only behavior leaking into SSR-safe imports.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GraphDiagnostic } from "./diagnostics.js";
|
|
2
|
+
import type { GraphEdge, GraphId, GraphModel, GraphNode } from "./model.js";
|
|
3
|
+
export interface GraphIndex {
|
|
4
|
+
readonly nodeById: ReadonlyMap<GraphId, GraphNode>;
|
|
5
|
+
readonly edgeById: ReadonlyMap<GraphId, GraphEdge>;
|
|
6
|
+
readonly outgoingByNodeId: ReadonlyMap<GraphId, readonly GraphEdge[]>;
|
|
7
|
+
readonly incomingByNodeId: ReadonlyMap<GraphId, readonly GraphEdge[]>;
|
|
8
|
+
}
|
|
9
|
+
export interface GraphDegree {
|
|
10
|
+
readonly in: number;
|
|
11
|
+
readonly out: number;
|
|
12
|
+
readonly total: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function createGraphIndex(graph: GraphModel): GraphIndex;
|
|
15
|
+
export declare function getGraphDegrees(graph: GraphModel): Record<string, GraphDegree>;
|
|
16
|
+
export declare function detectGraphCycle(graph: GraphModel): readonly GraphId[] | null;
|
|
17
|
+
export interface TopologicalSortResult {
|
|
18
|
+
readonly nodeIds: readonly GraphId[];
|
|
19
|
+
readonly diagnostics: readonly GraphDiagnostic[];
|
|
20
|
+
}
|
|
21
|
+
export declare function topologicalSortGraph(graph: GraphModel): TopologicalSortResult;
|
|
22
|
+
export declare function getReachableNodeIds(graph: GraphModel, startId: GraphId): readonly GraphId[];
|
|
23
|
+
export declare function getWeaklyConnectedComponents(graph: GraphModel): readonly (readonly GraphId[])[];
|
|
24
|
+
export interface ValidateTreeGraphOptions {
|
|
25
|
+
readonly rootId?: GraphId;
|
|
26
|
+
}
|
|
27
|
+
export declare function validateTreeGraph(graph: GraphModel, options?: ValidateTreeGraphOptions): GraphDiagnostic[];
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
export function createGraphIndex(graph) {
|
|
2
|
+
const nodeById = new Map();
|
|
3
|
+
const edgeById = new Map();
|
|
4
|
+
const outgoingByNodeId = new Map();
|
|
5
|
+
const incomingByNodeId = new Map();
|
|
6
|
+
for (const node of graph.nodes) {
|
|
7
|
+
if (!nodeById.has(node.id)) {
|
|
8
|
+
nodeById.set(node.id, node);
|
|
9
|
+
outgoingByNodeId.set(node.id, []);
|
|
10
|
+
incomingByNodeId.set(node.id, []);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
for (const edge of graph.edges) {
|
|
14
|
+
if (!edgeById.has(edge.id)) {
|
|
15
|
+
edgeById.set(edge.id, edge);
|
|
16
|
+
}
|
|
17
|
+
outgoingByNodeId.get(edge.source)?.push(edge);
|
|
18
|
+
incomingByNodeId.get(edge.target)?.push(edge);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
nodeById,
|
|
22
|
+
edgeById,
|
|
23
|
+
outgoingByNodeId,
|
|
24
|
+
incomingByNodeId
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function getGraphDegrees(graph) {
|
|
28
|
+
const index = createGraphIndex(graph);
|
|
29
|
+
const degrees = {};
|
|
30
|
+
for (const node of graph.nodes) {
|
|
31
|
+
const incoming = index.incomingByNodeId.get(node.id)?.length ?? 0;
|
|
32
|
+
const outgoing = index.outgoingByNodeId.get(node.id)?.length ?? 0;
|
|
33
|
+
degrees[node.id] = {
|
|
34
|
+
in: incoming,
|
|
35
|
+
out: outgoing,
|
|
36
|
+
total: incoming + outgoing
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return degrees;
|
|
40
|
+
}
|
|
41
|
+
export function detectGraphCycle(graph) {
|
|
42
|
+
const index = createGraphIndex(graph);
|
|
43
|
+
const visiting = new Set();
|
|
44
|
+
const visited = new Set();
|
|
45
|
+
const stack = [];
|
|
46
|
+
function visit(nodeId) {
|
|
47
|
+
if (visiting.has(nodeId)) {
|
|
48
|
+
const startIndex = stack.indexOf(nodeId);
|
|
49
|
+
return [...stack.slice(startIndex), nodeId];
|
|
50
|
+
}
|
|
51
|
+
if (visited.has(nodeId)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
visiting.add(nodeId);
|
|
55
|
+
stack.push(nodeId);
|
|
56
|
+
for (const edge of index.outgoingByNodeId.get(nodeId) ?? []) {
|
|
57
|
+
if (!index.nodeById.has(edge.target)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const cycle = visit(edge.target);
|
|
61
|
+
if (cycle) {
|
|
62
|
+
return cycle;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
stack.pop();
|
|
66
|
+
visiting.delete(nodeId);
|
|
67
|
+
visited.add(nodeId);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
for (const node of graph.nodes) {
|
|
71
|
+
const cycle = visit(node.id);
|
|
72
|
+
if (cycle) {
|
|
73
|
+
return cycle;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export function topologicalSortGraph(graph) {
|
|
79
|
+
const index = createGraphIndex(graph);
|
|
80
|
+
const indegrees = new Map();
|
|
81
|
+
for (const node of graph.nodes) {
|
|
82
|
+
indegrees.set(node.id, 0);
|
|
83
|
+
}
|
|
84
|
+
for (const edge of graph.edges) {
|
|
85
|
+
if (!index.nodeById.has(edge.source) || !index.nodeById.has(edge.target)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
indegrees.set(edge.target, (indegrees.get(edge.target) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
const queue = graph.nodes.filter((node) => indegrees.get(node.id) === 0).map((node) => node.id);
|
|
91
|
+
const ordered = [];
|
|
92
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
93
|
+
const nodeId = queue[cursor];
|
|
94
|
+
if (!nodeId) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
ordered.push(nodeId);
|
|
98
|
+
for (const edge of index.outgoingByNodeId.get(nodeId) ?? []) {
|
|
99
|
+
if (!indegrees.has(edge.target)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const nextDegree = (indegrees.get(edge.target) ?? 0) - 1;
|
|
103
|
+
indegrees.set(edge.target, nextDegree);
|
|
104
|
+
if (nextDegree === 0) {
|
|
105
|
+
queue.push(edge.target);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (ordered.length !== graph.nodes.length) {
|
|
110
|
+
const cycle = detectGraphCycle(graph);
|
|
111
|
+
return {
|
|
112
|
+
nodeIds: ordered,
|
|
113
|
+
diagnostics: [
|
|
114
|
+
{
|
|
115
|
+
code: "cycle-detected",
|
|
116
|
+
severity: "error",
|
|
117
|
+
message: cycle ? `Graph contains a cycle: ${cycle.join(" -> ")}.` : "Graph contains a cycle."
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { nodeIds: ordered, diagnostics: [] };
|
|
123
|
+
}
|
|
124
|
+
export function getReachableNodeIds(graph, startId) {
|
|
125
|
+
const index = createGraphIndex(graph);
|
|
126
|
+
const visited = new Set([startId]);
|
|
127
|
+
const reachable = [];
|
|
128
|
+
const queue = [startId];
|
|
129
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
130
|
+
const nodeId = queue[cursor];
|
|
131
|
+
if (!nodeId) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const edge of index.outgoingByNodeId.get(nodeId) ?? []) {
|
|
135
|
+
if (visited.has(edge.target) || !index.nodeById.has(edge.target)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
visited.add(edge.target);
|
|
139
|
+
reachable.push(edge.target);
|
|
140
|
+
queue.push(edge.target);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return reachable;
|
|
144
|
+
}
|
|
145
|
+
export function getWeaklyConnectedComponents(graph) {
|
|
146
|
+
const index = createGraphIndex(graph);
|
|
147
|
+
const visited = new Set();
|
|
148
|
+
const components = [];
|
|
149
|
+
for (const node of graph.nodes) {
|
|
150
|
+
if (visited.has(node.id)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const component = [];
|
|
154
|
+
const queue = [node.id];
|
|
155
|
+
visited.add(node.id);
|
|
156
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
157
|
+
const nodeId = queue[cursor];
|
|
158
|
+
if (!nodeId) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
component.push(nodeId);
|
|
162
|
+
const adjacent = [
|
|
163
|
+
...(index.outgoingByNodeId.get(nodeId) ?? []).map((edge) => edge.target),
|
|
164
|
+
...(index.incomingByNodeId.get(nodeId) ?? []).map((edge) => edge.source)
|
|
165
|
+
];
|
|
166
|
+
for (const adjacentId of adjacent) {
|
|
167
|
+
if (visited.has(adjacentId) || !index.nodeById.has(adjacentId)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
visited.add(adjacentId);
|
|
171
|
+
queue.push(adjacentId);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
components.push(component);
|
|
175
|
+
}
|
|
176
|
+
return components;
|
|
177
|
+
}
|
|
178
|
+
export function validateTreeGraph(graph, options = {}) {
|
|
179
|
+
const index = createGraphIndex(graph);
|
|
180
|
+
const diagnostics = [];
|
|
181
|
+
const parentCount = new Map();
|
|
182
|
+
for (const node of graph.nodes) {
|
|
183
|
+
parentCount.set(node.id, 0);
|
|
184
|
+
}
|
|
185
|
+
for (const edge of graph.edges) {
|
|
186
|
+
if (parentCount.has(edge.target)) {
|
|
187
|
+
parentCount.set(edge.target, (parentCount.get(edge.target) ?? 0) + 1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const roots = graph.nodes.filter((node) => parentCount.get(node.id) === 0).map((node) => node.id);
|
|
191
|
+
const expectedRoots = options.rootId ? [options.rootId] : roots;
|
|
192
|
+
if (roots.length !== 1 || (options.rootId && roots[0] !== options.rootId)) {
|
|
193
|
+
const rootNodeId = expectedRoots[0];
|
|
194
|
+
diagnostics.push({
|
|
195
|
+
code: "tree-multiple-roots",
|
|
196
|
+
severity: "error",
|
|
197
|
+
message: `Tree graphs must have exactly one root; found ${roots.length}.`,
|
|
198
|
+
...(rootNodeId ? { nodeId: rootNodeId } : {})
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
for (const [nodeId, count] of parentCount) {
|
|
202
|
+
if (count > 1) {
|
|
203
|
+
diagnostics.push({
|
|
204
|
+
code: "tree-multiple-parents",
|
|
205
|
+
severity: "error",
|
|
206
|
+
message: `Node "${nodeId}" has ${count} parents.`,
|
|
207
|
+
nodeId
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const cycle = detectGraphCycle(graph);
|
|
212
|
+
if (cycle) {
|
|
213
|
+
diagnostics.push({
|
|
214
|
+
code: "cycle-detected",
|
|
215
|
+
severity: "error",
|
|
216
|
+
message: `Tree graph contains a cycle: ${cycle.join(" -> ")}.`
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (roots.length === 1 && !cycle) {
|
|
220
|
+
const reachable = new Set([roots[0], ...getReachableNodeIds(graph, roots[0] ?? "")]);
|
|
221
|
+
const disconnected = graph.nodes.find((node) => !reachable.has(node.id));
|
|
222
|
+
if (disconnected && index.nodeById.has(disconnected.id)) {
|
|
223
|
+
diagnostics.push({
|
|
224
|
+
code: "tree-disconnected",
|
|
225
|
+
severity: "error",
|
|
226
|
+
message: `Node "${disconnected.id}" is not reachable from the tree root.`,
|
|
227
|
+
nodeId: disconnected.id
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return diagnostics;
|
|
232
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GraphEdge, GraphId, GraphModel, GraphNode } from "./model.js";
|
|
2
|
+
export type GraphDiagnosticCode = "missing-graph-title" | "duplicate-node-id" | "duplicate-edge-id" | "dangling-edge-source" | "dangling-edge-target" | "self-loop" | "cycle-detected" | "missing-node-label" | "missing-edge-label" | "missing-node-position" | "tree-multiple-roots" | "tree-multiple-parents" | "tree-disconnected";
|
|
3
|
+
export type GraphDiagnosticSeverity = "error" | "warning";
|
|
4
|
+
export interface GraphDiagnostic {
|
|
5
|
+
readonly code: GraphDiagnosticCode;
|
|
6
|
+
readonly severity: GraphDiagnosticSeverity;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
readonly nodeId?: GraphId;
|
|
9
|
+
readonly edgeId?: GraphId;
|
|
10
|
+
}
|
|
11
|
+
export interface ValidateGraphOptions {
|
|
12
|
+
readonly allowSelfLoops?: boolean;
|
|
13
|
+
readonly requireEdgeLabels?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function validateGraph(graph: GraphModel, options?: ValidateGraphOptions): GraphDiagnostic[];
|
|
16
|
+
export declare function hasGraphErrors(diagnostics: readonly GraphDiagnostic[]): boolean;
|
|
17
|
+
export declare function formatGraphDiagnostic(diagnosticEntry: GraphDiagnostic): string;
|
|
18
|
+
export declare function getNodeLabel(node: GraphNode): string;
|
|
19
|
+
export declare function getEdgeAccessibleLabel(edge: GraphEdge, graph: GraphModel): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
function isBlank(value) {
|
|
2
|
+
return value === undefined || value.trim().length === 0;
|
|
3
|
+
}
|
|
4
|
+
function diagnostic(input) {
|
|
5
|
+
return input;
|
|
6
|
+
}
|
|
7
|
+
export function validateGraph(graph, options = {}) {
|
|
8
|
+
const requireEdgeLabels = options.requireEdgeLabels ?? true;
|
|
9
|
+
const diagnostics = [];
|
|
10
|
+
const nodeIds = new Set();
|
|
11
|
+
const edgeIds = new Set();
|
|
12
|
+
if (isBlank(graph.title)) {
|
|
13
|
+
diagnostics.push(diagnostic({
|
|
14
|
+
code: "missing-graph-title",
|
|
15
|
+
severity: "error",
|
|
16
|
+
message: "Graph title is required for accessible graph output."
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
for (const node of graph.nodes) {
|
|
20
|
+
if (nodeIds.has(node.id)) {
|
|
21
|
+
diagnostics.push(diagnostic({
|
|
22
|
+
code: "duplicate-node-id",
|
|
23
|
+
severity: "error",
|
|
24
|
+
message: `Node ID "${node.id}" is used more than once.`,
|
|
25
|
+
nodeId: node.id
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
nodeIds.add(node.id);
|
|
29
|
+
if (isBlank(node.label)) {
|
|
30
|
+
diagnostics.push(diagnostic({
|
|
31
|
+
code: "missing-node-label",
|
|
32
|
+
severity: "error",
|
|
33
|
+
message: `Node "${node.id}" is missing a label.`,
|
|
34
|
+
nodeId: node.id
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const edge of graph.edges) {
|
|
39
|
+
if (edgeIds.has(edge.id)) {
|
|
40
|
+
diagnostics.push(diagnostic({
|
|
41
|
+
code: "duplicate-edge-id",
|
|
42
|
+
severity: "error",
|
|
43
|
+
message: `Edge ID "${edge.id}" is used more than once.`,
|
|
44
|
+
edgeId: edge.id
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
edgeIds.add(edge.id);
|
|
48
|
+
if (!nodeIds.has(edge.source)) {
|
|
49
|
+
diagnostics.push(diagnostic({
|
|
50
|
+
code: "dangling-edge-source",
|
|
51
|
+
severity: "error",
|
|
52
|
+
message: `Edge "${edge.id}" references missing source node "${edge.source}".`,
|
|
53
|
+
nodeId: edge.source,
|
|
54
|
+
edgeId: edge.id
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
if (!nodeIds.has(edge.target)) {
|
|
58
|
+
diagnostics.push(diagnostic({
|
|
59
|
+
code: "dangling-edge-target",
|
|
60
|
+
severity: "error",
|
|
61
|
+
message: `Edge "${edge.id}" references missing target node "${edge.target}".`,
|
|
62
|
+
nodeId: edge.target,
|
|
63
|
+
edgeId: edge.id
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
if (requireEdgeLabels && isBlank(edge.label) && isBlank(edge.ariaLabel)) {
|
|
67
|
+
diagnostics.push(diagnostic({
|
|
68
|
+
code: "missing-edge-label",
|
|
69
|
+
severity: "error",
|
|
70
|
+
message: `Edge "${edge.id}" needs a label or ariaLabel.`,
|
|
71
|
+
edgeId: edge.id
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
if (edge.source === edge.target && options.allowSelfLoops !== true) {
|
|
75
|
+
diagnostics.push(diagnostic({
|
|
76
|
+
code: "self-loop",
|
|
77
|
+
severity: "warning",
|
|
78
|
+
message: `Edge "${edge.id}" points from node "${edge.source}" back to itself.`,
|
|
79
|
+
nodeId: edge.source,
|
|
80
|
+
edgeId: edge.id
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return diagnostics;
|
|
85
|
+
}
|
|
86
|
+
export function hasGraphErrors(diagnostics) {
|
|
87
|
+
return diagnostics.some((entry) => entry.severity === "error");
|
|
88
|
+
}
|
|
89
|
+
export function formatGraphDiagnostic(diagnosticEntry) {
|
|
90
|
+
const edge = diagnosticEntry.edgeId ? ` edge=${diagnosticEntry.edgeId}` : "";
|
|
91
|
+
const node = diagnosticEntry.nodeId ? ` node=${diagnosticEntry.nodeId}` : "";
|
|
92
|
+
return `${diagnosticEntry.severity} ${diagnosticEntry.code}${edge}${node}: ${diagnosticEntry.message}`;
|
|
93
|
+
}
|
|
94
|
+
export function getNodeLabel(node) {
|
|
95
|
+
return node.label.trim();
|
|
96
|
+
}
|
|
97
|
+
export function getEdgeAccessibleLabel(edge, graph) {
|
|
98
|
+
const ariaLabel = edge.ariaLabel;
|
|
99
|
+
if (ariaLabel !== undefined && ariaLabel.trim().length > 0) {
|
|
100
|
+
return ariaLabel;
|
|
101
|
+
}
|
|
102
|
+
const label = edge.label;
|
|
103
|
+
if (label !== undefined && label.trim().length > 0) {
|
|
104
|
+
return label;
|
|
105
|
+
}
|
|
106
|
+
const source = graph.nodes.find((node) => node.id === edge.source)?.label ?? edge.source;
|
|
107
|
+
const target = graph.nodes.find((node) => node.id === edge.target)?.label ?? edge.target;
|
|
108
|
+
return `${source} connects to ${target}`;
|
|
109
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
+
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
+
export interface DagLayeredLayoutOptions {
|
|
4
|
+
readonly direction?: "top-bottom" | "left-right";
|
|
5
|
+
readonly nodeSize?: GraphSize;
|
|
6
|
+
readonly nodeGap?: number;
|
|
7
|
+
readonly rankGap?: number;
|
|
8
|
+
readonly rankStrategy?: "longest-path" | "source-depth";
|
|
9
|
+
}
|
|
10
|
+
export declare function layoutDagGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(graph: GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>, options?: DagLayeredLayoutOptions): GraphLayoutResult<TNodeData, TEdgeData, TNodeKind, TEdgeKind>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createGraphIndex, detectGraphCycle, topologicalSortGraph } from "../algorithms.js";
|
|
2
|
+
import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
|
|
3
|
+
function rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction) {
|
|
4
|
+
if (direction === "left-right") {
|
|
5
|
+
return {
|
|
6
|
+
x: rank * (size.width + rankGap),
|
|
7
|
+
y: indexInRank * (size.height + nodeGap)
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
x: indexInRank * (size.width + nodeGap),
|
|
12
|
+
y: rank * (size.height + rankGap)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function layoutDagGraph(graph, options = {}) {
|
|
16
|
+
const cycle = detectGraphCycle(graph);
|
|
17
|
+
if (cycle) {
|
|
18
|
+
const diagnostic = {
|
|
19
|
+
code: "cycle-detected",
|
|
20
|
+
severity: "error",
|
|
21
|
+
message: `DAG layout requires an acyclic graph; found ${cycle.join(" -> ")}.`
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
graph: {
|
|
25
|
+
...graph,
|
|
26
|
+
nodes: [],
|
|
27
|
+
edges: []
|
|
28
|
+
},
|
|
29
|
+
diagnostics: [diagnostic]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const size = options.nodeSize ?? defaultGraphNodeSize;
|
|
33
|
+
const nodeGap = options.nodeGap ?? 32;
|
|
34
|
+
const rankGap = options.rankGap ?? 96;
|
|
35
|
+
const direction = options.direction ?? "top-bottom";
|
|
36
|
+
const index = createGraphIndex(graph);
|
|
37
|
+
const sorted = topologicalSortGraph(graph);
|
|
38
|
+
const ranks = new Map();
|
|
39
|
+
for (const nodeId of sorted.nodeIds) {
|
|
40
|
+
const incoming = index.incomingByNodeId.get(nodeId) ?? [];
|
|
41
|
+
const rank = incoming.reduce((nextRank, edge) => {
|
|
42
|
+
return Math.max(nextRank, (ranks.get(edge.source) ?? 0) + 1);
|
|
43
|
+
}, 0);
|
|
44
|
+
ranks.set(nodeId, rank);
|
|
45
|
+
}
|
|
46
|
+
const rankBuckets = new Map();
|
|
47
|
+
for (const node of graph.nodes) {
|
|
48
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
49
|
+
rankBuckets.set(rank, [...(rankBuckets.get(rank) ?? []), node.id]);
|
|
50
|
+
}
|
|
51
|
+
const nodes = graph.nodes.map((node) => {
|
|
52
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
53
|
+
const indexInRank = rankBuckets.get(rank)?.indexOf(node.id) ?? 0;
|
|
54
|
+
return {
|
|
55
|
+
...node,
|
|
56
|
+
position: rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction),
|
|
57
|
+
size: node.size ?? size
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
61
|
+
return {
|
|
62
|
+
graph: {
|
|
63
|
+
...graph,
|
|
64
|
+
nodes,
|
|
65
|
+
edges: graph.edges.map((edge) => edgeWithPoints(edge, nodeById))
|
|
66
|
+
},
|
|
67
|
+
diagnostics: sorted.diagnostics
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
+
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
+
export interface ManualLayoutOptions {
|
|
4
|
+
readonly defaultNodeSize?: GraphSize;
|
|
5
|
+
readonly missingPosition?: "error" | "grid" | "zero";
|
|
6
|
+
}
|
|
7
|
+
export declare function layoutManualGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(graph: GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>, options?: ManualLayoutOptions): GraphLayoutResult<TNodeData, TEdgeData, TNodeKind, TEdgeKind>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
|
|
2
|
+
function gridPosition(index, size) {
|
|
3
|
+
const columns = 4;
|
|
4
|
+
return {
|
|
5
|
+
x: (index % columns) * (size.width + 48),
|
|
6
|
+
y: Math.floor(index / columns) * (size.height + 48)
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export function layoutManualGraph(graph, options = {}) {
|
|
10
|
+
const defaultNodeSize = options.defaultNodeSize ?? defaultGraphNodeSize;
|
|
11
|
+
const missingPosition = options.missingPosition ?? "grid";
|
|
12
|
+
const diagnostics = [];
|
|
13
|
+
const nodes = graph.nodes.map((node, index) => {
|
|
14
|
+
if (!node.position && missingPosition === "error") {
|
|
15
|
+
diagnostics.push({
|
|
16
|
+
code: "missing-node-position",
|
|
17
|
+
severity: "error",
|
|
18
|
+
message: `Node "${node.id}" is missing a manual position.`,
|
|
19
|
+
nodeId: node.id
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const fallbackPosition = missingPosition === "zero" ? { x: 0, y: 0 } : gridPosition(index, defaultNodeSize);
|
|
23
|
+
return {
|
|
24
|
+
...node,
|
|
25
|
+
position: node.position ?? fallbackPosition,
|
|
26
|
+
size: node.size ?? defaultNodeSize
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
30
|
+
const edges = graph.edges.map((edge) => edgeWithPoints(edge, nodeById));
|
|
31
|
+
return {
|
|
32
|
+
graph: {
|
|
33
|
+
...graph,
|
|
34
|
+
nodes,
|
|
35
|
+
edges
|
|
36
|
+
},
|
|
37
|
+
diagnostics
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
+
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
+
export interface TreeLayoutOptions {
|
|
4
|
+
readonly direction?: "top-bottom" | "left-right";
|
|
5
|
+
readonly nodeSize?: GraphSize;
|
|
6
|
+
readonly siblingGap?: number;
|
|
7
|
+
readonly levelGap?: number;
|
|
8
|
+
readonly rootId?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function layoutTreeGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(graph: GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>, options?: TreeLayoutOptions): GraphLayoutResult<TNodeData, TEdgeData, TNodeKind, TEdgeKind>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createGraphIndex, validateTreeGraph } from "../algorithms.js";
|
|
2
|
+
import { defaultGraphNodeSize, edgeWithPoints } from "./types.js";
|
|
3
|
+
function orientPoint(depth, breadth, size, levelGap, direction) {
|
|
4
|
+
if (direction === "left-right") {
|
|
5
|
+
return {
|
|
6
|
+
x: depth * (size.width + levelGap),
|
|
7
|
+
y: breadth
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
x: breadth,
|
|
12
|
+
y: depth * (size.height + levelGap)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function layoutTreeGraph(graph, options = {}) {
|
|
16
|
+
const diagnostics = validateTreeGraph(graph, options.rootId ? { rootId: options.rootId } : {});
|
|
17
|
+
const size = options.nodeSize ?? defaultGraphNodeSize;
|
|
18
|
+
const siblingGap = options.siblingGap ?? 32;
|
|
19
|
+
const levelGap = options.levelGap ?? 96;
|
|
20
|
+
const direction = options.direction ?? "top-bottom";
|
|
21
|
+
const index = createGraphIndex(graph);
|
|
22
|
+
const hasBlockingDiagnostics = diagnostics.some((entry) => {
|
|
23
|
+
return entry.code === "cycle-detected" || entry.code === "tree-multiple-parents";
|
|
24
|
+
});
|
|
25
|
+
const parentCounts = new Map(graph.nodes.map((node) => [node.id, 0]));
|
|
26
|
+
for (const edge of graph.edges) {
|
|
27
|
+
if (parentCounts.has(edge.target)) {
|
|
28
|
+
parentCounts.set(edge.target, (parentCounts.get(edge.target) ?? 0) + 1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const rootId = options.rootId ?? graph.nodes.find((node) => parentCounts.get(node.id) === 0)?.id;
|
|
32
|
+
const positions = new Map();
|
|
33
|
+
let leafCursor = 0;
|
|
34
|
+
function place(nodeId, depth) {
|
|
35
|
+
const children = (index.outgoingByNodeId.get(nodeId) ?? [])
|
|
36
|
+
.map((edge) => edge.target)
|
|
37
|
+
.filter((targetId) => index.nodeById.has(targetId));
|
|
38
|
+
if (children.length === 0) {
|
|
39
|
+
const breadth = leafCursor * (size.width + siblingGap);
|
|
40
|
+
leafCursor += 1;
|
|
41
|
+
positions.set(nodeId, orientPoint(depth, breadth, size, levelGap, direction));
|
|
42
|
+
return breadth;
|
|
43
|
+
}
|
|
44
|
+
const childBreadths = children.map((childId) => place(childId, depth + 1));
|
|
45
|
+
const breadth = (childBreadths[0] ?? 0) + ((childBreadths.at(-1) ?? 0) - (childBreadths[0] ?? 0)) / 2;
|
|
46
|
+
positions.set(nodeId, orientPoint(depth, breadth, size, levelGap, direction));
|
|
47
|
+
return breadth;
|
|
48
|
+
}
|
|
49
|
+
if (rootId && !hasBlockingDiagnostics) {
|
|
50
|
+
place(rootId, 0);
|
|
51
|
+
}
|
|
52
|
+
const nodes = graph.nodes.map((node, index) => {
|
|
53
|
+
return {
|
|
54
|
+
...node,
|
|
55
|
+
position: positions.get(node.id) ?? orientPoint(0, index * (size.width + siblingGap), size, levelGap, direction),
|
|
56
|
+
size: node.size ?? size
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
60
|
+
return {
|
|
61
|
+
graph: {
|
|
62
|
+
...graph,
|
|
63
|
+
nodes,
|
|
64
|
+
edges: graph.edges.map((edge) => edgeWithPoints(edge, nodeById))
|
|
65
|
+
},
|
|
66
|
+
diagnostics
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GraphDiagnostic } from "../diagnostics.js";
|
|
2
|
+
import type { GraphEdge, GraphModel, GraphNode, GraphPoint, GraphSize } from "../model.js";
|
|
3
|
+
export interface PositionedGraphNode<TData = unknown, TKind extends string = string> extends GraphNode<TData, TKind> {
|
|
4
|
+
readonly position: GraphPoint;
|
|
5
|
+
readonly size: GraphSize;
|
|
6
|
+
}
|
|
7
|
+
export interface PositionedGraphEdge<TData = unknown, TKind extends string = string> extends GraphEdge<TData, TKind> {
|
|
8
|
+
readonly points: readonly GraphPoint[];
|
|
9
|
+
readonly labelPosition?: GraphPoint;
|
|
10
|
+
}
|
|
11
|
+
export interface PositionedGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string> extends Omit<GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>, "nodes" | "edges"> {
|
|
12
|
+
readonly nodes: readonly PositionedGraphNode<TNodeData, TNodeKind>[];
|
|
13
|
+
readonly edges: readonly PositionedGraphEdge<TEdgeData, TEdgeKind>[];
|
|
14
|
+
}
|
|
15
|
+
export interface GraphLayoutResult<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string> {
|
|
16
|
+
readonly graph: PositionedGraph<TNodeData, TEdgeData, TNodeKind, TEdgeKind>;
|
|
17
|
+
readonly diagnostics: readonly GraphDiagnostic[];
|
|
18
|
+
}
|
|
19
|
+
export declare const defaultGraphNodeSize: GraphSize;
|
|
20
|
+
export declare function nodeCenter(node: PositionedGraphNode): GraphPoint;
|
|
21
|
+
export declare function edgeWithPoints<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(edge: GraphEdge<TEdgeData, TEdgeKind>, nodes: ReadonlyMap<string, PositionedGraphNode<TNodeData, TNodeKind>>): PositionedGraphEdge<TEdgeData, TEdgeKind>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const defaultGraphNodeSize = { width: 100, height: 48 };
|
|
2
|
+
export function nodeCenter(node) {
|
|
3
|
+
return {
|
|
4
|
+
x: node.position.x + node.size.width / 2,
|
|
5
|
+
y: node.position.y + node.size.height / 2
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export function edgeWithPoints(edge, nodes) {
|
|
9
|
+
const source = nodes.get(edge.source);
|
|
10
|
+
const target = nodes.get(edge.target);
|
|
11
|
+
const points = source && target ? [nodeCenter(source), nodeCenter(target)] : [];
|
|
12
|
+
const labelPosition = points.length === 2 && points[0] && points[1]
|
|
13
|
+
? {
|
|
14
|
+
x: (points[0].x + points[1].x) / 2,
|
|
15
|
+
y: (points[0].y + points[1].y) / 2
|
|
16
|
+
}
|
|
17
|
+
: undefined;
|
|
18
|
+
return labelPosition ? { ...edge, points, labelPosition } : { ...edge, points };
|
|
19
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type GraphId = string;
|
|
2
|
+
export type GraphDirection = "directed" | "undirected";
|
|
3
|
+
export interface GraphPoint {
|
|
4
|
+
readonly x: number;
|
|
5
|
+
readonly y: number;
|
|
6
|
+
}
|
|
7
|
+
export interface GraphSize {
|
|
8
|
+
readonly width: number;
|
|
9
|
+
readonly height: number;
|
|
10
|
+
}
|
|
11
|
+
export interface GraphNode<TData = unknown, TKind extends string = string> {
|
|
12
|
+
readonly id: GraphId;
|
|
13
|
+
readonly label: string;
|
|
14
|
+
readonly kind?: TKind;
|
|
15
|
+
readonly data?: TData;
|
|
16
|
+
readonly position?: GraphPoint;
|
|
17
|
+
readonly size?: GraphSize;
|
|
18
|
+
readonly status?: string;
|
|
19
|
+
readonly groupId?: GraphId;
|
|
20
|
+
readonly ariaDescription?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface GraphEdge<TData = unknown, TKind extends string = string> {
|
|
23
|
+
readonly id: GraphId;
|
|
24
|
+
readonly source: GraphId;
|
|
25
|
+
readonly target: GraphId;
|
|
26
|
+
readonly label?: string;
|
|
27
|
+
readonly ariaLabel?: string;
|
|
28
|
+
readonly kind?: TKind;
|
|
29
|
+
readonly data?: TData;
|
|
30
|
+
readonly directed?: boolean;
|
|
31
|
+
readonly status?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface GraphModel<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string> {
|
|
34
|
+
readonly id?: GraphId;
|
|
35
|
+
readonly title: string;
|
|
36
|
+
readonly description?: string;
|
|
37
|
+
readonly direction?: GraphDirection;
|
|
38
|
+
readonly nodes: readonly GraphNode<TNodeData, TNodeKind>[];
|
|
39
|
+
readonly edges: readonly GraphEdge<TEdgeData, TEdgeKind>[];
|
|
40
|
+
}
|
|
41
|
+
export declare function defineGraph<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string, TEdgeKind extends string = string>(graph: GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>): GraphModel<TNodeData, TEdgeData, TNodeKind, TEdgeKind>;
|
package/dist/model.js
ADDED
package/dist/styles.css
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* src/styles.css */
|
|
2
|
+
@layer zvk-graphs-tokens, zvk-graphs-components;
|
|
3
|
+
|
|
4
|
+
/* src/styles/graph.css */
|
|
5
|
+
@layer zvk-graphs-tokens {
|
|
6
|
+
:root {
|
|
7
|
+
--zvk-graphs-canvas-bg: var(--zvk-ui-color-background, Canvas);
|
|
8
|
+
--zvk-graphs-node-bg: var(--zvk-ui-color-surface, Canvas);
|
|
9
|
+
--zvk-graphs-node-border: var(--zvk-ui-card-border, GrayText);
|
|
10
|
+
--zvk-graphs-node-text: var(--zvk-ui-color-foreground, CanvasText);
|
|
11
|
+
--zvk-graphs-muted-text: var(--zvk-ui-color-muted-foreground, GrayText);
|
|
12
|
+
--zvk-graphs-edge-stroke: var(--zvk-ui-color-border-strong, CanvasText);
|
|
13
|
+
--zvk-graphs-edge-muted-stroke: var(--zvk-ui-color-border, GrayText);
|
|
14
|
+
--zvk-graphs-selected-bg: var(--zvk-ui-color-primary-soft, color-mix(in srgb, Highlight 12%, Canvas));
|
|
15
|
+
--zvk-graphs-selected-stroke: var(--zvk-ui-color-primary, Highlight);
|
|
16
|
+
--zvk-graphs-focus-outline: var(--zvk-ui-focus-ring, 0 0 0 3px Highlight);
|
|
17
|
+
--zvk-graphs-focus-stroke: var(--zvk-ui-color-ring, Highlight);
|
|
18
|
+
--zvk-graphs-success: var(--zvk-ui-color-success, #0a6b3a);
|
|
19
|
+
--zvk-graphs-warning: var(--zvk-ui-color-warning, #8a5a00);
|
|
20
|
+
--zvk-graphs-destructive: var(--zvk-ui-color-destructive, #b00020);
|
|
21
|
+
--zvk-graphs-info: var(--zvk-ui-color-info, #087ea4);
|
|
22
|
+
--zvk-graphs-node-radius: var(--zvk-ui-radius-md, 0.5rem);
|
|
23
|
+
--zvk-graphs-node-shadow: var(--zvk-ui-shadow-xs, none);
|
|
24
|
+
--zvk-graphs-edge-width: 1.5px;
|
|
25
|
+
--zvk-graphs-selected-edge-width: 2.5px;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@layer zvk-graphs-components {
|
|
30
|
+
:where(.zvk-graphs) {
|
|
31
|
+
color: var(--zvk-graphs-node-text);
|
|
32
|
+
background: var(--zvk-graphs-canvas-bg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
:where(.zvk-graphs__svg) {
|
|
36
|
+
display: block;
|
|
37
|
+
max-width: 100%;
|
|
38
|
+
height: auto;
|
|
39
|
+
overflow: visible;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
:where(.zvk-graphs__edge) {
|
|
43
|
+
fill: none;
|
|
44
|
+
stroke: var(--zvk-graphs-edge-stroke);
|
|
45
|
+
stroke-width: var(--zvk-graphs-edge-width);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
:where(.zvk-graphs__edge-group[data-selected="true"] .zvk-graphs__edge) {
|
|
49
|
+
stroke: var(--zvk-graphs-selected-stroke);
|
|
50
|
+
stroke-width: var(--zvk-graphs-selected-edge-width);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:where(.zvk-graphs__marker) {
|
|
54
|
+
fill: var(--zvk-graphs-edge-stroke);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:where(.zvk-graphs__edge-label) {
|
|
58
|
+
fill: var(--zvk-graphs-muted-text);
|
|
59
|
+
font: 12px system-ui, sans-serif;
|
|
60
|
+
paint-order: stroke;
|
|
61
|
+
stroke: var(--zvk-graphs-canvas-bg);
|
|
62
|
+
stroke-width: 3px;
|
|
63
|
+
text-anchor: middle;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
:where(.zvk-graphs__node-shape) {
|
|
67
|
+
fill: var(--zvk-graphs-node-bg);
|
|
68
|
+
stroke: var(--zvk-graphs-node-border);
|
|
69
|
+
stroke-width: 1px;
|
|
70
|
+
filter: var(--zvk-graphs-node-shadow);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
:where(.zvk-graphs__node-label) {
|
|
74
|
+
fill: var(--zvk-graphs-node-text);
|
|
75
|
+
font: 13px system-ui, sans-serif;
|
|
76
|
+
dominant-baseline: middle;
|
|
77
|
+
pointer-events: none;
|
|
78
|
+
text-anchor: middle;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
:where(.zvk-graphs__node-selection) {
|
|
82
|
+
fill: var(--zvk-graphs-selected-bg);
|
|
83
|
+
stroke: var(--zvk-graphs-selected-stroke);
|
|
84
|
+
stroke-width: 2px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
:where(.zvk-graphs__node[data-status="success"] .zvk-graphs__node-shape) {
|
|
88
|
+
stroke: var(--zvk-graphs-success);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
:where(.zvk-graphs__node[data-status="warning"] .zvk-graphs__node-shape) {
|
|
92
|
+
stroke: var(--zvk-graphs-warning);
|
|
93
|
+
stroke-dasharray: 4 3;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
:where(.zvk-graphs__node[data-status="destructive"] .zvk-graphs__node-shape) {
|
|
97
|
+
stroke: var(--zvk-graphs-destructive);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
:where(.zvk-graphs__fallback) {
|
|
101
|
+
margin-top: 0.75rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
:where(.zvk-graphs__fallback h3) {
|
|
105
|
+
margin: 0.75rem 0 0.25rem;
|
|
106
|
+
font: 600 0.875rem system-ui, sans-serif;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:where(.zvk-graphs__fallback ul) {
|
|
110
|
+
margin: 0;
|
|
111
|
+
padding-inline-start: 1.25rem;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
package/dist/svg.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type ReactNode, type SVGProps } from "react";
|
|
2
|
+
import type { GraphId } from "./model.js";
|
|
3
|
+
import { type PositionedGraph, type PositionedGraphNode } from "./layout/index.js";
|
|
4
|
+
export type RenderGraphNode<TData = unknown, TKind extends string = string> = (input: {
|
|
5
|
+
readonly node: PositionedGraphNode<TData, TKind>;
|
|
6
|
+
readonly selected: boolean;
|
|
7
|
+
readonly focused: boolean;
|
|
8
|
+
}) => ReactNode;
|
|
9
|
+
export interface GraphSvgProps<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string> {
|
|
10
|
+
readonly graph: PositionedGraph<TNodeData, TEdgeData, TNodeKind>;
|
|
11
|
+
readonly titleId: string;
|
|
12
|
+
readonly descriptionId: string;
|
|
13
|
+
readonly selectedNodeIds?: readonly GraphId[];
|
|
14
|
+
readonly selectedEdgeIds?: readonly GraphId[];
|
|
15
|
+
readonly renderNode?: RenderGraphNode<TNodeData, TNodeKind>;
|
|
16
|
+
readonly svgProps?: Omit<SVGProps<SVGSVGElement>, "children">;
|
|
17
|
+
}
|
|
18
|
+
export interface GraphFigureProps<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string> {
|
|
19
|
+
readonly graph: PositionedGraph<TNodeData, TEdgeData, TNodeKind>;
|
|
20
|
+
readonly selectedNodeIds?: readonly GraphId[];
|
|
21
|
+
readonly selectedEdgeIds?: readonly GraphId[];
|
|
22
|
+
readonly renderNode?: RenderGraphNode<TNodeData, TNodeKind>;
|
|
23
|
+
readonly fallbackMode?: "list" | "tree" | "none";
|
|
24
|
+
}
|
|
25
|
+
export declare function GraphSvg<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string>({ graph, titleId, descriptionId, selectedNodeIds, selectedEdgeIds, renderNode, svgProps }: GraphSvgProps<TNodeData, TEdgeData, TNodeKind>): ReactNode;
|
|
26
|
+
export declare function GraphFallbackList({ graph }: {
|
|
27
|
+
readonly graph: PositionedGraph;
|
|
28
|
+
}): ReactNode;
|
|
29
|
+
export declare function GraphFallbackTree({ graph }: {
|
|
30
|
+
readonly graph: PositionedGraph;
|
|
31
|
+
}): ReactNode;
|
|
32
|
+
export declare function GraphFigure<TNodeData = unknown, TEdgeData = unknown, TNodeKind extends string = string>({ graph, selectedNodeIds, selectedEdgeIds, renderNode, fallbackMode }: GraphFigureProps<TNodeData, TEdgeData, TNodeKind>): ReactNode;
|
package/dist/svg.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useId } from "react";
|
|
3
|
+
import { getEdgeAccessibleLabel } from "./diagnostics.js";
|
|
4
|
+
import { nodeCenter } from "./layout/index.js";
|
|
5
|
+
function pathForPoints(points) {
|
|
6
|
+
if (points.length === 0 || !points[0]) {
|
|
7
|
+
return "";
|
|
8
|
+
}
|
|
9
|
+
return points.slice(1).reduce((path, point) => `${path} L ${point.x} ${point.y}`, `M ${points[0].x} ${points[0].y}`);
|
|
10
|
+
}
|
|
11
|
+
function graphBounds(graph) {
|
|
12
|
+
const padding = 24;
|
|
13
|
+
const maxX = graph.nodes.reduce((value, node) => Math.max(value, node.position.x + node.size.width), 0);
|
|
14
|
+
const maxY = graph.nodes.reduce((value, node) => Math.max(value, node.position.y + node.size.height), 0);
|
|
15
|
+
const width = Math.max(1, maxX + padding * 2);
|
|
16
|
+
const height = Math.max(1, maxY + padding * 2);
|
|
17
|
+
return {
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
viewBox: `${-padding} ${-padding} ${width} ${height}`
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function nodeSelected(selectedNodeIds, nodeId) {
|
|
24
|
+
return selectedNodeIds?.includes(nodeId) ?? false;
|
|
25
|
+
}
|
|
26
|
+
function edgeSelected(selectedEdgeIds, edgeId) {
|
|
27
|
+
return selectedEdgeIds?.includes(edgeId) ?? false;
|
|
28
|
+
}
|
|
29
|
+
export function GraphSvg({ graph, titleId, descriptionId, selectedNodeIds, selectedEdgeIds, renderNode, svgProps }) {
|
|
30
|
+
const bounds = graphBounds(graph);
|
|
31
|
+
const markerId = `${titleId}-arrow`;
|
|
32
|
+
const hasDirectedEdges = graph.edges.some((edge) => edge.directed !== false);
|
|
33
|
+
return (_jsxs("svg", { ...svgProps, className: ["zvk-graphs__svg", svgProps?.className].filter(Boolean).join(" "), role: "img", "aria-labelledby": titleId, "aria-describedby": descriptionId, viewBox: bounds.viewBox, width: svgProps?.width ?? bounds.width, height: svgProps?.height ?? bounds.height, children: [_jsx("title", { id: titleId, children: graph.title }), _jsx("desc", { id: descriptionId, children: graph.description ?? `${graph.nodes.length} nodes and ${graph.edges.length} edges.` }), hasDirectedEdges ? (_jsx("defs", { children: _jsx("marker", { id: markerId, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto", children: _jsx("path", { className: "zvk-graphs__marker", d: "M 0 0 L 10 5 L 0 10 z" }) }) })) : null, _jsx("g", { className: "zvk-graphs__edges", children: graph.edges.map((edge) => {
|
|
34
|
+
const selected = edgeSelected(selectedEdgeIds, edge.id);
|
|
35
|
+
const d = pathForPoints(edge.points);
|
|
36
|
+
return (_jsxs("g", { className: "zvk-graphs__edge-group", "data-edge-id": edge.id, "data-status": edge.status, "data-selected": selected ? "true" : undefined, children: [_jsx("path", { className: "zvk-graphs__edge", d: d, markerEnd: edge.directed === false ? undefined : `url(#${markerId})` }), edge.label && edge.labelPosition ? (_jsx("text", { className: "zvk-graphs__edge-label", x: edge.labelPosition.x, y: edge.labelPosition.y - 6, children: edge.label })) : null] }, edge.id));
|
|
37
|
+
}) }), _jsx("g", { className: "zvk-graphs__nodes", children: graph.nodes.map((node) => {
|
|
38
|
+
const selected = nodeSelected(selectedNodeIds, node.id);
|
|
39
|
+
const center = nodeCenter(node);
|
|
40
|
+
return (_jsxs("g", { className: "zvk-graphs__node", "data-node-id": node.id, "data-kind": node.kind, "data-status": node.status, "data-selected": selected ? "true" : undefined, transform: `translate(${node.position.x} ${node.position.y})`, children: [renderNode ? (renderNode({ node, selected, focused: false })) : (_jsxs(_Fragment, { children: [_jsx("rect", { className: "zvk-graphs__node-shape", width: node.size.width, height: node.size.height, rx: "8" }), _jsx("text", { className: "zvk-graphs__node-label", x: node.size.width / 2, y: node.size.height / 2, children: node.label }), _jsx("title", { children: node.ariaDescription ?? node.label })] })), selected ? (_jsx("rect", { className: "zvk-graphs__node-selection", x: "-4", y: "-4", width: node.size.width + 8, height: node.size.height + 8, rx: "10", "aria-hidden": "true" })) : null, _jsx("circle", { className: "zvk-graphs__node-anchor", cx: center.x - node.position.x, cy: center.y - node.position.y, r: "0" })] }, node.id));
|
|
41
|
+
}) })] }));
|
|
42
|
+
}
|
|
43
|
+
export function GraphFallbackList({ graph }) {
|
|
44
|
+
return (_jsxs("div", { className: "zvk-graphs__fallback", children: [_jsx("h3", { children: "Nodes" }), _jsx("ul", { children: graph.nodes.map((node) => (_jsxs("li", { children: [node.label, node.status ? ` (${node.status})` : ""] }, node.id))) }), _jsx("h3", { children: "Edges" }), _jsx("ul", { children: graph.edges.map((edge) => (_jsx("li", { children: getEdgeAccessibleLabel(edge, graph) }, edge.id))) })] }));
|
|
45
|
+
}
|
|
46
|
+
export function GraphFallbackTree({ graph }) {
|
|
47
|
+
return _jsx(GraphFallbackList, { graph: graph });
|
|
48
|
+
}
|
|
49
|
+
export function GraphFigure({ graph, selectedNodeIds, selectedEdgeIds, renderNode, fallbackMode = "list" }) {
|
|
50
|
+
const id = useId();
|
|
51
|
+
const titleId = `${id}-title`;
|
|
52
|
+
const descriptionId = `${id}-description`;
|
|
53
|
+
return (_jsxs("figure", { className: "zvk-graphs", "data-zvk-graphs": true, children: [_jsx(GraphSvg, { graph: graph, titleId: titleId, descriptionId: descriptionId, ...(selectedNodeIds ? { selectedNodeIds } : {}), ...(selectedEdgeIds ? { selectedEdgeIds } : {}), ...(renderNode ? { renderNode } : {}) }), fallbackMode === "none" ? null : fallbackMode === "tree" ? (_jsx(GraphFallbackTree, { graph: graph })) : (_jsx(GraphFallbackList, { graph: graph }))] }));
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zvk/graphs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Accessible SVG-first node and edge graph utilities, deterministic layouts, and static React renderers for ZVK applications.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/brandon-schabel/zvk.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": [
|
|
16
|
+
"**/*.css"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE.md",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./model": {
|
|
31
|
+
"types": "./dist/model.d.ts",
|
|
32
|
+
"import": "./dist/model.js"
|
|
33
|
+
},
|
|
34
|
+
"./diagnostics": {
|
|
35
|
+
"types": "./dist/diagnostics.d.ts",
|
|
36
|
+
"import": "./dist/diagnostics.js"
|
|
37
|
+
},
|
|
38
|
+
"./algorithms": {
|
|
39
|
+
"types": "./dist/algorithms.d.ts",
|
|
40
|
+
"import": "./dist/algorithms.js"
|
|
41
|
+
},
|
|
42
|
+
"./layout": {
|
|
43
|
+
"types": "./dist/layout/index.d.ts",
|
|
44
|
+
"import": "./dist/layout/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./svg": {
|
|
47
|
+
"types": "./dist/svg.d.ts",
|
|
48
|
+
"import": "./dist/svg.js"
|
|
49
|
+
},
|
|
50
|
+
"./styles.css": "./dist/styles.css",
|
|
51
|
+
"./package.json": "./package.json"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"clean": "bun run scripts/clean.mjs",
|
|
55
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
56
|
+
"build:css": "bun run scripts/build-css.mjs",
|
|
57
|
+
"build": "bun run clean && bun run build:types && bun run build:css",
|
|
58
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"test:ssr": "vitest run tests/ssr --environment node",
|
|
61
|
+
"test:exports": "vitest run tests/exports --environment node",
|
|
62
|
+
"test:types": "tsd",
|
|
63
|
+
"validate:exports": "bun run scripts/validate-exports.mjs",
|
|
64
|
+
"tarball:inspect": "bun run scripts/check-tarball.mjs",
|
|
65
|
+
"pack:dry": "bun pm pack --dry-run",
|
|
66
|
+
"preflight": "bun run typecheck && bun run build && bun run test && bun run test:ssr && bun run test:types && bun run test:exports && bun run validate:exports && bun run tarball:inspect && bun run pack:dry"
|
|
67
|
+
},
|
|
68
|
+
"dependencies": {},
|
|
69
|
+
"peerDependencies": {
|
|
70
|
+
"react": "^19.0.0",
|
|
71
|
+
"react-dom": "^19.0.0"
|
|
72
|
+
},
|
|
73
|
+
"tsd": {
|
|
74
|
+
"directory": "tests/types"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"@types/node": "^25.9.1",
|
|
78
|
+
"@types/react": "^19.2.16",
|
|
79
|
+
"@types/react-dom": "^19.2.3",
|
|
80
|
+
"tsd": "^0.33.0",
|
|
81
|
+
"typescript": "^6.0.3",
|
|
82
|
+
"vitest": "^4.1.8"
|
|
83
|
+
}
|
|
84
|
+
}
|