@carlonicora/nextjs-jsonapi 1.98.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.
@@ -5,6 +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, fitLayeredLayoutToAspectRatio, type LayeredRankDir } from "./computeLayeredLayout";
8
9
 
9
10
  /**
10
11
  * Custom hook for D3 graph visualization with larger circles and more interactive features
@@ -14,7 +15,16 @@ export function useCustomD3Graph(
14
15
  links: D3Link[],
15
16
  onNodeClick: (nodeId: string) => void,
16
17
  visibleNodeIds?: Set<string>,
17
- options?: { directed?: boolean },
18
+ options?: {
19
+ directed?: boolean;
20
+ layout?: "radial" | "layered";
21
+ layered?: {
22
+ rankdir?: LayeredRankDir;
23
+ nodesep?: number;
24
+ ranksep?: number;
25
+ fitContainer?: boolean;
26
+ };
27
+ },
18
28
  loadingNodeIds?: Set<string>,
19
29
  containerKey?: string | number,
20
30
  ) {
@@ -230,168 +240,238 @@ export function useCustomD3Graph(
230
240
  .on("wheel.zoom", null)
231
241
  .on("dblclick.zoom", null);
232
242
 
233
- const childDistanceFromRoot = Math.min(width, height) * 0.4;
234
- const grandchildDistanceFromChild = nodeRadius * 10;
235
-
236
- const centralNodeId = nodes[0].id;
243
+ const layoutMode = options?.layout ?? "radial";
244
+ let layeredPositionsApplied = false;
245
+
246
+ if (layoutMode === "layered") {
247
+ const layeredOpts = options?.layered ?? {};
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
+ });
237
265
 
238
- const nodeHierarchy = new Map<
239
- string,
240
- {
241
- depth: number;
242
- parent: string | null;
243
- children: string[];
244
- angle?: number;
245
- x?: number;
246
- y?: number;
266
+ if (positions) {
267
+ visibleNodes.forEach((node) => {
268
+ const saved = nodePositionsRef.current.get(node.id);
269
+ if (saved) {
270
+ node.fx = saved.x;
271
+ node.fy = saved.y;
272
+ node.x = saved.x;
273
+ node.y = saved.y;
274
+ return;
275
+ }
276
+ const pos = positions.get(node.id);
277
+ if (pos) {
278
+ node.fx = pos.x;
279
+ node.fy = pos.y;
280
+ node.x = pos.x;
281
+ node.y = pos.y;
282
+ nodePositionsRef.current.set(node.id, { x: pos.x, y: pos.y });
283
+ }
284
+ });
285
+ // d3.forceLink normally mutates link.source/link.target from string
286
+ // IDs to D3Node references when the simulation initializes. The
287
+ // layered branch skips the simulation, so we resolve those refs
288
+ // ourselves — otherwise the downstream link rendering reads
289
+ // `(d.source as D3Node).x` against a string and draws every line
290
+ // at (0,0)→(0,0).
291
+ const nodeById = new Map<string, D3Node>();
292
+ visibleNodes.forEach((n) => nodeById.set(n.id, n));
293
+ visibleLinks.forEach((link) => {
294
+ if (typeof link.source === "string") {
295
+ const src = nodeById.get(link.source);
296
+ if (src) link.source = src;
297
+ }
298
+ if (typeof link.target === "string") {
299
+ const tgt = nodeById.get(link.target);
300
+ if (tgt) link.target = tgt;
301
+ }
302
+ });
303
+ layeredPositionsApplied = true;
304
+ } else {
305
+ console.warn("[useCustomD3Graph] Layered layout failed; falling back to radial.");
247
306
  }
248
- >();
307
+ }
249
308
 
250
- nodeHierarchy.set(centralNodeId, {
251
- depth: 0,
252
- parent: null,
253
- children: [],
254
- });
309
+ let simulation: d3.Simulation<D3Node, D3Link> | null = null;
255
310
 
256
- visibleLinks.forEach((link) => {
257
- const sourceId = typeof link.source === "string" ? link.source : link.source.id;
258
- const targetId = typeof link.target === "string" ? link.target : link.target.id;
311
+ if (!layeredPositionsApplied) {
312
+ const childDistanceFromRoot = Math.min(width, height) * 0.4;
313
+ const grandchildDistanceFromChild = nodeRadius * 10;
259
314
 
260
- if (sourceId === centralNodeId) {
261
- nodeHierarchy.set(targetId, { depth: 1, parent: centralNodeId, children: [] });
262
- const rootNode = nodeHierarchy.get(centralNodeId);
263
- if (rootNode) {
264
- rootNode.children.push(targetId);
315
+ const centralNodeId = nodes[0].id;
316
+
317
+ const nodeHierarchy = new Map<
318
+ string,
319
+ {
320
+ depth: number;
321
+ parent: string | null;
322
+ children: string[];
323
+ angle?: number;
324
+ x?: number;
325
+ y?: number;
265
326
  }
266
- }
267
- });
327
+ >();
268
328
 
269
- visibleLinks.forEach((link) => {
270
- const sourceId = typeof link.source === "string" ? link.source : link.source.id;
271
- const targetId = typeof link.target === "string" ? link.target : link.target.id;
329
+ nodeHierarchy.set(centralNodeId, {
330
+ depth: 0,
331
+ parent: null,
332
+ children: [],
333
+ });
272
334
 
273
- const sourceNode = nodeHierarchy.get(sourceId);
274
- if (sourceNode && sourceNode.depth === 1 && !nodeHierarchy.has(targetId)) {
275
- nodeHierarchy.set(targetId, { depth: 2, parent: sourceId, children: [] });
276
- sourceNode.children.push(targetId);
277
- }
278
- });
335
+ visibleLinks.forEach((link) => {
336
+ const sourceId = typeof link.source === "string" ? link.source : link.source.id;
337
+ const targetId = typeof link.target === "string" ? link.target : link.target.id;
279
338
 
280
- const rootChildren = nodeHierarchy.get(centralNodeId)?.children || [];
339
+ if (sourceId === centralNodeId) {
340
+ nodeHierarchy.set(targetId, { depth: 1, parent: centralNodeId, children: [] });
341
+ const rootNode = nodeHierarchy.get(centralNodeId);
342
+ if (rootNode) {
343
+ rootNode.children.push(targetId);
344
+ }
345
+ }
346
+ });
281
347
 
282
- const childAngleStep = (2 * Math.PI) / Math.max(rootChildren.length, 1);
348
+ visibleLinks.forEach((link) => {
349
+ const sourceId = typeof link.source === "string" ? link.source : link.source.id;
350
+ const targetId = typeof link.target === "string" ? link.target : link.target.id;
283
351
 
284
- rootChildren.forEach((childId, index) => {
285
- const childNode = nodeHierarchy.get(childId);
286
- if (childNode) {
287
- const angle = index * childAngleStep;
288
- childNode.angle = angle;
289
- childNode.x = width / 2 + childDistanceFromRoot * Math.cos(angle);
290
- childNode.y = height / 2 + childDistanceFromRoot * Math.sin(angle);
291
- }
292
- });
352
+ const sourceNode = nodeHierarchy.get(sourceId);
353
+ if (sourceNode && sourceNode.depth === 1 && !nodeHierarchy.has(targetId)) {
354
+ nodeHierarchy.set(targetId, { depth: 2, parent: sourceId, children: [] });
355
+ sourceNode.children.push(targetId);
356
+ }
357
+ });
293
358
 
294
- for (const [_nodeId, node] of nodeHierarchy.entries()) {
295
- if (node.depth === 1 && node.angle !== undefined && node.x !== undefined && node.y !== undefined) {
296
- const childAngle = node.angle;
297
- const childX = node.x;
298
- const childY = node.y;
299
- const grandchildren = node.children;
300
-
301
- if (grandchildren.length === 0) continue;
302
-
303
- const dirX = childX - width / 2;
304
- const dirY = childY - height / 2;
305
- const dirLength = Math.sqrt(dirX * dirX + dirY * dirY);
306
-
307
- const normDirX = dirX / dirLength;
308
- const normDirY = dirY / dirLength;
309
-
310
- if (grandchildren.length === 1) {
311
- const grandchildId = grandchildren[0];
312
- const grandchildNode = nodeHierarchy.get(grandchildId);
313
- if (grandchildNode) {
314
- grandchildNode.x = childX + normDirX * grandchildDistanceFromChild;
315
- grandchildNode.y = childY + normDirY * grandchildDistanceFromChild;
316
- grandchildNode.angle = childAngle;
317
- }
318
- } else {
319
- // Multiple grandchildren - arrange in semicircular arc
320
- const numChildren = grandchildren.length;
359
+ const rootChildren = nodeHierarchy.get(centralNodeId)?.children || [];
321
360
 
322
- // Dynamic arc span: scale from 60° (2 children) to 180° (7+ children)
323
- const minArc = Math.PI / 3; // 60 degrees
324
- const maxArc = Math.PI; // 180 degrees
325
- const arcProgress = Math.min(1, (numChildren - 2) / 5);
326
- const arcSpan = minArc + arcProgress * (maxArc - minArc);
361
+ const childAngleStep = (2 * Math.PI) / Math.max(rootChildren.length, 1);
327
362
 
328
- // Calculate starting angle (center the arc around the radial direction)
329
- const startAngle = childAngle - arcSpan / 2;
363
+ rootChildren.forEach((childId, index) => {
364
+ const childNode = nodeHierarchy.get(childId);
365
+ if (childNode) {
366
+ const angle = index * childAngleStep;
367
+ childNode.angle = angle;
368
+ childNode.x = width / 2 + childDistanceFromRoot * Math.cos(angle);
369
+ childNode.y = height / 2 + childDistanceFromRoot * Math.sin(angle);
370
+ }
371
+ });
330
372
 
331
- grandchildren.forEach((grandchildId, index) => {
332
- const grandchildNode = nodeHierarchy.get(grandchildId);
333
- if (!grandchildNode) return;
373
+ for (const [_nodeId, node] of nodeHierarchy.entries()) {
374
+ if (node.depth === 1 && node.angle !== undefined && node.x !== undefined && node.y !== undefined) {
375
+ const childAngle = node.angle;
376
+ const childX = node.x;
377
+ const childY = node.y;
378
+ const grandchildren = node.children;
334
379
 
335
- // Calculate angle for this child
336
- const angleOffset = numChildren > 1 ? (index / (numChildren - 1)) * arcSpan : 0;
337
- const angle = startAngle + angleOffset;
380
+ if (grandchildren.length === 0) continue;
338
381
 
339
- // Position at constant radius from parent
340
- grandchildNode.x = childX + grandchildDistanceFromChild * Math.cos(angle);
341
- grandchildNode.y = childY + grandchildDistanceFromChild * Math.sin(angle);
342
- grandchildNode.angle = angle;
343
- });
344
- }
345
- }
346
- }
382
+ const dirX = childX - width / 2;
383
+ const dirY = childY - height / 2;
384
+ const dirLength = Math.sqrt(dirX * dirX + dirY * dirY);
347
385
 
348
- visibleNodes.forEach((node) => {
349
- const savedPosition = nodePositionsRef.current.get(node.id);
386
+ const normDirX = dirX / dirLength;
387
+ const normDirY = dirY / dirLength;
350
388
 
351
- if (savedPosition) {
352
- node.fx = savedPosition.x;
353
- node.fy = savedPosition.y;
354
- } else {
355
- const hierarchyNode = nodeHierarchy.get(node.id);
356
- if (hierarchyNode && hierarchyNode.x !== undefined && hierarchyNode.y !== undefined) {
357
- node.fx = hierarchyNode.x;
358
- node.fy = hierarchyNode.y;
359
- // Save the calculated position so it persists across re-renders
360
- nodePositionsRef.current.set(node.id, { x: hierarchyNode.x, y: hierarchyNode.y });
361
- } else if (node.id === centralNodeId) {
362
- node.fx = width / 2;
363
- node.fy = height / 2;
364
- // Save the center position
365
- nodePositionsRef.current.set(node.id, { x: width / 2, y: height / 2 });
389
+ if (grandchildren.length === 1) {
390
+ const grandchildId = grandchildren[0];
391
+ const grandchildNode = nodeHierarchy.get(grandchildId);
392
+ if (grandchildNode) {
393
+ grandchildNode.x = childX + normDirX * grandchildDistanceFromChild;
394
+ grandchildNode.y = childY + normDirY * grandchildDistanceFromChild;
395
+ grandchildNode.angle = childAngle;
396
+ }
397
+ } else {
398
+ // Multiple grandchildren - arrange in semicircular arc
399
+ const numChildren = grandchildren.length;
400
+
401
+ // Dynamic arc span: scale from 60° (2 children) to 180° (7+ children)
402
+ const minArc = Math.PI / 3; // 60 degrees
403
+ const maxArc = Math.PI; // 180 degrees
404
+ const arcProgress = Math.min(1, (numChildren - 2) / 5);
405
+ const arcSpan = minArc + arcProgress * (maxArc - minArc);
406
+
407
+ // Calculate starting angle (center the arc around the radial direction)
408
+ const startAngle = childAngle - arcSpan / 2;
409
+
410
+ grandchildren.forEach((grandchildId, index) => {
411
+ const grandchildNode = nodeHierarchy.get(grandchildId);
412
+ if (!grandchildNode) return;
413
+
414
+ // Calculate angle for this child
415
+ const angleOffset = numChildren > 1 ? (index / (numChildren - 1)) * arcSpan : 0;
416
+ const angle = startAngle + angleOffset;
417
+
418
+ // Position at constant radius from parent
419
+ grandchildNode.x = childX + grandchildDistanceFromChild * Math.cos(angle);
420
+ grandchildNode.y = childY + grandchildDistanceFromChild * Math.sin(angle);
421
+ grandchildNode.angle = angle;
422
+ });
423
+ }
366
424
  }
367
425
  }
368
- });
369
426
 
370
- const simulation = d3
371
- .forceSimulation<D3Node>(visibleNodes)
372
- .force(
373
- "link",
374
- d3
375
- .forceLink<D3Node, D3Link>(visibleLinks)
376
- .id((d) => d.id)
377
- .distance(nodeRadius * 3)
378
- .strength(0.1),
379
- )
380
- .force("charge", d3.forceManyBody().strength(-500).distanceMax(300))
381
- .force("collision", d3.forceCollide().radius(nodeRadius * 1.2))
382
- .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1));
427
+ visibleNodes.forEach((node) => {
428
+ const savedPosition = nodePositionsRef.current.get(node.id);
383
429
 
384
- simulation.stop();
385
- for (let i = 0; i < 100; i++) {
386
- simulation.tick();
387
- }
430
+ if (savedPosition) {
431
+ node.fx = savedPosition.x;
432
+ node.fy = savedPosition.y;
433
+ } else {
434
+ const hierarchyNode = nodeHierarchy.get(node.id);
435
+ if (hierarchyNode && hierarchyNode.x !== undefined && hierarchyNode.y !== undefined) {
436
+ node.fx = hierarchyNode.x;
437
+ node.fy = hierarchyNode.y;
438
+ // Save the calculated position so it persists across re-renders
439
+ nodePositionsRef.current.set(node.id, { x: hierarchyNode.x, y: hierarchyNode.y });
440
+ } else if (node.id === centralNodeId) {
441
+ node.fx = width / 2;
442
+ node.fy = height / 2;
443
+ // Save the center position
444
+ nodePositionsRef.current.set(node.id, { x: width / 2, y: height / 2 });
445
+ }
446
+ }
447
+ });
388
448
 
389
- visibleNodes.forEach((node) => {
390
- if (node.fx === undefined) {
391
- node.fx = node.x;
392
- node.fy = node.y;
449
+ simulation = d3
450
+ .forceSimulation<D3Node>(visibleNodes)
451
+ .force(
452
+ "link",
453
+ d3
454
+ .forceLink<D3Node, D3Link>(visibleLinks)
455
+ .id((d) => d.id)
456
+ .distance(nodeRadius * 3)
457
+ .strength(0.1),
458
+ )
459
+ .force("charge", d3.forceManyBody().strength(-500).distanceMax(300))
460
+ .force("collision", d3.forceCollide().radius(nodeRadius * 1.2))
461
+ .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1));
462
+
463
+ simulation.stop();
464
+ for (let i = 0; i < 100; i++) {
465
+ simulation.tick();
393
466
  }
394
- });
467
+
468
+ visibleNodes.forEach((node) => {
469
+ if (node.fx === undefined) {
470
+ node.fx = node.x;
471
+ node.fy = node.y;
472
+ }
473
+ });
474
+ } // end if (!layeredPositionsApplied)
395
475
 
396
476
  // When directed, stop the line at the target node boundary so the
397
477
  // arrowhead tip sits just outside the circle rather than inside it.
@@ -704,9 +784,22 @@ export function useCustomD3Graph(
704
784
  });
705
785
 
706
786
  return () => {
707
- simulation.stop();
787
+ simulation?.stop();
708
788
  };
709
- }, [nodes, links, colorScale, visibleNodeIds, options?.directed, loadingNodeIds, onNodeClick]);
789
+ }, [
790
+ nodes,
791
+ links,
792
+ colorScale,
793
+ visibleNodeIds,
794
+ options?.directed,
795
+ options?.layout,
796
+ options?.layered?.rankdir,
797
+ options?.layered?.nodesep,
798
+ options?.layered?.ranksep,
799
+ options?.layered?.fitContainer,
800
+ loadingNodeIds,
801
+ onNodeClick,
802
+ ]);
710
803
 
711
804
  const zoomIn = useCallback(() => {
712
805
  if (!svgRef.current || !zoomBehaviorRef.current) return;