@carlonicora/nextjs-jsonapi 1.99.0 → 1.100.1

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,28 @@ 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
+
10
+ // Heuristic estimate of a node label's rendered width, used to size the
11
+ // force-collision radius so that long titles like "Investigative Journalism
12
+ // Class" do not overlap neighbouring circles. Mirrors the constants in
13
+ // computeLayeredLayout.ts — the small duplication is intentional, the
14
+ // heuristic may evolve per-layout-context.
15
+ const TITLE_PX_PER_CHAR_16 = 8;
16
+ const NAME_PX_PER_CHAR_12 = 6.5;
17
+ const NAME_PX_PER_CHAR_16_BOLD = 8;
18
+ const SUBTITLE_PX_PER_CHAR_11 = 6;
19
+ const LABEL_PADDING_PX = 16;
20
+
21
+ function estimateLabelWidth(node: D3Node): number {
22
+ if (node.subtitle) {
23
+ const titleWidth = (node.name?.length ?? 0) * TITLE_PX_PER_CHAR_16;
24
+ const subtitleWidth = node.subtitle.length * SUBTITLE_PX_PER_CHAR_11;
25
+ return Math.max(titleWidth, subtitleWidth) + LABEL_PADDING_PX;
26
+ }
27
+ const perChar = node.bold ? NAME_PX_PER_CHAR_16_BOLD : NAME_PX_PER_CHAR_12;
28
+ return (node.name?.length ?? 0) * perChar + LABEL_PADDING_PX;
29
+ }
9
30
 
10
31
  /**
11
32
  * Custom hook for D3 graph visualization with larger circles and more interactive features
@@ -22,6 +43,7 @@ export function useCustomD3Graph(
22
43
  rankdir?: LayeredRankDir;
23
44
  nodesep?: number;
24
45
  ranksep?: number;
46
+ fitContainer?: boolean;
25
47
  };
26
48
  },
27
49
  loadingNodeIds?: Set<string>,
@@ -244,13 +266,23 @@ export function useCustomD3Graph(
244
266
 
245
267
  if (layoutMode === "layered") {
246
268
  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
- });
269
+ const useFit = layeredOpts.fitContainer === true && width > 0 && height > 0;
270
+ const positions = useFit
271
+ ? fitLayeredLayoutToAspectRatio(visibleNodes, visibleLinks, {
272
+ rankdir: layeredOpts.rankdir ?? "LR",
273
+ nodesep: layeredOpts.nodesep,
274
+ ranksep: layeredOpts.ranksep,
275
+ minNodeWidth: nodeRadius * 2,
276
+ minNodeHeight: nodeRadius * 2,
277
+ targetAspectRatio: width / height,
278
+ })
279
+ : computeLayeredLayout(visibleNodes, visibleLinks, {
280
+ rankdir: layeredOpts.rankdir ?? "LR",
281
+ nodesep: layeredOpts.nodesep,
282
+ ranksep: layeredOpts.ranksep,
283
+ minNodeWidth: nodeRadius * 2,
284
+ minNodeHeight: nodeRadius * 2,
285
+ });
254
286
 
255
287
  if (positions) {
256
288
  visibleNodes.forEach((node) => {
@@ -445,12 +477,15 @@ export function useCustomD3Graph(
445
477
  .distance(nodeRadius * 3)
446
478
  .strength(0.1),
447
479
  )
448
- .force("charge", d3.forceManyBody().strength(-500).distanceMax(300))
449
- .force("collision", d3.forceCollide().radius(nodeRadius * 1.2))
480
+ .force("charge", d3.forceManyBody().strength(-2500).distanceMax(800))
481
+ .force(
482
+ "collision",
483
+ d3.forceCollide<D3Node>().radius((d) => Math.max(nodeRadius * 1.1, estimateLabelWidth(d) / 2 + 8)),
484
+ )
450
485
  .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1));
451
486
 
452
487
  simulation.stop();
453
- for (let i = 0; i < 100; i++) {
488
+ for (let i = 0; i < 300; i++) {
454
489
  simulation.tick();
455
490
  }
456
491
 
@@ -785,6 +820,7 @@ export function useCustomD3Graph(
785
820
  options?.layered?.rankdir,
786
821
  options?.layered?.nodesep,
787
822
  options?.layered?.ranksep,
823
+ options?.layered?.fitContainer,
788
824
  loadingNodeIds,
789
825
  onNodeClick,
790
826
  ]);