@diagrammo/dgmo 0.26.0 → 0.27.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 (136) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4182 -2704
  3. package/dist/advanced.d.cts +266 -58
  4. package/dist/advanced.d.ts +266 -58
  5. package/dist/advanced.js +4182 -2698
  6. package/dist/auto.cjs +4042 -2581
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4042 -2581
  9. package/dist/cli.cjs +172 -170
  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 +4067 -2583
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4067 -2583
  18. package/dist/internal.cjs +4182 -2704
  19. package/dist/internal.d.cts +266 -58
  20. package/dist/internal.d.ts +266 -58
  21. package/dist/internal.js +4182 -2698
  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 +1 -1
  33. package/src/advanced.ts +1 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -0
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion.ts +26 -12
  48. package/src/cycle/layout.ts +55 -72
  49. package/src/cycle/renderer.ts +11 -6
  50. package/src/d3.ts +78 -117
  51. package/src/diagnostics.ts +16 -0
  52. package/src/echarts.ts +46 -33
  53. package/src/editor/keywords.ts +4 -0
  54. package/src/er/layout.ts +114 -22
  55. package/src/er/parser.ts +28 -0
  56. package/src/er/renderer.ts +55 -2
  57. package/src/er/types.ts +3 -0
  58. package/src/gantt/renderer.ts +46 -38
  59. package/src/gantt/resolver.ts +9 -2
  60. package/src/graph/edge-spline.ts +29 -0
  61. package/src/graph/flowchart-parser.ts +34 -1
  62. package/src/graph/flowchart-renderer.ts +78 -64
  63. package/src/graph/layout.ts +206 -23
  64. package/src/graph/notes.ts +21 -0
  65. package/src/graph/state-parser.ts +26 -1
  66. package/src/graph/state-renderer.ts +78 -64
  67. package/src/graph/types.ts +13 -0
  68. package/src/index.ts +1 -1
  69. package/src/infra/layout.ts +46 -26
  70. package/src/infra/renderer.ts +16 -7
  71. package/src/journey-map/layout.ts +38 -49
  72. package/src/journey-map/renderer.ts +22 -45
  73. package/src/kanban/renderer.ts +15 -6
  74. package/src/label-layout.ts +3 -3
  75. package/src/map/completion.ts +77 -22
  76. package/src/map/context-labels.ts +57 -12
  77. package/src/map/data/PROVENANCE.json +1 -1
  78. package/src/map/data/airport-collisions.json +1 -0
  79. package/src/map/data/airports.json +1 -0
  80. package/src/map/data/types.ts +19 -0
  81. package/src/map/layout.ts +1196 -90
  82. package/src/map/legend-band.ts +2 -2
  83. package/src/map/load-data.ts +10 -1
  84. package/src/map/parser.ts +61 -32
  85. package/src/map/renderer.ts +284 -12
  86. package/src/map/resolved-types.ts +15 -1
  87. package/src/map/resolver.ts +132 -12
  88. package/src/map/types.ts +28 -8
  89. package/src/migrate/embedded.ts +9 -7
  90. package/src/mindmap/text-wrap.ts +13 -14
  91. package/src/org/layout.ts +19 -17
  92. package/src/org/renderer.ts +11 -4
  93. package/src/palettes/color-utils.ts +82 -21
  94. package/src/palettes/index.ts +0 -19
  95. package/src/palettes/registry.ts +1 -1
  96. package/src/palettes/types.ts +2 -2
  97. package/src/pert/layout.ts +48 -40
  98. package/src/pert/renderer.ts +30 -43
  99. package/src/pyramid/renderer.ts +4 -5
  100. package/src/raci/renderer.ts +34 -68
  101. package/src/render.ts +1 -1
  102. package/src/ring/renderer.ts +1 -2
  103. package/src/sequence/parser.ts +100 -22
  104. package/src/sequence/renderer.ts +75 -50
  105. package/src/sitemap/layout.ts +27 -19
  106. package/src/sitemap/renderer.ts +12 -5
  107. package/src/tech-radar/renderer.ts +11 -35
  108. package/src/utils/arrow-markers.ts +51 -0
  109. package/src/utils/fit-canvas.ts +64 -0
  110. package/src/utils/legend-constants.ts +8 -54
  111. package/src/utils/legend-d3.ts +10 -7
  112. package/src/utils/legend-layout.ts +7 -4
  113. package/src/utils/legend-types.ts +10 -4
  114. package/src/utils/note-box/constants.ts +25 -0
  115. package/src/utils/note-box/index.ts +11 -0
  116. package/src/utils/note-box/metrics.ts +90 -0
  117. package/src/utils/note-box/svg.ts +331 -0
  118. package/src/utils/notes/bounds.ts +30 -0
  119. package/src/utils/notes/build.ts +131 -0
  120. package/src/utils/notes/index.ts +18 -0
  121. package/src/utils/notes/model.ts +19 -0
  122. package/src/utils/notes/parse.ts +131 -0
  123. package/src/utils/notes/place.ts +177 -0
  124. package/src/utils/notes/resolve.ts +88 -0
  125. package/src/utils/number-format.ts +36 -0
  126. package/src/utils/parsing.ts +41 -0
  127. package/src/utils/reserved-key-registry.ts +4 -0
  128. package/src/utils/text-measure.ts +122 -0
  129. package/src/wireframe/layout.ts +4 -2
  130. package/src/wireframe/renderer.ts +8 -6
  131. package/src/palettes/dracula.ts +0 -68
  132. package/src/palettes/gruvbox.ts +0 -85
  133. package/src/palettes/monokai.ts +0 -68
  134. package/src/palettes/one-dark.ts +0 -70
  135. package/src/palettes/rose-pine.ts +0 -84
  136. package/src/palettes/solarized.ts +0 -77
@@ -3,7 +3,8 @@
3
3
  // ============================================================
4
4
 
5
5
  import * as d3Selection from 'd3-selection';
6
- import * as d3Shape from 'd3-shape';
6
+ import { appendArrowheadMarkers } from '../utils/arrow-markers';
7
+ import { fitDiagramToCanvas } from '../utils/fit-canvas';
7
8
  import { FONT_FAMILY } from '../fonts';
8
9
  import type { PaletteColors } from '../palettes';
9
10
  import { contrastText, shapeFill } from '../palettes/color-utils';
@@ -11,12 +12,21 @@ import type { ParsedGraph, GraphShape } from './types';
11
12
  import type { LayoutResult, LayoutNode } from './layout';
12
13
  import { parseFlowchart } from './flowchart-parser';
13
14
  import { layoutGraph } from './layout';
15
+ import { edgeSplinePath } from './edge-spline';
14
16
  import {
15
17
  TITLE_FONT_SIZE,
16
18
  TITLE_FONT_WEIGHT,
17
19
  TITLE_Y,
18
20
  } from '../utils/title-constants';
19
21
  import { ScaleContext } from '../utils/scaling';
22
+ import { measureText } from '../utils/text-measure';
23
+ import {
24
+ renderNoteBox,
25
+ renderNoteConnector,
26
+ renderNoteBadge,
27
+ noteConnectorPoints,
28
+ NOTE_BADGE_RADIUS,
29
+ } from '../utils/note-box';
20
30
 
21
31
  // ============================================================
22
32
  // Constants
@@ -427,16 +437,6 @@ function renderNodeShape(
427
437
  }
428
438
  }
429
439
 
430
- // ============================================================
431
- // Edge path generator
432
- // ============================================================
433
-
434
- const lineGenerator = d3Shape
435
- .line<{ x: number; y: number }>()
436
- .x((d) => d.x)
437
- .y((d) => d.y)
438
- .curve(d3Shape.curveBasis);
439
-
440
440
  // ============================================================
441
441
  // Main renderer
442
442
  // ============================================================
@@ -476,28 +476,16 @@ export function renderFlowchart(
476
476
 
477
477
  const diagramW = layout.width;
478
478
  const diagramH = layout.height;
479
- const scaleX = (width - sDiagramPadding * 2) / diagramW;
480
-
481
- // Export renders a fixed canvas (e.g. 1200×800). Fitting a small graph into
482
- // it and top-anchoring leaves a tall band of dead space below. In export
483
- // mode, scale to width (capped by MAX_SCALE) and size the canvas to the
484
- // scaled content height. The interactive preview keeps the fit-to-pane
485
- // behaviour so a small graph still fills its pane.
486
- let scale: number;
487
- let canvasHeight: number;
488
- if (exportDims) {
489
- scale = Math.min(MAX_SCALE, scaleX);
490
- canvasHeight = titleHeight + diagramH * scale + sDiagramPadding * 2;
491
- } else {
492
- const availH = height - titleHeight;
493
- const scaleY = (availH - sDiagramPadding * 2) / diagramH;
494
- scale = Math.min(MAX_SCALE, scaleX, scaleY);
495
- canvasHeight = height;
496
- }
497
-
498
- const scaledW = diagramW * scale;
499
- const offsetX = (width - scaledW) / 2;
500
- const offsetY = titleHeight + sDiagramPadding;
479
+ const { scale, offsetX, offsetY, canvasHeight } = fitDiagramToCanvas({
480
+ width,
481
+ height,
482
+ diagramW,
483
+ diagramH,
484
+ padding: sDiagramPadding,
485
+ titleHeight,
486
+ maxScale: MAX_SCALE,
487
+ exportMode: !!exportDims,
488
+ });
501
489
 
502
490
  const svg = d3Selection
503
491
  .select(container)
@@ -514,35 +502,14 @@ export function renderFlowchart(
514
502
 
515
503
  const defs = svg.append('defs');
516
504
 
517
- defs
518
- .append('marker')
519
- .attr('id', 'fc-arrow')
520
- .attr('viewBox', `0 0 ${sArrowheadW} ${sArrowheadH}`)
521
- .attr('refX', sArrowheadW)
522
- .attr('refY', sArrowheadH / 2)
523
- .attr('markerWidth', sArrowheadW)
524
- .attr('markerHeight', sArrowheadH)
525
- .attr('orient', 'auto')
526
- .append('polygon')
527
- .attr('points', `0,0 ${sArrowheadW},${sArrowheadH / 2} 0,${sArrowheadH}`)
528
- .attr('fill', palette.textMuted);
529
-
530
505
  const edgeColors = new Set<string>();
531
- for (const color of edgeColors) {
532
- const id = `fc-arrow-${color.replace('#', '')}`;
533
- defs
534
- .append('marker')
535
- .attr('id', id)
536
- .attr('viewBox', `0 0 ${sArrowheadW} ${sArrowheadH}`)
537
- .attr('refX', sArrowheadW)
538
- .attr('refY', sArrowheadH / 2)
539
- .attr('markerWidth', sArrowheadW)
540
- .attr('markerHeight', sArrowheadH)
541
- .attr('orient', 'auto')
542
- .append('polygon')
543
- .attr('points', `0,0 ${sArrowheadW},${sArrowheadH / 2} 0,${sArrowheadH}`)
544
- .attr('fill', color);
545
- }
506
+ appendArrowheadMarkers(defs, {
507
+ idPrefix: 'fc',
508
+ width: sArrowheadW,
509
+ height: sArrowheadH,
510
+ baseFill: palette.textMuted,
511
+ colors: edgeColors,
512
+ });
546
513
 
547
514
  if (showTitle) {
548
515
  const titleEl = svg
@@ -579,7 +546,6 @@ export function renderFlowchart(
579
546
  .append('g')
580
547
  .attr('transform', `translate(${offsetX}, ${offsetY}) scale(${scale})`);
581
548
 
582
- const LABEL_CHAR_W = 7;
583
549
  const LABEL_PAD = 8;
584
550
  const LABEL_H = 16;
585
551
  const PERP_OFFSET = 10;
@@ -598,7 +564,7 @@ export function renderFlowchart(
598
564
  if (!edge.label || edge.points.length < 2) continue;
599
565
  const midIdx = Math.floor(edge.points.length / 2);
600
566
  const midPt = edge.points[midIdx]!;
601
- const bgW = edge.label.length * LABEL_CHAR_W + LABEL_PAD;
567
+ const bgW = measureText(edge.label, sEdgeLabelFontSize) + LABEL_PAD;
602
568
 
603
569
  const prev = edge.points[Math.max(0, midIdx - 1)]!;
604
570
  const next = edge.points[Math.min(edge.points.length - 1, midIdx + 1)]!;
@@ -642,7 +608,7 @@ export function renderFlowchart(
642
608
  const edgeColor = palette.textMuted;
643
609
  const markerId = 'fc-arrow';
644
610
 
645
- const pathD = lineGenerator(edge.points);
611
+ const pathD = edgeSplinePath(edge.points);
646
612
  if (pathD) {
647
613
  edgeG
648
614
  .append('path')
@@ -690,6 +656,7 @@ export function renderFlowchart(
690
656
 
691
657
  const colorOff = graph.options?.['color'] === 'off';
692
658
  const solid = graph.options?.['solid-fill'] === 'on';
659
+ const noNotes = graph.options?.['no-notes'] === 'on';
693
660
  for (const node of layout.nodes) {
694
661
  const nodeG = contentG
695
662
  .append('g')
@@ -704,6 +671,10 @@ export function renderFlowchart(
704
671
  });
705
672
  }
706
673
 
674
+ // The shape is drawn at its dagre position — a note never moves it,
675
+ // so its edges stay connected. The note floats beside it.
676
+ const hasNote = !!node.note && !noNotes;
677
+
707
678
  renderNodeShape(
708
679
  nodeG as GSelection,
709
680
  node,
@@ -744,6 +715,49 @@ export function renderFlowchart(
744
715
  )
745
716
  .attr('font-size', sNodeFontSize)
746
717
  .text(node.label);
718
+
719
+ if (hasNote && node.note) {
720
+ if (node.note.collapsed) {
721
+ // Collapsed → comment-bubble badge in the node's top-right corner.
722
+ renderNoteBadge(
723
+ nodeG as GSelection,
724
+ {
725
+ x: node.width / 2 - NOTE_BADGE_RADIUS - 3,
726
+ y: -node.height / 2 + NOTE_BADGE_RADIUS + 3,
727
+ },
728
+ palette,
729
+ {
730
+ isDark,
731
+ ...(node.note.color && { color: node.note.color }),
732
+ lineNumber: node.note.lineNumber,
733
+ endLineNumber: node.note.endLineNumber,
734
+ }
735
+ );
736
+ } else {
737
+ // Solid tether from the shape edge to the floated note, on whichever
738
+ // side the collision-aware placement chose.
739
+ const [cx1, cy1, cx2, cy2] = noteConnectorPoints(node, node.note);
740
+ renderNoteConnector(nodeG as GSelection, cx1, cy1, cx2, cy2, palette);
741
+ renderNoteBox(
742
+ nodeG as GSelection,
743
+ {
744
+ x: node.note.x,
745
+ y: node.note.y,
746
+ width: node.note.width,
747
+ height: node.note.height,
748
+ },
749
+ node.note.lines,
750
+ palette,
751
+ {
752
+ isDark,
753
+ ...(node.note.color && { color: node.note.color }),
754
+ lineNumber: node.note.lineNumber,
755
+ endLineNumber: node.note.endLineNumber,
756
+ interactive: true,
757
+ }
758
+ );
759
+ }
760
+ }
747
761
  }
748
762
  }
749
763
 
@@ -6,6 +6,33 @@ import type {
6
6
  GraphGroup,
7
7
  GraphShape,
8
8
  } from './types';
9
+ import { resolveNotes } from './notes';
10
+ import { noteBoxSize } from '../utils/note-box';
11
+ import { placeNotes, noteCanvasShift } from '../utils/notes';
12
+ import type { WrappedDescLine } from '../utils/wrapped-desc';
13
+
14
+ export type NoteSide = 'above' | 'below' | 'left' | 'right';
15
+
16
+ /** A note box positioned relative to its anchor node's center. */
17
+ export interface NoteLayout {
18
+ readonly x: number;
19
+ readonly y: number;
20
+ readonly width: number;
21
+ readonly height: number;
22
+ /** Which side of the node the box sits on (drives the connector). */
23
+ readonly side: NoteSide;
24
+ /** Resolved hex accent (border + faded fill); default yellow if absent. */
25
+ readonly color?: string;
26
+ readonly lines: readonly WrappedDescLine[];
27
+ readonly lineNumber: number;
28
+ readonly endLineNumber: number;
29
+ /**
30
+ * When true the note is collapsed: the renderer draws a small badge at
31
+ * the node corner instead of the floated box, and `x/y/width/height/side/
32
+ * lines` are unused. Collapsed notes reserve no layout space.
33
+ */
34
+ readonly collapsed?: boolean;
35
+ }
9
36
 
10
37
  export interface LayoutNode {
11
38
  readonly id: string;
@@ -18,6 +45,13 @@ export interface LayoutNode {
18
45
  readonly y: number;
19
46
  readonly width: number;
20
47
  readonly height: number;
48
+ /**
49
+ * A note floated beside this node. The shape keeps its natural dagre
50
+ * position and dimensions (so its edges stay connected) — the note is
51
+ * placed in adjacent space and the canvas bounds are expanded to fit
52
+ * it. Absent on un-annotated nodes.
53
+ */
54
+ readonly note?: NoteLayout;
21
55
  }
22
56
 
23
57
  export interface LayoutEdge {
@@ -45,6 +79,11 @@ export interface LayoutOptions {
45
79
  collapsedChildCounts?: Map<string, number>;
46
80
  /** Original groups before collapse (includes collapsed ones) */
47
81
  originalGroups?: readonly GraphGroup[];
82
+ /**
83
+ * 1-based source line numbers of notes the user has collapsed. A
84
+ * collapsed note renders as a corner badge and reserves no space.
85
+ */
86
+ collapsedNotes?: ReadonlySet<number>;
48
87
  }
49
88
 
50
89
  export interface LayoutResult {
@@ -75,6 +114,7 @@ export function layoutGraph(
75
114
  ): LayoutResult {
76
115
  const collapsedChildCounts = options?.collapsedChildCounts;
77
116
  const originalGroups = options?.originalGroups;
117
+ const collapsedNotes = options?.collapsedNotes;
78
118
 
79
119
  // Collapsed groups become synthetic nodes in the graph
80
120
  const collapsedGroupNodes: GraphNode[] = [];
@@ -124,12 +164,48 @@ export function layoutGraph(
124
164
  }
125
165
  }
126
166
 
127
- // Add nodes with computed dimensions
167
+ // Resolve note anchors (no diagnostics here — the parser already
168
+ // emitted them). `no-notes` drops the reserved footprint entirely so
169
+ // layout matches an un-annotated diagram (ADR-4 / AC8).
170
+ const notesSuppressed = graph.options?.['no-notes'] === 'on';
171
+ const noteByNode =
172
+ notesSuppressed || !graph.notes
173
+ ? new Map()
174
+ : resolveNotes(graph.notes, graph.nodes);
175
+
176
+ // Pre-computed note geometry, keyed by node id, threaded into LayoutNode.
177
+ // The note does NOT change the node's dagre dims — the shape keeps its
178
+ // position and its edge connections; the note floats beside it and the
179
+ // canvas bounds are expanded to fit it.
180
+ interface NoteGeom {
181
+ noteW: number;
182
+ noteH: number;
183
+ color?: string;
184
+ lines: WrappedDescLine[];
185
+ lineNumber: number;
186
+ endLineNumber: number;
187
+ }
188
+ const noteGeoms = new Map<string, NoteGeom>();
189
+
190
+ // Add nodes with their natural dimensions (notes never inflate them).
128
191
  for (const node of allNodes) {
129
192
  const width = computeNodeWidth(node.label, node.shape);
130
193
  const height = computeNodeHeight(node.shape);
131
194
  g.setNode(node.id, { label: node.label, width, height });
132
195
 
196
+ const note = noteByNode.get(node.id);
197
+ if (note) {
198
+ const size = noteBoxSize(note.body);
199
+ noteGeoms.set(node.id, {
200
+ noteW: size.width,
201
+ noteH: size.height,
202
+ ...(note.color && { color: note.color }),
203
+ lines: size.lines,
204
+ lineNumber: note.lineNumber,
205
+ endLineNumber: note.endLineNumber,
206
+ });
207
+ }
208
+
133
209
  // Set parent for grouped nodes (only for non-collapsed groups)
134
210
  if (node.group && graph.groups?.some((gr) => gr.id === node.group)) {
135
211
  g.setParent(node.id, node.group);
@@ -154,8 +230,44 @@ export function layoutGraph(
154
230
  ? new Set(collapsedChildCounts.keys())
155
231
  : new Set<string>();
156
232
 
157
- const layoutNodes: LayoutNode[] = allNodes.map((node): LayoutNode => {
233
+ // Positioned nodes (no notes yet) feeds collision-aware placement.
234
+ const basePositioned = allNodes.map((node) => {
158
235
  const pos = g.node(node.id);
236
+ return { node, x: pos.x, y: pos.y, width: pos.width, height: pos.height };
237
+ });
238
+
239
+ // ── Collision-aware note placement (shared `utils/notes`) ───
240
+ // The note floats beside its node WITHOUT moving it: try the default
241
+ // side, flip to the opposite, then push outward past blockers — each
242
+ // placed note becoming an obstacle for later notes. Collapsed notes
243
+ // reserve no space (drawn as a corner badge).
244
+ const noteRequests = basePositioned
245
+ .filter((p) => noteGeoms.has(p.node.id))
246
+ .map((p) => {
247
+ const ng = noteGeoms.get(p.node.id)!;
248
+ return {
249
+ key: p.node.id,
250
+ node: { x: p.x, y: p.y, width: p.width, height: p.height },
251
+ noteW: ng.noteW,
252
+ noteH: ng.noteH,
253
+ collapsed: collapsedNotes?.has(ng.lineNumber) ?? false,
254
+ };
255
+ });
256
+ const placements = placeNotes(
257
+ basePositioned.map((p) => ({
258
+ x: p.x,
259
+ y: p.y,
260
+ width: p.width,
261
+ height: p.height,
262
+ })),
263
+ noteRequests,
264
+ graph.direction === 'LR' ? 'LR' : 'TB'
265
+ );
266
+
267
+ const layoutNodes: LayoutNode[] = basePositioned.map((p): LayoutNode => {
268
+ const node = p.node;
269
+ const ng = noteGeoms.get(node.id);
270
+ const placed = placements.get(node.id);
159
271
  return {
160
272
  id: node.id,
161
273
  label: node.label,
@@ -163,10 +275,38 @@ export function layoutGraph(
163
275
  ...(node.color !== undefined && { color: node.color }),
164
276
  ...(node.group !== undefined && { group: node.group }),
165
277
  lineNumber: node.lineNumber,
166
- x: pos.x,
167
- y: pos.y,
168
- width: pos.width,
169
- height: pos.height,
278
+ x: p.x,
279
+ y: p.y,
280
+ width: p.width,
281
+ height: p.height,
282
+ ...(ng &&
283
+ placed && {
284
+ // Local coords relative to the node center (translate origin).
285
+ note: placed.collapsed
286
+ ? {
287
+ x: 0,
288
+ y: 0,
289
+ width: 0,
290
+ height: 0,
291
+ side: 'right' as NoteSide,
292
+ ...(ng.color && { color: ng.color }),
293
+ lines: [],
294
+ lineNumber: ng.lineNumber,
295
+ endLineNumber: ng.endLineNumber,
296
+ collapsed: true,
297
+ }
298
+ : {
299
+ x: placed.x,
300
+ y: placed.y,
301
+ width: ng.noteW,
302
+ height: ng.noteH,
303
+ side: placed.side,
304
+ ...(ng.color && { color: ng.color }),
305
+ lines: ng.lines,
306
+ lineNumber: ng.lineNumber,
307
+ endLineNumber: ng.endLineNumber,
308
+ },
309
+ }),
170
310
  };
171
311
  });
172
312
 
@@ -260,29 +400,72 @@ export function layoutGraph(
260
400
  }
261
401
  }
262
402
 
263
- // Compute total diagram dimensions
264
- let totalWidth = 0;
265
- let totalHeight = 0;
403
+ // Content bounding box over nodes, their (floated) notes, and groups.
404
+ // Notes placed above/left of a node can land at negative coordinates;
405
+ // when they do, everything is shifted so nothing clips on export. Edges
406
+ // are intentionally excluded (matching the prior bounds behavior) so
407
+ // un-annotated layouts are byte-for-byte unchanged.
408
+ let bbMinX = Infinity;
409
+ let bbMinY = Infinity;
410
+ let bbMaxX = -Infinity;
411
+ let bbMaxY = -Infinity;
412
+ const extend = (l: number, t: number, r: number, b: number): void => {
413
+ if (l < bbMinX) bbMinX = l;
414
+ if (t < bbMinY) bbMinY = t;
415
+ if (r > bbMaxX) bbMaxX = r;
416
+ if (b > bbMaxY) bbMaxY = b;
417
+ };
266
418
  for (const node of layoutNodes) {
267
- const right = node.x + node.width / 2;
268
- const bottom = node.y + node.height / 2;
269
- if (right > totalWidth) totalWidth = right;
270
- if (bottom > totalHeight) totalHeight = bottom;
419
+ extend(
420
+ node.x - node.width / 2,
421
+ node.y - node.height / 2,
422
+ node.x + node.width / 2,
423
+ node.y + node.height / 2
424
+ );
425
+ if (node.note) {
426
+ extend(
427
+ node.x + node.note.x,
428
+ node.y + node.note.y,
429
+ node.x + node.note.x + node.note.width,
430
+ node.y + node.note.y + node.note.height
431
+ );
432
+ }
271
433
  }
272
434
  for (const group of layoutGroups) {
273
- const right = group.x + group.width;
274
- const bottom = group.y + group.height;
275
- if (right > totalWidth) totalWidth = right;
276
- if (bottom > totalHeight) totalHeight = bottom;
435
+ extend(group.x, group.y, group.x + group.width, group.y + group.height);
436
+ }
437
+ if (!Number.isFinite(bbMinX)) {
438
+ bbMinX = 0;
439
+ bbMinY = 0;
440
+ bbMaxX = 0;
441
+ bbMaxY = 0;
277
442
  }
278
- // Add margin
279
- totalWidth += 40;
280
- totalHeight += 40;
443
+
444
+ // Shift only when content runs off the top/left (note placed above/left).
445
+ const { shiftX, shiftY } = noteCanvasShift(bbMinX, bbMinY);
446
+
447
+ const shifted = shiftX !== 0 || shiftY !== 0;
448
+ const finalNodes = shifted
449
+ ? layoutNodes.map((n) => ({ ...n, x: n.x + shiftX, y: n.y + shiftY }))
450
+ : layoutNodes;
451
+ const finalEdges = shifted
452
+ ? layoutEdges.map((e) => ({
453
+ ...e,
454
+ points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
455
+ }))
456
+ : layoutEdges;
457
+ const finalGroups = shifted
458
+ ? layoutGroups.map((gr) => ({ ...gr, x: gr.x + shiftX, y: gr.y + shiftY }))
459
+ : layoutGroups;
460
+
461
+ // Add margin (matches prior behavior: max-edge + 40).
462
+ const totalWidth = bbMaxX + shiftX + 40;
463
+ const totalHeight = bbMaxY + shiftY + 40;
281
464
 
282
465
  return {
283
- nodes: layoutNodes,
284
- edges: layoutEdges,
285
- groups: layoutGroups,
466
+ nodes: finalNodes,
467
+ edges: finalEdges,
468
+ groups: finalGroups,
286
469
  width: totalWidth,
287
470
  height: totalHeight,
288
471
  };
@@ -0,0 +1,21 @@
1
+ // ============================================================
2
+ // Graph notes — thin re-export over the shared note pipeline
3
+ // ============================================================
4
+ //
5
+ // The note model, grammar, and resolver are now chart-neutral and live in
6
+ // `utils/notes/`. This module stays as the graph family's import site so
7
+ // the flowchart/state parsers (and their tests) keep one stable path. New
8
+ // charts should import from `utils/notes` directly.
9
+
10
+ export {
11
+ parseNoteHeader,
12
+ collectNoteBody,
13
+ tryCollectNote,
14
+ resolveNotes,
15
+ } from '../utils/notes';
16
+ export type {
17
+ DiagramNote,
18
+ CollectedNoteBody,
19
+ TryCollectNoteResult,
20
+ NoteTarget,
21
+ } from '../utils/notes';
@@ -18,7 +18,8 @@ import {
18
18
  } from '../utils/parsing';
19
19
  import { normalizeName, displayName } from '../utils/name-normalize';
20
20
  import type { Writable } from '../utils/brand';
21
- import type { ParsedGraph, GraphNode, GraphGroup } from './types';
21
+ import type { ParsedGraph, GraphNode, GraphGroup, GraphNote } from './types';
22
+ import { tryCollectNote, resolveNotes } from './notes';
22
23
 
23
24
  // ============================================================
24
25
  // Constants
@@ -203,6 +204,7 @@ export function parseState(
203
204
 
204
205
  const nodeMap = new Map<string, Writable<GraphNode>>();
205
206
  const indentStack: { nodeId: string; indent: number }[] = [];
207
+ const notes: GraphNote[] = [];
206
208
  let currentGroup: Writable<GraphGroup> | null = null;
207
209
  let groupIndent = -1;
208
210
  const groups: Writable<GraphGroup>[] = [];
@@ -309,6 +311,23 @@ export function parseState(
309
311
  }
310
312
  }
311
313
 
314
+ // Note annotation: `note <ref> [inline body]` + optional indented
315
+ // body. Only `note -> X` (arrow immediately after `note`) is excluded
316
+ // so a transition FROM a state named "note" still parses; arrows are
317
+ // allowed inside a note body.
318
+ const noteResult = tryCollectNote(
319
+ lines,
320
+ i,
321
+ indent,
322
+ palette,
323
+ result.diagnostics
324
+ );
325
+ if (noteResult) {
326
+ if (noteResult.note) notes.push(noteResult.note);
327
+ i = noteResult.lastIndex;
328
+ continue;
329
+ }
330
+
312
331
  // Group brackets: [Name] or [Name](color)
313
332
  const groupMatch = trimmed.match(GROUP_BRACKET_RE);
314
333
  // Regex capture group 1 is mandatory in GROUP_BRACKET_RE.
@@ -492,6 +511,12 @@ export function parseState(
492
511
 
493
512
  if (groups.length > 0) result.groups = groups;
494
513
 
514
+ // Resolve note refs against the state node map (forward refs OK).
515
+ if (notes.length > 0) {
516
+ result.notes = notes;
517
+ resolveNotes(notes, result.nodes, result.diagnostics);
518
+ }
519
+
495
520
  // Validation: no nodes found
496
521
  if (result.nodes.length === 0 && !result.error) {
497
522
  const diag = makeDgmoError(