@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.
@@ -0,0 +1,286 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { computeLayeredLayout, fitLayeredLayoutToAspectRatio } from "../computeLayeredLayout";
3
+ import type { D3Link, D3Node } from "../../interfaces";
4
+
5
+ function makeNode(id: string, name = id, extra: Partial<D3Node> = {}): D3Node {
6
+ return { id, name, instanceType: "test", ...extra };
7
+ }
8
+
9
+ function makeLink(source: string, target: string): D3Link {
10
+ return { source, target } as D3Link;
11
+ }
12
+
13
+ const MIN_WIDTH = 80;
14
+ const MIN_HEIGHT = 80;
15
+
16
+ describe("computeLayeredLayout", () => {
17
+ it("returns an empty map for an empty graph", () => {
18
+ const result = computeLayeredLayout([], [], {
19
+ minNodeWidth: MIN_WIDTH,
20
+ minNodeHeight: MIN_HEIGHT,
21
+ });
22
+ expect(result).not.toBeNull();
23
+ expect(result!.size).toBe(0);
24
+ });
25
+
26
+ it("places a single isolated node at a finite coordinate", () => {
27
+ const nodes = [makeNode("a")];
28
+ const result = computeLayeredLayout(nodes, [], {
29
+ minNodeWidth: MIN_WIDTH,
30
+ minNodeHeight: MIN_HEIGHT,
31
+ });
32
+ expect(result).not.toBeNull();
33
+ const pos = result!.get("a");
34
+ expect(pos).toBeDefined();
35
+ expect(Number.isFinite(pos!.x)).toBe(true);
36
+ expect(Number.isFinite(pos!.y)).toBe(true);
37
+ });
38
+
39
+ it("orders a linear chain left-to-right when rankdir=LR", () => {
40
+ const nodes = [makeNode("a"), makeNode("b"), makeNode("c")];
41
+ const links = [makeLink("a", "b"), makeLink("b", "c")];
42
+ const result = computeLayeredLayout(nodes, links, {
43
+ rankdir: "LR",
44
+ minNodeWidth: MIN_WIDTH,
45
+ minNodeHeight: MIN_HEIGHT,
46
+ });
47
+ expect(result).not.toBeNull();
48
+ const a = result!.get("a")!;
49
+ const b = result!.get("b")!;
50
+ const c = result!.get("c")!;
51
+ expect(a.x).toBeLessThan(b.x);
52
+ expect(b.x).toBeLessThan(c.x);
53
+ });
54
+
55
+ it("orders a linear chain top-to-bottom when rankdir=TB", () => {
56
+ const nodes = [makeNode("a"), makeNode("b"), makeNode("c")];
57
+ const links = [makeLink("a", "b"), makeLink("b", "c")];
58
+ const result = computeLayeredLayout(nodes, links, {
59
+ rankdir: "TB",
60
+ minNodeWidth: MIN_WIDTH,
61
+ minNodeHeight: MIN_HEIGHT,
62
+ });
63
+ expect(result).not.toBeNull();
64
+ const a = result!.get("a")!;
65
+ const b = result!.get("b")!;
66
+ const c = result!.get("c")!;
67
+ expect(a.y).toBeLessThan(b.y);
68
+ expect(b.y).toBeLessThan(c.y);
69
+ });
70
+
71
+ it("places siblings on the same rank but separated", () => {
72
+ const nodes = [makeNode("a"), makeNode("b"), makeNode("c")];
73
+ const links = [makeLink("a", "b"), makeLink("a", "c")];
74
+ const result = computeLayeredLayout(nodes, links, {
75
+ rankdir: "LR",
76
+ nodesep: 50,
77
+ minNodeWidth: MIN_WIDTH,
78
+ minNodeHeight: MIN_HEIGHT,
79
+ });
80
+ expect(result).not.toBeNull();
81
+ const a = result!.get("a")!;
82
+ const b = result!.get("b")!;
83
+ const c = result!.get("c")!;
84
+ expect(b.x).toBeGreaterThan(a.x);
85
+ expect(c.x).toBeGreaterThan(a.x);
86
+ expect(Math.abs(b.x - c.x)).toBeLessThan(5);
87
+ expect(Math.abs(b.y - c.y)).toBeGreaterThanOrEqual(50);
88
+ });
89
+
90
+ it("uses label width to widen ranks for long names", () => {
91
+ const nodes = [makeNode("a", "x"), makeNode("b", "x".repeat(50))];
92
+ const links = [makeLink("a", "b")];
93
+ const narrow = computeLayeredLayout(nodes, links, {
94
+ rankdir: "LR",
95
+ minNodeWidth: MIN_WIDTH,
96
+ minNodeHeight: MIN_HEIGHT,
97
+ });
98
+ const wider = computeLayeredLayout(
99
+ [makeNode("a", "x".repeat(50)), makeNode("b", "x".repeat(50))],
100
+ [makeLink("a", "b")],
101
+ {
102
+ rankdir: "LR",
103
+ minNodeWidth: MIN_WIDTH,
104
+ minNodeHeight: MIN_HEIGHT,
105
+ },
106
+ );
107
+ const narrowGap = narrow!.get("b")!.x - narrow!.get("a")!.x;
108
+ const widerGap = wider!.get("b")!.x - wider!.get("a")!.x;
109
+ expect(widerGap).toBeGreaterThan(narrowGap);
110
+ });
111
+
112
+ it("handles a scene-shaped graph (scene chain + location branches)", () => {
113
+ const nodes = [
114
+ makeNode("s1", "Scene 1", { instanceType: "scenes" }),
115
+ makeNode("s2", "Scene 2", { instanceType: "scenes" }),
116
+ makeNode("s3", "Scene 3", { instanceType: "scenes" }),
117
+ makeNode("loc1", "Loc 1", { instanceType: "locations" }),
118
+ makeNode("loc2", "Loc 2", { instanceType: "locations" }),
119
+ makeNode("loc3", "Loc 3", { instanceType: "locations" }),
120
+ ];
121
+ const links = [
122
+ makeLink("s1", "s2"),
123
+ makeLink("s2", "s3"),
124
+ makeLink("s1", "loc1"),
125
+ makeLink("s2", "loc2"),
126
+ makeLink("s3", "loc3"),
127
+ ];
128
+ const result = computeLayeredLayout(nodes, links, {
129
+ rankdir: "LR",
130
+ minNodeWidth: MIN_WIDTH,
131
+ minNodeHeight: MIN_HEIGHT,
132
+ });
133
+ expect(result).not.toBeNull();
134
+ expect(result!.size).toBe(6);
135
+ expect(result!.get("s1")!.x).toBeLessThan(result!.get("s2")!.x);
136
+ expect(result!.get("s2")!.x).toBeLessThan(result!.get("s3")!.x);
137
+ expect(result!.get("loc1")!.x).toBeGreaterThan(result!.get("s1")!.x);
138
+ expect(result!.get("loc2")!.x).toBeGreaterThan(result!.get("s2")!.x);
139
+ expect(result!.get("loc3")!.x).toBeGreaterThan(result!.get("s3")!.x);
140
+ });
141
+
142
+ it("ignores links to/from unknown nodes", () => {
143
+ const nodes = [makeNode("a"), makeNode("b")];
144
+ const links = [makeLink("a", "b"), makeLink("a", "ghost"), makeLink("ghost", "b")];
145
+ const result = computeLayeredLayout(nodes, links, {
146
+ minNodeWidth: MIN_WIDTH,
147
+ minNodeHeight: MIN_HEIGHT,
148
+ });
149
+ expect(result).not.toBeNull();
150
+ expect(result!.size).toBe(2);
151
+ });
152
+ });
153
+
154
+ function boundingBoxAspect(positions: Map<string, { x: number; y: number }>): number {
155
+ if (positions.size <= 1) return NaN;
156
+ let xMin = Infinity;
157
+ let xMax = -Infinity;
158
+ let yMin = Infinity;
159
+ let yMax = -Infinity;
160
+ positions.forEach((p) => {
161
+ xMin = Math.min(xMin, p.x);
162
+ xMax = Math.max(xMax, p.x);
163
+ yMin = Math.min(yMin, p.y);
164
+ yMax = Math.max(yMax, p.y);
165
+ });
166
+ const w = xMax - xMin;
167
+ const h = yMax - yMin;
168
+ if (h === 0) return Infinity;
169
+ return w / h;
170
+ }
171
+
172
+ describe("fitLayeredLayoutToAspectRatio", () => {
173
+ it("returns an empty map for an empty graph", () => {
174
+ const result = fitLayeredLayoutToAspectRatio([], [], {
175
+ minNodeWidth: MIN_WIDTH,
176
+ minNodeHeight: MIN_HEIGHT,
177
+ targetAspectRatio: 1.5,
178
+ });
179
+ expect(result).not.toBeNull();
180
+ expect(result!.size).toBe(0);
181
+ });
182
+
183
+ it("returns a single node at finite coords without crashing", () => {
184
+ const result = fitLayeredLayoutToAspectRatio([makeNode("a")], [], {
185
+ minNodeWidth: MIN_WIDTH,
186
+ minNodeHeight: MIN_HEIGHT,
187
+ targetAspectRatio: 1.5,
188
+ });
189
+ expect(result).not.toBeNull();
190
+ const pos = result!.get("a")!;
191
+ expect(Number.isFinite(pos.x)).toBe(true);
192
+ expect(Number.isFinite(pos.y)).toBe(true);
193
+ });
194
+
195
+ it("nudges aspect ratio toward a wider target than single-pass", () => {
196
+ // A branchy DAG with vertical structure under TB: a -> b1,b2,b3,b4,b5
197
+ // Single-pass TB makes this tall (5 siblings stacked vertically below a).
198
+ const nodes = [makeNode("a"), makeNode("b1"), makeNode("b2"), makeNode("b3"), makeNode("b4"), makeNode("b5")];
199
+ const links = [
200
+ makeLink("a", "b1"),
201
+ makeLink("a", "b2"),
202
+ makeLink("a", "b3"),
203
+ makeLink("a", "b4"),
204
+ makeLink("a", "b5"),
205
+ ];
206
+ const baseline = computeLayeredLayout(nodes, links, {
207
+ rankdir: "TB",
208
+ minNodeWidth: MIN_WIDTH,
209
+ minNodeHeight: MIN_HEIGHT,
210
+ });
211
+ const fitted = fitLayeredLayoutToAspectRatio(nodes, links, {
212
+ rankdir: "TB",
213
+ minNodeWidth: MIN_WIDTH,
214
+ minNodeHeight: MIN_HEIGHT,
215
+ targetAspectRatio: 4.0,
216
+ });
217
+ expect(baseline).not.toBeNull();
218
+ expect(fitted).not.toBeNull();
219
+ const baseAspect = boundingBoxAspect(baseline!);
220
+ const fitAspect = boundingBoxAspect(fitted!);
221
+ expect(Math.abs(fitAspect - 4.0)).toBeLessThan(Math.abs(baseAspect - 4.0));
222
+ });
223
+
224
+ it("nudges aspect ratio toward a narrower target than single-pass", () => {
225
+ // A wide LR chain (single rank deep, multi-rank wide); single-pass is
226
+ // very wide. Target 0.5 (taller than wide) should reduce ranksep and
227
+ // increase nodesep — though with no siblings the height grows slowly.
228
+ // We use a graph with one branching point to give the fitter vertical
229
+ // structure to expand into.
230
+ const nodes = [makeNode("a"), makeNode("b"), makeNode("c"), makeNode("d"), makeNode("e")];
231
+ const links = [makeLink("a", "b"), makeLink("a", "c"), makeLink("b", "d"), makeLink("c", "e")];
232
+ const baseline = computeLayeredLayout(nodes, links, {
233
+ rankdir: "LR",
234
+ minNodeWidth: MIN_WIDTH,
235
+ minNodeHeight: MIN_HEIGHT,
236
+ });
237
+ const fitted = fitLayeredLayoutToAspectRatio(nodes, links, {
238
+ rankdir: "LR",
239
+ minNodeWidth: MIN_WIDTH,
240
+ minNodeHeight: MIN_HEIGHT,
241
+ targetAspectRatio: 0.6,
242
+ });
243
+ expect(baseline).not.toBeNull();
244
+ expect(fitted).not.toBeNull();
245
+ const baseAspect = boundingBoxAspect(baseline!);
246
+ const fitAspect = boundingBoxAspect(fitted!);
247
+ expect(Math.abs(fitAspect - 0.6)).toBeLessThan(Math.abs(baseAspect - 0.6));
248
+ });
249
+
250
+ it("skips fitting for single-rank graphs (zero height in LR)", () => {
251
+ // Linear LR chain: all nodes on the same y (single rank tall),
252
+ // bbox height collapses to 0 — fitter should return positions
253
+ // unchanged from single-pass.
254
+ const nodes = [makeNode("a"), makeNode("b"), makeNode("c")];
255
+ const links = [makeLink("a", "b"), makeLink("b", "c")];
256
+ const fitted = fitLayeredLayoutToAspectRatio(nodes, links, {
257
+ rankdir: "LR",
258
+ minNodeWidth: MIN_WIDTH,
259
+ minNodeHeight: MIN_HEIGHT,
260
+ targetAspectRatio: 0.5,
261
+ });
262
+ expect(fitted).not.toBeNull();
263
+ expect(fitted!.size).toBe(3);
264
+ });
265
+
266
+ it("treats invalid targetAspectRatio (zero or NaN) as a no-op fit", () => {
267
+ const nodes = [makeNode("a"), makeNode("b")];
268
+ const links = [makeLink("a", "b")];
269
+ const zero = fitLayeredLayoutToAspectRatio(nodes, links, {
270
+ rankdir: "LR",
271
+ minNodeWidth: MIN_WIDTH,
272
+ minNodeHeight: MIN_HEIGHT,
273
+ targetAspectRatio: 0,
274
+ });
275
+ const nan = fitLayeredLayoutToAspectRatio(nodes, links, {
276
+ rankdir: "LR",
277
+ minNodeWidth: MIN_WIDTH,
278
+ minNodeHeight: MIN_HEIGHT,
279
+ targetAspectRatio: NaN,
280
+ });
281
+ expect(zero).not.toBeNull();
282
+ expect(zero!.size).toBe(2);
283
+ expect(nan).not.toBeNull();
284
+ expect(nan!.size).toBe(2);
285
+ });
286
+ });
@@ -0,0 +1,209 @@
1
+ import * as dagre from "dagre";
2
+ import { D3Link, D3Node } from "../interfaces";
3
+
4
+ export type LayeredRankDir = "LR" | "RL" | "TB" | "BT";
5
+
6
+ export interface LayeredLayoutOptions {
7
+ rankdir?: LayeredRankDir;
8
+ nodesep?: number;
9
+ ranksep?: number;
10
+ minNodeWidth: number;
11
+ minNodeHeight: number;
12
+ }
13
+
14
+ export interface LayeredLayoutPosition {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ export interface FitLayeredLayoutOptions extends LayeredLayoutOptions {
20
+ targetAspectRatio: number;
21
+ maxIterations?: number;
22
+ tolerance?: number;
23
+ }
24
+
25
+ const DEFAULT_RANKDIR: LayeredRankDir = "LR";
26
+ const DEFAULT_NODESEP = 50;
27
+ const DEFAULT_RANKSEP = 120;
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
+
38
+ const TITLE_PX_PER_CHAR_16 = 8;
39
+ const NAME_PX_PER_CHAR_12 = 6.5;
40
+ const NAME_PX_PER_CHAR_16_BOLD = 8;
41
+ const SUBTITLE_PX_PER_CHAR_11 = 6;
42
+ const LABEL_PADDING_PX = 16;
43
+
44
+ function estimateLabelWidth(node: D3Node): number {
45
+ if (node.subtitle) {
46
+ const titleWidth = (node.name?.length ?? 0) * TITLE_PX_PER_CHAR_16;
47
+ const subtitleWidth = node.subtitle.length * SUBTITLE_PX_PER_CHAR_11;
48
+ return Math.max(titleWidth, subtitleWidth) + LABEL_PADDING_PX;
49
+ }
50
+ const perChar = node.bold ? NAME_PX_PER_CHAR_16_BOLD : NAME_PX_PER_CHAR_12;
51
+ return (node.name?.length ?? 0) * perChar + LABEL_PADDING_PX;
52
+ }
53
+
54
+ function linkEndpointId(end: D3Link["source"] | D3Link["target"]): string {
55
+ return typeof end === "string" ? end : end.id;
56
+ }
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
+
67
+ /**
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`.
72
+ */
73
+ function runDagreOnce(nodes: D3Node[], links: D3Link[], opts: LayeredLayoutOptions): DagreRunResult | null {
74
+ const rankdir = opts.rankdir ?? DEFAULT_RANKDIR;
75
+ const nodesep = opts.nodesep ?? DEFAULT_NODESEP;
76
+ const ranksep = opts.ranksep ?? DEFAULT_RANKSEP;
77
+
78
+ const g = new dagre.graphlib.Graph({ directed: true });
79
+ g.setGraph({ rankdir, nodesep, ranksep, marginx: 20, marginy: 20 });
80
+ g.setDefaultEdgeLabel(() => ({}));
81
+
82
+ for (const node of nodes) {
83
+ const width = Math.max(opts.minNodeWidth, estimateLabelWidth(node));
84
+ const height = opts.minNodeHeight;
85
+ g.setNode(node.id, { width, height });
86
+ }
87
+
88
+ const seen = new Set<string>();
89
+ for (const link of links) {
90
+ const sourceId = linkEndpointId(link.source);
91
+ const targetId = linkEndpointId(link.target);
92
+ if (!g.hasNode(sourceId) || !g.hasNode(targetId)) continue;
93
+ const key = `${sourceId}->${targetId}`;
94
+ if (seen.has(key)) continue;
95
+ seen.add(key);
96
+ g.setEdge(sourceId, targetId);
97
+ }
98
+
99
+ try {
100
+ dagre.layout(g);
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ const positions = new Map<string, LayeredLayoutPosition>();
106
+ let xMin = Infinity;
107
+ let xMax = -Infinity;
108
+ let yMin = Infinity;
109
+ let yMax = -Infinity;
110
+
111
+ for (const node of nodes) {
112
+ const laid = g.node(node.id);
113
+ if (laid && Number.isFinite(laid.x) && Number.isFinite(laid.y)) {
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);
121
+ }
122
+ }
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;
209
+ }
@@ -35,3 +35,11 @@ export * from "./useNotificationSync";
35
35
  export * from "./usePageTracker";
36
36
  export * from "./usePushNotifications";
37
37
  export * from "./useSocket";
38
+ export {
39
+ computeLayeredLayout,
40
+ fitLayeredLayoutToAspectRatio,
41
+ type FitLayeredLayoutOptions,
42
+ type LayeredLayoutOptions,
43
+ type LayeredLayoutPosition,
44
+ type LayeredRankDir,
45
+ } from "./computeLayeredLayout";