@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.
Files changed (41) hide show
  1. package/README.md +21 -3
  2. package/dist/advanced.cjs +560 -269
  3. package/dist/advanced.d.cts +27 -2
  4. package/dist/advanced.d.ts +27 -2
  5. package/dist/advanced.js +559 -269
  6. package/dist/auto.cjs +558 -270
  7. package/dist/auto.js +93 -93
  8. package/dist/auto.mjs +558 -270
  9. package/dist/cli.cjs +144 -143
  10. package/dist/index.cjs +557 -269
  11. package/dist/index.js +557 -269
  12. package/package.json +1 -1
  13. package/src/advanced.ts +3 -0
  14. package/src/boxes-and-lines/layout-search.ts +214 -0
  15. package/src/boxes-and-lines/layout.ts +4 -0
  16. package/src/boxes-and-lines/parser.ts +78 -0
  17. package/src/boxes-and-lines/renderer.ts +57 -5
  18. package/src/boxes-and-lines/types.ts +9 -0
  19. package/src/c4/renderer.ts +7 -5
  20. package/src/chart-types.ts +2 -2
  21. package/src/class/renderer.ts +4 -2
  22. package/src/cli-banner.ts +107 -0
  23. package/src/cli.ts +13 -0
  24. package/src/colors.ts +22 -0
  25. package/src/er/renderer.ts +4 -2
  26. package/src/graph/flowchart-renderer.ts +4 -2
  27. package/src/graph/state-renderer.ts +4 -2
  28. package/src/infra/renderer.ts +8 -4
  29. package/src/journey-map/parser.ts +15 -1
  30. package/src/journey-map/renderer.ts +1 -1
  31. package/src/kanban/renderer.ts +1 -1
  32. package/src/map/renderer.ts +27 -14
  33. package/src/mindmap/renderer.ts +5 -3
  34. package/src/org/renderer.ts +67 -120
  35. package/src/palettes/color-utils.ts +7 -2
  36. package/src/pert/renderer.ts +13 -8
  37. package/src/raci/renderer.ts +1 -1
  38. package/src/sitemap/renderer.ts +35 -37
  39. package/src/utils/card.ts +183 -0
  40. package/src/utils/tag-groups.ts +10 -10
  41. package/src/utils/visual-conventions.ts +61 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- const EDGE_STROKE_WIDTH = 1.5;
60
- const NODE_STROKE_WIDTH = 1.5;
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
- const offsetX = (width - contentW * scale) / 2;
618
- const offsetY = sDiagramPadding + titleOffset + legendH;
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 = parsed.direction === 'TB' ? lineGeneratorTB : lineGeneratorLR;
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`. */
@@ -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
- const EDGE_STROKE_WIDTH = 1.5;
41
- const NODE_STROKE_WIDTH = 1.5;
42
- const CARD_RADIUS = 6;
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;
@@ -125,7 +125,7 @@ export const chartTypes: readonly ChartTypeMeta[] = [
125
125
  {
126
126
  id: 'map',
127
127
  description:
128
- 'Geographic concept map: highlight/score regions, drop points of interest, connect with routes or edges',
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',
@@ -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
- const EDGE_STROKE_WIDTH = 1.5;
51
- const NODE_STROKE_WIDTH = 1.5;
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)) {