@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.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +145 -93
- package/dist/editor.cjs +20 -3
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +20 -3
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +15 -2
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +15 -2
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +20843 -14937
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +426 -17
- package/dist/index.d.ts +426 -17
- package/dist/index.js +20795 -14912
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +380 -0
- package/dist/internal.cjs.map +1 -0
- package/dist/internal.d.cts +179 -0
- package/dist/internal.d.ts +179 -0
- package/dist/internal.js +337 -0
- package/dist/internal.js.map +1 -0
- package/docs/guide/chart-cycle.md +156 -0
- package/docs/guide/chart-journey-map.md +179 -0
- package/docs/guide/chart-pyramid.md +111 -0
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/registry.json +6 -0
- package/docs/language-reference.md +177 -6
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/pyramid/dikw.dgmo +17 -0
- package/gallery/fixtures/pyramid/inverted-funnel.dgmo +16 -0
- package/gallery/fixtures/pyramid/minimal.dgmo +5 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +11 -1
- package/src/boxes-and-lines/layout.ts +309 -33
- package/src/boxes-and-lines/parser.ts +86 -10
- package/src/boxes-and-lines/renderer.ts +250 -91
- package/src/boxes-and-lines/types.ts +1 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/cli.ts +5 -35
- package/src/completion.ts +233 -41
- package/src/cycle/layout.ts +723 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +566 -0
- package/src/cycle/types.ts +98 -0
- package/src/d3.ts +107 -8
- package/src/dgmo-router.ts +82 -3
- package/src/echarts.ts +8 -5
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +17 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/state-parser.ts +5 -10
- package/src/index.ts +63 -2
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +32 -8
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/internal.ts +16 -0
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1521 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +31 -15
- package/src/mindmap/parser.ts +12 -18
- package/src/mindmap/renderer.ts +14 -13
- package/src/mindmap/text-wrap.ts +22 -12
- package/src/mindmap/types.ts +2 -2
- package/src/org/collapse.ts +81 -0
- package/src/org/parser.ts +2 -6
- package/src/org/renderer.ts +212 -4
- package/src/pyramid/parser.ts +172 -0
- package/src/pyramid/renderer.ts +684 -0
- package/src/pyramid/types.ts +28 -0
- package/src/render.ts +2 -8
- package/src/sequence/parser.ts +62 -20
- package/src/sequence/renderer.ts +146 -40
- package/src/sharing.ts +1 -0
- package/src/sitemap/layout.ts +21 -6
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1112 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/legend-layout.ts +3 -1
- package/src/utils/parsing.ts +47 -7
- 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
|
+
}
|