@carlonicora/nextjs-jsonapi 1.97.2 → 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.
Files changed (37) hide show
  1. package/dist/{BlockNoteEditor-LRJUTHTW.mjs → BlockNoteEditor-IBV3KBQM.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-OOZGXU6E.js → BlockNoteEditor-LYJUF5N4.js} +9 -9
  3. package/dist/{BlockNoteEditor-OOZGXU6E.js.map → BlockNoteEditor-LYJUF5N4.js.map} +1 -1
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-XYGK26YG.mjs → chunk-CDNVUON3.mjs} +374 -148
  7. package/dist/chunk-CDNVUON3.mjs.map +1 -0
  8. package/dist/{chunk-HCOX3PKM.js → chunk-TRTKIQUB.js} +608 -382
  9. package/dist/chunk-TRTKIQUB.js.map +1 -0
  10. package/dist/client/index.d.mts +32 -3
  11. package/dist/client/index.d.ts +32 -3
  12. package/dist/client/index.js +4 -2
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/index.mjs +3 -1
  15. package/dist/components/index.d.mts +5 -2
  16. package/dist/components/index.d.ts +5 -2
  17. package/dist/components/index.js +2 -2
  18. package/dist/components/index.mjs +1 -1
  19. package/dist/{content.fields-hzZvhlBd.d.mts → content.fields-xH3TGvVk.d.mts} +2 -0
  20. package/dist/{content.fields-hzZvhlBd.d.ts → content.fields-xH3TGvVk.d.ts} +2 -0
  21. package/dist/contexts/index.js +2 -2
  22. package/dist/contexts/index.mjs +1 -1
  23. package/dist/core/index.d.mts +1 -1
  24. package/dist/core/index.d.ts +1 -1
  25. package/dist/index.d.mts +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/package.json +3 -1
  28. package/src/components/forms/FormDate.tsx +27 -3
  29. package/src/components/forms/FormDateTime.tsx +40 -8
  30. package/src/hooks/__tests__/computeLayeredLayout.spec.ts +152 -0
  31. package/src/hooks/computeLayeredLayout.ts +96 -0
  32. package/src/hooks/index.ts +6 -0
  33. package/src/hooks/useCustomD3Graph.tsx +310 -148
  34. package/src/interfaces/d3.node.interface.ts +2 -0
  35. package/dist/chunk-HCOX3PKM.js.map +0 -1
  36. package/dist/chunk-XYGK26YG.mjs.map +0 -1
  37. /package/dist/{BlockNoteEditor-LRJUTHTW.mjs.map → BlockNoteEditor-IBV3KBQM.mjs.map} +0 -0
@@ -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,6 +15,15 @@ export function useCustomD3Graph(
14
15
  links: D3Link[],
15
16
  onNodeClick: (nodeId: string) => void,
16
17
  visibleNodeIds?: Set<string>,
18
+ options?: {
19
+ directed?: boolean;
20
+ layout?: "radial" | "layered";
21
+ layered?: {
22
+ rankdir?: LayeredRankDir;
23
+ nodesep?: number;
24
+ ranksep?: number;
25
+ };
26
+ },
17
27
  loadingNodeIds?: Set<string>,
18
28
  containerKey?: string | number,
19
29
  ) {
@@ -154,6 +164,9 @@ export function useCustomD3Graph(
154
164
 
155
165
  const getNodeColor = useCallback(
156
166
  (node: D3Node) => {
167
+ if (node.color) {
168
+ return node.washedOut ? washOutColor(node.color) : node.color;
169
+ }
157
170
  const baseColor = colorScale.get(node.instanceType) || "gray";
158
171
  if (node.washedOut) {
159
172
  return washOutColor(baseColor);
@@ -190,6 +203,26 @@ export function useCustomD3Graph(
190
203
 
191
204
  const graphGroup = svg.append("g").attr("class", "graph-content");
192
205
 
206
+ const nodeRadius = 40;
207
+ const directed = options?.directed === true;
208
+
209
+ if (directed) {
210
+ const defs = svg.append("defs");
211
+ defs
212
+ .append("marker")
213
+ .attr("id", "narr8-arrow")
214
+ .attr("viewBox", "-10 -10 20 20")
215
+ .attr("markerWidth", 14)
216
+ .attr("markerHeight", 14)
217
+ .attr("markerUnits", "userSpaceOnUse")
218
+ .attr("orient", "auto")
219
+ .attr("refX", 0)
220
+ .attr("refY", 0)
221
+ .append("path")
222
+ .attr("d", "M-10,-10 L0,0 L-10,10 z")
223
+ .attr("fill", "#999");
224
+ }
225
+
193
226
  const zoom = d3
194
227
  .zoom<SVGSVGElement, unknown>()
195
228
  .scaleExtent([0.1, 4])
@@ -206,170 +239,245 @@ export function useCustomD3Graph(
206
239
  .on("wheel.zoom", null)
207
240
  .on("dblclick.zoom", null);
208
241
 
209
- const nodeRadius = 40;
210
-
211
- const childDistanceFromRoot = Math.min(width, height) * 0.4;
212
- const grandchildDistanceFromChild = nodeRadius * 10;
213
-
214
- 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
+ });
215
254
 
216
- const nodeHierarchy = new Map<
217
- string,
218
- {
219
- depth: number;
220
- parent: string | null;
221
- children: string[];
222
- angle?: number;
223
- x?: number;
224
- 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.");
225
295
  }
226
- >();
296
+ }
227
297
 
228
- nodeHierarchy.set(centralNodeId, {
229
- depth: 0,
230
- parent: null,
231
- children: [],
232
- });
298
+ let simulation: d3.Simulation<D3Node, D3Link> | null = null;
233
299
 
234
- visibleLinks.forEach((link) => {
235
- const sourceId = typeof link.source === "string" ? link.source : link.source.id;
236
- 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;
237
305
 
238
- if (sourceId === centralNodeId) {
239
- nodeHierarchy.set(targetId, { depth: 1, parent: centralNodeId, children: [] });
240
- const rootNode = nodeHierarchy.get(centralNodeId);
241
- if (rootNode) {
242
- 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;
243
315
  }
244
- }
245
- });
316
+ >();
246
317
 
247
- visibleLinks.forEach((link) => {
248
- const sourceId = typeof link.source === "string" ? link.source : link.source.id;
249
- const targetId = typeof link.target === "string" ? link.target : link.target.id;
318
+ nodeHierarchy.set(centralNodeId, {
319
+ depth: 0,
320
+ parent: null,
321
+ children: [],
322
+ });
250
323
 
251
- const sourceNode = nodeHierarchy.get(sourceId);
252
- if (sourceNode && sourceNode.depth === 1 && !nodeHierarchy.has(targetId)) {
253
- nodeHierarchy.set(targetId, { depth: 2, parent: sourceId, children: [] });
254
- sourceNode.children.push(targetId);
255
- }
256
- });
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;
257
327
 
258
- 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
+ });
259
336
 
260
- 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;
261
340
 
262
- rootChildren.forEach((childId, index) => {
263
- const childNode = nodeHierarchy.get(childId);
264
- if (childNode) {
265
- const angle = index * childAngleStep;
266
- childNode.angle = angle;
267
- childNode.x = width / 2 + childDistanceFromRoot * Math.cos(angle);
268
- childNode.y = height / 2 + childDistanceFromRoot * Math.sin(angle);
269
- }
270
- });
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
+ });
271
347
 
272
- for (const [_nodeId, node] of nodeHierarchy.entries()) {
273
- if (node.depth === 1 && node.angle !== undefined && node.x !== undefined && node.y !== undefined) {
274
- const childAngle = node.angle;
275
- const childX = node.x;
276
- const childY = node.y;
277
- const grandchildren = node.children;
278
-
279
- if (grandchildren.length === 0) continue;
280
-
281
- const dirX = childX - width / 2;
282
- const dirY = childY - height / 2;
283
- const dirLength = Math.sqrt(dirX * dirX + dirY * dirY);
284
-
285
- const normDirX = dirX / dirLength;
286
- const normDirY = dirY / dirLength;
287
-
288
- if (grandchildren.length === 1) {
289
- const grandchildId = grandchildren[0];
290
- const grandchildNode = nodeHierarchy.get(grandchildId);
291
- if (grandchildNode) {
292
- grandchildNode.x = childX + normDirX * grandchildDistanceFromChild;
293
- grandchildNode.y = childY + normDirY * grandchildDistanceFromChild;
294
- grandchildNode.angle = childAngle;
295
- }
296
- } else {
297
- // Multiple grandchildren - arrange in semicircular arc
298
- const numChildren = grandchildren.length;
348
+ const rootChildren = nodeHierarchy.get(centralNodeId)?.children || [];
299
349
 
300
- // Dynamic arc span: scale from 60° (2 children) to 180° (7+ children)
301
- const minArc = Math.PI / 3; // 60 degrees
302
- const maxArc = Math.PI; // 180 degrees
303
- const arcProgress = Math.min(1, (numChildren - 2) / 5);
304
- const arcSpan = minArc + arcProgress * (maxArc - minArc);
350
+ const childAngleStep = (2 * Math.PI) / Math.max(rootChildren.length, 1);
305
351
 
306
- // Calculate starting angle (center the arc around the radial direction)
307
- 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
+ });
308
361
 
309
- grandchildren.forEach((grandchildId, index) => {
310
- const grandchildNode = nodeHierarchy.get(grandchildId);
311
- 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;
312
368
 
313
- // Calculate angle for this child
314
- const angleOffset = numChildren > 1 ? (index / (numChildren - 1)) * arcSpan : 0;
315
- const angle = startAngle + angleOffset;
369
+ if (grandchildren.length === 0) continue;
316
370
 
317
- // Position at constant radius from parent
318
- grandchildNode.x = childX + grandchildDistanceFromChild * Math.cos(angle);
319
- grandchildNode.y = childY + grandchildDistanceFromChild * Math.sin(angle);
320
- grandchildNode.angle = angle;
321
- });
322
- }
323
- }
324
- }
371
+ const dirX = childX - width / 2;
372
+ const dirY = childY - height / 2;
373
+ const dirLength = Math.sqrt(dirX * dirX + dirY * dirY);
325
374
 
326
- visibleNodes.forEach((node) => {
327
- const savedPosition = nodePositionsRef.current.get(node.id);
375
+ const normDirX = dirX / dirLength;
376
+ const normDirY = dirY / dirLength;
328
377
 
329
- if (savedPosition) {
330
- node.fx = savedPosition.x;
331
- node.fy = savedPosition.y;
332
- } else {
333
- const hierarchyNode = nodeHierarchy.get(node.id);
334
- if (hierarchyNode && hierarchyNode.x !== undefined && hierarchyNode.y !== undefined) {
335
- node.fx = hierarchyNode.x;
336
- node.fy = hierarchyNode.y;
337
- // Save the calculated position so it persists across re-renders
338
- nodePositionsRef.current.set(node.id, { x: hierarchyNode.x, y: hierarchyNode.y });
339
- } else if (node.id === centralNodeId) {
340
- node.fx = width / 2;
341
- node.fy = height / 2;
342
- // Save the center position
343
- 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
+ }
344
413
  }
345
414
  }
346
- });
347
415
 
348
- const simulation = d3
349
- .forceSimulation<D3Node>(visibleNodes)
350
- .force(
351
- "link",
352
- d3
353
- .forceLink<D3Node, D3Link>(visibleLinks)
354
- .id((d) => d.id)
355
- .distance(nodeRadius * 3)
356
- .strength(0.1),
357
- )
358
- .force("charge", d3.forceManyBody().strength(-500).distanceMax(300))
359
- .force("collision", d3.forceCollide().radius(nodeRadius * 1.2))
360
- .force("center", d3.forceCenter(width / 2, height / 2).strength(0.1));
416
+ visibleNodes.forEach((node) => {
417
+ const savedPosition = nodePositionsRef.current.get(node.id);
361
418
 
362
- simulation.stop();
363
- for (let i = 0; i < 100; i++) {
364
- simulation.tick();
365
- }
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
+ });
366
437
 
367
- visibleNodes.forEach((node) => {
368
- if (node.fx === undefined) {
369
- node.fx = node.x;
370
- 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();
371
455
  }
372
- });
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)
464
+
465
+ // When directed, stop the line at the target node boundary so the
466
+ // arrowhead tip sits just outside the circle rather than inside it.
467
+ const linkX2 = (sx: number, sy: number, tx: number, ty: number): number => {
468
+ if (!directed) return tx;
469
+ const dx = tx - sx;
470
+ const dy = ty - sy;
471
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
472
+ return tx - (dx / dist) * nodeRadius;
473
+ };
474
+ const linkY2 = (sx: number, sy: number, tx: number, ty: number): number => {
475
+ if (!directed) return ty;
476
+ const dx = tx - sx;
477
+ const dy = ty - sy;
478
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
479
+ return ty - (dy / dist) * nodeRadius;
480
+ };
373
481
 
374
482
  const link = graphGroup
375
483
  .append("g")
@@ -380,9 +488,18 @@ export function useCustomD3Graph(
380
488
  .join("line")
381
489
  .attr("x1", (d) => (d.source as D3Node).x || 0)
382
490
  .attr("y1", (d) => (d.source as D3Node).y || 0)
383
- .attr("x2", (d) => (d.target as D3Node).x || 0)
384
- .attr("y2", (d) => (d.target as D3Node).y || 0)
385
- .attr("stroke-width", 1.5);
491
+ .attr("x2", (d) => {
492
+ const s = d.source as D3Node;
493
+ const t = d.target as D3Node;
494
+ return linkX2(s.x || 0, s.y || 0, t.x || 0, t.y || 0);
495
+ })
496
+ .attr("y2", (d) => {
497
+ const s = d.source as D3Node;
498
+ const t = d.target as D3Node;
499
+ return linkY2(s.x || 0, s.y || 0, t.x || 0, t.y || 0);
500
+ })
501
+ .attr("stroke-width", 1.5)
502
+ .attr("marker-end", directed ? "url(#narr8-arrow)" : null);
386
503
 
387
504
  const node = graphGroup
388
505
  .append("g")
@@ -423,12 +540,22 @@ export function useCustomD3Graph(
423
540
  return source.fy || source.y || 0;
424
541
  })
425
542
  .attr("x2", (l) => {
543
+ const source = l.source as D3Node;
426
544
  const target = l.target as D3Node;
427
- return target.fx || target.x || 0;
545
+ const sx = source.fx || source.x || 0;
546
+ const sy = source.fy || source.y || 0;
547
+ const tx = target.fx || target.x || 0;
548
+ const ty = target.fy || target.y || 0;
549
+ return linkX2(sx, sy, tx, ty);
428
550
  })
429
551
  .attr("y2", (l) => {
552
+ const source = l.source as D3Node;
430
553
  const target = l.target as D3Node;
431
- return target.fy || target.y || 0;
554
+ const sx = source.fx || source.x || 0;
555
+ const sy = source.fy || source.y || 0;
556
+ const tx = target.fx || target.x || 0;
557
+ const ty = target.fy || target.y || 0;
558
+ return linkY2(sx, sy, tx, ty);
432
559
  });
433
560
  })
434
561
  .on("end", function (event, d) {
@@ -489,8 +616,13 @@ export function useCustomD3Graph(
489
616
  .attr("r", nodeRadius)
490
617
  .attr("filter", null);
491
618
 
492
- // Return text to normal size with smooth transform
493
- const normalOffset = nodeRadius + 5;
619
+ // Return text to normal size with smooth transform. Two-line labels
620
+ // (when `d.subtitle` is set) sit one line-gap higher than the single
621
+ // line case — restoring to the bare `nodeRadius + 5` offset would
622
+ // shift the title down by that gap (visibly jumping after the first
623
+ // hover). The 16 here matches the `lineGap` used when the two-line
624
+ // text is first rendered (see the `d.subtitle` branch below).
625
+ const normalOffset = nodeRadius + 5 + (d.subtitle ? 16 : 0);
494
626
  currentNode
495
627
  .select("text")
496
628
  .transition()
@@ -607,6 +739,24 @@ export function useCustomD3Graph(
607
739
  .attr("dy", index === 0 ? `${startY}em` : `${lineHeight}em`)
608
740
  .text(word);
609
741
  });
742
+ } else if (d.subtitle) {
743
+ // Two-line label: name (bigger, bolder) on line 1, subtitle
744
+ // (smaller, dimmed) on line 2. Used by the scene graph to show
745
+ // the HAPPENS_AT location name beneath the scene title. The
746
+ // whole text block is lifted above the circle so the lower line
747
+ // sits at the same baseline the single-line case uses.
748
+ const titleSize = 16;
749
+ const subtitleSize = 11;
750
+ const lineGap = titleSize; // px between baselines
751
+ textElement.attr("dy", -nodeRadius - 5 - lineGap).attr("fill", "currentColor");
752
+ textElement.append("tspan").attr("x", 0).attr("font-size", titleSize).attr("font-weight", 700).text(d.name);
753
+ textElement
754
+ .append("tspan")
755
+ .attr("x", 0)
756
+ .attr("dy", lineGap)
757
+ .attr("font-size", subtitleSize)
758
+ .attr("fill-opacity", 0.7)
759
+ .text(d.subtitle);
610
760
  } else {
611
761
  // Non-root nodes: single line text above the circle. The
612
762
  // optional `bold` flag lets the consumer highlight a focal
@@ -623,9 +773,21 @@ export function useCustomD3Graph(
623
773
  });
624
774
 
625
775
  return () => {
626
- simulation.stop();
776
+ simulation?.stop();
627
777
  };
628
- }, [nodes, links, colorScale, visibleNodeIds, 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
+ ]);
629
791
 
630
792
  const zoomIn = useCallback(() => {
631
793
  if (!svgRef.current || !zoomBehaviorRef.current) return;
@@ -11,4 +11,6 @@ export interface D3Node extends d3.SimulationNodeDatum {
11
11
  visible?: boolean;
12
12
  washedOut?: boolean;
13
13
  bold?: boolean;
14
+ color?: string;
15
+ subtitle?: string;
14
16
  }