@diagrammo/dgmo 0.2.18 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/chart.ts CHANGED
@@ -20,6 +20,8 @@ export interface ChartDataPoint {
20
20
  lineNumber: number;
21
21
  }
22
22
 
23
+ import type { DgmoError } from './diagnostics';
24
+
23
25
  export interface ParsedChart {
24
26
  type: ChartType;
25
27
  title?: string;
@@ -34,6 +36,7 @@ export interface ParsedChart {
34
36
  label?: string;
35
37
  labels?: 'name' | 'value' | 'percent' | 'full';
36
38
  data: ChartDataPoint[];
39
+ diagnostics: DgmoError[];
37
40
  error?: string;
38
41
  }
39
42
 
@@ -43,6 +46,7 @@ export interface ParsedChart {
43
46
 
44
47
  import { resolveColor } from './colors';
45
48
  import type { PaletteColors } from './palettes';
49
+ import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
46
50
 
47
51
  // ============================================================
48
52
  // Parser
@@ -85,6 +89,14 @@ export function parseChart(
85
89
  const result: ParsedChart = {
86
90
  type: 'bar',
87
91
  data: [],
92
+ diagnostics: [],
93
+ };
94
+
95
+ const fail = (line: number, message: string): ParsedChart => {
96
+ const diag = makeDgmoError(line, message);
97
+ result.diagnostics.push(diag);
98
+ result.error = formatDgmoError(diag);
99
+ return result;
88
100
  };
89
101
 
90
102
  for (let i = 0; i < lines.length; i++) {
@@ -114,8 +126,10 @@ export function parseChart(
114
126
  if (VALID_TYPES.has(chartType)) {
115
127
  result.type = chartType;
116
128
  } else {
117
- result.error = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
118
- return result;
129
+ let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
130
+ const hint = suggest(raw, [...VALID_TYPES]);
131
+ if (hint) msg += ` ${hint}`;
132
+ return fail(lineNumber, msg);
119
133
  }
120
134
  continue;
121
135
  }
@@ -150,9 +164,12 @@ export function parseChart(
150
164
  }
151
165
 
152
166
  if (key === 'orientation') {
153
- const v = value.toLowerCase();
154
- if (v === 'horizontal' || v === 'vertical') {
155
- result.orientation = v;
167
+ // Only bar and bar-stacked support orientation (axis swapping)
168
+ if (result.type === 'bar' || result.type === 'bar-stacked') {
169
+ const v = value.toLowerCase();
170
+ if (v === 'horizontal' || v === 'vertical') {
171
+ result.orientation = v;
172
+ }
156
173
  }
157
174
  continue;
158
175
  }
@@ -219,12 +236,22 @@ export function parseChart(
219
236
  }
220
237
 
221
238
  // Validation
239
+ const setChartError = (line: number, message: string) => {
240
+ const diag = makeDgmoError(line, message);
241
+ result.diagnostics.push(diag);
242
+ result.error = formatDgmoError(diag);
243
+ };
244
+
245
+ const warn = (line: number, message: string): void => {
246
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
247
+ };
248
+
222
249
  if (!result.error && result.data.length === 0) {
223
- result.error = 'No data points found. Add data in format: Label: 123';
250
+ warn(1, 'No data points found. Add data in format: Label: 123');
224
251
  }
225
252
 
226
253
  if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
227
- result.error = `Chart type "bar-stacked" requires multiple series names. Use: series: Name1, Name2, Name3`;
254
+ setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series: Name1, Name2, Name3');
228
255
  }
229
256
 
230
257
  if (!result.error && result.seriesNames) {
@@ -232,10 +259,14 @@ export function parseChart(
232
259
  for (const dp of result.data) {
233
260
  const actualCount = 1 + (dp.extraValues?.length ?? 0);
234
261
  if (actualCount !== expectedCount) {
235
- result.error = `Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} comma-separated values.`;
236
- break;
262
+ warn(dp.lineNumber, `Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} comma-separated values.`);
237
263
  }
238
264
  }
265
+ // Filter out mismatched data points so renderers get clean data
266
+ result.data = result.data.filter((dp) => {
267
+ const actualCount = 1 + (dp.extraValues?.length ?? 0);
268
+ return actualCount === expectedCount;
269
+ });
239
270
  }
240
271
 
241
272
  return result;
@@ -1,5 +1,6 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import type {
4
5
  ParsedClassDiagram,
5
6
  ClassNode,
@@ -169,6 +170,15 @@ export function parseClassDiagram(
169
170
  type: 'class',
170
171
  classes: [],
171
172
  relationships: [],
173
+ options: {},
174
+ diagnostics: [],
175
+ };
176
+
177
+ const fail = (line: number, message: string): ParsedClassDiagram => {
178
+ const diag = makeDgmoError(line, message);
179
+ result.diagnostics.push(diag);
180
+ result.error = formatDgmoError(diag);
181
+ return result;
172
182
  };
173
183
 
174
184
  const classMap = new Map<string, ClassNode>();
@@ -216,8 +226,11 @@ export function parseClassDiagram(
216
226
  // Only recognize known metadata keys
217
227
  if (key === 'chart') {
218
228
  if (value.toLowerCase() !== 'class') {
219
- result.error = `Line ${lineNumber}: Expected chart type "class", got "${value}"`;
220
- return result;
229
+ const allTypes = ['class', 'flowchart', 'sequence', 'er', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
230
+ let msg = `Expected chart type "class", got "${value}"`;
231
+ const hint = suggest(value.toLowerCase(), allTypes);
232
+ if (hint) msg += `. ${hint}`;
233
+ return fail(lineNumber, msg);
221
234
  }
222
235
  continue;
223
236
  }
@@ -228,8 +241,11 @@ export function parseClassDiagram(
228
241
  continue;
229
242
  }
230
243
 
231
- // Unknown single-word keys are metadata — skip
232
- if (!/\s/.test(key)) continue;
244
+ // Store diagram-level options (e.g., color: off)
245
+ if (!/\s/.test(key)) {
246
+ result.options[key] = value;
247
+ continue;
248
+ }
233
249
  }
234
250
 
235
251
  // Indented lines = members of current class
@@ -314,8 +330,23 @@ export function parseClassDiagram(
314
330
 
315
331
  // Validation
316
332
  if (result.classes.length === 0 && !result.error) {
317
- result.error =
318
- 'No classes found. Add class declarations like "ClassName" or "ClassName [interface]".';
333
+ const diag = makeDgmoError(1, 'No classes found. Add class declarations like "ClassName" or "ClassName [interface]".');
334
+ result.diagnostics.push(diag);
335
+ result.error = formatDgmoError(diag);
336
+ }
337
+
338
+ // Warn about isolated classes (not in any relationship)
339
+ if (result.classes.length >= 2 && result.relationships.length >= 1 && !result.error) {
340
+ const connectedIds = new Set<string>();
341
+ for (const rel of result.relationships) {
342
+ connectedIds.add(rel.source);
343
+ connectedIds.add(rel.target);
344
+ }
345
+ for (const cls of result.classes) {
346
+ if (!connectedIds.has(cls.id)) {
347
+ result.diagnostics.push(makeDgmoError(cls.lineNumber, `Class "${cls.name}" is not connected to any other class`, 'warning'));
348
+ }
349
+ }
319
350
  }
320
351
 
321
352
  return result;
@@ -16,6 +16,7 @@ import { layoutClassDiagram } from './layout';
16
16
  // ============================================================
17
17
 
18
18
  const DIAGRAM_PADDING = 20;
19
+ const MAX_SCALE = 3;
19
20
  const CLASS_FONT_SIZE = 13;
20
21
  const MEMBER_FONT_SIZE = 11;
21
22
  const EDGE_LABEL_FONT_SIZE = 11;
@@ -40,7 +41,8 @@ function mix(a: string, b: string, pct: number): string {
40
41
  return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
41
42
  }
42
43
 
43
- function modifierColor(modifier: ClassModifier | undefined, palette: PaletteColors): string {
44
+ function modifierColor(modifier: ClassModifier | undefined, palette: PaletteColors, colorOff?: boolean): string {
45
+ if (colorOff) return palette.textMuted;
44
46
  switch (modifier) {
45
47
  case 'interface': return palette.colors.blue;
46
48
  case 'abstract': return palette.colors.purple;
@@ -49,13 +51,13 @@ function modifierColor(modifier: ClassModifier | undefined, palette: PaletteColo
49
51
  }
50
52
  }
51
53
 
52
- function nodeFill(palette: PaletteColors, isDark: boolean, modifier: ClassModifier | undefined, nodeColor?: string): string {
53
- const color = nodeColor ?? modifierColor(modifier, palette);
54
+ function nodeFill(palette: PaletteColors, isDark: boolean, modifier: ClassModifier | undefined, nodeColor?: string, colorOff?: boolean): string {
55
+ const color = nodeColor ?? modifierColor(modifier, palette, colorOff);
54
56
  return mix(color, isDark ? palette.surface : palette.bg, 20);
55
57
  }
56
58
 
57
- function nodeStroke(palette: PaletteColors, modifier: ClassModifier | undefined, nodeColor?: string): string {
58
- return nodeColor ?? modifierColor(modifier, palette);
59
+ function nodeStroke(palette: PaletteColors, modifier: ClassModifier | undefined, nodeColor?: string, colorOff?: boolean): string {
60
+ return nodeColor ?? modifierColor(modifier, palette, colorOff);
59
61
  }
60
62
 
61
63
  // ============================================================
@@ -130,7 +132,7 @@ export function renderClassDiagram(
130
132
  const availH = height - titleHeight;
131
133
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
132
134
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
133
- const scale = Math.min(1, scaleX, scaleY);
135
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
134
136
 
135
137
  const scaledW = diagramW * scale;
136
138
  const scaledH = diagramH * scale;
@@ -340,8 +342,9 @@ export function renderClassDiagram(
340
342
 
341
343
  const w = node.width;
342
344
  const h = node.height;
343
- const fill = nodeFill(palette, isDark, node.modifier, node.color);
344
- const stroke = nodeStroke(palette, node.modifier, node.color);
345
+ const colorOff = parsed.options?.color === 'off';
346
+ const fill = nodeFill(palette, isDark, node.modifier, node.color, colorOff);
347
+ const stroke = nodeStroke(palette, node.modifier, node.color, colorOff);
345
348
 
346
349
  // Outer rectangle
347
350
  nodeG.append('rect')
@@ -41,11 +41,15 @@ export interface ClassRelationship {
41
41
  lineNumber: number;
42
42
  }
43
43
 
44
+ import type { DgmoError } from '../diagnostics';
45
+
44
46
  export interface ParsedClassDiagram {
45
47
  type: 'class';
46
48
  title?: string;
47
49
  titleLineNumber?: number;
48
50
  classes: ClassNode[];
49
51
  relationships: ClassRelationship[];
52
+ options: Record<string, string>;
53
+ diagnostics: DgmoError[];
50
54
  error?: string;
51
55
  }
package/src/cli.ts CHANGED
@@ -3,9 +3,13 @@ import { execSync } from 'node:child_process';
3
3
  import { resolve, basename, extname } from 'node:path';
4
4
  import { Resvg } from '@resvg/resvg-js';
5
5
  import { render } from './render';
6
+ import { parseDgmo } from './dgmo-router';
7
+ import { parseDgmoChartType } from './dgmo-router';
8
+ import { formatDgmoError } from './diagnostics';
6
9
  import { getPalette } from './palettes/registry';
7
10
  import { DEFAULT_FONT_NAME } from './fonts';
8
11
  import { encodeDiagramUrl } from './sharing';
12
+ import { resolveOrgImports } from './org/resolver';
9
13
 
10
14
  const PALETTES = [
11
15
  'nord',
@@ -220,6 +224,20 @@ async function main(): Promise<void> {
220
224
  noInput();
221
225
  }
222
226
 
227
+ // Resolve org chart imports (tags: and import: directives)
228
+ if (opts.input && parseDgmoChartType(content) === 'org') {
229
+ const inputPath = resolve(opts.input);
230
+ const resolved = await resolveOrgImports(
231
+ content,
232
+ inputPath,
233
+ (p) => readFileSync(p, 'utf-8'),
234
+ );
235
+ for (const diag of resolved.diagnostics) {
236
+ console.error(formatDgmoError(diag));
237
+ }
238
+ content = resolved.content;
239
+ }
240
+
223
241
  // Determine output format early to handle URL before rendering
224
242
  const format = inferFormat(opts.output);
225
243
 
@@ -271,6 +289,21 @@ async function main(): Promise<void> {
271
289
  process.exit(1);
272
290
  }
273
291
 
292
+ // Parse first to collect diagnostics
293
+ const { diagnostics } = parseDgmo(content);
294
+ const errors = diagnostics.filter((d) => d.severity === 'error');
295
+ const warnings = diagnostics.filter((d) => d.severity === 'warning');
296
+
297
+ // Print warnings even if rendering succeeds
298
+ for (const w of warnings) {
299
+ console.error(`\u26A0 ${formatDgmoError(w)}`);
300
+ }
301
+
302
+ // Print errors
303
+ for (const e of errors) {
304
+ console.error(`\u2716 ${formatDgmoError(e)}`);
305
+ }
306
+
274
307
  const svg = await render(content, {
275
308
  theme: opts.theme,
276
309
  palette: opts.palette,
@@ -278,9 +311,11 @@ async function main(): Promise<void> {
278
311
  });
279
312
 
280
313
  if (!svg) {
281
- console.error(
282
- 'Error: Failed to render diagram. The input may be empty, invalid, or use an unsupported chart type.'
283
- );
314
+ if (errors.length === 0) {
315
+ console.error(
316
+ 'Error: Failed to render diagram. The input may be empty, invalid, or use an unsupported chart type.'
317
+ );
318
+ }
284
319
  process.exit(1);
285
320
  }
286
321
 
package/src/d3.ts CHANGED
@@ -167,6 +167,7 @@ export interface ParsedD3 {
167
167
  quadrantYAxis: [string, string] | null;
168
168
  quadrantYAxisLineNumber: number | null;
169
169
  quadrantTitleLineNumber: number | null;
170
+ diagnostics: DgmoError[];
170
171
  error: string | null;
171
172
  }
172
173
 
@@ -177,6 +178,8 @@ export interface ParsedD3 {
177
178
  import { resolveColor } from './colors';
178
179
  import type { PaletteColors } from './palettes';
179
180
  import { getSeriesColors } from './palettes';
181
+ import type { DgmoError } from './diagnostics';
182
+ import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
180
183
 
181
184
  // ============================================================
182
185
  // Timeline Date Helper
@@ -298,12 +301,19 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
298
301
  quadrantYAxis: null,
299
302
  quadrantYAxisLineNumber: null,
300
303
  quadrantTitleLineNumber: null,
304
+ diagnostics: [],
301
305
  error: null,
302
306
  };
303
307
 
304
- if (!content || !content.trim()) {
305
- result.error = 'Empty content';
308
+ const fail = (line: number, message: string): ParsedD3 => {
309
+ const diag = makeDgmoError(line, message);
310
+ result.diagnostics.push(diag);
311
+ result.error = formatDgmoError(diag);
306
312
  return result;
313
+ };
314
+
315
+ if (!content || !content.trim()) {
316
+ return fail(0, 'Empty content');
307
317
  }
308
318
 
309
319
  const lines = content.split('\n');
@@ -604,8 +614,11 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
604
614
  ) {
605
615
  result.type = value;
606
616
  } else {
607
- result.error = `Unsupported chart type: ${value}. Supported types: slope, wordcloud, arc, timeline, venn, quadrant, sequence`;
608
- return result;
617
+ const validD3Types = ['slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant', 'sequence'];
618
+ let msg = `Unsupported chart type: ${value}. Supported types: ${validD3Types.join(', ')}`;
619
+ const hint = suggest(value, validD3Types);
620
+ if (hint) msg += `. ${hint}`;
621
+ return fail(lineNumber, msg);
609
622
  }
610
623
  continue;
611
624
  }
@@ -620,12 +633,15 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
620
633
  }
621
634
 
622
635
  if (key === 'orientation') {
623
- const v = line
624
- .substring(colonIndex + 1)
625
- .trim()
626
- .toLowerCase();
627
- if (v === 'horizontal' || v === 'vertical') {
628
- result.orientation = v;
636
+ // Only arc and timeline support orientation
637
+ if (result.type === 'arc' || result.type === 'timeline') {
638
+ const v = line
639
+ .substring(colonIndex + 1)
640
+ .trim()
641
+ .toLowerCase();
642
+ if (v === 'horizontal' || v === 'vertical') {
643
+ result.orientation = v;
644
+ }
629
645
  }
630
646
  continue;
631
647
  }
@@ -783,8 +799,7 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
783
799
 
784
800
  // Validation
785
801
  if (!result.type) {
786
- result.error = 'Missing required "chart:" line (e.g., "chart: slope")';
787
- return result;
802
+ return fail(1, 'Missing required "chart:" line (e.g., "chart: slope")');
788
803
  }
789
804
 
790
805
  // Sequence diagrams are parsed by their own dedicated parser
@@ -792,15 +807,17 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
792
807
  return result;
793
808
  }
794
809
 
810
+ const warn = (line: number, message: string): void => {
811
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
812
+ };
813
+
795
814
  if (result.type === 'wordcloud') {
796
815
  // If no structured words were found, parse freeform text as word frequencies
797
816
  if (result.words.length === 0 && freeformLines.length > 0) {
798
817
  result.words = tokenizeFreeformText(freeformLines.join(' '));
799
818
  }
800
819
  if (result.words.length === 0) {
801
- result.error =
802
- 'No words found. Add words as "word: weight", one per line, or paste freeform text';
803
- return result;
820
+ warn(1, 'No words found. Add words as "word: weight", one per line, or paste freeform text');
804
821
  }
805
822
  // Apply max word limit (words are already sorted by weight desc for freeform)
806
823
  if (
@@ -817,15 +834,13 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
817
834
 
818
835
  if (result.type === 'arc') {
819
836
  if (result.links.length === 0) {
820
- result.error =
821
- 'No links found. Add links as "Source -> Target: weight" (e.g., "Alice -> Bob: 5")';
822
- return result;
837
+ warn(1, 'No links found. Add links as "Source -> Target: weight" (e.g., "Alice -> Bob: 5")');
823
838
  }
824
839
  // Validate arc ordering vs groups
825
840
  if (result.arcNodeGroups.length > 0) {
826
841
  if (result.arcOrder === 'name' || result.arcOrder === 'degree') {
827
- result.error = `Cannot use "order: ${result.arcOrder}" with ## section headers. Use "order: group" or remove section headers.`;
828
- return result;
842
+ warn(1, `Cannot use "order: ${result.arcOrder}" with ## section headers. Use "order: group" or remove section headers.`);
843
+ result.arcOrder = 'group';
829
844
  }
830
845
  if (result.arcOrder === 'appearance') {
831
846
  result.arcOrder = 'group';
@@ -836,70 +851,67 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
836
851
 
837
852
  if (result.type === 'timeline') {
838
853
  if (result.timelineEvents.length === 0) {
839
- result.error =
840
- 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"';
841
- return result;
854
+ warn(1, 'No events found. Add events as "YYYY: description" or "YYYY->YYYY: description"');
842
855
  }
843
856
  return result;
844
857
  }
845
858
 
846
859
  if (result.type === 'venn') {
847
860
  if (result.vennSets.length < 2) {
848
- result.error =
849
- 'At least 2 sets are required. Add sets as "Name: size" (e.g., "Math: 100")';
850
- return result;
861
+ return fail(1, 'At least 2 sets are required. Add sets as "Name: size" (e.g., "Math: 100")');
851
862
  }
852
863
  if (result.vennSets.length > 3) {
853
- result.error = 'At most 3 sets are supported. Remove extra sets.';
854
- return result;
864
+ return fail(1, 'At most 3 sets are supported. Remove extra sets.');
855
865
  }
856
- // Validate overlap references and sizes
866
+ // Validate overlap references and sizes — skip invalid overlaps
857
867
  const setMap = new Map(result.vennSets.map((s) => [s.name, s.size]));
868
+ const validOverlaps = [];
858
869
  for (const ov of result.vennOverlaps) {
870
+ let valid = true;
859
871
  for (const setName of ov.sets) {
860
872
  if (!setMap.has(setName)) {
861
- result.error = `Overlap references unknown set "${setName}". Define it first as "${setName}: <size>"`;
862
- return result;
873
+ result.diagnostics.push(makeDgmoError(ov.lineNumber, `Overlap references unknown set "${setName}". Define it first as "${setName}: <size>"`));
874
+ if (!result.error) result.error = formatDgmoError(result.diagnostics[result.diagnostics.length - 1]);
875
+ valid = false;
876
+ break;
863
877
  }
864
878
  }
879
+ if (!valid) continue;
865
880
  const minSetSize = Math.min(...ov.sets.map((s) => setMap.get(s)!));
866
881
  if (ov.size > minSetSize) {
867
- result.error = `Overlap size ${ov.size} exceeds smallest constituent set size ${minSetSize}`;
868
- return result;
882
+ warn(ov.lineNumber, `Overlap size ${ov.size} exceeds smallest constituent set size ${minSetSize}`);
869
883
  }
884
+ validOverlaps.push(ov);
870
885
  }
886
+ result.vennOverlaps = validOverlaps;
871
887
  return result;
872
888
  }
873
889
 
874
890
  if (result.type === 'quadrant') {
875
891
  if (result.quadrantPoints.length === 0) {
876
- result.error =
877
- 'No data points found. Add points as "Label: x, y" (e.g., "Item A: 0.5, 0.7")';
878
- return result;
892
+ warn(1, 'No data points found. Add points as "Label: x, y" (e.g., "Item A: 0.5, 0.7")');
879
893
  }
880
894
  return result;
881
895
  }
882
896
 
883
897
  // Slope chart validation
884
898
  if (result.periods.length < 2) {
885
- result.error =
886
- 'Missing or invalid periods line. Provide at least 2 comma-separated period labels (e.g., "2020, 2024")';
887
- return result;
899
+ return fail(1, 'Missing or invalid periods line. Provide at least 2 comma-separated period labels (e.g., "2020, 2024")');
888
900
  }
889
901
 
890
902
  if (result.data.length === 0) {
891
- result.error =
892
- 'No data lines found. Add data as "Label: value1, value2" (e.g., "Apple: 25, 35")';
893
- return result;
903
+ warn(1, 'No data lines found. Add data as "Label: value1, value2" (e.g., "Apple: 25, 35")');
894
904
  }
895
905
 
896
- // Validate value counts match period count
906
+ // Validate value counts match period count — warn and skip mismatched items
897
907
  for (const item of result.data) {
898
908
  if (item.values.length !== result.periods.length) {
899
- result.error = `Data item "${item.label}" has ${item.values.length} value(s) but ${result.periods.length} period(s) are defined`;
900
- return result;
909
+ warn(item.lineNumber, `Data item "${item.label}" has ${item.values.length} value(s) but ${result.periods.length} period(s) are defined`);
901
910
  }
902
911
  }
912
+ result.data = result.data.filter(
913
+ (item) => item.values.length === result.periods.length
914
+ );
903
915
 
904
916
  return result;
905
917
  }
@@ -2916,6 +2928,34 @@ export function renderTimeline(
2916
2928
  if (ev.endDate) {
2917
2929
  const y2 = yScale(parseTimelineDate(ev.endDate));
2918
2930
  const rectH = Math.max(y2 - y, 4);
2931
+
2932
+ let fill: string = laneColor;
2933
+ if (ev.uncertain) {
2934
+ const gradientId = `uncertain-vg-${ev.lineNumber}`;
2935
+ const defs =
2936
+ svg.select('defs').node() || svg.append('defs').node();
2937
+ d3Selection
2938
+ .select(defs as Element)
2939
+ .append('linearGradient')
2940
+ .attr('id', gradientId)
2941
+ .attr('x1', '0%')
2942
+ .attr('y1', '0%')
2943
+ .attr('x2', '0%')
2944
+ .attr('y2', '100%')
2945
+ .selectAll('stop')
2946
+ .data([
2947
+ { offset: '0%', opacity: 1 },
2948
+ { offset: '80%', opacity: 1 },
2949
+ { offset: '100%', opacity: 0 },
2950
+ ])
2951
+ .enter()
2952
+ .append('stop')
2953
+ .attr('offset', (d) => d.offset)
2954
+ .attr('stop-color', laneColor)
2955
+ .attr('stop-opacity', (d) => d.opacity);
2956
+ fill = `url(#${gradientId})`;
2957
+ }
2958
+
2919
2959
  evG
2920
2960
  .append('rect')
2921
2961
  .attr('x', laneCenter - 6)
@@ -2923,7 +2963,7 @@ export function renderTimeline(
2923
2963
  .attr('width', 12)
2924
2964
  .attr('height', rectH)
2925
2965
  .attr('rx', 4)
2926
- .attr('fill', laneColor);
2966
+ .attr('fill', fill);
2927
2967
  evG
2928
2968
  .append('text')
2929
2969
  .attr('x', laneCenter + 14)
@@ -3127,6 +3167,34 @@ export function renderTimeline(
3127
3167
  if (ev.endDate) {
3128
3168
  const y2 = yScale(parseTimelineDate(ev.endDate));
3129
3169
  const rectH = Math.max(y2 - y, 4);
3170
+
3171
+ let fill: string = color;
3172
+ if (ev.uncertain) {
3173
+ const gradientId = `uncertain-v-${ev.lineNumber}`;
3174
+ const defs =
3175
+ svg.select('defs').node() || svg.append('defs').node();
3176
+ d3Selection
3177
+ .select(defs as Element)
3178
+ .append('linearGradient')
3179
+ .attr('id', gradientId)
3180
+ .attr('x1', '0%')
3181
+ .attr('y1', '0%')
3182
+ .attr('x2', '0%')
3183
+ .attr('y2', '100%')
3184
+ .selectAll('stop')
3185
+ .data([
3186
+ { offset: '0%', opacity: 1 },
3187
+ { offset: '80%', opacity: 1 },
3188
+ { offset: '100%', opacity: 0 },
3189
+ ])
3190
+ .enter()
3191
+ .append('stop')
3192
+ .attr('offset', (d) => d.offset)
3193
+ .attr('stop-color', color)
3194
+ .attr('stop-opacity', (d) => d.opacity);
3195
+ fill = `url(#${gradientId})`;
3196
+ }
3197
+
3130
3198
  evG
3131
3199
  .append('rect')
3132
3200
  .attr('x', axisX - 6)
@@ -3134,7 +3202,7 @@ export function renderTimeline(
3134
3202
  .attr('width', 12)
3135
3203
  .attr('height', rectH)
3136
3204
  .attr('rx', 4)
3137
- .attr('fill', color);
3205
+ .attr('fill', fill);
3138
3206
  evG
3139
3207
  .append('text')
3140
3208
  .attr('x', axisX + 16)
@@ -4,6 +4,8 @@
4
4
  // ============================================================
5
5
 
6
6
  import { resolveColor } from './colors';
7
+ import type { DgmoError } from './diagnostics';
8
+ import { makeDgmoError, formatDgmoError } from './diagnostics';
7
9
 
8
10
  // ============================================================
9
11
  // Types
@@ -29,6 +31,7 @@ export interface ParsedQuadrant {
29
31
  bottomRight: QuadrantLabel | null;
30
32
  };
31
33
  points: { label: string; x: number; y: number; lineNumber: number }[];
34
+ diagnostics: DgmoError[];
32
35
  error: string | null;
33
36
  }
34
37
 
@@ -68,6 +71,7 @@ export function parseQuadrant(content: string): ParsedQuadrant {
68
71
  bottomRight: null,
69
72
  },
70
73
  points: [],
74
+ diagnostics: [],
71
75
  error: null,
72
76
  };
73
77
 
@@ -154,7 +158,9 @@ export function parseQuadrant(content: string): ParsedQuadrant {
154
158
  }
155
159
 
156
160
  if (result.points.length === 0) {
157
- result.error = 'No data points found. Add lines like: Label: 0.5, 0.7';
161
+ const diag = makeDgmoError(1, 'No data points found. Add lines like: Label: 0.5, 0.7');
162
+ result.diagnostics.push(diag);
163
+ result.error = formatDgmoError(diag);
158
164
  }
159
165
 
160
166
  return result;