@diagrammo/dgmo 0.30.0 → 0.31.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 +21 -3
- package/dist/advanced.cjs +560 -269
- package/dist/advanced.d.cts +27 -2
- package/dist/advanced.d.ts +27 -2
- package/dist/advanced.js +559 -269
- package/dist/auto.cjs +558 -270
- package/dist/auto.js +93 -93
- package/dist/auto.mjs +558 -270
- package/dist/cli.cjs +144 -143
- package/dist/index.cjs +557 -269
- package/dist/index.js +557 -269
- package/package.json +1 -1
- package/src/advanced.ts +3 -0
- package/src/boxes-and-lines/layout-search.ts +214 -0
- package/src/boxes-and-lines/layout.ts +4 -0
- package/src/boxes-and-lines/parser.ts +78 -0
- package/src/boxes-and-lines/renderer.ts +57 -5
- package/src/boxes-and-lines/types.ts +9 -0
- package/src/c4/renderer.ts +7 -5
- package/src/chart-types.ts +2 -2
- package/src/class/renderer.ts +4 -2
- package/src/cli-banner.ts +107 -0
- package/src/cli.ts +13 -0
- package/src/colors.ts +22 -0
- package/src/er/renderer.ts +4 -2
- package/src/graph/flowchart-renderer.ts +4 -2
- package/src/graph/state-renderer.ts +4 -2
- package/src/infra/renderer.ts +8 -4
- package/src/journey-map/parser.ts +15 -1
- package/src/journey-map/renderer.ts +1 -1
- package/src/kanban/renderer.ts +1 -1
- package/src/map/renderer.ts +27 -14
- package/src/mindmap/renderer.ts +5 -3
- package/src/org/renderer.ts +67 -120
- package/src/palettes/color-utils.ts +7 -2
- package/src/pert/renderer.ts +13 -8
- package/src/raci/renderer.ts +1 -1
- package/src/sitemap/renderer.ts +35 -37
- package/src/utils/card.ts +183 -0
- package/src/utils/tag-groups.ts +10 -10
- package/src/utils/visual-conventions.ts +61 -0
package/package.json
CHANGED
package/src/advanced.ts
CHANGED
|
@@ -174,6 +174,9 @@ export type {
|
|
|
174
174
|
} from './graph/types';
|
|
175
175
|
|
|
176
176
|
export type { TagGroup, TagEntry } from './utils/tag-groups';
|
|
177
|
+
// The canonical categorical auto-color rotation (RGB-seeded, max-contrast,
|
|
178
|
+
// neutrals excluded) — so app/editor swatch cyclers share dgmo's exact order.
|
|
179
|
+
export { autoTagColorCycle } from './utils/tag-groups';
|
|
177
180
|
|
|
178
181
|
export { parseInlineMarkdown, truncateBareUrl } from './utils/inline-markdown';
|
|
179
182
|
export type { InlineSpan } from './utils/inline-markdown';
|
|
@@ -1016,6 +1016,217 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1016
1016
|
Math.abs(p.x - rect.x) <= rect.w / 2 &&
|
|
1017
1017
|
Math.abs(p.y - rect.y) <= rect.h / 2;
|
|
1018
1018
|
|
|
1019
|
+
// ── Pinned-layout bypass (Canvas Editor spike, Decisions 3/7) ──
|
|
1020
|
+
// When a `layout` block positions EVERY node, place nodes directly from the
|
|
1021
|
+
// stored coordinates and skip the whole dagre search. Edges become
|
|
1022
|
+
// border-clipped straight connectors (no obstacle avoidance — honest-but-ugly).
|
|
1023
|
+
// FLAT, EXPANDED groups are honored: each group's container rect is computed
|
|
1024
|
+
// from its members' pinned positions (canvas group editing). A FLAT group can
|
|
1025
|
+
// also be COLLAPSED while pinned — it renders as a plain box at its members'
|
|
1026
|
+
// bbox-centre (so collapse no longer forces a full dagre reflow). Nested groups
|
|
1027
|
+
// still fall back to dagre (deferred).
|
|
1028
|
+
const pinned = parsed.nodePositions;
|
|
1029
|
+
const groupLabelSet = new Set(parsed.groups.map((g) => g.label));
|
|
1030
|
+
const groupsAreFlat = parsed.groups.every(
|
|
1031
|
+
(g) => !g.parentGroup && !g.children.some((c) => groupLabelSet.has(c))
|
|
1032
|
+
);
|
|
1033
|
+
// A collapsed group can stay pinned only when it's FLAT (top-level, no
|
|
1034
|
+
// sub-groups) and every one of its members has a pinned coord — otherwise we
|
|
1035
|
+
// can't place its box without a search, so fall back to dagre.
|
|
1036
|
+
const allOriginalGroupLabels = new Set(
|
|
1037
|
+
(collapseInfo?.originalGroups ?? parsed.groups).map((g) => g.label)
|
|
1038
|
+
);
|
|
1039
|
+
const collapsedAreFlatPinned =
|
|
1040
|
+
collapsedGroupLabels.size === 0 ||
|
|
1041
|
+
(pinned !== undefined &&
|
|
1042
|
+
collapseInfo !== undefined &&
|
|
1043
|
+
[...collapsedGroupLabels].every((label) => {
|
|
1044
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
1045
|
+
if (!og || og.parentGroup) return false;
|
|
1046
|
+
return og.children.every(
|
|
1047
|
+
(c) => pinned.has(c) && !allOriginalGroupLabels.has(c)
|
|
1048
|
+
);
|
|
1049
|
+
}));
|
|
1050
|
+
const allPinned =
|
|
1051
|
+
pinned !== undefined &&
|
|
1052
|
+
(parsed.nodes.length > 0 || collapsedGroupLabels.size > 0) &&
|
|
1053
|
+
parsed.nodes.every((n) => pinned.has(n.label)) &&
|
|
1054
|
+
groupsAreFlat &&
|
|
1055
|
+
collapsedAreFlatPinned;
|
|
1056
|
+
function placePinned(pins: ReadonlyMap<string, Pt>): BLLayoutResult {
|
|
1057
|
+
// Collapsed flat groups → a plain NODE-sized box at the bbox-centre of the
|
|
1058
|
+
// group's (now-hidden) members' pinned coords. The collapse transform
|
|
1059
|
+
// redirected their incident edges to `__group_<label>`, so register that id
|
|
1060
|
+
// in the position/rect lookups too.
|
|
1061
|
+
const collapsedPosByGid = new Map<string, Pt>();
|
|
1062
|
+
const collapsedBoxes: Array<{
|
|
1063
|
+
label: string;
|
|
1064
|
+
lineNumber: number;
|
|
1065
|
+
childCount: number;
|
|
1066
|
+
x: number;
|
|
1067
|
+
y: number;
|
|
1068
|
+
}> = [];
|
|
1069
|
+
if (collapseInfo)
|
|
1070
|
+
for (const label of collapsedGroupLabels) {
|
|
1071
|
+
const og = collapseInfo.originalGroups.find((g) => g.label === label);
|
|
1072
|
+
if (!og) continue;
|
|
1073
|
+
let cx0 = Infinity,
|
|
1074
|
+
cy0 = Infinity,
|
|
1075
|
+
cx1 = -Infinity,
|
|
1076
|
+
cy1 = -Infinity;
|
|
1077
|
+
for (const c of og.children) {
|
|
1078
|
+
const p = pins.get(c);
|
|
1079
|
+
if (!p) continue;
|
|
1080
|
+
cx0 = Math.min(cx0, p.x);
|
|
1081
|
+
cx1 = Math.max(cx1, p.x);
|
|
1082
|
+
cy0 = Math.min(cy0, p.y);
|
|
1083
|
+
cy1 = Math.max(cy1, p.y);
|
|
1084
|
+
}
|
|
1085
|
+
if (!Number.isFinite(cx0)) continue;
|
|
1086
|
+
const cx = (cx0 + cx1) / 2;
|
|
1087
|
+
const cy = (cy0 + cy1) / 2;
|
|
1088
|
+
collapsedPosByGid.set(`__group_${label}`, { x: cx, y: cy });
|
|
1089
|
+
collapsedBoxes.push({
|
|
1090
|
+
label,
|
|
1091
|
+
lineNumber: og.lineNumber,
|
|
1092
|
+
childCount:
|
|
1093
|
+
collapseInfo.collapsedChildCounts.get(label) ?? og.children.length,
|
|
1094
|
+
x: cx,
|
|
1095
|
+
y: cy,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
const posOf = (label: string): Pt | undefined =>
|
|
1099
|
+
pins.get(label) ?? collapsedPosByGid.get(label);
|
|
1100
|
+
const rectOf = (label: string) => {
|
|
1101
|
+
const p = posOf(label)!;
|
|
1102
|
+
const s = sizes.get(label) ?? { width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
1103
|
+
return { x: p.x, y: p.y, w: s.width, h: s.height };
|
|
1104
|
+
};
|
|
1105
|
+
const nodes = parsed.nodes.map((n) => {
|
|
1106
|
+
const r = rectOf(n.label);
|
|
1107
|
+
return { label: n.label, x: r.x, y: r.y, width: r.w, height: r.h };
|
|
1108
|
+
});
|
|
1109
|
+
const edges: BLLayoutEdge[] = parsed.edges.flatMap((e) => {
|
|
1110
|
+
const sp = posOf(e.source);
|
|
1111
|
+
const tp = posOf(e.target);
|
|
1112
|
+
if (!sp || !tp) return [];
|
|
1113
|
+
const srcRect = rectOf(e.source);
|
|
1114
|
+
const tgtRect = rectOf(e.target);
|
|
1115
|
+
const p0 = rectBorderPoint(srcRect, tp);
|
|
1116
|
+
const p1 = rectBorderPoint(tgtRect, sp);
|
|
1117
|
+
return [
|
|
1118
|
+
{
|
|
1119
|
+
source: e.source,
|
|
1120
|
+
target: e.target,
|
|
1121
|
+
...(e.label !== undefined && { label: e.label }),
|
|
1122
|
+
bidirectional: e.bidirectional,
|
|
1123
|
+
lineNumber: e.lineNumber,
|
|
1124
|
+
points: [p0, p1],
|
|
1125
|
+
yOffset: 0,
|
|
1126
|
+
parallelCount: 1,
|
|
1127
|
+
metadata: e.metadata,
|
|
1128
|
+
straight: true,
|
|
1129
|
+
},
|
|
1130
|
+
];
|
|
1131
|
+
});
|
|
1132
|
+
// Fit the canvas around the pinned content with a uniform margin on every
|
|
1133
|
+
// side. Crucially, content must never fall off the TOP/LEFT (the viewBox
|
|
1134
|
+
// origin is 0,0): if a node was dragged past the margin we shift everything
|
|
1135
|
+
// back on-canvas by `max(0, M - min)` — clamped so in-bounds diagrams keep
|
|
1136
|
+
// their exact pinned coords (shift 0) and only off-canvas ones are nudged.
|
|
1137
|
+
// Flat-group container rects: bbox of the group's members + side/bottom
|
|
1138
|
+
// padding, with a label zone reserved at the top (mirrors the renderer).
|
|
1139
|
+
const GROUP_PAD = 16;
|
|
1140
|
+
const nodeByLabel = new Map(nodes.map((n) => [n.label, n]));
|
|
1141
|
+
const groups: BLLayoutResult['groups'][number][] = [];
|
|
1142
|
+
for (const grp of parsed.groups) {
|
|
1143
|
+
let gx0 = Infinity,
|
|
1144
|
+
gy0 = Infinity,
|
|
1145
|
+
gx1 = -Infinity,
|
|
1146
|
+
gy1 = -Infinity;
|
|
1147
|
+
for (const c of grp.children) {
|
|
1148
|
+
const n = nodeByLabel.get(c);
|
|
1149
|
+
if (!n) continue;
|
|
1150
|
+
gx0 = Math.min(gx0, n.x - n.width / 2);
|
|
1151
|
+
gx1 = Math.max(gx1, n.x + n.width / 2);
|
|
1152
|
+
gy0 = Math.min(gy0, n.y - n.height / 2);
|
|
1153
|
+
gy1 = Math.max(gy1, n.y + n.height / 2);
|
|
1154
|
+
}
|
|
1155
|
+
if (!Number.isFinite(gx0)) continue; // members not pinned / empty group
|
|
1156
|
+
const x0 = gx0 - GROUP_PAD;
|
|
1157
|
+
const x1 = gx1 + GROUP_PAD;
|
|
1158
|
+
const y0 = gy0 - GROUP_LABEL_ZONE;
|
|
1159
|
+
const y1 = gy1 + GROUP_PAD;
|
|
1160
|
+
groups.push({
|
|
1161
|
+
label: grp.label,
|
|
1162
|
+
lineNumber: grp.lineNumber,
|
|
1163
|
+
x: (x0 + x1) / 2,
|
|
1164
|
+
y: (y0 + y1) / 2,
|
|
1165
|
+
width: x1 - x0,
|
|
1166
|
+
height: y1 - y0,
|
|
1167
|
+
collapsed: false,
|
|
1168
|
+
childCount: grp.children.length,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
// Collapsed flat groups: a plain box at the members' centre.
|
|
1172
|
+
for (const cb of collapsedBoxes) {
|
|
1173
|
+
groups.push({
|
|
1174
|
+
label: cb.label,
|
|
1175
|
+
lineNumber: cb.lineNumber,
|
|
1176
|
+
x: cb.x,
|
|
1177
|
+
y: cb.y,
|
|
1178
|
+
width: NODE_WIDTH,
|
|
1179
|
+
height: NODE_HEIGHT,
|
|
1180
|
+
collapsed: true,
|
|
1181
|
+
childCount: cb.childCount,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const M = 40;
|
|
1186
|
+
let minX = Infinity,
|
|
1187
|
+
minY = Infinity,
|
|
1188
|
+
maxX = -Infinity,
|
|
1189
|
+
maxY = -Infinity;
|
|
1190
|
+
const acc = (x: number, y: number) => {
|
|
1191
|
+
if (x < minX) minX = x;
|
|
1192
|
+
if (x > maxX) maxX = x;
|
|
1193
|
+
if (y < minY) minY = y;
|
|
1194
|
+
if (y > maxY) maxY = y;
|
|
1195
|
+
};
|
|
1196
|
+
for (const n of nodes) {
|
|
1197
|
+
acc(n.x - n.width / 2, n.y - n.height / 2);
|
|
1198
|
+
acc(n.x + n.width / 2, n.y + n.height / 2);
|
|
1199
|
+
}
|
|
1200
|
+
for (const e of edges) for (const p of e.points) acc(p.x, p.y);
|
|
1201
|
+
for (const gr of groups) {
|
|
1202
|
+
acc(gr.x - gr.width / 2, gr.y - gr.height / 2);
|
|
1203
|
+
acc(gr.x + gr.width / 2, gr.y + gr.height / 2);
|
|
1204
|
+
}
|
|
1205
|
+
// Only correct genuinely off-canvas content — a small tolerance ignores the
|
|
1206
|
+
// sub-pixel jitter from rounding coords near the margin, so an already
|
|
1207
|
+
// on-canvas diagram keeps its exact pinned positions (no creeping drift).
|
|
1208
|
+
const TOL = 2;
|
|
1209
|
+
const sx = minX < M - TOL ? M - minX : 0;
|
|
1210
|
+
const sy = minY < M - TOL ? M - minY : 0;
|
|
1211
|
+
const shifted = sx !== 0 || sy !== 0;
|
|
1212
|
+
return {
|
|
1213
|
+
nodes: shifted
|
|
1214
|
+
? nodes.map((n) => ({ ...n, x: n.x + sx, y: n.y + sy }))
|
|
1215
|
+
: nodes,
|
|
1216
|
+
edges: shifted
|
|
1217
|
+
? edges.map((e) => ({
|
|
1218
|
+
...e,
|
|
1219
|
+
points: e.points.map((p) => ({ x: p.x + sx, y: p.y + sy })),
|
|
1220
|
+
}))
|
|
1221
|
+
: edges,
|
|
1222
|
+
groups: shifted
|
|
1223
|
+
? groups.map((gr) => ({ ...gr, x: gr.x + sx, y: gr.y + sy }))
|
|
1224
|
+
: groups,
|
|
1225
|
+
width: maxX + sx + M,
|
|
1226
|
+
height: maxY + sy + M,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1019
1230
|
function place(cfg: {
|
|
1020
1231
|
ranker: string;
|
|
1021
1232
|
nodesep: number;
|
|
@@ -1173,6 +1384,9 @@ export function layoutBoxesAndLinesSearch(
|
|
|
1173
1384
|
} as BLLayoutResult;
|
|
1174
1385
|
}
|
|
1175
1386
|
|
|
1387
|
+
// Pinned mode short-circuits the entire search.
|
|
1388
|
+
if (allPinned) return placePinned(pinned!);
|
|
1389
|
+
|
|
1176
1390
|
const n = parsed.nodes.length;
|
|
1177
1391
|
// ~500ms budget: search a larger pool, then refine the top few exactly.
|
|
1178
1392
|
const seedCount =
|
|
@@ -66,6 +66,10 @@ export interface BLLayoutEdge {
|
|
|
66
66
|
/** Marker for renderer: draw with linear curve, not curveBasis (ELK gives
|
|
67
67
|
* us orthogonal polylines and curveBasis would smooth corners into waves) */
|
|
68
68
|
readonly deferred?: boolean;
|
|
69
|
+
/** Pinned-layout connector: a border-clipped straight 2-point segment (Canvas
|
|
70
|
+
* Editor spike, Decision 7). Renderer draws it with a linear generator —
|
|
71
|
+
* curveBasis collapses a 2-point polyline. */
|
|
72
|
+
readonly straight?: boolean;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export interface BLLayoutGroup {
|
|
@@ -127,6 +127,8 @@ export function parseBoxesAndLines(
|
|
|
127
127
|
const nodes: MutBLNode[] = [];
|
|
128
128
|
const edges: MutBLEdge[] = [];
|
|
129
129
|
const groups: MutBLGroup[] = [];
|
|
130
|
+
// Trailing `layout` block (Canvas Editor spike): node-id → absolute {x,y}.
|
|
131
|
+
const nodePositions = new Map<string, { x: number; y: number }>();
|
|
130
132
|
const result: Writable<ParsedBoxesAndLines> = {
|
|
131
133
|
type: 'boxes-and-lines',
|
|
132
134
|
title: null,
|
|
@@ -178,6 +180,11 @@ export function parseBoxesAndLines(
|
|
|
178
180
|
|
|
179
181
|
// Tag block state
|
|
180
182
|
let contentStarted = false;
|
|
183
|
+
// `layout` coordinate-block state (Canvas Editor spike). Unlike tag blocks,
|
|
184
|
+
// this is a TRAILING appendix — it may appear after diagram content.
|
|
185
|
+
let inLayoutBlock = false;
|
|
186
|
+
const LAYOUT_ENTRY_RE =
|
|
187
|
+
/^(.+?):\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/;
|
|
181
188
|
let currentTagGroup: Writable<TagGroup> | null = null;
|
|
182
189
|
// metaAliasMap: tag-group metadata-key aliases (per A1).
|
|
183
190
|
const metaAliasMap = new Map<string, string>();
|
|
@@ -436,6 +443,52 @@ export function parseBoxesAndLines(
|
|
|
436
443
|
currentTagGroup = null;
|
|
437
444
|
}
|
|
438
445
|
|
|
446
|
+
// `layout` coordinate block (Canvas Editor spike). A bare `layout` heading
|
|
447
|
+
// at indent 0 opens the block; indented `<node-id>: <x>, <y>` entries map a
|
|
448
|
+
// node to an absolute position. Any non-indented line closes it. Quarantined
|
|
449
|
+
// before group/node/edge matching so the entries don't parse as nodes.
|
|
450
|
+
if (!inLayoutBlock && indent === 0 && trimmed === 'layout') {
|
|
451
|
+
// Disambiguate from a node legitimately NAMED `layout`: only treat this as
|
|
452
|
+
// the coordinate appendix when the next non-blank line is an indented
|
|
453
|
+
// `<id>: <x>, <y>` entry. Otherwise fall through and parse `layout` as a
|
|
454
|
+
// normal node (no silent data loss).
|
|
455
|
+
let isBlock = false;
|
|
456
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
457
|
+
const peek = lines[j]!;
|
|
458
|
+
if (!peek.trim()) continue;
|
|
459
|
+
isBlock = measureIndent(peek) > 0 && LAYOUT_ENTRY_RE.test(peek.trim());
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
if (isBlock) {
|
|
463
|
+
flushDescription();
|
|
464
|
+
closeGroupsToIndent(0);
|
|
465
|
+
inLayoutBlock = true;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (inLayoutBlock) {
|
|
470
|
+
if (indent > 0) {
|
|
471
|
+
const lm = trimmed.match(LAYOUT_ENTRY_RE);
|
|
472
|
+
if (lm) {
|
|
473
|
+
nodePositions.set(lm[1]!.trim(), {
|
|
474
|
+
x: Number(lm[2]),
|
|
475
|
+
y: Number(lm[3]),
|
|
476
|
+
});
|
|
477
|
+
} else {
|
|
478
|
+
result.diagnostics.push(
|
|
479
|
+
makeDgmoError(
|
|
480
|
+
lineNum,
|
|
481
|
+
`Invalid layout entry "${trimmed}" — expected "<node-id>: <x>, <y>"`,
|
|
482
|
+
'warning'
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
// indent 0 → block ends; fall through to process this line normally.
|
|
489
|
+
inLayoutBlock = false;
|
|
490
|
+
}
|
|
491
|
+
|
|
439
492
|
// Description collection: indented non-edge lines under a node
|
|
440
493
|
if (descState !== null) {
|
|
441
494
|
if (indent > descState.indent) {
|
|
@@ -762,6 +815,31 @@ export function parseBoxesAndLines(
|
|
|
762
815
|
// passed to extractColor above), so auto colors match for consistency.
|
|
763
816
|
finalizeAutoTagColors(result.tagGroups as Writable<TagGroup>[]);
|
|
764
817
|
|
|
818
|
+
// Attach parsed `layout` positions. Validate coverage: unknown ids warn; a
|
|
819
|
+
// PARTIAL block (some nodes unpositioned) is honored by neither pin nor seed —
|
|
820
|
+
// the layout engine ignores it and auto-lays-out (Decision 3, AC12), so emit a
|
|
821
|
+
// single diagnostic naming the gap.
|
|
822
|
+
if (nodePositions.size > 0) {
|
|
823
|
+
const nodeLabelSet = new Set(result.nodes.map((n) => n.label));
|
|
824
|
+
for (const id of nodePositions.keys()) {
|
|
825
|
+
if (!nodeLabelSet.has(id)) {
|
|
826
|
+
pushWarning(0, `layout entry for unknown node "${id}" (ignored)`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
const unpositioned = result.nodes
|
|
830
|
+
.filter((n) => !nodePositions.has(n.label))
|
|
831
|
+
.map((n) => n.label);
|
|
832
|
+
if (unpositioned.length > 0) {
|
|
833
|
+
pushWarning(
|
|
834
|
+
0,
|
|
835
|
+
`layout block is partial — ${unpositioned.length} node(s) without coordinates ` +
|
|
836
|
+
`(${unpositioned.slice(0, 5).join(', ')}${unpositioned.length > 5 ? '…' : ''}); ` +
|
|
837
|
+
`ignoring the block and auto-laying-out`
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
result.nodePositions = nodePositions;
|
|
841
|
+
}
|
|
842
|
+
|
|
765
843
|
// Post-parse: inject default tag metadata and validate tag values
|
|
766
844
|
if (result.tagGroups.length > 0) {
|
|
767
845
|
injectDefaultTagMetadata(result.nodes, result.tagGroups);
|
|
@@ -56,9 +56,13 @@ const DIAGRAM_PADDING = 20;
|
|
|
56
56
|
const NODE_FONT_SIZE = 11;
|
|
57
57
|
const MIN_NODE_FONT_SIZE = 9;
|
|
58
58
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
import {
|
|
60
|
+
EDGE_STROKE_WIDTH,
|
|
61
|
+
NODE_STROKE_WIDTH,
|
|
62
|
+
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
61
63
|
const NODE_RX = 8;
|
|
64
|
+
// Intentional deviation (conventions §3): boxes-and-lines uses a 4px collapse
|
|
65
|
+
// bar (and 4px separator gap in layout.ts) — denser than the 6px default.
|
|
62
66
|
const COLLAPSE_BAR_HEIGHT = 4;
|
|
63
67
|
const ARROWHEAD_W = 5;
|
|
64
68
|
const ARROWHEAD_H = 4;
|
|
@@ -89,6 +93,15 @@ const lineGeneratorTB = d3Shape
|
|
|
89
93
|
.y((d) => d.y)
|
|
90
94
|
.curve(d3Shape.curveBasis);
|
|
91
95
|
|
|
96
|
+
// Straight (linear) generator for pinned-layout connectors (Canvas Editor
|
|
97
|
+
// spike). curveBasis collapses a 2-point polyline, so border-clipped straight
|
|
98
|
+
// edges must draw linearly.
|
|
99
|
+
const lineGeneratorStraight = d3Shape
|
|
100
|
+
.line<{ x: number; y: number }>()
|
|
101
|
+
.x((d) => d.x)
|
|
102
|
+
.y((d) => d.y)
|
|
103
|
+
.curve(d3Shape.curveLinear);
|
|
104
|
+
|
|
92
105
|
// ── Text fitting ───────────────────────────────────────────
|
|
93
106
|
|
|
94
107
|
function splitCamelCase(word: string): string[] {
|
|
@@ -614,8 +627,43 @@ export function renderBoxesAndLines(
|
|
|
614
627
|
const scaleY = height / (contentH + sDiagramPadding * 2);
|
|
615
628
|
const scale = Math.min(scaleX, scaleY, 3);
|
|
616
629
|
|
|
617
|
-
|
|
618
|
-
|
|
630
|
+
// Pinned (`layout`-block) coordinates can leave the content's real extent
|
|
631
|
+
// sitting off-centre inside layout.width/height — e.g. nodes pinned far from
|
|
632
|
+
// the origin bake a wide gap on one side. Re-centre the content within its
|
|
633
|
+
// own box by equalizing opposite margins. Gated to pinned diagrams so the
|
|
634
|
+
// auto-layout path (and its snapshots) stays byte-identical: that path
|
|
635
|
+
// already produces symmetric margins.
|
|
636
|
+
let centerShiftX = 0;
|
|
637
|
+
let centerShiftY = 0;
|
|
638
|
+
if (parsed.nodePositions && parsed.nodePositions.size > 0) {
|
|
639
|
+
let bMinX = Infinity,
|
|
640
|
+
bMinY = Infinity,
|
|
641
|
+
bMaxX = -Infinity,
|
|
642
|
+
bMaxY = -Infinity;
|
|
643
|
+
const accB = (x: number, y: number) => {
|
|
644
|
+
if (x < bMinX) bMinX = x;
|
|
645
|
+
if (x > bMaxX) bMaxX = x;
|
|
646
|
+
if (y < bMinY) bMinY = y;
|
|
647
|
+
if (y > bMaxY) bMaxY = y;
|
|
648
|
+
};
|
|
649
|
+
for (const n of layout.nodes) {
|
|
650
|
+
accB(n.x - n.width / 2, n.y - n.height / 2);
|
|
651
|
+
accB(n.x + n.width / 2, n.y + n.height / 2);
|
|
652
|
+
}
|
|
653
|
+
for (const g of layout.groups) {
|
|
654
|
+
accB(g.x - g.width / 2, g.y - g.height / 2);
|
|
655
|
+
accB(g.x + g.width / 2, g.y + g.height / 2);
|
|
656
|
+
}
|
|
657
|
+
for (const e of layout.edges) for (const p of e.points) accB(p.x, p.y);
|
|
658
|
+
if (Number.isFinite(bMinX)) {
|
|
659
|
+
centerShiftX = (layout.width - bMaxX - bMinX) / 2;
|
|
660
|
+
centerShiftY = (layout.height - bMaxY - bMinY) / 2;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const offsetX = (width - contentW * scale) / 2 + centerShiftX * scale;
|
|
665
|
+
const offsetY =
|
|
666
|
+
sDiagramPadding + titleOffset + legendH + centerShiftY * scale;
|
|
619
667
|
|
|
620
668
|
const svg: D3Svg = d3Selection
|
|
621
669
|
.select(container)
|
|
@@ -896,7 +944,11 @@ export function renderBoxesAndLines(
|
|
|
896
944
|
edgeGroups.set(i, edgeG as unknown as D3G);
|
|
897
945
|
|
|
898
946
|
const markerId = `bl-arrow-${color.replace('#', '')}`;
|
|
899
|
-
const gen =
|
|
947
|
+
const gen = le.straight
|
|
948
|
+
? lineGeneratorStraight
|
|
949
|
+
: parsed.direction === 'TB'
|
|
950
|
+
? lineGeneratorTB
|
|
951
|
+
: lineGeneratorLR;
|
|
900
952
|
const path = edgeG
|
|
901
953
|
.append('path')
|
|
902
954
|
.attr('class', 'bl-edge')
|
|
@@ -42,6 +42,15 @@ export interface ParsedBoxesAndLines {
|
|
|
42
42
|
readonly notes?: readonly DiagramNote[];
|
|
43
43
|
readonly initialHiddenTagValues: ReadonlyMap<string, ReadonlySet<string>>;
|
|
44
44
|
readonly direction: 'LR' | 'TB';
|
|
45
|
+
/** Optional per-node absolute positions, parsed from a trailing `layout`
|
|
46
|
+
* block (`<node-id>: <x>, <y>`). Diagram-space coordinates. When present and
|
|
47
|
+
* covering EVERY node, the layout engine bypasses auto-placement and pins
|
|
48
|
+
* nodes here (see Decision 3 — two clean modes). A partial block is ignored
|
|
49
|
+
* with a diagnostic (AC12). Experimental — Canvas Editor spike. */
|
|
50
|
+
readonly nodePositions?: ReadonlyMap<
|
|
51
|
+
string,
|
|
52
|
+
{ readonly x: number; readonly y: number }
|
|
53
|
+
>;
|
|
45
54
|
/** `box-metric <label> [low] [high]` — names the value-ramp dimension and
|
|
46
55
|
* optionally sets its endpoint colours. One color = high hue over a neutral
|
|
47
56
|
* low; two = explicit `low high`. Mirror of map's `region-metric`. */
|
package/src/c4/renderer.ts
CHANGED
|
@@ -37,16 +37,18 @@ const DESC_FONT_SIZE = 11;
|
|
|
37
37
|
const DESC_LINE_HEIGHT = 16;
|
|
38
38
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
39
39
|
const TECH_FONT_SIZE = 10;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
import {
|
|
41
|
+
EDGE_STROKE_WIDTH,
|
|
42
|
+
NODE_STROKE_WIDTH,
|
|
43
|
+
CARD_RADIUS,
|
|
44
|
+
META_FONT_SIZE,
|
|
45
|
+
META_LINE_HEIGHT,
|
|
46
|
+
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
43
47
|
const CARD_H_PAD = 20;
|
|
44
48
|
const CARD_V_PAD = 14;
|
|
45
49
|
const TYPE_LABEL_HEIGHT = 18;
|
|
46
50
|
const DIVIDER_GAP = 6;
|
|
47
51
|
const NAME_HEIGHT = 20;
|
|
48
|
-
const META_FONT_SIZE = 11;
|
|
49
|
-
const META_LINE_HEIGHT = 16;
|
|
50
52
|
const BOUNDARY_LABEL_FONT_SIZE = 12;
|
|
51
53
|
const BOUNDARY_STROKE_WIDTH = 1.5;
|
|
52
54
|
const BOUNDARY_RADIUS = 8;
|
package/src/chart-types.ts
CHANGED
|
@@ -125,7 +125,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
|
|
|
125
125
|
{
|
|
126
126
|
id: 'map',
|
|
127
127
|
description:
|
|
128
|
-
'Geographic
|
|
128
|
+
'Geographic map: a value or count per country, state, or region (choropleth); points of interest; routes. Use when categories are real-world places.',
|
|
129
129
|
},
|
|
130
130
|
|
|
131
131
|
// ── Tier 3 — Specialized analytical charts ────────────────
|
|
@@ -143,7 +143,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
|
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
id: 'slope',
|
|
146
|
-
description: 'Change between two periods',
|
|
146
|
+
description: 'Change for multiple things between exactly two periods',
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
id: 'sankey',
|
package/src/class/renderer.ts
CHANGED
|
@@ -47,8 +47,10 @@ const MAX_SCALE = 3;
|
|
|
47
47
|
const CLASS_FONT_SIZE = 13;
|
|
48
48
|
const MEMBER_FONT_SIZE = 11;
|
|
49
49
|
const EDGE_LABEL_FONT_SIZE = 11;
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
import {
|
|
51
|
+
EDGE_STROKE_WIDTH,
|
|
52
|
+
NODE_STROKE_WIDTH,
|
|
53
|
+
} from '../utils/visual-conventions'; // shared (Story 111.1)
|
|
52
54
|
const MEMBER_LINE_HEIGHT = 18;
|
|
53
55
|
const COMPARTMENT_PADDING_Y = 8;
|
|
54
56
|
const MEMBER_PADDING_X = 10;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// CLI Banner — colorful ASCII logo for `dgmo`
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Rendered as the header of `dgmo --help` and at the end of the
|
|
6
|
+
// install/init flows. Uses a horizontal true-color (24-bit) gradient
|
|
7
|
+
// sampled from the default `slate` palette (corporate blue → teal →
|
|
8
|
+
// steel cyan) so the wordmark matches the brand.
|
|
9
|
+
//
|
|
10
|
+
// Honors the same guards as `dgmo cat`: color only when stdout is a TTY
|
|
11
|
+
// and NO_COLOR is unset; otherwise prints plain ASCII so pipes/CI stay
|
|
12
|
+
// clean.
|
|
13
|
+
|
|
14
|
+
import { getPalette } from './palettes';
|
|
15
|
+
|
|
16
|
+
// ANSI Shadow letterform for "dgmo" (6 rows) + tagline.
|
|
17
|
+
const ART = [
|
|
18
|
+
'██████╗ ██████╗ ███╗ ███╗ ██████╗ ',
|
|
19
|
+
'██╔══██╗██╔════╝ ████╗ ████║██╔═══██╗',
|
|
20
|
+
'██║ ██║██║ ███╗██╔████╔██║██║ ██║',
|
|
21
|
+
'██║ ██║██║ ██║██║╚██╔╝██║██║ ██║',
|
|
22
|
+
'██████╔╝╚██████╔╝██║ ╚═╝ ██║╚██████╔╝',
|
|
23
|
+
'╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const TAGLINE = 'diagrams as code';
|
|
27
|
+
|
|
28
|
+
// Gradient stops, pulled by name from the live slate palette (light) so the
|
|
29
|
+
// wordmark tracks any palette edit instead of drifting from a hardcoded copy.
|
|
30
|
+
// blue → teal → green → amber: a vivid sweep across the categorical hues.
|
|
31
|
+
const GRADIENT_COLOR_NAMES = ['blue', 'teal', 'green', 'orange'] as const;
|
|
32
|
+
|
|
33
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
34
|
+
const h = hex.replace('#', '');
|
|
35
|
+
return [
|
|
36
|
+
parseInt(h.slice(0, 2), 16),
|
|
37
|
+
parseInt(h.slice(2, 4), 16),
|
|
38
|
+
parseInt(h.slice(4, 6), 16),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STOPS: Array<[number, number, number]> = (() => {
|
|
43
|
+
const colors = getPalette('slate').light.colors;
|
|
44
|
+
return GRADIENT_COLOR_NAMES.map((name) => hexToRgb(colors[name]));
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
function lerp(a: number, b: number, t: number): number {
|
|
48
|
+
return Math.round(a + (b - a) * t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Interpolate the multi-stop gradient at position t ∈ [0, 1]. */
|
|
52
|
+
function gradientAt(t: number): [number, number, number] {
|
|
53
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
54
|
+
const span = STOPS.length - 1;
|
|
55
|
+
const scaled = clamped * span;
|
|
56
|
+
const i = Math.min(span - 1, Math.floor(scaled));
|
|
57
|
+
const local = scaled - i;
|
|
58
|
+
const [r1, g1, b1] = STOPS[i]!;
|
|
59
|
+
const [r2, g2, b2] = STOPS[i + 1]!;
|
|
60
|
+
return [lerp(r1, r2, local), lerp(g1, g2, local), lerp(b1, b2, local)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fg(r: number, g: number, b: number): string {
|
|
64
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const RESET = '\x1b[0m';
|
|
68
|
+
|
|
69
|
+
export interface BannerOptions {
|
|
70
|
+
/** Force-disable color regardless of TTY (default honors stdout TTY + NO_COLOR). */
|
|
71
|
+
color?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the dgmo banner string. Colorized with a horizontal gradient when
|
|
76
|
+
* `color` is true; otherwise returns plain ASCII.
|
|
77
|
+
*/
|
|
78
|
+
export function renderBanner(opts: BannerOptions = {}): string {
|
|
79
|
+
const useColor =
|
|
80
|
+
opts.color ?? (process.stdout.isTTY === true && !process.env['NO_COLOR']);
|
|
81
|
+
|
|
82
|
+
const width = Math.max(...ART.map((line) => line.length));
|
|
83
|
+
|
|
84
|
+
const lines = ART.map((line) => {
|
|
85
|
+
if (!useColor) return line;
|
|
86
|
+
let out = '';
|
|
87
|
+
for (let col = 0; col < line.length; col++) {
|
|
88
|
+
const ch = line[col];
|
|
89
|
+
// Don't paint spaces — keeps escape sequences minimal.
|
|
90
|
+
if (ch === ' ') {
|
|
91
|
+
out += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const [r, g, b] = gradientAt(col / (width - 1));
|
|
95
|
+
out += fg(r, g, b) + ch;
|
|
96
|
+
}
|
|
97
|
+
return out + RESET;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Right-align the tagline under the wordmark, muted.
|
|
101
|
+
const pad = Math.max(0, width - TAGLINE.length);
|
|
102
|
+
const tagline = useColor
|
|
103
|
+
? `${' '.repeat(pad)}\x1b[2;3m${TAGLINE}${RESET}`
|
|
104
|
+
: `${' '.repeat(pad)}${TAGLINE}`;
|
|
105
|
+
|
|
106
|
+
return ['', ...lines, tagline, ''].join('\n');
|
|
107
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
migrateFile,
|
|
26
26
|
} from './migrate';
|
|
27
27
|
import { migrateEmbedded } from './migrate/embedded';
|
|
28
|
+
import { renderBanner } from './cli-banner';
|
|
28
29
|
|
|
29
30
|
// Derived from the palette registry so new palettes are auto-included.
|
|
30
31
|
const PALETTES = getAvailablePalettes().map((p) => p.id);
|
|
@@ -464,6 +465,7 @@ For architecture diagrams, sequence diagrams, flowcharts, and charts, use the \`
|
|
|
464
465
|
`;
|
|
465
466
|
|
|
466
467
|
function printHelp(): void {
|
|
468
|
+
console.log(renderBanner());
|
|
467
469
|
console.log(`Usage: dgmo <input> [options]
|
|
468
470
|
cat input.dgmo | dgmo [options]
|
|
469
471
|
dgmo cat <file> Display file with syntax highlighting
|
|
@@ -648,10 +650,12 @@ function svgToPng(svg: string, background?: string): Buffer {
|
|
|
648
650
|
}
|
|
649
651
|
|
|
650
652
|
function noInput(): never {
|
|
653
|
+
console.error(renderBanner());
|
|
651
654
|
const samplePath = resolve('sample.dgmo');
|
|
652
655
|
if (existsSync(samplePath)) {
|
|
653
656
|
console.error('Error: No input file specified');
|
|
654
657
|
console.error(`Try: dgmo ${basename(samplePath)}`);
|
|
658
|
+
console.error('Run dgmo --help for all options.');
|
|
655
659
|
process.exit(1);
|
|
656
660
|
}
|
|
657
661
|
writeFileSync(
|
|
@@ -925,6 +929,15 @@ async function main(): Promise<void> {
|
|
|
925
929
|
return;
|
|
926
930
|
}
|
|
927
931
|
|
|
932
|
+
if (
|
|
933
|
+
opts.installClaudeCodeIntegration ||
|
|
934
|
+
opts.installClaudeSkill ||
|
|
935
|
+
opts.installCodexIntegration ||
|
|
936
|
+
opts.installClaudeDesktopIntegration
|
|
937
|
+
) {
|
|
938
|
+
console.log(renderBanner());
|
|
939
|
+
}
|
|
940
|
+
|
|
928
941
|
if (opts.installClaudeCodeIntegration) {
|
|
929
942
|
const claudeDir = join(homedir(), '.claude');
|
|
930
943
|
if (!existsSync(claudeDir)) {
|