@zvk/graphs 0.1.1 → 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.
@@ -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
- return input;
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
@@ -2,4 +2,5 @@ export * from "./algorithms.js";
2
2
  export * from "./diagnostics.js";
3
3
  export * from "./layout/index.js";
4
4
  export * from "./model.js";
5
+ export * from "./serialization.js";
5
6
  export * from "./svg.js";
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ export * from "./algorithms.js";
2
2
  export * from "./diagnostics.js";
3
3
  export * from "./layout/index.js";
4
4
  export * from "./model.js";
5
+ export * from "./serialization.js";
5
6
  export * from "./svg.js";
@@ -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
  }