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

render-timeline.mjs

576 lines · archify/renderers/timeline/render-timeline.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 focusNodeAttrs,
7 focusNodeTitle,
8 loadDiagram,
9 svgAccessibleText,
10 svgRootAttrs,
11 writeDiagram,
12} from '../shared/cli.mjs';
13import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
14import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
15import { nodeTextFit } from '../shared/text-fit.mjs';
16import { translateMessage as i18nText } from '../shared/i18n.mjs';
17import { asArray } from '../shared/geometry.mjs';
19const __dirname = path.dirname(fileURLToPath(import.meta.url));
20const layoutJsonMode = process.argv.includes('--layout-json');
21const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
22const { diagram: tl, template, outPath, sourceEvidence } = loadDiagram({
23 rendererDir: __dirname,
24 diagramType: 'timeline',
25 defaultExample: 'payment-incident.timeline.json',
26 argv: cliArgs,
27});
28const locale = tl.meta.locale;
29const ADVANCE = nodeTextFit.widthFactor;
30const MINUTE = 60_000;
31const HOUR = 60 * MINUTE;
32const DAY = 24 * HOUR;
34const layout = {
35 margin: 28,
36 top: 52,
37 axisH: 60,
38 // The lane column fits its longest label (13px bold), so a lane name never
39 // runs into the axis.
40 laneLabelW: asArray(tl.lanes).length
41 ? Math.ceil(Math.max(88, ...tl.lanes.map((lane) => textUnits(lane.label) * 13 * nodeTextFit.widthFactor * 1.06 + 24)))
42 : 0,
43 axisWidth: tl.layout?.width ?? 960,
44 edgePad: 72,
45 breakW: 60,
46 laneGap: 10,
47 lanePad: 14,
48 // Room between the lane's time line and the nearest card on either side,
49 // which the stem crosses.
50 stemGap: 16,
51 rowGap: 8,
52 cardMaxW: 200,
53 cardPadX: 10,
54 cardAccent: 12,
55 titleFont: 12.5,
56 titleLine: 15,
57 timeFont: 10.5,
58 minTickPx: 84,
59};
61// ---- Input checks --------------------------------------------------------------
62// Time is the one fact this diagram draws, so every timestamp must parse and
63// state its own offset (the schema requires Z or ±HH:MM); the display zone must
64// be a real IANA zone, and every event must sit in a declared lane.
65// Date.parse normalizes some impossible dates (February 30 becomes March 2).
66// Check the authored calendar date independently of its UTC offset first.
67function validCalendarDate(timestamp) {
68 const [year, month, day] = timestamp.slice(0, 10).split('-').map(Number);
69 const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
70 const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
71 return month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1];
73const timezone = tl.meta.timezone || 'UTC';
75 const problems = [];
76 const details = [];
77 const fail = (code, message, subject, evidence, supportedFixes) => {
78 problems.push(message);
79 details.push({ code, severity: 'error', message, subject: { diagramType: 'timeline', ...subject }, evidence, supportedFixes });
80 };
81 try {
82 new Intl.DateTimeFormat('en-US', { timeZone: timezone });
83 } catch {
84 fail('timeline/invalid-timezone', `meta.timezone "${timezone}" is not a recognised IANA time zone.`,
85 { path: '/meta/timezone' }, { timezone }, ['Use an IANA zone such as "UTC", "Europe/Berlin", or "Asia/Shanghai".']);
86 }
87 const laneIds = new Set();
88 for (const [index, lane] of asArray(tl.lanes).entries()) {
89 if (laneIds.has(lane.id)) fail('timeline/duplicate-lane-id', `Lane id "${lane.id}" is declared twice.`,
90 { path: `/lanes/${index}/id` }, { id: lane.id }, ['Give every lane a unique id and update its event references.']);
91 laneIds.add(lane.id);
92 }
93 const seen = new Set();
94 for (const [index, event] of asArray(tl.events).entries()) {
95 if (seen.has(event.id)) fail('timeline/duplicate-id', `Event id "${event.id}" is declared twice.`, { path: `/events/${index}/id` }, { id: event.id }, ['Give every event a unique id.']);
96 seen.add(event.id);
97 if (!validCalendarDate(event.at) || !Number.isFinite(Date.parse(event.at))) {
98 fail('timeline/invalid-timestamp', `Event "${event.id}" timestamp "${event.at}" is not a valid date and time.`,
99 { path: `/events/${index}/at`, nodeId: event.id }, { at: event.at }, ['Use ISO 8601 with an explicit offset, e.g. "2026-10-01T10:05:00+08:00".']);
100 }
101 if (laneIds.size && !laneIds.has(event.lane)) {
102 fail('timeline/unknown-lane', event.lane === undefined
103 ? `Event "${event.id}" has no lane, but the timeline declares lanes.`
104 : `Event "${event.id}" names lane "${event.lane}", which is not declared.`,
105 { path: `/events/${index}/lane`, nodeId: event.id }, { lane: event.lane ?? null, lanes: [...laneIds] },
106 ['Set lane to one of the declared lane ids.', 'Declare the missing lane.']);
107 }
108 }
109 if (problems.length) throwDiagnosticProblems('Timeline validation failed', problems, { code: 'timeline/input', subject: { diagramType: 'timeline' }, diagnostics: details });
112// ---- Time zone formatting ------------------------------------------------------
113const partsFormat = new Intl.DateTimeFormat('en-US', {
114 timeZone: timezone, hourCycle: 'h23', era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
115});
116function wall(ms) {
117 const parts = Object.fromEntries(partsFormat.formatToParts(new Date(ms)).map((part) => [part.type, part.value]));
118 return { y: parts.era === 'BC' ? String(1 - Number(parts.year)) : parts.year, mo: parts.month, d: parts.day, h: parts.hour, mi: parts.minute, s: parts.second };
120// Offset of the display zone at an instant, in ms (local wall clock - UTC).
121function zoneOffset(ms) {
122 const w = wall(ms);
123 const local = new Date(0);
124 local.setUTCFullYear(+w.y, +w.mo - 1, +w.d);
125 local.setUTCHours(+w.h, +w.mi, +w.s, 0);
126 return local.getTime() - Math.floor(ms / 1000) * 1000;
128function offsetLabel(ms) {
129 const minutes = Math.round(zoneOffset(ms) / MINUTE);
130 const sign = minutes < 0 ? '-' : '+';
131 const abs = Math.abs(minutes);
132 return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
135// ---- Events in time order -------------------------------------------------------
136const events = asArray(tl.events)
137 .map((event, index) => ({ ...event, index, t: Date.parse(event.at) }))
138 .sort((a, b) => a.t - b.t || a.index - b.index);
139const times = [...new Set(events.map((event) => event.t))];
140const first = times[0];
141const last = times.at(-1);
142const span = last - first;
144const showSeconds = events.some((event) => new Date(event.t).getUTCSeconds() !== 0 || new Date(event.t).getUTCMilliseconds() !== 0);
145const multiDay = (() => {
146 const a = wall(first);
147 const b = wall(last);
148 return a.y !== b.y || a.mo !== b.mo || a.d !== b.d;
149})();
150function clockLabel(ms, { seconds = showSeconds, date = multiDay } = {}) {
151 const w = wall(ms);
152 const fraction = new Date(ms).getUTCMilliseconds();
153 return `${date ? `${w.mo}-${w.d} ` : ''}${w.h}:${w.mi}${seconds ? `:${w.s}${fraction ? `.${String(fraction).padStart(3, '0')}` : ''}` : ''}`;
156function durationLabel(ms) {
157 const units = [['d', DAY], ['h', HOUR], ['min', MINUTE], ['s', 1000]];
158 const parts = [];
159 let rest = ms;
160 for (const [unit, size] of units) {
161 const value = Math.floor(rest / size);
162 if (value) parts.push(`${value} ${unit}`);
163 rest -= value * size;
164 if (parts.length === 2) break;
165 }
166 return parts.join(' ') || '0 s';
169// The visible break label is approximate (it carries ≈), so it rounds to the
170// two most significant adjacent units: "15 h", never "15 h 3 s".
171function approximateDuration(ms) {
172 const units = [['d', DAY], ['h', HOUR], ['min', MINUTE], ['s', 1000]];
173 const index = units.findIndex(([, size]) => ms >= size);
174 if (index === -1) return '0 s';
175 const [major, majorSize] = units[index];
176 if (index === units.length - 1) return `${Math.round(ms / majorSize)} ${major}`;
177 const [minor, minorSize] = units[index + 1];
178 let whole = Math.floor(ms / majorSize);
179 let rest = Math.round((ms - whole * majorSize) / minorSize);
180 if (rest * minorSize >= majorSize) { whole += 1; rest = 0; }
181 return rest ? `${whole} ${major} ${rest} ${minor}` : `${whole} ${major}`;
184// ---- Axis segments and disclosed breaks ----------------------------------------
185// A quiet period much longer than the timeline's ordinary rhythm would squeeze
186// every other event into a sliver. With `layout.breaks: "auto"` (the default)
187// such a gap is drawn as a fixed-width break that states the omitted duration,
188// so the axis stays proportional inside each segment and never hides that it
189// was compressed. A gap is a break when it is longer than eight times the
190// median gap and longer than a tenth of the whole span; at most eight are
191// drawn (the longest), so bursts of activity get readable room.
192const gaps = times.slice(1).map((time, index) => ({ from: times[index], to: time, length: time - times[index] }));
193const sortedGaps = gaps.map((gap) => gap.length).sort((a, b) => a - b);
194const medianGap = sortedGaps.length ? sortedGaps[Math.floor((sortedGaps.length - 1) / 2)] : 0;
195const SEGMENT_FLOOR = 28;
196const candidates = tl.layout?.breaks === 'none' ? [] : gaps
197 .filter((gap) => gap.length > 8 * medianGap && gap.length > 0.1 * span)
198 .sort((a, b) => b.length - a.length)
199 .slice(0, 8);
201// Reserve room for proportional time before accepting a break. If fixed-width
202// labels would exhaust the authored axis, leave shorter quiet periods on the
203// linear scale instead of collapsing all distinct instants to one position.
204const breaks = [];
205let fixedWidth = layout.edgePad * 2 + SEGMENT_FLOOR;
206for (const gap of candidates) {
207 gap.label = `≈ ${approximateDuration(gap.length)}`;
208 gap.width = Math.max(layout.breakW, Math.ceil(textUnits(gap.label) * 10.5 * ADVANCE + 18));
209 const addedWidth = gap.width + SEGMENT_FLOOR;
210 if (fixedWidth + addedWidth + layout.minTickPx > layout.axisWidth) continue;
211 breaks.push(gap);
212 fixedWidth += addedWidth;
214breaks.sort((a, b) => a.from - b.from);
216const segments = [];
218 let start = first;
219 for (const gap of breaks) {
220 segments.push({ start, end: gap.from });
221 start = gap.to;
222 }
223 segments.push({ start, end: last });
225const linearTime = segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
226const axisX0 = layout.margin + layout.laneLabelW;
227// A break is as wide as its duration label; every segment gets a small floor
228// width so a lone event between two breaks never shares their x, and the rest
229// of the axis is shared in proportion to time. Inside a segment the scale is
230// one constant, so distances there are exact.
231const drawable = layout.axisWidth - layout.edgePad * 2
232 - breaks.reduce((sum, gap) => sum + gap.width, 0) - SEGMENT_FLOOR * segments.length;
233const scale = linearTime > 0 ? Math.max(0, drawable) / linearTime : 0;
235 let x = axisX0 + layout.edgePad;
236 const share = linearTime > 0 ? 0 : Math.max(0, drawable) / segments.length;
237 for (const [index, segment] of segments.entries()) {
238 const inner = (segment.end - segment.start) * scale + share;
239 segment.x0 = x + SEGMENT_FLOOR / 2 + (segment.end === segment.start ? inner / 2 : 0);
240 segment.x1 = segment.end === segment.start ? segment.x0 : segment.x0 + inner;
241 x += SEGMENT_FLOOR + inner;
242 if (index < breaks.length) {
243 breaks[index].x0 = x;
244 breaks[index].x1 = x + breaks[index].width;
245 x += breaks[index].width;
246 }
247 }
249const axisX1 = axisX0 + layout.axisWidth;
251function xOf(time) {
252 const segment = segments.find((candidate) => time >= candidate.start && time <= candidate.end) ?? segments[0];
253 return Math.round((segment.x0 + (time - segment.start) * scale) * 100) / 100;
256// Ticks fall on the display zone's wall clock, so a +05:30 zone still ticks
257// on its own whole hours.
258const TICKS = [1000, 2000, 5000, 10_000, 15_000, 30_000, MINUTE, 2 * MINUTE, 5 * MINUTE, 10 * MINUTE, 15 * MINUTE, 30 * MINUTE,
259 HOUR, 2 * HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY];
261function wallClockTicks(start, end, interval) {
262 // 日探测只收集时区偏移,不把一天当成固定长度的当地日历日。
263 // 即使时间段两端的偏移相同,也不能漏掉段内先进入、再退出的夏令时。
264 const offsets = new Set([zoneOffset(start), zoneOffset(end)]);
265 for (let probe = start + DAY; probe < end; probe += DAY) offsets.add(zoneOffset(probe));
266 const candidates = new Set();
267 for (const offset of offsets) {
268 const firstTick = Math.ceil((start + offset) / interval) * interval - offset;
269 for (let t = firstTick; t <= end; t += interval) {
270 // 同一个墙钟时刻可能对应两个真实瞬间,也可能不存在。按实际偏移回查,
271 // 保留回拨小时的两个刻度,并排除跳时或跳日期产生的虚构时刻。
272 if (offsets.size === 1 || zoneOffset(t) === offset) candidates.add(t);
273 }
274 }
275 return [...candidates].sort((left, right) => left - right);
278const ticks = [];
279for (const segment of segments) {
280 const length = segment.end - segment.start;
281 if (length <= 0 || scale <= 0) {
282 ticks.push({ t: segment.start, x: segment.x0 });
283 continue;
284 }
285 const interval = TICKS.find((candidate) => candidate * scale >= layout.minTickPx) ?? TICKS.at(-1);
286 const before = ticks.length;
287 for (const t of wallClockTicks(segment.start, segment.end, interval)) ticks.push({ t, x: xOf(t), interval });
288 // A segment too short for a regular tick still states where it starts.
289 if (ticks.length === before) ticks.push({ t: segment.start, x: segment.x0, interval });
291const tickSeconds = ticks.some((tick) => tick.interval < MINUTE);
293// ---- Lanes and event cards ------------------------------------------------------
294const lanes = asArray(tl.lanes).length ? tl.lanes : [{ id: '__all', label: '' }];
295const laneOf = (event) => (asArray(tl.lanes).length ? event.lane : '__all');
297function wrapTitle(text, maxWidth) {
298 const limit = Math.max(6, Math.floor(maxWidth / (layout.titleFont * ADVANCE * 1.05)));
299 const words = String(text).split(/(\s+)/);
300 const lines = [];
301 let current = '';
302 for (const word of words) {
303 if (textUnits((current + word).trim()) <= limit) { current += word; continue; }
304 if (current.trim()) lines.push(current.trim());
305 current = word.trimStart();
306 while (textUnits(current) > limit) {
307 const chars = Array.from(current);
308 let cut = 0;
309 for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= limit; cut += 1) units += textUnits(chars[cut]);
310 lines.push(chars.slice(0, Math.max(1, cut)).join(''));
311 current = chars.slice(Math.max(1, cut)).join('');
312 }
313 }
314 if (current.trim()) lines.push(current.trim());
315 return lines;
318const cards = events.map((event) => {
319 const time = clockLabel(event.t);
320 const padLeft = layout.cardAccent + 2;
321 const natural = Math.max(textUnits(event.title) * layout.titleFont * ADVANCE * 1.05, textUnits(time) * layout.timeFont * ADVANCE);
322 const width = Math.ceil(Math.min(layout.cardMaxW, natural + padLeft + layout.cardPadX));
323 const lines = wrapTitle(event.title, width - padLeft - layout.cardPadX);
324 const height = 9 + 12 + lines.length * layout.titleLine + 7;
325 const x = xOf(event.t);
326 // The card is centred on its instant but never leaves the axis area.
327 const left = Math.min(Math.max(x - width / 2, axisX0 + 4), axisX1 - width - 4);
328 return { ...event, time, width, height, lines, x, left, padLeft };
329});
331// Within a lane, cards sit on either side of the lane's time line and pack
332// into rows outward from it: a card takes whichever side lets it sit closest
333// to the line without touching an earlier card, so simultaneous and close
334// events stack instead of overprinting, and a burst spreads both ways rather
335// than growing one tall column. Ties go below the line. The dot always stays
336// on the line, at the event's exact instant.
337const laneLayout = [];
339 let y = layout.top + layout.axisH;
340 for (const lane of lanes) {
341 const members = cards.filter((card) => laneOf(card) === lane.id);
342 const sides = { above: { ends: [], heights: [] }, below: { ends: [], heights: [] } };
343 const fit = (side, card) => {
344 const index = sides[side].ends.findIndex((end) => card.left >= end + 10);
345 return index === -1 ? sides[side].ends.length : index;
346 };
347 for (const card of members) {
348 const above = fit('above', card);
349 const below = fit('below', card);
350 card.side = above < below ? 'above' : 'below';
351 card.row = card.side === 'above' ? above : below;
352 const side = sides[card.side];
353 side.ends[card.row] = card.left + card.width;
354 side.heights[card.row] = Math.max(side.heights[card.row] || 0, card.height);
355 }
356 const extent = (side) => side.heights.reduce((sum, height) => sum + height + layout.rowGap, 0) - (side.heights.length ? layout.rowGap : 0);
357 const aboveExtent = extent(sides.above);
358 const lineY = y + layout.lanePad + (aboveExtent ? aboveExtent + layout.stemGap : 10);
359 const offsets = (side) => {
360 const out = [];
361 side.heights.reduce((offset, height, row) => { out[row] = offset; return offset + height + layout.rowGap; }, layout.stemGap);
362 return out;
363 };
364 const aboveOffset = offsets(sides.above);
365 const belowOffset = offsets(sides.below);
366 for (const card of members) {
367 card.lineY = lineY;
368 // Above the line a row is bottom-aligned to its edge nearest the line,
369 // below it top-aligned, so every stem in a row has the same length.
370 card.top = card.side === 'above' ? lineY - aboveOffset[card.row] - card.height : lineY + belowOffset[card.row];
371 }
372 const bottom = sides.below.heights.length ? lineY + belowOffset.at(-1) + sides.below.heights.at(-1) : lineY + 10;
373 const height = Math.max(56, bottom - y + layout.lanePad);
374 laneLayout.push({ ...lane, y, height, lineY });
375 y += height + layout.laneGap;
376 }
379// ---- Legend ---------------------------------------------------------------------
380const KIND_TONE = { default: 'external', change: 'frontend', alert: 'security', action: 'messagebus', recovery: 'backend' };
381const kindOf = (event) => event.kind || 'default';
382const legendEntries = resolveLegend(undefined,
383 Object.keys(KIND_TONE).map((kind) => ({ kind, label: i18nText(locale, `legend.timeline.${kind}`), interactive: false })),
384 new Set(events.map(kindOf)))
385 .filter((entry, _, all) => all.length > 1);
387const contentBottom = laneLayout.at(-1).y + laneLayout.at(-1).height;
388const width = Math.ceil(axisX1 + layout.margin);
389const footprint = legendFootprint(legendEntries, { width: width - layout.margin * 2, fontSize: 11 });
390const height = Math.ceil(contentBottom + layout.margin + (legendEntries.length ? 34 + footprint.extraHeight : 0));
391const viewBox = [width, height];
393function legendLayout() {
394 return { x: layout.margin, baselineY: height - 16, width: width - layout.margin * 2, fontSize: 11, minTitleY: contentBottom + 8, diagramType: 'timeline' };
397// ---- Rendering ------------------------------------------------------------------
398function renderAxis() {
399 const top = layout.top;
400 const bottom = contentBottom;
401 const lineY = top + layout.axisH - 10;
402 // Tick labels never overprint each other or a break's duration label. On a
403 // multi-day axis a tick names its date only when the date changes from the
404 // previous label, so the axis reads "09-29 15:00 · 18:00 · 21:00".
405 const taken = [];
406 let lastDate = null;
407 const shown = ticks.flatMap((tick) => {
408 const w = wall(tick.t);
409 const date = `${w.mo}-${w.d}`;
410 const label = clockLabel(tick.t, { seconds: tickSeconds, date: multiDay && date !== lastDate });
411 const half = textUnits(label) * 10.5 * ADVANCE / 2 + 5;
412 const box = [tick.x - half, tick.x + half];
413 if (taken.some(([a, b]) => box[0] < b && a < box[1])) return [];
414 taken.push(box);
415 lastDate = date;
416 return [{ ...tick, label }];
417 });
418 const grid = shown.map((tick) => ` <line x1="${tick.x}" y1="${lineY}" x2="${tick.x}" y2="${bottom}" class="tl-grid" stroke-width="1"/>
419 <line x1="${tick.x}" y1="${lineY - 4}" x2="${tick.x}" y2="${lineY}" class="tl-axis" stroke-width="1"/>
420 <text x="${tick.x}" y="${lineY - 10}" class="t-muted tl-tick" font-size="10.5" text-anchor="middle">${esc(tick.label)}</text>`).join('\n');
421 const segmentLines = segments.map((segment, index) => {
422 const from = index ? breaks[index - 1].x1 : axisX0 + 8;
423 const to = index < breaks.length ? breaks[index].x0 : axisX1 - 8;
424 return ` <line x1="${from}" y1="${lineY}" x2="${to}" y2="${lineY}" class="tl-axis" stroke-width="1"/>`;
425 }).join('\n');
426 const breakMarks = breaks.map((gap) => {
427 const mid = (gap.x0 + gap.x1) / 2;
428 const zig = (x) => `M ${x - 2.5} ${lineY - 6} L ${x + 2.5} ${lineY + 6}`;
429 const omitted = i18nText(locale, 'timeline.break', { duration: durationLabel(gap.length) });
430 return ` <g data-timeline-break="" data-break-ms="${gap.length}" aria-label="${esc(omitted)}" role="img">
431 <rect x="${gap.x0 + 6}" y="${lineY}" width="${gap.width - 12}" height="${bottom - lineY}" fill="url(#tl-break-hatch)" class="tl-break-band"/>
432 <path d="${zig(gap.x0 + 6)} ${zig(gap.x0 + 10)} ${zig(gap.x1 - 10)} ${zig(gap.x1 - 6)}" class="tl-break-zig" stroke-width="1.3" stroke-linecap="round" fill="none"/>
433 <rect x="${mid - gap.width / 2 + 4}" y="${lineY - 41}" width="${gap.width - 8}" height="17" rx="8.5" class="tl-break-chip"/>
434 <text x="${mid}" y="${lineY - 29}" class="t-muted" font-size="10" font-weight="700" text-anchor="middle">${esc(gap.label)}</text>
435 </g>`;
436 }).join('\n');
437 return `${grid}\n${segmentLines}\n${breakMarks}`;
440function renderHeader() {
441 const evidence = i18nText(locale, `timeline.evidence.${tl.meta.evidence}`);
442 const evidenceW = Math.ceil(textUnits(evidence) * 10.5 * ADVANCE + 24);
443 const date = multiDay ? '' : ` · ${wall(first).y}-${wall(first).mo}-${wall(first).d}`;
444 const zone = `${timezone === 'UTC' ? 'UTC' : `${timezone} (${offsetLabel(first)})`}${date}`;
445 const disclosure = breaks.length ? ` · ${i18nText(locale, 'timeline.compressed', { count: breaks.length })}` : '';
446 const caption = `${i18nText(locale, 'timeline.axis')}: ${zone}${disclosure}`;
447 const tone = tl.meta.evidence === 'observed' ? 'backend' : 'messagebus';
448 return ` <g data-timeline-evidence="${esc(tl.meta.evidence)}">
449 <rect x="${layout.margin}" y="14" width="${evidenceW}" height="22" rx="11" class="c-${tone}" stroke-width="1"/>
450 <circle cx="${layout.margin + 11}" cy="25" r="3" class="t-${tone}"/>
451 <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>
452 </g>
453 <text data-timeline-axis-caption="" x="${layout.margin + evidenceW + 14}" y="29.5" class="t-muted" font-size="11.5">${esc(caption)}</text>`;
456function renderLanes() {
457 return laneLayout.map((lane, index) => ` <g data-timeline-lane="${esc(lane.id)}">
458 <rect x="${layout.margin - 8}" y="${lane.y}" width="${axisX1 - layout.margin + 8}" height="${lane.height}" rx="10" class="tl-lane${index % 2 ? ' tl-lane-alt' : ''}"/>
459 <line x1="${axisX0 + 8}" y1="${lane.lineY}" x2="${axisX1 - 8}" y2="${lane.lineY}" class="tl-lane-line" stroke-width="1.5" stroke-linecap="round"/>
460 ${lane.label ? `<text x="${layout.margin}" y="${lane.lineY + 4.5}" class="t-primary" font-size="13" font-weight="700">${esc(lane.label)}</text>` : ''}
461 </g>`).join('\n');
464function renderEvent(card, order) {
465 const tone = KIND_TONE[kindOf(card)];
466 const lane = laneLayout.find((entry) => entry.id === laneOf(card));
467 // The Node index groups by the context before " › ", so the lane leads.
468 const context = [lane?.label, `${clockLabel(card.t, { seconds: true, date: true })} ${offsetLabel(card.t)}`].filter(Boolean).join(' \u203a ');
469 const passport = { kind: tone, sublabel: card.detail, context };
470 const textX = card.left + card.padLeft;
471 let y = card.top + 9 + 10;
472 const time = `<text x="${textX}" y="${y}" class="t-${tone} tl-time" font-size="${layout.timeFont}" font-weight="600">${esc(card.time)}</text>`;
473 y += 2;
474 const title = card.lines.map((line) => {
475 y += layout.titleLine;
476 return `<tspan x="${textX}" y="${y}">${esc(line)}</tspan>`;
477 }).join('');
478 // A tone accent down the card's leading edge carries the event kind, so the
479 // card body stays a quiet surface for the text.
480 const accent = `<line x1="${card.left + 6}" y1="${card.top + 7}" x2="${card.left + 6}" y2="${card.top + card.height - 7}" class="tl-accent tl-accent-${tone}" stroke-width="3" stroke-linecap="round"/>`;
481 return ` <g ${focusNodeAttrs(card.id, card.title, passport, locale)} data-timeline-at="${esc(card.at)}" data-timeline-ms="${card.t}" data-timeline-x="${card.x}">
482 ${focusNodeTitle(card.title, passport)}
483 <rect x="${card.left}" y="${card.top}" width="${card.width}" height="${card.height}" rx="7" class="c-mask"/>
484 <rect x="${card.left}" y="${card.top}" width="${card.width}" height="${card.height}" rx="7" class="c-${tone} tl-card"${animateAttr(tl.meta, 'node', order)} stroke-width="1"/>
485 ${accent}
486 ${time}
487 <text data-node-label="" class="t-primary" font-size="${layout.titleFont}" font-weight="650">${title}</text>
488 <circle cx="${card.x}" cy="${card.lineY}" r="4.75" class="t-${tone}"/>
489 </g>`;
492function legendSwatch(entry) {
493 return `<circle cx="${entry.x + 6}" cy="${entry.baseline - 4}" r="4.5" class="t-${KIND_TONE[entry.kind]} tl-dot" stroke-width="2"/>`;
496function renderSvg() {
497 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(tl.meta)} data-motion-choreography="sweep" data-timeline-ui="" data-reader-fit="intrinsic-height" data-reader-min-text="7.5">
498${svgAccessibleText(tl.meta, 'timeline')}
499${renderDefinitions(breaks.length ? `
500 <pattern id="tl-break-hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="6" class="tl-hatch" stroke-width="1"/></pattern>` : '')}
501 <style>
502 svg[data-timeline-ui] .tl-grid { stroke: var(--lane-stroke); stroke-dasharray: 2 4; opacity: .7; }
503 svg[data-timeline-ui] .tl-axis { stroke: var(--text-dim); }
504 svg[data-timeline-ui] .tl-lane { fill: var(--lane-fill); stroke: none; opacity: .75; }
505 svg[data-timeline-ui] .tl-lane-alt { opacity: 0; }
506 svg[data-timeline-ui] .tl-lane-line { stroke: var(--lane-stroke); }
507 svg[data-timeline-ui] .tl-stem { stroke: var(--text-dim); }
508 svg[data-timeline-ui] .tl-card { fill-opacity: .55; stroke-opacity: .5; }
509 svg[data-timeline-ui] .tl-card.c-external { fill: var(--mask); stroke: var(--lane-stroke); stroke-opacity: 1; }
510 svg[data-timeline-ui] .tl-dot { stroke: var(--mask); }
511 svg[data-timeline-ui] .tl-halo { fill: var(--mask); }
512 svg[data-timeline-ui] .tl-break-band { opacity: .55; }
513 svg[data-timeline-ui] .tl-break-chip { fill: var(--mask); stroke: var(--lane-stroke); stroke-width: 1; }
514 svg[data-timeline-ui] .tl-hatch { stroke: var(--lane-stroke); }
515 svg[data-timeline-ui] .tl-break-zig { stroke: var(--text-dim); }
516${Object.values(KIND_TONE).map((tone) => ` svg[data-timeline-ui] .tl-accent-${tone} { stroke: var(--${tone}-stroke); }
517 svg[data-timeline-ui] .tl-stem-${tone} { stroke: color-mix(in srgb, var(--${tone}-stroke) 45%, var(--lane-stroke)); }
518 svg[data-timeline-ui] .tl-pin-${tone} { fill: var(--${tone}-fill); stroke: color-mix(in srgb, var(--${tone}-stroke) 35%, transparent); stroke-width: 1; }`).join('\n')}
519 svg[data-timeline-ui] .tl-tick, svg[data-timeline-ui] .tl-time { font-variant-numeric: tabular-nums; }
520 </style>
522 <!-- Background Grid -->
523 <rect width="100%" height="100%" fill="url(#grid)" />
525${renderHeader()}
527 <!-- Lanes -->
528${renderLanes()}
530 <!-- Time axis (proportional within each segment; breaks are disclosed) -->
531${renderAxis()}
533 <!-- Stems sit behind every card, so a stacked card is never crossed by
534 another event's connector -->
535${cards.map((card) => ` <line data-detail="context" data-timeline-stem="${esc(card.id)}" x1="${card.x}" y1="${card.lineY}" x2="${card.x}" y2="${card.side === 'above' ? card.top + card.height : card.top}" class="tl-stem tl-stem-${KIND_TONE[kindOf(card)]}" stroke-width="1"/>`).join('\n')}
537 <!-- One halo layer under every dot, so events seconds apart merge into
538 one outlined capsule instead of cutting crescents into each other -->
539${cards.map((card) => ` <circle data-detail="context" cx="${card.x}" cy="${card.lineY}" r="7.5" class="tl-halo"/>`).join('\n')}
540${cards.map((card) => ` <circle data-detail="context" cx="${card.x}" cy="${card.lineY}" r="6.5" class="tl-pin tl-pin-${KIND_TONE[kindOf(card)]}"/>`).join('\n')}
542 <!-- Events in time order -->
543${cards.map(renderEvent).join('\n')}
545 <!-- Legend -->
546${legendEntries.length ? renderResolvedLegend({ entries: legendEntries, locale, layout: legendLayout(), renderSwatch: legendSwatch }) : ''}
547 </svg>`;
550function buildLayoutReport() {
551 return {
552 diagram_type: 'timeline',
553 viewBox,
554 timezone,
555 scalePxPerMinute: Math.round(scale * MINUTE * 1000) / 1000,
556 segments: segments.map(({ start, end, x0, x1 }) => ({ start, end, x0, x1 })),
557 breaks: breaks.map(({ from, to, length, x0, x1 }) => ({ from, to, length, x0, x1 })),
558 ticks: ticks.map(({ t, x }) => ({ t, x })),
559 lanes: laneLayout.map(({ id, y, height, lineY }) => ({ id, y, height, lineY })),
560 events: cards.map((card) => ({ id: card.id, t: card.t, lane: laneOf(card), x: card.x, left: card.left, top: card.top, width: card.width, height: card.height, row: card.row })),
561 };
564if (layoutJsonMode) {
565 console.log(JSON.stringify(buildLayoutReport(), null, 2));
566 process.exit(0);
568writeDiagram({
569 outPath,
570 template,
571 diagramType: 'timeline',
572 meta: tl.meta,
573 svg: renderSvg(),
574 cards: tl.cards,
575 sourceEvidence,
576});