@diagrammo/dgmo 0.7.2 → 0.8.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 (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3529 -1061
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3516 -1061
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +312 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +82 -31
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
package/src/d3.ts CHANGED
@@ -88,6 +88,7 @@ export interface TimelineEra {
88
88
  endDate: string;
89
89
  label: string;
90
90
  color: string | null;
91
+ lineNumber: number;
91
92
  }
92
93
 
93
94
  export interface TimelineMarker {
@@ -181,22 +182,22 @@ import { getSeriesColors } from './palettes';
181
182
  import { mix } from './palettes/color-utils';
182
183
  import type { DgmoError } from './diagnostics';
183
184
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
184
- import { collectIndentedValues, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
185
+ import { collectIndentedValues, extractColor, normalizeDirection, parseFirstLine, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
185
186
  import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
186
187
  import type { TagGroup } from './utils/tag-groups';
187
188
  import {
188
189
  LEGEND_HEIGHT as TL_LEGEND_HEIGHT,
189
190
  LEGEND_PILL_PAD as TL_LEGEND_PILL_PAD,
190
191
  LEGEND_PILL_FONT_SIZE as TL_LEGEND_PILL_FONT_SIZE,
191
- LEGEND_PILL_FONT_W as TL_LEGEND_PILL_FONT_W,
192
192
  LEGEND_CAPSULE_PAD as TL_LEGEND_CAPSULE_PAD,
193
193
  LEGEND_DOT_R as TL_LEGEND_DOT_R,
194
194
  LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
195
- LEGEND_ENTRY_FONT_W as TL_LEGEND_ENTRY_FONT_W,
196
195
  LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
197
196
  LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
198
197
  LEGEND_GROUP_GAP as TL_LEGEND_GROUP_GAP,
198
+ measureLegendText,
199
199
  } from './utils/legend-constants';
200
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from './utils/title-constants';
200
201
 
201
202
  // ============================================================
202
203
  // Shared Rendering Helpers
@@ -217,11 +218,11 @@ function renderChartTitle(
217
218
  const titleEl = svg.append('text')
218
219
  .attr('class', 'chart-title')
219
220
  .attr('x', width / 2)
220
- .attr('y', 30)
221
+ .attr('y', TITLE_Y)
221
222
  .attr('text-anchor', 'middle')
222
223
  .attr('fill', textColor)
223
- .attr('font-size', '20px')
224
- .attr('font-weight', '700')
224
+ .attr('font-size', TITLE_FONT_SIZE)
225
+ .attr('font-weight', TITLE_FONT_WEIGHT)
225
226
  .style('cursor', onClickItem && titleLineNumber ? 'pointer' : 'default')
226
227
  .text(title);
227
228
  if (titleLineNumber) {
@@ -261,45 +262,97 @@ function initD3Chart(
261
262
  // ============================================================
262
263
 
263
264
  /**
264
- * Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD) to a fractional year number.
265
+ * Converts a date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a fractional year number.
265
266
  */
266
267
  export function parseTimelineDate(s: string): number {
267
- const parts = s.split('-').map((p) => parseInt(p, 10));
268
+ // Split off optional time component
269
+ const spaceIdx = s.indexOf(' ');
270
+ let datePart = s;
271
+ let hour = 0;
272
+ let minute = 0;
273
+
274
+ if (spaceIdx !== -1) {
275
+ datePart = s.slice(0, spaceIdx);
276
+ const timePart = s.slice(spaceIdx + 1);
277
+ const timeParts = timePart.split(':');
278
+ if (timeParts.length === 2) {
279
+ hour = parseInt(timeParts[0], 10);
280
+ minute = parseInt(timeParts[1], 10);
281
+ }
282
+ }
283
+
284
+ const parts = datePart.split('-').map((p) => parseInt(p, 10));
268
285
  const year = parts[0];
269
286
  const month = parts.length >= 2 ? parts[1] : 1;
270
287
  const day = parts.length >= 3 ? parts[2] : 1;
271
- return year + (month - 1) / 12 + (day - 1) / 365;
288
+ return year + (month - 1) / 12 + (day - 1) / 365 + hour / 8760 + minute / 525600;
289
+ }
290
+
291
+ /** Convert a fractional year number back to a Date (inverse of parseTimelineDate). */
292
+ function fractionalYearToDate(frac: number): Date {
293
+ const year = Math.floor(frac);
294
+ const remainder = frac - year;
295
+ // Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
296
+ const monthFrac = remainder * 12;
297
+ const month = Math.floor(monthFrac); // 0-based
298
+ const monthRemainder = remainder - month / 12;
299
+ const dayFrac = monthRemainder * 365; // fractional day-of-year offset
300
+ const day = Math.floor(dayFrac) + 1;
301
+ const dayRemainder = dayFrac - Math.floor(dayFrac);
302
+ const hourFrac = dayRemainder * 24;
303
+ const hour = Math.floor(hourFrac);
304
+ const minute = Math.round((hourFrac - hour) * 60);
305
+ return new Date(year, month, day, hour, minute);
306
+ }
307
+
308
+ /** Convert a Date to a fractional year number. */
309
+ function dateToFractionalYear(d: Date): number {
310
+ return d.getFullYear() + d.getMonth() / 12 + (d.getDate() - 1) / 365
311
+ + d.getHours() / 8760 + d.getMinutes() / 525600;
272
312
  }
273
313
 
274
314
  /**
275
315
  * Adds a duration to a date string and returns the resulting date string.
276
- * Supports: d (days), w (weeks), m (months), y (years)
316
+ * Supports: d (days), w (weeks), m (months), y (years), h (hours), min (minutes)
277
317
  * Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
278
- * Preserves the precision of the input date (YYYY, YYYY-MM, or YYYY-MM-DD).
318
+ * Preserves the precision of the input date (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM).
279
319
  */
280
320
  export function addDurationToDate(
281
321
  startDate: string,
282
322
  amount: number,
283
- unit: 'd' | 'w' | 'm' | 'y'
323
+ unit: 'd' | 'w' | 'm' | 'y' | 'h' | 'min'
284
324
  ): string {
285
- const parts = startDate.split('-').map((p) => parseInt(p, 10));
325
+ // Split off optional time component
326
+ const spaceIdx = startDate.indexOf(' ');
327
+ let datePart = startDate;
328
+ let hour = 0;
329
+ let minute = 0;
330
+
331
+ if (spaceIdx !== -1) {
332
+ datePart = startDate.slice(0, spaceIdx);
333
+ const timePart = startDate.slice(spaceIdx + 1);
334
+ const tp = timePart.split(':');
335
+ if (tp.length === 2) {
336
+ hour = parseInt(tp[0], 10);
337
+ minute = parseInt(tp[1], 10);
338
+ }
339
+ }
340
+
341
+ const parts = datePart.split('-').map((p) => parseInt(p, 10));
286
342
  const year = parts[0];
287
343
  const month = parts.length >= 2 ? parts[1] : 1;
288
344
  const day = parts.length >= 3 ? parts[2] : 1;
289
345
 
290
- const date = new Date(year, month - 1, day);
346
+ const date = new Date(year, month - 1, day, hour, minute);
291
347
 
292
348
  switch (unit) {
293
349
  case 'd':
294
- // Round days to nearest integer
295
350
  date.setDate(date.getDate() + Math.round(amount));
296
351
  break;
297
352
  case 'w':
298
- // Convert weeks to days, round to nearest integer
299
353
  date.setDate(date.getDate() + Math.round(amount * 7));
300
354
  break;
301
355
  case 'm': {
302
- // Add whole months, then remaining days
303
356
  const wholeMonths = Math.floor(amount);
304
357
  const fractionalDays = Math.round((amount - wholeMonths) * 30);
305
358
  date.setMonth(date.getMonth() + wholeMonths);
@@ -309,7 +362,6 @@ export function addDurationToDate(
309
362
  break;
310
363
  }
311
364
  case 'y': {
312
- // Add whole years, then remaining months
313
365
  const wholeYears = Math.floor(amount);
314
366
  const fractionalMonths = Math.round((amount - wholeYears) * 12);
315
367
  date.setFullYear(date.getFullYear() + wholeYears);
@@ -318,17 +370,28 @@ export function addDurationToDate(
318
370
  }
319
371
  break;
320
372
  }
373
+ case 'h':
374
+ date.setTime(date.getTime() + amount * 3600000);
375
+ break;
376
+ case 'min':
377
+ date.setTime(date.getTime() + amount * 60000);
378
+ break;
321
379
  }
322
380
 
323
381
  // Preserve original precision
324
382
  const endYear = date.getFullYear();
325
383
  const endMonth = String(date.getMonth() + 1).padStart(2, '0');
326
384
  const endDay = String(date.getDate()).padStart(2, '0');
385
+ const endHour = String(date.getHours()).padStart(2, '0');
386
+ const endMinute = String(date.getMinutes()).padStart(2, '0');
387
+ const hasTime = unit === 'h' || unit === 'min' || spaceIdx !== -1;
327
388
 
328
389
  if (parts.length === 1) {
329
390
  return String(endYear);
330
391
  } else if (parts.length === 2) {
331
392
  return `${endYear}-${endMonth}`;
393
+ } else if (hasTime && (date.getHours() !== 0 || date.getMinutes() !== 0)) {
394
+ return `${endYear}-${endMonth}-${endDay} ${endHour}:${endMinute}`;
332
395
  } else {
333
396
  return `${endYear}-${endMonth}-${endDay}`;
334
397
  }
@@ -401,6 +464,8 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
401
464
  let currentTimelineGroup: string | null = null;
402
465
  let currentTimelineTagGroup: TagGroup | null = null;
403
466
  const timelineAliasMap = new Map<string, string>();
467
+ const VALID_D3_TYPES = new Set(['slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant', 'sequence']);
468
+ let firstLineParsed = false;
404
469
 
405
470
  for (let i = 0; i < lines.length; i++) {
406
471
  const rawLine = lines[i];
@@ -411,6 +476,24 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
411
476
  // Skip empty lines
412
477
  if (!line) continue;
413
478
 
479
+ // Skip comments
480
+ if (line.startsWith('//')) continue;
481
+
482
+ // First non-empty, non-comment line: chart type + optional title
483
+ if (!firstLineParsed) {
484
+ firstLineParsed = true;
485
+ const firstLineResult = parseFirstLine(line);
486
+ if (firstLineResult && VALID_D3_TYPES.has(firstLineResult.chartType)) {
487
+ result.type = firstLineResult.chartType as ParsedVisualization['type'];
488
+ if (firstLineResult.title) {
489
+ result.title = firstLineResult.title;
490
+ result.titleLineNumber = lineNumber;
491
+ }
492
+ continue;
493
+ }
494
+ // Not a bare chart type — fall through to normal parsing
495
+ }
496
+
414
497
  // Timeline tag group heading: `tag: Name [alias X]`
415
498
  if (result.type === 'timeline' && indent === 0) {
416
499
  const tagBlockMatch = matchTagBlockHeading(line);
@@ -487,11 +570,6 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
487
570
  currentTimelineGroup = null;
488
571
  }
489
572
 
490
- // Skip comments
491
- if (line.startsWith('//')) {
492
- continue;
493
- }
494
-
495
573
  // Arc link line: source -> target(color): weight
496
574
  if (result.type === 'arc') {
497
575
  const linkMatch = line.match(
@@ -527,10 +605,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
527
605
  }
528
606
  }
529
607
 
530
- // Timeline era lines: era YYYY->YYYY: Label (color)
608
+ // Timeline era lines: era YYYY->YYYY Label (color)
531
609
  if (result.type === 'timeline') {
532
610
  const eraMatch = line.match(
533
- /^era\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
611
+ /^era\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*:?\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
534
612
  );
535
613
  if (eraMatch) {
536
614
  const colorAnnotation = eraMatch[4]?.trim() || null;
@@ -541,13 +619,14 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
541
619
  color: colorAnnotation
542
620
  ? resolveColor(colorAnnotation, palette)
543
621
  : null,
622
+ lineNumber,
544
623
  });
545
624
  continue;
546
625
  }
547
626
 
548
- // Timeline marker lines: marker: YYYY Label (color)
627
+ // Timeline marker lines: marker YYYY Label (color)
549
628
  const markerMatch = line.match(
550
- /^marker:\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
629
+ /^marker:?\s+(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
551
630
  );
552
631
  if (markerMatch) {
553
632
  const colorAnnotation = markerMatch[3]?.trim() || null;
@@ -565,17 +644,17 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
565
644
 
566
645
  // Timeline event lines: duration, range, or point
567
646
  if (result.type === 'timeline') {
568
- // Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years)
647
+ // Duration event: 2026-07-15->30d: description (d=days, w=weeks, m=months, y=years, h=hours, min=minutes)
569
648
  // Supports decimals up to 2 places (e.g., 1.25y = 1 year 3 months)
570
649
  // Supports uncertain end with ? suffix (e.g., ->3m?: fades out the last 20%)
571
650
  const durationMatch = line.match(
572
- /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d+(?:\.\d{1,2})?)([dwmy])(\?)?\s*:\s*(.+)$/
651
+ /^(\d{4}(?:-\d{2})?(?:-\d{2}(?: \d{2}:\d{2})?)?)\s*->\s*(\d+(?:\.\d{1,2})?)(min|[dwmyh])(\?)?(?:\s*:\s*|\s+)(.+)$/
573
652
  );
574
653
  if (durationMatch) {
575
654
  const startDate = durationMatch[1];
576
655
  const uncertain = durationMatch[4] === '?';
577
656
  const amount = parseFloat(durationMatch[2]);
578
- const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y';
657
+ const unit = durationMatch[3] as 'd' | 'w' | 'm' | 'y' | 'h' | 'min';
579
658
  const endDate = addDurationToDate(startDate, amount, unit);
580
659
  const segments = durationMatch[5].split('|');
581
660
  const metadata = segments.length > 1
@@ -593,9 +672,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
593
672
  continue;
594
673
  }
595
674
 
596
- // Range event: 1655->1667: description (supports uncertain end: 1655->1667?)
675
+ // Range event: 1655->1667 description (supports uncertain end: 1655->1667?)
597
676
  const rangeMatch = line.match(
598
- /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?\s*:\s*(.+)$/
677
+ /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*->\s*(\d{4}(?:-\d{2})?(?:-\d{2})?)(\?)?(?:\s*:\s*|\s+)(.+)$/
599
678
  );
600
679
  if (rangeMatch) {
601
680
  const segments = rangeMatch[4].split('|');
@@ -614,9 +693,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
614
693
  continue;
615
694
  }
616
695
 
617
- // Point event: 1718: description
696
+ // Point event: 1718 description (or legacy 1718: description)
618
697
  const pointMatch = line.match(
619
- /^(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+)$/
698
+ /^(\d{4}(?:-\d{2})?(?:-\d{2})?)(?:\s*:\s*|\s+)(.+)$/
620
699
  );
621
700
  if (pointMatch) {
622
701
  const segments = pointMatch[2].split('|');
@@ -664,7 +743,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
664
743
  let color: string | null = null;
665
744
  if (colorName) {
666
745
  const resolved = resolveColor(colorName, palette);
667
- if (resolved.startsWith('#')) {
746
+ if (resolved === null) {
747
+ warn(lineNumber, `Hex colors are not supported — use named colors (blue, red, green, etc.)`);
748
+ } else if (resolved.startsWith('#')) {
668
749
  color = resolved;
669
750
  } else {
670
751
  warn(lineNumber, `Unknown color "${colorName}" on set "${name}". Using auto-assigned color.`);
@@ -765,7 +846,102 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
765
846
  }
766
847
  }
767
848
 
768
- // Check for metadata lines
849
+ // ── Space-separated options (no colon) ──────────────────
850
+ const spaceIdx = line.indexOf(' ');
851
+ if (spaceIdx >= 0) {
852
+ const firstToken = line.substring(0, spaceIdx).toLowerCase();
853
+ const restValue = line.substring(spaceIdx + 1).trim();
854
+
855
+ if (firstToken === 'chart' && VALID_D3_TYPES.has(restValue.toLowerCase())) {
856
+ result.type = restValue.toLowerCase() as ParsedVisualization['type'];
857
+ continue;
858
+ }
859
+
860
+ if (firstToken === 'title') {
861
+ result.title = restValue;
862
+ result.titleLineNumber = lineNumber;
863
+ if (result.type === 'quadrant') {
864
+ result.quadrantTitleLineNumber = lineNumber;
865
+ }
866
+ continue;
867
+ }
868
+
869
+ if (firstToken === 'orientation' || firstToken === 'direction') {
870
+ if (result.type === 'arc' || result.type === 'timeline') {
871
+ const vLower = restValue.toLowerCase();
872
+ if (vLower === 'horizontal' || vLower === 'vertical') {
873
+ result.orientation = vLower;
874
+ } else {
875
+ const dir = normalizeDirection(restValue);
876
+ if (dir === 'LR') result.orientation = 'horizontal';
877
+ else if (dir === 'TB') result.orientation = 'vertical';
878
+ }
879
+ }
880
+ continue;
881
+ }
882
+
883
+ if (firstToken === 'order') {
884
+ const v = restValue.toLowerCase();
885
+ if (v === 'name' || v === 'group' || v === 'degree') {
886
+ result.arcOrder = v;
887
+ }
888
+ continue;
889
+ }
890
+
891
+ if (firstToken === 'sort') {
892
+ const vLower = restValue.toLowerCase();
893
+ if (vLower === 'time' || vLower === 'group') {
894
+ result.timelineSort = vLower;
895
+ } else if (vLower === 'tag' || vLower.startsWith('tag:')) {
896
+ result.timelineSort = 'tag';
897
+ if (vLower.startsWith('tag:')) {
898
+ const groupRef = restValue.substring(4).trim();
899
+ if (groupRef) {
900
+ result.timelineDefaultSwimlaneTG = groupRef;
901
+ }
902
+ }
903
+ }
904
+ continue;
905
+ }
906
+
907
+ if (firstToken === 'swimlanes') {
908
+ const v = restValue.toLowerCase();
909
+ if (v === 'on') result.timelineSwimlanes = true;
910
+ else if (v === 'off') result.timelineSwimlanes = false;
911
+ continue;
912
+ }
913
+
914
+ if (firstToken === 'rotate') {
915
+ const v = restValue.toLowerCase();
916
+ if (v === 'none' || v === 'mixed' || v === 'angled') {
917
+ result.cloudOptions.rotate = v;
918
+ }
919
+ continue;
920
+ }
921
+
922
+ if (firstToken === 'max') {
923
+ const v = parseInt(restValue, 10);
924
+ if (!isNaN(v) && v > 0) {
925
+ result.cloudOptions.max = v;
926
+ }
927
+ continue;
928
+ }
929
+
930
+ if (firstToken === 'size') {
931
+ const parts = restValue.split(',').map((s) => parseInt(s.trim(), 10));
932
+ if (
933
+ parts.length === 2 &&
934
+ parts.every((n) => !isNaN(n) && n > 0) &&
935
+ parts[0] < parts[1]
936
+ ) {
937
+ result.cloudOptions.minSize = parts[0];
938
+ result.cloudOptions.maxSize = parts[1];
939
+ }
940
+ continue;
941
+ }
942
+ }
943
+
944
+ // ── Colon-separated metadata / options (legacy + data lines) ──
769
945
  const colonIndex = line.indexOf(':');
770
946
 
771
947
  if (colonIndex !== -1) {
@@ -780,18 +956,10 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
780
956
  .substring(colonIndex + 1)
781
957
  .trim()
782
958
  .toLowerCase();
783
- if (
784
- value === 'slope' ||
785
- value === 'wordcloud' ||
786
- value === 'arc' ||
787
- value === 'timeline' ||
788
- value === 'venn' ||
789
- value === 'quadrant' ||
790
- value === 'sequence'
791
- ) {
792
- result.type = value;
959
+ if (VALID_D3_TYPES.has(value)) {
960
+ result.type = value as ParsedVisualization['type'];
793
961
  } else {
794
- const validD3Types = ['slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant', 'sequence'];
962
+ const validD3Types = [...VALID_D3_TYPES];
795
963
  let msg = `Unsupported chart type: ${value}. Supported types: ${validD3Types.join(', ')}`;
796
964
  const hint = suggest(value, validD3Types);
797
965
  if (hint) msg += `. ${hint}`;
@@ -809,15 +977,18 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
809
977
  continue;
810
978
  }
811
979
 
812
- if (key === 'orientation') {
980
+ if (key === 'orientation' || key === 'direction') {
813
981
  // Only arc and timeline support orientation
814
982
  if (result.type === 'arc' || result.type === 'timeline') {
815
- const v = line
816
- .substring(colonIndex + 1)
817
- .trim()
818
- .toLowerCase();
819
- if (v === 'horizontal' || v === 'vertical') {
820
- result.orientation = v;
983
+ const raw = line.substring(colonIndex + 1).trim();
984
+ // Accept horizontal/vertical directly, or LR/TB via normalizeDirection
985
+ const vLower = raw.toLowerCase();
986
+ if (vLower === 'horizontal' || vLower === 'vertical') {
987
+ result.orientation = vLower;
988
+ } else {
989
+ const dir = normalizeDirection(raw);
990
+ if (dir === 'LR') result.orientation = 'horizontal';
991
+ else if (dir === 'TB') result.orientation = 'vertical';
821
992
  }
822
993
  }
823
994
  continue;
@@ -972,7 +1143,12 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
972
1143
 
973
1144
  // Validation
974
1145
  if (!result.type) {
975
- return fail(1, 'Missing required "chart:" line (e.g., "chart: slope")');
1146
+ const validD3Types = [...VALID_D3_TYPES];
1147
+ const firstNonEmpty = lines.find(l => l.trim() && !l.trim().startsWith('//'))?.trim() ?? '';
1148
+ const hint = suggest(firstNonEmpty.split(/\s/)[0].toLowerCase(), validD3Types);
1149
+ let msg = `Unsupported chart type: "${firstNonEmpty.split(/\s/)[0]}". Supported types: ${validD3Types.join(', ')}`;
1150
+ if (hint) msg += `. ${hint}`;
1151
+ return fail(1, msg);
976
1152
  }
977
1153
 
978
1154
  // Sequence diagrams are parsed by their own dedicated parser
@@ -2155,6 +2331,7 @@ function renderEras(
2155
2331
  const eraG = g
2156
2332
  .append('g')
2157
2333
  .attr('class', 'tl-era')
2334
+ .attr('data-line-number', String(era.lineNumber))
2158
2335
  .attr('data-era-start', String(startVal))
2159
2336
  .attr('data-era-end', String(endVal))
2160
2337
  .style('cursor', 'pointer')
@@ -2358,19 +2535,30 @@ const MONTH_ABBR = [
2358
2535
  ];
2359
2536
 
2360
2537
  /**
2361
- * Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD) to a human-readable label.
2362
- * '1718' → '1718'
2363
- * '1718-05' → 'May 1718'
2364
- * '1718-05-22' → 'May 22, 1718'
2538
+ * Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a human-readable label.
2539
+ * '1718' → '1718'
2540
+ * '1718-05' → 'May 1718'
2541
+ * '1718-05-22' → 'May 22, 1718'
2542
+ * '2024-06-15 14:30' → 'Jun 15, 2024 14:30'
2365
2543
  */
2366
2544
  export function formatDateLabel(dateStr: string): string {
2367
- const parts = dateStr.split('-');
2545
+ // Split off optional time component
2546
+ const spaceIdx = dateStr.indexOf(' ');
2547
+ let datePart = dateStr;
2548
+ let timeSuffix = '';
2549
+
2550
+ if (spaceIdx !== -1) {
2551
+ datePart = dateStr.slice(0, spaceIdx);
2552
+ timeSuffix = ' ' + dateStr.slice(spaceIdx + 1);
2553
+ }
2554
+
2555
+ const parts = datePart.split('-');
2368
2556
  const year = parts[0];
2369
- if (parts.length === 1) return year;
2557
+ if (parts.length === 1) return year + timeSuffix;
2370
2558
  const month = MONTH_ABBR[parseInt(parts[1], 10) - 1];
2371
- if (parts.length === 2) return `${month} ${year}`;
2559
+ if (parts.length === 2) return `${month} ${year}${timeSuffix}`;
2372
2560
  const day = parseInt(parts[2], 10);
2373
- return `${month} ${day}, ${year}`;
2561
+ return `${month} ${day}, ${year}${timeSuffix}`;
2374
2562
  }
2375
2563
 
2376
2564
  /**
@@ -2432,6 +2620,55 @@ export function computeTimeTicks(
2432
2620
  }
2433
2621
  }
2434
2622
  }
2623
+ } else if (span <= 0.000685) {
2624
+ // Minute ticks for spans ≤ ~6 hours
2625
+ // Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
2626
+ let stepMin = 5;
2627
+ const spanHours = span * 8760;
2628
+ if (spanHours > 3) stepMin = 30;
2629
+ else if (spanHours > 1) stepMin = 15;
2630
+ else if (spanHours > 0.5) stepMin = 10;
2631
+
2632
+ // Iterate from the start hour boundary
2633
+ const startDate = fractionalYearToDate(domainMin);
2634
+ // Round down to nearest step boundary
2635
+ startDate.setMinutes(Math.floor(startDate.getMinutes() / stepMin) * stepMin, 0, 0);
2636
+
2637
+ while (true) {
2638
+ const val = dateToFractionalYear(startDate);
2639
+ if (val > domainMax) break;
2640
+ if (val >= domainMin) {
2641
+ const hh = String(startDate.getHours()).padStart(2, '0');
2642
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
2643
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
2644
+ }
2645
+ startDate.setMinutes(startDate.getMinutes() + stepMin);
2646
+ }
2647
+ } else if (span <= 0.00822) {
2648
+ // Hour ticks for spans ≤ ~3 days
2649
+ // Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
2650
+ let stepHour = 1;
2651
+ const spanHours = span * 8760;
2652
+ if (spanHours > 48) stepHour = 6;
2653
+ else if (spanHours > 24) stepHour = 3;
2654
+ else if (spanHours > 12) stepHour = 2;
2655
+
2656
+ const startDate = fractionalYearToDate(domainMin);
2657
+ // Round down to nearest step boundary
2658
+ startDate.setHours(Math.floor(startDate.getHours() / stepHour) * stepHour, 0, 0, 0);
2659
+
2660
+ while (true) {
2661
+ const val = dateToFractionalYear(startDate);
2662
+ if (val > domainMax) break;
2663
+ if (val >= domainMin) {
2664
+ const mon = MONTH_ABBR[startDate.getMonth()];
2665
+ const d = startDate.getDate();
2666
+ const hh = String(startDate.getHours()).padStart(2, '0');
2667
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
2668
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
2669
+ }
2670
+ startDate.setHours(startDate.getHours() + stepHour);
2671
+ }
2435
2672
  } else {
2436
2673
  // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
2437
2674
  for (let y = minYear; y <= maxYear + 1; y++) {
@@ -3116,8 +3353,9 @@ export function renderTimeline(
3116
3353
  const svg = d3Selection
3117
3354
  .select(container)
3118
3355
  .append('svg')
3119
- .attr('width', width)
3120
- .attr('height', height)
3356
+ .attr('viewBox', `0 0 ${width} ${height}`)
3357
+ .attr('width', exportDims ? width : '100%')
3358
+ .attr('preserveAspectRatio', 'xMidYMin meet')
3121
3359
  .style('background', bgColor);
3122
3360
 
3123
3361
  const g = svg
@@ -3365,8 +3603,9 @@ export function renderTimeline(
3365
3603
  const svg = d3Selection
3366
3604
  .select(container)
3367
3605
  .append('svg')
3368
- .attr('width', width)
3369
- .attr('height', height)
3606
+ .attr('viewBox', `0 0 ${width} ${height}`)
3607
+ .attr('width', exportDims ? width : '100%')
3608
+ .attr('preserveAspectRatio', 'xMidYMin meet')
3370
3609
  .style('background', bgColor);
3371
3610
 
3372
3611
  const g = svg
@@ -4228,11 +4467,9 @@ export function renderTimeline(
4228
4467
  const LG_HEIGHT = TL_LEGEND_HEIGHT;
4229
4468
  const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
4230
4469
  const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
4231
- const LG_PILL_FONT_W = TL_LEGEND_PILL_FONT_W;
4232
4470
  const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
4233
4471
  const LG_DOT_R = TL_LEGEND_DOT_R;
4234
4472
  const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
4235
- const LG_ENTRY_FONT_W = TL_LEGEND_ENTRY_FONT_W;
4236
4473
  const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
4237
4474
  const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
4238
4475
  const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
@@ -4255,13 +4492,13 @@ export function renderTimeline(
4255
4492
  expandedWidth: number;
4256
4493
  };
4257
4494
  const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
4258
- const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
4495
+ const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
4259
4496
  // Expanded: pill + icon (unless viewMode) + entries
4260
4497
  const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
4261
4498
  let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
4262
4499
  for (const entry of g.entries) {
4263
4500
  const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4264
- entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4501
+ entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
4265
4502
  }
4266
4503
  return {
4267
4504
  group: g,
@@ -4360,7 +4597,7 @@ export function renderTimeline(
4360
4597
  currentSwimlaneGroup.toLowerCase() === groupKey;
4361
4598
 
4362
4599
  const pillLabel = lg.group.name;
4363
- const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
4600
+ const pillWidth = measureLegendText(pillLabel, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
4364
4601
 
4365
4602
  const gEl = legendContainer
4366
4603
  .append('g')
@@ -4497,7 +4734,7 @@ export function renderTimeline(
4497
4734
  .attr('fill', palette.textMuted)
4498
4735
  .text(entry.value);
4499
4736
 
4500
- entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4737
+ entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
4501
4738
  }
4502
4739
  }
4503
4740
 
@@ -5816,6 +6053,8 @@ function finalizeSvgExport(
5816
6053
  }
5817
6054
  svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5818
6055
  svgEl.style.fontFamily = FONT_FAMILY;
6056
+ // Strip elements marked for export exclusion (e.g., inactive legend pills)
6057
+ svgEl.querySelectorAll('[data-export-ignore]').forEach((el) => el.remove());
5819
6058
  const svgHtml = svgEl.outerHTML;
5820
6059
  document.body.removeChild(container);
5821
6060
  if (options?.branding !== false) {