@zvk/graphs 0.1.2 → 0.1.4

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # @zvk/graphs Changelog
2
2
 
3
+ ## [0.1.4](https://github.com/brandon-schabel/zvk/compare/graphs-v0.1.3...graphs-v0.1.4) (2026-06-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * improve out-of-box DX and UI components ([#24](https://github.com/brandon-schabel/zvk/issues/24)) ([b01272a](https://github.com/brandon-schabel/zvk/commit/b01272a2b35099f5e1fa1dea5883687a0fb4c2e6))
9
+
10
+ ## [0.1.3](https://github.com/brandon-schabel/zvk/compare/graphs-v0.1.2...graphs-v0.1.3) (2026-06-20)
11
+
12
+
13
+ ### Features
14
+
15
+ * improve package release workflow and repo maintainability ([#22](https://github.com/brandon-schabel/zvk/issues/22)) ([a41cb66](https://github.com/brandon-schabel/zvk/commit/a41cb66554496f241c5a8e30b29a76c8b8ca92b3))
16
+
3
17
  ## [0.1.2](https://github.com/brandon-schabel/zvk/compare/graphs-v0.1.1...graphs-v0.1.2) (2026-06-19)
4
18
 
5
19
 
package/README.md CHANGED
@@ -456,3 +456,7 @@ after export validation.
456
456
  `bun run --filter @zvk/graphs verify:style-contract` checks the positive style
457
457
  surface: graph layers, public `.zvk-graphs*` selectors, graph token definitions,
458
458
  UI-token inheritance, state selectors, and forced-colors coverage.
459
+
460
+ ## Repo Skill
461
+
462
+ Use `.codex/skills/use-zvk-graphs/SKILL.md` when maintaining this package.
@@ -1,6 +1,7 @@
1
1
  import { createGraphIndex, detectGraphCycle, topologicalSortGraph } from "../algorithms.js";
2
2
  import { validateGraphLayout } from "../diagnostics.js";
3
3
  import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
4
+ import { resolveGraphNodeSize } from "./node-size.js";
4
5
  function rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction) {
5
6
  if (direction === "left-right") {
6
7
  return {
@@ -77,7 +78,7 @@ export function layoutDagGraph(graph, options = {}) {
77
78
  return {
78
79
  ...node,
79
80
  position: rankedPoint(rank, indexInRank, size, nodeGap, rankGap, direction),
80
- size: node.size ?? size
81
+ size: resolveGraphNodeSize(node, size)
81
82
  };
82
83
  });
83
84
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
@@ -1,5 +1,6 @@
1
1
  import { validateGraphLayout } from "../diagnostics.js";
2
2
  import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
3
+ import { resolveGraphNodeSize } from "./node-size.js";
3
4
  function gridPosition(index, size) {
4
5
  const columns = 4;
5
6
  return {
@@ -24,7 +25,7 @@ export function layoutManualGraph(graph, options = {}) {
24
25
  return {
25
26
  ...node,
26
27
  position: node.position ?? fallbackPosition,
27
- size: node.size ?? defaultNodeSize
28
+ size: resolveGraphNodeSize(node, defaultNodeSize)
28
29
  };
29
30
  });
30
31
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
@@ -0,0 +1,2 @@
1
+ import type { GraphNode, GraphSize } from "../model.js";
2
+ export declare function resolveGraphNodeSize(node: GraphNode, defaultSize: GraphSize): GraphSize;
@@ -0,0 +1,83 @@
1
+ const baseTextPaddingX = 20;
2
+ const labelCharacterWidth = 7;
3
+ const metadataCharacterWidth = 6;
4
+ const chromeCharacterWidth = 6;
5
+ const chromeGap = 40;
6
+ const topLaneHeight = 18;
7
+ const metadataLineHeight = 12;
8
+ const verticalPadding = 18;
9
+ const labelLineHeightByDensity = {
10
+ comfortable: 14,
11
+ compact: 12,
12
+ dense: 11
13
+ };
14
+ function summaryValue(value) {
15
+ if (value === null) {
16
+ return "None";
17
+ }
18
+ if (typeof value === "string") {
19
+ return value.trim().length > 0 ? value : undefined;
20
+ }
21
+ if (typeof value === "number" || typeof value === "boolean") {
22
+ return String(value);
23
+ }
24
+ return undefined;
25
+ }
26
+ function summaryMetadataRows(metadata) {
27
+ return (metadata ?? []).flatMap((item) => {
28
+ const value = summaryValue(item.value);
29
+ if (value === undefined || item.visibility !== "summary") {
30
+ return [];
31
+ }
32
+ return `${item.label}: ${value}`;
33
+ });
34
+ }
35
+ function nodeLabelLines(node) {
36
+ const lines = node.labelLines?.filter((line) => line.trim().length > 0);
37
+ return lines && lines.length > 0 ? lines : [node.label];
38
+ }
39
+ function nodeBadge(node) {
40
+ return node.presentation?.badge ?? node.presentation?.statusLabel;
41
+ }
42
+ function nodeGlyph(node) {
43
+ return node.presentation?.glyph ?? node.presentation?.statusGlyph;
44
+ }
45
+ function textWidth(text, characterWidth) {
46
+ return text.length * characterWidth + baseTextPaddingX;
47
+ }
48
+ function richNodeHeight(node, labelLineCount, summaryRowCount) {
49
+ const density = node.presentation?.density ?? "comfortable";
50
+ const labelLineHeight = labelLineHeightByDensity[density];
51
+ const hasTopLane = nodeGlyph(node) !== undefined || nodeBadge(node) !== undefined;
52
+ const topHeight = hasTopLane ? topLaneHeight : 0;
53
+ const labelHeight = labelLineCount * labelLineHeight;
54
+ const metadataHeight = summaryRowCount * metadataLineHeight;
55
+ const shapeReserve = node.presentation?.shape === "diamond" ? 12 : 0;
56
+ return topHeight + labelHeight + metadataHeight + verticalPadding + shapeReserve;
57
+ }
58
+ function richNodeWidth(node, labelLines, summaryRows) {
59
+ const glyph = nodeGlyph(node);
60
+ const badge = nodeBadge(node);
61
+ const labelWidth = Math.max(...labelLines.map((line) => textWidth(line, labelCharacterWidth)));
62
+ const metadataWidth = summaryRows.length > 0 ? Math.max(...summaryRows.map((row) => textWidth(row, metadataCharacterWidth))) : 0;
63
+ const chromeWidth = glyph || badge
64
+ ? (glyph?.length ?? 0) * chromeCharacterWidth + (badge?.length ?? 0) * chromeCharacterWidth + chromeGap
65
+ : 0;
66
+ const shapeReserve = node.presentation?.shape === "diamond" ? 24 : node.presentation?.shape === "circle" ? 12 : 0;
67
+ return Math.max(labelWidth, metadataWidth, chromeWidth) + shapeReserve;
68
+ }
69
+ export function resolveGraphNodeSize(node, defaultSize) {
70
+ if (node.size) {
71
+ return node.size;
72
+ }
73
+ const labelLines = nodeLabelLines(node);
74
+ const summaryRows = summaryMetadataRows(node.metadata);
75
+ const hasRichPresentation = labelLines.length > 1 || summaryRows.length > 0 || nodeGlyph(node) !== undefined || nodeBadge(node) !== undefined;
76
+ if (!hasRichPresentation) {
77
+ return defaultSize;
78
+ }
79
+ return {
80
+ width: Math.max(defaultSize.width, Math.ceil(richNodeWidth(node, labelLines, summaryRows))),
81
+ height: Math.max(defaultSize.height, Math.ceil(richNodeHeight(node, labelLines.length, summaryRows.length)))
82
+ };
83
+ }
@@ -1,6 +1,7 @@
1
1
  import { createGraphIndex, validateTreeGraph } from "../algorithms.js";
2
2
  import { validateGraphLayout } from "../diagnostics.js";
3
3
  import { defaultGraphNodeSize, edgesWithPoints, getGraphEdgeLabelOverlapDiagnostics } from "./types.js";
4
+ import { resolveGraphNodeSize } from "./node-size.js";
4
5
  function orientPoint(depth, breadth, size, levelGap, direction) {
5
6
  if (direction === "left-right") {
6
7
  return {
@@ -57,7 +58,7 @@ export function layoutTreeGraph(graph, options = {}) {
57
58
  return {
58
59
  ...node,
59
60
  position: positions.get(node.id) ?? orientPoint(0, index * (size.width + siblingGap), size, levelGap, direction),
60
- size: node.size ?? size
61
+ size: resolveGraphNodeSize(node, size)
61
62
  };
62
63
  });
63
64
  const nodeById = new Map(nodes.map((node) => [node.id, node]));
@@ -78,6 +78,8 @@ export interface GraphBoundsOptions {
78
78
  }
79
79
  export interface GraphGroupBoundsOptions {
80
80
  readonly padding?: number;
81
+ readonly labelGap?: number;
82
+ readonly labelHeight?: number;
81
83
  }
82
84
  export interface DerivedGraphGroupBounds {
83
85
  readonly groupId: GraphId;
@@ -38,6 +38,54 @@ function parallelOffset(options) {
38
38
  }
39
39
  return (parallelIndex - (parallelCount - 1) / 2) * parallelGap;
40
40
  }
41
+ function pointInsideNodeBounds(point, node) {
42
+ return (point.x >= node.position.x &&
43
+ point.x <= node.position.x + node.size.width &&
44
+ point.y >= node.position.y &&
45
+ point.y <= node.position.y + node.size.height);
46
+ }
47
+ function clipRoutePointToNodeBounds(node, from, to) {
48
+ if (!pointInsideNodeBounds(from, node)) {
49
+ return from;
50
+ }
51
+ const dx = to.x - from.x;
52
+ const dy = to.y - from.y;
53
+ if (dx === 0 && dy === 0) {
54
+ return from;
55
+ }
56
+ const minX = node.position.x;
57
+ const maxX = node.position.x + node.size.width;
58
+ const minY = node.position.y;
59
+ const maxY = node.position.y + node.size.height;
60
+ const candidates = [];
61
+ if (dx > 0) {
62
+ candidates.push((maxX - from.x) / dx);
63
+ }
64
+ else if (dx < 0) {
65
+ candidates.push((minX - from.x) / dx);
66
+ }
67
+ if (dy > 0) {
68
+ candidates.push((maxY - from.y) / dy);
69
+ }
70
+ else if (dy < 0) {
71
+ candidates.push((minY - from.y) / dy);
72
+ }
73
+ const routeT = candidates
74
+ .filter((candidate) => candidate > 0 && candidate <= 1)
75
+ .sort((left, right) => left - right)
76
+ .find((candidate) => {
77
+ const x = from.x + dx * candidate;
78
+ const y = from.y + dy * candidate;
79
+ return x >= minX - 0.0001 && x <= maxX + 0.0001 && y >= minY - 0.0001 && y <= maxY + 0.0001;
80
+ });
81
+ if (routeT === undefined) {
82
+ return from;
83
+ }
84
+ return {
85
+ x: normalizeNumber(from.x + dx * routeT),
86
+ y: normalizeNumber(from.y + dy * routeT)
87
+ };
88
+ }
41
89
  function labelSide(edge, labelPosition) {
42
90
  const start = edge.points[0];
43
91
  const end = edge.points.at(-1);
@@ -107,6 +155,14 @@ function paddedLabelBounds(bounds, padding) {
107
155
  height: bounds.height + padding * 2
108
156
  };
109
157
  }
158
+ function nodeLabelClearanceBounds(node, padding = 4) {
159
+ return {
160
+ x: node.position.x - padding,
161
+ y: node.position.y - padding,
162
+ width: node.size.width + padding * 2,
163
+ height: node.size.height + padding * 2
164
+ };
165
+ }
110
166
  function labelBoundsOverlap(first, second) {
111
167
  const width = Math.min(first.x + first.width, second.x + second.width) - Math.max(first.x, second.x);
112
168
  const height = Math.min(first.y + first.height, second.y + second.height) - Math.max(first.y, second.y);
@@ -176,8 +232,88 @@ export function getGraphEdgeLabelOverlapDiagnostics(graph, options = {}) {
176
232
  }
177
233
  return diagnostics;
178
234
  }
235
+ function labelOverlapsEndpointNodes(bounds, source, target) {
236
+ return {
237
+ source: labelBoundsOverlap(bounds, nodeLabelClearanceBounds(source)) !== undefined,
238
+ target: labelBoundsOverlap(bounds, nodeLabelClearanceBounds(target)) !== undefined
239
+ };
240
+ }
241
+ function shiftPoint(point, direction, distance) {
242
+ return {
243
+ x: normalizeNumber(point.x + direction.x * distance),
244
+ y: normalizeNumber(point.y + direction.y * distance)
245
+ };
246
+ }
247
+ function normalizeDirection(start, end) {
248
+ const dx = end.x - start.x;
249
+ const dy = end.y - start.y;
250
+ const length = Math.hypot(dx, dy);
251
+ if (length === 0) {
252
+ return undefined;
253
+ }
254
+ return {
255
+ x: dx / length,
256
+ y: dy / length
257
+ };
258
+ }
259
+ function labelBoundsAtPosition(edge, route, labelPosition) {
260
+ return deriveGraphEdgeLabelAnchor({
261
+ ...edge,
262
+ points: route.points,
263
+ labelPosition,
264
+ routeKind: route.kind
265
+ })?.bounds;
266
+ }
267
+ function clearEndpointLabelPosition(edge, route, source, target) {
268
+ if (!edge.label || !route.labelPosition) {
269
+ return route.labelPosition;
270
+ }
271
+ const initialBounds = labelBoundsAtPosition(edge, route, route.labelPosition);
272
+ if (!initialBounds) {
273
+ return route.labelPosition;
274
+ }
275
+ const initialOverlap = labelOverlapsEndpointNodes(initialBounds, source, target);
276
+ if (!initialOverlap.source && !initialOverlap.target) {
277
+ return route.labelPosition;
278
+ }
279
+ const start = route.points[0];
280
+ const end = route.points.at(-1);
281
+ if (!start || !end) {
282
+ return route.labelPosition;
283
+ }
284
+ const alongRoute = normalizeDirection(start, end);
285
+ if (!alongRoute) {
286
+ return route.labelPosition;
287
+ }
288
+ const awayFromSource = alongRoute;
289
+ const awayFromTarget = { x: -alongRoute.x, y: -alongRoute.y };
290
+ const normal = { x: -alongRoute.y, y: alongRoute.x };
291
+ const oppositeNormal = { x: alongRoute.y, y: -alongRoute.x };
292
+ const primaryDirections = initialOverlap.source && !initialOverlap.target
293
+ ? [awayFromSource, normal, oppositeNormal, awayFromTarget]
294
+ : initialOverlap.target && !initialOverlap.source
295
+ ? [awayFromTarget, normal, oppositeNormal, awayFromSource]
296
+ : [normal, oppositeNormal, awayFromSource, awayFromTarget];
297
+ for (const distance of [8, 16, 24, 32, 40, 48, 64, 80, 96]) {
298
+ for (const direction of primaryDirections) {
299
+ const candidate = shiftPoint(route.labelPosition, direction, distance);
300
+ const bounds = labelBoundsAtPosition(edge, route, candidate);
301
+ if (!bounds) {
302
+ continue;
303
+ }
304
+ const overlap = labelOverlapsEndpointNodes(bounds, source, target);
305
+ if (!overlap.source && !overlap.target) {
306
+ return candidate;
307
+ }
308
+ }
309
+ }
310
+ return route.labelPosition;
311
+ }
179
312
  export function deriveGraphGroupBounds(graph, options = {}) {
180
313
  const padding = options.padding ?? 16;
314
+ const labelHeight = options.labelHeight ?? 18;
315
+ const labelGap = options.labelGap ?? 8;
316
+ const topPadding = Math.max(padding, labelHeight + labelGap);
181
317
  const groups = graph.groups ?? [];
182
318
  return groups.flatMap((group) => {
183
319
  const nodes = graph.nodes.filter((node) => node.groupId === group.id);
@@ -202,8 +338,8 @@ export function deriveGraphGroupBounds(graph, options = {}) {
202
338
  {
203
339
  groupId: group.id,
204
340
  nodeIds: nodes.map((node) => node.id),
205
- position: { x: minX - padding, y: minY - padding },
206
- size: { width: maxX - minX + padding * 2, height: maxY - minY + padding * 2 }
341
+ position: { x: minX - padding, y: minY - topPadding },
342
+ size: { width: maxX - minX + padding * 2, height: maxY - minY + topPadding + padding }
207
343
  }
208
344
  ];
209
345
  });
@@ -227,8 +363,10 @@ export function routeGraphEdge(source, target, options = {}) {
227
363
  const labelPosition = points[2];
228
364
  return labelPosition ? { kind, points, labelPosition } : { kind, points };
229
365
  }
230
- const routedStart = { x: start.x + offset.x, y: start.y + offset.y };
231
- const routedEnd = { x: end.x + offset.x, y: end.y + offset.y };
366
+ const offsetStart = { x: start.x + offset.x, y: start.y + offset.y };
367
+ const offsetEnd = { x: end.x + offset.x, y: end.y + offset.y };
368
+ const routedStart = clipRoutePointToNodeBounds(source, offsetStart, offsetEnd);
369
+ const routedEnd = clipRoutePointToNodeBounds(target, offsetEnd, offsetStart);
232
370
  if (kind === "curve") {
233
371
  const middle = midpoint(routedStart, routedEnd);
234
372
  const controlOffset = offsetPoint(routedStart, routedEnd, options.parallelGap ?? 24);
@@ -274,6 +412,21 @@ export function getGraphBounds(graph, options = {}) {
274
412
  ];
275
413
  });
276
414
  const edgePoints = includeEdges ? graph.edges.flatMap((edge) => edge.points) : [];
415
+ const edgeLabelPoints = includeEdges
416
+ ? graph.edges.flatMap((edge) => {
417
+ const labelAnchor = deriveGraphEdgeLabelAnchor(edge);
418
+ if (!labelAnchor) {
419
+ return [];
420
+ }
421
+ return [
422
+ { x: labelAnchor.bounds.x, y: labelAnchor.bounds.y },
423
+ {
424
+ x: labelAnchor.bounds.x + labelAnchor.bounds.width,
425
+ y: labelAnchor.bounds.y + labelAnchor.bounds.height
426
+ }
427
+ ];
428
+ })
429
+ : [];
277
430
  const groupPoints = deriveGraphGroupBounds(graph).flatMap((group) => {
278
431
  return [
279
432
  group.position,
@@ -283,7 +436,7 @@ export function getGraphBounds(graph, options = {}) {
283
436
  }
284
437
  ];
285
438
  });
286
- const points = [...nodePoints, ...edgePoints, ...groupPoints];
439
+ const points = [...nodePoints, ...edgePoints, ...edgeLabelPoints, ...groupPoints];
287
440
  if (points.length === 0) {
288
441
  return {
289
442
  x: -padding,
@@ -316,8 +469,9 @@ export function edgeWithPoints(edge, nodes, options = {}) {
316
469
  if (!route) {
317
470
  return { ...edge, points: [] };
318
471
  }
319
- return route.labelPosition
320
- ? { ...edge, points: route.points, labelPosition: route.labelPosition, routeKind: route.kind }
472
+ const labelPosition = source && target ? clearEndpointLabelPosition(edge, route, source, target) : route.labelPosition;
473
+ return labelPosition
474
+ ? { ...edge, points: route.points, labelPosition, routeKind: route.kind }
321
475
  : { ...edge, points: route.points, routeKind: route.kind };
322
476
  }
323
477
  export function edgesWithPoints(edges, nodes, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvk/graphs",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Accessible SVG-first node and edge graph utilities, deterministic layouts, and static React renderers for ZVK applications.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -70,6 +70,7 @@
70
70
  "test": "vitest run",
71
71
  "test:ssr": "vitest run tests/ssr --environment node",
72
72
  "test:exports": "vitest run tests/exports --environment node",
73
+ "test:docs-examples": "bun run build && vitest run tests/docs-examples --environment node",
73
74
  "test:types": "tsd",
74
75
  "validate:exports": "bun run scripts/validate-exports.mjs",
75
76
  "validate:source-policy": "bun run scripts/validate-source-policy.mjs",