@zvk/graphs 0.1.0 → 0.1.2
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 +14 -0
- package/README.md +376 -4
- package/dist/algorithms.d.ts +73 -1
- package/dist/algorithms.js +287 -0
- package/dist/diagnostics.d.ts +74 -2
- package/dist/diagnostics.js +577 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/layout/dag.d.ts +5 -2
- package/dist/layout/dag.js +38 -12
- package/dist/layout/manual.d.ts +3 -2
- package/dist/layout/manual.js +11 -8
- package/dist/layout/tree.d.ts +3 -2
- package/dist/layout/tree.js +15 -8
- package/dist/layout/types.d.ts +76 -2
- package/dist/layout/types.js +336 -9
- package/dist/model.d.ts +47 -0
- package/dist/serialization.d.ts +14 -0
- package/dist/serialization.js +68 -0
- package/dist/styles.css +209 -7
- package/dist/svg.js +234 -19
- package/package.json +22 -8
package/dist/diagnostics.js
CHANGED
|
@@ -1,14 +1,196 @@
|
|
|
1
|
+
import { getGraphEdgeLabelOverlapDiagnostics } from "./layout/types.js";
|
|
1
2
|
function isBlank(value) {
|
|
2
3
|
return value === undefined || value.trim().length === 0;
|
|
3
4
|
}
|
|
5
|
+
const graphDiagnosticCategoryByCode = {
|
|
6
|
+
"missing-graph-title": "accessibility",
|
|
7
|
+
"duplicate-node-id": "structure",
|
|
8
|
+
"duplicate-edge-id": "structure",
|
|
9
|
+
"duplicate-group-id": "structure",
|
|
10
|
+
"dangling-edge-source": "structure",
|
|
11
|
+
"dangling-edge-target": "structure",
|
|
12
|
+
"unknown-node-group": "structure",
|
|
13
|
+
"self-loop": "structure",
|
|
14
|
+
"cycle-detected": "structure",
|
|
15
|
+
"missing-group-label": "accessibility",
|
|
16
|
+
"missing-node-label": "accessibility",
|
|
17
|
+
"missing-edge-label": "accessibility",
|
|
18
|
+
"missing-node-description": "accessibility",
|
|
19
|
+
"missing-edge-description": "accessibility",
|
|
20
|
+
"missing-graph-description": "accessibility",
|
|
21
|
+
"duplicate-node-label": "accessibility",
|
|
22
|
+
"duplicate-edge-label": "accessibility",
|
|
23
|
+
"fallback-disabled": "fallback",
|
|
24
|
+
"missing-node-position": "layout",
|
|
25
|
+
"missing-node-size": "layout",
|
|
26
|
+
"duplicate-node-position": "layout",
|
|
27
|
+
"large-graph": "performance",
|
|
28
|
+
"dense-graph": "performance",
|
|
29
|
+
"large-fallback-content": "performance",
|
|
30
|
+
"large-visible-label-count": "performance",
|
|
31
|
+
"high-route-complexity": "performance",
|
|
32
|
+
"large-metadata-summary": "performance",
|
|
33
|
+
"parallel-edge": "layout",
|
|
34
|
+
"edge-label-overlap-risk": "layout",
|
|
35
|
+
"empty-group": "structure",
|
|
36
|
+
"tree-multiple-roots": "tree",
|
|
37
|
+
"tree-multiple-parents": "tree",
|
|
38
|
+
"tree-disconnected": "tree"
|
|
39
|
+
};
|
|
40
|
+
const graphDiagnosticFixByCode = {
|
|
41
|
+
"missing-graph-title": {
|
|
42
|
+
target: "graph",
|
|
43
|
+
title: "Add a graph title",
|
|
44
|
+
description: "Provide a non-empty graph title so static SVG output has an accessible name."
|
|
45
|
+
},
|
|
46
|
+
"missing-graph-description": {
|
|
47
|
+
target: "graph",
|
|
48
|
+
title: "Add a graph description",
|
|
49
|
+
description: "Describe the graph purpose or structure for complex static graph output."
|
|
50
|
+
},
|
|
51
|
+
"missing-node-label": {
|
|
52
|
+
target: "node",
|
|
53
|
+
title: "Add a node label",
|
|
54
|
+
description: "Give each node a visible label that identifies the represented entity."
|
|
55
|
+
},
|
|
56
|
+
"missing-edge-label": {
|
|
57
|
+
target: "edge",
|
|
58
|
+
title: "Add an edge label",
|
|
59
|
+
description: "Provide an edge label or accessibility label that explains the relationship."
|
|
60
|
+
},
|
|
61
|
+
"dangling-edge-source": {
|
|
62
|
+
target: "edge",
|
|
63
|
+
title: "Use an existing source node",
|
|
64
|
+
description: "Update the edge source to reference a node that exists in this graph."
|
|
65
|
+
},
|
|
66
|
+
"dangling-edge-target": {
|
|
67
|
+
target: "edge",
|
|
68
|
+
title: "Use an existing target node",
|
|
69
|
+
description: "Update the edge target to reference a node that exists in this graph."
|
|
70
|
+
},
|
|
71
|
+
"duplicate-node-id": {
|
|
72
|
+
target: "node",
|
|
73
|
+
title: "Use unique node IDs",
|
|
74
|
+
description: "Every node ID must be unique so graph helpers can build deterministic indexes."
|
|
75
|
+
},
|
|
76
|
+
"duplicate-edge-id": {
|
|
77
|
+
target: "edge",
|
|
78
|
+
title: "Use unique edge IDs",
|
|
79
|
+
description: "Every edge ID must be unique so diagnostics, routes, and selections can address it."
|
|
80
|
+
},
|
|
81
|
+
"duplicate-group-id": {
|
|
82
|
+
target: "group",
|
|
83
|
+
title: "Use unique group IDs",
|
|
84
|
+
description: "Every group ID must be unique so static group bounds can be addressed deterministically."
|
|
85
|
+
},
|
|
86
|
+
"missing-group-label": {
|
|
87
|
+
target: "group",
|
|
88
|
+
title: "Add a group label",
|
|
89
|
+
description: "Provide a visible group label for static group containers and fallback details."
|
|
90
|
+
},
|
|
91
|
+
"unknown-node-group": {
|
|
92
|
+
target: "node",
|
|
93
|
+
title: "Reference an existing group",
|
|
94
|
+
description: "Update the node groupId to reference a group defined on this graph."
|
|
95
|
+
},
|
|
96
|
+
"empty-group": {
|
|
97
|
+
target: "group",
|
|
98
|
+
title: "Assign nodes to the group",
|
|
99
|
+
description: "A static group should either contain at least one node or provide explicit bounds."
|
|
100
|
+
},
|
|
101
|
+
"fallback-disabled": {
|
|
102
|
+
target: "graph",
|
|
103
|
+
title: "Provide equivalent fallback content",
|
|
104
|
+
description: "Render equivalent graph details elsewhere when disabling built-in fallback content."
|
|
105
|
+
},
|
|
106
|
+
"missing-node-position": {
|
|
107
|
+
target: "layout",
|
|
108
|
+
title: "Add a manual node position",
|
|
109
|
+
description: "Provide a node position or choose a layout fallback that can position missing nodes."
|
|
110
|
+
},
|
|
111
|
+
"missing-node-size": {
|
|
112
|
+
target: "layout",
|
|
113
|
+
title: "Add an explicit node size",
|
|
114
|
+
description: "Provide a node size when precise static layout and bounds are important."
|
|
115
|
+
},
|
|
116
|
+
"large-fallback-content": {
|
|
117
|
+
target: "graph",
|
|
118
|
+
title: "Simplify fallback content",
|
|
119
|
+
description: "Filter, collapse, or summarize the graph before rendering large static fallback content."
|
|
120
|
+
},
|
|
121
|
+
"large-visible-label-count": {
|
|
122
|
+
target: "graph",
|
|
123
|
+
title: "Reduce visible labels",
|
|
124
|
+
description: "Filter, collapse, or shorten visible labels before rendering dense static graph output."
|
|
125
|
+
},
|
|
126
|
+
"high-route-complexity": {
|
|
127
|
+
target: "layout",
|
|
128
|
+
title: "Simplify edge routes",
|
|
129
|
+
description: "Reduce routed edge complexity or split the graph into smaller focused views."
|
|
130
|
+
},
|
|
131
|
+
"large-metadata-summary": {
|
|
132
|
+
target: "graph",
|
|
133
|
+
title: "Reduce summary metadata",
|
|
134
|
+
description: "Keep only the most important summary metadata visible in static graph nodes."
|
|
135
|
+
},
|
|
136
|
+
"edge-label-overlap-risk": {
|
|
137
|
+
target: "layout",
|
|
138
|
+
title: "Separate edge labels",
|
|
139
|
+
description: "Adjust route hints, spacing, or label copy so estimated edge-label bounds do not overlap."
|
|
140
|
+
},
|
|
141
|
+
"tree-multiple-roots": {
|
|
142
|
+
target: "layout",
|
|
143
|
+
title: "Choose one tree root",
|
|
144
|
+
description: "Tree layout expects exactly one root or an explicit valid root ID."
|
|
145
|
+
},
|
|
146
|
+
"tree-multiple-parents": {
|
|
147
|
+
target: "layout",
|
|
148
|
+
title: "Use one parent per tree node",
|
|
149
|
+
description: "Tree layout requires each non-root node to have exactly one parent."
|
|
150
|
+
},
|
|
151
|
+
"tree-disconnected": {
|
|
152
|
+
target: "layout",
|
|
153
|
+
title: "Connect every tree node",
|
|
154
|
+
description: "Tree fallback and layout need every node reachable from the tree root."
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function diagnosticCategory(code) {
|
|
158
|
+
return graphDiagnosticCategoryByCode[code];
|
|
159
|
+
}
|
|
160
|
+
function diagnosticFix(code) {
|
|
161
|
+
return graphDiagnosticFixByCode[code];
|
|
162
|
+
}
|
|
4
163
|
function diagnostic(input) {
|
|
5
|
-
|
|
164
|
+
const details = { ...(input.details ?? {}) };
|
|
165
|
+
if (input.nodeId !== undefined) {
|
|
166
|
+
details.nodeId = input.nodeId;
|
|
167
|
+
}
|
|
168
|
+
if (input.edgeId !== undefined) {
|
|
169
|
+
details.edgeId = input.edgeId;
|
|
170
|
+
}
|
|
171
|
+
const hasDetails = Object.keys(details).length > 0;
|
|
172
|
+
const fix = input.fix ?? diagnosticFix(input.code);
|
|
173
|
+
return {
|
|
174
|
+
code: input.code,
|
|
175
|
+
severity: input.severity,
|
|
176
|
+
message: input.message,
|
|
177
|
+
...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
|
|
178
|
+
...(input.edgeId !== undefined ? { edgeId: input.edgeId } : {}),
|
|
179
|
+
category: input.category ?? diagnosticCategory(input.code),
|
|
180
|
+
...(hasDetails ? { details } : {}),
|
|
181
|
+
...(fix !== undefined ? { fix } : {})
|
|
182
|
+
};
|
|
6
183
|
}
|
|
7
184
|
export function validateGraph(graph, options = {}) {
|
|
8
185
|
const requireEdgeLabels = options.requireEdgeLabels ?? true;
|
|
186
|
+
const auditAccessibility = options.auditAccessibility ?? false;
|
|
9
187
|
const diagnostics = [];
|
|
188
|
+
const groupIds = new Set();
|
|
189
|
+
const groupMemberCounts = new Map();
|
|
10
190
|
const nodeIds = new Set();
|
|
11
191
|
const edgeIds = new Set();
|
|
192
|
+
const nodeLabelByValue = new Map();
|
|
193
|
+
const edgeLabelByValue = new Map();
|
|
12
194
|
if (isBlank(graph.title)) {
|
|
13
195
|
diagnostics.push(diagnostic({
|
|
14
196
|
code: "missing-graph-title",
|
|
@@ -16,6 +198,40 @@ export function validateGraph(graph, options = {}) {
|
|
|
16
198
|
message: "Graph title is required for accessible graph output."
|
|
17
199
|
}));
|
|
18
200
|
}
|
|
201
|
+
if ((options.requireGraphDescription === true || auditAccessibility) && isBlank(graph.description)) {
|
|
202
|
+
diagnostics.push(diagnostic({
|
|
203
|
+
code: "missing-graph-description",
|
|
204
|
+
severity: options.requireGraphDescription === true ? "warning" : "info",
|
|
205
|
+
message: "Graph description is recommended for complex graph output."
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
if (options.fallbackMode === "none") {
|
|
209
|
+
diagnostics.push(diagnostic({
|
|
210
|
+
code: "fallback-disabled",
|
|
211
|
+
severity: "warning",
|
|
212
|
+
message: "Fallback content is disabled; make sure equivalent graph details are available elsewhere."
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
for (const group of graph.groups ?? []) {
|
|
216
|
+
if (groupIds.has(group.id)) {
|
|
217
|
+
diagnostics.push(diagnostic({
|
|
218
|
+
code: "duplicate-group-id",
|
|
219
|
+
severity: "error",
|
|
220
|
+
message: `Group ID "${group.id}" is used more than once.`,
|
|
221
|
+
details: { groupId: group.id }
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
groupIds.add(group.id);
|
|
225
|
+
groupMemberCounts.set(group.id, 0);
|
|
226
|
+
if (isBlank(group.label)) {
|
|
227
|
+
diagnostics.push(diagnostic({
|
|
228
|
+
code: "missing-group-label",
|
|
229
|
+
severity: "error",
|
|
230
|
+
message: `Group "${group.id}" is missing a label.`,
|
|
231
|
+
details: { groupId: group.id }
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
19
235
|
for (const node of graph.nodes) {
|
|
20
236
|
if (nodeIds.has(node.id)) {
|
|
21
237
|
diagnostics.push(diagnostic({
|
|
@@ -26,6 +242,20 @@ export function validateGraph(graph, options = {}) {
|
|
|
26
242
|
}));
|
|
27
243
|
}
|
|
28
244
|
nodeIds.add(node.id);
|
|
245
|
+
if (node.groupId && groupIds.size > 0) {
|
|
246
|
+
if (groupIds.has(node.groupId)) {
|
|
247
|
+
groupMemberCounts.set(node.groupId, (groupMemberCounts.get(node.groupId) ?? 0) + 1);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
diagnostics.push(diagnostic({
|
|
251
|
+
code: "unknown-node-group",
|
|
252
|
+
severity: "error",
|
|
253
|
+
message: `Node "${node.id}" references unknown group "${node.groupId}".`,
|
|
254
|
+
nodeId: node.id,
|
|
255
|
+
details: { groupId: node.groupId }
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
29
259
|
if (isBlank(node.label)) {
|
|
30
260
|
diagnostics.push(diagnostic({
|
|
31
261
|
code: "missing-node-label",
|
|
@@ -34,6 +264,43 @@ export function validateGraph(graph, options = {}) {
|
|
|
34
264
|
nodeId: node.id
|
|
35
265
|
}));
|
|
36
266
|
}
|
|
267
|
+
if (auditAccessibility &&
|
|
268
|
+
isBlank(node.summary) &&
|
|
269
|
+
isBlank(node.ariaDescription) &&
|
|
270
|
+
isBlank(node.accessibility?.description)) {
|
|
271
|
+
diagnostics.push(diagnostic({
|
|
272
|
+
code: "missing-node-description",
|
|
273
|
+
severity: "info",
|
|
274
|
+
message: `Node "${node.id}" has no summary or accessibility description.`,
|
|
275
|
+
nodeId: node.id
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
const nodeLabel = node.label.trim();
|
|
279
|
+
if (options.warnDuplicateLabels === true && nodeLabel.length > 0) {
|
|
280
|
+
const existingNodeId = nodeLabelByValue.get(nodeLabel);
|
|
281
|
+
if (existingNodeId && existingNodeId !== node.id) {
|
|
282
|
+
diagnostics.push(diagnostic({
|
|
283
|
+
code: "duplicate-node-label",
|
|
284
|
+
severity: "warning",
|
|
285
|
+
message: `Node label "${nodeLabel}" is used by more than one node.`,
|
|
286
|
+
nodeId: node.id
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
nodeLabelByValue.set(nodeLabel, node.id);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const group of graph.groups ?? []) {
|
|
295
|
+
if ((groupMemberCounts.get(group.id) ?? 0) > 0 || (group.position && group.size)) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
diagnostics.push(diagnostic({
|
|
299
|
+
code: "empty-group",
|
|
300
|
+
severity: "warning",
|
|
301
|
+
message: `Group "${group.id}" has no member nodes or explicit bounds.`,
|
|
302
|
+
details: { groupId: group.id }
|
|
303
|
+
}));
|
|
37
304
|
}
|
|
38
305
|
for (const edge of graph.edges) {
|
|
39
306
|
if (edgeIds.has(edge.id)) {
|
|
@@ -71,6 +338,29 @@ export function validateGraph(graph, options = {}) {
|
|
|
71
338
|
edgeId: edge.id
|
|
72
339
|
}));
|
|
73
340
|
}
|
|
341
|
+
if (auditAccessibility && isBlank(edge.summary) && isBlank(edge.accessibility?.description)) {
|
|
342
|
+
diagnostics.push(diagnostic({
|
|
343
|
+
code: "missing-edge-description",
|
|
344
|
+
severity: "info",
|
|
345
|
+
message: `Edge "${edge.id}" has no summary or accessibility description.`,
|
|
346
|
+
edgeId: edge.id
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
const edgeLabel = (edge.label ?? edge.ariaLabel ?? "").trim();
|
|
350
|
+
if (options.warnDuplicateLabels === true && edgeLabel.length > 0) {
|
|
351
|
+
const existingEdgeId = edgeLabelByValue.get(edgeLabel);
|
|
352
|
+
if (existingEdgeId && existingEdgeId !== edge.id) {
|
|
353
|
+
diagnostics.push(diagnostic({
|
|
354
|
+
code: "duplicate-edge-label",
|
|
355
|
+
severity: "warning",
|
|
356
|
+
message: `Edge label "${edgeLabel}" is used by more than one edge.`,
|
|
357
|
+
edgeId: edge.id
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
edgeLabelByValue.set(edgeLabel, edge.id);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
74
364
|
if (edge.source === edge.target && options.allowSelfLoops !== true) {
|
|
75
365
|
diagnostics.push(diagnostic({
|
|
76
366
|
code: "self-loop",
|
|
@@ -86,6 +376,65 @@ export function validateGraph(graph, options = {}) {
|
|
|
86
376
|
export function hasGraphErrors(diagnostics) {
|
|
87
377
|
return diagnostics.some((entry) => entry.severity === "error");
|
|
88
378
|
}
|
|
379
|
+
function diagnosticGroupValue(diagnosticEntry, groupBy) {
|
|
380
|
+
if (groupBy === "category") {
|
|
381
|
+
return diagnosticEntry.category ?? diagnosticCategory(diagnosticEntry.code);
|
|
382
|
+
}
|
|
383
|
+
return diagnosticEntry[groupBy];
|
|
384
|
+
}
|
|
385
|
+
export function countGraphDiagnostics(diagnostics, groupBy) {
|
|
386
|
+
const counts = {};
|
|
387
|
+
for (const diagnosticEntry of diagnostics) {
|
|
388
|
+
const key = diagnosticGroupValue(diagnosticEntry, groupBy);
|
|
389
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
390
|
+
}
|
|
391
|
+
return counts;
|
|
392
|
+
}
|
|
393
|
+
export function groupGraphDiagnostics(diagnostics, groupBy) {
|
|
394
|
+
const groups = {};
|
|
395
|
+
for (const diagnosticEntry of diagnostics) {
|
|
396
|
+
const key = diagnosticGroupValue(diagnosticEntry, groupBy);
|
|
397
|
+
groups[key] = [...(groups[key] ?? []), diagnosticEntry];
|
|
398
|
+
}
|
|
399
|
+
return groups;
|
|
400
|
+
}
|
|
401
|
+
export function filterGraphDiagnostics(diagnostics, filter) {
|
|
402
|
+
return diagnostics.filter((diagnosticEntry) => {
|
|
403
|
+
if (filter.severity !== undefined && diagnosticEntry.severity !== filter.severity) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
if (filter.code !== undefined && diagnosticEntry.code !== filter.code) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if (filter.category !== undefined && diagnosticGroupValue(diagnosticEntry, "category") !== filter.category) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
if (filter.nodeId !== undefined && diagnosticEntry.nodeId !== filter.nodeId) {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (filter.edgeId !== undefined && diagnosticEntry.edgeId !== filter.edgeId) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
export function getGraphDiagnosticFixes(diagnostics) {
|
|
422
|
+
const fixes = [];
|
|
423
|
+
const seen = new Set();
|
|
424
|
+
for (const diagnosticEntry of diagnostics) {
|
|
425
|
+
const fix = diagnosticEntry.fix ?? diagnosticFix(diagnosticEntry.code);
|
|
426
|
+
if (!fix) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const key = `${fix.target ?? ""}:${fix.title}:${fix.description ?? ""}`;
|
|
430
|
+
if (seen.has(key)) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
seen.add(key);
|
|
434
|
+
fixes.push(fix);
|
|
435
|
+
}
|
|
436
|
+
return fixes;
|
|
437
|
+
}
|
|
89
438
|
export function formatGraphDiagnostic(diagnosticEntry) {
|
|
90
439
|
const edge = diagnosticEntry.edgeId ? ` edge=${diagnosticEntry.edgeId}` : "";
|
|
91
440
|
const node = diagnosticEntry.nodeId ? ` node=${diagnosticEntry.nodeId}` : "";
|
|
@@ -95,6 +444,10 @@ export function getNodeLabel(node) {
|
|
|
95
444
|
return node.label.trim();
|
|
96
445
|
}
|
|
97
446
|
export function getEdgeAccessibleLabel(edge, graph) {
|
|
447
|
+
const accessibilityLabel = edge.accessibility?.label;
|
|
448
|
+
if (accessibilityLabel !== undefined && accessibilityLabel.trim().length > 0) {
|
|
449
|
+
return accessibilityLabel;
|
|
450
|
+
}
|
|
98
451
|
const ariaLabel = edge.ariaLabel;
|
|
99
452
|
if (ariaLabel !== undefined && ariaLabel.trim().length > 0) {
|
|
100
453
|
return ariaLabel;
|
|
@@ -107,3 +460,226 @@ export function getEdgeAccessibleLabel(edge, graph) {
|
|
|
107
460
|
const target = graph.nodes.find((node) => node.id === edge.target)?.label ?? edge.target;
|
|
108
461
|
return `${source} connects to ${target}`;
|
|
109
462
|
}
|
|
463
|
+
function graphDensity(graph) {
|
|
464
|
+
const nodeCount = graph.nodes.length;
|
|
465
|
+
if (nodeCount <= 1) {
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
return graph.edges.length / (nodeCount * (nodeCount - 1));
|
|
469
|
+
}
|
|
470
|
+
function hasVisibleMetadataValue(item) {
|
|
471
|
+
if (item.value === null) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
if (typeof item.value === "string") {
|
|
475
|
+
return item.value.trim().length > 0;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
function countSummaryMetadata(metadata) {
|
|
480
|
+
return (metadata ?? []).filter((item) => item.visibility === "summary" && hasVisibleMetadataValue(item)).length;
|
|
481
|
+
}
|
|
482
|
+
function countNodeLabels(node) {
|
|
483
|
+
const labelLines = node.labelLines?.filter((line) => line.trim().length > 0);
|
|
484
|
+
if (labelLines && labelLines.length > 0) {
|
|
485
|
+
return labelLines.length;
|
|
486
|
+
}
|
|
487
|
+
return isBlank(node.label) ? 0 : 1;
|
|
488
|
+
}
|
|
489
|
+
function edgePoints(edge) {
|
|
490
|
+
const points = edge.points;
|
|
491
|
+
return Array.isArray(points) ? points : [];
|
|
492
|
+
}
|
|
493
|
+
function edgeRouteKind(edge) {
|
|
494
|
+
const routeKind = edge.routeKind;
|
|
495
|
+
return typeof routeKind === "string" ? routeKind : undefined;
|
|
496
|
+
}
|
|
497
|
+
export function estimateGraphRenderCost(graph) {
|
|
498
|
+
const groupCount = graph.groups?.length ?? 0;
|
|
499
|
+
const nodeCount = graph.nodes.length;
|
|
500
|
+
const edgeCount = graph.edges.length;
|
|
501
|
+
const fallbackRowCount = groupCount + nodeCount + edgeCount;
|
|
502
|
+
const visibleLabelCount = (graph.groups ?? []).filter((group) => !isBlank(group.label)).length +
|
|
503
|
+
graph.nodes.reduce((count, node) => count + countNodeLabels(node), 0) +
|
|
504
|
+
graph.edges.filter((edge) => !isBlank(edge.label)).length;
|
|
505
|
+
const metadataSummaryCount = (graph.groups ?? []).reduce((count, group) => count + countSummaryMetadata(group.metadata), 0) +
|
|
506
|
+
graph.nodes.reduce((count, node) => count + countSummaryMetadata(node.metadata), 0) +
|
|
507
|
+
graph.edges.reduce((count, edge) => count + countSummaryMetadata(edge.metadata), 0);
|
|
508
|
+
const routePointCount = graph.edges.reduce((count, edge) => count + edgePoints(edge).length, 0);
|
|
509
|
+
const routedEdgeCount = graph.edges.filter((edge) => edgePoints(edge).length > 0).length;
|
|
510
|
+
const complexRouteCount = graph.edges.filter((edge) => {
|
|
511
|
+
const points = edgePoints(edge);
|
|
512
|
+
const routeKind = edgeRouteKind(edge);
|
|
513
|
+
return points.length > 2 || (routeKind !== undefined && routeKind !== "straight");
|
|
514
|
+
}).length;
|
|
515
|
+
const roughSvgElementCount = 1 +
|
|
516
|
+
groupCount * 2 +
|
|
517
|
+
nodeCount * 3 +
|
|
518
|
+
edgeCount * 3 +
|
|
519
|
+
visibleLabelCount +
|
|
520
|
+
metadataSummaryCount +
|
|
521
|
+
routePointCount;
|
|
522
|
+
return {
|
|
523
|
+
complexRouteCount,
|
|
524
|
+
edgeCount,
|
|
525
|
+
fallbackRowCount,
|
|
526
|
+
groupCount,
|
|
527
|
+
metadataSummaryCount,
|
|
528
|
+
nodeCount,
|
|
529
|
+
roughSvgElementCount,
|
|
530
|
+
routedEdgeCount,
|
|
531
|
+
routePointCount,
|
|
532
|
+
visibleLabelCount
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function renderCostThresholdDiagnostic(input) {
|
|
536
|
+
if (input.threshold === undefined || input.count < input.threshold) {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
return diagnostic({
|
|
540
|
+
code: input.code,
|
|
541
|
+
severity: "warning",
|
|
542
|
+
message: input.message,
|
|
543
|
+
details: {
|
|
544
|
+
[input.detailKey]: input.count,
|
|
545
|
+
roughSvgElementCount: input.roughSvgElementCount,
|
|
546
|
+
threshold: input.threshold
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
export function validateGraphLayout(graph, options = {}) {
|
|
551
|
+
const diagnostics = [];
|
|
552
|
+
const warnLargeGraphAt = options.warnLargeGraphAt;
|
|
553
|
+
const warnDenseGraphAt = options.warnDenseGraphAt;
|
|
554
|
+
const renderCost = estimateGraphRenderCost(graph);
|
|
555
|
+
if (warnLargeGraphAt !== undefined && graph.nodes.length >= warnLargeGraphAt) {
|
|
556
|
+
diagnostics.push(diagnostic({
|
|
557
|
+
code: "large-graph",
|
|
558
|
+
severity: "warning",
|
|
559
|
+
message: `Graph has ${graph.nodes.length} nodes; consider simplifying static SVG output for large graphs.`
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
const density = graphDensity(graph);
|
|
563
|
+
if (warnDenseGraphAt !== undefined && density >= warnDenseGraphAt) {
|
|
564
|
+
diagnostics.push(diagnostic({
|
|
565
|
+
code: "dense-graph",
|
|
566
|
+
severity: "warning",
|
|
567
|
+
message: `Graph edge density ${density.toFixed(2)} may produce cluttered static layouts.`
|
|
568
|
+
}));
|
|
569
|
+
}
|
|
570
|
+
diagnostics.push(...[
|
|
571
|
+
renderCostThresholdDiagnostic({
|
|
572
|
+
code: "large-fallback-content",
|
|
573
|
+
count: renderCost.fallbackRowCount,
|
|
574
|
+
detailKey: "fallbackRowCount",
|
|
575
|
+
message: `Graph fallback content has ${renderCost.fallbackRowCount} rows; consider filtering, collapsing, or summarizing static output.`,
|
|
576
|
+
roughSvgElementCount: renderCost.roughSvgElementCount,
|
|
577
|
+
threshold: options.warnFallbackRowsAt
|
|
578
|
+
}),
|
|
579
|
+
renderCostThresholdDiagnostic({
|
|
580
|
+
code: "large-visible-label-count",
|
|
581
|
+
count: renderCost.visibleLabelCount,
|
|
582
|
+
detailKey: "visibleLabelCount",
|
|
583
|
+
message: `Graph has ${renderCost.visibleLabelCount} visible labels; consider focused views or shorter labels for static output.`,
|
|
584
|
+
roughSvgElementCount: renderCost.roughSvgElementCount,
|
|
585
|
+
threshold: options.warnVisibleLabelsAt
|
|
586
|
+
}),
|
|
587
|
+
renderCostThresholdDiagnostic({
|
|
588
|
+
code: "high-route-complexity",
|
|
589
|
+
count: renderCost.routePointCount,
|
|
590
|
+
detailKey: "routePointCount",
|
|
591
|
+
message: `Graph routes use ${renderCost.routePointCount} route points across ${renderCost.routedEdgeCount} routed edges.`,
|
|
592
|
+
roughSvgElementCount: renderCost.roughSvgElementCount,
|
|
593
|
+
threshold: options.warnRoutePointsAt
|
|
594
|
+
}),
|
|
595
|
+
renderCostThresholdDiagnostic({
|
|
596
|
+
code: "large-metadata-summary",
|
|
597
|
+
count: renderCost.metadataSummaryCount,
|
|
598
|
+
detailKey: "metadataSummaryCount",
|
|
599
|
+
message: `Graph has ${renderCost.metadataSummaryCount} visible summary metadata entries; keep static summaries focused.`,
|
|
600
|
+
roughSvgElementCount: renderCost.roughSvgElementCount,
|
|
601
|
+
threshold: options.warnMetadataSummariesAt
|
|
602
|
+
})
|
|
603
|
+
].filter((entry) => entry !== undefined));
|
|
604
|
+
if (options.warnDuplicatePositions === true) {
|
|
605
|
+
const seenPositions = new Map();
|
|
606
|
+
for (const node of graph.nodes) {
|
|
607
|
+
if (!node.position) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const key = `${node.position.x}:${node.position.y}`;
|
|
611
|
+
const existingNodeId = seenPositions.get(key);
|
|
612
|
+
if (existingNodeId && existingNodeId !== node.id) {
|
|
613
|
+
diagnostics.push(diagnostic({
|
|
614
|
+
code: "duplicate-node-position",
|
|
615
|
+
severity: "warning",
|
|
616
|
+
message: `Node "${node.id}" shares position (${node.position.x}, ${node.position.y}) with node "${existingNodeId}".`,
|
|
617
|
+
nodeId: node.id
|
|
618
|
+
}));
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
seenPositions.set(key, node.id);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (options.warnMissingSizes === true) {
|
|
626
|
+
for (const node of graph.nodes) {
|
|
627
|
+
if (!node.size) {
|
|
628
|
+
diagnostics.push(diagnostic({
|
|
629
|
+
code: "missing-node-size",
|
|
630
|
+
severity: "warning",
|
|
631
|
+
message: `Node "${node.id}" is missing an explicit size; layout will use the default node size.`,
|
|
632
|
+
nodeId: node.id
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (options.warnParallelEdges === true) {
|
|
638
|
+
const seenEdges = new Map();
|
|
639
|
+
for (const edge of graph.edges) {
|
|
640
|
+
const key = `${edge.source}->${edge.target}`;
|
|
641
|
+
const existingEdgeId = seenEdges.get(key);
|
|
642
|
+
if (existingEdgeId && existingEdgeId !== edge.id) {
|
|
643
|
+
diagnostics.push(diagnostic({
|
|
644
|
+
code: "parallel-edge",
|
|
645
|
+
severity: "warning",
|
|
646
|
+
message: `Edge "${edge.id}" is parallel to edge "${existingEdgeId}".`,
|
|
647
|
+
edgeId: edge.id
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
seenEdges.set(key, edge.id);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (options.warnSelfLoops === true) {
|
|
656
|
+
for (const edge of graph.edges) {
|
|
657
|
+
if (edge.source === edge.target) {
|
|
658
|
+
diagnostics.push(diagnostic({
|
|
659
|
+
code: "self-loop",
|
|
660
|
+
severity: "warning",
|
|
661
|
+
message: `Edge "${edge.id}" is a self-loop; static layouts render it with a simple loop route.`,
|
|
662
|
+
nodeId: edge.source,
|
|
663
|
+
edgeId: edge.id
|
|
664
|
+
}));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (options.warnEdgeLabelOverlaps === true) {
|
|
669
|
+
diagnostics.push(...getGraphEdgeLabelOverlapDiagnostics(graph));
|
|
670
|
+
}
|
|
671
|
+
return diagnostics;
|
|
672
|
+
}
|
|
673
|
+
export function createGraphHealthReport(graph, options = {}) {
|
|
674
|
+
const diagnostics = [...validateGraph(graph, options), ...validateGraphLayout(graph, options)];
|
|
675
|
+
return {
|
|
676
|
+
diagnostics,
|
|
677
|
+
errors: filterGraphDiagnostics(diagnostics, { severity: "error" }),
|
|
678
|
+
warnings: filterGraphDiagnostics(diagnostics, { severity: "warning" }),
|
|
679
|
+
info: filterGraphDiagnostics(diagnostics, { severity: "info" }),
|
|
680
|
+
countsByCode: countGraphDiagnostics(diagnostics, "code"),
|
|
681
|
+
countsByCategory: countGraphDiagnostics(diagnostics, "category"),
|
|
682
|
+
canRenderStaticSvg: !hasGraphErrors(diagnostics),
|
|
683
|
+
shouldRenderFallback: options.fallbackMode !== "none"
|
|
684
|
+
};
|
|
685
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/layout/dag.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { type ValidateGraphLayoutOptions } from "../diagnostics.js";
|
|
1
2
|
import type { GraphModel, GraphSize } from "../model.js";
|
|
2
|
-
import { type GraphLayoutResult } from "./types.js";
|
|
3
|
-
export interface DagLayeredLayoutOptions {
|
|
3
|
+
import { type GraphEdgesWithPointsOptions, type GraphLayoutResult } from "./types.js";
|
|
4
|
+
export interface DagLayeredLayoutOptions extends ValidateGraphLayoutOptions, GraphEdgesWithPointsOptions {
|
|
4
5
|
readonly direction?: "top-bottom" | "left-right";
|
|
5
6
|
readonly nodeSize?: GraphSize;
|
|
6
7
|
readonly nodeGap?: number;
|
|
8
|
+
readonly orderByNodeId?: Readonly<Record<string, number>>;
|
|
9
|
+
readonly rankByNodeId?: Readonly<Record<string, number>>;
|
|
7
10
|
readonly rankGap?: number;
|
|
8
11
|
readonly rankStrategy?: "longest-path" | "source-depth";
|
|
9
12
|
}
|