@diagrammo/dgmo 0.34.0 → 0.35.0

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 (55) hide show
  1. package/.claude/commands/dgmo.md +17 -32
  2. package/.github/copilot-instructions.md +1 -1
  3. package/README.md +7 -1
  4. package/SKILL.md +1 -1
  5. package/dist/advanced.cjs +462 -465
  6. package/dist/advanced.d.cts +2 -51
  7. package/dist/advanced.d.ts +2 -51
  8. package/dist/advanced.js +462 -466
  9. package/dist/auto.cjs +463 -142
  10. package/dist/auto.js +91 -91
  11. package/dist/auto.mjs +463 -142
  12. package/dist/cli.cjs +143 -326
  13. package/dist/index.cjs +462 -141
  14. package/dist/index.js +462 -141
  15. package/docs/ai-integration.md +46 -26
  16. package/docs/language-reference.md +2 -2
  17. package/package.json +2 -2
  18. package/src/advanced.ts +0 -12
  19. package/src/c4/parser.ts +15 -6
  20. package/src/chart.ts +1 -1
  21. package/src/class/parser.ts +6 -2
  22. package/src/cli-banner.ts +2 -3
  23. package/src/cli.ts +539 -1223
  24. package/src/cycle/parser.ts +4 -1
  25. package/src/dgmo-router.ts +16 -2
  26. package/src/diagnostics.ts +0 -0
  27. package/src/echarts.ts +5 -1
  28. package/src/er/parser.ts +5 -1
  29. package/src/gantt/parser.ts +13 -4
  30. package/src/graph/flowchart-parser.ts +5 -1
  31. package/src/graph/state-parser.ts +5 -1
  32. package/src/infra/parser.ts +10 -6
  33. package/src/journey-map/parser.ts +5 -2
  34. package/src/kanban/parser.ts +6 -2
  35. package/src/map/context-labels.ts +29 -19
  36. package/src/map/layout.ts +403 -108
  37. package/src/map/legend-band.ts +28 -0
  38. package/src/map/parser.ts +13 -4
  39. package/src/map/renderer.ts +74 -23
  40. package/src/map/resolver.ts +2 -2
  41. package/src/org/parser.ts +8 -2
  42. package/src/pyramid/parser.ts +4 -1
  43. package/src/raci/parser.ts +4 -1
  44. package/src/ring/parser.ts +4 -1
  45. package/src/sequence/parser.ts +6 -3
  46. package/src/sitemap/parser.ts +12 -3
  47. package/src/tech-radar/parser.ts +6 -3
  48. package/src/utils/parsing.ts +2 -2
  49. package/src/utils/tag-groups.ts +3 -3
  50. package/src/visualizations/parse.ts +9 -3
  51. package/src/migrate/data-comma.ts +0 -117
  52. package/src/migrate/embedded.ts +0 -238
  53. package/src/migrate/index.ts +0 -295
  54. package/src/migrate/line-classifier.ts +0 -128
  55. package/src/migrate/line-transformer.ts +0 -218
@@ -97,3 +97,31 @@ export function mapLegendBand(
97
97
  mapLegendTop(opts.hasTitle, opts.hasSubtitle) + height + LEGEND_BOTTOM_GAP
98
98
  );
99
99
  }
100
+
101
+ /** The legend's actual centred bounding box (top-centre overlay), or null when no
102
+ * legend is drawn. Used to feed the legend into the on-map label collision set so
103
+ * a region / context name never lands under it. Mirrors `mapLegendBand` but keeps
104
+ * the width + x rather than collapsing to a band height. */
105
+ export function mapLegendBox(
106
+ legend: MapLayoutLegend | null,
107
+ opts: {
108
+ width: number;
109
+ mode: LegendMode;
110
+ hasTitle: boolean;
111
+ hasSubtitle: boolean;
112
+ }
113
+ ): { x: number; y: number; width: number; height: number } | null {
114
+ if (!legend) return null;
115
+ const groups = mapLegendGroups(legend);
116
+ if (groups.length === 0) return null;
117
+ const config = mapLegendConfig(groups, opts.mode);
118
+ const state: LegendState = { activeGroup: legend.activeGroup };
119
+ const { width, height } = computeLegendLayout(config, state, opts.width);
120
+ if (height <= 0 || width <= 0) return null;
121
+ return {
122
+ x: (opts.width - width) / 2,
123
+ y: mapLegendTop(opts.hasTitle, opts.hasSubtitle),
124
+ width,
125
+ height,
126
+ };
127
+ }
package/src/map/parser.ts CHANGED
@@ -369,7 +369,10 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
369
369
  function handleTag(trimmed: string, line: number): void {
370
370
  const m = matchTagBlockHeading(trimmed);
371
371
  if (!m) {
372
- pushError(line, `Malformed tag declaration: "${trimmed}".`);
372
+ pushError(
373
+ line,
374
+ `Malformed tag declaration: "${trimmed}" — write 'tag <Group>' (optionally 'tag <Group> as <alias>'), e.g. 'tag Market as m', with indented 'Value color' entries below.`
375
+ );
373
376
  return;
374
377
  }
375
378
  emitTagLegacyDiagnostic(m, line, diagnostics);
@@ -500,7 +503,7 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
500
503
  if (!pos || (pos.kind === 'name' && !pos.name)) {
501
504
  pushError(
502
505
  line,
503
- 'route requires an origin: `route <origin> [style: arc]`.'
506
+ 'route needs an origin place — `route <origin>` then indented legs (`-> dest`); origin may be a city name, IATA code, or `<lat> <lon>`. (§24B)'
504
507
  );
505
508
  return;
506
509
  }
@@ -579,7 +582,10 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
579
582
  const parts = trimmed.split(ARROW_SPLIT);
580
583
  // parts = [ep0, tok0, ep1, tok1, ep2, ...]
581
584
  if (parts.length < 3) {
582
- pushError(line, `Malformed edge: "${trimmed}".`);
585
+ pushError(
586
+ line,
587
+ `Malformed edge: "${trimmed}" — an edge is 'A -> B' (or 'A -label-> B', '~>' for arc, '--' undirected), both endpoints non-empty. (§24B)`
588
+ );
583
589
  return;
584
590
  }
585
591
  const endpoints: string[] = [];
@@ -609,7 +615,10 @@ export function parseMap(content: string, palette?: PaletteColors): ParsedMap {
609
615
  const from = endpoints[k]!;
610
616
  const to = endpoints[k + 1]!;
611
617
  if (!from || !to) {
612
- pushError(line, `Edge has an empty endpoint: "${trimmed}".`);
618
+ pushError(
619
+ line,
620
+ `Edge has an empty endpoint: "${trimmed}" — both sides of the arrow need a place, e.g. 'Paris -> Berlin'. (§24B)`
621
+ );
613
622
  continue;
614
623
  }
615
624
  const isLast = k === links.length - 1;
@@ -136,7 +136,8 @@ function coastlineOuterRings(
136
136
  const paths: string[] = [];
137
137
  for (const r of regions) {
138
138
  // Reuse the rings parsed once in layoutMap; fall back for older layouts.
139
- const rings = (r.rings as Array<Array<[number, number]>>) ?? parsePathRings(r.d);
139
+ const rings =
140
+ (r.rings as Array<Array<[number, number]>>) ?? parsePathRings(r.d);
140
141
  for (let i = 0; i < rings.length; i++) {
141
142
  const ring = rings[i]!;
142
143
  if (ring.length < 3) continue;
@@ -684,10 +685,7 @@ export function renderMap(
684
685
  for (const r of layout.regions) {
685
686
  // bbox is precomputed once in layoutMap (roadmap #2); fall back to
686
687
  // parsing only for layouts predating that field.
687
- let minX: number,
688
- minY: number,
689
- maxX: number,
690
- maxY: number;
688
+ let minX: number, minY: number, maxX: number, maxY: number;
691
689
  if (r.bbox) {
692
690
  [minX, minY, maxX, maxY] = r.bbox;
693
691
  } else {
@@ -898,10 +896,67 @@ export function renderMap(
898
896
  .append('g')
899
897
  .attr('class', 'dgmo-map-legs')
900
898
  .attr('fill', 'none');
901
- layout.legs.forEach((leg, i) => {
899
+ // Parse a single-segment leg path (`M…L…` or `M…Q…`) into its end point and the
900
+ // unit tangent arriving there. Returns null for anything unexpected (then the
901
+ // leg renders without the trimmed-arrow treatment).
902
+ const legEnd = (
903
+ d: string
904
+ ): { ex: number; ey: number; tx: number; ty: number } | null => {
905
+ const m =
906
+ /^M(-?[\d.]+),(-?[\d.]+)(?:L(-?[\d.]+),(-?[\d.]+)|Q(-?[\d.]+),(-?[\d.]+) (-?[\d.]+),(-?[\d.]+))$/.exec(
907
+ d
908
+ );
909
+ if (!m) return null;
910
+ const x0 = +m[1]!;
911
+ const y0 = +m[2]!;
912
+ let ex: number;
913
+ let ey: number;
914
+ let fromX: number;
915
+ let fromY: number;
916
+ if (m[3] !== undefined) {
917
+ ex = +m[3]!;
918
+ ey = +m[4]!;
919
+ fromX = x0;
920
+ fromY = y0;
921
+ } else {
922
+ ex = +m[7]!;
923
+ ey = +m[8]!;
924
+ fromX = +m[5]!; // quadratic control → tangent at the end
925
+ fromY = +m[6]!;
926
+ }
927
+ const dx = ex - fromX;
928
+ const dy = ey - fromY;
929
+ const len = Math.hypot(dx, dy);
930
+ if (!(len > 0)) return null;
931
+ return { ex, ey, tx: dx / len, ty: dy / len };
932
+ };
933
+ layout.legs.forEach((leg) => {
934
+ // A directed leg's stroke is TRIMMED back to the arrowhead base so a heavy
935
+ // weighted line doesn't run through (and bulge past) the narrowing arrowhead.
936
+ // The arrowhead itself is drawn as a filled triangle at the true endpoint,
937
+ // sized to always be a touch wider than the stroke so the stroke end is fully
938
+ // covered. Undirected (or unparseable) legs keep the full path.
939
+ const end = leg.arrow ? legEnd(leg.d) : null;
940
+ let drawD = leg.d;
941
+ let arrowTri: string | null = null;
942
+ if (end) {
943
+ const aLen = arrowSize(leg.width); // arrow depth along the line
944
+ const aHalf = Math.max(aLen * 0.5, leg.width * 0.6 + 1); // half base ≥ stroke
945
+ const bx = end.ex - end.tx * aLen; // base centre
946
+ const by = end.ey - end.ty * aLen;
947
+ const px = -end.ty; // perpendicular
948
+ const py = end.tx;
949
+ arrowTri = `M${bx + px * aHalf},${by + py * aHalf}L${end.ex},${end.ey}L${bx - px * aHalf},${by - py * aHalf}z`;
950
+ // Re-point the stroke's end at the arrow base (small along-tangent shift; for
951
+ // an arc leg the negligible end-curvature change is hidden by the arrowhead).
952
+ drawD = leg.d.replace(
953
+ /(-?[\d.]+),(-?[\d.]+)$/,
954
+ `${bx.toFixed(2)},${by.toFixed(2)}`
955
+ );
956
+ }
902
957
  const p = gLegs
903
958
  .append('path')
904
- .attr('d', leg.d)
959
+ .attr('d', drawD)
905
960
  .attr('stroke', leg.color)
906
961
  .attr('stroke-width', leg.width)
907
962
  .attr('stroke-linecap', 'round')
@@ -918,23 +973,19 @@ export function renderMap(
918
973
  // A 0-width invisible leg path is hard to hit; pointer-events on the visible
919
974
  // stroke is enough for the line widths legs use.
920
975
  wireSync(p, leg.lineNumber);
921
- if (leg.arrow) {
922
- const id = nid(`dgmo-map-arrow-${i}`);
923
- const s = arrowSize(leg.width);
924
- defs
925
- .append('marker')
926
- .attr('id', id)
927
- .attr('viewBox', '0 0 10 10')
928
- .attr('refX', 10)
929
- .attr('refY', 5)
930
- .attr('markerUnits', 'userSpaceOnUse')
931
- .attr('markerWidth', s)
932
- .attr('markerHeight', s)
933
- .attr('orient', 'auto-start-reverse')
976
+ if (arrowTri) {
977
+ // Filled arrowhead at the true endpoint — a flat triangle whose base sits
978
+ // where the trimmed stroke ends, so the line meets it cleanly with no
979
+ // show-through. Round join keeps the tip from looking spiky on thin legs.
980
+ gLegs
934
981
  .append('path')
935
- .attr('d', 'M0,0L10,5L0,10z')
936
- .attr('fill', leg.color);
937
- p.attr('marker-end', `url(#${id})`);
982
+ .attr('d', arrowTri)
983
+ .attr('fill', leg.color)
984
+ .attr('stroke', leg.color)
985
+ .attr('stroke-width', 1)
986
+ .attr('stroke-linejoin', 'round')
987
+ .attr('data-from-id', leg.fromId)
988
+ .attr('data-to-id', leg.toId);
938
989
  }
939
990
  if (leg.label !== undefined && leg.labelX !== undefined) {
940
991
  // Text shade is contrast-picked in layout against the fill under the label
@@ -420,7 +420,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
420
420
  const hint = suggest(r.name, allNames);
421
421
  err(
422
422
  r.lineNumber,
423
- `Unknown subdivision "${r.name}".${hint ? ' ' + hint : ''}`,
423
+ `Unknown region "${r.name}" — not a known country or US state.${hint ? ' ' + hint : ''} Search the exact token with \`dgmo map-search "${r.name}"\` (or the lookup_map_location tool), use an ISO code (e.g. FR, US-CA), or coordinates.`,
424
424
  'E_MAP_UNKNOWN_SUBDIVISION'
425
425
  );
426
426
  continue;
@@ -527,7 +527,7 @@ export function resolveMap(parsed: ParsedMap, data: MapData): ResolvedMap {
527
527
  const hint = suggest(name, cityNames);
528
528
  err(
529
529
  line,
530
- `Unknown place "${name}" (not in the gazetteer; use coordinates).${hint ? ' ' + hint : ''}`,
530
+ `Unknown place "${name}" — not in the gazetteer.${hint ? ' ' + hint : ''} Search the exact token with \`dgmo map-search "${name}"\` (or the lookup_map_location tool), or use coordinates \`poi <lat> <lon>\`.`,
531
531
  'E_MAP_UNKNOWN_PLACE'
532
532
  );
533
533
  return { kind: 'miss' };
package/src/org/parser.ts CHANGED
@@ -342,7 +342,10 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
342
342
  // Find the parent node: top of stack (the most recent node)
343
343
  const parent = findMetadataParent(indent, indentStack);
344
344
  if (!parent) {
345
- pushError(lineNumber, 'Metadata has no parent node');
345
+ pushError(
346
+ lineNumber,
347
+ "Metadata 'key: value' must be indented under a node — no node precedes it."
348
+ );
346
349
  } else {
347
350
  parent.metadata = { ...parent.metadata, [key]: value };
348
351
  }
@@ -364,7 +367,10 @@ export function parseOrg(content: string, palette?: PaletteColors): ParsedOrg {
364
367
  );
365
368
  attachNode(node, indent, indentStack, result);
366
369
  } else {
367
- pushError(lineNumber, 'Metadata has no parent node');
370
+ pushError(
371
+ lineNumber,
372
+ "Metadata 'key: value' must be indented under a node — no node precedes it."
373
+ );
368
374
  }
369
375
  } else {
370
376
  // It's a node label — possibly with single-line metadata
@@ -193,7 +193,10 @@ export function parsePyramid(content: string): ParsedPyramid {
193
193
 
194
194
  // ── Indented: description line under current layer ──
195
195
  if (!currentLayer) {
196
- warn(lineNum, `Unexpected indented line: "${trimmed}".`);
196
+ warn(
197
+ lineNum,
198
+ `Indented description "${trimmed}" has no layer above it — add a layer name on a non-indented line first. (§23)`
199
+ );
197
200
  continue;
198
201
  }
199
202
  const descLine = trimmed.startsWith('- ')
@@ -706,7 +706,10 @@ export function parseRaci(
706
706
 
707
707
  // ── Catch-all: stray content line in body ────────────────────
708
708
  if (bodyStarted) {
709
- errorAt(lineNumber, `Unexpected line: '${trimmed}'.`);
709
+ errorAt(
710
+ lineNumber,
711
+ `Unexpected line: '${trimmed}'. In the body expect a '[Phase]' header, an indented task name, or an assignment 'Role: A R C I' under a task. (§24A)`
712
+ );
710
713
  } else {
711
714
  // Pre-body unrecognized line — likely a typo'd directive. Tolerate
712
715
  // by emitting a warning rather than failing the whole parse.
@@ -205,7 +205,10 @@ export function parseRing(content: string): ParsedRing {
205
205
 
206
206
  // ── Indented: description line under current layer ──
207
207
  if (!currentLayer) {
208
- warn(lineNum, `Unexpected indented line: "${trimmed}".`);
208
+ warn(
209
+ lineNum,
210
+ `Indented description "${trimmed}" has no layer above it — add a layer name on a non-indented line first. (§24)`
211
+ );
209
212
  continue;
210
213
  }
211
214
  const descLine = trimmed.startsWith('- ')
@@ -878,10 +878,13 @@ export function parseSequenceDgmo(
878
878
  .trim()
879
879
  .replace(/\([^)]*\)$/, '')
880
880
  .trim();
881
- const metaPart = rawInside.substring(pipeIdx).trim();
881
+ const metaPart = rawInside
882
+ .substring(pipeIdx)
883
+ .replace(/^\|\s*/, '')
884
+ .trim();
882
885
  pushError(
883
886
  lineNumber,
884
- `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`
887
+ `Metadata goes after the bracket with no pipe — use '[${cleanName}] ${metaPart}' (e.g. '[Backend] t: Product'), not '[${rawInside.trim()}]'`
885
888
  );
886
889
  continue;
887
890
  }
@@ -1676,7 +1679,7 @@ export function parseSequenceDgmo(
1676
1679
  // Catch-all: nothing matched this line
1677
1680
  pushWarning(
1678
1681
  lineNumber,
1679
- `Unexpected line: '${trimmed}'. Expected a message (A -> B), participant, section (== Name ==), or block keyword (if/loop/parallel).`
1682
+ `Unexpected line: '${trimmed}'. Expected a message ('A -> B' or 'A -label-> B'), participant ('Name' or 'Name is a actor'), note ('note right of A Text'), section ('== Name =='), or block ('if/loop/parallel <label>').`
1680
1683
  );
1681
1684
  }
1682
1685
 
@@ -363,7 +363,10 @@ export function parseSitemap(
363
363
  // at a shallower indent (same pattern as metadata attachment)
364
364
  const source = findParentNode(indent, indentStack);
365
365
  if (!source) {
366
- pushError(lineNumber, 'Arrow has no source node');
366
+ pushError(
367
+ lineNumber,
368
+ "Arrow '-> target' must be indented under its source page — no page precedes it."
369
+ );
367
370
  } else {
368
371
  deferredArrows.push({
369
372
  sourceNode: source,
@@ -455,7 +458,10 @@ export function parseSitemap(
455
458
 
456
459
  const parent = findParentNode(indent, indentStack);
457
460
  if (!parent) {
458
- pushError(lineNumber, 'Metadata has no parent node');
461
+ pushError(
462
+ lineNumber,
463
+ "Metadata 'key: value' must be indented under a page — no page precedes it."
464
+ );
459
465
  } else {
460
466
  parent.metadata = { ...parent.metadata, [key]: value };
461
467
  }
@@ -476,7 +482,10 @@ export function parseSitemap(
476
482
  const key = normalizeName(node.label);
477
483
  labelToNode.set(key, node);
478
484
  } else {
479
- pushError(lineNumber, 'Metadata has no parent node');
485
+ pushError(
486
+ lineNumber,
487
+ "Metadata 'key: value' must be indented under a page — no page precedes it."
488
+ );
480
489
  }
481
490
  } else {
482
491
  // Check if this is a description line for a parent node
@@ -144,7 +144,10 @@ export function parseTechRadar(content: string): ParsedTechRadar {
144
144
  // --- Rings block ---
145
145
  if (indent === 0 && trimmed.toLowerCase() === 'rings') {
146
146
  if (result.rings.length > 0) {
147
- warn(lineNumber, 'Duplicate "rings" block — using last one');
147
+ warn(
148
+ lineNumber,
149
+ 'Duplicate "rings" block — only the last is kept; merge all ring names into one "rings" block.'
150
+ );
148
151
  result.rings = [];
149
152
  }
150
153
  inRingsBlock = true;
@@ -410,7 +413,7 @@ export function parseTechRadar(content: string): ParsedTechRadar {
410
413
  result.diagnostics.push(
411
414
  makeDgmoError(
412
415
  lineNumber,
413
- `Blip "${trimmed}" has no ring assignment. Place it under a ring section header or use: ${trimmed} | ring: RingName`
416
+ `Blip "${trimmed}" has no ring. Put it under a ring section header, or add a colon-keyed ring — '${trimmed} ring: RingName' (per §20).`
414
417
  )
415
418
  );
416
419
  continue;
@@ -421,7 +424,7 @@ export function parseTechRadar(content: string): ParsedTechRadar {
421
424
  if (!trimmed.includes('|') && headerParsed && result.rings.length > 0) {
422
425
  warn(
423
426
  lineNumber,
424
- `Unrecognized line "${trimmed}". Quadrant headers require pipe metadata: ${trimmed} | quadrant: top-left`
427
+ `Unrecognized line "${trimmed}". A quadrant header is 'Name quadrant: <top-left|top-right|bottom-left|bottom-right>' (per §20).`
425
428
  );
426
429
  }
427
430
  }
@@ -564,9 +564,9 @@ export function peelRampColors(label: string): {
564
564
  return { label: second.rest, low: second.color, high: first.color };
565
565
  }
566
566
 
567
- /** Error message for multiple pipes on a single line. */
567
+ /** Error message for stray pipe(s) where metadata used to go (0.18.0 removed `|`). */
568
568
  export const MULTIPLE_PIPE_ERROR =
569
- 'Use a single "|" to start metadata, then separate items with commas.';
569
+ "'|' is no longer metadata syntax — put metadata on the same line as 'key: value, key2: value2' (no pipes), per §1.4.";
570
570
 
571
571
  /**
572
572
  * Parse metadata from segments after the first (name) segment.
@@ -524,12 +524,12 @@ export function validateTagValues(
524
524
  );
525
525
  if (!isPrefix) {
526
526
  const defined = group.entries.map((e) => e.value);
527
- let msg = `Unknown value '${value}' for tag group '${group.name}'`;
527
+ let msg =
528
+ `Unknown value '${value}' for tag group '${group.name}' — ` +
529
+ `use one of: ${defined.join(', ')}`;
528
530
  const hint = suggestFn?.(value, defined);
529
531
  if (hint) {
530
532
  msg += `. ${hint}`;
531
- } else {
532
- msg += ` — defined values: ${defined.join(', ')}`;
533
533
  }
534
534
  pushWarning(entity.lineNumber, msg);
535
535
  }
@@ -452,7 +452,10 @@ function parseVisualizationFull(
452
452
  lineNumber,
453
453
  });
454
454
  } else {
455
- warn(lineNumber, `Unrecognized era entry: "${line}"`);
455
+ warn(
456
+ lineNumber,
457
+ `Unrecognized era entry: "${line}" — use '<start> -> <end> Label [color]' (e.g. '1716 -> 1717 Reign').`
458
+ );
456
459
  }
457
460
  continue;
458
461
  }
@@ -486,7 +489,10 @@ function parseVisualizationFull(
486
489
  lineNumber,
487
490
  });
488
491
  } else {
489
- warn(lineNumber, `Unrecognized marker entry: "${line}"`);
492
+ warn(
493
+ lineNumber,
494
+ `Unrecognized marker entry: "${line}" — use '<date> Label [color]' (e.g. '1776 Independence').`
495
+ );
490
496
  }
491
497
  continue;
492
498
  }
@@ -1411,7 +1417,7 @@ function parseVisualizationFull(
1411
1417
  result.diagnostics.push(
1412
1418
  makeDgmoError(
1413
1419
  ov.lineNumber,
1414
- `Intersection references unknown set or alias "${ref}"`
1420
+ `Intersection references unknown set or alias "${ref}" — declare the set first on its own line ('Name [as <alias>] [color]', e.g. 'Navigation as nav blue'), then reference it by that name or alias. (§16.4)`
1415
1421
  )
1416
1422
  );
1417
1423
  if (!result.error)
@@ -1,117 +0,0 @@
1
- // Data-row comma migration for the `dgmo migrate` tool.
2
- //
3
- // 1.0 freeze: data-chart value separators are space-only and thousands
4
- // commas are dropped (see `dgmo-language-spec-decisions.md` §"Silently-accepted
5
- // dual forms" — Comma-row reconciliation). This module rewrites the VALUE
6
- // region of a data-chart row, stripping value-separator and thousands commas
7
- // while leaving everything else (label, trailing color word, arrow source/
8
- // target, alias) byte-identical.
9
- //
10
- // Surgery, not re-parse: we never reconstruct the line from parsed values, so
11
- // colors, aliases, and arrow endpoints survive verbatim. We only touch the
12
- // trailing run of numeric value tokens.
13
-
14
- import { RECOGNIZED_COLOR_NAMES } from '../colors';
15
-
16
- /** Data-chart types whose rows carry comma-separable numeric values. */
17
- export const DATA_COMMA_CHART_TYPES = new Set<string>([
18
- // Simple charts (chart.ts)
19
- 'bar',
20
- 'line',
21
- 'multi-line',
22
- 'pie',
23
- 'doughnut',
24
- 'area',
25
- 'polar-area',
26
- 'radar',
27
- 'bar-stacked',
28
- // Extended charts (echarts.ts)
29
- 'scatter',
30
- 'heatmap',
31
- 'sankey',
32
- 'chord',
33
- 'funnel',
34
- // Visualizations (d3 / visualizations/parse.ts)
35
- 'quadrant',
36
- 'slope',
37
- 'arc',
38
- 'wordcloud',
39
- ]);
40
-
41
- const COLOR_SET = new Set<string>(RECOGNIZED_COLOR_NAMES);
42
-
43
- /**
44
- * A token that is a numeric value, optionally carrying comma/underscore digit
45
- * grouping. Accepts an optional leading `-`, optional trailing comma (the
46
- * separator glued to the value, e.g. `400,`), and decimals.
47
- */
48
- const NUMERIC_VALUE_RE = /^-?\d[\d,_]*(?:\.\d+)?,?$/;
49
-
50
- /** True iff `tok` is a bare numeric value token (modulo grouping/separators). */
51
- function isNumericValueToken(tok: string): boolean {
52
- if (!NUMERIC_VALUE_RE.test(tok)) return false;
53
- // Must contain at least one digit (guards against `,` alone).
54
- return /\d/.test(tok);
55
- }
56
-
57
- /**
58
- * Rewrite the value region of a single data-chart row, removing value-separator
59
- * and thousands commas. Returns the comma-free line plus whether it changed.
60
- * Lines without a comma in the value region are returned unchanged.
61
- *
62
- * The value region is the maximal trailing run of numeric value tokens
63
- * (whitespace-separated, each optionally comma-grouped or comma-suffixed),
64
- * optionally preceded — for the trailing-color-after-value forms — by nothing
65
- * (a trailing color word sits AFTER values and is preserved verbatim because it
66
- * is not numeric and stops the scan from the right).
67
- */
68
- export function migrateDataRowCommaLine(line: string): {
69
- line: string;
70
- changed: boolean;
71
- } {
72
- // Preserve exact leading indentation.
73
- const indentMatch = line.match(/^[ \t]*/);
74
- const indent = indentMatch ? indentMatch[0] : '';
75
- const body = line.substring(indent.length);
76
- if (!body || body.startsWith('//')) return { line, changed: false };
77
-
78
- // Split into whitespace tokens, keeping a simple structure. Commas may be
79
- // glued to tokens (`400,`) or stand between number tokens after a comma-split
80
- // — we normalize by splitting on whitespace then handling glued commas.
81
- const tokens = body.split(/\s+/);
82
- if (tokens.length < 2) return { line, changed: false };
83
-
84
- // Walk from the right collecting the value region. A trailing color word is
85
- // allowed as the very last token (sankey link color / data-point color) and
86
- // is kept verbatim — we resume value collection before it.
87
- let i = tokens.length - 1;
88
-
89
- // Peel an optional trailing color word (kept as-is, not a value).
90
- if (i >= 1 && COLOR_SET.has(tokens[i]!)) {
91
- i--;
92
- }
93
-
94
- const valueStart = ((): number => {
95
- let j = i;
96
- while (j >= 1 && isNumericValueToken(tokens[j]!)) j--;
97
- return j + 1; // first value-token index
98
- })();
99
-
100
- // No value region (need at least one numeric token, and a label before it).
101
- if (valueStart > i || valueStart < 1) return { line, changed: false };
102
-
103
- // The value tokens are tokens[valueStart..i]. Detect any comma among them.
104
- const valueTokens = tokens.slice(valueStart, i + 1);
105
- const hasComma = valueTokens.some((t) => t.includes(','));
106
- if (!hasComma) return { line, changed: false };
107
-
108
- // Strip commas: each numeric token loses its thousands/separator commas.
109
- // A token like `400,` → `400`; `1,000` → `1000`; `1,234.56` → `1234.56`.
110
- const cleaned = valueTokens.map((t) => t.replace(/,/g, ''));
111
-
112
- // Rebuild: label (tokens before valueStart) + cleaned values + verbatim tail.
113
- const labelPart = tokens.slice(0, valueStart);
114
- const tail = tokens.slice(i + 1); // trailing color word(s), verbatim
115
- const rebuiltBody = [...labelPart, ...cleaned, ...tail].join(' ');
116
- return { line: indent + rebuiltBody, changed: rebuiltBody !== body };
117
- }