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