@diagrammo/dgmo 0.7.3 → 0.8.1

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 +3522 -1072
  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 +3509 -1072
  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 +324 -78
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +735 -241
  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 +42 -23
  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;
@@ -945,8 +1116,17 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
945
1116
  if (colonIndex === -1 && !line.includes(' ')) {
946
1117
  // Single bare word — structured mode
947
1118
  result.words.push({ text: line, weight: 10, lineNumber });
1119
+ } else if (colonIndex === -1) {
1120
+ // Try "word weight" or "multi-word-label weight" space-separated format
1121
+ const lastSpace = line.lastIndexOf(' ');
1122
+ const maybeWeight = lastSpace >= 0 ? parseFloat(line.substring(lastSpace + 1)) : NaN;
1123
+ if (lastSpace >= 0 && !isNaN(maybeWeight) && maybeWeight > 0) {
1124
+ result.words.push({ text: line.substring(0, lastSpace).trim(), weight: maybeWeight, lineNumber });
1125
+ } else {
1126
+ freeformLines.push(line);
1127
+ }
948
1128
  } else {
949
- // Multi-word line or non-numeric colon line — freeform text
1129
+ // Non-numeric colon line — freeform text
950
1130
  freeformLines.push(line);
951
1131
  }
952
1132
  continue;
@@ -972,7 +1152,12 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
972
1152
 
973
1153
  // Validation
974
1154
  if (!result.type) {
975
- return fail(1, 'Missing required "chart:" line (e.g., "chart: slope")');
1155
+ const validD3Types = [...VALID_D3_TYPES];
1156
+ const firstNonEmpty = lines.find(l => l.trim() && !l.trim().startsWith('//'))?.trim() ?? '';
1157
+ const hint = suggest(firstNonEmpty.split(/\s/)[0].toLowerCase(), validD3Types);
1158
+ let msg = `Unsupported chart type: "${firstNonEmpty.split(/\s/)[0]}". Supported types: ${validD3Types.join(', ')}`;
1159
+ if (hint) msg += `. ${hint}`;
1160
+ return fail(1, msg);
976
1161
  }
977
1162
 
978
1163
  // Sequence diagrams are parsed by their own dedicated parser
@@ -2155,6 +2340,7 @@ function renderEras(
2155
2340
  const eraG = g
2156
2341
  .append('g')
2157
2342
  .attr('class', 'tl-era')
2343
+ .attr('data-line-number', String(era.lineNumber))
2158
2344
  .attr('data-era-start', String(startVal))
2159
2345
  .attr('data-era-end', String(endVal))
2160
2346
  .style('cursor', 'pointer')
@@ -2358,19 +2544,30 @@ const MONTH_ABBR = [
2358
2544
  ];
2359
2545
 
2360
2546
  /**
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'
2547
+ * Converts a DSL date string (YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DD HH:MM) to a human-readable label.
2548
+ * '1718' → '1718'
2549
+ * '1718-05' → 'May 1718'
2550
+ * '1718-05-22' → 'May 22, 1718'
2551
+ * '2024-06-15 14:30' → 'Jun 15, 2024 14:30'
2365
2552
  */
2366
2553
  export function formatDateLabel(dateStr: string): string {
2367
- const parts = dateStr.split('-');
2554
+ // Split off optional time component
2555
+ const spaceIdx = dateStr.indexOf(' ');
2556
+ let datePart = dateStr;
2557
+ let timeSuffix = '';
2558
+
2559
+ if (spaceIdx !== -1) {
2560
+ datePart = dateStr.slice(0, spaceIdx);
2561
+ timeSuffix = ' ' + dateStr.slice(spaceIdx + 1);
2562
+ }
2563
+
2564
+ const parts = datePart.split('-');
2368
2565
  const year = parts[0];
2369
- if (parts.length === 1) return year;
2566
+ if (parts.length === 1) return year + timeSuffix;
2370
2567
  const month = MONTH_ABBR[parseInt(parts[1], 10) - 1];
2371
- if (parts.length === 2) return `${month} ${year}`;
2568
+ if (parts.length === 2) return `${month} ${year}${timeSuffix}`;
2372
2569
  const day = parseInt(parts[2], 10);
2373
- return `${month} ${day}, ${year}`;
2570
+ return `${month} ${day}, ${year}${timeSuffix}`;
2374
2571
  }
2375
2572
 
2376
2573
  /**
@@ -2432,6 +2629,55 @@ export function computeTimeTicks(
2432
2629
  }
2433
2630
  }
2434
2631
  }
2632
+ } else if (span <= 0.000685) {
2633
+ // Minute ticks for spans ≤ ~6 hours
2634
+ // Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
2635
+ let stepMin = 5;
2636
+ const spanHours = span * 8760;
2637
+ if (spanHours > 3) stepMin = 30;
2638
+ else if (spanHours > 1) stepMin = 15;
2639
+ else if (spanHours > 0.5) stepMin = 10;
2640
+
2641
+ // Iterate from the start hour boundary
2642
+ const startDate = fractionalYearToDate(domainMin);
2643
+ // Round down to nearest step boundary
2644
+ startDate.setMinutes(Math.floor(startDate.getMinutes() / stepMin) * stepMin, 0, 0);
2645
+
2646
+ while (true) {
2647
+ const val = dateToFractionalYear(startDate);
2648
+ if (val > domainMax) break;
2649
+ if (val >= domainMin) {
2650
+ const hh = String(startDate.getHours()).padStart(2, '0');
2651
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
2652
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
2653
+ }
2654
+ startDate.setMinutes(startDate.getMinutes() + stepMin);
2655
+ }
2656
+ } else if (span <= 0.00822) {
2657
+ // Hour ticks for spans ≤ ~3 days
2658
+ // Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
2659
+ let stepHour = 1;
2660
+ const spanHours = span * 8760;
2661
+ if (spanHours > 48) stepHour = 6;
2662
+ else if (spanHours > 24) stepHour = 3;
2663
+ else if (spanHours > 12) stepHour = 2;
2664
+
2665
+ const startDate = fractionalYearToDate(domainMin);
2666
+ // Round down to nearest step boundary
2667
+ startDate.setHours(Math.floor(startDate.getHours() / stepHour) * stepHour, 0, 0, 0);
2668
+
2669
+ while (true) {
2670
+ const val = dateToFractionalYear(startDate);
2671
+ if (val > domainMax) break;
2672
+ if (val >= domainMin) {
2673
+ const mon = MONTH_ABBR[startDate.getMonth()];
2674
+ const d = startDate.getDate();
2675
+ const hh = String(startDate.getHours()).padStart(2, '0');
2676
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
2677
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
2678
+ }
2679
+ startDate.setHours(startDate.getHours() + stepHour);
2680
+ }
2435
2681
  } else {
2436
2682
  // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
2437
2683
  for (let y = minYear; y <= maxYear + 1; y++) {
@@ -3116,8 +3362,9 @@ export function renderTimeline(
3116
3362
  const svg = d3Selection
3117
3363
  .select(container)
3118
3364
  .append('svg')
3119
- .attr('width', width)
3120
- .attr('height', height)
3365
+ .attr('viewBox', `0 0 ${width} ${height}`)
3366
+ .attr('width', exportDims ? width : '100%')
3367
+ .attr('preserveAspectRatio', 'xMidYMin meet')
3121
3368
  .style('background', bgColor);
3122
3369
 
3123
3370
  const g = svg
@@ -3365,8 +3612,9 @@ export function renderTimeline(
3365
3612
  const svg = d3Selection
3366
3613
  .select(container)
3367
3614
  .append('svg')
3368
- .attr('width', width)
3369
- .attr('height', height)
3615
+ .attr('viewBox', `0 0 ${width} ${height}`)
3616
+ .attr('width', exportDims ? width : '100%')
3617
+ .attr('preserveAspectRatio', 'xMidYMin meet')
3370
3618
  .style('background', bgColor);
3371
3619
 
3372
3620
  const g = svg
@@ -4228,11 +4476,9 @@ export function renderTimeline(
4228
4476
  const LG_HEIGHT = TL_LEGEND_HEIGHT;
4229
4477
  const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
4230
4478
  const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
4231
- const LG_PILL_FONT_W = TL_LEGEND_PILL_FONT_W;
4232
4479
  const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
4233
4480
  const LG_DOT_R = TL_LEGEND_DOT_R;
4234
4481
  const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
4235
- const LG_ENTRY_FONT_W = TL_LEGEND_ENTRY_FONT_W;
4236
4482
  const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
4237
4483
  const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
4238
4484
  const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
@@ -4255,13 +4501,13 @@ export function renderTimeline(
4255
4501
  expandedWidth: number;
4256
4502
  };
4257
4503
  const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
4258
- const pillW = g.name.length * LG_PILL_FONT_W + LG_PILL_PAD;
4504
+ const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
4259
4505
  // Expanded: pill + icon (unless viewMode) + entries
4260
4506
  const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
4261
4507
  let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
4262
4508
  for (const entry of g.entries) {
4263
4509
  const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
4264
- entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4510
+ entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
4265
4511
  }
4266
4512
  return {
4267
4513
  group: g,
@@ -4360,7 +4606,7 @@ export function renderTimeline(
4360
4606
  currentSwimlaneGroup.toLowerCase() === groupKey;
4361
4607
 
4362
4608
  const pillLabel = lg.group.name;
4363
- const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
4609
+ const pillWidth = measureLegendText(pillLabel, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
4364
4610
 
4365
4611
  const gEl = legendContainer
4366
4612
  .append('g')
@@ -4497,7 +4743,7 @@ export function renderTimeline(
4497
4743
  .attr('fill', palette.textMuted)
4498
4744
  .text(entry.value);
4499
4745
 
4500
- entryX = textX + entry.value.length * LG_ENTRY_FONT_W + LG_ENTRY_TRAIL;
4746
+ entryX = textX + measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) + LG_ENTRY_TRAIL;
4501
4747
  }
4502
4748
  }
4503
4749
 
@@ -4583,7 +4829,7 @@ export function renderWordCloud(
4583
4829
 
4584
4830
  const fontSize = (weight: number): number => {
4585
4831
  const t = (weight - minWeight) / range;
4586
- return minSize + t * (maxSize - minSize);
4832
+ return minSize + Math.sqrt(t) * (maxSize - minSize);
4587
4833
  };
4588
4834
 
4589
4835
  const rotateFn = getRotateFn(cloudOptions.rotate);
@@ -4600,7 +4846,7 @@ export function renderWordCloud(
4600
4846
  cloud<WordCloudWord & cloud.Word>()
4601
4847
  .size([width, cloudHeight])
4602
4848
  .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
4603
- .padding(4)
4849
+ .padding(2)
4604
4850
  .rotate(rotateFn)
4605
4851
  .fontSize((d) => d.size!)
4606
4852
  .font(FONT_FAMILY)
@@ -4675,7 +4921,7 @@ function renderWordCloudAsync(
4675
4921
 
4676
4922
  const fontSize = (weight: number): number => {
4677
4923
  const t = (weight - minWeight) / range;
4678
- return minSize + t * (maxSize - minSize);
4924
+ return minSize + Math.sqrt(t) * (maxSize - minSize);
4679
4925
  };
4680
4926
 
4681
4927
  const rotateFn = getRotateFn(cloudOptions.rotate);
@@ -4699,7 +4945,7 @@ function renderWordCloudAsync(
4699
4945
  cloud<WordCloudWord & cloud.Word>()
4700
4946
  .size([width, cloudHeight])
4701
4947
  .words(words.map((w) => ({ ...w, size: fontSize(w.weight) })))
4702
- .padding(4)
4948
+ .padding(2)
4703
4949
  .rotate(rotateFn)
4704
4950
  .fontSize((d) => d.size!)
4705
4951
  .font(FONT_FAMILY)