@diagrammo/dgmo 0.2.21 → 0.2.23

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.
@@ -0,0 +1,217 @@
1
+ // ============================================================
2
+ // Initiative Status Diagram — Layout (dagre-based)
3
+ // ============================================================
4
+
5
+ import dagre from '@dagrejs/dagre';
6
+ import type { ParsedInitiativeStatus, ISEdge, InitiativeStatus } from './types';
7
+
8
+ export interface ISLayoutNode {
9
+ label: string;
10
+ status: import('./types').InitiativeStatus;
11
+ shape: import('../sequence/parser').ParticipantType;
12
+ lineNumber: number;
13
+ x: number;
14
+ y: number;
15
+ width: number;
16
+ height: number;
17
+ }
18
+
19
+ export interface ISLayoutEdge {
20
+ source: string;
21
+ target: string;
22
+ label?: string;
23
+ status: import('./types').InitiativeStatus;
24
+ lineNumber: number;
25
+ points: { x: number; y: number }[];
26
+ }
27
+
28
+ export interface ISLayoutGroup {
29
+ label: string;
30
+ status: InitiativeStatus;
31
+ x: number;
32
+ y: number;
33
+ width: number;
34
+ height: number;
35
+ lineNumber: number;
36
+ }
37
+
38
+ export interface ISLayoutResult {
39
+ nodes: ISLayoutNode[];
40
+ edges: ISLayoutEdge[];
41
+ groups: ISLayoutGroup[];
42
+ width: number;
43
+ height: number;
44
+ }
45
+
46
+ // Roll up child statuses: worst (least-progressed) wins
47
+ // Priority: todo > wip > done > na > null
48
+ const STATUS_PRIORITY: Record<string, number> = { todo: 3, wip: 2, done: 1, na: 0 };
49
+
50
+ function rollUpStatus(members: ISLayoutNode[]): InitiativeStatus {
51
+ let worst: InitiativeStatus = null;
52
+ let worstPri = -1;
53
+ for (const m of members) {
54
+ const pri = m.status ? (STATUS_PRIORITY[m.status] ?? -1) : -1;
55
+ if (pri > worstPri) {
56
+ worstPri = pri;
57
+ worst = m.status;
58
+ }
59
+ }
60
+ return worst;
61
+ }
62
+
63
+ // Golden ratio fixed-size nodes — all boxes are identical dimensions
64
+ const PHI = 1.618;
65
+ const NODE_HEIGHT = 60;
66
+ const NODE_WIDTH = Math.round(NODE_HEIGHT * PHI); // ~97
67
+ const GROUP_PADDING = 20;
68
+
69
+ export function layoutInitiativeStatus(parsed: ParsedInitiativeStatus): ISLayoutResult {
70
+ if (parsed.nodes.length === 0) {
71
+ return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
72
+ }
73
+
74
+ const hasGroups = parsed.groups.length > 0;
75
+ 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
+ });
82
+ g.setDefaultEdgeLabel(() => ({}));
83
+
84
+ // Add group parent nodes
85
+ for (const group of parsed.groups) {
86
+ const groupId = `__group_${group.label}`;
87
+ g.setNode(groupId, { label: group.label, clusterLabelPos: 'top' });
88
+ }
89
+
90
+ // Add nodes — all same size (golden ratio)
91
+ for (const node of parsed.nodes) {
92
+ g.setNode(node.label, { label: node.label, width: NODE_WIDTH, height: NODE_HEIGHT });
93
+ }
94
+
95
+ // Assign children to group parents
96
+ for (const group of parsed.groups) {
97
+ const groupId = `__group_${group.label}`;
98
+ for (const nodeLabel of group.nodeLabels) {
99
+ if (g.hasNode(nodeLabel)) {
100
+ g.setParent(nodeLabel, groupId);
101
+ }
102
+ }
103
+ }
104
+
105
+ // Add edges — use multigraph names to allow duplicates between same pair
106
+ for (let i = 0; i < parsed.edges.length; i++) {
107
+ const edge = parsed.edges[i];
108
+ g.setEdge(edge.source, edge.target, { label: edge.label ?? '' }, `e${i}`);
109
+ }
110
+
111
+ // Compute layout
112
+ dagre.layout(g);
113
+
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.
117
+ const layoutNodes: ISLayoutNode[] = parsed.nodes.map((node) => {
118
+ const pos = g.node(node.label);
119
+ return {
120
+ label: node.label,
121
+ status: node.status,
122
+ shape: node.shape,
123
+ lineNumber: node.lineNumber,
124
+ x: pos.x,
125
+ y: pos.y,
126
+ width: pos.width,
127
+ height: pos.height,
128
+ };
129
+ });
130
+
131
+ // Extract edge waypoints
132
+ 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
+ };
142
+ });
143
+
144
+ // Compute group bounding boxes from member positions
145
+ const layoutGroups: ISLayoutGroup[] = [];
146
+ if (parsed.groups.length > 0) {
147
+ const nodeMap = new Map(layoutNodes.map((n) => [n.label, n]));
148
+ for (const group of parsed.groups) {
149
+ const members = group.nodeLabels
150
+ .map((label) => nodeMap.get(label))
151
+ .filter((n): n is ISLayoutNode => n !== undefined);
152
+
153
+ if (members.length === 0) continue;
154
+
155
+ let minX = Infinity;
156
+ let minY = Infinity;
157
+ let maxX = -Infinity;
158
+ let maxY = -Infinity;
159
+
160
+ for (const member of members) {
161
+ const left = member.x - member.width / 2;
162
+ const right = member.x + member.width / 2;
163
+ const top = member.y - member.height / 2;
164
+ const bottom = member.y + member.height / 2;
165
+ if (left < minX) minX = left;
166
+ if (right > maxX) maxX = right;
167
+ if (top < minY) minY = top;
168
+ if (bottom > maxY) maxY = bottom;
169
+ }
170
+
171
+ layoutGroups.push({
172
+ label: group.label,
173
+ status: rollUpStatus(members),
174
+ x: minX - GROUP_PADDING,
175
+ y: minY - GROUP_PADDING,
176
+ width: maxX - minX + GROUP_PADDING * 2,
177
+ height: maxY - minY + GROUP_PADDING * 2,
178
+ lineNumber: group.lineNumber,
179
+ });
180
+ }
181
+ }
182
+
183
+ // Compute total dimensions
184
+ let totalWidth = 0;
185
+ let totalHeight = 0;
186
+ for (const node of layoutNodes) {
187
+ const right = node.x + node.width / 2;
188
+ const bottom = node.y + node.height / 2;
189
+ if (right > totalWidth) totalWidth = right;
190
+ if (bottom > totalHeight) totalHeight = bottom;
191
+ }
192
+ // Also consider group bounds
193
+ 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;
198
+ }
199
+ // Also consider edge control points
200
+ for (const edge of layoutEdges) {
201
+ for (const pt of edge.points) {
202
+ if (pt.x > totalWidth) totalWidth = pt.x;
203
+ if (pt.y > totalHeight) totalHeight = pt.y;
204
+ }
205
+ }
206
+ // Add margin
207
+ totalWidth += 40;
208
+ totalHeight += 40;
209
+
210
+ return {
211
+ nodes: layoutNodes,
212
+ edges: layoutEdges,
213
+ groups: layoutGroups,
214
+ width: totalWidth,
215
+ height: totalHeight,
216
+ };
217
+ }
@@ -0,0 +1,246 @@
1
+ // ============================================================
2
+ // Initiative Status Diagram — Parser
3
+ // ============================================================
4
+
5
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
+ import type { DgmoError } from '../diagnostics';
7
+ import type {
8
+ ParsedInitiativeStatus,
9
+ ISNode,
10
+ ISEdge,
11
+ ISGroup,
12
+ InitiativeStatus,
13
+ } from './types';
14
+ import { VALID_STATUSES } from './types';
15
+ import { inferParticipantType } from '../sequence/participant-inference';
16
+
17
+ // ============================================================
18
+ // Heuristic — does this content look like an initiative-status diagram?
19
+ // ============================================================
20
+
21
+ /**
22
+ * Returns true if the content looks like an initiative-status diagram.
23
+ * Detects `->` arrows combined with `| done/wip/todo/na` status markers.
24
+ */
25
+ export function looksLikeInitiativeStatus(content: string): boolean {
26
+ const lines = content.split('\n');
27
+ let hasArrow = false;
28
+ let hasStatus = false;
29
+ for (const line of lines) {
30
+ const trimmed = line.trim();
31
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
32
+ if (trimmed.match(/^chart\s*:/i)) continue;
33
+ if (trimmed.match(/^title\s*:/i)) continue;
34
+ if (trimmed.includes('->')) hasArrow = true;
35
+ if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
36
+ if (hasArrow && hasStatus) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ // ============================================================
42
+ // Parser
43
+ // ============================================================
44
+
45
+ function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): InitiativeStatus {
46
+ const trimmed = raw.trim().toLowerCase();
47
+ if (!trimmed) return null;
48
+ if (VALID_STATUSES.includes(trimmed)) return trimmed as InitiativeStatus;
49
+
50
+ // Unknown status — emit warning with suggestion
51
+ const hint = suggest(trimmed, VALID_STATUSES);
52
+ const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
53
+ diagnostics.push(makeDgmoError(line, msg, 'warning'));
54
+ return null;
55
+ }
56
+
57
+ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
58
+ const result: ParsedInitiativeStatus = {
59
+ type: 'initiative-status',
60
+ title: null,
61
+ titleLineNumber: null,
62
+ nodes: [],
63
+ edges: [],
64
+ groups: [],
65
+ options: {},
66
+ diagnostics: [],
67
+ error: undefined,
68
+ };
69
+
70
+ const lines = content.split('\n');
71
+ const nodeLabels = new Set<string>();
72
+ let currentGroup: ISGroup | null = null;
73
+
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const lineNum = i + 1; // 1-based
76
+ const raw = lines[i];
77
+ const trimmed = raw.trim();
78
+
79
+ // Skip blanks and comments
80
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
81
+
82
+ // chart: header
83
+ const chartMatch = trimmed.match(/^chart\s*:\s*(.+)/i);
84
+ if (chartMatch) {
85
+ const chartType = chartMatch[1].trim().toLowerCase();
86
+ if (chartType !== 'initiative-status') {
87
+ const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${chartType}"`);
88
+ result.diagnostics.push(diag);
89
+ result.error = formatDgmoError(diag);
90
+ return result;
91
+ }
92
+ continue;
93
+ }
94
+
95
+ // title: header
96
+ const titleMatch = trimmed.match(/^title\s*:\s*(.+)/i);
97
+ if (titleMatch) {
98
+ result.title = titleMatch[1].trim();
99
+ result.titleLineNumber = lineNum;
100
+ continue;
101
+ }
102
+
103
+ // Group header: [Group Name]
104
+ const groupMatch = trimmed.match(/^\[(.+)\]\s*$/);
105
+ if (groupMatch) {
106
+ // Close previous group
107
+ if (currentGroup) {
108
+ result.groups.push(currentGroup);
109
+ }
110
+ currentGroup = { label: groupMatch[1], nodeLabels: [], lineNumber: lineNum };
111
+ continue;
112
+ }
113
+
114
+ // Non-indented line closes the current group
115
+ const isIndented = raw.length > 0 && raw !== trimmed && /^\s/.test(raw);
116
+ if (!isIndented && currentGroup) {
117
+ result.groups.push(currentGroup);
118
+ currentGroup = null;
119
+ }
120
+
121
+ // Edge: contains `->`
122
+ if (trimmed.includes('->')) {
123
+ const edge = parseEdgeLine(trimmed, lineNum, result.diagnostics);
124
+ if (edge) result.edges.push(edge);
125
+ continue;
126
+ }
127
+
128
+ // Node: everything else
129
+ const node = parseNodeLine(trimmed, lineNum, result.diagnostics);
130
+ if (node) {
131
+ if (nodeLabels.has(node.label)) {
132
+ result.diagnostics.push(
133
+ makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
134
+ );
135
+ } else {
136
+ nodeLabels.add(node.label);
137
+ }
138
+ result.nodes.push(node);
139
+ // Add to current group if indented
140
+ if (currentGroup && isIndented) {
141
+ currentGroup.nodeLabels.push(node.label);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Close any trailing group
147
+ if (currentGroup) {
148
+ result.groups.push(currentGroup);
149
+ }
150
+
151
+ // Validate edges reference declared nodes
152
+ for (const edge of result.edges) {
153
+ if (!nodeLabels.has(edge.source)) {
154
+ result.diagnostics.push(
155
+ makeDgmoError(edge.lineNumber, `Edge source "${edge.source}" is not a declared node`, 'warning')
156
+ );
157
+ // Auto-create an implicit node
158
+ if (!result.nodes.some((n) => n.label === edge.source)) {
159
+ result.nodes.push({ label: edge.source, status: null, shape: inferParticipantType(edge.source), lineNumber: edge.lineNumber });
160
+ nodeLabels.add(edge.source);
161
+ }
162
+ }
163
+ if (!nodeLabels.has(edge.target)) {
164
+ result.diagnostics.push(
165
+ makeDgmoError(edge.lineNumber, `Edge target "${edge.target}" is not a declared node`, 'warning')
166
+ );
167
+ if (!result.nodes.some((n) => n.label === edge.target)) {
168
+ result.nodes.push({ label: edge.target, status: null, shape: inferParticipantType(edge.target), lineNumber: edge.lineNumber });
169
+ nodeLabels.add(edge.target);
170
+ }
171
+ }
172
+ }
173
+
174
+ return result;
175
+ }
176
+
177
+ // ============================================================
178
+ // Line parsers
179
+ // ============================================================
180
+
181
+ function parseNodeLine(
182
+ trimmed: string,
183
+ lineNum: number,
184
+ diagnostics: DgmoError[]
185
+ ): ISNode | null {
186
+ // Format: <label> | <status>
187
+ // or just: <label>
188
+ const pipeIdx = trimmed.lastIndexOf('|');
189
+ if (pipeIdx >= 0) {
190
+ const label = trimmed.slice(0, pipeIdx).trim();
191
+ const statusRaw = trimmed.slice(pipeIdx + 1).trim();
192
+ if (!label) return null;
193
+ const status = parseStatus(statusRaw, lineNum, diagnostics);
194
+ return { label, status, shape: inferParticipantType(label), lineNumber: lineNum };
195
+ }
196
+ return { label: trimmed, status: null, shape: inferParticipantType(trimmed), lineNumber: lineNum };
197
+ }
198
+
199
+ function parseEdgeLine(
200
+ trimmed: string,
201
+ lineNum: number,
202
+ diagnostics: DgmoError[]
203
+ ): ISEdge | null {
204
+ // Format: <source> -> <target>: <label> | <status>
205
+ // or: <source> -> <target> | <status>
206
+ // or: <source> -> <target>: <label>
207
+ // or: <source> -> <target>
208
+
209
+ const arrowIdx = trimmed.indexOf('->');
210
+ if (arrowIdx < 0) return null;
211
+
212
+ const source = trimmed.slice(0, arrowIdx).trim();
213
+ let rest = trimmed.slice(arrowIdx + 2).trim();
214
+
215
+ if (!source || !rest) {
216
+ diagnostics.push(makeDgmoError(lineNum, 'Edge is missing source or target'));
217
+ return null;
218
+ }
219
+
220
+ // Extract status from end (after last |)
221
+ let status: InitiativeStatus = null;
222
+ const lastPipe = rest.lastIndexOf('|');
223
+ if (lastPipe >= 0) {
224
+ const statusRaw = rest.slice(lastPipe + 1).trim();
225
+ status = parseStatus(statusRaw, lineNum, diagnostics);
226
+ rest = rest.slice(0, lastPipe).trim();
227
+ }
228
+
229
+ // Extract target and optional label (target: label)
230
+ let target: string;
231
+ let label: string | undefined;
232
+ const colonIdx = rest.indexOf(':');
233
+ if (colonIdx >= 0) {
234
+ target = rest.slice(0, colonIdx).trim();
235
+ label = rest.slice(colonIdx + 1).trim() || undefined;
236
+ } else {
237
+ target = rest.trim();
238
+ }
239
+
240
+ if (!target) {
241
+ diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
242
+ return null;
243
+ }
244
+
245
+ return { source, target, label, status, lineNumber: lineNum };
246
+ }