@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
@@ -6,7 +6,7 @@ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
6
  import type { DgmoError } from '../diagnostics';
7
7
  import type { TagGroup, TagEntry } from '../utils/tag-groups';
8
8
  import { matchTagBlockHeading } from '../utils/tag-groups';
9
- import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING, parseFirstLine, prescanOptions, GROUP_HASH_RE } from '../utils/parsing';
10
10
  import { parseOffset } from '../utils/duration';
11
11
  import type { PaletteColors } from '../palettes';
12
12
  import { resolveColor } from '../colors';
@@ -30,44 +30,38 @@ import type {
30
30
 
31
31
  // ── Regexes ─────────────────────────────────────────────────
32
32
 
33
- /** Duration task: `30d: Label`, `1.5w: Label`, `10bd?: Label` */
34
- const DURATION_RE = /^(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
33
+ /** Duration task: `30d Label`, `1.5w Label`, `10bd? Label`, `2h Label`, `90min Label` */
34
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
35
35
 
36
- /** Explicit date task: `2024-01-15: Label` */
37
- const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
36
+ /** Explicit date task: `2024-01-15 Label` or `2024-01-15 14:30 Label` */
37
+ const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s+(.+)$/;
38
38
 
39
- /** Timeline migration syntax: `2024-01-15 -> 30d: Label` */
40
- const TIMELINE_DURATION_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
39
+ /** Timeline migration syntax: `2024-01-15 -> 30d Label` or `2024-01-15 14:30 -> 2h Label` */
40
+ const TIMELINE_DURATION_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*->\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
41
41
 
42
42
  /** Group container: `[GroupName]` with optional pipe metadata */
43
43
  const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
44
44
 
45
- /** Dependency: `-> TargetName` with optional pipe metadata */
46
- const DEPENDENCY_RE = /^->\s*(.+)$/;
45
+ /** Dependency: `-> TargetName` or `-label-> TargetName` with optional pipe metadata */
46
+ const DEPENDENCY_RE = /^(?:-(.+?))?->\s*(.+)$/;
47
47
 
48
48
  /** Comment line */
49
49
  const COMMENT_RE = /^\/\//;
50
50
 
51
- /** Era: `era YYYY[-MM[-DD]] -> YYYY[-MM[-DD]]: Label (color?)` */
52
- const ERA_RE = /^era\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
51
+ /** Era: `era YYYY[-MM[-DD[ HH:MM]]] -> YYYY[-MM[-DD[ HH:MM]]] Label (color?)` */
52
+ const ERA_RE = /^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+(.+)$/i;
53
53
 
54
- /** Marker: `marker: YYYY[-MM[-DD]] Label (color?)` */
55
- const MARKER_RE = /^marker:\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s+(.+)$/i;
54
+ /** Marker: `marker YYYY[-MM[-DD[ HH:MM]]] Label (color?)` */
55
+ const MARKER_RE = /^marker\s+(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/i;
56
56
 
57
- /** Holiday date: `2024-01-15: Label` */
58
- const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
57
+ /** Holiday date: `2024-01-15 Label` */
58
+ const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2})\s+(.+)$/;
59
59
 
60
- /** Holiday range: `2024-12-24 -> 2024-12-31: Label` */
61
- const HOLIDAY_RANGE_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
60
+ /** Holiday range: `2024-12-24 -> 2024-12-31 Label` */
61
+ const HOLIDAY_RANGE_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d{4}-\d{2}-\d{2})\s+(.+)$/;
62
62
 
63
- /** Workweek override: `workweek: sun-thu` */
64
- const WORKWEEK_RE = /^workweek:\s*(.+)$/i;
65
-
66
- /** chart: gantt */
67
- const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
68
-
69
- /** Option lines */
70
- const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
63
+ /** Workweek override: `workweek sun-thu` */
64
+ const WORKWEEK_RE = /^workweek\s+(.+)$/i;
71
65
 
72
66
  // Valid weekday names
73
67
  const WEEKDAY_MAP: Record<string, Weekday> = {
@@ -101,10 +95,9 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
101
95
  start: null,
102
96
  title: null,
103
97
  titleLineNumber: null,
104
- orientation: 'horizontal',
105
98
  todayMarker: 'off',
106
99
  criticalPath: false,
107
- dependencies: false,
100
+ dependencies: true,
108
101
  sort: 'default',
109
102
  defaultSwimlaneGroup: null,
110
103
  optionLineNumbers: {},
@@ -193,14 +186,19 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
193
186
 
194
187
  // ── Chart type ────────────────────────────────────────
195
188
 
196
- const chartTypeMatch = line.match(CHART_TYPE_RE);
197
- if (chartTypeMatch) {
198
- const type = chartTypeMatch[1].trim().toLowerCase();
199
- if (type !== 'gantt') {
200
- return fail(lineNumber, `Expected chart type "gantt", got "${type}"`);
189
+ if (!seenChartType) {
190
+ const firstLineResult = parseFirstLine(line);
191
+ if (firstLineResult) {
192
+ if (firstLineResult.chartType !== 'gantt') {
193
+ return fail(lineNumber, `Expected chart type "gantt", got "${firstLineResult.chartType}"`);
194
+ }
195
+ seenChartType = true;
196
+ if (firstLineResult.title) {
197
+ result.options.title = firstLineResult.title;
198
+ result.options.titleLineNumber = lineNumber;
199
+ }
200
+ continue;
201
201
  }
202
- seenChartType = true;
203
- continue;
204
202
  }
205
203
 
206
204
  // ── Holidays block ────────────────────────────────────
@@ -261,22 +259,18 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
261
259
  currentTagGroup = null;
262
260
  // fall through to process this line normally
263
261
  } else {
264
- // Parse tag entry: `Value(color)` or `Value` with optional `default` suffix
262
+ // Parse tag entry: `Value(color)` or `Value`
263
+ // First entry is the default (no `default` keyword needed)
265
264
  if (COMMENT_RE.test(line)) continue;
266
- let entryLine = line;
267
- let isDefault = false;
268
- if (entryLine.endsWith(' default') || entryLine.endsWith('\tdefault')) {
269
- isDefault = true;
270
- entryLine = entryLine.replace(/\s+default$/, '').trim();
271
- }
272
- const extracted = extractColor(entryLine, palette);
265
+ const extracted = extractColor(line, palette);
273
266
  const color = extracted.color || seriesColors[currentTagGroup.entries.length % seriesColors.length] || '#888888';
267
+ const isFirstEntry = currentTagGroup.entries.length === 0;
274
268
  currentTagGroup.entries.push({
275
269
  value: extracted.label,
276
270
  color,
277
271
  lineNumber,
278
272
  });
279
- if (isDefault) {
273
+ if (isFirstEntry) {
280
274
  currentTagGroup.defaultValue = extracted.label;
281
275
  }
282
276
  continue;
@@ -302,7 +296,8 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
302
296
  // Dependency under a task
303
297
  const depMatch = line.match(DEPENDENCY_RE);
304
298
  if (depMatch) {
305
- const depParts = depMatch[1].split('|');
299
+ const label = depMatch[1]?.trim() || undefined;
300
+ const depParts = depMatch[2].split('|');
306
301
  const targetName = depParts[0].trim();
307
302
  let offset: Offset | undefined;
308
303
 
@@ -310,7 +305,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
310
305
  const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
311
306
  if (meta.lag || meta.lead) {
312
307
  const key = meta.lag ? 'lag' : 'lead';
313
- softError(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
308
+ softError(lineNumber, `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
314
309
  }
315
310
  if (meta.offset) {
316
311
  const raw = meta.offset;
@@ -327,6 +322,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
327
322
 
328
323
  lastTaskNode.dependencies.push({
329
324
  targetName,
325
+ label,
330
326
  offset,
331
327
  lineNumber,
332
328
  });
@@ -349,7 +345,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
349
345
 
350
346
  // ── Header options ────────────────────────────────────
351
347
 
352
- if (line.toLowerCase() === 'holidays') {
348
+ if (line.toLowerCase() === 'holiday' || line.toLowerCase() === 'holidays') {
353
349
  inHolidaysBlock = true;
354
350
  holidaysBlockIndent = indent;
355
351
  inHeaderBlock = false;
@@ -357,9 +353,26 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
357
353
  continue;
358
354
  }
359
355
 
356
+ // Single-line holiday: `holiday 2024-12-25 Christmas`
357
+ const holidayInlineMatch = line.match(/^holiday\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/i);
358
+ if (holidayInlineMatch) {
359
+ result.holidays.dates.push({
360
+ date: holidayInlineMatch[1],
361
+ label: holidayInlineMatch[2].trim(),
362
+ lineNumber,
363
+ });
364
+ result.options.holidaysLineNumber ??= lineNumber;
365
+ inHeaderBlock = false;
366
+ continue;
367
+ }
368
+
360
369
  // Tag block heading
361
370
  const tagMatch = matchTagBlockHeading(line);
362
371
  if (tagMatch) {
372
+ if (tagMatch.deprecated) {
373
+ softError(lineNumber, `'## ${tagMatch.name}' is no longer supported — use 'tag ${tagMatch.name}' instead`);
374
+ continue;
375
+ }
363
376
  inTagBlock = true;
364
377
  tagBlockIndent = indent;
365
378
  inHeaderBlock = false;
@@ -406,11 +419,49 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
406
419
  continue;
407
420
  }
408
421
 
409
- // Options (start, title, orientation, etc.)
410
- const optMatch = line.match(OPTION_RE);
411
- if (optMatch && isKnownOption(optMatch[1].toLowerCase())) {
412
- const key = optMatch[1].toLowerCase();
413
- const value = optMatch[2].trim();
422
+ // Options — space-separated: `start 2024-04-01`, `title My Plan`
423
+ // Boolean options: bare keyword = on, `no-X` = off
424
+ const optNoColonMatch = line.match(/^([a-z][a-z0-9-]*)\s+(.+)$/i);
425
+ const bareKeyword = line.match(/^([a-z][a-z0-9-]*)$/i);
426
+
427
+ // Bare boolean keywords
428
+ if (bareKeyword && KNOWN_BOOLEANS.has(bareKeyword[1].toLowerCase())) {
429
+ const key = bareKeyword[1].toLowerCase();
430
+ result.options.optionLineNumbers[key] = lineNumber;
431
+ switch (key) {
432
+ case 'critical-path':
433
+ result.options.criticalPath = true;
434
+ break;
435
+ case 'today-marker':
436
+ result.options.todayMarker = 'on';
437
+ break;
438
+ }
439
+ continue;
440
+ }
441
+
442
+ // Negated booleans: `no-dependencies`, `no-critical-path`
443
+ if (bareKeyword && bareKeyword[1].toLowerCase().startsWith('no-')) {
444
+ const base = bareKeyword[1].toLowerCase().substring(3);
445
+ if (KNOWN_BOOLEANS.has(base)) {
446
+ result.options.optionLineNumbers[base] = lineNumber;
447
+ switch (base) {
448
+ case 'dependencies':
449
+ result.options.dependencies = false;
450
+ break;
451
+ case 'critical-path':
452
+ result.options.criticalPath = false;
453
+ break;
454
+ case 'today-marker':
455
+ result.options.todayMarker = 'off';
456
+ break;
457
+ }
458
+ continue;
459
+ }
460
+ }
461
+
462
+ if (optNoColonMatch && isKnownOption(optNoColonMatch[1].toLowerCase())) {
463
+ const key = optNoColonMatch[1].toLowerCase();
464
+ const value = optNoColonMatch[2].trim();
414
465
  result.options.optionLineNumbers[key] = lineNumber;
415
466
 
416
467
  switch (key) {
@@ -422,26 +473,21 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
422
473
  result.options.titleLineNumber = lineNumber;
423
474
  break;
424
475
  case 'orientation':
425
- if (value === 'horizontal' || value === 'vertical') {
426
- result.options.orientation = value;
427
- } else {
428
- warn(lineNumber, `Invalid orientation: "${value}". Expected "horizontal" or "vertical".`);
429
- }
476
+ warn(lineNumber, `'orientation' is not supported for gantt charts`);
430
477
  break;
431
478
  case 'today-marker':
432
- if (value === 'on' || value === 'off') {
433
- result.options.todayMarker = value;
434
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
479
+ if (/^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?$/.test(value)) {
435
480
  result.options.todayMarker = value;
436
481
  } else {
437
- warn(lineNumber, `Invalid today-marker value: "${value}". Expected "on", "off", or YYYY-MM-DD.`);
482
+ warn(lineNumber, `Invalid today-marker value: "${value}". Expected YYYY-MM-DD.`);
438
483
  }
439
484
  break;
440
485
  case 'critical-path':
441
- result.options.criticalPath = value === 'on';
486
+ result.options.criticalPath = true;
442
487
  break;
443
488
  case 'dependencies':
444
- result.options.dependencies = value === 'on';
489
+ // Boolean with value but `dependencies` is now default ON, so only `no-dependencies` turns it off
490
+ result.options.dependencies = true;
445
491
  break;
446
492
  case 'sort':
447
493
  if (value === 'tag' || value.startsWith('tag:')) {
@@ -460,6 +506,29 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
460
506
 
461
507
  inHeaderBlock = false;
462
508
 
509
+ // ── `# Group` alternate syntax ──────────────────────────
510
+
511
+ const hashGroupMatch = line.match(GROUP_HASH_RE);
512
+ if (hashGroupMatch) {
513
+ const nameExtracted = extractColor(hashGroupMatch[1], palette);
514
+ const group: GanttGroup = {
515
+ name: nameExtracted.label,
516
+ color: nameExtracted.color || null,
517
+ metadata: {},
518
+ lineNumber,
519
+ children: [],
520
+ };
521
+ const groupNode: GanttNode = { kind: 'group', ...group };
522
+ currentContainer().push(groupNode);
523
+ blockStack.push({
524
+ node: groupNode as GanttGroup,
525
+ indent,
526
+ containerType: 'group',
527
+ });
528
+ lastTaskNode = null;
529
+ continue;
530
+ }
531
+
463
532
  // ── Parallel block ────────────────────────────────────
464
533
 
465
534
  if (line === 'parallel') {
@@ -583,11 +652,12 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
583
652
  if (depMatch) {
584
653
  // Dependency without a task context is an error
585
654
  if (!lastTaskNode) {
586
- softError(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
655
+ softError(lineNumber, `Dependency "-> ${depMatch[2]}" must be indented under a task.`);
587
656
  continue;
588
657
  }
589
658
  // This happens when the dep is at the same indent as the task
590
- const depParts = depMatch[1].split('|');
659
+ const label = depMatch[1]?.trim() || undefined;
660
+ const depParts = depMatch[2].split('|');
591
661
  const targetName = depParts[0].trim();
592
662
  let offset: Offset | undefined;
593
663
 
@@ -595,7 +665,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
595
665
  const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
596
666
  if (meta.lag || meta.lead) {
597
667
  const key = meta.lag ? 'lag' : 'lead';
598
- warn(lineNumber, `"${key}" is deprecated — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
668
+ softError(lineNumber, `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
599
669
  }
600
670
  if (meta.offset) {
601
671
  const raw = meta.offset;
@@ -610,13 +680,13 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
610
680
  }
611
681
  }
612
682
 
613
- lastTaskNode.dependencies.push({ targetName, offset, lineNumber });
683
+ lastTaskNode.dependencies.push({ targetName, label, offset, lineNumber });
614
684
  continue;
615
685
  }
616
686
 
617
687
  // ── Bare label = parse error ──────────────────────────
618
688
 
619
- softError(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
689
+ softError(lineNumber, `Expected duration (e.g., "10d Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
620
690
  continue;
621
691
  }
622
692
 
@@ -631,7 +701,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
631
701
 
632
702
  // Validate sort: tag requires tag groups
633
703
  if (result.options.sort === 'tag' && result.tagGroups.length === 0) {
634
- warn(0, 'sort: tag has no effect — no tag groups defined.');
704
+ warn(0, 'sort tag has no effect — no tag groups defined.');
635
705
  result.options.sort = 'default';
636
706
  }
637
707
 
@@ -677,7 +747,7 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
677
747
  // Reject lag/lead — use offset instead
678
748
  if (metadata.lag || metadata.lead) {
679
749
  const key = metadata.lag ? 'lag' : 'lead';
680
- softError(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
750
+ softError(ln, `"${key}" is no longer supported — use "offset: ${metadata[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
681
751
  }
682
752
 
683
753
  // Extract task-level offset from metadata
@@ -767,6 +837,11 @@ const KNOWN_OPTIONS = new Set([
767
837
  'critical-path', 'dependencies', 'chart', 'sort',
768
838
  ]);
769
839
 
840
+ /** Boolean options that can appear as bare keywords or with `no-` prefix. */
841
+ const KNOWN_BOOLEANS = new Set([
842
+ 'critical-path', 'today-marker', 'dependencies',
843
+ ]);
844
+
770
845
  function isKnownOption(key: string): boolean {
771
846
  return KNOWN_OPTIONS.has(key);
772
847
  }
@@ -14,16 +14,16 @@ import {
14
14
  LEGEND_HEIGHT,
15
15
  LEGEND_PILL_PAD,
16
16
  LEGEND_PILL_FONT_SIZE,
17
- LEGEND_PILL_FONT_W,
18
17
  LEGEND_CAPSULE_PAD,
19
18
  LEGEND_DOT_R,
20
19
  LEGEND_ENTRY_FONT_SIZE,
21
- LEGEND_ENTRY_FONT_W,
22
20
  LEGEND_ENTRY_DOT_GAP,
23
21
  LEGEND_ENTRY_TRAIL,
24
22
  LEGEND_GROUP_GAP,
25
23
  LEGEND_ICON_W,
24
+ measureLegendText,
26
25
  } from '../utils/legend-constants';
26
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
27
27
  import type { PaletteColors } from '../palettes';
28
28
  import type { D3ExportDimensions } from '../d3';
29
29
  import type { ResolvedSchedule, ResolvedTask, ResolvedGroup, Weekday } from './types';
@@ -258,8 +258,8 @@ export function renderGantt(
258
258
  .select(container)
259
259
  .append('svg')
260
260
  .attr('viewBox', `0 0 ${containerWidth} ${outerHeight}`)
261
- .attr('width', containerWidth)
262
- .attr('height', outerHeight)
261
+ .attr('width', exportDims ? containerWidth : '100%')
262
+ .attr('preserveAspectRatio', 'xMidYMin meet')
263
263
  .attr('font-family', FONT_FAMILY)
264
264
  .style('overflow', 'visible');
265
265
 
@@ -273,10 +273,10 @@ export function renderGantt(
273
273
  svg
274
274
  .append('text')
275
275
  .attr('x', containerWidth / 2)
276
- .attr('y', 30)
276
+ .attr('y', TITLE_Y)
277
277
  .attr('text-anchor', 'middle')
278
- .attr('font-size', '20px')
279
- .attr('font-weight', '700')
278
+ .attr('font-size', TITLE_FONT_SIZE)
279
+ .attr('font-weight', TITLE_FONT_WEIGHT)
280
280
  .attr('fill', palette.text)
281
281
  .text(title);
282
282
  }
@@ -1251,6 +1251,38 @@ function renderDependencyArrows(
1251
1251
  .attr('points', arrowheadPoints(tx, ty, headSize, angle))
1252
1252
  .attr('fill', arrowColor)
1253
1253
  .attr('opacity', 0.5);
1254
+
1255
+ // Label at midpoint of the dependency path
1256
+ if (dep.label) {
1257
+ const midX = (sx + tx) / 2;
1258
+ const midY = (sy + ty) / 2;
1259
+ // Background rect for readability
1260
+ const labelEl = g.append('text')
1261
+ .attr('class', 'gantt-dep-label')
1262
+ .attr('data-dep-from', rt.task.id)
1263
+ .attr('data-dep-to', targetTask.task.id)
1264
+ .attr('x', midX)
1265
+ .attr('y', midY - 4)
1266
+ .attr('text-anchor', 'middle')
1267
+ .attr('font-size', 10)
1268
+ .attr('font-family', FONT_FAMILY)
1269
+ .attr('fill', palette.text)
1270
+ .attr('opacity', 0.7)
1271
+ .text(dep.label);
1272
+ // Insert a background rect behind the text for contrast
1273
+ const bbox = (labelEl.node() as SVGTextElement)?.getBBox?.();
1274
+ if (bbox && bbox.width > 0) {
1275
+ g.insert('rect', '.gantt-dep-label:last-of-type')
1276
+ .attr('class', 'gantt-dep-label-bg')
1277
+ .attr('x', bbox.x - 2)
1278
+ .attr('y', bbox.y - 1)
1279
+ .attr('width', bbox.width + 4)
1280
+ .attr('height', bbox.height + 2)
1281
+ .attr('fill', palette.bg)
1282
+ .attr('opacity', 0.8)
1283
+ .attr('rx', 2);
1284
+ }
1285
+ }
1254
1286
  }
1255
1287
  }
1256
1288
  }
@@ -1407,13 +1439,13 @@ function renderTagLegend(
1407
1439
  const isSwimlane = currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1408
1440
  const showIcon = !legendViewMode && tagGroups.length > 0;
1409
1441
  const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1410
- const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1442
+ const pillW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD + iconReserve;
1411
1443
  let groupW = pillW;
1412
1444
  if (isActive) {
1413
1445
  const entries = filteredEntries.get(group.name.toLowerCase()) ?? group.entries;
1414
1446
  let entriesW = 0;
1415
1447
  for (const entry of entries) {
1416
- entriesW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1448
+ entriesW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1417
1449
  }
1418
1450
  groupW = LEGEND_CAPSULE_PAD * 2 + pillW + 4 + entriesW;
1419
1451
  } else if (isSwimlane && !isActive) {
@@ -1427,7 +1459,7 @@ function renderTagLegend(
1427
1459
 
1428
1460
  // Critical Path pill width
1429
1461
  const cpLabel = 'Critical Path';
1430
- const cpPillW = cpLabel.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1462
+ const cpPillW = measureLegendText(cpLabel, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1431
1463
  if (hasCriticalPath) {
1432
1464
  if (visibleGroups.length > 0) totalW += LEGEND_GROUP_GAP;
1433
1465
  totalW += cpPillW;
@@ -1449,7 +1481,7 @@ function renderTagLegend(
1449
1481
  const isSwimlane = currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1450
1482
  const showIcon = !legendViewMode && tagGroups.length > 0;
1451
1483
  const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1452
- const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD + iconReserve;
1484
+ const pillW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD + iconReserve;
1453
1485
  const pillH = isActive ? LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2 : LEGEND_HEIGHT;
1454
1486
  const groupW = groupWidths[i];
1455
1487
 
@@ -1496,7 +1528,7 @@ function renderTagLegend(
1496
1528
  }
1497
1529
 
1498
1530
  // Pill text (offset to leave room for icon on right)
1499
- const textW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
1531
+ const textW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
1500
1532
  gEl.append('text')
1501
1533
  .attr('x', pillXOff + textW / 2)
1502
1534
  .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
@@ -1589,7 +1621,7 @@ function renderTagLegend(
1589
1621
  }
1590
1622
  });
1591
1623
 
1592
- ex += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
1624
+ ex += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
1593
1625
  }
1594
1626
  }
1595
1627
 
@@ -2272,7 +2304,11 @@ function diamondPoints(cx: number, cy: number, size: number): string {
2272
2304
  const MONTH_ABBR = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
2273
2305
 
2274
2306
  function formatGanttDate(d: Date): string {
2275
- return `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
2307
+ const base = `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
2308
+ if (d.getHours() === 0 && d.getMinutes() === 0) return base;
2309
+ const hh = String(d.getHours()).padStart(2, '0');
2310
+ const mm = String(d.getMinutes()).padStart(2, '0');
2311
+ return `${base} ${hh}:${mm}`;
2276
2312
  }
2277
2313
 
2278
2314
  function showGanttDateIndicators(
@@ -7,8 +7,8 @@ import type { TagGroup } from '../utils/tag-groups';
7
7
 
8
8
  // ── Duration ────────────────────────────────────────────────
9
9
 
10
- /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years). bd = business days. */
11
- export type DurationUnit = 'd' | 'bd' | 'w' | 'm' | 'q' | 'y';
10
+ /** Calendar units: d (days), w (weeks), m (months), q (quarters), y (years), h (hours), min (minutes). bd = business days. */
11
+ export type DurationUnit = 'd' | 'bd' | 'w' | 'm' | 'q' | 'y' | 'h' | 'min';
12
12
 
13
13
  export interface Duration {
14
14
  amount: number;
@@ -24,6 +24,7 @@ export interface Offset {
24
24
 
25
25
  export interface GanttDependency {
26
26
  targetName: string; // raw string from `-> X` or `-> Group.X`
27
+ label?: string; // optional label from `-label-> X` syntax
27
28
  offset?: Offset;
28
29
  lineNumber: number;
29
30
  }
@@ -109,7 +110,6 @@ export interface GanttOptions {
109
110
  start: string | null; // YYYY[-MM[-DD]] or null for relative timeline
110
111
  title: string | null;
111
112
  titleLineNumber: number | null;
112
- orientation: 'horizontal' | 'vertical';
113
113
  todayMarker: 'off' | 'on' | string; // 'on' = current date, string = YYYY-MM-DD
114
114
  criticalPath: boolean;
115
115
  dependencies: boolean;