@diagrammo/dgmo 0.26.0 → 0.28.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 (138) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +5651 -3193
  3. package/dist/advanced.d.cts +272 -58
  4. package/dist/advanced.d.ts +272 -58
  5. package/dist/advanced.js +5650 -3186
  6. package/dist/auto.cjs +5511 -3070
  7. package/dist/auto.js +116 -137
  8. package/dist/auto.mjs +5510 -3069
  9. package/dist/cli.cjs +168 -189
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +5536 -3072
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +5535 -3071
  18. package/dist/internal.cjs +5651 -3193
  19. package/dist/internal.d.cts +272 -58
  20. package/dist/internal.d.ts +272 -58
  21. package/dist/internal.js +5650 -3186
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +7 -3
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout-layered.ts +722 -0
  36. package/src/boxes-and-lines/layout-search.ts +1200 -0
  37. package/src/boxes-and-lines/layout.ts +202 -571
  38. package/src/boxes-and-lines/parser.ts +43 -8
  39. package/src/boxes-and-lines/renderer.ts +223 -96
  40. package/src/boxes-and-lines/types.ts +9 -2
  41. package/src/c4/layout.ts +14 -32
  42. package/src/c4/parser.ts +9 -5
  43. package/src/c4/renderer.ts +34 -39
  44. package/src/class/layout.ts +118 -18
  45. package/src/class/parser.ts +35 -0
  46. package/src/class/renderer.ts +58 -2
  47. package/src/class/types.ts +3 -0
  48. package/src/cli.ts +4 -4
  49. package/src/completion.ts +26 -12
  50. package/src/cycle/layout.ts +55 -72
  51. package/src/cycle/renderer.ts +11 -6
  52. package/src/d3.ts +78 -117
  53. package/src/diagnostics.ts +16 -0
  54. package/src/echarts.ts +46 -33
  55. package/src/editor/keywords.ts +4 -0
  56. package/src/er/layout.ts +114 -22
  57. package/src/er/parser.ts +28 -0
  58. package/src/er/renderer.ts +55 -2
  59. package/src/er/types.ts +3 -0
  60. package/src/gantt/renderer.ts +46 -38
  61. package/src/gantt/resolver.ts +9 -2
  62. package/src/graph/edge-spline.ts +29 -0
  63. package/src/graph/flowchart-parser.ts +34 -1
  64. package/src/graph/flowchart-renderer.ts +78 -64
  65. package/src/graph/layout.ts +206 -23
  66. package/src/graph/notes.ts +21 -0
  67. package/src/graph/state-parser.ts +26 -1
  68. package/src/graph/state-renderer.ts +78 -64
  69. package/src/graph/types.ts +13 -0
  70. package/src/index.ts +1 -1
  71. package/src/infra/layout.ts +46 -26
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +101 -25
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1212 -96
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/renderer.ts +30 -43
  101. package/src/pyramid/renderer.ts +4 -5
  102. package/src/raci/renderer.ts +34 -68
  103. package/src/render.ts +1 -1
  104. package/src/ring/renderer.ts +1 -2
  105. package/src/sequence/parser.ts +100 -22
  106. package/src/sequence/renderer.ts +75 -50
  107. package/src/sitemap/layout.ts +27 -19
  108. package/src/sitemap/renderer.ts +12 -5
  109. package/src/tech-radar/renderer.ts +11 -35
  110. package/src/utils/arrow-markers.ts +51 -0
  111. package/src/utils/fit-canvas.ts +64 -0
  112. package/src/utils/legend-constants.ts +8 -54
  113. package/src/utils/legend-d3.ts +10 -7
  114. package/src/utils/legend-layout.ts +7 -4
  115. package/src/utils/legend-types.ts +10 -4
  116. package/src/utils/note-box/constants.ts +25 -0
  117. package/src/utils/note-box/index.ts +11 -0
  118. package/src/utils/note-box/metrics.ts +90 -0
  119. package/src/utils/note-box/svg.ts +331 -0
  120. package/src/utils/notes/bounds.ts +30 -0
  121. package/src/utils/notes/build.ts +131 -0
  122. package/src/utils/notes/index.ts +18 -0
  123. package/src/utils/notes/model.ts +19 -0
  124. package/src/utils/notes/parse.ts +131 -0
  125. package/src/utils/notes/place.ts +177 -0
  126. package/src/utils/notes/resolve.ts +88 -0
  127. package/src/utils/number-format.ts +36 -0
  128. package/src/utils/parsing.ts +41 -0
  129. package/src/utils/reserved-key-registry.ts +4 -0
  130. package/src/utils/text-measure.ts +122 -0
  131. package/src/wireframe/layout.ts +4 -2
  132. package/src/wireframe/renderer.ts +8 -6
  133. package/src/palettes/dracula.ts +0 -68
  134. package/src/palettes/gruvbox.ts +0 -85
  135. package/src/palettes/monokai.ts +0 -68
  136. package/src/palettes/one-dark.ts +0 -70
  137. package/src/palettes/rose-pine.ts +0 -84
  138. package/src/palettes/solarized.ts +0 -77
package/src/er/layout.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import dagre from '@dagrejs/dagre';
2
+ import { measureText } from '../utils/text-measure';
3
+ import {
4
+ resolveNotes,
5
+ buildPlacedNotes,
6
+ noteCanvasShift,
7
+ type PlacedNote,
8
+ } from '../utils/notes';
2
9
  import type { ParsedERDiagram, ERTable, ERRelationship } from './types';
3
10
 
4
11
  // ============================================================
@@ -12,6 +19,13 @@ export interface ERLayoutNode extends ERTable {
12
19
  readonly height: number;
13
20
  readonly headerHeight: number;
14
21
  readonly columnsHeight: number;
22
+ /** A note floated beside this table (never moves the box). */
23
+ readonly note?: PlacedNote;
24
+ }
25
+
26
+ export interface ERLayoutOptions {
27
+ /** 1-based source lines of notes the user collapsed (corner badge). */
28
+ collapsedNotes?: ReadonlySet<number>;
15
29
  }
16
30
 
17
31
  export interface ERLayoutEdge {
@@ -35,9 +49,15 @@ export interface ERLayoutResult {
35
49
  // ============================================================
36
50
 
37
51
  const MIN_WIDTH = 140;
38
- const CHAR_WIDTH = 7.5;
39
52
  const PADDING_X = 24;
40
53
  const HEADER_BASE = 36;
54
+ // Font sizes mirror the renderer: table name at TABLE_FONT_SIZE, columns at
55
+ // COLUMN_FONT_SIZE. Keep these in sync with renderer.ts.
56
+ const TABLE_FONT_SIZE = 13;
57
+ const COLUMN_FONT_SIZE = 11;
58
+ const EDGE_LABEL_FONT_SIZE = 11;
59
+ // Renderer offsets column text by 16px when a constraint icon is present.
60
+ const CONSTRAINT_ICON_WIDTH = 16;
41
61
  const MEMBER_LINE_HEIGHT = 18;
42
62
  const COMPARTMENT_PADDING_Y = 8;
43
63
  const SEPARATOR_HEIGHT = 1;
@@ -55,14 +75,18 @@ function computeNodeDimensions(table: ERTable): {
55
75
  headerHeight: number;
56
76
  columnsHeight: number;
57
77
  } {
58
- let maxTextLen = table.name.length;
78
+ // Width: max rendered pixel width of table name and column rows. Measure
79
+ // each at the font size the renderer draws it with; reserve the constraint
80
+ // icon's horizontal offset when a column carries one.
81
+ let maxTextWidth = measureText(table.name, TABLE_FONT_SIZE);
59
82
  for (const col of table.columns) {
60
83
  let colText = col.name;
61
- if (col.type) colText += ` ${col.type}`;
62
- if (col.constraints.length > 0) colText += ` XX`; // icon space
63
- maxTextLen = Math.max(maxTextLen, colText.length);
84
+ if (col.type) colText += `: ${col.type}`;
85
+ let colWidth = measureText(colText, COLUMN_FONT_SIZE);
86
+ if (col.constraints.length > 0) colWidth += CONSTRAINT_ICON_WIDTH;
87
+ maxTextWidth = Math.max(maxTextWidth, colWidth);
64
88
  }
65
- const width = Math.max(MIN_WIDTH, maxTextLen * CHAR_WIDTH + PADDING_X);
89
+ const width = Math.max(MIN_WIDTH, maxTextWidth + PADDING_X);
66
90
  const headerHeight = HEADER_BASE;
67
91
 
68
92
  let columnsHeight = 0;
@@ -227,7 +251,7 @@ function layoutComponent(
227
251
  if (rel.label && (ed?.points ?? []).length > 0) {
228
252
  const pts = ed!.points;
229
253
  const mid = pts[Math.floor(pts.length / 2)];
230
- const hw = (rel.label.length * 7 + 8) / 2;
254
+ const hw = (measureText(rel.label, EDGE_LABEL_FONT_SIZE) + 8) / 2;
231
255
  minX = Math.min(minX, mid.x - hw);
232
256
  maxX = Math.max(maxX, mid.x + hw);
233
257
  }
@@ -333,7 +357,10 @@ function packComponents(
333
357
  // Layout engine
334
358
  // ============================================================
335
359
 
336
- export function layoutERDiagram(parsed: ParsedERDiagram): ERLayoutResult {
360
+ export function layoutERDiagram(
361
+ parsed: ParsedERDiagram,
362
+ options?: ERLayoutOptions
363
+ ): ERLayoutResult {
337
364
  if (parsed.tables.length === 0) {
338
365
  return { nodes: [], edges: [], width: 0, height: 0 };
339
366
  }
@@ -415,24 +442,89 @@ export function layoutERDiagram(parsed: ParsedERDiagram): ERLayoutResult {
415
442
  };
416
443
  });
417
444
 
418
- // ── 6. Total canvas dimensions ───────────────────────────────────────────────
419
- let maxX = 0;
420
- let maxY = 0;
421
- for (const node of layoutNodes) {
422
- maxX = Math.max(maxX, node.x + node.width / 2);
423
- maxY = Math.max(maxY, node.y + node.height / 2);
445
+ // ── 6. Notes ─────────────────────────────────────────────────────────────────
446
+ // Resolve note anchors (no diagnostics — the parser already emitted them).
447
+ // `no-notes` drops the footprint so layout matches an un-annotated diagram.
448
+ // The note floats beside its table WITHOUT moving it (ER nodes are center
449
+ // -positioned default side right).
450
+ const notesSuppressed = parsed.options?.['no-notes'] === 'on';
451
+ const noteByNode =
452
+ notesSuppressed || !parsed.notes
453
+ ? new Map()
454
+ : resolveNotes(
455
+ parsed.notes,
456
+ parsed.tables.map((t) => ({ id: t.id, label: t.name }))
457
+ );
458
+ const placedNotes = buildPlacedNotes(
459
+ layoutNodes.map((n) => ({
460
+ id: n.id,
461
+ x: n.x,
462
+ y: n.y,
463
+ width: n.width,
464
+ height: n.height,
465
+ })),
466
+ noteByNode,
467
+ 'TB',
468
+ options?.collapsedNotes
469
+ );
470
+ const notedNodes: ERLayoutNode[] = layoutNodes.map((n) => {
471
+ const note = placedNotes.get(n.id);
472
+ return note ? { ...n, note } : n;
473
+ });
474
+
475
+ // ── 7. Content bbox (incl. floated notes) + off-canvas shift ─────────────────
476
+ let bbMinX = Infinity;
477
+ let bbMinY = Infinity;
478
+ let bbMaxX = -Infinity;
479
+ let bbMaxY = -Infinity;
480
+ const extend = (l: number, t: number, r: number, b: number): void => {
481
+ if (l < bbMinX) bbMinX = l;
482
+ if (t < bbMinY) bbMinY = t;
483
+ if (r > bbMaxX) bbMaxX = r;
484
+ if (b > bbMaxY) bbMaxY = b;
485
+ };
486
+ for (const node of notedNodes) {
487
+ extend(
488
+ node.x - node.width / 2,
489
+ node.y - node.height / 2,
490
+ node.x + node.width / 2,
491
+ node.y + node.height / 2
492
+ );
493
+ if (node.note && !node.note.collapsed) {
494
+ extend(
495
+ node.x + node.note.x,
496
+ node.y + node.note.y,
497
+ node.x + node.note.x + node.note.width,
498
+ node.y + node.note.y + node.note.height
499
+ );
500
+ }
424
501
  }
425
502
  for (const edge of layoutEdges) {
426
- for (const pt of edge.points) {
427
- maxX = Math.max(maxX, pt.x);
428
- maxY = Math.max(maxY, pt.y);
429
- }
503
+ for (const pt of edge.points) extend(pt.x, pt.y, pt.x, pt.y);
430
504
  }
505
+ if (!Number.isFinite(bbMinX)) {
506
+ bbMinX = 0;
507
+ bbMinY = 0;
508
+ bbMaxX = 0;
509
+ bbMaxY = 0;
510
+ }
511
+
512
+ const { shiftX, shiftY } = noteCanvasShift(bbMinX, bbMinY);
513
+ const shifted = shiftX !== 0 || shiftY !== 0;
514
+ const finalNodes = shifted
515
+ ? notedNodes.map((n) => ({ ...n, x: n.x + shiftX, y: n.y + shiftY }))
516
+ : notedNodes;
517
+ const finalEdges = shifted
518
+ ? layoutEdges.map((e) => ({
519
+ ...e,
520
+ points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
521
+ }))
522
+ : layoutEdges;
431
523
 
432
524
  return {
433
- nodes: layoutNodes,
434
- edges: layoutEdges,
435
- width: maxX + HALF_MARGIN,
436
- height: maxY + HALF_MARGIN,
525
+ nodes: finalNodes,
526
+ edges: finalEdges,
527
+ width: bbMaxX + shiftX + HALF_MARGIN,
528
+ height: bbMaxY + shiftY + HALF_MARGIN,
437
529
  };
438
530
  }
package/src/er/parser.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  stripDefaultModifier,
32
32
  } from '../utils/tag-groups';
33
33
  import type { TagGroup } from '../utils/tag-groups';
34
+ import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
34
35
  import type { Writable } from '../utils/brand';
35
36
  import type {
36
37
  ParsedERDiagram,
@@ -252,6 +253,7 @@ export function parseERDiagram(
252
253
  // assign into it (table.metadata is `Readonly<Record<...>>` per the spec).
253
254
  const tableMetadataMap = new Map<string, Record<string, string>>();
254
255
  let currentTable: Writable<ERTable> | null = null;
256
+ const notes: DiagramNote[] = [];
255
257
  let contentStarted = false;
256
258
  let currentTagGroup: Writable<TagGroup> | null = null;
257
259
  // metaAliasMap: tag-group metadata-key aliases (per A1 convention).
@@ -466,6 +468,21 @@ export function parseERDiagram(
466
468
  currentTable = null;
467
469
  contentStarted = true;
468
470
 
471
+ // Note annotation (top-level): `note <Table> [inline body]` + an optional
472
+ // indented body. `note -> X` is excluded so it can still parse as content.
473
+ const noteResult = tryCollectNote(
474
+ lines,
475
+ i,
476
+ indent,
477
+ palette,
478
+ result.diagnostics
479
+ );
480
+ if (noteResult) {
481
+ if (noteResult.note) notes.push(noteResult.note);
482
+ i = noteResult.lastIndex;
483
+ continue;
484
+ }
485
+
469
486
  // Reject top-level relationships — must be indented under source table
470
487
  const rel = parseRelationship(trimmed, lineNumber, pushError);
471
488
  if (rel) {
@@ -596,6 +613,17 @@ export function parseERDiagram(
596
613
  }
597
614
  }
598
615
 
616
+ // Resolve note refs against table names (forward refs OK). The id→note
617
+ // binding is recomputed in layout; this pass surfaces diagnostics.
618
+ if (notes.length > 0) {
619
+ result.notes = notes;
620
+ resolveNotes(
621
+ notes,
622
+ result.tables.map((t) => ({ id: t.id, label: t.name })),
623
+ result.diagnostics
624
+ );
625
+ }
626
+
599
627
  // Warn about isolated tables (not in any relationship)
600
628
  if (
601
629
  result.tables.length >= 2 &&
@@ -18,6 +18,14 @@ import {
18
18
  TITLE_Y,
19
19
  } from '../utils/title-constants';
20
20
  import { ScaleContext } from '../utils/scaling';
21
+ import { measureText } from '../utils/text-measure';
22
+ import {
23
+ renderNoteBox,
24
+ renderNoteConnector,
25
+ renderNoteBadge,
26
+ noteConnectorPoints,
27
+ NOTE_BADGE_RADIUS,
28
+ } from '../utils/note-box';
21
29
  import type { ParsedERDiagram, ERConstraint } from './types';
22
30
  import type { ERLayoutResult } from './layout';
23
31
  import { parseERDiagram } from './parser';
@@ -388,8 +396,7 @@ export function renderERDiagram(
388
396
  const midIdx = Math.floor(pts.length / 2);
389
397
  // In-bounds: midIdx is in [0, pts.length-1] since pts.length >= 2.
390
398
  const midPt = pts[midIdx]!;
391
- const labelLen = edge.label.length;
392
- const bgW = labelLen * 7 + 8;
399
+ const bgW = measureText(edge.label, sEdgeLabelFontSize) + 8;
393
400
  const bgH = 16;
394
401
 
395
402
  edgeG
@@ -549,6 +556,52 @@ export function renderERDiagram(
549
556
  memberY += sMemberLineHeight;
550
557
  }
551
558
  }
559
+
560
+ // ── Note (floated beside the table, or a collapsed corner badge) ──
561
+ // The table keeps its layout position; the note floats in adjacent
562
+ // space. Coords are node-center-local (the node `<g>` is at the center).
563
+ if (node.note) {
564
+ if (node.note.collapsed) {
565
+ renderNoteBadge(
566
+ nodeG,
567
+ {
568
+ x: w / 2 - NOTE_BADGE_RADIUS - 3,
569
+ y: -h / 2 + NOTE_BADGE_RADIUS + 3,
570
+ },
571
+ palette,
572
+ {
573
+ isDark,
574
+ ...(node.note.color && { color: node.note.color }),
575
+ lineNumber: node.note.lineNumber,
576
+ endLineNumber: node.note.endLineNumber,
577
+ }
578
+ );
579
+ } else {
580
+ const [cx1, cy1, cx2, cy2] = noteConnectorPoints(
581
+ { width: w, height: h },
582
+ node.note
583
+ );
584
+ renderNoteConnector(nodeG, cx1, cy1, cx2, cy2, palette);
585
+ renderNoteBox(
586
+ nodeG,
587
+ {
588
+ x: node.note.x,
589
+ y: node.note.y,
590
+ width: node.note.width,
591
+ height: node.note.height,
592
+ },
593
+ node.note.lines,
594
+ palette,
595
+ {
596
+ isDark,
597
+ ...(node.note.color && { color: node.note.color }),
598
+ lineNumber: node.note.lineNumber,
599
+ endLineNumber: node.note.endLineNumber,
600
+ interactive: true,
601
+ }
602
+ );
603
+ }
604
+ }
552
605
  }
553
606
 
554
607
  // ── Tag Legend ──
package/src/er/types.ts CHANGED
@@ -35,6 +35,7 @@ export interface ERRelationship {
35
35
 
36
36
  import type { DgmoError } from '../diagnostics';
37
37
  import type { TagGroup } from '../utils/tag-groups';
38
+ import type { DiagramNote } from '../utils/notes';
38
39
 
39
40
  export interface ParsedERDiagram {
40
41
  readonly type: 'er';
@@ -44,6 +45,8 @@ export interface ParsedERDiagram {
44
45
  readonly tables: readonly ERTable[];
45
46
  readonly relationships: readonly ERRelationship[];
46
47
  readonly tagGroups: readonly TagGroup[];
48
+ /** Generic node notes (`note <Table> …`); resolved in layout. */
49
+ readonly notes?: readonly DiagramNote[];
47
50
  readonly diagnostics: readonly DgmoError[];
48
51
  readonly error: string | null;
49
52
  }
@@ -10,7 +10,12 @@ import { contrastText, mix, shapeFill } from '../palettes/color-utils';
10
10
  import { normalizeName } from '../utils/name-normalize';
11
11
  import { resolveTagColor, resolveActiveTagGroup } from '../utils/tag-groups';
12
12
  import { ScaleContext } from '../utils/scaling';
13
- import { computeTimeTicks } from '../utils/time-ticks';
13
+ import { computeTimeTicks, MONTH_ABBR } from '../utils/time-ticks';
14
+ import {
15
+ measureText,
16
+ truncateText,
17
+ CHAR_WIDTH_RATIO,
18
+ } from '../utils/text-measure';
14
19
  import {
15
20
  LEGEND_HEIGHT,
16
21
  LEGEND_PILL_PAD,
@@ -60,7 +65,6 @@ const MILESTONE_SIZE = 10;
60
65
  const MIN_LEFT_MARGIN = 120;
61
66
  const BOTTOM_MARGIN = 40;
62
67
  const RIGHT_MARGIN = 20;
63
- const CHAR_W = 6.5; // estimated px per character for bar labels
64
68
  const LABEL_PAD = 8; // inner padding to decide if label fits inside bar
65
69
  const LABEL_GAP = 5; // gap between bar edge and external label
66
70
 
@@ -80,11 +84,11 @@ function computeBarLabel(
80
84
  innerWidth: number,
81
85
  textColor: string,
82
86
  onFillColor?: string,
83
- charW = CHAR_W,
87
+ fontSize = 10,
84
88
  labelPad = LABEL_PAD,
85
89
  labelGap = LABEL_GAP
86
90
  ): BarLabelPlacement | null {
87
- const textWidth = label.length * charW;
91
+ const textWidth = measureText(label, fontSize);
88
92
  const x2 = x1 + barWidth;
89
93
 
90
94
  if (textWidth < barWidth - labelPad) {
@@ -105,13 +109,15 @@ function computeBarLabel(
105
109
  }
106
110
 
107
111
  const availWidth = x1 - labelGap;
108
- if (availWidth > charW * 3) {
109
- const maxChars = Math.floor(availWidth / charW) - 1;
112
+ // Roughly three average glyphs' worth of room before truncation is worthwhile.
113
+ if (availWidth > fontSize * CHAR_WIDTH_RATIO * 3) {
114
+ const truncated = truncateText(label, fontSize, availWidth);
115
+ if (!truncated) return null;
110
116
  return {
111
117
  x: x1 - labelGap,
112
118
  anchor: 'end',
113
119
  fill: textColor,
114
- text: label.slice(0, maxChars) + '\u2026',
120
+ text: truncated,
115
121
  };
116
122
  }
117
123
 
@@ -270,25 +276,34 @@ export function renderGantt(
270
276
  const rows = tagRows ?? buildRowList(resolved, collapsedGroups);
271
277
  const isTagMode = tagRows !== null;
272
278
 
273
- // Compute left margin based on longest visible label (include ● /◆ prefix for tasks)
274
- const allLabels = isTagMode
279
+ // Compute left margin based on longest visible label (include ● /◆ prefix
280
+ // for tasks). Labels render at font 11; group labels carry a pixel indent.
281
+ const LABEL_FONT = 11;
282
+ const labelWidths = isTagMode
275
283
  ? [
276
284
  ...rows
277
285
  .filter((r): r is LaneHeaderRow => r.type === 'lane-header')
278
- .map((r) => r.laneName),
286
+ .map((r) => measureText(r.laneName, LABEL_FONT)),
279
287
  ...rows
280
288
  .filter((r): r is TaskRow => r.type === 'task')
281
- .map((r) => '● ' + r.task.task.label),
289
+ .map((r) => measureText('● ' + r.task.task.label, LABEL_FONT)),
282
290
  ]
283
291
  : [
284
- ...resolved.tasks.map((t) => '● ' + t.task.label),
292
+ ...resolved.tasks.map((t) =>
293
+ measureText('● ' + t.task.label, LABEL_FONT)
294
+ ),
285
295
  ...resolved.groups.map((g) => {
286
- const px = g.depth <= 2 ? g.depth * 14 : 2 * 14 + (g.depth - 2) * 8;
287
- return ' '.repeat(Math.ceil(px / 7)) + g.name;
296
+ const indentPx =
297
+ g.depth <= 2 ? g.depth * 14 : 2 * 14 + (g.depth - 2) * 8;
298
+ return indentPx + measureText(g.name, LABEL_FONT);
288
299
  }),
289
300
  ];
290
- const maxLabelLen = Math.max(...allLabels.map((l) => l.length), 10);
291
- const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelLen * 7 + 30);
301
+ // Floor on a 10-char label so very short schedules still get a usable gutter.
302
+ const maxLabelWidth = Math.max(
303
+ ...labelWidths,
304
+ measureText('0000000000', LABEL_FONT)
305
+ );
306
+ const leftMargin = Math.max(MIN_LEFT_MARGIN, maxLabelWidth + 30);
292
307
 
293
308
  const totalRows = rows.length;
294
309
 
@@ -356,7 +371,6 @@ export function renderGantt(
356
371
  const sMilestoneSize = ctx.structural(MILESTONE_SIZE);
357
372
  const sBottomMargin = ctx.aesthetic(BOTTOM_MARGIN);
358
373
  const sRightMargin = ctx.aesthetic(RIGHT_MARGIN);
359
- const sCharW = ctx.text(CHAR_W, 4);
360
374
  const sLabelPad = ctx.structural(LABEL_PAD);
361
375
  const sLabelGap = ctx.structural(LABEL_GAP);
362
376
  const sBandAccentW = ctx.structural(BAND_ACCENT_W);
@@ -923,7 +937,7 @@ export function renderGantt(
923
937
  palette.textOnFillLight,
924
938
  palette.textOnFillDark
925
939
  ),
926
- sCharW,
940
+ sFont10,
927
941
  sLabelPad,
928
942
  sLabelGap
929
943
  );
@@ -1007,7 +1021,7 @@ export function renderGantt(
1007
1021
  palette.textOnFillLight,
1008
1022
  palette.textOnFillDark
1009
1023
  ),
1010
- sCharW,
1024
+ sFont10,
1011
1025
  sLabelPad,
1012
1026
  sLabelGap
1013
1027
  );
@@ -1311,7 +1325,7 @@ export function renderGantt(
1311
1325
  palette.textOnFillLight,
1312
1326
  palette.textOnFillDark
1313
1327
  ),
1314
- sCharW,
1328
+ sFont10,
1315
1329
  sLabelPad,
1316
1330
  sLabelGap
1317
1331
  );
@@ -1625,7 +1639,7 @@ function drawHolidayBand(
1625
1639
  // Hover label in SVG-space (date header row) — hidden by default
1626
1640
  // Background rect to mask date labels underneath
1627
1641
  const labelX = chartLeftMargin + x1 + bandW / 2;
1628
- const textLen = label.length * 6 + 8;
1642
+ const textLen = measureText(label, 10) + 8;
1629
1643
  const labelBg = svg
1630
1644
  .append('rect')
1631
1645
  .attr('class', 'gantt-holiday-hover-bg')
@@ -1957,6 +1971,15 @@ function drawSwimlaneIcon(
1957
1971
  .attr('class', 'gantt-swimlane-icon')
1958
1972
  .attr('transform', `translate(${x}, ${y})`);
1959
1973
 
1974
+ // Transparent hit area so the whole icon (not just the 2px bars) is clickable
1975
+ iconG
1976
+ .append('rect')
1977
+ .attr('x', -5)
1978
+ .attr('y', -5)
1979
+ .attr('width', 22)
1980
+ .attr('height', 18)
1981
+ .attr('fill', 'transparent');
1982
+
1960
1983
  const color = isActive ? palette.primary : palette.textMuted;
1961
1984
  const opacity = isActive ? 1 : 0.35;
1962
1985
  const barWidths = [8, 12, 6];
@@ -2342,7 +2365,7 @@ function renderTagLegend(
2342
2365
 
2343
2366
  // ── Era & Marker Rendering ──────────────────────────────────
2344
2367
 
2345
- const ERA_COLORS = ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
2368
+ const ERA_COLORS = ['#3b6ea5', '#5b9357', '#c9a227', '#cc7a33', '#7d5ba6'];
2346
2369
 
2347
2370
  function renderErasAndMarkers(
2348
2371
  g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
@@ -2467,7 +2490,7 @@ function renderErasAndMarkers(
2467
2490
  for (let i = 0; i < resolved.markers.length; i++) {
2468
2491
  const marker = resolved.markers[i];
2469
2492
  if (!marker) continue; // In-bounds by loop guard; appeases TS.
2470
- const color = marker.color || palette.accent || '#d08770';
2493
+ const color = marker.color || palette.accent || '#3a9188';
2471
2494
  // In-bounds: markerXs.length === resolved.markers.length.
2472
2495
  const mx = markerXs[i]!;
2473
2496
  const markerDate = parseDateStringToDate(marker.date);
@@ -3499,21 +3522,6 @@ function diamondPoints(cx: number, cy: number, size: number): string {
3499
3522
 
3500
3523
  // ── Hover Date Indicators ───────────────────────────────────
3501
3524
 
3502
- const MONTH_ABBR = [
3503
- 'Jan',
3504
- 'Feb',
3505
- 'Mar',
3506
- 'Apr',
3507
- 'May',
3508
- 'Jun',
3509
- 'Jul',
3510
- 'Aug',
3511
- 'Sep',
3512
- 'Oct',
3513
- 'Nov',
3514
- 'Dec',
3515
- ];
3516
-
3517
3525
  function formatGanttDate(d: Date): string {
3518
3526
  const base = `${MONTH_ABBR[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
3519
3527
  if (d.getHours() === 0 && d.getMinutes() === 0) return base;
@@ -55,6 +55,13 @@ export function collectTasks(nodes: readonly GanttNode[]): GanttTask[] {
55
55
  /** Strip bracket syntax: `[Backend].API Design` → `Backend.API Design` */
56
56
  const BRACKET_GROUP_RE = /^\[(.+?)\]\.(.+)$/;
57
57
 
58
+ /** Disambiguation message when a name matches more than one task. */
59
+ function ambiguousTaskMessage(trimmed: string, suggestions: string[]): string {
60
+ return `Multiple tasks match "${trimmed}". Did you mean ${suggestions
61
+ .map((s) => `\`${s}\``)
62
+ .join(' or ')}?`;
63
+ }
64
+
58
65
  export function resolveTaskName(
59
66
  name: string,
60
67
  allTasks: GanttTask[]
@@ -86,7 +93,7 @@ export function resolveTaskName(
86
93
  );
87
94
  return {
88
95
  kind: 'ambiguous',
89
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
96
+ message: ambiguousTaskMessage(trimmed, suggestions),
90
97
  };
91
98
  }
92
99
 
@@ -113,7 +120,7 @@ export function resolveTaskName(
113
120
  );
114
121
  return {
115
122
  kind: 'ambiguous',
116
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
123
+ message: ambiguousTaskMessage(trimmed, suggestions),
117
124
  };
118
125
  }
119
126
 
@@ -0,0 +1,29 @@
1
+ // ============================================================
2
+ // Edge spline — basis curve clamped to its endpoints
3
+ // ============================================================
4
+
5
+ import * as d3Shape from 'd3-shape';
6
+
7
+ const lineGenerator = d3Shape
8
+ .line<{ x: number; y: number }>()
9
+ .x((d) => d.x)
10
+ .y((d) => d.y)
11
+ .curve(d3Shape.curveBasis);
12
+
13
+ /**
14
+ * Build a smooth edge path through dagre waypoints.
15
+ *
16
+ * `d3.curveBasis` already begins with `moveTo(P0)` and ends with
17
+ * `lineTo(Pn)`, so the path reaches both node borders and its final
18
+ * segment carries a real direction (correct `marker-end` orientation).
19
+ *
20
+ * NOTE: do NOT clamp by triplicating the endpoints — that appends
21
+ * zero-length trailing segments, and WebKit then computes a degenerate
22
+ * tangent for the arrowhead, rendering it at the wrong angle (resvg and
23
+ * Chromium tolerate it; WKWebView does not).
24
+ */
25
+ export function edgeSplinePath(
26
+ points: ReadonlyArray<{ readonly x: number; readonly y: number }>
27
+ ): string | null {
28
+ return lineGenerator(points as { x: number; y: number }[]);
29
+ }
@@ -17,7 +17,14 @@ import {
17
17
  } from '../utils/parsing';
18
18
  import { normalizeName, displayName } from '../utils/name-normalize';
19
19
  import type { Writable } from '../utils/brand';
20
- import type { ParsedGraph, GraphNode, GraphEdge, GraphShape } from './types';
20
+ import type {
21
+ ParsedGraph,
22
+ GraphNode,
23
+ GraphEdge,
24
+ GraphShape,
25
+ GraphNote,
26
+ } from './types';
27
+ import { tryCollectNote, resolveNotes } from './notes';
21
28
 
22
29
  // ============================================================
23
30
  // Helpers
@@ -260,6 +267,7 @@ export function parseFlowchart(
260
267
 
261
268
  const nodeMap = new Map<string, GraphNode>();
262
269
  const indentStack: { nodeId: string; indent: number }[] = [];
270
+ const notes: GraphNote[] = [];
263
271
  let contentStarted = false;
264
272
  let firstLineParsed = false;
265
273
 
@@ -499,6 +507,23 @@ export function parseFlowchart(
499
507
  }
500
508
  }
501
509
 
510
+ // Note annotation: `note <ref> [inline body]` + optional indented
511
+ // body. Handled before options so `note foo bar` is never mistaken
512
+ // for an option. Only `note -> X` (arrow immediately after `note`) is
513
+ // excluded so it can edge; arrows are allowed inside a note body.
514
+ const noteResult = tryCollectNote(
515
+ lines,
516
+ i,
517
+ indent,
518
+ palette,
519
+ result.diagnostics
520
+ );
521
+ if (noteResult) {
522
+ if (noteResult.note) notes.push(noteResult.note);
523
+ i = noteResult.lastIndex;
524
+ continue;
525
+ }
526
+
502
527
  // Options (space-separated, before content)
503
528
  if (!contentStarted) {
504
529
  // Bare boolean: direction-lr
@@ -559,6 +584,14 @@ export function parseFlowchart(
559
584
  result.error = formatDgmoError(diag);
560
585
  }
561
586
 
587
+ // Resolve note refs (forward refs OK — runs after all nodes parsed).
588
+ // Emits diagnostics for unknown/ambiguous/duplicate refs; the resolved
589
+ // binding is recomputed in layout from `result.notes`.
590
+ if (notes.length > 0) {
591
+ result.notes = notes;
592
+ resolveNotes(notes, result.nodes, result.diagnostics);
593
+ }
594
+
562
595
  // Warn about orphaned nodes (not referenced in any edge)
563
596
  if (result.nodes.length >= 2 && result.edges.length >= 1 && !result.error) {
564
597
  const connectedIds = new Set<string>();