@diagrammo/dgmo 0.25.5 → 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.
- package/README.md +3 -3
- package/dist/advanced.cjs +4255 -2756
- package/dist/advanced.d.cts +285 -59
- package/dist/advanced.d.ts +285 -59
- package/dist/advanced.js +4253 -2750
- package/dist/auto.cjs +4051 -2589
- package/dist/auto.js +124 -122
- package/dist/auto.mjs +4051 -2589
- package/dist/cli.cjs +172 -170
- package/dist/editor.cjs +4 -0
- package/dist/editor.js +4 -0
- package/dist/highlight.cjs +4 -0
- package/dist/highlight.js +4 -0
- package/dist/index.cjs +4076 -2591
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +4076 -2591
- package/dist/internal.cjs +4255 -2756
- package/dist/internal.d.cts +285 -59
- package/dist/internal.d.ts +285 -59
- package/dist/internal.js +4253 -2750
- package/dist/map-data/PROVENANCE.json +1 -1
- package/dist/map-data/airport-collisions.json +1 -0
- package/dist/map-data/airports.json +1 -0
- package/docs/language-reference.md +68 -18
- package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
- package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
- package/gallery/fixtures/map-region-values.dgmo +13 -0
- package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
- package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
- package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
- package/package.json +1 -1
- package/src/advanced.ts +3 -6
- package/src/auto/index.ts +1 -1
- package/src/boxes-and-lines/layout.ts +146 -26
- package/src/boxes-and-lines/parser.ts +43 -8
- package/src/boxes-and-lines/renderer.ts +223 -96
- package/src/boxes-and-lines/types.ts +9 -2
- package/src/c4/layout.ts +14 -32
- package/src/c4/parser.ts +9 -5
- package/src/c4/renderer.ts +34 -39
- package/src/class/layout.ts +118 -18
- package/src/class/parser.ts +35 -1
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion-types.ts +0 -1
- package/src/completion.ts +106 -51
- package/src/cycle/layout.ts +55 -72
- package/src/cycle/renderer.ts +11 -6
- package/src/d3.ts +78 -117
- package/src/diagnostics.ts +16 -0
- package/src/echarts.ts +46 -33
- package/src/editor/keywords.ts +4 -0
- package/src/er/layout.ts +114 -22
- package/src/er/parser.ts +28 -1
- package/src/er/renderer.ts +55 -2
- package/src/er/types.ts +3 -0
- package/src/gantt/renderer.ts +46 -38
- package/src/gantt/resolver.ts +9 -2
- package/src/graph/edge-spline.ts +29 -0
- package/src/graph/flowchart-parser.ts +35 -2
- package/src/graph/flowchart-renderer.ts +80 -52
- package/src/graph/layout.ts +206 -23
- package/src/graph/notes.ts +21 -0
- package/src/graph/state-parser.ts +26 -1
- package/src/graph/state-renderer.ts +80 -52
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- package/src/infra/parser.ts +1 -1
- package/src/infra/renderer.ts +16 -7
- package/src/journey-map/layout.ts +38 -49
- package/src/journey-map/renderer.ts +22 -45
- package/src/kanban/renderer.ts +15 -6
- package/src/label-layout.ts +3 -3
- package/src/map/completion.ts +77 -22
- package/src/map/context-labels.ts +57 -12
- package/src/map/data/PROVENANCE.json +1 -1
- package/src/map/data/airport-collisions.json +1 -0
- package/src/map/data/airports.json +1 -0
- package/src/map/data/types.ts +19 -0
- package/src/map/layout.ts +1196 -90
- package/src/map/legend-band.ts +2 -2
- package/src/map/load-data.ts +10 -1
- package/src/map/parser.ts +61 -32
- package/src/map/renderer.ts +284 -12
- package/src/map/resolved-types.ts +15 -1
- package/src/map/resolver.ts +132 -12
- package/src/map/types.ts +28 -8
- package/src/migrate/embedded.ts +9 -7
- package/src/mindmap/text-wrap.ts +13 -14
- package/src/org/layout.ts +19 -17
- package/src/org/renderer.ts +11 -4
- package/src/palettes/color-utils.ts +82 -21
- package/src/palettes/index.ts +0 -19
- package/src/palettes/registry.ts +1 -1
- package/src/palettes/types.ts +2 -2
- package/src/pert/layout.ts +48 -40
- package/src/pert/parser.ts +0 -14
- package/src/pert/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +42 -70
- package/src/render.ts +1 -1
- package/src/ring/renderer.ts +1 -2
- package/src/sequence/parser.ts +100 -22
- package/src/sequence/renderer.ts +75 -50
- package/src/sitemap/layout.ts +27 -19
- package/src/sitemap/renderer.ts +12 -5
- package/src/tech-radar/renderer.ts +11 -35
- package/src/utils/arrow-markers.ts +51 -0
- package/src/utils/fit-canvas.ts +64 -0
- package/src/utils/legend-constants.ts +8 -54
- package/src/utils/legend-d3.ts +10 -7
- package/src/utils/legend-layout.ts +7 -4
- package/src/utils/legend-types.ts +10 -4
- package/src/utils/note-box/constants.ts +25 -0
- package/src/utils/note-box/index.ts +11 -0
- package/src/utils/note-box/metrics.ts +90 -0
- package/src/utils/note-box/svg.ts +331 -0
- package/src/utils/notes/bounds.ts +30 -0
- package/src/utils/notes/build.ts +131 -0
- package/src/utils/notes/index.ts +18 -0
- package/src/utils/notes/model.ts +19 -0
- package/src/utils/notes/parse.ts +131 -0
- package/src/utils/notes/place.ts +177 -0
- package/src/utils/notes/resolve.ts +88 -0
- package/src/utils/number-format.ts +36 -0
- package/src/utils/parsing.ts +41 -0
- package/src/utils/reserved-key-registry.ts +4 -0
- package/src/utils/text-measure.ts +122 -0
- package/src/wireframe/layout.ts +4 -2
- package/src/wireframe/renderer.ts +8 -6
- package/src/palettes/dracula.ts +0 -68
- package/src/palettes/gruvbox.ts +0 -85
- package/src/palettes/monokai.ts +0 -68
- package/src/palettes/one-dark.ts +0 -70
- package/src/palettes/rose-pine.ts +0 -84
- package/src/palettes/solarized.ts +0 -77
package/src/c4/layout.ts
CHANGED
|
@@ -21,8 +21,15 @@ type MutableC4LayoutEdge = Writable<C4LayoutEdge> & {
|
|
|
21
21
|
import {
|
|
22
22
|
LEGEND_PILL_FONT_SIZE,
|
|
23
23
|
LEGEND_ENTRY_FONT_SIZE,
|
|
24
|
+
LEGEND_HEIGHT,
|
|
25
|
+
LEGEND_PILL_PAD,
|
|
26
|
+
LEGEND_DOT_R,
|
|
27
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
28
|
+
LEGEND_ENTRY_TRAIL,
|
|
29
|
+
LEGEND_CAPSULE_PAD,
|
|
24
30
|
measureLegendText,
|
|
25
31
|
} from '../utils/legend-constants';
|
|
32
|
+
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
26
33
|
|
|
27
34
|
/** dagre node label shape after layout(). */
|
|
28
35
|
interface DagreNodeLabel {
|
|
@@ -115,29 +122,23 @@ export interface C4LayoutResult {
|
|
|
115
122
|
// Constants
|
|
116
123
|
// ============================================================
|
|
117
124
|
|
|
118
|
-
const CHAR_WIDTH = 8;
|
|
119
125
|
const MIN_NODE_WIDTH = 160;
|
|
120
126
|
const MAX_NODE_WIDTH = 260;
|
|
121
127
|
const TYPE_LABEL_HEIGHT = 18;
|
|
122
128
|
const DIVIDER_GAP = 6;
|
|
123
129
|
const NAME_HEIGHT = 20;
|
|
130
|
+
const NAME_FONT_SIZE = 14;
|
|
124
131
|
const DESC_LINE_HEIGHT = 16;
|
|
125
|
-
const
|
|
132
|
+
const DESC_FONT_SIZE = 11;
|
|
126
133
|
const CARD_V_PAD = 14;
|
|
127
134
|
const CARD_H_PAD = 20;
|
|
128
135
|
const META_LINE_HEIGHT = 16;
|
|
129
|
-
const
|
|
136
|
+
const META_FONT_SIZE = 11;
|
|
130
137
|
const MARGIN = 40;
|
|
131
138
|
const BOUNDARY_PAD = 40;
|
|
132
139
|
const GROUP_BOUNDARY_PAD = 24;
|
|
133
140
|
|
|
134
141
|
// Legend constants (match org)
|
|
135
|
-
const LEGEND_HEIGHT = 28;
|
|
136
|
-
const LEGEND_PILL_PAD = 16;
|
|
137
|
-
const LEGEND_DOT_R = 4;
|
|
138
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
139
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
140
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
141
142
|
|
|
142
143
|
// ============================================================
|
|
143
144
|
// Post-Layout Crossing Reduction
|
|
@@ -640,24 +641,6 @@ function resolveNodeColor(
|
|
|
640
641
|
// Node Sizing
|
|
641
642
|
// ============================================================
|
|
642
643
|
|
|
643
|
-
function wrapText(text: string, maxWidth: number, charWidth: number): string[] {
|
|
644
|
-
const words = text.split(/\s+/);
|
|
645
|
-
const lines: string[] = [];
|
|
646
|
-
let current = '';
|
|
647
|
-
|
|
648
|
-
for (const word of words) {
|
|
649
|
-
const test = current ? `${current} ${word}` : word;
|
|
650
|
-
if (test.length * charWidth > maxWidth && current) {
|
|
651
|
-
lines.push(current);
|
|
652
|
-
current = word;
|
|
653
|
-
} else {
|
|
654
|
-
current = test;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
if (current) lines.push(current);
|
|
658
|
-
return lines;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
644
|
/** Keys to exclude from the below-divider metadata display. */
|
|
662
645
|
const META_EXCLUDE_KEYS = new Set([
|
|
663
646
|
'description',
|
|
@@ -689,7 +672,7 @@ export function computeC4NodeDimensions(
|
|
|
689
672
|
options?: { showTechnology?: boolean }
|
|
690
673
|
): { width: number; height: number } {
|
|
691
674
|
// Width: based on name length, clamped
|
|
692
|
-
const nameWidth = el.name
|
|
675
|
+
const nameWidth = measureText(el.name, NAME_FONT_SIZE) + CARD_H_PAD * 2;
|
|
693
676
|
let width = Math.max(MIN_NODE_WIDTH, Math.min(MAX_NODE_WIDTH, nameWidth));
|
|
694
677
|
|
|
695
678
|
if (options?.showTechnology) {
|
|
@@ -700,7 +683,7 @@ export function computeC4NodeDimensions(
|
|
|
700
683
|
const desc = el.description?.join('\n');
|
|
701
684
|
if (desc) {
|
|
702
685
|
const contentWidth = width - CARD_H_PAD * 2;
|
|
703
|
-
const lines =
|
|
686
|
+
const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
|
|
704
687
|
height += lines.length * DESC_LINE_HEIGHT;
|
|
705
688
|
}
|
|
706
689
|
|
|
@@ -713,8 +696,7 @@ export function computeC4NodeDimensions(
|
|
|
713
696
|
const maxMetaWidth = Math.max(
|
|
714
697
|
...metaEntries.map(
|
|
715
698
|
(e) =>
|
|
716
|
-
(e.key
|
|
717
|
-
CARD_H_PAD * 2
|
|
699
|
+
measureText(`${e.key}: ${e.value}`, META_FONT_SIZE) + CARD_H_PAD * 2
|
|
718
700
|
)
|
|
719
701
|
);
|
|
720
702
|
if (maxMetaWidth > width) width = Math.min(MAX_NODE_WIDTH, maxMetaWidth);
|
|
@@ -730,7 +712,7 @@ export function computeC4NodeDimensions(
|
|
|
730
712
|
const desc = el.description?.join('\n');
|
|
731
713
|
if (desc) {
|
|
732
714
|
const contentWidth = width - CARD_H_PAD * 2;
|
|
733
|
-
const lines =
|
|
715
|
+
const lines = wrapTextToWidth(desc, DESC_FONT_SIZE, contentWidth);
|
|
734
716
|
height += lines.length * DESC_LINE_HEIGHT;
|
|
735
717
|
}
|
|
736
718
|
|
package/src/c4/parser.ts
CHANGED
|
@@ -63,11 +63,15 @@ const C4_IS_A_RE =
|
|
|
63
63
|
/** Matches relationship arrows: `->`, `~>`, `<->`, `<~>` */
|
|
64
64
|
const RELATIONSHIP_RE = /^(<?-?>|<?~?>)\s*(.+)$/;
|
|
65
65
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Labeled arrow relationships: -label->, ~label~>, <-label->, <~label~>.
|
|
68
|
+
* Label captured lazily so a line splits at the FIRST arrow — matches every
|
|
69
|
+
* other chart parser (which use lazy `.+?`); greedy `.+` split at the last.
|
|
70
|
+
*/
|
|
71
|
+
const C4_LABELED_SYNC_RE = /^-(.+?)->\s*(.+)$/;
|
|
72
|
+
const C4_LABELED_ASYNC_RE = /^~(.+?)~>\s*(.+)$/;
|
|
73
|
+
const C4_LABELED_BIDI_SYNC_RE = /^<-(.+?)->\s*(.+)$/;
|
|
74
|
+
const C4_LABELED_BIDI_ASYNC_RE = /^<~(.+?)~>\s*(.+)$/;
|
|
71
75
|
|
|
72
76
|
/** Matches section headers: `containers`, `components`, `deployment` (bare keyword) */
|
|
73
77
|
const SECTION_HEADER_RE = /^(containers|components|deployment)\s*$/i;
|
package/src/c4/renderer.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { PaletteColors } from '../palettes';
|
|
|
9
9
|
import { contrastText, mix, shapeFill } from '../palettes/color-utils';
|
|
10
10
|
import { renderInlineText } from '../utils/inline-markdown';
|
|
11
11
|
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
12
|
+
import { measureText, wrapTextToWidth } from '../utils/text-measure';
|
|
12
13
|
import type { ParsedC4 } from './types';
|
|
13
14
|
import type { C4LayoutResult, C4LayoutEdge } from './layout';
|
|
14
15
|
import { parseC4 } from './parser';
|
|
@@ -35,7 +36,6 @@ const TYPE_FONT_SIZE = 10;
|
|
|
35
36
|
const NAME_FONT_SIZE = 14;
|
|
36
37
|
const DESC_FONT_SIZE = 11;
|
|
37
38
|
const DESC_LINE_HEIGHT = 16;
|
|
38
|
-
const DESC_CHAR_WIDTH = 6.5;
|
|
39
39
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
40
40
|
const TECH_FONT_SIZE = 10;
|
|
41
41
|
const EDGE_STROKE_WIDTH = 1.5;
|
|
@@ -47,7 +47,6 @@ const TYPE_LABEL_HEIGHT = 18;
|
|
|
47
47
|
const DIVIDER_GAP = 6;
|
|
48
48
|
const NAME_HEIGHT = 20;
|
|
49
49
|
const META_FONT_SIZE = 11;
|
|
50
|
-
const META_CHAR_WIDTH = 6.5;
|
|
51
50
|
const META_LINE_HEIGHT = 16;
|
|
52
51
|
const BOUNDARY_LABEL_FONT_SIZE = 12;
|
|
53
52
|
const BOUNDARY_STROKE_WIDTH = 1.5;
|
|
@@ -111,28 +110,6 @@ function nodeStroke(
|
|
|
111
110
|
return typeColor(type, palette, nodeColor);
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
// ============================================================
|
|
115
|
-
// Text wrapping helper
|
|
116
|
-
// ============================================================
|
|
117
|
-
|
|
118
|
-
function wrapText(text: string, maxWidth: number, charWidth: number): string[] {
|
|
119
|
-
const words = text.split(/\s+/);
|
|
120
|
-
const lines: string[] = [];
|
|
121
|
-
let current = '';
|
|
122
|
-
|
|
123
|
-
for (const word of words) {
|
|
124
|
-
const test = current ? `${current} ${word}` : word;
|
|
125
|
-
if (test.length * charWidth > maxWidth && current) {
|
|
126
|
-
lines.push(current);
|
|
127
|
-
current = word;
|
|
128
|
-
} else {
|
|
129
|
-
current = test;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (current) lines.push(current);
|
|
133
|
-
return lines;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
113
|
// ============================================================
|
|
137
114
|
// Edge path generator
|
|
138
115
|
// ============================================================
|
|
@@ -394,8 +371,11 @@ export function renderC4Context(
|
|
|
394
371
|
const techText = edge.technology ? `[${edge.technology}]` : '';
|
|
395
372
|
|
|
396
373
|
// Background rect
|
|
397
|
-
const
|
|
398
|
-
|
|
374
|
+
const textW = Math.max(
|
|
375
|
+
measureText(labelText, EDGE_LABEL_FONT_SIZE),
|
|
376
|
+
measureText(techText, TECH_FONT_SIZE)
|
|
377
|
+
);
|
|
378
|
+
const bgW = textW + 12;
|
|
399
379
|
const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
|
|
400
380
|
|
|
401
381
|
edgeG
|
|
@@ -522,8 +502,7 @@ export function renderC4Context(
|
|
|
522
502
|
// Name (bold) — above divider
|
|
523
503
|
if (node.type === 'person') {
|
|
524
504
|
// Person icon to the left of name
|
|
525
|
-
const
|
|
526
|
-
const textWidth = node.name.length * nameCharWidth;
|
|
505
|
+
const textWidth = measureText(node.name, NAME_FONT_SIZE);
|
|
527
506
|
const gap = 6;
|
|
528
507
|
const totalWidth = PERSON_ICON_W + gap + textWidth;
|
|
529
508
|
const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
|
|
@@ -577,7 +556,11 @@ export function renderC4Context(
|
|
|
577
556
|
// Description (wrapping, inline markdown) — must contrast against fill
|
|
578
557
|
if (node.description) {
|
|
579
558
|
const contentWidth = w - CARD_H_PAD * 2;
|
|
580
|
-
const lines =
|
|
559
|
+
const lines = wrapTextToWidth(
|
|
560
|
+
node.description,
|
|
561
|
+
DESC_FONT_SIZE,
|
|
562
|
+
contentWidth
|
|
563
|
+
);
|
|
581
564
|
for (const line of lines) {
|
|
582
565
|
const textEl = nodeG
|
|
583
566
|
.append('text')
|
|
@@ -840,8 +823,11 @@ function renderEdges(
|
|
|
840
823
|
if (edge.label || edge.technology) {
|
|
841
824
|
const labelText = edge.label ?? '';
|
|
842
825
|
const techText = edge.technology ? `[${edge.technology}]` : '';
|
|
843
|
-
const
|
|
844
|
-
|
|
826
|
+
const textW = Math.max(
|
|
827
|
+
measureText(labelText, EDGE_LABEL_FONT_SIZE),
|
|
828
|
+
measureText(techText, TECH_FONT_SIZE)
|
|
829
|
+
);
|
|
830
|
+
const bgW = textW + 12;
|
|
845
831
|
const bgH = (labelText ? 16 : 0) + (techText ? 14 : 0) + 4;
|
|
846
832
|
|
|
847
833
|
pendingLabels.push({
|
|
@@ -1508,12 +1494,12 @@ export function renderC4Containers(
|
|
|
1508
1494
|
if (layout.boundary) {
|
|
1509
1495
|
const b = layout.boundary;
|
|
1510
1496
|
const labelText = `${b.label} \u2014 ${b.typeLabel}`;
|
|
1511
|
-
const w = labelText
|
|
1497
|
+
const w = measureText(labelText, BOUNDARY_LABEL_FONT_SIZE) + 12;
|
|
1512
1498
|
const h = BOUNDARY_LABEL_FONT_SIZE + 4;
|
|
1513
1499
|
boundaryLabelObstacles.push({ x: b.x + 12, y: b.y + 16 - h + 4, w, h });
|
|
1514
1500
|
}
|
|
1515
1501
|
for (const gb of layout.groupBoundaries) {
|
|
1516
|
-
const w = gb.label
|
|
1502
|
+
const w = measureText(gb.label, BOUNDARY_LABEL_FONT_SIZE) + 12;
|
|
1517
1503
|
const h = BOUNDARY_LABEL_FONT_SIZE + 4;
|
|
1518
1504
|
boundaryLabelObstacles.push({ x: gb.x + 10, y: gb.y + 14 - h + 4, w, h });
|
|
1519
1505
|
}
|
|
@@ -1621,8 +1607,7 @@ export function renderC4Containers(
|
|
|
1621
1607
|
|
|
1622
1608
|
// Name (bold)
|
|
1623
1609
|
if (node.type === 'person') {
|
|
1624
|
-
const
|
|
1625
|
-
const textWidth = node.name.length * nameCharWidth;
|
|
1610
|
+
const textWidth = measureText(node.name, NAME_FONT_SIZE);
|
|
1626
1611
|
const gap = 6;
|
|
1627
1612
|
const totalWidth = PERSON_ICON_W + gap + textWidth;
|
|
1628
1613
|
const iconCx = -totalWidth / 2 + PERSON_ICON_W / 2;
|
|
@@ -1666,7 +1651,11 @@ export function renderC4Containers(
|
|
|
1666
1651
|
// Description (above divider, inline markdown) — contrast against fill
|
|
1667
1652
|
if (node.description) {
|
|
1668
1653
|
const contentWidth = w - CARD_H_PAD * 2;
|
|
1669
|
-
const lines =
|
|
1654
|
+
const lines = wrapTextToWidth(
|
|
1655
|
+
node.description,
|
|
1656
|
+
DESC_FONT_SIZE,
|
|
1657
|
+
contentWidth
|
|
1658
|
+
);
|
|
1670
1659
|
for (const line of lines) {
|
|
1671
1660
|
const textEl = nodeG
|
|
1672
1661
|
.append('text')
|
|
@@ -1702,8 +1691,10 @@ export function renderC4Containers(
|
|
|
1702
1691
|
|
|
1703
1692
|
yPos += DIVIDER_GAP;
|
|
1704
1693
|
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1694
|
+
const maxKeyWidth = Math.max(
|
|
1695
|
+
...metaEntries.map((e) => measureText(`${e.key}: `, META_FONT_SIZE))
|
|
1696
|
+
);
|
|
1697
|
+
const valueX = -w / 2 + CARD_H_PAD + maxKeyWidth;
|
|
1707
1698
|
|
|
1708
1699
|
for (const entry of metaEntries) {
|
|
1709
1700
|
// Key — contrast against fill (textMuted is illegible on solid fills)
|
|
@@ -1750,7 +1741,11 @@ export function renderC4Containers(
|
|
|
1750
1741
|
// Description (inline markdown) — contrast against fill
|
|
1751
1742
|
if (node.description) {
|
|
1752
1743
|
const contentWidth = w - CARD_H_PAD * 2;
|
|
1753
|
-
const lines =
|
|
1744
|
+
const lines = wrapTextToWidth(
|
|
1745
|
+
node.description,
|
|
1746
|
+
DESC_FONT_SIZE,
|
|
1747
|
+
contentWidth
|
|
1748
|
+
);
|
|
1754
1749
|
for (const line of lines) {
|
|
1755
1750
|
const textEl = nodeG
|
|
1756
1751
|
.append('text')
|
package/src/class/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 { ParsedClassDiagram, ClassNode, RelationshipType } from './types';
|
|
3
10
|
|
|
4
11
|
// ============================================================
|
|
@@ -13,6 +20,16 @@ export interface ClassLayoutNode extends ClassNode {
|
|
|
13
20
|
readonly headerHeight: number;
|
|
14
21
|
readonly fieldsHeight: number;
|
|
15
22
|
readonly methodsHeight: number;
|
|
23
|
+
/** A note floated beside this class (never moves the box). */
|
|
24
|
+
readonly note?: PlacedNote;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ClassLayoutOptions {
|
|
28
|
+
/**
|
|
29
|
+
* 1-based source line numbers of notes the user has collapsed. A
|
|
30
|
+
* collapsed note renders as a corner badge and reserves no space.
|
|
31
|
+
*/
|
|
32
|
+
collapsedNotes?: ReadonlySet<number>;
|
|
16
33
|
}
|
|
17
34
|
|
|
18
35
|
export interface ClassLayoutEdge {
|
|
@@ -36,9 +53,12 @@ export interface ClassLayoutResult {
|
|
|
36
53
|
// ============================================================
|
|
37
54
|
|
|
38
55
|
const MIN_WIDTH = 140;
|
|
39
|
-
const CHAR_WIDTH = 7.5;
|
|
40
56
|
const PADDING_X = 24;
|
|
41
57
|
const HEADER_BASE = 36;
|
|
58
|
+
// Font sizes mirror the renderer: class name at CLASS_FONT_SIZE, members and
|
|
59
|
+
// the modifier badge at MEMBER_FONT_SIZE. Keep these in sync with renderer.ts.
|
|
60
|
+
const CLASS_FONT_SIZE = 13;
|
|
61
|
+
const MEMBER_FONT_SIZE = 11;
|
|
42
62
|
const MODIFIER_BADGE = 16; // extra height for <<interface>> etc.
|
|
43
63
|
const MEMBER_LINE_HEIGHT = 18;
|
|
44
64
|
const COMPARTMENT_PADDING_Y = 8;
|
|
@@ -59,10 +79,15 @@ function computeNodeDimensions(node: ClassNode): {
|
|
|
59
79
|
const methods = node.members.filter((m) => m.isMethod);
|
|
60
80
|
const isEnum = node.modifier === 'enum';
|
|
61
81
|
|
|
62
|
-
// Width: max of class name,
|
|
63
|
-
|
|
82
|
+
// Width: max rendered pixel width of class name, modifier badge, and members.
|
|
83
|
+
// Measure each at the font size the renderer draws it with.
|
|
84
|
+
let maxTextWidth = measureText(node.name, CLASS_FONT_SIZE);
|
|
64
85
|
if (node.modifier) {
|
|
65
|
-
|
|
86
|
+
// Renderer draws the badge as «modifier» at MEMBER_FONT_SIZE.
|
|
87
|
+
maxTextWidth = Math.max(
|
|
88
|
+
maxTextWidth,
|
|
89
|
+
measureText(`«${node.modifier}»`, MEMBER_FONT_SIZE)
|
|
90
|
+
);
|
|
66
91
|
}
|
|
67
92
|
for (const m of node.members) {
|
|
68
93
|
let memberText = m.name;
|
|
@@ -74,9 +99,12 @@ function computeNodeDimensions(node: ClassNode): {
|
|
|
74
99
|
}
|
|
75
100
|
// Add visibility prefix width
|
|
76
101
|
memberText = `+ ${memberText}`;
|
|
77
|
-
|
|
102
|
+
maxTextWidth = Math.max(
|
|
103
|
+
maxTextWidth,
|
|
104
|
+
measureText(memberText, MEMBER_FONT_SIZE)
|
|
105
|
+
);
|
|
78
106
|
}
|
|
79
|
-
const width = Math.max(MIN_WIDTH,
|
|
107
|
+
const width = Math.max(MIN_WIDTH, maxTextWidth + PADDING_X);
|
|
80
108
|
|
|
81
109
|
// Header height
|
|
82
110
|
const headerHeight = HEADER_BASE + (node.modifier ? MODIFIER_BADGE : 0);
|
|
@@ -130,7 +158,8 @@ function computeNodeDimensions(node: ClassNode): {
|
|
|
130
158
|
// ============================================================
|
|
131
159
|
|
|
132
160
|
export function layoutClassDiagram(
|
|
133
|
-
parsed: ParsedClassDiagram
|
|
161
|
+
parsed: ParsedClassDiagram,
|
|
162
|
+
options?: ClassLayoutOptions
|
|
134
163
|
): ClassLayoutResult {
|
|
135
164
|
if (parsed.classes.length === 0) {
|
|
136
165
|
return { nodes: [], edges: [], width: 0, height: 0 };
|
|
@@ -175,10 +204,40 @@ export function layoutClassDiagram(
|
|
|
175
204
|
// Run layout
|
|
176
205
|
dagre.layout(g);
|
|
177
206
|
|
|
207
|
+
// ── Notes ──────────────────────────────────────────────────
|
|
208
|
+
// Resolve note anchors (no diagnostics here — the parser already emitted
|
|
209
|
+
// them). `no-notes` drops the reserved footprint so layout matches an
|
|
210
|
+
// un-annotated diagram. The note floats beside its class WITHOUT moving
|
|
211
|
+
// it (class TB layout → default side right).
|
|
212
|
+
const notesSuppressed = parsed.options?.['no-notes'] === 'on';
|
|
213
|
+
const noteByNode =
|
|
214
|
+
notesSuppressed || !parsed.notes
|
|
215
|
+
? new Map()
|
|
216
|
+
: resolveNotes(
|
|
217
|
+
parsed.notes,
|
|
218
|
+
parsed.classes.map((c) => ({ id: c.id, label: c.name }))
|
|
219
|
+
);
|
|
220
|
+
const placedNotes = buildPlacedNotes(
|
|
221
|
+
parsed.classes.map((node) => {
|
|
222
|
+
const pos = g.node(node.id);
|
|
223
|
+
return {
|
|
224
|
+
id: node.id,
|
|
225
|
+
x: pos.x,
|
|
226
|
+
y: pos.y,
|
|
227
|
+
width: pos.width,
|
|
228
|
+
height: pos.height,
|
|
229
|
+
};
|
|
230
|
+
}),
|
|
231
|
+
noteByNode,
|
|
232
|
+
'TB',
|
|
233
|
+
options?.collapsedNotes
|
|
234
|
+
);
|
|
235
|
+
|
|
178
236
|
// Extract positioned nodes
|
|
179
237
|
const layoutNodes: ClassLayoutNode[] = parsed.classes.map((node) => {
|
|
180
238
|
const pos = g.node(node.id);
|
|
181
239
|
const dims = dimMap.get(node.id)!;
|
|
240
|
+
const note = placedNotes.get(node.id);
|
|
182
241
|
return {
|
|
183
242
|
...node,
|
|
184
243
|
x: pos.x,
|
|
@@ -188,6 +247,7 @@ export function layoutClassDiagram(
|
|
|
188
247
|
headerHeight: dims.headerHeight,
|
|
189
248
|
fieldsHeight: dims.fieldsHeight,
|
|
190
249
|
methodsHeight: dims.methodsHeight,
|
|
250
|
+
...(note && { note }),
|
|
191
251
|
};
|
|
192
252
|
});
|
|
193
253
|
|
|
@@ -205,21 +265,61 @@ export function layoutClassDiagram(
|
|
|
205
265
|
return layoutEdge;
|
|
206
266
|
});
|
|
207
267
|
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
268
|
+
// Content bbox over nodes and their (floated) notes. A note placed
|
|
269
|
+
// above/left can land at negative coords; when it does, shift everything
|
|
270
|
+
// so nothing clips. Un-annotated diagrams keep min ≥ 0 → no shift.
|
|
271
|
+
let bbMinX = Infinity;
|
|
272
|
+
let bbMinY = Infinity;
|
|
273
|
+
let bbMaxX = -Infinity;
|
|
274
|
+
let bbMaxY = -Infinity;
|
|
275
|
+
const extend = (l: number, t: number, r: number, b: number): void => {
|
|
276
|
+
if (l < bbMinX) bbMinX = l;
|
|
277
|
+
if (t < bbMinY) bbMinY = t;
|
|
278
|
+
if (r > bbMaxX) bbMaxX = r;
|
|
279
|
+
if (b > bbMaxY) bbMaxY = b;
|
|
280
|
+
};
|
|
211
281
|
for (const node of layoutNodes) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
282
|
+
extend(
|
|
283
|
+
node.x - node.width / 2,
|
|
284
|
+
node.y - node.height / 2,
|
|
285
|
+
node.x + node.width / 2,
|
|
286
|
+
node.y + node.height / 2
|
|
287
|
+
);
|
|
288
|
+
if (node.note && !node.note.collapsed) {
|
|
289
|
+
extend(
|
|
290
|
+
node.x + node.note.x,
|
|
291
|
+
node.y + node.note.y,
|
|
292
|
+
node.x + node.note.x + node.note.width,
|
|
293
|
+
node.y + node.note.y + node.note.height
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!Number.isFinite(bbMinX)) {
|
|
298
|
+
bbMinX = 0;
|
|
299
|
+
bbMinY = 0;
|
|
300
|
+
bbMaxX = 0;
|
|
301
|
+
bbMaxY = 0;
|
|
216
302
|
}
|
|
217
|
-
|
|
218
|
-
|
|
303
|
+
|
|
304
|
+
const { shiftX, shiftY } = noteCanvasShift(bbMinX, bbMinY);
|
|
305
|
+
const shifted = shiftX !== 0 || shiftY !== 0;
|
|
306
|
+
const finalNodes = shifted
|
|
307
|
+
? layoutNodes.map((n) => ({ ...n, x: n.x + shiftX, y: n.y + shiftY }))
|
|
308
|
+
: layoutNodes;
|
|
309
|
+
const finalEdges = shifted
|
|
310
|
+
? layoutEdges.map((e) => ({
|
|
311
|
+
...e,
|
|
312
|
+
points: e.points.map((pt) => ({ x: pt.x + shiftX, y: pt.y + shiftY })),
|
|
313
|
+
}))
|
|
314
|
+
: layoutEdges;
|
|
315
|
+
|
|
316
|
+
// Totals: max extent (incl. notes) + the prior 40 px margin.
|
|
317
|
+
const totalWidth = bbMaxX + shiftX + 40;
|
|
318
|
+
const totalHeight = bbMaxY + shiftY + 40;
|
|
219
319
|
|
|
220
320
|
return {
|
|
221
|
-
nodes:
|
|
222
|
-
edges:
|
|
321
|
+
nodes: finalNodes,
|
|
322
|
+
edges: finalEdges,
|
|
223
323
|
width: totalWidth,
|
|
224
324
|
height: totalHeight,
|
|
225
325
|
};
|
package/src/class/parser.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
tryParseSharedOption,
|
|
15
15
|
} from '../utils/parsing';
|
|
16
16
|
import { normalizeName, displayName } from '../utils/name-normalize';
|
|
17
|
+
import { tryCollectNote, resolveNotes, type DiagramNote } from '../utils/notes';
|
|
17
18
|
import type { Writable } from '../utils/brand';
|
|
18
19
|
import type {
|
|
19
20
|
ParsedClassDiagram,
|
|
@@ -192,6 +193,7 @@ export function parseClassDiagram(
|
|
|
192
193
|
};
|
|
193
194
|
|
|
194
195
|
const classMap = new Map<string, Writable<ClassNode>>();
|
|
196
|
+
const notes: DiagramNote[] = [];
|
|
195
197
|
|
|
196
198
|
// Per-parse alias literal → canonical class id (TD-18). Per C8.
|
|
197
199
|
const nameAliasMap = new Map<string, string>();
|
|
@@ -266,6 +268,27 @@ export function parseClassDiagram(
|
|
|
266
268
|
// Skip comments
|
|
267
269
|
if (trimmed.startsWith('//')) continue;
|
|
268
270
|
|
|
271
|
+
// Note annotation (top-level): `note <ClassName> [inline body]` + an
|
|
272
|
+
// optional indented body. Checked before options so a note is never
|
|
273
|
+
// swallowed as an option; gated to indent 0 so an indented member
|
|
274
|
+
// starting with "note" stays a member. `note -> X` is excluded.
|
|
275
|
+
if (indent === 0) {
|
|
276
|
+
const noteResult = tryCollectNote(
|
|
277
|
+
lines,
|
|
278
|
+
i,
|
|
279
|
+
indent,
|
|
280
|
+
palette,
|
|
281
|
+
result.diagnostics
|
|
282
|
+
);
|
|
283
|
+
if (noteResult) {
|
|
284
|
+
currentClass = null;
|
|
285
|
+
contentStarted = true;
|
|
286
|
+
if (noteResult.note) notes.push(noteResult.note);
|
|
287
|
+
i = noteResult.lastIndex;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
269
292
|
// First line: bare chart type + optional title (new syntax)
|
|
270
293
|
if (!contentStarted && indent === 0 && i === 0) {
|
|
271
294
|
const firstLine = parseFirstLine(trimmed);
|
|
@@ -439,6 +462,18 @@ export function parseClassDiagram(
|
|
|
439
462
|
);
|
|
440
463
|
}
|
|
441
464
|
|
|
465
|
+
// Resolve note refs against class names (forward refs OK — runs after all
|
|
466
|
+
// classes parsed). The id→note binding is recomputed in layout; this pass
|
|
467
|
+
// only surfaces unknown/ambiguous/duplicate diagnostics.
|
|
468
|
+
if (notes.length > 0) {
|
|
469
|
+
result.notes = notes;
|
|
470
|
+
resolveNotes(
|
|
471
|
+
notes,
|
|
472
|
+
result.classes.map((c) => ({ id: c.id, label: c.name })),
|
|
473
|
+
result.diagnostics
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
442
477
|
// Validation
|
|
443
478
|
if (result.classes.length === 0 && !result.error) {
|
|
444
479
|
const diag = makeDgmoError(
|
|
@@ -592,6 +627,5 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
592
627
|
return {
|
|
593
628
|
kind: 'class',
|
|
594
629
|
entities,
|
|
595
|
-
keywords: ['extends', 'implements', 'abstract', 'interface', 'enum'],
|
|
596
630
|
};
|
|
597
631
|
}
|
package/src/class/renderer.ts
CHANGED
|
@@ -28,6 +28,16 @@ import type { ClassLayoutResult } from './layout';
|
|
|
28
28
|
import { parseClassDiagram } from './parser';
|
|
29
29
|
import { layoutClassDiagram } from './layout';
|
|
30
30
|
import { ScaleContext } from '../utils/scaling';
|
|
31
|
+
import { measureText } from '../utils/text-measure';
|
|
32
|
+
import {
|
|
33
|
+
renderNoteBox,
|
|
34
|
+
renderNoteConnector,
|
|
35
|
+
renderNoteBadge,
|
|
36
|
+
noteConnectorPoints,
|
|
37
|
+
NOTE_BADGE_RADIUS,
|
|
38
|
+
} from '../utils/note-box';
|
|
39
|
+
|
|
40
|
+
type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
|
|
31
41
|
|
|
32
42
|
// ============================================================
|
|
33
43
|
// Constants
|
|
@@ -468,8 +478,7 @@ export function renderClassDiagram(
|
|
|
468
478
|
const midIdx = Math.floor(edge.points.length / 2);
|
|
469
479
|
// Edges are always non-empty (drawn from a valid graph layout).
|
|
470
480
|
const midPt = edge.points[midIdx]!;
|
|
471
|
-
const
|
|
472
|
-
const bgW = labelLen * 7 + 8;
|
|
481
|
+
const bgW = measureText(edge.label, sEdgeLabelFontSize) + 8;
|
|
473
482
|
const bgH = 16;
|
|
474
483
|
|
|
475
484
|
edgeG
|
|
@@ -693,6 +702,53 @@ export function renderClassDiagram(
|
|
|
693
702
|
}
|
|
694
703
|
}
|
|
695
704
|
}
|
|
705
|
+
|
|
706
|
+
// ── Note (floated beside the class, or a collapsed corner badge) ──
|
|
707
|
+
// The class keeps its layout position; the note floats in adjacent
|
|
708
|
+
// space chosen by the collision-aware placer. Coords are node-center
|
|
709
|
+
// -local (the node `<g>` is translated to the center).
|
|
710
|
+
if (node.note) {
|
|
711
|
+
if (node.note.collapsed) {
|
|
712
|
+
renderNoteBadge(
|
|
713
|
+
nodeG as GSelection,
|
|
714
|
+
{
|
|
715
|
+
x: w / 2 - NOTE_BADGE_RADIUS - 3,
|
|
716
|
+
y: -h / 2 + NOTE_BADGE_RADIUS + 3,
|
|
717
|
+
},
|
|
718
|
+
palette,
|
|
719
|
+
{
|
|
720
|
+
isDark,
|
|
721
|
+
...(node.note.color && { color: node.note.color }),
|
|
722
|
+
lineNumber: node.note.lineNumber,
|
|
723
|
+
endLineNumber: node.note.endLineNumber,
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
} else {
|
|
727
|
+
const [cx1, cy1, cx2, cy2] = noteConnectorPoints(
|
|
728
|
+
{ width: w, height: h },
|
|
729
|
+
node.note
|
|
730
|
+
);
|
|
731
|
+
renderNoteConnector(nodeG as GSelection, cx1, cy1, cx2, cy2, palette);
|
|
732
|
+
renderNoteBox(
|
|
733
|
+
nodeG as GSelection,
|
|
734
|
+
{
|
|
735
|
+
x: node.note.x,
|
|
736
|
+
y: node.note.y,
|
|
737
|
+
width: node.note.width,
|
|
738
|
+
height: node.note.height,
|
|
739
|
+
},
|
|
740
|
+
node.note.lines,
|
|
741
|
+
palette,
|
|
742
|
+
{
|
|
743
|
+
isDark,
|
|
744
|
+
...(node.note.color && { color: node.note.color }),
|
|
745
|
+
lineNumber: node.note.lineNumber,
|
|
746
|
+
endLineNumber: node.note.endLineNumber,
|
|
747
|
+
interactive: true,
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
696
752
|
}
|
|
697
753
|
}
|
|
698
754
|
|
package/src/class/types.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface ClassRelationship {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
import type { DgmoError } from '../diagnostics';
|
|
45
|
+
import type { DiagramNote } from '../utils/notes';
|
|
45
46
|
|
|
46
47
|
export interface ParsedClassDiagram {
|
|
47
48
|
readonly type: 'class';
|
|
@@ -50,6 +51,8 @@ export interface ParsedClassDiagram {
|
|
|
50
51
|
readonly classes: readonly ClassNode[];
|
|
51
52
|
readonly relationships: readonly ClassRelationship[];
|
|
52
53
|
readonly options: Readonly<Record<string, string>>;
|
|
54
|
+
/** Generic node notes (`note <ClassName> …`); resolved in layout. */
|
|
55
|
+
readonly notes?: readonly DiagramNote[];
|
|
53
56
|
readonly diagnostics: readonly DgmoError[];
|
|
54
57
|
readonly error: string | null;
|
|
55
58
|
}
|