@diagrammo/dgmo 0.8.21 → 0.8.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.
Files changed (114) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +145 -93
  4. package/dist/editor.cjs +20 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +20 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +15 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +15 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +20843 -14937
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +426 -17
  15. package/dist/index.d.ts +426 -17
  16. package/dist/index.js +20795 -14912
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal.cjs +380 -0
  19. package/dist/internal.cjs.map +1 -0
  20. package/dist/internal.d.cts +179 -0
  21. package/dist/internal.d.ts +179 -0
  22. package/dist/internal.js +337 -0
  23. package/dist/internal.js.map +1 -0
  24. package/docs/guide/chart-cycle.md +156 -0
  25. package/docs/guide/chart-journey-map.md +179 -0
  26. package/docs/guide/chart-pyramid.md +111 -0
  27. package/docs/guide/chart-sitemap.md +18 -1
  28. package/docs/guide/chart-tech-radar.md +219 -0
  29. package/docs/guide/registry.json +6 -0
  30. package/docs/language-reference.md +177 -6
  31. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  32. package/gallery/fixtures/c4-full.dgmo +2 -2
  33. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  34. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  35. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  36. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  37. package/gallery/fixtures/gantt-full.dgmo +2 -2
  38. package/gallery/fixtures/gantt.dgmo +2 -2
  39. package/gallery/fixtures/infra-full.dgmo +2 -2
  40. package/gallery/fixtures/infra.dgmo +1 -1
  41. package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
  42. package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
  43. package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
  44. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  45. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  46. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  47. package/gallery/fixtures/tech-radar.dgmo +36 -0
  48. package/gallery/fixtures/timeline.dgmo +1 -1
  49. package/package.json +11 -1
  50. package/src/boxes-and-lines/layout.ts +309 -33
  51. package/src/boxes-and-lines/parser.ts +86 -10
  52. package/src/boxes-and-lines/renderer.ts +250 -91
  53. package/src/boxes-and-lines/types.ts +1 -1
  54. package/src/c4/layout.ts +8 -8
  55. package/src/c4/parser.ts +35 -2
  56. package/src/c4/renderer.ts +19 -3
  57. package/src/c4/types.ts +1 -0
  58. package/src/chart.ts +14 -7
  59. package/src/cli.ts +5 -35
  60. package/src/completion.ts +233 -41
  61. package/src/cycle/layout.ts +723 -0
  62. package/src/cycle/parser.ts +352 -0
  63. package/src/cycle/renderer.ts +566 -0
  64. package/src/cycle/types.ts +98 -0
  65. package/src/d3.ts +107 -8
  66. package/src/dgmo-router.ts +82 -3
  67. package/src/echarts.ts +8 -5
  68. package/src/editor/dgmo.grammar +5 -1
  69. package/src/editor/dgmo.grammar.js +1 -1
  70. package/src/editor/keywords.ts +17 -0
  71. package/src/gantt/parser.ts +2 -8
  72. package/src/graph/flowchart-parser.ts +15 -21
  73. package/src/graph/state-parser.ts +5 -10
  74. package/src/index.ts +63 -2
  75. package/src/infra/layout.ts +218 -74
  76. package/src/infra/parser.ts +32 -8
  77. package/src/infra/renderer.ts +14 -8
  78. package/src/infra/types.ts +10 -3
  79. package/src/internal.ts +16 -0
  80. package/src/journey-map/layout.ts +386 -0
  81. package/src/journey-map/parser.ts +540 -0
  82. package/src/journey-map/renderer.ts +1521 -0
  83. package/src/journey-map/types.ts +47 -0
  84. package/src/kanban/parser.ts +3 -10
  85. package/src/kanban/renderer.ts +31 -15
  86. package/src/mindmap/parser.ts +12 -18
  87. package/src/mindmap/renderer.ts +14 -13
  88. package/src/mindmap/text-wrap.ts +22 -12
  89. package/src/mindmap/types.ts +2 -2
  90. package/src/org/collapse.ts +81 -0
  91. package/src/org/parser.ts +2 -6
  92. package/src/org/renderer.ts +212 -4
  93. package/src/pyramid/parser.ts +172 -0
  94. package/src/pyramid/renderer.ts +684 -0
  95. package/src/pyramid/types.ts +28 -0
  96. package/src/render.ts +2 -8
  97. package/src/sequence/parser.ts +62 -20
  98. package/src/sequence/renderer.ts +146 -40
  99. package/src/sharing.ts +1 -0
  100. package/src/sitemap/layout.ts +21 -6
  101. package/src/sitemap/parser.ts +26 -17
  102. package/src/sitemap/renderer.ts +34 -0
  103. package/src/sitemap/types.ts +1 -0
  104. package/src/tech-radar/index.ts +14 -0
  105. package/src/tech-radar/interactive.ts +1112 -0
  106. package/src/tech-radar/layout.ts +190 -0
  107. package/src/tech-radar/parser.ts +385 -0
  108. package/src/tech-radar/renderer.ts +1159 -0
  109. package/src/tech-radar/shared.ts +187 -0
  110. package/src/tech-radar/types.ts +81 -0
  111. package/src/utils/description-helpers.ts +33 -0
  112. package/src/utils/legend-layout.ts +3 -1
  113. package/src/utils/parsing.ts +47 -7
  114. package/src/utils/tag-groups.ts +46 -60
@@ -0,0 +1,190 @@
1
+ import type {
2
+ ParsedTechRadar,
3
+ TechRadarLayoutPoint,
4
+ QuadrantPosition,
5
+ } from './types';
6
+
7
+ /** Clockwise quadrant order matching global numbering. */
8
+ const POSITION_ORDER: readonly QuadrantPosition[] = [
9
+ 'top-left',
10
+ 'top-right',
11
+ 'bottom-right',
12
+ 'bottom-left',
13
+ ];
14
+
15
+ /**
16
+ * Maps quadrant position to its angular arc range (in radians).
17
+ * 0° = right (3 o'clock), counter-clockwise:
18
+ * top-right → 0° to 90° (π/2)
19
+ * top-left → 90° to 180° (π)
20
+ * bottom-left → 180° to 270° (3π/2)
21
+ * bottom-right → 270° to 360° (2π)
22
+ */
23
+ function getQuadrantArc(position: QuadrantPosition): {
24
+ startAngle: number;
25
+ endAngle: number;
26
+ } {
27
+ switch (position) {
28
+ case 'top-right':
29
+ return { startAngle: 0, endAngle: Math.PI / 2 };
30
+ case 'top-left':
31
+ return { startAngle: Math.PI / 2, endAngle: Math.PI };
32
+ case 'bottom-left':
33
+ return { startAngle: Math.PI, endAngle: (3 * Math.PI) / 2 };
34
+ case 'bottom-right':
35
+ return { startAngle: (3 * Math.PI) / 2, endAngle: 2 * Math.PI };
36
+ }
37
+ }
38
+
39
+ /** Blip circle radius in pixels — scales down when slices are dense. */
40
+ const BASE_BLIP_RADIUS = 12;
41
+ const MIN_BLIP_RADIUS = 7;
42
+
43
+ /**
44
+ * Compute deterministic, non-overlapping blip positions for a tech radar.
45
+ *
46
+ * Each blip is positioned within its ring+quadrant slice using polar coordinates,
47
+ * then converted to cartesian. The algorithm is:
48
+ * - Stable: changes in one slice don't affect other slices
49
+ * - Deterministic: same input always produces same output
50
+ * - Collision-avoiding: nudges overlapping blips radially within their ring band
51
+ */
52
+ export function computeRadarLayout(
53
+ parsed: ParsedTechRadar,
54
+ width: number,
55
+ height: number
56
+ ): TechRadarLayoutPoint[] {
57
+ const points: TechRadarLayoutPoint[] = [];
58
+
59
+ if (parsed.rings.length === 0 || parsed.quadrants.length === 0) return points;
60
+
61
+ const cx = width / 2;
62
+ const cy = height / 2;
63
+ const maxRadius = Math.min(cx, cy) * 0.88; // leave margin for labels
64
+ const ringCount = parsed.rings.length;
65
+ const ringBandWidth = maxRadius / ringCount;
66
+ const ringOrder = parsed.rings.map((r) => r.name);
67
+
68
+ // Padding from ring/quadrant boundaries (fraction of band)
69
+ const radialPadding = ringBandWidth * 0.12;
70
+ const angularPadding = 0.05; // radians from quadrant dividers
71
+
72
+ for (const quadrant of parsed.quadrants) {
73
+ const quadrantIndex = POSITION_ORDER.indexOf(quadrant.position);
74
+ const { startAngle, endAngle } = getQuadrantArc(quadrant.position);
75
+ const usableArcStart = startAngle + angularPadding;
76
+ const usableArcEnd = endAngle - angularPadding;
77
+
78
+ // Group blips by ring
79
+ const blipsByRing = new Map<string, typeof quadrant.blips>();
80
+ for (const blip of quadrant.blips) {
81
+ const list = blipsByRing.get(blip.ring) ?? [];
82
+ list.push(blip);
83
+ blipsByRing.set(blip.ring, list);
84
+ }
85
+
86
+ const blipRadius = Math.max(
87
+ MIN_BLIP_RADIUS,
88
+ Math.min(BASE_BLIP_RADIUS, (ringBandWidth - 2 * radialPadding) / 3)
89
+ );
90
+
91
+ for (const [ringName, blips] of blipsByRing) {
92
+ const ringIndex = ringOrder.indexOf(ringName);
93
+ if (ringIndex < 0) continue;
94
+
95
+ const rInner = ringIndex * ringBandWidth + radialPadding;
96
+ const rOuter = (ringIndex + 1) * ringBandWidth - radialPadding;
97
+ const rMid = (rInner + rOuter) / 2;
98
+ const usableRadial = rOuter - rInner - blipRadius * 2;
99
+
100
+ // Distribute blips evenly across the arc
101
+ const arcSpan = usableArcEnd - usableArcStart;
102
+ const placedPoints: { angle: number; radius: number }[] = [];
103
+
104
+ for (let bi = 0; bi < blips.length; bi++) {
105
+ const blip = blips[bi];
106
+
107
+ // Spread across arc evenly
108
+ let angle: number;
109
+ if (blips.length === 1) {
110
+ angle = (usableArcStart + usableArcEnd) / 2;
111
+ } else {
112
+ angle = usableArcStart + ((bi + 0.5) / blips.length) * arcSpan;
113
+ }
114
+
115
+ // Stagger radially to avoid overlap in dense slices
116
+ let radius: number;
117
+ if (blips.length <= 3) {
118
+ radius = rMid;
119
+ } else {
120
+ // Alternate between inner and outer portions
121
+ const radialSlots = Math.min(3, Math.ceil(blips.length / 3));
122
+ const slot = bi % radialSlots;
123
+ radius =
124
+ rInner +
125
+ blipRadius +
126
+ (slot / Math.max(1, radialSlots - 1)) * usableRadial;
127
+ }
128
+
129
+ // Collision detection: nudge if overlapping with already-placed blips
130
+ let attempts = 0;
131
+ while (attempts < 20) {
132
+ const x = cx + radius * Math.cos(angle);
133
+ const y = cy - radius * Math.sin(angle); // SVG y is inverted
134
+ const overlapping = placedPoints.some((p) => {
135
+ const px = cx + p.radius * Math.cos(p.angle);
136
+ const py = cy - p.radius * Math.sin(p.angle);
137
+ const dx = x - px;
138
+ const dy = y - py;
139
+ return Math.sqrt(dx * dx + dy * dy) < blipRadius * 2.2;
140
+ });
141
+ if (!overlapping) break;
142
+ // Nudge: try different radial position within band
143
+ radius += blipRadius * 0.8;
144
+ if (radius > rOuter - blipRadius) {
145
+ radius = rInner + blipRadius;
146
+ angle += arcSpan * 0.05; // small angular shift
147
+ // Clamp angle to stay within this quadrant's arc
148
+ if (angle > usableArcEnd) angle = usableArcEnd;
149
+ }
150
+ attempts++;
151
+ }
152
+
153
+ placedPoints.push({ angle, radius });
154
+
155
+ points.push({
156
+ blip,
157
+ x: cx + radius * Math.cos(angle),
158
+ y: cy - radius * Math.sin(angle),
159
+ quadrantIndex,
160
+ ringIndex,
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ return points;
167
+ }
168
+
169
+ /**
170
+ * Get the center and max radius for a radar at the given dimensions.
171
+ * Useful for renderers that need these values independently.
172
+ */
173
+ export function getRadarGeometry(
174
+ width: number,
175
+ height: number,
176
+ ringCount: number
177
+ ): {
178
+ cx: number;
179
+ cy: number;
180
+ maxRadius: number;
181
+ ringBandWidth: number;
182
+ } {
183
+ const cx = width / 2;
184
+ const cy = height / 2;
185
+ const maxRadius = Math.min(cx, cy) * 0.88;
186
+ return { cx, cy, maxRadius, ringBandWidth: maxRadius / ringCount };
187
+ }
188
+
189
+ /** Exported for testing. */
190
+ export { POSITION_ORDER, getQuadrantArc, BASE_BLIP_RADIUS, MIN_BLIP_RADIUS };
@@ -0,0 +1,385 @@
1
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
2
+ import {
3
+ measureIndent,
4
+ parseFirstLine,
5
+ parsePipeMetadata,
6
+ OPTION_NOCOLON_RE,
7
+ } from '../utils/parsing';
8
+ import type {
9
+ ParsedTechRadar,
10
+ TechRadarQuadrant,
11
+ TechRadarBlip,
12
+ QuadrantPosition,
13
+ BlipTrend,
14
+ } from './types';
15
+
16
+ const VALID_POSITIONS: readonly QuadrantPosition[] = [
17
+ 'top-left',
18
+ 'top-right',
19
+ 'bottom-left',
20
+ 'bottom-right',
21
+ ];
22
+
23
+ const VALID_TRENDS: readonly BlipTrend[] = ['new', 'up', 'down', 'stable'];
24
+
25
+ /** Clockwise order for global numbering: TL, TR, BR, BL. */
26
+ const POSITION_ORDER: readonly QuadrantPosition[] = [
27
+ 'top-left',
28
+ 'top-right',
29
+ 'bottom-right',
30
+ 'bottom-left',
31
+ ];
32
+
33
+ /** Known tech-radar options (key-value). */
34
+ const KNOWN_OPTIONS = new Set<string>([]);
35
+ /** Known tech-radar boolean options (bare keyword). */
36
+ const KNOWN_BOOLEANS = new Set<string>([]);
37
+
38
+ export function parseTechRadar(content: string): ParsedTechRadar {
39
+ const result: ParsedTechRadar = {
40
+ type: 'tech-radar',
41
+ title: '',
42
+ titleLineNumber: 0,
43
+ rings: [],
44
+ quadrants: [],
45
+ options: {},
46
+ diagnostics: [],
47
+ error: null,
48
+ };
49
+
50
+ const fail = (line: number, message: string): ParsedTechRadar => {
51
+ const diag = makeDgmoError(line, message);
52
+ result.diagnostics.push(diag);
53
+ result.error = formatDgmoError(diag);
54
+ return result;
55
+ };
56
+
57
+ const warn = (line: number, message: string): void => {
58
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
59
+ };
60
+
61
+ if (!content || !content.trim()) {
62
+ return fail(0, 'No content provided');
63
+ }
64
+
65
+ /** Check if a string matches a declared ring name or alias (case-insensitive). */
66
+ function isRingName(name: string): boolean {
67
+ const lower = name.toLowerCase();
68
+ return result.rings.some(
69
+ (r) =>
70
+ r.name.toLowerCase() === lower ||
71
+ (r.alias !== null && r.alias.toLowerCase() === lower)
72
+ );
73
+ }
74
+
75
+ /** Get the canonical ring name for a case-insensitive match (by name or alias). */
76
+ function canonicalRingName(name: string): string {
77
+ const lower = name.toLowerCase();
78
+ const ring = result.rings.find(
79
+ (r) =>
80
+ r.name.toLowerCase() === lower ||
81
+ (r.alias !== null && r.alias.toLowerCase() === lower)
82
+ );
83
+ return ring?.name ?? name;
84
+ }
85
+
86
+ const lines = content.split('\n');
87
+ let headerParsed = false;
88
+ let inRingsBlock = false;
89
+ let currentQuadrant: TechRadarQuadrant | null = null;
90
+ let currentBlip: TechRadarBlip | null = null;
91
+ let blipBaseIndent = 0;
92
+ let currentRing: string | null = null; // active ring section (new syntax)
93
+
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ const lineNumber = i + 1;
97
+ const trimmed = line.trim();
98
+ const indent = measureIndent(line);
99
+
100
+ // Skip empty lines
101
+ if (!trimmed) {
102
+ if (inRingsBlock && result.rings.length > 0) {
103
+ inRingsBlock = false;
104
+ }
105
+ continue;
106
+ }
107
+
108
+ // Skip comments
109
+ if (trimmed.startsWith('//')) continue;
110
+
111
+ // --- First line: chart type + title ---
112
+ if (!headerParsed) {
113
+ const firstLine = parseFirstLine(trimmed);
114
+ if (firstLine && firstLine.chartType === 'tech-radar') {
115
+ result.title = firstLine.title ?? '';
116
+ result.titleLineNumber = lineNumber;
117
+ headerParsed = true;
118
+ continue;
119
+ }
120
+ return fail(lineNumber, 'Expected "tech-radar" chart type declaration');
121
+ }
122
+
123
+ // --- Rings block ---
124
+ if (indent === 0 && trimmed.toLowerCase() === 'rings') {
125
+ if (result.rings.length > 0) {
126
+ warn(lineNumber, 'Duplicate "rings" block — using last one');
127
+ result.rings = [];
128
+ }
129
+ inRingsBlock = true;
130
+ currentBlip = null;
131
+ currentQuadrant = null;
132
+ currentRing = null;
133
+ continue;
134
+ }
135
+
136
+ if (inRingsBlock) {
137
+ if (indent > 0) {
138
+ // Parse ring declaration: `Name`, `Name alias a`, or `Name aka a`
139
+ const aliasMatch = trimmed.match(/^(.+?)\s+(?:alias|aka)\s+(\S+)\s*$/i);
140
+ if (aliasMatch) {
141
+ result.rings.push({
142
+ name: aliasMatch[1].trim(),
143
+ alias: aliasMatch[2].trim(),
144
+ lineNumber,
145
+ });
146
+ } else {
147
+ result.rings.push({ name: trimmed, alias: null, lineNumber });
148
+ }
149
+ continue;
150
+ }
151
+ inRingsBlock = false;
152
+ }
153
+
154
+ // --- Options (bare keyword or key value, top-level only) ---
155
+ if (indent === 0 && !trimmed.includes('|')) {
156
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
157
+ if (optMatch) {
158
+ const key = optMatch[1].toLowerCase();
159
+ if (KNOWN_OPTIONS.has(key)) {
160
+ result.options[key] = optMatch[2].trim();
161
+ currentBlip = null;
162
+ continue;
163
+ }
164
+ }
165
+ if (KNOWN_BOOLEANS.has(trimmed.toLowerCase())) {
166
+ result.options[trimmed.toLowerCase()] = 'on';
167
+ currentBlip = null;
168
+ continue;
169
+ }
170
+ }
171
+
172
+ // --- Quadrant section header: `Name | quadrant: position` ---
173
+ if (indent === 0 && trimmed.includes('|')) {
174
+ const segments = trimmed.split('|');
175
+ const meta = parsePipeMetadata(segments);
176
+ const quadrantPos = meta['quadrant'];
177
+
178
+ if (quadrantPos) {
179
+ const position = quadrantPos.toLowerCase() as QuadrantPosition;
180
+ if (!VALID_POSITIONS.includes(position)) {
181
+ const hint = suggest(position, [...VALID_POSITIONS]);
182
+ let msg = `Invalid quadrant position "${quadrantPos}". Must be one of: ${VALID_POSITIONS.join(', ')}`;
183
+ if (hint) msg += `. ${hint}`;
184
+ result.diagnostics.push(makeDgmoError(lineNumber, msg));
185
+ continue;
186
+ }
187
+
188
+ const existing = result.quadrants.find((q) => q.position === position);
189
+ if (existing) {
190
+ result.diagnostics.push(
191
+ makeDgmoError(
192
+ lineNumber,
193
+ `Duplicate quadrant position "${position}" — already used by "${existing.name}"`
194
+ )
195
+ );
196
+ continue;
197
+ }
198
+
199
+ const name = segments[0].trim();
200
+ const color = meta['color'] ?? null;
201
+
202
+ currentQuadrant = {
203
+ name,
204
+ position,
205
+ color,
206
+ lineNumber,
207
+ blips: [],
208
+ };
209
+ result.quadrants.push(currentQuadrant);
210
+ currentBlip = null;
211
+ currentRing = null;
212
+ continue;
213
+ }
214
+ }
215
+
216
+ // --- Inside a quadrant ---
217
+ if (currentQuadrant && indent > 0) {
218
+ // --- Ring section header (new syntax): indented line matching a declared ring name ---
219
+ if (
220
+ !trimmed.includes('|') &&
221
+ result.rings.length > 0 &&
222
+ isRingName(trimmed)
223
+ ) {
224
+ currentRing = canonicalRingName(trimmed);
225
+ currentBlip = null;
226
+ continue;
227
+ }
228
+
229
+ // --- Description lines: indented deeper than current blip ---
230
+ if (currentBlip && indent > blipBaseIndent) {
231
+ currentBlip.description.push(trimmed);
232
+ continue;
233
+ }
234
+
235
+ // --- Blip with pipe metadata ---
236
+ if (trimmed.includes('|')) {
237
+ const segments = trimmed.split('|');
238
+ const meta = parsePipeMetadata(segments);
239
+
240
+ // Determine ring: explicit `ring:` metadata overrides section ring
241
+ const explicitRing = meta['ring'];
242
+ const effectiveRing = explicitRing
243
+ ? canonicalRingName(explicitRing)
244
+ : currentRing;
245
+
246
+ if (!effectiveRing) {
247
+ result.diagnostics.push(
248
+ makeDgmoError(
249
+ lineNumber,
250
+ `Blip "${segments[0].trim()}" has no ring assignment. Use a ring section header or add "ring: RingName" metadata.`
251
+ )
252
+ );
253
+ continue;
254
+ }
255
+
256
+ // Validate ring name
257
+ if (
258
+ explicitRing &&
259
+ result.rings.length > 0 &&
260
+ !isRingName(explicitRing)
261
+ ) {
262
+ const hint = suggest(
263
+ explicitRing,
264
+ result.rings.map((r) => r.name)
265
+ );
266
+ let msg = `Unknown ring "${explicitRing}" on blip "${segments[0].trim()}"`;
267
+ if (hint) msg += `. ${hint}`;
268
+ result.diagnostics.push(makeDgmoError(lineNumber, msg));
269
+ continue;
270
+ }
271
+
272
+ // Parse optional trend
273
+ let trend: BlipTrend | null = null;
274
+ if (meta['trend']) {
275
+ const trendVal = meta['trend'].toLowerCase() as BlipTrend;
276
+ if (!VALID_TRENDS.includes(trendVal)) {
277
+ const hint = suggest(meta['trend'], [...VALID_TRENDS]);
278
+ let msg = `Unknown trend "${meta['trend']}" on blip "${segments[0].trim()}". Must be one of: ${VALID_TRENDS.join(', ')}`;
279
+ if (hint) msg += `. ${hint}`;
280
+ result.diagnostics.push(makeDgmoError(lineNumber, msg, 'warning'));
281
+ } else {
282
+ trend = trendVal;
283
+ }
284
+ }
285
+
286
+ currentBlip = {
287
+ name: segments[0].trim(),
288
+ ring: effectiveRing,
289
+ trend,
290
+ description: [],
291
+ lineNumber,
292
+ globalNumber: 0,
293
+ };
294
+ blipBaseIndent = indent;
295
+ currentQuadrant.blips.push(currentBlip);
296
+ continue;
297
+ }
298
+
299
+ // --- Blip without pipe metadata (plain name, inherits ring from section) ---
300
+ if (currentRing) {
301
+ currentBlip = {
302
+ name: trimmed,
303
+ ring: currentRing,
304
+ trend: null,
305
+ description: [],
306
+ lineNumber,
307
+ globalNumber: 0,
308
+ };
309
+ blipBaseIndent = indent;
310
+ currentQuadrant.blips.push(currentBlip);
311
+ continue;
312
+ }
313
+
314
+ // No ring section and no pipe metadata — error
315
+ result.diagnostics.push(
316
+ makeDgmoError(
317
+ lineNumber,
318
+ `Blip "${trimmed}" has no ring assignment. Place it under a ring section header or use: ${trimmed} | ring: RingName`
319
+ )
320
+ );
321
+ continue;
322
+ }
323
+
324
+ // --- Unrecognized top-level line ---
325
+ if (indent === 0) {
326
+ if (!trimmed.includes('|') && headerParsed && result.rings.length > 0) {
327
+ warn(
328
+ lineNumber,
329
+ `Unrecognized line "${trimmed}". Quadrant headers require pipe metadata: ${trimmed} | quadrant: top-left`
330
+ );
331
+ }
332
+ }
333
+ }
334
+
335
+ // --- Validation ---
336
+
337
+ if (result.rings.length === 0) {
338
+ result.diagnostics.push(
339
+ makeDgmoError(
340
+ 0,
341
+ 'Missing "rings" block — declare ring names before quadrant sections'
342
+ )
343
+ );
344
+ }
345
+
346
+ if (result.quadrants.length !== 4) {
347
+ const msg =
348
+ result.quadrants.length === 0
349
+ ? 'No quadrants declared. Exactly 4 quadrants are required.'
350
+ : `Expected exactly 4 quadrants, found ${result.quadrants.length}. Each quadrant needs a unique position (top-left, top-right, bottom-left, bottom-right).`;
351
+ result.diagnostics.push(makeDgmoError(0, msg));
352
+ }
353
+
354
+ // --- Global numbering ---
355
+ assignGlobalNumbers(result);
356
+
357
+ return result;
358
+ }
359
+
360
+ /**
361
+ * Assign global blip numbers. Order:
362
+ * 1. Quadrants in clockwise order: top-left, top-right, bottom-right, bottom-left
363
+ * 2. Within each quadrant: rings from innermost to outermost
364
+ * 3. Within each ring: declaration order
365
+ */
366
+ function assignGlobalNumbers(result: ParsedTechRadar): void {
367
+ const ringOrder = result.rings.map((r) => r.name);
368
+ let counter = 1;
369
+
370
+ for (const position of POSITION_ORDER) {
371
+ const quadrant = result.quadrants.find((q) => q.position === position);
372
+ if (!quadrant) continue;
373
+
374
+ const sortedBlips = [...quadrant.blips].sort((a, b) => {
375
+ const aRing = ringOrder.indexOf(a.ring);
376
+ const bRing = ringOrder.indexOf(b.ring);
377
+ if (aRing !== bRing) return aRing - bRing;
378
+ return a.lineNumber - b.lineNumber;
379
+ });
380
+
381
+ for (const blip of sortedBlips) {
382
+ blip.globalNumber = counter++;
383
+ }
384
+ }
385
+ }