@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,905 @@
1
+ import type {
2
+ TraceStep,
3
+ TraceStackFrame,
4
+ TraceValue,
5
+ TraceHeapObject,
6
+ TraceLayout,
7
+ TraceObjectLayout,
8
+ } from '../types';
9
+
10
+ /** Maximum number of array cells to display. */
11
+ const ARRAY_MAX_CELLS = 12;
12
+
13
+ /** Default font size in pixels. */
14
+ const DEFAULT_FONT_SIZE = 15;
15
+
16
+ /** Gap between stack area and heap area. */
17
+ const SECTION_GAP = 60;
18
+
19
+ /** Padding around the SVG. */
20
+ const PADDING = 8;
21
+
22
+ /** Horizontal margin when routing stack-to-heap arrows. */
23
+ const ARROW_MARGIN = 20;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Escape & format helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Escape &, <, >, " for SVG text content. */
30
+ export function escapeXml(s: string): string {
31
+ return s
32
+ .replace(/&/g, '&amp;')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;')
35
+ .replace(/"/g, '&quot;');
36
+ }
37
+
38
+ /** Format a TraceValue for display. */
39
+ export function formatValue(val: TraceValue): string {
40
+ switch (val.type) {
41
+ case 'null':
42
+ return 'null';
43
+ case 'boolean':
44
+ return String(val.value);
45
+ case 'char':
46
+ return `'${val.value}'`;
47
+ case 'String':
48
+ return `"${val.value}"`;
49
+ case 'ref':
50
+ return '\u2022';
51
+ case 'unknown':
52
+ return '?';
53
+ case 'uninitialized':
54
+ return '';
55
+ case 'truncated':
56
+ return `\u2026(${val.remaining})`;
57
+ default:
58
+ if ('value' in val) {
59
+ return String(val.value);
60
+ }
61
+ return '?';
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // filterStep -- remove args from main(), hide objects reachable only via args
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function isStringObject(obj: TraceHeapObject): boolean {
70
+ return 'value' in obj && obj.type === 'String';
71
+ }
72
+
73
+ export function filterStep(step: TraceStep): {
74
+ stack: TraceStackFrame[];
75
+ heap: Record<string, TraceHeapObject>;
76
+ } {
77
+ // Remove 'args' from main() locals
78
+ const stack = step.stack.map(frame => {
79
+ if (frame.method !== 'main') {
80
+ return frame;
81
+ }
82
+ const locals: Record<string, TraceValue> = {};
83
+ for (const [name, val] of Object.entries(frame.locals)) {
84
+ if (name === 'args') {
85
+ continue;
86
+ }
87
+ locals[name] = val;
88
+ }
89
+ return { ...frame, locals };
90
+ });
91
+
92
+ // Find objects reachable only through the hidden 'args' variable
93
+ const argsOnly = new Set<string>();
94
+ for (const frame of step.stack) {
95
+ if (frame.method === 'main') {
96
+ const argsVal = frame.locals.args;
97
+ if (argsVal?.type === 'ref') {
98
+ const q = [argsVal.id];
99
+ while (q.length > 0) {
100
+ const id = q.shift()!;
101
+ if (argsOnly.has(id) || !step.heap[id]) {
102
+ continue;
103
+ }
104
+ argsOnly.add(id);
105
+ const obj = step.heap[id];
106
+ if ('elements' in obj) {
107
+ for (const el of obj.elements) {
108
+ if (el.type === 'ref') {
109
+ q.push(el.id);
110
+ }
111
+ }
112
+ } else if ('fields' in obj) {
113
+ for (const v of Object.values(obj.fields)) {
114
+ if (v.type === 'ref') {
115
+ q.push(v.id);
116
+ }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Find objects reachable from visible (non-args) variables
125
+ const reachable = new Set<string>();
126
+ const queue: string[] = [];
127
+ for (const frame of stack) {
128
+ for (const val of Object.values(frame.locals)) {
129
+ if (val.type === 'ref') {
130
+ queue.push(val.id);
131
+ }
132
+ }
133
+ }
134
+ while (queue.length > 0) {
135
+ const id = queue.shift()!;
136
+ if (reachable.has(id) || !step.heap[id]) {
137
+ continue;
138
+ }
139
+ reachable.add(id);
140
+ const obj = step.heap[id];
141
+ if ('elements' in obj) {
142
+ for (const el of obj.elements) {
143
+ if (el.type === 'ref') {
144
+ queue.push(el.id);
145
+ }
146
+ }
147
+ } else if ('fields' in obj) {
148
+ for (const val of Object.values(obj.fields)) {
149
+ if (val.type === 'ref') {
150
+ queue.push(val.id);
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // Keep heap objects that are reachable from visible variables, or that
157
+ // aren't exclusively reachable from the hidden 'args'
158
+ const heap: Record<string, TraceHeapObject> = {};
159
+ const sortedIds = Object.keys(step.heap)
160
+ .filter(id => reachable.has(id) || !argsOnly.has(id))
161
+ .sort((a, b) => {
162
+ const numA = parseInt(a.split('_').pop()!, 10);
163
+ const numB = parseInt(b.split('_').pop()!, 10);
164
+ return numA - numB;
165
+ });
166
+ for (const id of sortedIds) {
167
+ heap[id] = step.heap[id];
168
+ }
169
+
170
+ return { stack, heap };
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Stack layout
175
+ // ---------------------------------------------------------------------------
176
+
177
+ interface FrameLayout {
178
+ x: number;
179
+ y: number;
180
+ width: number;
181
+ height: number;
182
+ frame: TraceStackFrame;
183
+ varPositions: Map<string, { x: number; y: number }>;
184
+ }
185
+
186
+ function layoutStack(
187
+ stack: TraceStackFrame[],
188
+ dims: { rowH: number; varBoxX: number; varBoxW: number; frameWidth: number },
189
+ ): FrameLayout[] {
190
+ const layouts: FrameLayout[] = [];
191
+ let y = 0;
192
+ const stackX = 0;
193
+
194
+ // Reverse so main() is at top, newest frame at bottom
195
+ const reversed = [...stack].reverse();
196
+ for (const frame of reversed) {
197
+ const varCount = Object.keys(frame.locals).length;
198
+ const varsH = Math.max(varCount, 1) * dims.rowH;
199
+ const height = dims.rowH + varsH + PADDING;
200
+ const varPositions = new Map<string, { x: number; y: number }>();
201
+
202
+ let rowY = dims.rowH;
203
+ for (const name of Object.keys(frame.locals)) {
204
+ varPositions.set(name, {
205
+ x: stackX + dims.varBoxX + dims.varBoxW / 2,
206
+ y: y + rowY + dims.rowH / 2,
207
+ });
208
+ rowY += dims.rowH;
209
+ }
210
+
211
+ layouts.push({
212
+ x: stackX,
213
+ y,
214
+ width: dims.frameWidth,
215
+ height,
216
+ frame,
217
+ varPositions,
218
+ });
219
+ y += height;
220
+ }
221
+
222
+ return layouts;
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // SVG frame generation
227
+ // ---------------------------------------------------------------------------
228
+
229
+ function svgFrame(
230
+ layout: FrameLayout,
231
+ isFirst: boolean,
232
+ dims: { rowH: number; varBoxX: number; varBoxW: number; fontSize: number },
233
+ ): string {
234
+ const { x, y, width, frame } = layout;
235
+ const parts: string[] = [];
236
+ parts.push(`<g class="trace-frame" transform="translate(${x},${y})">`);
237
+
238
+ // Separator between frames (not for the first)
239
+ if (!isFirst) {
240
+ parts.push(
241
+ `<line class="trace-separator" x1="0" y1="0" x2="${width}" y2="0"/>`,
242
+ );
243
+ }
244
+
245
+ // Frame label
246
+ const label =
247
+ frame.line != null ? `${frame.method}:${frame.line}` : frame.method;
248
+ parts.push(
249
+ `<text class="trace-frame-title" x="6" y="${dims.rowH / 2}" ` +
250
+ `dominant-baseline="central" font-weight="bold">${escapeXml(label)}</text>`,
251
+ );
252
+
253
+ // Variables
254
+ let rowY = dims.rowH;
255
+ for (const [name, val] of Object.entries(frame.locals)) {
256
+ // Variable name (right-aligned)
257
+ parts.push(
258
+ `<text class="trace-var-name" x="${dims.varBoxX - 8}" y="${rowY + dims.rowH / 2}" ` +
259
+ `text-anchor="end" dominant-baseline="central">${escapeXml(name)}</text>`,
260
+ );
261
+
262
+ // Value box
263
+ parts.push(
264
+ `<rect class="trace-var-box" x="${dims.varBoxX}" y="${rowY}" ` +
265
+ `width="${dims.varBoxW}" height="${dims.rowH}"/>`,
266
+ );
267
+
268
+ // Value text (not for refs -- arrow handles it)
269
+ if (val.type !== 'ref') {
270
+ const cls = val.type === 'null' ? 'trace-val trace-null' : 'trace-val';
271
+ parts.push(
272
+ `<text class="${cls}" x="${dims.varBoxX + dims.varBoxW / 2}" y="${rowY + dims.rowH / 2}" ` +
273
+ `text-anchor="middle" dominant-baseline="central">${escapeXml(formatValue(val))}</text>`,
274
+ );
275
+ }
276
+
277
+ rowY += dims.rowH;
278
+ }
279
+
280
+ parts.push('</g>');
281
+ return parts.join('');
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // SVG heap object generation
286
+ // ---------------------------------------------------------------------------
287
+
288
+ function svgHeapObject(
289
+ id: string,
290
+ obj: TraceHeapObject,
291
+ pos: TraceObjectLayout,
292
+ dims: { rowH: number; fontSize: number },
293
+ absX: number,
294
+ absY: number,
295
+ ): string {
296
+ const { width, height } = pos;
297
+ const parts: string[] = [];
298
+ parts.push(
299
+ `<g class="trace-obj" data-id="${escapeXml(id)}" transform="translate(${absX},${absY})">`,
300
+ );
301
+
302
+ if ('value' in obj && isStringObject(obj)) {
303
+ // Strings: just quoted text, no box
304
+ parts.push(
305
+ `<text class="trace-val" x="0" y="${height / 2}" ` +
306
+ `dominant-baseline="central">${escapeXml(`"${obj.value}"`)}</text>`,
307
+ );
308
+ parts.push('</g>');
309
+ return parts.join('');
310
+ }
311
+
312
+ // Border rect
313
+ parts.push(
314
+ `<rect class="trace-obj-border" x="0" y="0" width="${width}" height="${height}"/>`,
315
+ );
316
+
317
+ if ('elements' in obj) {
318
+ const cellCount = Math.min(obj.elements.length, ARRAY_MAX_CELLS);
319
+ const cw = pos.cellWidth ?? Math.round(dims.fontSize * 3);
320
+ for (let i = 0; i < cellCount; i++) {
321
+ const cellX = i * cw;
322
+
323
+ if (i > 0) {
324
+ parts.push(
325
+ `<line class="trace-separator" x1="${cellX}" y1="0" x2="${cellX}" y2="${height}"/>`,
326
+ );
327
+ }
328
+
329
+ const el = obj.elements[i];
330
+ if (el.type !== 'ref') {
331
+ const cls = el.type === 'null' ? 'trace-val trace-null' : 'trace-val';
332
+ parts.push(
333
+ `<text class="${cls}" x="${cellX + cw / 2}" y="${height / 2}" ` +
334
+ `text-anchor="middle" dominant-baseline="central">${escapeXml(formatValue(el))}</text>`,
335
+ );
336
+ }
337
+ }
338
+ } else if ('fields' in obj) {
339
+ const childFieldSet = new Set(pos.childFields ?? []);
340
+ const vx = pos.valueX ?? Math.round(dims.fontSize * 7);
341
+
342
+ if (childFieldSet.size > 0) {
343
+ // Tree node: data fields on top, child-slot row at bottom
344
+ const dataFields = Object.entries(obj.fields).filter(
345
+ ([name]) => !childFieldSet.has(name),
346
+ );
347
+ const childFields = Array.from(childFieldSet);
348
+
349
+ // Draw data field rows
350
+ let rowY = 0;
351
+ for (const [name, val] of dataFields) {
352
+ if (rowY > 0) {
353
+ parts.push(
354
+ `<line class="trace-separator" x1="0" y1="${rowY}" x2="${width}" y2="${rowY}"/>`,
355
+ );
356
+ }
357
+
358
+ const nameX = Math.round(dims.fontSize * 0.5);
359
+ parts.push(
360
+ `<text class="trace-field-name" x="${nameX}" y="${rowY + dims.rowH / 2}" ` +
361
+ `dominant-baseline="central">${escapeXml(name)}</text>`,
362
+ );
363
+
364
+ // Value box
365
+ parts.push(
366
+ `<rect class="trace-var-box" x="${vx}" y="${rowY}" ` +
367
+ `width="${width - vx}" height="${dims.rowH}"/>`,
368
+ );
369
+
370
+ if (val.type !== 'ref') {
371
+ const cls =
372
+ val.type === 'null' ? 'trace-val trace-null' : 'trace-val';
373
+ parts.push(
374
+ `<text class="${cls}" x="${vx + (width - vx) / 2}" y="${rowY + dims.rowH / 2}" ` +
375
+ `text-anchor="middle" dominant-baseline="central">${escapeXml(formatValue(val))}</text>`,
376
+ );
377
+ }
378
+
379
+ rowY += dims.rowH;
380
+ }
381
+
382
+ // Draw child-slot row at bottom
383
+ const slotY = rowY;
384
+ parts.push(
385
+ `<line class="trace-separator" x1="0" y1="${slotY}" x2="${width}" y2="${slotY}"/>`,
386
+ );
387
+
388
+ const slotCount = childFields.length;
389
+ const labelRowH = dims.rowH;
390
+ const boxY = slotY + labelRowH;
391
+ const boxH = height - boxY;
392
+
393
+ for (let i = 0; i < slotCount; i++) {
394
+ const slotX = Math.round((width * i) / slotCount);
395
+ const slotEnd = Math.round((width * (i + 1)) / slotCount);
396
+ const sw = slotEnd - slotX;
397
+ const fieldName = childFields[i];
398
+ const val = obj.fields[fieldName];
399
+
400
+ // Field name label centered in the label row
401
+ parts.push(
402
+ `<text class="trace-field-name" x="${slotX + sw / 2}" ` +
403
+ `y="${slotY + labelRowH / 2}" text-anchor="middle" ` +
404
+ `dominant-baseline="central">${escapeXml(fieldName)}</text>`,
405
+ );
406
+
407
+ // Box for the value
408
+ parts.push(
409
+ `<rect class="trace-var-box" x="${slotX}" y="${boxY}" ` +
410
+ `width="${sw}" height="${boxH}"/>`,
411
+ );
412
+
413
+ // Show "null" centered in the box for null slots
414
+ if (val && val.type === 'null') {
415
+ parts.push(
416
+ `<text class="trace-val trace-null" x="${slotX + sw / 2}" ` +
417
+ `y="${boxY + boxH / 2}" text-anchor="middle" ` +
418
+ `dominant-baseline="central">${escapeXml(formatValue(val))}</text>`,
419
+ );
420
+ }
421
+ }
422
+ } else {
423
+ // Non-tree field object: just data rows, no extra space
424
+ let rowY = 0;
425
+ for (const [name, val] of Object.entries(obj.fields)) {
426
+ if (rowY > 0) {
427
+ parts.push(
428
+ `<line class="trace-separator" x1="0" y1="${rowY}" x2="${width}" y2="${rowY}"/>`,
429
+ );
430
+ }
431
+
432
+ const nameX = Math.round(dims.fontSize * 0.5);
433
+ parts.push(
434
+ `<text class="trace-field-name" x="${nameX}" y="${rowY + dims.rowH / 2}" ` +
435
+ `dominant-baseline="central">${escapeXml(name)}</text>`,
436
+ );
437
+
438
+ // Value box
439
+ parts.push(
440
+ `<rect class="trace-var-box" x="${vx}" y="${rowY}" ` +
441
+ `width="${width - vx}" height="${dims.rowH}"/>`,
442
+ );
443
+
444
+ if (val.type !== 'ref') {
445
+ const cls =
446
+ val.type === 'null' ? 'trace-val trace-null' : 'trace-val';
447
+ parts.push(
448
+ `<text class="${cls}" x="${vx + (width - vx) / 2}" y="${rowY + dims.rowH / 2}" ` +
449
+ `text-anchor="middle" dominant-baseline="central">${escapeXml(formatValue(val))}</text>`,
450
+ );
451
+ }
452
+
453
+ rowY += dims.rowH;
454
+ }
455
+ }
456
+ } else if ('value' in obj) {
457
+ const nameX = Math.round(dims.fontSize * 0.5);
458
+ parts.push(
459
+ `<text class="trace-val" x="${nameX}" y="${dims.rowH / 2}" ` +
460
+ `dominant-baseline="central">${escapeXml(String(obj.value))}</text>`,
461
+ );
462
+ }
463
+
464
+ parts.push('</g>');
465
+ return parts.join('');
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Arrow generation
470
+ // ---------------------------------------------------------------------------
471
+
472
+ interface HeapObjInfo {
473
+ id: string;
474
+ absX: number;
475
+ absY: number;
476
+ width: number;
477
+ height: number;
478
+ pos: TraceObjectLayout;
479
+ }
480
+
481
+ /**
482
+ * Given a source point and a target rectangle, find the point where the line
483
+ * from source to target center intersects the target's boundary.
484
+ */
485
+ function rectBoundaryPoint(
486
+ sx: number,
487
+ sy: number,
488
+ rx: number,
489
+ ry: number,
490
+ rw: number,
491
+ rh: number,
492
+ ): { x: number; y: number } {
493
+ const cx = rx + rw / 2;
494
+ const cy = ry + rh / 2;
495
+ const dx = sx - cx;
496
+ const dy = sy - cy;
497
+
498
+ if (dx === 0 && dy === 0) {
499
+ return { x: cx, y: ry }; // source is at center, default to top
500
+ }
501
+
502
+ const hw = rw / 2;
503
+ const hh = rh / 2;
504
+
505
+ // Scale factor to reach each edge
506
+ const scaleX = dx !== 0 ? hw / Math.abs(dx) : Infinity;
507
+ const scaleY = dy !== 0 ? hh / Math.abs(dy) : Infinity;
508
+ const scale = Math.min(scaleX, scaleY);
509
+
510
+ return { x: Math.round(cx + dx * scale), y: Math.round(cy + dy * scale) };
511
+ }
512
+
513
+ /**
514
+ * Get the center of the child-slot box for a given field.
515
+ * The slot row has a small label at the top and a box below it.
516
+ */
517
+ function childSlotCenter(
518
+ objInfo: HeapObjInfo,
519
+ fieldName: string,
520
+ dims: { rowH: number; fontSize: number },
521
+ ): { x: number; y: number } {
522
+ const childFields = objInfo.pos.childFields ?? [];
523
+ const slotCount = childFields.length;
524
+ const idx = childFields.indexOf(fieldName);
525
+ const slotX = Math.round((objInfo.width * idx) / slotCount);
526
+ const slotEnd = Math.round((objInfo.width * (idx + 1)) / slotCount);
527
+ const sw = slotEnd - slotX;
528
+
529
+ // The child area is the last 2 * rowH of the object. Label row then box row.
530
+ const boxY = objInfo.height - dims.rowH;
531
+ const boxH = dims.rowH;
532
+
533
+ return {
534
+ x: Math.round(objInfo.absX + slotX + sw / 2),
535
+ y: Math.round(objInfo.absY + boxY + boxH / 2),
536
+ };
537
+ }
538
+
539
+ function generateArrows(
540
+ stack: TraceStackFrame[],
541
+ heap: Record<string, TraceHeapObject>,
542
+ stackLayouts: FrameLayout[],
543
+ heapObjMap: Map<string, HeapObjInfo>,
544
+ dims: { rowH: number; fontSize: number },
545
+ ): string[] {
546
+ const parts: string[] = [];
547
+
548
+ // Stack-to-heap arrow: quadratic Bezier, starts horizontally.
549
+ function arrow(
550
+ sx: number,
551
+ sy: number,
552
+ target: HeapObjInfo,
553
+ style: 'fg' | 'dim' = 'fg',
554
+ ): string {
555
+ const ep = rectBoundaryPoint(
556
+ sx,
557
+ sy,
558
+ target.absX,
559
+ target.absY,
560
+ target.width,
561
+ target.height,
562
+ );
563
+ const cpx = Math.round((sx + ep.x) / 2);
564
+ const cls = style === 'fg' ? 'trace-arrow' : 'trace-arrow-dim';
565
+ const marker = style === 'fg' ? 'trace-arrowhead' : 'trace-arrowhead-dim';
566
+ return (
567
+ `<path class="${cls}" d="` +
568
+ `M ${sx} ${sy} Q ${cpx} ${sy}, ${ep.x} ${ep.y}" ` +
569
+ `marker-end="url(#${marker})"/>`
570
+ );
571
+ }
572
+
573
+ // Child-slot arrow: orthogonal routing (down -> across -> down) with
574
+ // rounded corners. Each slot gets its own horizontal channel so sibling
575
+ // arrows from the same parent don't overlap.
576
+ function childSlotArrow(sx: number, sy: number, target: HeapObjInfo): string {
577
+ const tx = Math.round(target.absX + target.width / 2);
578
+ const ty = target.absY;
579
+
580
+ // Nearly aligned vertically: straight line
581
+ if (Math.abs(tx - sx) < 4) {
582
+ return (
583
+ `<path class="trace-arrow-heap" d="M ${sx} ${sy} L ${tx} ${ty}" ` +
584
+ `marker-end="url(#trace-arrowhead-dim)"/>`
585
+ );
586
+ }
587
+
588
+ // Horizontal channel at the midpoint between source and target top.
589
+ // Sibling arrows diverge horizontally so they share the same channel Y.
590
+ const channelY = Math.round((sy + ty) / 2);
591
+
592
+ const vertSpace = Math.abs(channelY - sy);
593
+ const horizSpace = Math.abs(tx - sx);
594
+ const r = Math.min(5, vertSpace / 2, horizSpace / 2);
595
+ const dir = tx > sx ? 1 : -1;
596
+
597
+ const path =
598
+ `M ${sx} ${sy} ` +
599
+ `L ${sx} ${channelY - r} ` +
600
+ `Q ${sx} ${channelY}, ${sx + dir * r} ${channelY} ` +
601
+ `L ${tx - dir * r} ${channelY} ` +
602
+ `Q ${tx} ${channelY}, ${tx} ${channelY + r} ` +
603
+ `L ${tx} ${ty}`;
604
+
605
+ return (
606
+ `<path class="trace-arrow-heap" d="${path}" ` +
607
+ `marker-end="url(#trace-arrowhead-dim)"/>`
608
+ );
609
+ }
610
+
611
+ // Non-structural heap arrow: cubic Bezier with auto-detected direction
612
+ // (typically horizontal toward ref chains).
613
+ function heapArrow(sx: number, sy: number, target: HeapObjInfo): string {
614
+ const ep = rectBoundaryPoint(
615
+ sx,
616
+ sy,
617
+ target.absX,
618
+ target.absY,
619
+ target.width,
620
+ target.height,
621
+ );
622
+ const dx = ep.x - sx;
623
+ const dy = ep.y - sy;
624
+ const dist = Math.sqrt(dx * dx + dy * dy);
625
+ if (dist < 1) {
626
+ return '';
627
+ }
628
+
629
+ const d = Math.min(dist * 0.4, 50);
630
+ let cp1x: number;
631
+ let cp1y: number;
632
+
633
+ if (Math.abs(dy) >= Math.abs(dx)) {
634
+ cp1x = sx;
635
+ cp1y = dy >= 0 ? sy + d : sy - d;
636
+ } else {
637
+ cp1x = dx >= 0 ? sx + d : sx - d;
638
+ cp1y = sy;
639
+ }
640
+
641
+ // Approach target along its surface normal
642
+ const tcx = target.absX + target.width / 2;
643
+ const tcy = target.absY + target.height / 2;
644
+ const nx = ep.x - tcx;
645
+ const ny = ep.y - tcy;
646
+ const nlen = Math.sqrt(nx * nx + ny * ny);
647
+ let cp2x = ep.x;
648
+ let cp2y = ep.y;
649
+ if (nlen > 0) {
650
+ cp2x = ep.x + (nx / nlen) * d;
651
+ cp2y = ep.y + (ny / nlen) * d;
652
+ }
653
+
654
+ return (
655
+ `<path class="trace-arrow-heap" d="` +
656
+ `M ${sx} ${sy} C ${Math.round(cp1x)} ${Math.round(cp1y)}, ` +
657
+ `${Math.round(cp2x)} ${Math.round(cp2y)}, ${ep.x} ${ep.y}" ` +
658
+ `marker-end="url(#trace-arrowhead-dim)"/>`
659
+ );
660
+ }
661
+
662
+ // Collect dim arrows first, then current-frame arrows, so fg-color
663
+ // arrows paint on top in SVG document order.
664
+ const currentFrameIndex = stackLayouts.length - 1;
665
+ const fgArrows: string[] = [];
666
+
667
+ // Stack-to-heap arrows from non-current frames (dim)
668
+ for (let fi = 0; fi < stackLayouts.length; fi++) {
669
+ const fl = stackLayouts[fi];
670
+ if (fi === currentFrameIndex) {
671
+ continue;
672
+ }
673
+ for (const [name, val] of Object.entries(fl.frame.locals)) {
674
+ if (val.type !== 'ref') {
675
+ continue;
676
+ }
677
+ const target = heapObjMap.get(val.id);
678
+ const from = fl.varPositions.get(name);
679
+ if (!target || !from) {
680
+ continue;
681
+ }
682
+ parts.push(arrow(Math.round(from.x), Math.round(from.y), target, 'dim'));
683
+ }
684
+ }
685
+
686
+ // Stack-to-heap arrows from current frame (fg, deferred)
687
+ if (currentFrameIndex >= 0) {
688
+ const fl = stackLayouts[currentFrameIndex];
689
+ for (const [name, val] of Object.entries(fl.frame.locals)) {
690
+ if (val.type !== 'ref') {
691
+ continue;
692
+ }
693
+ const target = heapObjMap.get(val.id);
694
+ const from = fl.varPositions.get(name);
695
+ if (!target || !from) {
696
+ continue;
697
+ }
698
+ fgArrows.push(
699
+ arrow(Math.round(from.x), Math.round(from.y), target, 'fg'),
700
+ );
701
+ }
702
+ }
703
+
704
+ // Heap-to-heap arrows (dim)
705
+ for (const [id, objInfo] of heapObjMap) {
706
+ const obj = heap[id];
707
+ if (!obj) {
708
+ continue;
709
+ }
710
+
711
+ if ('fields' in obj) {
712
+ const childFieldSet = new Set(objInfo.pos.childFields ?? []);
713
+ for (const [fieldName, val] of Object.entries(obj.fields)) {
714
+ if (val.type !== 'ref') {
715
+ continue;
716
+ }
717
+ const target = heapObjMap.get(val.id);
718
+ if (!target) {
719
+ continue;
720
+ }
721
+
722
+ if (childFieldSet.has(fieldName)) {
723
+ // Structural child: orthogonal routing through per-slot channel
724
+ const start = childSlotCenter(objInfo, fieldName, dims);
725
+ parts.push(childSlotArrow(start.x, start.y, target));
726
+ } else {
727
+ // Data field: cubic Bezier with auto direction
728
+ const vx = objInfo.pos.valueX ?? Math.round(dims.fontSize * 7);
729
+ let rowIdx = 0;
730
+ for (const name of Object.keys(obj.fields)) {
731
+ if (childFieldSet.has(name)) {
732
+ continue;
733
+ }
734
+ if (name === fieldName) {
735
+ break;
736
+ }
737
+ rowIdx++;
738
+ }
739
+ const start = {
740
+ x: Math.round(objInfo.absX + vx + (objInfo.width - vx) / 2),
741
+ y: Math.round(objInfo.absY + rowIdx * dims.rowH + dims.rowH / 2),
742
+ };
743
+ parts.push(heapArrow(start.x, start.y, target));
744
+ }
745
+ }
746
+ }
747
+
748
+ if ('elements' in obj) {
749
+ const cellCount = Math.min(obj.elements.length, ARRAY_MAX_CELLS);
750
+ const cw = objInfo.pos.cellWidth ?? Math.round(DEFAULT_FONT_SIZE * 3);
751
+ for (let i = 0; i < cellCount; i++) {
752
+ const el = obj.elements[i];
753
+ if (el.type !== 'ref') {
754
+ continue;
755
+ }
756
+ const target = heapObjMap.get(el.id);
757
+ if (!target) {
758
+ continue;
759
+ }
760
+ const sx = Math.round(objInfo.absX + (i + 0.5) * cw);
761
+ const sy = Math.round(objInfo.absY + objInfo.height / 2);
762
+ parts.push(heapArrow(sx, sy, target));
763
+ }
764
+ }
765
+ }
766
+
767
+ // Append fg-color arrows last so they render on top
768
+ for (const a of fgArrows) {
769
+ parts.push(a);
770
+ }
771
+
772
+ return parts;
773
+ }
774
+
775
+ // ---------------------------------------------------------------------------
776
+ // Main entry point
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /**
780
+ * Generate a complete SVG string for a single trace step.
781
+ */
782
+ export function generateStepSvg(
783
+ step: TraceStep,
784
+ layout: TraceLayout,
785
+ fontSize: number = DEFAULT_FONT_SIZE,
786
+ ): string {
787
+ const { stack, heap } = filterStep(step);
788
+
789
+ const hasVars = stack.some(f => Object.keys(f.locals).length > 0);
790
+ const hasHeap = Object.keys(heap).length > 0;
791
+
792
+ if (!hasVars && !hasHeap) {
793
+ return (
794
+ '<svg class="trace-memory" viewBox="0 0 0 0" ' +
795
+ 'xmlns="http://www.w3.org/2000/svg"></svg>'
796
+ );
797
+ }
798
+
799
+ const rowH = Math.round(fontSize * 2);
800
+ const varBoxX = Math.round(fontSize * 7);
801
+ const varBoxW = Math.round(fontSize * 4);
802
+ const frameWidth = varBoxX + varBoxW + PADDING;
803
+
804
+ const dims = { rowH, varBoxX, varBoxW, frameWidth, fontSize };
805
+
806
+ // Layout stack
807
+ const stackLayouts = layoutStack(stack, dims);
808
+ const stackRight = frameWidth;
809
+ const stackBottom =
810
+ stackLayouts.length > 0
811
+ ? Math.max(...stackLayouts.map(l => l.y + l.height))
812
+ : 0;
813
+
814
+ // Position heap objects (layout positions are relative to heap area)
815
+ const heapX = stackRight + SECTION_GAP;
816
+
817
+ // Build heap object info map
818
+ const heapObjMap = new Map<string, HeapObjInfo>();
819
+ for (const [id] of Object.entries(heap)) {
820
+ const pos = layout.objects[id];
821
+ if (!pos) {
822
+ continue;
823
+ }
824
+
825
+ heapObjMap.set(id, {
826
+ id,
827
+ absX: heapX + pos.x,
828
+ absY: PADDING + pos.y,
829
+ width: pos.width,
830
+ height: pos.height,
831
+ pos,
832
+ });
833
+ }
834
+
835
+ const heapBottom =
836
+ heapObjMap.size > 0
837
+ ? Math.max(...Array.from(heapObjMap.values()).map(l => l.absY + l.height))
838
+ : 0;
839
+ const heapRight =
840
+ heapObjMap.size > 0
841
+ ? Math.max(...Array.from(heapObjMap.values()).map(l => l.absX + l.width))
842
+ : heapX;
843
+
844
+ const totalWidth =
845
+ (hasHeap ? heapRight + ARROW_MARGIN : stackRight) + PADDING;
846
+ const totalHeight = Math.max(stackBottom, heapBottom) + PADDING;
847
+
848
+ // Build SVG
849
+ const parts: string[] = [];
850
+ parts.push(
851
+ `<svg class="trace-memory" viewBox="0 0 ${totalWidth} ${totalHeight}" ` +
852
+ `width="${totalWidth}" height="${totalHeight}" ` +
853
+ `xmlns="http://www.w3.org/2000/svg">`,
854
+ );
855
+
856
+ // Defs: arrowhead markers
857
+ parts.push('<defs>');
858
+ parts.push(
859
+ '<marker id="trace-arrowhead" markerWidth="8" markerHeight="6" ' +
860
+ 'refX="8" refY="3" orient="auto" markerUnits="strokeWidth">',
861
+ );
862
+ parts.push('<polygon class="trace-arrowhead" points="0 0, 8 3, 0 6"/>');
863
+ parts.push('</marker>');
864
+ parts.push(
865
+ '<marker id="trace-arrowhead-dim" markerWidth="8" markerHeight="6" ' +
866
+ 'refX="8" refY="3" orient="auto" markerUnits="strokeWidth">',
867
+ );
868
+ parts.push('<polygon class="trace-arrowhead-dim" points="0 0, 8 3, 0 6"/>');
869
+ parts.push('</marker>');
870
+ parts.push('</defs>');
871
+
872
+ // Stack right border
873
+ parts.push(
874
+ `<line class="trace-separator" x1="${stackRight}" y1="0" ` +
875
+ `x2="${stackRight}" y2="${totalHeight}"/>`,
876
+ );
877
+
878
+ // Stack frames
879
+ for (let i = 0; i < stackLayouts.length; i++) {
880
+ parts.push(svgFrame(stackLayouts[i], i === 0, dims));
881
+ }
882
+
883
+ // Heap objects
884
+ for (const [id, objInfo] of heapObjMap) {
885
+ const obj = heap[id];
886
+ parts.push(
887
+ svgHeapObject(id, obj, objInfo.pos, dims, objInfo.absX, objInfo.absY),
888
+ );
889
+ }
890
+
891
+ // Arrows
892
+ const arrowParts = generateArrows(
893
+ stack,
894
+ heap,
895
+ stackLayouts,
896
+ heapObjMap,
897
+ dims,
898
+ );
899
+ for (const arrow of arrowParts) {
900
+ parts.push(arrow);
901
+ }
902
+
903
+ parts.push('</svg>');
904
+ return parts.join('');
905
+ }