@carlonicora/nextjs-jsonapi 1.99.0 → 1.100.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.
@@ -16,10 +16,25 @@ export interface LayeredLayoutPosition {
16
16
  y: number;
17
17
  }
18
18
 
19
+ export interface FitLayeredLayoutOptions extends LayeredLayoutOptions {
20
+ targetAspectRatio: number;
21
+ maxIterations?: number;
22
+ tolerance?: number;
23
+ }
24
+
19
25
  const DEFAULT_RANKDIR: LayeredRankDir = "LR";
20
26
  const DEFAULT_NODESEP = 50;
21
27
  const DEFAULT_RANKSEP = 120;
22
28
 
29
+ const MIN_NODESEP = 20;
30
+ const MAX_NODESEP = 400;
31
+ const MIN_RANKSEP = 40;
32
+ const MAX_RANKSEP = 600;
33
+ const MIN_FACTOR = 0.25;
34
+ const MAX_FACTOR = 4;
35
+ const DEFAULT_TOLERANCE = 0.05;
36
+ const DEFAULT_MAX_ITERATIONS = 4;
37
+
23
38
  const TITLE_PX_PER_CHAR_16 = 8;
24
39
  const NAME_PX_PER_CHAR_12 = 6.5;
25
40
  const NAME_PX_PER_CHAR_16_BOLD = 8;
@@ -40,20 +55,22 @@ function linkEndpointId(end: D3Link["source"] | D3Link["target"]): string {
40
55
  return typeof end === "string" ? end : end.id;
41
56
  }
42
57
 
58
+ function clamp(value: number, min: number, max: number): number {
59
+ return Math.min(Math.max(value, min), max);
60
+ }
61
+
62
+ interface DagreRunResult {
63
+ positions: Map<string, LayeredLayoutPosition>;
64
+ bbox: { width: number; height: number };
65
+ }
66
+
43
67
  /**
44
- * Compute a layered DAG layout using dagre. Pure function: no DOM, no React,
45
- * no d3 globals. Returns a Map of node.id -> { x, y } in graph coordinates.
46
- *
47
- * Returns null if dagre.layout throws (e.g. an unexpected cycle). Callers
48
- * should fall back to their previous layout in that case.
68
+ * Run dagre once for the given nodes/links/opts and return both the
69
+ * computed positions and the bounding box of the laid-out graph.
70
+ * Internal helper — callers should use `computeLayeredLayout` or
71
+ * `fitLayeredLayoutToAspectRatio`.
49
72
  */
50
- export function computeLayeredLayout(
51
- nodes: D3Node[],
52
- links: D3Link[],
53
- opts: LayeredLayoutOptions,
54
- ): Map<string, LayeredLayoutPosition> | null {
55
- if (nodes.length === 0) return new Map();
56
-
73
+ function runDagreOnce(nodes: D3Node[], links: D3Link[], opts: LayeredLayoutOptions): DagreRunResult | null {
57
74
  const rankdir = opts.rankdir ?? DEFAULT_RANKDIR;
58
75
  const nodesep = opts.nodesep ?? DEFAULT_NODESEP;
59
76
  const ranksep = opts.ranksep ?? DEFAULT_RANKSEP;
@@ -86,11 +103,107 @@ export function computeLayeredLayout(
86
103
  }
87
104
 
88
105
  const positions = new Map<string, LayeredLayoutPosition>();
106
+ let xMin = Infinity;
107
+ let xMax = -Infinity;
108
+ let yMin = Infinity;
109
+ let yMax = -Infinity;
110
+
89
111
  for (const node of nodes) {
90
112
  const laid = g.node(node.id);
91
113
  if (laid && Number.isFinite(laid.x) && Number.isFinite(laid.y)) {
92
114
  positions.set(node.id, { x: laid.x, y: laid.y });
115
+ const halfW = (laid.width ?? opts.minNodeWidth) / 2;
116
+ const halfH = (laid.height ?? opts.minNodeHeight) / 2;
117
+ xMin = Math.min(xMin, laid.x - halfW);
118
+ xMax = Math.max(xMax, laid.x + halfW);
119
+ yMin = Math.min(yMin, laid.y - halfH);
120
+ yMax = Math.max(yMax, laid.y + halfH);
93
121
  }
94
122
  }
95
- return positions;
123
+
124
+ const bbox =
125
+ positions.size === 0
126
+ ? { width: 0, height: 0 }
127
+ : { width: Math.max(0, xMax - xMin), height: Math.max(0, yMax - yMin) };
128
+
129
+ return { positions, bbox };
130
+ }
131
+
132
+ /**
133
+ * Compute a layered DAG layout using dagre. Pure function: no DOM, no React,
134
+ * no d3 globals. Returns a Map of node.id -> { x, y } in graph coordinates.
135
+ *
136
+ * Returns null if dagre.layout throws (e.g. an unexpected cycle). Callers
137
+ * should fall back to their previous layout in that case.
138
+ */
139
+ export function computeLayeredLayout(
140
+ nodes: D3Node[],
141
+ links: D3Link[],
142
+ opts: LayeredLayoutOptions,
143
+ ): Map<string, LayeredLayoutPosition> | null {
144
+ if (nodes.length === 0) return new Map();
145
+ const result = runDagreOnce(nodes, links, opts);
146
+ return result ? result.positions : null;
147
+ }
148
+
149
+ /**
150
+ * Compute a layered layout, then iteratively re-run dagre with adjusted
151
+ * `nodesep`/`ranksep` until the bounding-box aspect ratio is within
152
+ * `tolerance` of `targetAspectRatio` (or `maxIterations` is reached).
153
+ *
154
+ * Degenerate cases (empty graph, single-rank graph where one axis has
155
+ * zero extent, missing target ratio) skip fitting and return the
156
+ * single-pass result.
157
+ */
158
+ export function fitLayeredLayoutToAspectRatio(
159
+ nodes: D3Node[],
160
+ links: D3Link[],
161
+ opts: FitLayeredLayoutOptions,
162
+ ): Map<string, LayeredLayoutPosition> | null {
163
+ if (nodes.length === 0) return new Map();
164
+ if (!Number.isFinite(opts.targetAspectRatio) || opts.targetAspectRatio <= 0) {
165
+ return computeLayeredLayout(nodes, links, opts);
166
+ }
167
+
168
+ const maxIterations = opts.maxIterations ?? DEFAULT_MAX_ITERATIONS;
169
+ const tolerance = opts.tolerance ?? DEFAULT_TOLERANCE;
170
+ const rankdir = opts.rankdir ?? DEFAULT_RANKDIR;
171
+ const isHorizontalFlow = rankdir === "LR" || rankdir === "RL";
172
+
173
+ let nodesep = opts.nodesep ?? DEFAULT_NODESEP;
174
+ let ranksep = opts.ranksep ?? DEFAULT_RANKSEP;
175
+
176
+ let best: DagreRunResult | null = null;
177
+
178
+ for (let i = 0; i < maxIterations; i++) {
179
+ const result = runDagreOnce(nodes, links, {
180
+ ...opts,
181
+ nodesep,
182
+ ranksep,
183
+ });
184
+ if (!result) return best ? best.positions : null;
185
+ best = result;
186
+
187
+ if (result.positions.size <= 1) return result.positions;
188
+
189
+ const { width, height } = result.bbox;
190
+ if (width === 0 || height === 0) return result.positions;
191
+
192
+ const currentAspect = width / height;
193
+ const ratio = opts.targetAspectRatio / currentAspect;
194
+
195
+ if (Math.abs(ratio - 1) < tolerance) return result.positions;
196
+
197
+ const factor = clamp(Math.sqrt(ratio), MIN_FACTOR, MAX_FACTOR);
198
+
199
+ if (isHorizontalFlow) {
200
+ ranksep = clamp(ranksep * factor, MIN_RANKSEP, MAX_RANKSEP);
201
+ nodesep = clamp(nodesep / factor, MIN_NODESEP, MAX_NODESEP);
202
+ } else {
203
+ ranksep = clamp(ranksep / factor, MIN_RANKSEP, MAX_RANKSEP);
204
+ nodesep = clamp(nodesep * factor, MIN_NODESEP, MAX_NODESEP);
205
+ }
206
+ }
207
+
208
+ return best ? best.positions : null;
96
209
  }
@@ -37,6 +37,8 @@ export * from "./usePushNotifications";
37
37
  export * from "./useSocket";
38
38
  export {
39
39
  computeLayeredLayout,
40
+ fitLayeredLayoutToAspectRatio,
41
+ type FitLayeredLayoutOptions,
40
42
  type LayeredLayoutOptions,
41
43
  type LayeredLayoutPosition,
42
44
  type LayeredRankDir,
@@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react";
5
5
  import { useCallback, useEffect, useMemo, useRef } from "react";
6
6
  import { renderToStaticMarkup } from "react-dom/server";
7
7
  import { D3Link, D3Node } from "../interfaces";
8
- import { computeLayeredLayout, type LayeredRankDir } from "./computeLayeredLayout";
8
+ import { computeLayeredLayout, fitLayeredLayoutToAspectRatio, type LayeredRankDir } from "./computeLayeredLayout";
9
9
 
10
10
  /**
11
11
  * Custom hook for D3 graph visualization with larger circles and more interactive features
@@ -22,6 +22,7 @@ export function useCustomD3Graph(
22
22
  rankdir?: LayeredRankDir;
23
23
  nodesep?: number;
24
24
  ranksep?: number;
25
+ fitContainer?: boolean;
25
26
  };
26
27
  },
27
28
  loadingNodeIds?: Set<string>,
@@ -244,13 +245,23 @@ export function useCustomD3Graph(
244
245
 
245
246
  if (layoutMode === "layered") {
246
247
  const layeredOpts = options?.layered ?? {};
247
- const positions = computeLayeredLayout(visibleNodes, visibleLinks, {
248
- rankdir: layeredOpts.rankdir ?? "LR",
249
- nodesep: layeredOpts.nodesep,
250
- ranksep: layeredOpts.ranksep,
251
- minNodeWidth: nodeRadius * 2,
252
- minNodeHeight: nodeRadius * 2,
253
- });
248
+ const useFit = layeredOpts.fitContainer === true && width > 0 && height > 0;
249
+ const positions = useFit
250
+ ? fitLayeredLayoutToAspectRatio(visibleNodes, visibleLinks, {
251
+ rankdir: layeredOpts.rankdir ?? "LR",
252
+ nodesep: layeredOpts.nodesep,
253
+ ranksep: layeredOpts.ranksep,
254
+ minNodeWidth: nodeRadius * 2,
255
+ minNodeHeight: nodeRadius * 2,
256
+ targetAspectRatio: width / height,
257
+ })
258
+ : computeLayeredLayout(visibleNodes, visibleLinks, {
259
+ rankdir: layeredOpts.rankdir ?? "LR",
260
+ nodesep: layeredOpts.nodesep,
261
+ ranksep: layeredOpts.ranksep,
262
+ minNodeWidth: nodeRadius * 2,
263
+ minNodeHeight: nodeRadius * 2,
264
+ });
254
265
 
255
266
  if (positions) {
256
267
  visibleNodes.forEach((node) => {
@@ -785,6 +796,7 @@ export function useCustomD3Graph(
785
796
  options?.layered?.rankdir,
786
797
  options?.layered?.nodesep,
787
798
  options?.layered?.ranksep,
799
+ options?.layered?.fitContainer,
788
800
  loadingNodeIds,
789
801
  onNodeClick,
790
802
  ]);