Skill Tracer
Skills › Archify › archify › render-waterfall.mjs

render-waterfall.mjs

413 lines · archify/renderers/waterfall/render-waterfall.mjs at a406594

1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
4import {
5 animateAttr,
6 focusEdgeAttrs,
7 focusNodeAttrs,
8 focusNodeTitle,
9 loadDiagram,
10 svgAccessibleText,
11 svgRootAttrs,
12 writeDiagram,
13} from '../shared/cli.mjs';
14import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
15import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
16import { nodeTextFit } from '../shared/text-fit.mjs';
17import { translateMessage as i18nText } from '../shared/i18n.mjs';
18import { asArray } from '../shared/geometry.mjs';
20const __dirname = path.dirname(fileURLToPath(import.meta.url));
21const layoutJsonMode = process.argv.includes('--layout-json');
22const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
23const { diagram: wf, template, outPath, sourceEvidence } = loadDiagram({
24 rendererDir: __dirname,
25 diagramType: 'waterfall',
26 defaultExample: 'checkout-request.waterfall.json',
27 argv: cliArgs,
28});
29const locale = wf.meta.locale;
30const unit = wf.meta.unit || 'ms';
31// The schema spells microseconds `us` for ASCII input; the canvas uses µs.
32const unitLabel = unit === 'us' ? 'µs' : unit;
33const ADVANCE = nodeTextFit.widthFactor;
35const layout = {
36 margin: 28,
37 top: 52,
38 axisH: 36,
39 rowH: 25,
40 barH: 14,
41 indent: 18,
42 nameFont: 12.5,
43 durationFont: 11,
44 axisWidth: wf.layout?.width ?? 760,
45 columnGap: 28,
46 minTickPx: 80,
47};
49// ---- Timing checks -------------------------------------------------------------
50// Bars are drawn from recorded timing only. A span states `end` or `duration`
51// (not both unless they agree); only `status: "incomplete"` may state neither,
52// and it is drawn as an open bar up to the last recorded instant instead of
53// with an invented end. Parents must exist and must not form a cycle.
54const problems = [];
55const details = [];
56function fail(code, message, subject, evidence, supportedFixes) {
57 problems.push(message);
58 details.push({ code, severity: 'error', message, subject: { diagramType: 'waterfall', ...subject }, evidence, supportedFixes });
60const raw = asArray(wf.spans);
61const byId = new Map();
62for (const [index, span] of raw.entries()) {
63 if (byId.has(span.id)) fail('waterfall/duplicate-id', `Span id "${span.id}" is declared twice.`, { path: `/spans/${index}/id` }, { id: span.id }, ['Give every span a unique id.']);
64 byId.set(span.id, span);
65 const hasEnd = Number.isFinite(span.end);
66 const hasDuration = Number.isFinite(span.duration);
67 const timing = (message, fixes) => fail('waterfall/invalid-timing', `Span "${span.id}" ${message}`, { path: `/spans/${index}`, nodeId: span.id },
68 { start: span.start, end: span.end ?? null, duration: span.duration ?? null, status: span.status ?? 'ok' }, fixes);
69 if (hasDuration && !Number.isFinite(span.start + span.duration)) {
70 timing('has a derived end outside the finite numeric range.', ['Rescale the recorded timing and unit to a representable range.']);
71 }
72 if (hasEnd && span.end < span.start) timing(`ends (${span.end}) before it starts (${span.start}).`, ['Correct start or end from the recorded timing.']);
73 if (hasEnd && hasDuration && Math.abs(span.end - span.start - span.duration) > 1e-9) {
74 timing(`states end ${span.end} and duration ${span.duration}, which disagree with start ${span.start}.`, ['Keep only one of end or duration.']);
75 }
76 if (!hasEnd && !hasDuration && span.status !== 'incomplete') {
77 timing('has neither end nor duration. A span without a recorded end must be marked status "incomplete".', ['Add the recorded end or duration.', 'Set status to "incomplete" if the trace has no end for it.']);
78 }
80for (const [index, span] of raw.entries()) {
81 if (span.parent !== undefined && !byId.has(span.parent)) {
82 fail('waterfall/missing-parent', `Span "${span.id}" names parent "${span.parent}", which is not declared.`,
83 { path: `/spans/${index}/parent`, nodeId: span.id }, { parent: span.parent }, ['Declare the parent span.', 'Remove parent to make this a root span.']);
84 }
87 const reported = new Set();
88 for (const [index, span] of raw.entries()) {
89 const chain = [];
90 let current = span;
91 while (current && current.parent !== undefined && !chain.includes(current.id)) {
92 chain.push(current.id);
93 current = byId.get(current.parent);
94 }
95 if (current && chain.includes(current.id)) {
96 const cycle = chain.slice(chain.indexOf(current.id));
97 const key = [...cycle].sort().join('|');
98 if (reported.has(key)) continue;
99 reported.add(key);
100 fail('waterfall/cycle', `Parent links form a cycle: ${[...cycle, cycle[0]].map((id) => `"${id}"`).join(' -> ')}.`,
101 { path: `/spans/${index}/parent`, nodeId: span.id }, { cycle }, ['Point one span in the cycle at a parent outside it.']);
102 }
103 }
105if (problems.length) throwDiagnosticProblems('Waterfall validation failed', problems, { code: 'waterfall/input', subject: { diagramType: 'waterfall' }, diagnostics: details });
107// ---- Timing model --------------------------------------------------------------
108const recordedEnd = (span) => (Number.isFinite(span.end) ? span.end : Number.isFinite(span.duration) ? span.start + span.duration : null);
109const t0 = Math.min(...raw.map((span) => span.start));
110const t1 = Math.max(...raw.map((span) => recordedEnd(span) ?? span.start));
111const wall = t1 - t0;
113const spans = raw.map((span, index) => {
114 const end = recordedEnd(span);
115 const open = end === null;
116 // An incomplete span with no end is drawn to the last recorded instant of
117 // the trace and labelled as a lower bound, never as a measured duration.
118 return { ...span, index, status: span.status || 'ok', open, end: open ? t1 : end, duration: (open ? t1 : end) - span.start };
119});
120const children = new Map(spans.map((span) => [span.id, []]));
121for (const span of spans) if (span.parent !== undefined) children.get(span.parent).push(span);
122const byStart = (a, b) => a.start - b.start || a.index - b.index;
123const rows = [];
124(function visit(list, depth) {
125 for (const span of [...list].sort(byStart)) {
126 rows.push({ ...span, depth });
127 visit(children.get(span.id), depth + 1);
128 }
129}(spans.filter((span) => span.parent === undefined), 0));
131function number(value) {
132 if (value !== 0 && Math.abs(value) < 0.01) {
133 return Math.abs(value) < 1e-6 ? Number(value.toPrecision(3)).toString()
134 : value.toLocaleString('en-US', { maximumSignificantDigits: 3 });
135 }
136 const rounded = Math.abs(value) > Number.MAX_VALUE / 100 ? value : Math.round(value * 100) / 100;
137 return rounded.toLocaleString('en-US', { maximumFractionDigits: 2 });
139const formatDuration = (value) => `${number(value)} ${unitLabel}`;
140function percent(value) {
141 if (!wall) return '100%';
142 const share = (value / wall) * 100;
143 return `${share >= 10 ? Math.round(share) : Math.round(share * 10) / 10}%`;
146// ---- Tones ---------------------------------------------------------------------
147// Colour states who did the work: each service gets its own palette family in
148// order of first appearance, and a span without one inherits its parent's.
149// A span no service owns stays neutral. Red is never a service colour: it is
150// reserved for status "error", so a failure is never mistaken for a team.
151const SERVICE_TONES = ['frontend', 'backend', 'database', 'cloud', 'messagebus'];
152const services = [...new Set(rows.map((row) => row.service).filter(Boolean))];
153const serviceOf = new Map();
154for (const row of rows) serviceOf.set(row.id, row.service ?? (row.parent !== undefined ? serviceOf.get(row.parent) : undefined));
155const serviceTone = (service) => (service === undefined ? 'external' : SERVICE_TONES[services.indexOf(service) % SERVICE_TONES.length]);
156const STATUS_TONE = { error: 'security', cancelled: 'external' };
157const toneOf = (row) => STATUS_TONE[row.status] ?? serviceTone(serviceOf.get(row.id));
159// ---- Geometry ------------------------------------------------------------------
160// Name column: tree indent, then a service dot, then the name.
161const NAME_DOT = 14;
162const nameWidth = (row) => row.depth * layout.indent + NAME_DOT + textUnits(row.name) * layout.nameFont * ADVANCE * 1.04;
163const labelW = Math.ceil(Math.max(140, ...rows.map(nameWidth)));
164const axisX0 = layout.margin + labelW + layout.columnGap;
165const axisX1 = axisX0 + layout.axisWidth;
166const scale = wall > 0 ? layout.axisWidth / wall : 0;
167if (!Number.isFinite(scale)) {
168 fail('waterfall/invalid-timing', 'The recorded range cannot form a finite axis scale.',
169 { path: '/spans' }, { start: t0, end: t1, wall }, ['Rescale the recorded timing and unit to a representable range.']);
170 throwDiagnosticProblems('Waterfall validation failed', problems, { diagnostics: details });
172const xOf = (t) => Math.round((axisX0 + (t - t0) * scale) * 100) / 100;
173const rowsTop = layout.top + layout.axisH;
175const NICE = [1, 2, 2.5, 5];
176const tickStep = (() => {
177 if (!wall) return 1;
178 const target = (layout.minTickPx / layout.axisWidth) * wall;
179 let magnitude = 10 ** Math.floor(Math.log10(target));
180 for (;;) {
181 for (const factor of NICE) if (factor * magnitude >= target) return factor * magnitude;
182 magnitude *= 10;
183 }
184})();
185const ticks = [];
186const firstTick = Math.ceil(t0 / tickStep) * tickStep;
187const tickCount = Math.max(0, Math.floor((t1 - firstTick) / tickStep + 1e-9) + 1);
188for (let index = 0; index < tickCount; index += 1) {
189 const t = firstTick + index * tickStep;
190 // Large timestamps can round adjacent ticks to the same double. Index-based
191 // iteration remains bounded even when adding tickStep cannot advance t.
192 if (ticks.at(-1)?.t !== t) ticks.push({ t, x: xOf(t) });
195// Absolute times need enough precision for the axis and the shortest recorded
196// span, rather than the duration formatter's fixed significant-digit floor.
197const timeResolution = Math.min(tickStep, ...spans.filter(row => row.duration > 0).map(row => row.duration));
198function timeNumber(value) {
199 const ordinary = number(value);
200 if (Math.abs(value) < 1e21 && Math.abs(Number(ordinary.replaceAll(',', '')) - value) <= timeResolution / 100) return ordinary;
201 const digits = value === 0 ? 1 : Math.min(17, Math.max(1, Math.floor(Math.log10(Math.abs(value))) - Math.floor(Math.log10(timeResolution)) + 2));
202 const rounded = Number(value.toPrecision(digits));
203 return Number.isFinite(rounded) ? rounded.toString() : value.toString();
206const LABEL_GAP = 7;
207const parentIds = new Set(rows.map((row) => row.parent).filter((id) => id !== undefined));
208const placed = rows.map((row, index) => {
209 const y = rowsTop + index * layout.rowH;
210 const x0 = xOf(row.start);
211 const x1 = xOf(row.end);
212 const width = Math.max(2, x1 - x0);
213 const label = row.open ? i18nText(locale, 'waterfall.incomplete', { duration: formatDuration(row.duration) }) : formatDuration(row.duration);
214 const labelWidth = textUnits(label) * layout.durationFont * ADVANCE + 4;
215 // Every duration reads at the same place, just after its bar; the canvas
216 // reserves room past the axis for the widest one, so a short operation and
217 // a bar that ends at the wall-clock edge are labelled the same way.
218 return { ...row, y, x0, x1, width, label, labelWidth, placement: 'after', tone: toneOf(row), parentBar: parentIds.has(row.id) };
219});
221// ---- Legend & canvas -----------------------------------------------------------
222// The legend names the services (when more than one does work) and any status
223// that changes a bar's paint; a single-service trace with only ok spans needs
224// no key.
225const presentStatuses = new Set(placed.map((row) => row.status));
226// Past five services the palette repeats. Services that share a colour share
227// one entry, so the key never claims a colour belongs to a single service.
228const serviceLegendEntries = [...services.reduce((byTone, service) => {
229 const tone = serviceTone(service);
230 byTone.set(tone, [...(byTone.get(tone) || []), service]);
231 return byTone;
232}, new Map())].map(([tone, owners]) => ({ kind: `service:${owners[0]}`, label: owners.join(' · '), tone, interactive: false }));
233const legendEntries = [
234 ...(services.length > 1 ? serviceLegendEntries : []),
235 ...resolveLegend(undefined, ['error', 'cancelled', 'incomplete'].map((kind) => ({ kind, label: i18nText(locale, `legend.waterfall.${kind}`), tone: STATUS_TONE[kind] ?? 'external', interactive: false })), presentStatuses),
236];
237const contentBottom = rowsTop + placed.length * layout.rowH + 8;
238const labelRoom = Math.ceil(Math.max(...placed.map((row) => row.x1 + LABEL_GAP + row.labelWidth)) - axisX1);
239const width = Math.ceil(axisX1 + Math.max(24, labelRoom) + layout.margin);
240const axisLabels = ticks.map(tick => {
241 const text = `${timeNumber(tick.t)} ${unitLabel}`;
242 const halfWidth = (textUnits(text) * 10.5 * ADVANCE + 4) / 2;
243 const x = Math.max(layout.margin + halfWidth, Math.min(tick.x, width - layout.margin - halfWidth));
244 return { text, x, left: x - halfWidth, right: x + halfWidth };
245});
246// Keep the endpoints readable; omit intermediate labels that cannot fit. The
247// ticks and the proportional time geometry remain unchanged.
248if (axisLabels.length > 1) {
249 const first = axisLabels[0];
250 const last = axisLabels.at(-1);
251 const overlap = Math.max(0, first.right + 8 - last.left);
252 const leftShift = Math.min(overlap, first.left - layout.margin);
253 first.x -= leftShift;
254 first.left -= leftShift;
255 first.right -= leftShift;
256 const rightShift = Math.min(overlap - leftShift, width - layout.margin - last.right);
257 last.x += rightShift;
258 last.left += rightShift;
259 last.right += rightShift;
261let previousLabelRight = -Infinity;
262for (const [index, label] of axisLabels.entries()) {
263 const last = axisLabels.at(-1);
264 if (label.left < previousLabelRight + 8 || (index > 0 && index < axisLabels.length - 1 && label.right + 8 > last.left)) continue;
265 label.visible = true;
266 previousLabelRight = label.right;
268const footprint = legendFootprint(legendEntries, { width: width - layout.margin * 2, fontSize: 11 });
269const height = Math.ceil(contentBottom + layout.margin + (legendEntries.length ? 34 + footprint.extraHeight : 0));
270const viewBox = [width, height];
272// ---- Rendering -----------------------------------------------------------------
273function renderHeader() {
274 const evidence = i18nText(locale, `waterfall.evidence.${wf.meta.evidence}`);
275 const evidenceW = Math.ceil(textUnits(evidence) * 10.5 * ADVANCE + 24);
276 const tone = wf.meta.evidence === 'measured' ? 'backend' : 'messagebus';
277 const wallText = i18nText(locale, 'waterfall.wall', { duration: formatDuration(wall) });
278 const range = `${timeNumber(t0)} → ${timeNumber(t1)} ${unitLabel}`;
279 const wallX = layout.margin + evidenceW + 14;
280 return ` <g data-waterfall-evidence="${esc(wf.meta.evidence)}">
281 <rect x="${layout.margin}" y="14" width="${evidenceW}" height="22" rx="11" class="c-${tone}" stroke-width="1"/>
282 <circle cx="${layout.margin + 11}" cy="25" r="3" class="t-${tone}"/>
283 <text x="${layout.margin + evidenceW / 2 + 5}" y="29" class="t-${tone}" font-size="10.5" font-weight="700" text-anchor="middle">${esc(evidence)}</text>
284 </g>
285 <text data-waterfall-total="${wall}" x="${wallX}" y="29.5" class="t-primary wf-num" font-size="13" font-weight="700">${esc(wallText)}<tspan class="t-muted" font-weight="500" dx="10">${esc(range)}</tspan></text>`;
288function renderAxis() {
289 const lineY = rowsTop - 6;
290 return [
291 ` <text x="${layout.margin}" y="${lineY - 10}" class="t-muted wf-caps" font-size="10" font-weight="700">${esc(i18nText(locale, 'waterfall.operation'))}</text>`,
292 ` <line x1="${axisX0}" y1="${lineY}" x2="${axisX1}" y2="${lineY}" class="wf-axis" stroke-width="1"/>`,
293 ...ticks.map((tick, index) => ` <line x1="${tick.x}" y1="${lineY}" x2="${tick.x}" y2="${contentBottom}" class="wf-grid${index ? '' : ' wf-grid-origin'}" stroke-width="1"/>
294 <line x1="${tick.x}" y1="${lineY - 4}" x2="${tick.x}" y2="${lineY}" class="wf-axis" stroke-width="1"/>
295 ${axisLabels[index].visible ? `<text x="${axisLabels[index].x}" y="${lineY - 10}" class="t-muted wf-num" font-size="10.5" text-anchor="middle">${esc(axisLabels[index].text)}</text>` : ''}`),
296 ].join('\n');
299// Tree guides in the name column state the parent/child structure; the bars
300// themselves only ever state time.
301const dotX = (row) => layout.margin + row.depth * layout.indent + 4;
303function renderGuides() {
304 const rowIndex = new Map(placed.map((row, index) => [row.id, index]));
305 return placed.filter((row) => row.parent !== undefined).map((row) => {
306 const parent = placed[rowIndex.get(row.parent)];
307 const parentY = parent.y + layout.rowH / 2 + 6;
308 const y = row.y + layout.rowH / 2;
309 const x = dotX(parent);
310 return ` <path ${focusEdgeAttrs(row.parent, row.id, undefined, row.index)} d="M ${x} ${parentY} V ${y - 4} Q ${x} ${y} ${x + 4} ${y} H ${dotX(row) - 7}" class="wf-guide" stroke-width="1" fill="none"/>`;
311 }).join('\n');
314// A span with children is drawn as a tinted, outlined bracket over its time;
315// a span that does the work itself is a solid bar. Parent and child are never
316// added together, and the paint says which bars are inclusive summaries.
317function renderRow(row, order) {
318 const parent = row.parent !== undefined ? placed.find((candidate) => candidate.id === row.parent) : null;
319 const timing = `${timeNumber(row.start)}–${row.open ? '…' : timeNumber(row.end)} ${unitLabel} · ${row.label} · ${i18nText(locale, 'waterfall.share', { percent: percent(row.duration), total: formatDuration(wall) })}`;
320 const service = serviceOf.get(row.id);
321 const passport = { kind: row.tone, sublabel: [timing, row.detail].filter(Boolean).join(' — '), context: [service, parent?.name].filter(Boolean).join(' \u203a ') || undefined };
322 const midY = row.y + layout.rowH / 2;
323 const barY = midY - layout.barH / 2;
324 const nameX = dotX(row) + 10;
325 const paint = row.open ? 'wf-open' : row.parentBar ? 'wf-span' : 'wf-solid';
326 const openTail = row.open ? `<path d="M ${row.x1 - 8} ${barY + 2} L ${row.x1 - 2} ${midY} L ${row.x1 - 8} ${barY + layout.barH - 2}" class="wf-open-edge t-${row.tone}" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>` : '';
327 return ` <g ${focusNodeAttrs(row.id, row.name, passport, locale)} data-waterfall-start="${row.start}" data-waterfall-end="${row.open ? '' : row.end}" data-waterfall-duration="${row.duration}" data-waterfall-depth="${row.depth}" data-waterfall-status="${row.status}"${service ? ` data-waterfall-service="${esc(service)}"` : ''}>
328 ${focusNodeTitle(row.name, passport)}
329 <rect x="${layout.margin - 8}" y="${row.y + 1}" width="${width - layout.margin * 2 + 16}" height="${layout.rowH - 2}" rx="6" class="c-mask wf-row${order % 2 ? ' wf-row-alt' : ''}"/>
330 <rect x="${row.x0}" y="${barY}" width="${row.width}" height="${layout.barH}" rx="${Math.min(4, row.width / 2)}" class="c-${row.tone} ${paint}"${animateAttr(wf.meta, 'node', order)} stroke-width="1.2"/>
331 ${openTail}
332 <circle cx="${dotX(row)}" cy="${midY}" r="${row.depth ? 3.5 : 4.5}" class="t-${row.tone} wf-dot"/>
333 <text data-node-label="" x="${nameX}" y="${midY + 4.3}" class="t-primary" font-size="${layout.nameFont}" font-weight="${row.depth ? (row.parentBar ? 650 : 500) : 750}">${esc(row.name)}</text>
334 <text x="${row.x1 + LABEL_GAP}" y="${midY + 3.8}" class="${row.parentBar ? 't-primary' : 't-muted'} wf-num" font-size="${layout.durationFont}" font-weight="${row.parentBar ? 700 : 600}">${esc(row.label)}</text>
335 </g>`;
338function legendSwatch(entry) {
339 const paint = entry.kind === 'incomplete' ? 'wf-open' : 'wf-solid';
340 return `<rect x="${entry.x}" y="${entry.baseline - 10}" width="14" height="10" rx="2.5" class="c-${entry.tone} ${paint}" stroke-width="1.2"/>`;
343const SOLID_TONES = ['frontend', 'backend', 'database', 'cloud', 'messagebus', 'security', 'external'];
345function renderSvg() {
346 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(wf.meta)} data-motion-choreography="replay" data-waterfall-ui="" data-waterfall-unit="${esc(unit)}" data-reader-fit="width-first" data-reader-min-text="7.5">
347${svgAccessibleText(wf.meta, 'waterfall')}
348${renderDefinitions()}
349 <style>
350 svg[data-waterfall-ui] .wf-axis { stroke: var(--text-dim); }
351 svg[data-waterfall-ui] .wf-grid { stroke: var(--lane-stroke); stroke-dasharray: 2 4; opacity: .7; }
352 svg[data-waterfall-ui] .wf-grid-origin { stroke-dasharray: none; opacity: .9; }
353 svg[data-waterfall-ui] .wf-row { fill: transparent; }
354 svg[data-waterfall-ui] .wf-row-alt { fill: var(--lane-fill); fill-opacity: 1; opacity: .7; }
355 svg[data-waterfall-ui] .wf-guide { stroke: var(--lane-stroke); }
356 svg[data-waterfall-ui] .wf-span { fill-opacity: .9; }
357 svg[data-waterfall-ui] .wf-open { stroke-dasharray: 3 2.5; fill-opacity: .6; }
358 svg[data-waterfall-ui] .wf-open-edge { fill: none !important; }
359 svg[data-waterfall-ui] .wf-num { font-variant-numeric: tabular-nums; }
360 svg[data-waterfall-ui] .wf-caps { letter-spacing: .06em; }
361 svg[data-waterfall-ui] .wf-solid { stroke: none; }
362 /* Thin bars stay crisp: the classic preset's card shadow blurs their edges. */
363 svg[data-waterfall-ui] [data-node-id] > rect { filter: none !important; }
364${SOLID_TONES.map((tone) => ` svg[data-waterfall-ui] .wf-solid.c-${tone} { fill: var(--${tone}-stroke); }
365 svg[data-waterfall-ui] .wf-open-edge.t-${tone} { stroke: var(--${tone}-stroke); }`).join('\n')}
366 </style>
368 <!-- Background Grid -->
369 <rect width="100%" height="100%" fill="url(#grid)" />
371${renderHeader()}
373 <!-- Time axis (bar position and length come only from recorded timing) -->
374${renderAxis()}
376 <!-- Parent / child guides -->
377${renderGuides()}
379 <!-- Spans, depth-first by start time -->
380${placed.map(renderRow).join('\n')}
382 <!-- Legend -->
383${legendEntries.length ? renderResolvedLegend({ entries: legendEntries, locale, layout: { x: layout.margin, baselineY: height - 16, width: width - layout.margin * 2, fontSize: 11, minTitleY: contentBottom + 8, diagramType: 'waterfall' }, renderSwatch: legendSwatch }) : ''}
384 </svg>`;
387function buildLayoutReport() {
388 return {
389 diagram_type: 'waterfall',
390 unit,
391 viewBox,
392 origin: t0,
393 wall,
394 pxPerUnit: scale,
395 axis: { x0: axisX0, x1: axisX1 },
396 ticks,
397 rows: placed.map((row) => ({ id: row.id, parent: row.parent ?? null, depth: row.depth, start: row.start, end: row.end, open: row.open, x0: row.x0, x1: row.x1, y: row.y, label: row.label, placement: row.placement })),
398 };
401if (layoutJsonMode) {
402 console.log(JSON.stringify(buildLayoutReport(), null, 2));
403 process.exit(0);
405writeDiagram({
406 outPath,
407 template,
408 diagramType: 'waterfall',
409 meta: wf.meta,
410 svg: renderSvg(),
411 cards: wf.cards,
412 sourceEvidence,
413});