@abreen/tada 1.4.1 → 1.5.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 (103) hide show
  1. package/README.md +4 -2
  2. package/bin/tada.ts +81 -57
  3. package/build/apply-base-path-plugin.ts +22 -6
  4. package/build/code.test.ts +108 -1
  5. package/build/colors.test.ts +66 -0
  6. package/build/columns-plugin.ts +116 -0
  7. package/build/content-watch.ts +30 -21
  8. package/build/copy.ts +1 -1
  9. package/build/external-links-plugin.ts +1 -1
  10. package/build/generate-content-assets.ts +46 -4
  11. package/build/generate-favicon.ts +1 -1
  12. package/build/generate-fonts.ts +1 -1
  13. package/build/generate-katex-assets.ts +1 -1
  14. package/build/log.test.ts +7 -1
  15. package/build/log.ts +4 -1
  16. package/build/markdown-plugins.test.ts +65 -3
  17. package/build/pagefind.ts +1 -1
  18. package/build/pipeline.ts +4 -20
  19. package/build/serve.test.ts +59 -0
  20. package/build/serve.ts +29 -63
  21. package/build/templates.ts +2 -2
  22. package/build/toc-plugin.test.ts +40 -25
  23. package/build/toc-plugin.ts +9 -2
  24. package/build/{types.ts → types.d.ts} +87 -1
  25. package/build/util.ts +1 -1
  26. package/build/utils/code.ts +76 -3
  27. package/build/utils/content-files.test.ts +56 -0
  28. package/build/utils/content-files.ts +15 -11
  29. package/build/utils/file-types.test.ts +18 -0
  30. package/build/utils/file-types.ts +4 -0
  31. package/build/utils/include.test.ts +117 -0
  32. package/build/utils/include.ts +53 -0
  33. package/build/utils/jdi-runner/TraceRunner.java +416 -0
  34. package/build/utils/literate-java.ts +2 -2
  35. package/build/utils/markdown.ts +32 -14
  36. package/build/utils/paths.ts +1 -1
  37. package/build/utils/render.ts +97 -11
  38. package/build/utils/shiki-highlighter.ts +1 -1
  39. package/build/utils/trace-layout.test.ts +570 -0
  40. package/build/utils/trace-layout.ts +848 -0
  41. package/build/utils/trace-svg.test.ts +425 -0
  42. package/build/utils/trace-svg.ts +905 -0
  43. package/build/utils/trace.test.ts +169 -0
  44. package/build/utils/trace.ts +334 -0
  45. package/build/validate-internal-links-plugin.test.ts +30 -2
  46. package/build/validate-internal-links-plugin.ts +3 -3
  47. package/build/watch-reload-client.ts +4 -20
  48. package/build/watch.ts +436 -448
  49. package/init/content/index.md +10 -5
  50. package/init/content/labs/00/index.md +1 -1
  51. package/init/content/labs/01/SearchTreeDemo.java +31 -0
  52. package/init/content/labs/01/TraceDemo.java +22 -0
  53. package/init/content/labs/01/index.md +27 -0
  54. package/init/content/labs/index.md +2 -0
  55. package/init/content/lectures/01/Rectangle.java +2 -0
  56. package/init/content/lectures/01/index.md +2 -1
  57. package/init/content/lectures/01/rectangle.py +45 -0
  58. package/init/content/lectures/02/_pr1.md +5 -0
  59. package/init/content/lectures/02/index.md +11 -0
  60. package/init/content/lectures/02/subdir/_foobar.html +3 -0
  61. package/init/content/lectures/02/subdir/_pr2.md +6 -0
  62. package/init/content/lectures/index.md +3 -0
  63. package/init/content/markdown.md +30 -0
  64. package/package.json +20 -3
  65. package/src/_base.scss +13 -2
  66. package/src/_layout.scss +6 -5
  67. package/src/anchor/index.test.ts +64 -0
  68. package/src/code/index.test.ts +71 -0
  69. package/src/code/style.scss +23 -0
  70. package/src/code.scss +5 -3
  71. package/src/header/_base.scss +48 -9
  72. package/src/header/index.test.ts +89 -0
  73. package/src/header/index.ts +1 -1
  74. package/src/header/style.scss +1 -5
  75. package/src/index.ts +18 -20
  76. package/src/literate/style.scss +15 -7
  77. package/src/navigate/eligible.test.ts +101 -0
  78. package/src/navigate/eligible.ts +62 -0
  79. package/src/navigate/index.test.ts +82 -0
  80. package/src/navigate/index.ts +365 -0
  81. package/src/navigate/lifecycle.test.ts +26 -0
  82. package/src/navigate/lifecycle.ts +65 -0
  83. package/src/navigate/style.scss +61 -0
  84. package/src/print/index.test.ts +71 -0
  85. package/src/question/index.test.ts +82 -0
  86. package/src/question/style.scss +12 -0
  87. package/src/search/index.ts +38 -14
  88. package/src/search/style.scss +23 -14
  89. package/src/style.scss +25 -12
  90. package/src/timezone/index.test.ts +301 -1
  91. package/src/timezone/index.ts +68 -56
  92. package/src/toc/index.test.ts +237 -0
  93. package/src/toc/index.ts +29 -69
  94. package/src/toc/style.scss +17 -0
  95. package/src/top/index.test.ts +46 -0
  96. package/src/top/index.ts +2 -2
  97. package/src/trace/index.test.ts +528 -0
  98. package/src/trace/index.ts +250 -0
  99. package/src/trace/style.scss +210 -0
  100. package/src/trace/types.d.ts +47 -0
  101. package/src/util.test.ts +135 -0
  102. package/templates/_top.html +23 -22
  103. package/types/jsdom.d.ts +9 -0
@@ -0,0 +1,848 @@
1
+ import { flextree } from 'd3-flextree';
2
+ import type {
3
+ TraceStep,
4
+ TraceValue,
5
+ TraceLayout,
6
+ TraceObjectLayout,
7
+ } from '../types';
8
+
9
+ /** Maximum number of array cells to display. */
10
+ const ARRAY_MAX_CELLS = 12;
11
+
12
+ /** Default font size in pixels. */
13
+ const DEFAULT_FONT_SIZE = 15;
14
+
15
+ /** Gap between objects in the layout. */
16
+ const OBJ_GAP = 24;
17
+
18
+ /** Char width ratio for monospace text. */
19
+ const CHAR_WIDTH_RATIO = 0.6;
20
+
21
+ /** Row height as a multiple of font size. */
22
+ const ROW_HEIGHT_RATIO = 2;
23
+
24
+ /** Minimum width for a value box in a field object. */
25
+ const MIN_VALUE_WIDTH = 40;
26
+
27
+ /** Information collected about a heap object across all trace steps. */
28
+ export interface ObjectInfo {
29
+ id: string;
30
+ type: string;
31
+ firstStep: number;
32
+ lastStep: number;
33
+ /** Set of object IDs this object ever references (across all steps). */
34
+ references: Set<string>;
35
+ /** Per-field reference targets (field name -> set of referenced object IDs). */
36
+ fieldRefs: Map<string, Set<string>>;
37
+ /** Max number of fields seen on this object (for field objects). */
38
+ maxFields: number;
39
+ /** Max number of elements seen on this object (for arrays). */
40
+ maxElements: number;
41
+ /** String value (for string-valued objects). */
42
+ value: string | null;
43
+ isArray: boolean;
44
+ isString: boolean;
45
+ /** All field names ever seen on this object. */
46
+ fieldNames: Set<string>;
47
+ /** Maximum display character count of any value seen in each field. */
48
+ maxFieldValueWidths: Map<string, number>;
49
+ }
50
+
51
+ /** A tree node used to build the hierarchy for d3-flextree. */
52
+ export interface TreeNode {
53
+ id: string;
54
+ width: number;
55
+ height: number;
56
+ children: TreeNode[];
57
+ }
58
+
59
+ /**
60
+ * Walk every step, collect every heap object's type, lifetime (first/last step),
61
+ * all references it ever makes, max fields seen, whether it's an array or string.
62
+ */
63
+ export function scanSteps(steps: TraceStep[]): Map<string, ObjectInfo> {
64
+ const objects = new Map<string, ObjectInfo>();
65
+
66
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
67
+ const step = steps[stepIndex];
68
+ for (const [id, obj] of Object.entries(step.heap)) {
69
+ let info = objects.get(id);
70
+ if (!info) {
71
+ info = {
72
+ id,
73
+ type: obj.type,
74
+ firstStep: stepIndex,
75
+ lastStep: stepIndex,
76
+ references: new Set(),
77
+ fieldRefs: new Map(),
78
+ maxFields: 0,
79
+ maxElements: 0,
80
+ value: null,
81
+ isArray: false,
82
+ isString: false,
83
+ fieldNames: new Set(),
84
+ maxFieldValueWidths: new Map(),
85
+ };
86
+ objects.set(id, info);
87
+ }
88
+
89
+ info.lastStep = stepIndex;
90
+
91
+ if ('elements' in obj) {
92
+ info.isArray = true;
93
+ info.maxElements = Math.max(info.maxElements, obj.elements.length);
94
+
95
+ if (!info.fieldRefs.has('__elements__')) {
96
+ info.fieldRefs.set('__elements__', new Set());
97
+ }
98
+ const elemRefs = info.fieldRefs.get('__elements__')!;
99
+
100
+ for (const elem of obj.elements) {
101
+ if (elem.type === 'ref') {
102
+ info.references.add(elem.id);
103
+ elemRefs.add(elem.id);
104
+ }
105
+ }
106
+ } else if ('fields' in obj) {
107
+ const fieldEntries = Object.entries(obj.fields);
108
+ info.maxFields = Math.max(info.maxFields, fieldEntries.length);
109
+ for (const [fieldName, val] of fieldEntries) {
110
+ info.fieldNames.add(fieldName);
111
+
112
+ if (val.type === 'ref') {
113
+ info.references.add(val.id);
114
+ if (!info.fieldRefs.has(fieldName)) {
115
+ info.fieldRefs.set(fieldName, new Set());
116
+ }
117
+ info.fieldRefs.get(fieldName)!.add(val.id);
118
+ }
119
+
120
+ // Track max display width for each field's value
121
+ const valWidth = traceValueDisplayLength(val);
122
+ const current = info.maxFieldValueWidths.get(fieldName) ?? 0;
123
+ if (valWidth > current) {
124
+ info.maxFieldValueWidths.set(fieldName, valWidth);
125
+ }
126
+ }
127
+ } else if ('value' in obj) {
128
+ info.isString = true;
129
+ info.value = obj.value;
130
+ }
131
+ }
132
+ }
133
+
134
+ return objects;
135
+ }
136
+
137
+ /** Get the display character length of a TraceValue. */
138
+ function traceValueDisplayLength(val: TraceValue): number {
139
+ switch (val.type) {
140
+ case 'int':
141
+ case 'long':
142
+ case 'short':
143
+ case 'byte':
144
+ case 'float':
145
+ case 'double':
146
+ return String(val.value).length;
147
+ case 'boolean':
148
+ return val.value ? 4 : 5; // "true" or "false"
149
+ case 'char':
150
+ return 3; // 'x'
151
+ case 'String':
152
+ return val.value.length + 2; // quoted
153
+ case 'null':
154
+ return 4;
155
+ case 'ref':
156
+ return 2; // reference arrow placeholder
157
+ case 'unknown':
158
+ return 1;
159
+ case 'uninitialized':
160
+ return 1;
161
+ case 'truncated':
162
+ return 3; // "..."
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Build a map of objectId -> child objectIds by following reference fields,
168
+ * excluding fields listed in ignoreFields.
169
+ * Also returns a mapping of parentId -> (fieldName -> childId) so we know
170
+ * which fields are structural children.
171
+ */
172
+ export function buildSupergraph(
173
+ objects: Map<string, ObjectInfo>,
174
+ ignoreFields: Record<string, string[]> = {},
175
+ ): {
176
+ graph: Map<string, string[]>;
177
+ referenced: Set<string>;
178
+ fieldToChild: Map<string, Map<string, string>>;
179
+ } {
180
+ const graph = new Map<string, string[]>();
181
+ const referenced = new Set<string>();
182
+ /** parentId -> (fieldName -> childId) for structural children */
183
+ const fieldToChild = new Map<string, Map<string, string>>();
184
+
185
+ for (const [id, info] of objects) {
186
+ const children: string[] = [];
187
+ const seen = new Set<string>();
188
+ const ignore = new Set(ignoreFields[info.type] ?? []);
189
+ const fieldMap = new Map<string, string>();
190
+
191
+ for (const [fieldName, refs] of info.fieldRefs) {
192
+ // Skip ignored fields (but never skip __elements__ for arrays)
193
+ if (fieldName !== '__elements__' && ignore.has(fieldName)) {
194
+ continue;
195
+ }
196
+
197
+ for (const refId of refs) {
198
+ if (!seen.has(refId) && objects.has(refId)) {
199
+ seen.add(refId);
200
+ children.push(refId);
201
+ referenced.add(refId);
202
+ if (fieldName !== '__elements__') {
203
+ fieldMap.set(fieldName, refId);
204
+ }
205
+ }
206
+ }
207
+ }
208
+
209
+ graph.set(id, children);
210
+ if (fieldMap.size > 0) {
211
+ fieldToChild.set(id, fieldMap);
212
+ }
213
+ }
214
+
215
+ return { graph, referenced, fieldToChild };
216
+ }
217
+
218
+ /**
219
+ * Compute the display size of a heap object.
220
+ *
221
+ * @param childFields - field names that are structural children (rendered as
222
+ * bottom slots instead of normal rows). Only applies to field objects.
223
+ */
224
+ export function getObjectSize(
225
+ info: ObjectInfo,
226
+ fontSize: number = DEFAULT_FONT_SIZE,
227
+ childFields: Set<string> = new Set(),
228
+ ): { width: number; height: number; valueX?: number; cellWidth?: number } {
229
+ const charWidth = fontSize * CHAR_WIDTH_RATIO;
230
+ const rowHeight = fontSize * ROW_HEIGHT_RATIO;
231
+
232
+ if (info.isString) {
233
+ const text = `"${info.value ?? ''}"`;
234
+ const textWidth = text.length * charWidth;
235
+ const width = Math.max(info.type.length * charWidth + 16, textWidth + 16);
236
+ const height = rowHeight * 2; // header + value
237
+ return { width, height };
238
+ }
239
+
240
+ if (info.isArray) {
241
+ const cellCount = Math.min(info.maxElements, ARRAY_MAX_CELLS);
242
+ const cellWidth = Math.max(charWidth * 3, MIN_VALUE_WIDTH);
243
+ const width = Math.max(
244
+ info.type.length * charWidth + 16,
245
+ cellCount * cellWidth,
246
+ );
247
+ const height = rowHeight;
248
+ return { width, height, cellWidth };
249
+ }
250
+
251
+ // Field object
252
+ const fieldNames = Array.from(info.fieldNames);
253
+ const dataFields = fieldNames.filter(f => !childFields.has(f));
254
+ const hasChildSlots = childFields.size > 0;
255
+
256
+ const maxFieldNameWidth = dataFields.reduce(
257
+ (max, name) => Math.max(max, name.length * charWidth),
258
+ 0,
259
+ );
260
+ const maxValueWidth = Math.max(
261
+ MIN_VALUE_WIDTH,
262
+ ...dataFields.map(
263
+ name => (info.maxFieldValueWidths.get(name) ?? 0) * charWidth,
264
+ ),
265
+ );
266
+ const namePad = Math.round(fontSize * 0.5);
267
+ const valueX = namePad + maxFieldNameWidth + namePad;
268
+ const contentWidth = valueX + maxValueWidth + 8;
269
+
270
+ // For the child-slot row, compute minimum width from slot labels
271
+ let childSlotWidth = 0;
272
+ if (hasChildSlots) {
273
+ const childFieldNames = Array.from(childFields);
274
+ const slotPadding = 16; // padding inside each slot
275
+ childSlotWidth = childFieldNames.reduce(
276
+ (sum, name) => sum + name.length * charWidth + slotPadding,
277
+ 0,
278
+ );
279
+ }
280
+
281
+ const headerWidth = info.type.length * charWidth + 16;
282
+ const width = Math.max(headerWidth, contentWidth, childSlotWidth);
283
+ const dataRows = Math.max(dataFields.length, 1);
284
+ // data rows + (2 child-slot rows if tree node: label row + box row)
285
+ const height = rowHeight * (dataRows + (hasChildSlots ? 2 : 0));
286
+ return { width, height, valueX };
287
+ }
288
+
289
+ /**
290
+ * The main entry point. Scans steps, builds supergraph, finds root objects,
291
+ * builds a tree for each root, runs d3-flextree, translates positions to
292
+ * all-positive coordinates, returns positions for every object.
293
+ */
294
+ export function computeLayout(
295
+ steps: TraceStep[],
296
+ ignoreFields: Record<string, string[]> = {},
297
+ fontSize: number = DEFAULT_FONT_SIZE,
298
+ ): TraceLayout {
299
+ const objects = scanSteps(steps);
300
+
301
+ if (objects.size === 0) {
302
+ return { objects: {}, ignoreFields };
303
+ }
304
+
305
+ const { graph, fieldToChild } = buildSupergraph(objects, ignoreFields);
306
+
307
+ // Determine child fields for each object based on the supergraph.
308
+ // A field is a "child field" if it ever pointed to a structural child.
309
+ // First pass: find which field names are structural per type.
310
+ const typeChildFields = new Map<string, Set<string>>();
311
+ for (const [parentId, fMap] of fieldToChild) {
312
+ const parentInfo = objects.get(parentId);
313
+ if (parentInfo) {
314
+ for (const [fieldName, childId] of fMap) {
315
+ const childInfo = objects.get(childId);
316
+ if (childInfo && childInfo.type === parentInfo.type) {
317
+ let s = typeChildFields.get(parentInfo.type);
318
+ if (!s) {
319
+ s = new Set();
320
+ typeChildFields.set(parentInfo.type, s);
321
+ }
322
+ s.add(fieldName);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ // Chain types (1 structural child field, e.g., linked list) are laid out
328
+ // horizontally. Tree types (2+ fields, e.g., BST) use d3-flextree.
329
+ // Only tree types get child-slot rendering.
330
+ const chainTypes = new Set<string>();
331
+ for (const [type, fields] of typeChildFields) {
332
+ if (fields.size === 1) {
333
+ chainTypes.add(type);
334
+ }
335
+ }
336
+
337
+ // objectChildFields: only tree types get child-slot rendering so that
338
+ // chain nodes render as plain field objects with arrows going right.
339
+ const objectChildFields = new Map<string, Set<string>>();
340
+ for (const [id, info] of objects) {
341
+ if (chainTypes.has(info.type)) {
342
+ continue;
343
+ }
344
+ const cFields = typeChildFields.get(info.type);
345
+ if (cFields && cFields.size > 0) {
346
+ objectChildFields.set(id, new Set(cFields));
347
+ }
348
+ }
349
+
350
+ // Compute structural child IDs (object IDs, not field names) per parent,
351
+ // using typeChildFields directly so chain types are included.
352
+ const structuralChildren = new Map<string, Set<string>>();
353
+ for (const [parentId, fMap] of fieldToChild) {
354
+ const parentInfo = objects.get(parentId);
355
+ if (!parentInfo) {
356
+ continue;
357
+ }
358
+ const cFields = typeChildFields.get(parentInfo.type);
359
+ if (!cFields) {
360
+ continue;
361
+ }
362
+ const structIds = new Set<string>();
363
+ for (const [fieldName, childId] of fMap) {
364
+ if (cFields.has(fieldName)) {
365
+ structIds.add(childId);
366
+ }
367
+ }
368
+ if (structIds.size > 0) {
369
+ structuralChildren.set(parentId, structIds);
370
+ }
371
+ }
372
+
373
+ // Find tree roots: objects that (a) belong to a type with structural child
374
+ // fields (i.e., a tree type) and (b) are not themselves a structural child
375
+ // of another object. Cross-type refs (e.g., PreorderIterator -> Node) don't
376
+ // prevent an object from being a root.
377
+ const structurallyReferenced = new Set<string>();
378
+ for (const childSet of structuralChildren.values()) {
379
+ for (const childId of childSet) {
380
+ structurallyReferenced.add(childId);
381
+ }
382
+ }
383
+ const roots: string[] = [];
384
+ for (const id of objects.keys()) {
385
+ if (structurallyReferenced.has(id)) {
386
+ continue;
387
+ }
388
+ const info = objects.get(id)!;
389
+ if (!typeChildFields.has(info.type)) {
390
+ continue;
391
+ }
392
+ roots.push(id);
393
+ }
394
+
395
+ // Sort roots by first appearance for deterministic ordering
396
+ roots.sort((a, b) => objects.get(a)!.firstStep - objects.get(b)!.firstStep);
397
+
398
+ // Compute sizes for all objects (now with child field info)
399
+ const sizes = new Map<
400
+ string,
401
+ { width: number; height: number; valueX?: number; cellWidth?: number }
402
+ >();
403
+ for (const [id, info] of objects) {
404
+ const cFields = objectChildFields.get(id) ?? new Set();
405
+ sizes.set(id, getObjectSize(info, fontSize, cFields));
406
+ }
407
+
408
+ // Identify tree nodes (reachable from roots via structural refs)
409
+ const treeNodeIds = new Set<string>();
410
+ function markTreeNodes(id: string) {
411
+ if (treeNodeIds.has(id)) {
412
+ return;
413
+ }
414
+ treeNodeIds.add(id);
415
+ const sChildren = structuralChildren.get(id);
416
+ if (sChildren) {
417
+ for (const childId of sChildren) {
418
+ if (objects.has(childId)) {
419
+ markTreeNodes(childId);
420
+ }
421
+ }
422
+ }
423
+ }
424
+ for (const rootId of roots) {
425
+ if (objects.has(rootId)) {
426
+ markTreeNodes(rootId);
427
+ }
428
+ }
429
+
430
+ // Compute horizontal ref chains (non-tree children laid out to the right)
431
+ const refChains = new Map<string, RefChain>();
432
+ const claimed = new Set<string>();
433
+ for (const treeId of treeNodeIds) {
434
+ const chain = computeRefChain(
435
+ treeId,
436
+ graph,
437
+ structuralChildren,
438
+ sizes,
439
+ treeNodeIds,
440
+ claimed,
441
+ );
442
+ if (chain.ids.length > 0) {
443
+ refChains.set(treeId, chain);
444
+ for (const id of chain.ids) {
445
+ claimed.add(id);
446
+ }
447
+ }
448
+ }
449
+
450
+ // Build layouts: chains go left-to-right, trees use d3-flextree
451
+ const result: Record<string, TraceObjectLayout> = {};
452
+ let yOffset = 0;
453
+
454
+ for (const rootId of roots) {
455
+ const rootInfo = objects.get(rootId)!;
456
+
457
+ if (chainTypes.has(rootInfo.type)) {
458
+ // Horizontal chain layout with ref chains below each node
459
+ let xOff = 0;
460
+ let maxHeight = 0;
461
+ let currentId: string | undefined = rootId;
462
+ const visited = new Set<string>();
463
+
464
+ while (currentId && !visited.has(currentId) && objects.has(currentId)) {
465
+ visited.add(currentId);
466
+ const size = sizes.get(currentId)!;
467
+ const chain = refChains.get(currentId);
468
+
469
+ // Effective width: node + ref chain to the right (if any)
470
+ const nodeWidth = size.width;
471
+ const chainWidth = chain
472
+ ? Math.round(OBJ_GAP * 0.5) + chain.totalWidth
473
+ : 0;
474
+ const effectiveWidth = nodeWidth + chainWidth;
475
+
476
+ const ol: TraceObjectLayout = {
477
+ x: Math.round(xOff),
478
+ y: Math.round(yOffset),
479
+ width: size.width,
480
+ height: size.height,
481
+ };
482
+ if (size.valueX !== undefined) {
483
+ ol.valueX = size.valueX;
484
+ }
485
+ if (size.cellWidth !== undefined) {
486
+ ol.cellWidth = size.cellWidth;
487
+ }
488
+ result[currentId] = ol;
489
+
490
+ // Place ref chain objects to the right of this chain node
491
+ if (chain) {
492
+ let refX = xOff + nodeWidth + Math.round(OBJ_GAP * 0.5);
493
+ for (const refId of chain.ids) {
494
+ const rs = sizes.get(refId)!;
495
+ const rol: TraceObjectLayout = {
496
+ x: Math.round(refX),
497
+ y: Math.round(yOffset),
498
+ width: rs.width,
499
+ height: rs.height,
500
+ };
501
+ if (rs.valueX !== undefined) {
502
+ rol.valueX = rs.valueX;
503
+ }
504
+ if (rs.cellWidth !== undefined) {
505
+ rol.cellWidth = rs.cellWidth;
506
+ }
507
+ result[refId] = rol;
508
+ refX += rs.width + Math.round(OBJ_GAP * 0.5);
509
+ }
510
+ maxHeight = Math.max(maxHeight, size.height, chain.maxHeight);
511
+ } else {
512
+ maxHeight = Math.max(maxHeight, size.height);
513
+ }
514
+
515
+ xOff += effectiveWidth + OBJ_GAP;
516
+
517
+ // Follow the single structural child
518
+ const sChildren = structuralChildren.get(currentId);
519
+ currentId = sChildren ? [...sChildren][0] : undefined;
520
+ }
521
+
522
+ yOffset += maxHeight + OBJ_GAP;
523
+ continue;
524
+ }
525
+
526
+ // Tree layout with d3-flextree
527
+ const visited = new Set<string>();
528
+ const treeData = buildTreeNode(
529
+ rootId,
530
+ graph,
531
+ sizes,
532
+ visited,
533
+ structuralChildren,
534
+ );
535
+
536
+ if (!treeData) {
537
+ continue;
538
+ }
539
+
540
+ // d3-flextree nodeSize includes the chain width so nodes are spaced apart
541
+ const layoutEngine = flextree<TreeNode>({}).nodeSize(node => {
542
+ const chain = refChains.get(node.data.id);
543
+ const ew =
544
+ node.data.width +
545
+ (chain ? Math.round(OBJ_GAP * 0.5) + chain.totalWidth : 0);
546
+ const eh = chain
547
+ ? Math.max(node.data.height, chain.maxHeight)
548
+ : node.data.height;
549
+ return [ew + OBJ_GAP, eh + OBJ_GAP];
550
+ });
551
+
552
+ const root = layoutEngine.hierarchy(treeData, d => d.children);
553
+ layoutEngine(root);
554
+
555
+ // Find bounds to translate to positive coordinates
556
+ let minX = Infinity;
557
+ let minY = Infinity;
558
+ let maxY = -Infinity;
559
+
560
+ root.each(node => {
561
+ const chain = refChains.get(node.data.id);
562
+ const ew =
563
+ node.data.width +
564
+ (chain ? Math.round(OBJ_GAP * 0.5) + chain.totalWidth : 0);
565
+ const eh = chain
566
+ ? Math.max(node.data.height, chain.maxHeight)
567
+ : node.data.height;
568
+ minX = Math.min(minX, node.x - ew / 2);
569
+ minY = Math.min(minY, node.y);
570
+ maxY = Math.max(maxY, node.y + eh);
571
+ });
572
+
573
+ // Place tree nodes and their ref chains
574
+ root.each(node => {
575
+ const size = sizes.get(node.data.id)!;
576
+ const cFields = objectChildFields.get(node.data.id);
577
+ const chain = refChains.get(node.data.id);
578
+ const ew =
579
+ node.data.width +
580
+ (chain ? Math.round(OBJ_GAP * 0.5) + chain.totalWidth : 0);
581
+
582
+ // Tree node is left-aligned within its effective bounding box
583
+ const leftEdge = node.x - ew / 2;
584
+
585
+ const ol: TraceObjectLayout = {
586
+ x: Math.round(leftEdge - minX),
587
+ y: Math.round(node.y - minY + yOffset),
588
+ width: size.width,
589
+ height: size.height,
590
+ };
591
+ if (size.valueX !== undefined) {
592
+ ol.valueX = size.valueX;
593
+ }
594
+ if (size.cellWidth !== undefined) {
595
+ ol.cellWidth = size.cellWidth;
596
+ }
597
+ if (cFields && cFields.size > 0) {
598
+ ol.childFields = Array.from(cFields);
599
+ }
600
+ result[node.data.id] = ol;
601
+
602
+ // Place chain objects to the right of the tree node
603
+ if (chain) {
604
+ let xOff =
605
+ Math.round(leftEdge - minX) + size.width + Math.round(OBJ_GAP * 0.5);
606
+ for (const childId of chain.ids) {
607
+ const cs = sizes.get(childId)!;
608
+ const col: TraceObjectLayout = {
609
+ x: xOff,
610
+ y: Math.round(node.y - minY + yOffset),
611
+ width: cs.width,
612
+ height: cs.height,
613
+ };
614
+ if (cs.valueX !== undefined) {
615
+ col.valueX = cs.valueX;
616
+ }
617
+ if (cs.cellWidth !== undefined) {
618
+ col.cellWidth = cs.cellWidth;
619
+ }
620
+ result[childId] = col;
621
+ xOff += cs.width + Math.round(OBJ_GAP * 0.5);
622
+ }
623
+ }
624
+ });
625
+
626
+ // Stack next tree below this one
627
+ yOffset += maxY - minY + OBJ_GAP;
628
+ }
629
+
630
+ // Position arrays and their referenced objects below the cells
631
+ for (const [id, info] of objects) {
632
+ if (!info.isArray || id in result) {
633
+ continue;
634
+ }
635
+
636
+ const size = sizes.get(id)!;
637
+ const cw = size.cellWidth ?? Math.round(fontSize * CHAR_WIDTH_RATIO * 3);
638
+
639
+ const ol: TraceObjectLayout = {
640
+ x: 0,
641
+ y: Math.round(yOffset),
642
+ width: size.width,
643
+ height: size.height,
644
+ };
645
+ if (size.cellWidth !== undefined) {
646
+ ol.cellWidth = size.cellWidth;
647
+ }
648
+ result[id] = ol;
649
+
650
+ // Get unreferenced children from graph, preserving cell order
651
+ const children = (graph.get(id) ?? []).filter(
652
+ cid => !(cid in result) && objects.has(cid),
653
+ );
654
+
655
+ if (children.length > 0) {
656
+ // Center each child below its cell, then resolve overlaps
657
+ const placements = children.map((childId, i) => {
658
+ const cs = sizes.get(childId)!;
659
+ return {
660
+ id: childId,
661
+ x: (i + 0.5) * cw - cs.width / 2,
662
+ width: cs.width,
663
+ height: cs.height,
664
+ size: cs,
665
+ };
666
+ });
667
+
668
+ const gap = Math.round(OBJ_GAP * 0.5);
669
+ for (let j = 1; j < placements.length; j++) {
670
+ const prev = placements[j - 1];
671
+ const curr = placements[j];
672
+ const minX = prev.x + prev.width + gap;
673
+ if (curr.x < minX) {
674
+ curr.x = minX;
675
+ }
676
+ }
677
+
678
+ const refY = Math.round(yOffset + size.height + OBJ_GAP);
679
+ let maxRefHeight = 0;
680
+ for (const p of placements) {
681
+ const col: TraceObjectLayout = {
682
+ x: Math.round(Math.max(0, p.x)),
683
+ y: refY,
684
+ width: p.size.width,
685
+ height: p.size.height,
686
+ };
687
+ if (p.size.valueX !== undefined) {
688
+ col.valueX = p.size.valueX;
689
+ }
690
+ if (p.size.cellWidth !== undefined) {
691
+ col.cellWidth = p.size.cellWidth;
692
+ }
693
+ result[p.id] = col;
694
+ maxRefHeight = Math.max(maxRefHeight, p.height);
695
+ }
696
+
697
+ yOffset = refY + maxRefHeight + OBJ_GAP;
698
+ } else {
699
+ yOffset += size.height + OBJ_GAP;
700
+ }
701
+ }
702
+
703
+ // Place any orphan objects that weren't reached by the tree/chain/array passes
704
+ for (const id of objects.keys()) {
705
+ if (!(id in result)) {
706
+ const size = sizes.get(id)!;
707
+ const ol: TraceObjectLayout = {
708
+ x: 0,
709
+ y: Math.round(yOffset),
710
+ width: size.width,
711
+ height: size.height,
712
+ };
713
+ if (size.valueX !== undefined) {
714
+ ol.valueX = size.valueX;
715
+ }
716
+ if (size.cellWidth !== undefined) {
717
+ ol.cellWidth = size.cellWidth;
718
+ }
719
+ result[id] = ol;
720
+ yOffset += size.height + OBJ_GAP;
721
+ }
722
+ }
723
+
724
+ return { objects: result, ignoreFields };
725
+ }
726
+
727
+ /** Chain of non-tree objects laid out horizontally to the right of a tree node. */
728
+ interface RefChain {
729
+ ids: string[];
730
+ totalWidth: number;
731
+ maxHeight: number;
732
+ }
733
+
734
+ /** Compute the chain of non-tree objects reachable from a tree node. */
735
+ function computeRefChain(
736
+ startId: string,
737
+ graph: Map<string, string[]>,
738
+ structuralChildren: Map<string, Set<string>>,
739
+ sizes: Map<
740
+ string,
741
+ { width: number; height: number; valueX?: number; cellWidth?: number }
742
+ >,
743
+ treeNodeIds: Set<string>,
744
+ claimed: Set<string>,
745
+ ): RefChain {
746
+ const ids: string[] = [];
747
+ let totalWidth = 0;
748
+ let maxHeight = 0;
749
+
750
+ const allChildren = graph.get(startId) ?? [];
751
+ const structural = structuralChildren.get(startId);
752
+
753
+ const queue: string[] = [];
754
+ const visited = new Set<string>();
755
+
756
+ for (const childId of allChildren) {
757
+ if (structural?.has(childId)) {
758
+ continue;
759
+ }
760
+ if (treeNodeIds.has(childId)) {
761
+ continue;
762
+ }
763
+ if (claimed.has(childId)) {
764
+ continue;
765
+ }
766
+ if (sizes.has(childId)) {
767
+ queue.push(childId);
768
+ }
769
+ }
770
+
771
+ while (queue.length > 0) {
772
+ const id = queue.shift()!;
773
+ if (visited.has(id)) {
774
+ continue;
775
+ }
776
+ visited.add(id);
777
+
778
+ const size = sizes.get(id);
779
+ if (!size) {
780
+ continue;
781
+ }
782
+
783
+ ids.push(id);
784
+ totalWidth += size.width;
785
+ maxHeight = Math.max(maxHeight, size.height);
786
+
787
+ const childChildren = graph.get(id) ?? [];
788
+ for (const gcId of childChildren) {
789
+ if (
790
+ !visited.has(gcId) &&
791
+ !treeNodeIds.has(gcId) &&
792
+ !claimed.has(gcId) &&
793
+ sizes.has(gcId)
794
+ ) {
795
+ queue.push(gcId);
796
+ }
797
+ }
798
+ }
799
+
800
+ if (ids.length > 1) {
801
+ totalWidth += (ids.length - 1) * Math.round(OBJ_GAP * 0.5);
802
+ }
803
+
804
+ return { ids, totalWidth, maxHeight };
805
+ }
806
+
807
+ /** Recursively build a TreeNode for d3-flextree (structural children only). */
808
+ function buildTreeNode(
809
+ id: string,
810
+ graph: Map<string, string[]>,
811
+ sizes: Map<
812
+ string,
813
+ { width: number; height: number; valueX?: number; cellWidth?: number }
814
+ >,
815
+ visited: Set<string>,
816
+ structuralChildren: Map<string, Set<string>>,
817
+ ): TreeNode | null {
818
+ if (visited.has(id)) {
819
+ return null;
820
+ }
821
+ visited.add(id);
822
+
823
+ const size = sizes.get(id);
824
+ if (!size) {
825
+ return null;
826
+ }
827
+
828
+ const children: TreeNode[] = [];
829
+ const childIds = graph.get(id) ?? [];
830
+ const structural = structuralChildren.get(id);
831
+ for (const childId of childIds) {
832
+ if (!structural?.has(childId)) {
833
+ continue;
834
+ }
835
+ const childNode = buildTreeNode(
836
+ childId,
837
+ graph,
838
+ sizes,
839
+ visited,
840
+ structuralChildren,
841
+ );
842
+ if (childNode) {
843
+ children.push(childNode);
844
+ }
845
+ }
846
+
847
+ return { id, width: size.width, height: size.height, children };
848
+ }