@diagrammo/dgmo 0.8.19 → 0.8.20
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/dist/cli.cjs +89 -130
- package/dist/index.cjs +681 -872
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +110 -103
- package/dist/index.d.ts +110 -103
- package/dist/index.js +693 -864
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +73 -0
- package/package.json +22 -9
- package/src/boxes-and-lines/parser.ts +8 -3
- package/src/c4/parser.ts +8 -7
- package/src/class/parser.ts +6 -0
- package/src/cli.ts +1 -9
- package/src/d3.ts +16 -234
- package/src/dgmo-router.ts +97 -5
- package/src/diagnostics.ts +16 -6
- package/src/echarts.ts +43 -10
- package/src/er/parser.ts +22 -2
- package/src/gantt/renderer.ts +2 -2
- package/src/graph/flowchart-parser.ts +89 -52
- package/src/graph/state-parser.ts +60 -35
- package/src/index.ts +13 -16
- package/src/infra/parser.ts +9 -2
- package/src/kanban/renderer.ts +2 -2
- package/src/palettes/color-utils.ts +4 -12
- package/src/palettes/index.ts +0 -4
- package/src/render.ts +30 -16
- package/src/sequence/parser.ts +7 -2
- package/src/sequence/renderer.ts +12 -3
- package/src/sitemap/renderer.ts +1 -6
- package/src/utils/arrows.ts +180 -11
- package/src/utils/d3-types.ts +4 -0
- package/src/utils/legend-constants.ts +0 -4
- package/src/utils/time-ticks.ts +213 -0
- package/src/branding.ts +0 -67
- package/src/dgmo-mermaid.ts +0 -262
- package/src/palettes/mermaid-bridge.ts +0 -220
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Time axis tick computation — shared by d3.ts and gantt/renderer.ts
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Scale from 'd3-scale';
|
|
6
|
+
|
|
7
|
+
export const MONTH_ABBR = [
|
|
8
|
+
'Jan',
|
|
9
|
+
'Feb',
|
|
10
|
+
'Mar',
|
|
11
|
+
'Apr',
|
|
12
|
+
'May',
|
|
13
|
+
'Jun',
|
|
14
|
+
'Jul',
|
|
15
|
+
'Aug',
|
|
16
|
+
'Sep',
|
|
17
|
+
'Oct',
|
|
18
|
+
'Nov',
|
|
19
|
+
'Dec',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function fractionalYearToDate(frac: number): Date {
|
|
23
|
+
const year = Math.floor(frac);
|
|
24
|
+
const remainder = frac - year;
|
|
25
|
+
// Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
|
|
26
|
+
const monthFrac = remainder * 12;
|
|
27
|
+
const month = Math.floor(monthFrac); // 0-based
|
|
28
|
+
const monthRemainder = remainder - month / 12;
|
|
29
|
+
const dayFrac = monthRemainder * 365; // fractional day-of-year offset
|
|
30
|
+
const day = Math.floor(dayFrac) + 1;
|
|
31
|
+
const dayRemainder = dayFrac - Math.floor(dayFrac);
|
|
32
|
+
const hourFrac = dayRemainder * 24;
|
|
33
|
+
const hour = Math.floor(hourFrac);
|
|
34
|
+
const minute = Math.round((hourFrac - hour) * 60);
|
|
35
|
+
return new Date(year, month, day, hour, minute);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert a Date to a fractional year number. */
|
|
39
|
+
function dateToFractionalYear(d: Date): number {
|
|
40
|
+
return (
|
|
41
|
+
d.getFullYear() +
|
|
42
|
+
d.getMonth() / 12 +
|
|
43
|
+
(d.getDate() - 1) / 365 +
|
|
44
|
+
d.getHours() / 8760 +
|
|
45
|
+
d.getMinutes() / 525600
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generates adaptive tick marks along a time axis.
|
|
51
|
+
* Picks the right granularity (years, months, weeks, days, hours, minutes)
|
|
52
|
+
* based on the domain span.
|
|
53
|
+
*
|
|
54
|
+
* Optional boundary parameters add ticks at exact data start/end:
|
|
55
|
+
* - boundaryStart/boundaryEnd: numeric date values
|
|
56
|
+
* - boundaryStartLabel/boundaryEndLabel: formatted labels for those dates
|
|
57
|
+
*/
|
|
58
|
+
export function computeTimeTicks(
|
|
59
|
+
domainMin: number,
|
|
60
|
+
domainMax: number,
|
|
61
|
+
scale: d3Scale.ScaleLinear<number, number>,
|
|
62
|
+
boundaryStart?: number,
|
|
63
|
+
boundaryEnd?: number,
|
|
64
|
+
boundaryStartLabel?: string,
|
|
65
|
+
boundaryEndLabel?: string
|
|
66
|
+
): { pos: number; label: string }[] {
|
|
67
|
+
const minYear = Math.floor(domainMin);
|
|
68
|
+
const maxYear = Math.floor(domainMax);
|
|
69
|
+
const span = domainMax - domainMin;
|
|
70
|
+
|
|
71
|
+
let ticks: { pos: number; label: string }[] = [];
|
|
72
|
+
|
|
73
|
+
// Year ticks for multi-year spans (need at least 2 boundaries)
|
|
74
|
+
const firstYear = Math.ceil(domainMin);
|
|
75
|
+
const lastYear = Math.floor(domainMax);
|
|
76
|
+
if (lastYear >= firstYear + 1) {
|
|
77
|
+
// Decimate ticks for long spans so labels don't overlap
|
|
78
|
+
const yearSpan = lastYear - firstYear;
|
|
79
|
+
let step = 1;
|
|
80
|
+
if (yearSpan > 80) step = 20;
|
|
81
|
+
else if (yearSpan > 40) step = 10;
|
|
82
|
+
else if (yearSpan > 20) step = 5;
|
|
83
|
+
else if (yearSpan > 10) step = 2;
|
|
84
|
+
|
|
85
|
+
// Align to step boundary so ticks land on round years (1700, 1710, …)
|
|
86
|
+
const alignedFirst = Math.ceil(firstYear / step) * step;
|
|
87
|
+
for (let y = alignedFirst; y <= lastYear; y += step) {
|
|
88
|
+
ticks.push({ pos: scale(y), label: String(y) });
|
|
89
|
+
}
|
|
90
|
+
} else if (span > 0.25) {
|
|
91
|
+
// Month ticks for spans > ~3 months
|
|
92
|
+
const crossesYear = maxYear > minYear;
|
|
93
|
+
for (let y = minYear; y <= maxYear + 1; y++) {
|
|
94
|
+
for (let m = 1; m <= 12; m++) {
|
|
95
|
+
const val = y + (m - 1) / 12;
|
|
96
|
+
if (val > domainMax) break;
|
|
97
|
+
if (val >= domainMin) {
|
|
98
|
+
ticks.push({
|
|
99
|
+
pos: scale(val),
|
|
100
|
+
label: crossesYear
|
|
101
|
+
? `${MONTH_ABBR[m - 1]} '${String(y).slice(-2)}`
|
|
102
|
+
: MONTH_ABBR[m - 1],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (span <= 0.000685) {
|
|
108
|
+
// Minute ticks for spans ≤ ~6 hours
|
|
109
|
+
// Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
|
|
110
|
+
let stepMin = 5;
|
|
111
|
+
const spanHours = span * 8760;
|
|
112
|
+
if (spanHours > 3) stepMin = 30;
|
|
113
|
+
else if (spanHours > 1) stepMin = 15;
|
|
114
|
+
else if (spanHours > 0.5) stepMin = 10;
|
|
115
|
+
|
|
116
|
+
// Iterate from the start hour boundary
|
|
117
|
+
const startDate = fractionalYearToDate(domainMin);
|
|
118
|
+
// Round down to nearest step boundary
|
|
119
|
+
startDate.setMinutes(
|
|
120
|
+
Math.floor(startDate.getMinutes() / stepMin) * stepMin,
|
|
121
|
+
0,
|
|
122
|
+
0
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
while (true) {
|
|
126
|
+
const val = dateToFractionalYear(startDate);
|
|
127
|
+
if (val > domainMax) break;
|
|
128
|
+
if (val >= domainMin) {
|
|
129
|
+
const hh = String(startDate.getHours()).padStart(2, '0');
|
|
130
|
+
const mm = String(startDate.getMinutes()).padStart(2, '0');
|
|
131
|
+
ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
|
|
132
|
+
}
|
|
133
|
+
startDate.setMinutes(startDate.getMinutes() + stepMin);
|
|
134
|
+
}
|
|
135
|
+
} else if (span <= 0.00822) {
|
|
136
|
+
// Hour ticks for spans ≤ ~3 days
|
|
137
|
+
// Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
|
|
138
|
+
let stepHour = 1;
|
|
139
|
+
const spanHours = span * 8760;
|
|
140
|
+
if (spanHours > 48) stepHour = 6;
|
|
141
|
+
else if (spanHours > 24) stepHour = 3;
|
|
142
|
+
else if (spanHours > 12) stepHour = 2;
|
|
143
|
+
|
|
144
|
+
// For single-day spans, just show HH:MM without the date prefix
|
|
145
|
+
const singleDay = spanHours <= 24;
|
|
146
|
+
|
|
147
|
+
const startDate = fractionalYearToDate(domainMin);
|
|
148
|
+
// Round down to nearest step boundary
|
|
149
|
+
startDate.setHours(
|
|
150
|
+
Math.floor(startDate.getHours() / stepHour) * stepHour,
|
|
151
|
+
0,
|
|
152
|
+
0,
|
|
153
|
+
0
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
while (true) {
|
|
157
|
+
const val = dateToFractionalYear(startDate);
|
|
158
|
+
if (val > domainMax) break;
|
|
159
|
+
if (val >= domainMin) {
|
|
160
|
+
const hh = String(startDate.getHours()).padStart(2, '0');
|
|
161
|
+
const mm = String(startDate.getMinutes()).padStart(2, '0');
|
|
162
|
+
if (singleDay) {
|
|
163
|
+
ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
|
|
164
|
+
} else {
|
|
165
|
+
const mon = MONTH_ABBR[startDate.getMonth()];
|
|
166
|
+
const d = startDate.getDate();
|
|
167
|
+
ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
startDate.setHours(startDate.getHours() + stepHour);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
|
|
174
|
+
for (let y = minYear; y <= maxYear + 1; y++) {
|
|
175
|
+
for (let m = 1; m <= 12; m++) {
|
|
176
|
+
for (const d of [1, 8, 15, 22]) {
|
|
177
|
+
const val = y + (m - 1) / 12 + (d - 1) / 365;
|
|
178
|
+
if (val > domainMax) break;
|
|
179
|
+
if (val >= domainMin) {
|
|
180
|
+
ticks.push({
|
|
181
|
+
pos: scale(val),
|
|
182
|
+
label: `${MONTH_ABBR[m - 1]} ${d}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Add boundary ticks at exact data start/end if provided
|
|
191
|
+
// When a boundary tick collides with a standard tick, replace the standard tick
|
|
192
|
+
const collisionThreshold = 40; // pixels
|
|
193
|
+
|
|
194
|
+
if (boundaryStart !== undefined && boundaryStartLabel) {
|
|
195
|
+
const boundaryPos = scale(boundaryStart);
|
|
196
|
+
// Remove any standard ticks that would collide with the start boundary
|
|
197
|
+
ticks = ticks.filter(
|
|
198
|
+
(t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
|
|
199
|
+
);
|
|
200
|
+
ticks.unshift({ pos: boundaryPos, label: boundaryStartLabel });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (boundaryEnd !== undefined && boundaryEndLabel) {
|
|
204
|
+
const boundaryPos = scale(boundaryEnd);
|
|
205
|
+
// Remove any standard ticks that would collide with the end boundary
|
|
206
|
+
ticks = ticks.filter(
|
|
207
|
+
(t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
|
|
208
|
+
);
|
|
209
|
+
ticks.push({ pos: boundaryPos, label: boundaryEndLabel });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return ticks;
|
|
213
|
+
}
|
package/src/branding.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { FONT_FAMILY } from './fonts';
|
|
2
|
-
|
|
3
|
-
const BRANDING_HEIGHT = 20;
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Injects `diagrammo.app` branding text into an SVG string.
|
|
7
|
-
* Extends the SVG height by 20px and places the text at the bottom-right.
|
|
8
|
-
*/
|
|
9
|
-
export function injectBranding(svgHtml: string, mutedColor: string): string {
|
|
10
|
-
if (!svgHtml) return svgHtml;
|
|
11
|
-
|
|
12
|
-
const brandingText = `<text x="0" y="0" font-size="10" font-family="${FONT_FAMILY}" fill="${mutedColor}" opacity="0.5" text-anchor="end">diagrammo.app</text>`;
|
|
13
|
-
|
|
14
|
-
// Parse viewBox
|
|
15
|
-
const vbMatch = svgHtml.match(/viewBox="([^"]+)"/);
|
|
16
|
-
const heightAttrMatch = svgHtml.match(/height="([^"]+)"/);
|
|
17
|
-
|
|
18
|
-
if (vbMatch) {
|
|
19
|
-
const parts = vbMatch[1].split(/\s+/).map(Number);
|
|
20
|
-
if (parts.length === 4) {
|
|
21
|
-
const [vx, vy, vw, vh] = parts;
|
|
22
|
-
const newVh = vh + BRANDING_HEIGHT;
|
|
23
|
-
const textX = vx + vw - 4;
|
|
24
|
-
const textY = vy + vh + BRANDING_HEIGHT - 6;
|
|
25
|
-
const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
|
|
26
|
-
|
|
27
|
-
let result = svgHtml.replace(
|
|
28
|
-
/viewBox="[^"]+"/,
|
|
29
|
-
`viewBox="${vx} ${vy} ${vw} ${newVh}"`
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
// Update height attribute if present
|
|
33
|
-
if (heightAttrMatch) {
|
|
34
|
-
const oldH = parseFloat(heightAttrMatch[1]);
|
|
35
|
-
if (!isNaN(oldH)) {
|
|
36
|
-
result = result.replace(
|
|
37
|
-
`height="${heightAttrMatch[1]}"`,
|
|
38
|
-
`height="${oldH + BRANDING_HEIGHT}"`
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
result = result.replace('</svg>', `${positioned}</svg>`);
|
|
44
|
-
return result;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Fallback: no viewBox, try width/height attributes
|
|
49
|
-
if (heightAttrMatch) {
|
|
50
|
-
const widthMatch = svgHtml.match(/width="([^"]+)"/);
|
|
51
|
-
const w = widthMatch ? parseFloat(widthMatch[1]) : 800;
|
|
52
|
-
const h = parseFloat(heightAttrMatch[1]);
|
|
53
|
-
if (!isNaN(h) && !isNaN(w)) {
|
|
54
|
-
const textX = w - 4;
|
|
55
|
-
const textY = h + BRANDING_HEIGHT - 6;
|
|
56
|
-
const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
|
|
57
|
-
let result = svgHtml.replace(
|
|
58
|
-
`height="${heightAttrMatch[1]}"`,
|
|
59
|
-
`height="${h + BRANDING_HEIGHT}"`
|
|
60
|
-
);
|
|
61
|
-
result = result.replace('</svg>', `${positioned}</svg>`);
|
|
62
|
-
return result;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return svgHtml;
|
|
67
|
-
}
|
package/src/dgmo-mermaid.ts
DELETED
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// .dgmo → Mermaid Translation Layer
|
|
3
|
-
// Parses dgmo quadrant syntax and generates valid Mermaid code.
|
|
4
|
-
// ============================================================
|
|
5
|
-
|
|
6
|
-
import { resolveColorWithDiagnostic } from './colors';
|
|
7
|
-
import type { DgmoError } from './diagnostics';
|
|
8
|
-
import { makeDgmoError, formatDgmoError } from './diagnostics';
|
|
9
|
-
|
|
10
|
-
// ============================================================
|
|
11
|
-
// Types
|
|
12
|
-
// ============================================================
|
|
13
|
-
|
|
14
|
-
interface QuadrantLabel {
|
|
15
|
-
text: string;
|
|
16
|
-
color: string | null;
|
|
17
|
-
lineNumber: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface ParsedQuadrant {
|
|
21
|
-
title: string | null;
|
|
22
|
-
titleLineNumber: number | null;
|
|
23
|
-
xAxis: [string, string] | null;
|
|
24
|
-
xAxisLineNumber: number | null;
|
|
25
|
-
yAxis: [string, string] | null;
|
|
26
|
-
yAxisLineNumber: number | null;
|
|
27
|
-
quadrants: {
|
|
28
|
-
topRight: QuadrantLabel | null;
|
|
29
|
-
topLeft: QuadrantLabel | null;
|
|
30
|
-
bottomLeft: QuadrantLabel | null;
|
|
31
|
-
bottomRight: QuadrantLabel | null;
|
|
32
|
-
};
|
|
33
|
-
points: { label: string; x: number; y: number; lineNumber: number }[];
|
|
34
|
-
diagnostics: DgmoError[];
|
|
35
|
-
error: string | null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ============================================================
|
|
39
|
-
// Parser
|
|
40
|
-
// ============================================================
|
|
41
|
-
|
|
42
|
-
/** Regex for quadrant label lines: `top-right Promote (green)` */
|
|
43
|
-
const QUADRANT_LABEL_RE = /^(.+?)(?:\s*\(([^)]+)\))?\s*$/;
|
|
44
|
-
|
|
45
|
-
/** Regex for data point lines: `Label 0.9, 0.5` */
|
|
46
|
-
const DATA_POINT_RE = /^(.+?)\s+([0-9]*\.?[0-9]+)\s*,\s*([0-9]*\.?[0-9]+)\s*$/;
|
|
47
|
-
|
|
48
|
-
const QUADRANT_POSITIONS = new Set([
|
|
49
|
-
'top-right',
|
|
50
|
-
'top-left',
|
|
51
|
-
'bottom-left',
|
|
52
|
-
'bottom-right',
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Parses a .dgmo quadrant document into a structured object.
|
|
57
|
-
* Lines are processed sequentially; unknown lines are silently skipped.
|
|
58
|
-
*/
|
|
59
|
-
export function parseQuadrant(content: string): ParsedQuadrant {
|
|
60
|
-
const result: ParsedQuadrant = {
|
|
61
|
-
title: null,
|
|
62
|
-
titleLineNumber: null,
|
|
63
|
-
xAxis: null,
|
|
64
|
-
xAxisLineNumber: null,
|
|
65
|
-
yAxis: null,
|
|
66
|
-
yAxisLineNumber: null,
|
|
67
|
-
quadrants: {
|
|
68
|
-
topRight: null,
|
|
69
|
-
topLeft: null,
|
|
70
|
-
bottomLeft: null,
|
|
71
|
-
bottomRight: null,
|
|
72
|
-
},
|
|
73
|
-
points: [],
|
|
74
|
-
diagnostics: [],
|
|
75
|
-
error: null,
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const lines = content.split('\n');
|
|
79
|
-
|
|
80
|
-
for (let i = 0; i < lines.length; i++) {
|
|
81
|
-
const line = lines[i].trim();
|
|
82
|
-
const lineNumber = i + 1; // 1-indexed for editor
|
|
83
|
-
|
|
84
|
-
// Skip empty lines and comments
|
|
85
|
-
if (!line || line.startsWith('//')) continue;
|
|
86
|
-
|
|
87
|
-
// Skip the chart: directive (already consumed by router)
|
|
88
|
-
if (/^chart\s*:/i.test(line)) continue;
|
|
89
|
-
|
|
90
|
-
// title <text>
|
|
91
|
-
const titleMatch = line.match(/^title\s+(.+)/i);
|
|
92
|
-
if (titleMatch) {
|
|
93
|
-
result.title = titleMatch[1].trim();
|
|
94
|
-
result.titleLineNumber = lineNumber;
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// x-label Low, High
|
|
99
|
-
const xMatch = line.match(/^x-label\s+(.+)/i);
|
|
100
|
-
if (xMatch) {
|
|
101
|
-
const parts = xMatch[1].split(',').map((s) => s.trim());
|
|
102
|
-
if (parts.length >= 2) {
|
|
103
|
-
result.xAxis = [parts[0], parts[1]];
|
|
104
|
-
result.xAxisLineNumber = lineNumber;
|
|
105
|
-
}
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// y-label Low, High
|
|
110
|
-
const yMatch = line.match(/^y-label\s+(.+)/i);
|
|
111
|
-
if (yMatch) {
|
|
112
|
-
const parts = yMatch[1].split(',').map((s) => s.trim());
|
|
113
|
-
if (parts.length >= 2) {
|
|
114
|
-
result.yAxis = [parts[0], parts[1]];
|
|
115
|
-
result.yAxisLineNumber = lineNumber;
|
|
116
|
-
}
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Quadrant position labels: top-right Label (color)
|
|
121
|
-
const posMatch = line.match(
|
|
122
|
-
/^(top-right|top-left|bottom-left|bottom-right)\s+(.+)/i
|
|
123
|
-
);
|
|
124
|
-
if (posMatch) {
|
|
125
|
-
const position = posMatch[1].toLowerCase();
|
|
126
|
-
const labelMatch = posMatch[2].match(QUADRANT_LABEL_RE);
|
|
127
|
-
if (labelMatch) {
|
|
128
|
-
const label: QuadrantLabel = {
|
|
129
|
-
text: labelMatch[1].trim(),
|
|
130
|
-
color: labelMatch[2]
|
|
131
|
-
? (resolveColorWithDiagnostic(
|
|
132
|
-
labelMatch[2].trim(),
|
|
133
|
-
lineNumber,
|
|
134
|
-
result.diagnostics
|
|
135
|
-
) ?? null)
|
|
136
|
-
: null,
|
|
137
|
-
lineNumber,
|
|
138
|
-
};
|
|
139
|
-
if (position === 'top-right') result.quadrants.topRight = label;
|
|
140
|
-
else if (position === 'top-left') result.quadrants.topLeft = label;
|
|
141
|
-
else if (position === 'bottom-left')
|
|
142
|
-
result.quadrants.bottomLeft = label;
|
|
143
|
-
else if (position === 'bottom-right')
|
|
144
|
-
result.quadrants.bottomRight = label;
|
|
145
|
-
}
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Data points: Label x, y
|
|
150
|
-
const pointMatch = line.match(DATA_POINT_RE);
|
|
151
|
-
if (pointMatch) {
|
|
152
|
-
// Make sure this isn't a quadrant position keyword
|
|
153
|
-
const key = pointMatch[1].trim().toLowerCase();
|
|
154
|
-
if (!QUADRANT_POSITIONS.has(key)) {
|
|
155
|
-
result.points.push({
|
|
156
|
-
label: pointMatch[1].trim(),
|
|
157
|
-
x: parseFloat(pointMatch[2]),
|
|
158
|
-
y: parseFloat(pointMatch[3]),
|
|
159
|
-
lineNumber,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (result.points.length === 0) {
|
|
167
|
-
const diag = makeDgmoError(
|
|
168
|
-
1,
|
|
169
|
-
'No data points found. Add lines like: Label 0.5, 0.7'
|
|
170
|
-
);
|
|
171
|
-
result.diagnostics.push(diag);
|
|
172
|
-
result.error = formatDgmoError(diag);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ============================================================
|
|
179
|
-
// Mermaid Builder
|
|
180
|
-
// ============================================================
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Generates valid Mermaid quadrantChart syntax from a parsed quadrant.
|
|
184
|
-
* Returns a string ready for the Mermaid renderer.
|
|
185
|
-
*/
|
|
186
|
-
export function buildMermaidQuadrant(
|
|
187
|
-
parsed: ParsedQuadrant,
|
|
188
|
-
options: {
|
|
189
|
-
isDark?: boolean;
|
|
190
|
-
textColor?: string;
|
|
191
|
-
mutedTextColor?: string;
|
|
192
|
-
} = {}
|
|
193
|
-
): string {
|
|
194
|
-
const { isDark = false, textColor, mutedTextColor } = options;
|
|
195
|
-
const lines: string[] = [];
|
|
196
|
-
|
|
197
|
-
// %%{init}%% block — fill colors with reduced opacity + text color overrides
|
|
198
|
-
const fillAlpha = isDark ? '30' : '55';
|
|
199
|
-
const primaryText = textColor ?? (isDark ? '#d0d0d0' : '#333333');
|
|
200
|
-
const quadrantLabelText = mutedTextColor ?? (isDark ? '#888888' : '#666666');
|
|
201
|
-
|
|
202
|
-
const colorMap: Record<string, string> = {};
|
|
203
|
-
if (parsed.quadrants.topRight?.color)
|
|
204
|
-
colorMap.quadrant1Fill = parsed.quadrants.topRight.color + fillAlpha;
|
|
205
|
-
if (parsed.quadrants.topLeft?.color)
|
|
206
|
-
colorMap.quadrant2Fill = parsed.quadrants.topLeft.color + fillAlpha;
|
|
207
|
-
if (parsed.quadrants.bottomLeft?.color)
|
|
208
|
-
colorMap.quadrant3Fill = parsed.quadrants.bottomLeft.color + fillAlpha;
|
|
209
|
-
if (parsed.quadrants.bottomRight?.color)
|
|
210
|
-
colorMap.quadrant4Fill = parsed.quadrants.bottomRight.color + fillAlpha;
|
|
211
|
-
|
|
212
|
-
// Quadrant labels use muted color, points use primary text color
|
|
213
|
-
colorMap.quadrant1TextFill = quadrantLabelText;
|
|
214
|
-
colorMap.quadrant2TextFill = quadrantLabelText;
|
|
215
|
-
colorMap.quadrant3TextFill = quadrantLabelText;
|
|
216
|
-
colorMap.quadrant4TextFill = quadrantLabelText;
|
|
217
|
-
colorMap.quadrantPointTextFill = primaryText;
|
|
218
|
-
colorMap.quadrantXAxisTextFill = primaryText;
|
|
219
|
-
colorMap.quadrantYAxisTextFill = primaryText;
|
|
220
|
-
colorMap.quadrantTitleFill = primaryText;
|
|
221
|
-
|
|
222
|
-
const vars = JSON.stringify(colorMap);
|
|
223
|
-
lines.push(`%%{init: {"themeVariables": ${vars}}}%%`);
|
|
224
|
-
|
|
225
|
-
lines.push('quadrantChart');
|
|
226
|
-
|
|
227
|
-
if (parsed.title) {
|
|
228
|
-
lines.push(` title ${parsed.title}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (parsed.xAxis) {
|
|
232
|
-
lines.push(` x-axis ${parsed.xAxis[0]} --> ${parsed.xAxis[1]}`);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (parsed.yAxis) {
|
|
236
|
-
lines.push(` y-axis ${parsed.yAxis[0]} --> ${parsed.yAxis[1]}`);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Helper to quote labels that need it (contain spaces or special chars)
|
|
240
|
-
const quote = (s: string): string => (/[\s,:[\]]/.test(s) ? `"${s}"` : s);
|
|
241
|
-
|
|
242
|
-
// Quadrant labels: 1=top-right, 2=top-left, 3=bottom-left, 4=bottom-right
|
|
243
|
-
if (parsed.quadrants.topRight) {
|
|
244
|
-
lines.push(` quadrant-1 ${quote(parsed.quadrants.topRight.text)}`);
|
|
245
|
-
}
|
|
246
|
-
if (parsed.quadrants.topLeft) {
|
|
247
|
-
lines.push(` quadrant-2 ${quote(parsed.quadrants.topLeft.text)}`);
|
|
248
|
-
}
|
|
249
|
-
if (parsed.quadrants.bottomLeft) {
|
|
250
|
-
lines.push(` quadrant-3 ${quote(parsed.quadrants.bottomLeft.text)}`);
|
|
251
|
-
}
|
|
252
|
-
if (parsed.quadrants.bottomRight) {
|
|
253
|
-
lines.push(` quadrant-4 ${quote(parsed.quadrants.bottomRight.text)}`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Data points
|
|
257
|
-
for (const point of parsed.points) {
|
|
258
|
-
lines.push(` ${quote(point.label)}: [${point.x}, ${point.y}]`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return lines.join('\n');
|
|
262
|
-
}
|