@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.
- package/README.md +3 -3
- package/dist/advanced.cjs +4182 -2704
- package/dist/advanced.d.cts +266 -58
- package/dist/advanced.d.ts +266 -58
- package/dist/advanced.js +4182 -2698
- package/dist/auto.cjs +4042 -2581
- package/dist/auto.js +124 -122
- package/dist/auto.mjs +4042 -2581
- 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 +4067 -2583
- package/dist/index.d.cts +33 -8
- package/dist/index.d.ts +33 -8
- package/dist/index.js +4067 -2583
- package/dist/internal.cjs +4182 -2704
- package/dist/internal.d.cts +266 -58
- package/dist/internal.d.ts +266 -58
- package/dist/internal.js +4182 -2698
- 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 +1 -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 -0
- package/src/class/renderer.ts +58 -2
- package/src/class/types.ts +3 -0
- package/src/cli.ts +4 -4
- package/src/completion.ts +26 -12
- 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 -0
- 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 +34 -1
- package/src/graph/flowchart-renderer.ts +78 -64
- 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 +78 -64
- package/src/graph/types.ts +13 -0
- package/src/index.ts +1 -1
- package/src/infra/layout.ts +46 -26
- 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/renderer.ts +30 -43
- package/src/pyramid/renderer.ts +4 -5
- package/src/raci/renderer.ts +34 -68
- 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/sequence/renderer.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
wrapDescriptionLines,
|
|
28
28
|
type WrappedDescLine,
|
|
29
29
|
} from '../utils/wrapped-desc';
|
|
30
|
+
import { measureText, truncateText } from '../utils/text-measure';
|
|
30
31
|
import { resolveSequenceTags } from './tag-resolution';
|
|
31
32
|
import type { ResolvedTagMap } from './tag-resolution';
|
|
32
33
|
import { resolveActiveTagGroup } from '../utils/tag-groups';
|
|
@@ -67,48 +68,57 @@ const NOTE_PAD_V = 6;
|
|
|
67
68
|
const NOTE_FONT_SIZE = 10;
|
|
68
69
|
const NOTE_LINE_H = 14;
|
|
69
70
|
const NOTE_GAP = 15;
|
|
70
|
-
const NOTE_CHAR_W = 6;
|
|
71
71
|
const ACTIVATION_WIDTH = 10;
|
|
72
72
|
const SELF_CALL_HEIGHT = 25;
|
|
73
73
|
const SELF_CALL_WIDTH = 30;
|
|
74
74
|
// Actors render their label below the stick figure (at boxH + 14). Their
|
|
75
75
|
// lifeline starts this far below the box so the dashes clear the label text.
|
|
76
76
|
const ACTOR_LABEL_CLEARANCE = 22;
|
|
77
|
-
function wrapTextLines(
|
|
77
|
+
function wrapTextLines(
|
|
78
|
+
text: string,
|
|
79
|
+
maxWidth: number,
|
|
80
|
+
fontSize: number
|
|
81
|
+
): WrappedDescLine[] {
|
|
78
82
|
// Convert leading "- " to the canonical bullet prefix so the shared wrap
|
|
79
83
|
// helper can split bullet lines into bullet-first / bullet-cont kinds and
|
|
80
84
|
// give us hanging-indent alignment on continuation lines.
|
|
81
85
|
const rawLines = text
|
|
82
86
|
.split('\n')
|
|
83
87
|
.map((l) => (l.startsWith('- ') ? '• ' + l.slice(2) : l));
|
|
84
|
-
|
|
88
|
+
// Drive the shared bullet-aware wrapper by pixel width: passing the note's
|
|
89
|
+
// available text width as the limit and a glyph-accurate measurer as the
|
|
90
|
+
// length function turns its char-count comparison into a true pixel wrap.
|
|
91
|
+
return wrapDescriptionLines(rawLines, maxWidth, (s) =>
|
|
92
|
+
measureText(s, fontSize)
|
|
93
|
+
);
|
|
85
94
|
}
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Available pixel width for a participant label inside a box of the given
|
|
98
|
+
* width (the 10px accounts for the same left/right inset the layout uses).
|
|
99
|
+
*/
|
|
100
|
+
const labelTextWidth = (boxW: number): number => boxW - 10;
|
|
101
|
+
|
|
87
102
|
/**
|
|
88
103
|
* Split a participant label into multiple lines if it exceeds the box width.
|
|
89
104
|
* Splits on spaces first, then dashes, then camelCase boundaries.
|
|
90
|
-
* Approximate max chars based on font-size 13 (~7.5px per char average).
|
|
91
105
|
*/
|
|
92
|
-
const LABEL_CHAR_WIDTH = 7.5;
|
|
93
|
-
const LABEL_MAX_CHARS = Math.floor(
|
|
94
|
-
(PARTICIPANT_BOX_WIDTH - 10) / LABEL_CHAR_WIDTH
|
|
95
|
-
); // ~14 chars
|
|
96
|
-
|
|
97
106
|
function splitParticipantLabel(
|
|
98
107
|
label: string,
|
|
99
|
-
|
|
108
|
+
maxWidth: number,
|
|
109
|
+
fontSize: number
|
|
100
110
|
): string[] {
|
|
101
|
-
if (label
|
|
111
|
+
if (measureText(label, fontSize) <= maxWidth) return [label];
|
|
102
112
|
|
|
103
113
|
// Split on spaces
|
|
104
114
|
if (label.includes(' ')) {
|
|
105
|
-
return wrapLabelWords(label.split(' '),
|
|
115
|
+
return wrapLabelWords(label.split(' '), maxWidth, fontSize);
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
// Split on dashes/underscores/colons/slashes
|
|
109
119
|
if (/[-_:/]/.test(label)) {
|
|
110
120
|
const parts = label.split(/[-_:/]+/);
|
|
111
|
-
return wrapLabelWords(parts,
|
|
121
|
+
return wrapLabelWords(parts, maxWidth, fontSize);
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
// Split on camelCase boundaries: "UserLookupCloudFx" → ["User", "Lookup", "Cloud", "Fx"]
|
|
@@ -117,19 +127,23 @@ function splitParticipantLabel(
|
|
|
117
127
|
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1\x00$2')
|
|
118
128
|
.split('\x00');
|
|
119
129
|
if (camelParts.length > 1) {
|
|
120
|
-
return wrapLabelWords(camelParts,
|
|
130
|
+
return wrapLabelWords(camelParts, maxWidth, fontSize);
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
return [label];
|
|
124
134
|
}
|
|
125
135
|
|
|
126
|
-
/** Greedily join word parts into lines that fit within
|
|
127
|
-
function wrapLabelWords(
|
|
136
|
+
/** Greedily join word parts into lines that fit within maxWidth pixels. */
|
|
137
|
+
function wrapLabelWords(
|
|
138
|
+
words: string[],
|
|
139
|
+
maxWidth: number,
|
|
140
|
+
fontSize: number
|
|
141
|
+
): string[] {
|
|
128
142
|
const lines: string[] = [];
|
|
129
143
|
let current = '';
|
|
130
144
|
for (const word of words) {
|
|
131
145
|
const test = current ? current + word : word;
|
|
132
|
-
if (test
|
|
146
|
+
if (measureText(test, fontSize) > maxWidth && current) {
|
|
133
147
|
lines.push(current);
|
|
134
148
|
current = word;
|
|
135
149
|
} else {
|
|
@@ -929,10 +943,16 @@ export function renderSequenceDiagram(
|
|
|
929
943
|
const MAX_BOX_WIDTH = 225;
|
|
930
944
|
let uniformBoxWidth = PARTICIPANT_BOX_WIDTH;
|
|
931
945
|
for (const p of participants) {
|
|
932
|
-
const lines = splitParticipantLabel(
|
|
946
|
+
const lines = splitParticipantLabel(
|
|
947
|
+
p.label,
|
|
948
|
+
labelTextWidth(PARTICIPANT_BOX_WIDTH),
|
|
949
|
+
LABEL_FONT_SIZE
|
|
950
|
+
);
|
|
933
951
|
if (lines.length === 0) continue;
|
|
934
|
-
const widest = Math.max(
|
|
935
|
-
|
|
952
|
+
const widest = Math.max(
|
|
953
|
+
...lines.map((l) => measureText(l, LABEL_FONT_SIZE))
|
|
954
|
+
);
|
|
955
|
+
const labelWidth = widest + 10;
|
|
936
956
|
uniformBoxWidth = Math.max(uniformBoxWidth, labelWidth);
|
|
937
957
|
}
|
|
938
958
|
uniformBoxWidth = Math.min(MAX_BOX_WIDTH, uniformBoxWidth);
|
|
@@ -964,17 +984,16 @@ export function renderSequenceDiagram(
|
|
|
964
984
|
const sNoteFontSize = ctx.text(NOTE_FONT_SIZE);
|
|
965
985
|
const sNoteLineH = ctx.structural(NOTE_LINE_H);
|
|
966
986
|
const sNoteGap = ctx.structural(NOTE_GAP);
|
|
967
|
-
const sNoteCharW = ctx.text(NOTE_CHAR_W, 4);
|
|
968
987
|
const sActivationWidth = ctx.structural(ACTIVATION_WIDTH);
|
|
969
988
|
const sSelfCallHeight = SELF_CALL_HEIGHT;
|
|
970
989
|
const sSelfCallWidth = ctx.structural(SELF_CALL_WIDTH);
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
990
|
+
// Pixel width available for note text inside the widest allowed note box
|
|
991
|
+
// (box width minus the horizontal padding on both sides and the fold cut).
|
|
992
|
+
const sNoteTextWidthMax = sNoteMaxW - sNotePadH * 2 - sNoteFold;
|
|
974
993
|
const sNoteLaneMax = sGap - sActivationWidth - sNoteGap;
|
|
975
|
-
const sLabelCharWidth = ctx.text(LABEL_CHAR_WIDTH, 5);
|
|
976
|
-
const sLabelMaxChars = Math.floor((sBoxW - 10) / sLabelCharWidth);
|
|
977
994
|
const sLabelFontSize = ctx.text(LABEL_FONT_SIZE);
|
|
995
|
+
// Pixel width available for a participant label inside the scaled box.
|
|
996
|
+
const sLabelTextWidth = labelTextWidth(sBoxW);
|
|
978
997
|
|
|
979
998
|
// Participant index lookup — used to clamp note width within one lane
|
|
980
999
|
const participantIndexMap = new Map<string, number>();
|
|
@@ -1017,8 +1036,10 @@ export function renderSequenceDiagram(
|
|
|
1017
1036
|
return Math.min(sNoteMaxW, laneMax);
|
|
1018
1037
|
};
|
|
1019
1038
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1039
|
+
// Pixel width available for note text inside a note box of the given outer
|
|
1040
|
+
// width — outer width minus horizontal padding (both sides) and the fold cut.
|
|
1041
|
+
const noteTextWidth = (maxW: number): number =>
|
|
1042
|
+
maxW - sNotePadH * 2 - sNoteFold;
|
|
1022
1043
|
|
|
1023
1044
|
const activationsOff = parsedOptions['activations']?.toLowerCase() === 'off';
|
|
1024
1045
|
|
|
@@ -1249,9 +1270,9 @@ export function renderSequenceDiagram(
|
|
|
1249
1270
|
const NOTE_TRAILING_GAP = ctx.aesthetic(35);
|
|
1250
1271
|
const computeNoteHeight = (
|
|
1251
1272
|
text: string,
|
|
1252
|
-
|
|
1273
|
+
textWidth: number = sNoteTextWidthMax
|
|
1253
1274
|
): number => {
|
|
1254
|
-
const lines = wrapTextLines(text,
|
|
1275
|
+
const lines = wrapTextLines(text, textWidth, sNoteFontSize);
|
|
1255
1276
|
return lines.length * sNoteLineH + sNotePadV * 2;
|
|
1256
1277
|
};
|
|
1257
1278
|
let trailingNoteSpace = 0; // extra space for notes at the end with no following message
|
|
@@ -1273,7 +1294,7 @@ export function renderSequenceDiagram(
|
|
|
1273
1294
|
const note = els[j]! as SequenceNote;
|
|
1274
1295
|
const sc = isNoteAfterSelfCall(note);
|
|
1275
1296
|
const maxW = noteEffectiveMaxW(note.participantId, note.position, sc);
|
|
1276
|
-
const noteH = computeNoteHeight(note.text,
|
|
1297
|
+
const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
|
|
1277
1298
|
totalExtent += noteH + NOTE_OFFSET_BELOW;
|
|
1278
1299
|
j++;
|
|
1279
1300
|
}
|
|
@@ -1571,7 +1592,7 @@ export function renderSequenceDiagram(
|
|
|
1571
1592
|
);
|
|
1572
1593
|
const prevNoteH = computeNoteHeight(
|
|
1573
1594
|
prevNote.text,
|
|
1574
|
-
|
|
1595
|
+
noteTextWidth(prevMaxW)
|
|
1575
1596
|
);
|
|
1576
1597
|
noteTopY = prevNoteY + prevNoteH + NOTE_OFFSET_BELOW;
|
|
1577
1598
|
} else {
|
|
@@ -1615,7 +1636,7 @@ export function renderSequenceDiagram(
|
|
|
1615
1636
|
effectiveNotePosition(note),
|
|
1616
1637
|
isNoteAfterSelfCall(note)
|
|
1617
1638
|
);
|
|
1618
|
-
const noteH = computeNoteHeight(note.text,
|
|
1639
|
+
const noteH = computeNoteHeight(note.text, noteTextWidth(maxW));
|
|
1619
1640
|
contentBottomY = Math.max(
|
|
1620
1641
|
contentBottomY,
|
|
1621
1642
|
noteTopY + noteH + NOTE_TRAILING_GAP
|
|
@@ -1651,7 +1672,8 @@ export function renderSequenceDiagram(
|
|
|
1651
1672
|
if (step.from === step.to && step.from === rightmostId) {
|
|
1652
1673
|
const selfProj = sActivationWidth + sSelfCallWidth;
|
|
1653
1674
|
let labelProj = 0;
|
|
1654
|
-
|
|
1675
|
+
// Self-call labels render at the fixed 12px message-label size.
|
|
1676
|
+
if (step.label) labelProj = measureText(step.label, 12) + 15;
|
|
1655
1677
|
rightProjection = Math.max(rightProjection, selfProj + labelProj);
|
|
1656
1678
|
}
|
|
1657
1679
|
}
|
|
@@ -1726,12 +1748,11 @@ export function renderSequenceDiagram(
|
|
|
1726
1748
|
// Post-layout content scan: detect labels/notes that overflow the SVG boundaries.
|
|
1727
1749
|
// Message labels render at a fixed 12px font (unscaled) so they can extend past
|
|
1728
1750
|
// the scaled participant grid at small scale factors.
|
|
1729
|
-
const MSG_LABEL_CHAR_W = 7;
|
|
1730
1751
|
let contentLeft = 0;
|
|
1731
1752
|
let contentRight = svgWidth;
|
|
1732
1753
|
for (const step of renderSteps) {
|
|
1733
1754
|
if (!step.label) continue;
|
|
1734
|
-
const labelW = step.label
|
|
1755
|
+
const labelW = measureText(step.label, 12);
|
|
1735
1756
|
if (step.from === step.to) {
|
|
1736
1757
|
const px = participantX.get(step.from);
|
|
1737
1758
|
if (px !== undefined) {
|
|
@@ -2101,7 +2122,7 @@ export function renderSequenceDiagram(
|
|
|
2101
2122
|
solid,
|
|
2102
2123
|
sBoxW,
|
|
2103
2124
|
sBoxH,
|
|
2104
|
-
|
|
2125
|
+
sLabelTextWidth,
|
|
2105
2126
|
sLabelFontSize
|
|
2106
2127
|
);
|
|
2107
2128
|
|
|
@@ -2651,7 +2672,8 @@ export function renderSequenceDiagram(
|
|
|
2651
2672
|
|
|
2652
2673
|
// Transparent hit area scoped to the label so the toggle stays clickable
|
|
2653
2674
|
// without the band swallowing clicks across the full diagram width.
|
|
2654
|
-
|
|
2675
|
+
// Label renders at the fixed 11px section-label size.
|
|
2676
|
+
const labelHitW = Math.max(80, measureText(labelText, 11) + 24);
|
|
2655
2677
|
sectionG
|
|
2656
2678
|
.append('rect')
|
|
2657
2679
|
.attr('x', labelX - labelHitW / 2)
|
|
@@ -2915,13 +2937,15 @@ export function renderSequenceDiagram(
|
|
|
2915
2937
|
position,
|
|
2916
2938
|
afterSelfCall
|
|
2917
2939
|
);
|
|
2918
|
-
const
|
|
2919
|
-
const wrappedLines = wrapTextLines(el.text,
|
|
2940
|
+
const textWidth = noteTextWidth(maxW);
|
|
2941
|
+
const wrappedLines = wrapTextLines(el.text, textWidth, sNoteFontSize);
|
|
2920
2942
|
const noteH = wrappedLines.length * sNoteLineH + sNotePadV * 2;
|
|
2921
|
-
const
|
|
2943
|
+
const maxLineW = Math.max(
|
|
2944
|
+
...wrappedLines.map((l) => measureText(l.text, sNoteFontSize))
|
|
2945
|
+
);
|
|
2922
2946
|
const noteW = Math.min(
|
|
2923
2947
|
maxW,
|
|
2924
|
-
Math.max(80,
|
|
2948
|
+
Math.max(80, maxLineW + sNotePadH * 2 + sNoteFold)
|
|
2925
2949
|
);
|
|
2926
2950
|
// Shift notes past self-call loopback when applicable
|
|
2927
2951
|
const rightOffset =
|
|
@@ -3089,7 +3113,7 @@ function renderParticipant(
|
|
|
3089
3113
|
solid?: boolean,
|
|
3090
3114
|
boxW: number = W,
|
|
3091
3115
|
boxH: number = H,
|
|
3092
|
-
|
|
3116
|
+
labelTextW: number = labelTextWidth(W),
|
|
3093
3117
|
labelFontSize: number = LABEL_FONT_SIZE
|
|
3094
3118
|
): void {
|
|
3095
3119
|
const g = svg
|
|
@@ -3124,7 +3148,11 @@ function renderParticipant(
|
|
|
3124
3148
|
|
|
3125
3149
|
// Render label — below the shape for actors, centered inside for others
|
|
3126
3150
|
const isActor = participant.type === 'actor';
|
|
3127
|
-
const labelLines = splitParticipantLabel(
|
|
3151
|
+
const labelLines = splitParticipantLabel(
|
|
3152
|
+
participant.label,
|
|
3153
|
+
labelTextW,
|
|
3154
|
+
labelFontSize
|
|
3155
|
+
);
|
|
3128
3156
|
const fontSize = labelFontSize;
|
|
3129
3157
|
const lineHeight = fontSize + 2;
|
|
3130
3158
|
const labelFill = isActor
|
|
@@ -3142,12 +3170,9 @@ function renderParticipant(
|
|
|
3142
3170
|
.attr('font-size', fontSize)
|
|
3143
3171
|
.attr('font-weight', 500);
|
|
3144
3172
|
|
|
3145
|
-
const maxLabelW = boxW
|
|
3146
|
-
const truncLine = (text: string): string =>
|
|
3147
|
-
|
|
3148
|
-
const maxCharsEst = Math.floor(maxLabelW / (fontSize * 0.6));
|
|
3149
|
-
return maxCharsEst > 2 ? text.slice(0, maxCharsEst - 1) + '…' : text;
|
|
3150
|
-
};
|
|
3173
|
+
const maxLabelW = labelTextWidth(boxW);
|
|
3174
|
+
const truncLine = (text: string): string =>
|
|
3175
|
+
truncateText(text, fontSize, maxLabelW);
|
|
3151
3176
|
|
|
3152
3177
|
if (labelLines.length === 1) {
|
|
3153
3178
|
textEl
|
package/src/sitemap/layout.ts
CHANGED
|
@@ -10,8 +10,24 @@ import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
|
|
|
10
10
|
import {
|
|
11
11
|
LEGEND_PILL_FONT_SIZE,
|
|
12
12
|
LEGEND_ENTRY_FONT_SIZE,
|
|
13
|
+
LEGEND_HEIGHT,
|
|
14
|
+
LEGEND_PILL_PAD,
|
|
15
|
+
LEGEND_CAPSULE_PAD,
|
|
16
|
+
LEGEND_DOT_R,
|
|
17
|
+
LEGEND_ENTRY_DOT_GAP,
|
|
18
|
+
LEGEND_ENTRY_TRAIL,
|
|
19
|
+
LEGEND_GROUP_GAP,
|
|
20
|
+
LEGEND_EYE_SIZE,
|
|
21
|
+
LEGEND_EYE_GAP,
|
|
13
22
|
measureLegendText,
|
|
14
23
|
} from '../utils/legend-constants';
|
|
24
|
+
import { measureText } from '../utils/text-measure';
|
|
25
|
+
|
|
26
|
+
// Font sizes — must match the renderer (renderer.ts) so card sizing here
|
|
27
|
+
// agrees pixel-for-pixel with what gets drawn.
|
|
28
|
+
const LABEL_FONT_SIZE = 13;
|
|
29
|
+
const META_FONT_SIZE = 11;
|
|
30
|
+
const CONTAINER_LABEL_FONT_SIZE = 13;
|
|
15
31
|
|
|
16
32
|
// ============================================================
|
|
17
33
|
// Types
|
|
@@ -119,7 +135,6 @@ function clipToRectBorder(
|
|
|
119
135
|
// Constants
|
|
120
136
|
// ============================================================
|
|
121
137
|
|
|
122
|
-
const CHAR_WIDTH = 7.5;
|
|
123
138
|
const META_LINE_HEIGHT = 16;
|
|
124
139
|
const HEADER_HEIGHT = 28;
|
|
125
140
|
const SEPARATOR_GAP = 6;
|
|
@@ -134,15 +149,6 @@ const CONTAINER_LABEL_HEIGHT = 28;
|
|
|
134
149
|
const CONTAINER_META_LINE_HEIGHT = 16;
|
|
135
150
|
|
|
136
151
|
// Legend (kanban-style pills)
|
|
137
|
-
const LEGEND_HEIGHT = 28;
|
|
138
|
-
const LEGEND_PILL_PAD = 16;
|
|
139
|
-
const LEGEND_CAPSULE_PAD = 4;
|
|
140
|
-
const LEGEND_DOT_R = 4;
|
|
141
|
-
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
142
|
-
const LEGEND_ENTRY_TRAIL = 8;
|
|
143
|
-
const LEGEND_GROUP_GAP = 12;
|
|
144
|
-
const LEGEND_EYE_SIZE = 14;
|
|
145
|
-
const LEGEND_EYE_GAP = 6;
|
|
146
152
|
|
|
147
153
|
// ============================================================
|
|
148
154
|
// Helpers
|
|
@@ -167,20 +173,22 @@ function computeCardWidth(
|
|
|
167
173
|
meta: Record<string, string>,
|
|
168
174
|
descLines?: readonly string[]
|
|
169
175
|
): number {
|
|
170
|
-
|
|
176
|
+
// Measure each text element at the font size the renderer draws it with so
|
|
177
|
+
// the card never under-sizes its content.
|
|
178
|
+
let maxContentWidth = measureText(label, LABEL_FONT_SIZE);
|
|
171
179
|
for (const [key, value] of Object.entries(meta)) {
|
|
172
|
-
|
|
173
|
-
|
|
180
|
+
// Renderer draws meta as "key:" then the value, both at META_FONT_SIZE,
|
|
181
|
+
// with a space separating them.
|
|
182
|
+
const lineWidth = measureText(`${key}: ${value}`, META_FONT_SIZE);
|
|
183
|
+
if (lineWidth > maxContentWidth) maxContentWidth = lineWidth;
|
|
174
184
|
}
|
|
175
185
|
if (descLines) {
|
|
176
186
|
for (const dl of descLines) {
|
|
177
|
-
|
|
187
|
+
const dlWidth = measureText(dl, META_FONT_SIZE);
|
|
188
|
+
if (dlWidth > maxContentWidth) maxContentWidth = dlWidth;
|
|
178
189
|
}
|
|
179
190
|
}
|
|
180
|
-
return Math.max(
|
|
181
|
-
MIN_CARD_WIDTH,
|
|
182
|
-
Math.ceil(maxChars * CHAR_WIDTH) + CARD_H_PAD * 2
|
|
183
|
-
);
|
|
191
|
+
return Math.max(MIN_CARD_WIDTH, Math.ceil(maxContentWidth) + CARD_H_PAD * 2);
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
function computeCardHeight(
|
|
@@ -301,7 +309,7 @@ function flattenNodes(
|
|
|
301
309
|
fullMeta: { ...node.metadata },
|
|
302
310
|
width: Math.max(
|
|
303
311
|
MIN_CARD_WIDTH,
|
|
304
|
-
node.label
|
|
312
|
+
measureText(node.label, CONTAINER_LABEL_FONT_SIZE) + CARD_H_PAD * 2
|
|
305
313
|
),
|
|
306
314
|
height: labelHeight + CONTAINER_PAD_BOTTOM,
|
|
307
315
|
});
|
package/src/sitemap/renderer.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ParsedSitemap } from './types';
|
|
|
11
11
|
import type { SitemapLayoutResult, SitemapLegendGroup } from './layout';
|
|
12
12
|
import { renderInlineText } from '../utils/inline-markdown';
|
|
13
13
|
import { preprocessDescriptionLine } from '../utils/description-helpers';
|
|
14
|
+
import { measureText } from '../utils/text-measure';
|
|
14
15
|
import {
|
|
15
16
|
LEGEND_HEIGHT,
|
|
16
17
|
LEGEND_GROUP_GAP,
|
|
@@ -347,8 +348,12 @@ export function renderSitemap(
|
|
|
347
348
|
const metaDisplayKeys = metaEntries.map(
|
|
348
349
|
([k]) => displayNames.get(k) ?? k
|
|
349
350
|
);
|
|
350
|
-
const
|
|
351
|
-
|
|
351
|
+
const maxKeyWidth = Math.max(
|
|
352
|
+
...metaDisplayKeys.map((k) =>
|
|
353
|
+
measureText(`${k}: `, sContainerMetaFontSize)
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
const valueX = 10 + maxKeyWidth;
|
|
352
357
|
const metaStartY = sContainerHeaderHeight + sContainerMetaFontSize - 2;
|
|
353
358
|
|
|
354
359
|
for (let i = 0; i < metaEntries.length; i++) {
|
|
@@ -429,7 +434,7 @@ export function renderSitemap(
|
|
|
429
434
|
// Edge label with background badge
|
|
430
435
|
if (edge.label && edge.points.length >= 2) {
|
|
431
436
|
const mid = edge.points[Math.floor(edge.points.length / 2)]!;
|
|
432
|
-
const labelW = edge.label
|
|
437
|
+
const labelW = measureText(edge.label, sEdgeLabelFontSize) + 10;
|
|
433
438
|
const labelH = sEdgeLabelFontSize + 6;
|
|
434
439
|
|
|
435
440
|
edgeG
|
|
@@ -529,8 +534,10 @@ export function renderSitemap(
|
|
|
529
534
|
const metaDisplayKeys = metaEntries.map(
|
|
530
535
|
([k]) => displayNames.get(k) ?? k
|
|
531
536
|
);
|
|
532
|
-
const
|
|
533
|
-
|
|
537
|
+
const maxKeyWidth = Math.max(
|
|
538
|
+
...metaDisplayKeys.map((k) => measureText(`${k}: `, sMetaFontSize))
|
|
539
|
+
);
|
|
540
|
+
const valueX = 10 + maxKeyWidth;
|
|
534
541
|
|
|
535
542
|
for (let i = 0; i < metaEntries.length; i++) {
|
|
536
543
|
// In-bounds by loop guard.
|
|
@@ -6,6 +6,11 @@ import type { D3ExportDimensions } from '../utils/d3-types';
|
|
|
6
6
|
import type { CompactViewState } from '../sharing';
|
|
7
7
|
import { parseInlineMarkdown } from '../utils/inline-markdown';
|
|
8
8
|
import { safeHref } from '../utils/safe-href';
|
|
9
|
+
import {
|
|
10
|
+
measureText,
|
|
11
|
+
truncateText,
|
|
12
|
+
wrapTextToWidth,
|
|
13
|
+
} from '../utils/text-measure';
|
|
9
14
|
import type {
|
|
10
15
|
ParsedTechRadar,
|
|
11
16
|
QuadrantPosition,
|
|
@@ -780,7 +785,7 @@ function renderBlipListing(
|
|
|
780
785
|
const textX = colX + sListingBlipR * 2 + 6;
|
|
781
786
|
const availableWidth = colWidth - sListingBlipR * 2 - 8;
|
|
782
787
|
const fullLabel = `${blip.name} (${blip.ring})`;
|
|
783
|
-
const label =
|
|
788
|
+
const label = truncateText(fullLabel, sListingFontSize, availableWidth);
|
|
784
789
|
|
|
785
790
|
itemGroup
|
|
786
791
|
.append('text')
|
|
@@ -834,20 +839,6 @@ function renderBlipListing(
|
|
|
834
839
|
}
|
|
835
840
|
}
|
|
836
841
|
|
|
837
|
-
/** Estimate max characters that fit in `availablePx` at the given font size. */
|
|
838
|
-
function truncateLabel(
|
|
839
|
-
text: string,
|
|
840
|
-
availablePx: number,
|
|
841
|
-
fontSize: number
|
|
842
|
-
): string {
|
|
843
|
-
// Average character width ≈ 0.58 × fontSize for Helvetica/Inter
|
|
844
|
-
const avgCharWidth = fontSize * 0.58;
|
|
845
|
-
const maxChars = Math.floor(availablePx / avgCharWidth);
|
|
846
|
-
if (maxChars <= 0) return '';
|
|
847
|
-
if (text.length <= maxChars) return text;
|
|
848
|
-
return text.substring(0, maxChars - 1) + '\u2026';
|
|
849
|
-
}
|
|
850
|
-
|
|
851
842
|
// ============================================================
|
|
852
843
|
// Ring×Quadrant Hover Interactivity
|
|
853
844
|
// ============================================================
|
|
@@ -1164,28 +1155,13 @@ function renderQuadrantLabel(
|
|
|
1164
1155
|
maxWidth: number,
|
|
1165
1156
|
baseFontSize = QUADRANT_LABEL_FONT_SIZE
|
|
1166
1157
|
): void {
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
// Split into words and wrap
|
|
1171
|
-
const words = name.split(/\s+/);
|
|
1172
|
-
const lines: string[] = [];
|
|
1173
|
-
let currentLine = '';
|
|
1174
|
-
|
|
1175
|
-
for (const word of words) {
|
|
1176
|
-
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
1177
|
-
if (testLine.length > maxCharsPerLine && currentLine) {
|
|
1178
|
-
lines.push(currentLine);
|
|
1179
|
-
currentLine = word;
|
|
1180
|
-
} else {
|
|
1181
|
-
currentLine = testLine;
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
if (currentLine) lines.push(currentLine);
|
|
1158
|
+
// Greedy word-wrap to the available pixel width.
|
|
1159
|
+
const lines = wrapTextToWidth(name, baseFontSize, maxWidth);
|
|
1185
1160
|
|
|
1186
1161
|
// Scale font down if any line is still too wide
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1162
|
+
const estimatedWidth = Math.max(
|
|
1163
|
+
...lines.map((l) => measureText(l, baseFontSize))
|
|
1164
|
+
);
|
|
1189
1165
|
const fontSize =
|
|
1190
1166
|
estimatedWidth > maxWidth
|
|
1191
1167
|
? Math.max(12, baseFontSize * (maxWidth / estimatedWidth))
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared arrowhead <marker> defs
|
|
3
|
+
// ============================================================
|
|
4
|
+
// The flowchart and state renderers carried a byte-identical block
|
|
5
|
+
// that appends a base arrowhead marker plus one per edge color. This
|
|
6
|
+
// is the single source so they can't drift.
|
|
7
|
+
|
|
8
|
+
import type { BaseType, Selection } from 'd3-selection';
|
|
9
|
+
|
|
10
|
+
export interface ArrowheadMarkerOptions {
|
|
11
|
+
/** Marker id prefix, e.g. 'fc' → `fc-arrow`, `fc-arrow-<hex>`. */
|
|
12
|
+
idPrefix: string;
|
|
13
|
+
/** Marker width (already scaled by the caller's ScaleContext). */
|
|
14
|
+
width: number;
|
|
15
|
+
/** Marker height. */
|
|
16
|
+
height: number;
|
|
17
|
+
/** Fill for the base (uncolored) arrowhead. */
|
|
18
|
+
baseFill: string;
|
|
19
|
+
/** Edge colors needing their own tinted marker (hex strings). */
|
|
20
|
+
colors?: Iterable<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Append an arrowhead `<marker>` (id `${idPrefix}-arrow`) to `defs`, plus
|
|
25
|
+
* one tinted marker per edge color (id `${idPrefix}-arrow-<hex-without-#>`).
|
|
26
|
+
*/
|
|
27
|
+
export function appendArrowheadMarkers<GElement extends BaseType>(
|
|
28
|
+
defs: Selection<GElement, unknown, null, undefined>,
|
|
29
|
+
opts: ArrowheadMarkerOptions
|
|
30
|
+
): void {
|
|
31
|
+
const { idPrefix, width, height, baseFill, colors } = opts;
|
|
32
|
+
const appendMarker = (id: string, fill: string): void => {
|
|
33
|
+
defs
|
|
34
|
+
.append('marker')
|
|
35
|
+
.attr('id', id)
|
|
36
|
+
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
37
|
+
.attr('refX', width)
|
|
38
|
+
.attr('refY', height / 2)
|
|
39
|
+
.attr('markerWidth', width)
|
|
40
|
+
.attr('markerHeight', height)
|
|
41
|
+
.attr('orient', 'auto')
|
|
42
|
+
.append('polygon')
|
|
43
|
+
.attr('points', `0,0 ${width},${height / 2} 0,${height}`)
|
|
44
|
+
.attr('fill', fill);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
appendMarker(`${idPrefix}-arrow`, baseFill);
|
|
48
|
+
for (const color of colors ?? []) {
|
|
49
|
+
appendMarker(`${idPrefix}-arrow-${color.replace('#', '')}`, color);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Diagram → canvas fit
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Shared by renderers that top-anchor a diagram and fit it to the
|
|
5
|
+
// canvas with the same scale model. Each renderer with a *different*
|
|
6
|
+
// model (legend-band reserves, vertical centering, content-padded
|
|
7
|
+
// scale) keeps its own math on purpose — this is only for the ones
|
|
8
|
+
// that were byte-identical.
|
|
9
|
+
|
|
10
|
+
export interface CanvasFit {
|
|
11
|
+
scale: number;
|
|
12
|
+
offsetX: number;
|
|
13
|
+
offsetY: number;
|
|
14
|
+
canvasHeight: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FitDiagramParams {
|
|
18
|
+
/** Canvas width. */
|
|
19
|
+
width: number;
|
|
20
|
+
/** Canvas height (interactive pane height; ignored for canvasHeight in export mode). */
|
|
21
|
+
height: number;
|
|
22
|
+
/** Laid-out diagram width / height. */
|
|
23
|
+
diagramW: number;
|
|
24
|
+
diagramH: number;
|
|
25
|
+
/** Scaled padding around the diagram. */
|
|
26
|
+
padding: number;
|
|
27
|
+
/** Reserved height above the diagram (title band), 0 if none. */
|
|
28
|
+
titleHeight: number;
|
|
29
|
+
/** Upper bound on scale. */
|
|
30
|
+
maxScale: number;
|
|
31
|
+
/** True when rendering to a fixed export canvas. */
|
|
32
|
+
exportMode: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fit a top-anchored diagram into the canvas.
|
|
37
|
+
*
|
|
38
|
+
* Export renders a fixed canvas (e.g. 1200×800); fitting a small graph into it
|
|
39
|
+
* and top-anchoring leaves a tall dead band below. In export mode we scale to
|
|
40
|
+
* width (capped by maxScale) and size the canvas to the scaled content height.
|
|
41
|
+
* The interactive preview keeps fit-to-pane (min of width/height scale) so a
|
|
42
|
+
* small graph still fills its pane.
|
|
43
|
+
*/
|
|
44
|
+
export function fitDiagramToCanvas(p: FitDiagramParams): CanvasFit {
|
|
45
|
+
const scaleX = (p.width - p.padding * 2) / p.diagramW;
|
|
46
|
+
let scale: number;
|
|
47
|
+
let canvasHeight: number;
|
|
48
|
+
if (p.exportMode) {
|
|
49
|
+
scale = Math.min(p.maxScale, scaleX);
|
|
50
|
+
canvasHeight = p.titleHeight + p.diagramH * scale + p.padding * 2;
|
|
51
|
+
} else {
|
|
52
|
+
const availH = p.height - p.titleHeight;
|
|
53
|
+
const scaleY = (availH - p.padding * 2) / p.diagramH;
|
|
54
|
+
scale = Math.min(p.maxScale, scaleX, scaleY);
|
|
55
|
+
canvasHeight = p.height;
|
|
56
|
+
}
|
|
57
|
+
const scaledW = p.diagramW * scale;
|
|
58
|
+
return {
|
|
59
|
+
scale,
|
|
60
|
+
offsetX: (p.width - scaledW) / 2,
|
|
61
|
+
offsetY: p.titleHeight + p.padding,
|
|
62
|
+
canvasHeight,
|
|
63
|
+
};
|
|
64
|
+
}
|