@diagrammo/dgmo 0.4.2 → 0.4.4

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 (60) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +28 -0
  2. package/.claude/skills/dgmo-generate/SKILL.md +1 -0
  3. package/.claude/skills/dgmo-sequence/SKILL.md +24 -1
  4. package/.cursorrules +27 -2
  5. package/.github/copilot-instructions.md +36 -3
  6. package/.windsurfrules +27 -2
  7. package/README.md +12 -3
  8. package/dist/cli.cjs +197 -154
  9. package/dist/index.cjs +8647 -3447
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +503 -58
  12. package/dist/index.d.ts +503 -58
  13. package/dist/index.js +8379 -3200
  14. package/dist/index.js.map +1 -1
  15. package/docs/ai-integration.md +1 -1
  16. package/docs/language-reference.md +336 -17
  17. package/docs/migration-sequence-color-to-tags.md +98 -0
  18. package/package.json +1 -1
  19. package/src/c4/renderer.ts +1 -20
  20. package/src/class/renderer.ts +1 -11
  21. package/src/cli.ts +40 -0
  22. package/src/d3.ts +92 -2
  23. package/src/dgmo-router.ts +11 -0
  24. package/src/echarts.ts +74 -8
  25. package/src/er/parser.ts +29 -3
  26. package/src/er/renderer.ts +1 -15
  27. package/src/graph/flowchart-parser.ts +7 -30
  28. package/src/graph/flowchart-renderer.ts +62 -69
  29. package/src/graph/layout.ts +5 -0
  30. package/src/graph/state-parser.ts +388 -0
  31. package/src/graph/state-renderer.ts +496 -0
  32. package/src/graph/types.ts +4 -2
  33. package/src/index.ts +42 -1
  34. package/src/infra/compute.ts +1113 -0
  35. package/src/infra/layout.ts +578 -0
  36. package/src/infra/parser.ts +559 -0
  37. package/src/infra/renderer.ts +1553 -0
  38. package/src/infra/roles.ts +60 -0
  39. package/src/infra/serialize.ts +67 -0
  40. package/src/infra/types.ts +221 -0
  41. package/src/infra/validation.ts +192 -0
  42. package/src/initiative-status/layout.ts +56 -61
  43. package/src/initiative-status/renderer.ts +13 -13
  44. package/src/kanban/renderer.ts +1 -24
  45. package/src/org/layout.ts +28 -37
  46. package/src/org/parser.ts +16 -1
  47. package/src/org/renderer.ts +159 -121
  48. package/src/org/resolver.ts +90 -23
  49. package/src/palettes/color-utils.ts +30 -0
  50. package/src/render.ts +2 -0
  51. package/src/sequence/parser.ts +202 -42
  52. package/src/sequence/renderer.ts +576 -113
  53. package/src/sequence/tag-resolution.ts +163 -0
  54. package/src/sharing.ts +8 -0
  55. package/src/sitemap/collapse.ts +187 -0
  56. package/src/sitemap/layout.ts +738 -0
  57. package/src/sitemap/parser.ts +489 -0
  58. package/src/sitemap/renderer.ts +774 -0
  59. package/src/sitemap/types.ts +42 -0
  60. package/src/utils/tag-groups.ts +119 -0
@@ -1,9 +1,13 @@
1
1
  // ============================================================
2
- // Initiative Status Diagram — Layout (dagre-based)
2
+ // Initiative Status Diagram — Layout
3
+ //
4
+ // Uses dagre for rank assignment, node ordering, and edge
5
+ // routing. Edge waypoints are taken directly from dagre's
6
+ // output without modification.
3
7
  // ============================================================
4
8
 
5
9
  import dagre from '@dagrejs/dagre';
6
- import type { ParsedInitiativeStatus, ISEdge, InitiativeStatus } from './types';
10
+ import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
7
11
 
8
12
  export interface ISLayoutNode {
9
13
  label: string;
@@ -43,8 +47,6 @@ export interface ISLayoutResult {
43
47
  height: number;
44
48
  }
45
49
 
46
- // Roll up child statuses: worst (least-progressed) wins
47
- // Priority: todo > wip > done > na > null
48
50
  const STATUS_PRIORITY: Record<string, number> = { todo: 3, wip: 2, done: 1, na: 0 };
49
51
 
50
52
  function rollUpStatus(members: ISLayoutNode[]): InitiativeStatus {
@@ -60,60 +62,48 @@ function rollUpStatus(members: ISLayoutNode[]): InitiativeStatus {
60
62
  return worst;
61
63
  }
62
64
 
63
- // Golden ratio fixed-size nodes — all boxes are identical dimensions
64
65
  const PHI = 1.618;
65
66
  const NODE_HEIGHT = 60;
66
- const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ~97
67
+ const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI);
67
68
  const GROUP_PADDING = 20;
69
+ const NODESEP = 80;
70
+ const RANKSEP = 160;
71
+
72
+ // ============================================================
73
+ // Main layout function
74
+ // ============================================================
68
75
 
69
76
  export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayoutResult {
70
77
  if (parsed.nodes.length === 0) {
71
78
  return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
72
79
  }
73
80
 
81
+ // Build and run dagre graph
74
82
  const hasGroups = parsed.groups.length > 0;
75
83
  const g = new dagre.graphlib.Graph({ multigraph: true, compound: hasGroups });
76
- g.setGraph({
77
- rankdir: 'LR',
78
- nodesep: 80,
79
- ranksep: 160,
80
- edgesep: 40,
81
- });
84
+ g.setGraph({ rankdir: 'LR', nodesep: NODESEP, ranksep: RANKSEP });
82
85
  g.setDefaultEdgeLabel(() => ({}));
83
86
 
84
- // Add group parent nodes
85
87
  for (const group of parsed.groups) {
86
- const groupId = `__group_${group.label}`;
87
- g.setNode(groupId, { label: group.label, clusterLabelPos: 'top' });
88
+ g.setNode(`__group_${group.label}`, { label: group.label, clusterLabelPos: 'top' });
88
89
  }
89
-
90
- // Add nodes — all same size (golden ratio)
91
90
  for (const node of parsed.nodes) {
92
91
  g.setNode(node.label, { label: node.label, width: NODE_WIDTH, height: NODE_HEIGHT });
93
92
  }
94
-
95
- // Assign children to group parents
96
93
  for (const group of parsed.groups) {
97
94
  const groupId = `__group_${group.label}`;
98
95
  for (const nodeLabel of group.nodeLabels) {
99
- if (g.hasNode(nodeLabel)) {
100
- g.setParent(nodeLabel, groupId);
101
- }
96
+ if (g.hasNode(nodeLabel)) g.setParent(nodeLabel, groupId);
102
97
  }
103
98
  }
104
-
105
- // Add edges — use multigraph names to allow duplicates between same pair
106
99
  for (let i = 0; i < parsed.edges.length; i++) {
107
100
  const edge = parsed.edges[i];
108
101
  g.setEdge(edge.source, edge.target, { label: edge.label ?? '' }, `e${i}`);
109
102
  }
110
103
 
111
- // Compute layout
112
104
  dagre.layout(g);
113
105
 
114
- // Extract positioned nodes — dagre owns within-rank ordering
115
- // (crossing minimization). We don't reorder post-layout because
116
- // that would desync edge waypoints from node positions.
106
+ // Extract node positions
117
107
  const layoutNodes: ISLayoutNode[] = parsed.nodes.map((node) => {
118
108
  const pos = g.node(node.label);
119
109
  return {
@@ -128,35 +118,51 @@ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayout
128
118
  };
129
119
  });
130
120
 
131
- // Extract edge waypoints
121
+ const nodeMap = new Map(layoutNodes.map((n) => [n.label, n]));
122
+ const allNodeX = layoutNodes.map((n) => n.x);
123
+
124
+ // Adjacent-rank edges: 4-point elbow (perpendicular exit/entry, no crossings).
125
+ // Multi-rank edges: dagre's interior waypoints for obstacle avoidance, with
126
+ // first/last points pinned to exact node boundaries at node-center Y.
132
127
  const layoutEdges: ISLayoutEdge[] = parsed.edges.map((edge, i) => {
133
- const edgeData = g.edge(edge.source, edge.target, `e${i}`);
134
- return {
135
- source: edge.source,
136
- target: edge.target,
137
- label: edge.label,
138
- status: edge.status,
139
- lineNumber: edge.lineNumber,
140
- points: edgeData?.points ?? [],
141
- };
128
+ const src = nodeMap.get(edge.source)!;
129
+ const tgt = nodeMap.get(edge.target)!;
130
+ const exitX = src.x + src.width / 2;
131
+ const enterX = tgt.x - tgt.width / 2;
132
+ const dagreEdge = g.edge(edge.source, edge.target, `e${i}`);
133
+ const dagrePoints: { x: number; y: number }[] = dagreEdge?.points ?? [];
134
+ const hasIntermediateRank = allNodeX.some((x) => x > src.x + 20 && x < tgt.x - 20);
135
+ const step = Math.min((enterX - exitX) * 0.15, 20);
136
+
137
+ const fixedDagrePoints = dagrePoints.length >= 2 ? [
138
+ { x: exitX, y: src.y },
139
+ ...dagrePoints.slice(1, -1),
140
+ { x: enterX, y: tgt.y },
141
+ ] : dagrePoints;
142
+
143
+ const points = (tgt.x > src.x && !hasIntermediateRank)
144
+ ? [
145
+ { x: exitX, y: src.y },
146
+ { x: exitX + step, y: src.y },
147
+ { x: enterX - step, y: tgt.y },
148
+ { x: enterX, y: tgt.y },
149
+ ]
150
+ : fixedDagrePoints;
151
+ return { source: edge.source, target: edge.target, label: edge.label,
152
+ status: edge.status, lineNumber: edge.lineNumber, points };
142
153
  });
143
154
 
144
- // Compute group bounding boxes from member positions
155
+ // Compute group bounding boxes
145
156
  const layoutGroups: ISLayoutGroup[] = [];
146
157
  if (parsed.groups.length > 0) {
147
- const nodeMap = new Map(layoutNodes.map((n) => [n.label, n]));
158
+ const nMap = new Map(layoutNodes.map((n) => [n.label, n]));
148
159
  for (const group of parsed.groups) {
149
160
  const members = group.nodeLabels
150
- .map((label) => nodeMap.get(label))
161
+ .map((label) => nMap.get(label))
151
162
  .filter((n): n is ISLayoutNode => n !== undefined);
152
-
153
163
  if (members.length === 0) continue;
154
164
 
155
- let minX = Infinity;
156
- let minY = Infinity;
157
- let maxX = -Infinity;
158
- let maxY = -Infinity;
159
-
165
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
160
166
  for (const member of members) {
161
167
  const left = member.x - member.width / 2;
162
168
  const right = member.x + member.width / 2;
@@ -189,29 +195,18 @@ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayout
189
195
  if (right > totalWidth) totalWidth = right;
190
196
  if (bottom > totalHeight) totalHeight = bottom;
191
197
  }
192
- // Also consider group bounds
193
198
  for (const group of layoutGroups) {
194
- const right = group.x + group.width;
195
- const bottom = group.y + group.height;
196
- if (right > totalWidth) totalWidth = right;
197
- if (bottom > totalHeight) totalHeight = bottom;
199
+ if (group.x + group.width > totalWidth) totalWidth = group.x + group.width;
200
+ if (group.y + group.height > totalHeight) totalHeight = group.y + group.height;
198
201
  }
199
- // Also consider edge control points
200
202
  for (const edge of layoutEdges) {
201
203
  for (const pt of edge.points) {
202
204
  if (pt.x > totalWidth) totalWidth = pt.x;
203
205
  if (pt.y > totalHeight) totalHeight = pt.y;
204
206
  }
205
207
  }
206
- // Add margin
207
208
  totalWidth += 40;
208
209
  totalHeight += 40;
209
210
 
210
- return {
211
- nodes: layoutNodes,
212
- edges: layoutEdges,
213
- groups: layoutGroups,
214
- width: totalWidth,
215
- height: totalHeight,
216
- };
211
+ return { nodes: layoutNodes, edges: layoutEdges, groups: layoutGroups, width: totalWidth, height: totalHeight };
217
212
  }
@@ -5,7 +5,7 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import * as d3Shape from 'd3-shape';
7
7
  import { FONT_FAMILY } from '../fonts';
8
- import { contrastText } from '../palettes/color-utils';
8
+ import { contrastText, mix } from '../palettes/color-utils';
9
9
  import type { PaletteColors } from '../palettes';
10
10
  import type { ParsedInitiativeStatus, InitiativeStatus } from './types';
11
11
  import type { ParticipantType } from '../sequence/parser';
@@ -37,17 +37,6 @@ const GROUP_LABEL_FONT_SIZE = 11;
37
37
  // Color helpers
38
38
  // ============================================================
39
39
 
40
- function mix(a: string, b: string, pct: number): string {
41
- const parse = (h: string) => {
42
- const r = h.replace('#', '');
43
- const f = r.length === 3 ? r[0]+r[0]+r[1]+r[1]+r[2]+r[2] : r;
44
- return [parseInt(f.substring(0,2),16), parseInt(f.substring(2,4),16), parseInt(f.substring(4,6),16)];
45
- };
46
- const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b), t = pct/100;
47
- const c = (x: number, y: number) => Math.round(x*t + y*(1-t)).toString(16).padStart(2,'0');
48
- return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
49
- }
50
-
51
40
  function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
52
41
  switch (status) {
53
42
  case 'done': return palette.colors.green;
@@ -80,10 +69,13 @@ function edgeStrokeColor(status: InitiativeStatus, palette: PaletteColors, isDar
80
69
  // Edge path generator
81
70
  // ============================================================
82
71
 
72
+ // curveMonotoneX: interpolates through all control points and guarantees no
73
+ // Y-overshoot between consecutive points. Works for both our 4-point elbows
74
+ // (adjacent-rank) and dagre's fixed waypoints (multi-rank).
83
75
  const lineGenerator = d3Shape.line<{ x: number; y: number }>()
84
76
  .x((d) => d.x)
85
77
  .y((d) => d.y)
86
- .curve(d3Shape.curveBasis);
78
+ .curve(d3Shape.curveMonotoneX);
87
79
 
88
80
  // ============================================================
89
81
  // Text fitting — wrap or shrink to fit fixed-size nodes
@@ -672,6 +664,14 @@ export function renderInitiativeStatus(
672
664
 
673
665
  const pathD = lineGenerator(edge.points);
674
666
  if (pathD) {
667
+ // Transparent wide hit area behind the visible edge
668
+ edgeG
669
+ .append('path')
670
+ .attr('d', pathD)
671
+ .attr('fill', 'none')
672
+ .attr('stroke', 'transparent')
673
+ .attr('stroke-width', 16);
674
+
675
675
  edgeG
676
676
  .append('path')
677
677
  .attr('d', pathD)
@@ -5,6 +5,7 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import { FONT_FAMILY } from '../fonts';
7
7
  import type { PaletteColors } from '../palettes';
8
+ import { mix } from '../palettes/color-utils';
8
9
  import { renderInlineText } from '../utils/inline-markdown';
9
10
  import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
10
11
  import { parseKanban } from './parser';
@@ -40,30 +41,6 @@ const LEGEND_FONT_SIZE = 11;
40
41
  const LEGEND_DOT_R = 4;
41
42
  const LEGEND_ENTRY_FONT_SIZE = 10;
42
43
 
43
- // ============================================================
44
- // Color helpers
45
- // ============================================================
46
-
47
- function mix(a: string, b: string, pct: number): string {
48
- const parse = (h: string) => {
49
- const r = h.replace('#', '');
50
- const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
51
- return [
52
- parseInt(f.substring(0, 2), 16),
53
- parseInt(f.substring(2, 4), 16),
54
- parseInt(f.substring(4, 6), 16),
55
- ];
56
- };
57
- const [ar, ag, ab] = parse(a);
58
- const [br, bg, bb] = parse(b);
59
- const t = pct / 100;
60
- const c = (x: number, y: number) =>
61
- Math.round(x * t + y * (1 - t))
62
- .toString(16)
63
- .padStart(2, '0');
64
- return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
65
- }
66
-
67
44
  // ============================================================
68
45
  // Tag color resolution
69
46
  // ============================================================
package/src/org/layout.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { hierarchy, tree } from 'd3-hierarchy';
6
6
  import type { ParsedOrg, OrgNode, OrgTagGroup } from './parser';
7
+ import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
7
8
 
8
9
  // ============================================================
9
10
  // Types
@@ -169,20 +170,9 @@ function resolveNodeColor(
169
170
  tagGroups: OrgTagGroup[],
170
171
  activeGroupName: string | null
171
172
  ): string | undefined {
173
+ // Explicit inline (color) always wins — handled before tag resolution
172
174
  if (node.color) return node.color;
173
- if (!activeGroupName) return undefined;
174
-
175
- const group = tagGroups.find(
176
- (g) => g.name.toLowerCase() === activeGroupName.toLowerCase()
177
- );
178
- if (!group) return undefined;
179
- const metaValue =
180
- node.metadata[group.name.toLowerCase()] ??
181
- (node.isContainer ? undefined : group.defaultValue);
182
- if (!metaValue) return '#999999';
183
- return group.entries.find(
184
- (e) => e.value.toLowerCase() === metaValue.toLowerCase()
185
- )?.color ?? '#999999';
175
+ return resolveTagColor(node.metadata, tagGroups, activeGroupName, node.isContainer);
186
176
  }
187
177
 
188
178
  // ============================================================
@@ -325,38 +315,33 @@ function computeLegendGroups(
325
315
 
326
316
  /**
327
317
  * Inject default tag group values into non-container node metadata.
328
- * Idempotent only sets keys not already present.
318
+ * Delegates to shared `injectDefaultTagMetadata` with org-specific skip logic.
329
319
  */
330
320
  function injectDefaultMetadata(
331
321
  roots: OrgNode[],
332
322
  tagGroups: OrgTagGroup[]
333
323
  ): void {
334
- const defaults: { key: string; value: string }[] = [];
335
- for (const group of tagGroups) {
336
- if (group.defaultValue) {
337
- defaults.push({ key: group.name.toLowerCase(), value: group.defaultValue });
338
- }
339
- }
340
- if (defaults.length === 0) return;
341
-
342
- const walk = (node: OrgNode) => {
343
- if (!node.isContainer) {
344
- for (const { key, value } of defaults) {
345
- if (!(key in node.metadata)) {
346
- node.metadata[key] = value;
347
- }
348
- }
349
- }
350
- for (const child of node.children) walk(child);
324
+ // Flatten all nodes (recursive) for the shared utility
325
+ const allNodes: OrgNode[] = [];
326
+ const collect = (node: OrgNode) => {
327
+ allNodes.push(node);
328
+ for (const child of node.children) collect(child);
351
329
  };
352
- for (const root of roots) walk(root);
330
+ for (const root of roots) collect(root);
331
+
332
+ injectDefaultTagMetadata(
333
+ allNodes,
334
+ tagGroups,
335
+ (entity) => (entity as OrgNode).isContainer
336
+ );
353
337
  }
354
338
 
355
339
  export function layoutOrg(
356
340
  parsed: ParsedOrg,
357
341
  hiddenCounts?: Map<string, number>,
358
342
  activeTagGroup?: string | null,
359
- hiddenAttributes?: Set<string>
343
+ hiddenAttributes?: Set<string>,
344
+ expandAllLegend?: boolean
360
345
  ): OrgLayoutResult {
361
346
  if (parsed.roots.length === 0) {
362
347
  // Legend-only: compute and position legend groups even without nodes
@@ -510,9 +495,13 @@ export function layoutOrg(
510
495
  (d) => d.data.orgNode.id !== '__virtual_root__'
511
496
  );
512
497
 
513
- // Collect max actual card height per depth level
498
+ // Collect max actual card height per depth level.
499
+ // Exclude __stack_ placeholders — their aggregate height (multiple
500
+ // stacked cards) would inflate the level max and push sibling
501
+ // subtrees' deeper children far below where they need to be.
514
502
  const levelMaxHeight = new Map<number, number>();
515
503
  for (const d of descendants) {
504
+ if (d.data.orgNode.id.startsWith('__stack_')) continue;
516
505
  const cur = levelMaxHeight.get(d.depth) ?? 0;
517
506
  if (d.data.height > cur) levelMaxHeight.set(d.depth, d.data.height);
518
507
  }
@@ -1128,14 +1117,16 @@ export function layoutOrg(
1128
1117
  const legendPosition = parsed.options?.['legend-position'] ?? 'top';
1129
1118
 
1130
1119
  // When a tag group is active, only that group is laid out (full size).
1131
- // When none is active, all groups are laid out minified.
1120
+ // When none is active, all groups are laid out minified — unless
1121
+ // expandAllLegend is set (export mode), which shows all groups expanded.
1132
1122
  const visibleGroups = activeTagGroup != null
1133
1123
  ? legendGroups.filter((g) => g.name.toLowerCase() === activeTagGroup.toLowerCase())
1134
1124
  : legendGroups;
1125
+ const allExpanded = expandAllLegend && activeTagGroup == null;
1135
1126
  const effectiveW = (g: OrgLegendGroup) =>
1136
- activeTagGroup != null ? g.width : g.minifiedWidth;
1127
+ activeTagGroup != null || allExpanded ? g.width : g.minifiedWidth;
1137
1128
  const effectiveH = (g: OrgLegendGroup) =>
1138
- activeTagGroup != null ? g.height : g.minifiedHeight;
1129
+ activeTagGroup != null || allExpanded ? g.height : g.minifiedHeight;
1139
1130
 
1140
1131
  if (visibleGroups.length > 0) {
1141
1132
  if (legendPosition === 'bottom') {
package/src/org/parser.ts CHANGED
@@ -2,7 +2,7 @@ import type { PaletteColors } from '../palettes';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
4
  import type { TagGroup, TagEntry } from '../utils/tag-groups';
5
- import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
5
+ import { isTagBlockHeading, matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
6
6
  import {
7
7
  measureIndent,
8
8
  extractColor,
@@ -292,6 +292,21 @@ export function parseOrg(
292
292
  }
293
293
  }
294
294
 
295
+ // Validate tag group values on nodes
296
+ if (result.tagGroups.length > 0) {
297
+ // Flatten all nodes for the shared validation utility
298
+ const allNodes: OrgNode[] = [];
299
+ const collectAll = (nodes: OrgNode[]) => {
300
+ for (const node of nodes) {
301
+ allNodes.push(node);
302
+ collectAll(node.children);
303
+ }
304
+ };
305
+ collectAll(result.roots);
306
+
307
+ validateTagValues(allNodes, result.tagGroups, pushWarning, suggest);
308
+ }
309
+
295
310
  if (result.roots.length === 0 && result.tagGroups.length === 0 && !result.error) {
296
311
  const diag = makeDgmoError(1, 'No nodes found in org chart');
297
312
  result.diagnostics.push(diag);