@diagrammo/dgmo 0.25.5 → 0.27.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 (139) hide show
  1. package/README.md +3 -3
  2. package/dist/advanced.cjs +4255 -2756
  3. package/dist/advanced.d.cts +285 -59
  4. package/dist/advanced.d.ts +285 -59
  5. package/dist/advanced.js +4253 -2750
  6. package/dist/auto.cjs +4051 -2589
  7. package/dist/auto.js +124 -122
  8. package/dist/auto.mjs +4051 -2589
  9. package/dist/cli.cjs +172 -170
  10. package/dist/editor.cjs +4 -0
  11. package/dist/editor.js +4 -0
  12. package/dist/highlight.cjs +4 -0
  13. package/dist/highlight.js +4 -0
  14. package/dist/index.cjs +4076 -2591
  15. package/dist/index.d.cts +33 -8
  16. package/dist/index.d.ts +33 -8
  17. package/dist/index.js +4076 -2591
  18. package/dist/internal.cjs +4255 -2756
  19. package/dist/internal.d.cts +285 -59
  20. package/dist/internal.d.ts +285 -59
  21. package/dist/internal.js +4253 -2750
  22. package/dist/map-data/PROVENANCE.json +1 -1
  23. package/dist/map-data/airport-collisions.json +1 -0
  24. package/dist/map-data/airports.json +1 -0
  25. package/docs/language-reference.md +68 -18
  26. package/gallery/fixtures/boxes-and-lines-diverging.dgmo +15 -0
  27. package/gallery/fixtures/map-choropleth-diverging.dgmo +9 -0
  28. package/gallery/fixtures/map-region-values.dgmo +13 -0
  29. package/gallery/fixtures/map-subnational-zoom.dgmo +12 -0
  30. package/gallery/fixtures/map-tagged-legs.dgmo +16 -0
  31. package/gallery/fixtures/map-undirected-edges.dgmo +12 -0
  32. package/package.json +1 -1
  33. package/src/advanced.ts +3 -6
  34. package/src/auto/index.ts +1 -1
  35. package/src/boxes-and-lines/layout.ts +146 -26
  36. package/src/boxes-and-lines/parser.ts +43 -8
  37. package/src/boxes-and-lines/renderer.ts +223 -96
  38. package/src/boxes-and-lines/types.ts +9 -2
  39. package/src/c4/layout.ts +14 -32
  40. package/src/c4/parser.ts +9 -5
  41. package/src/c4/renderer.ts +34 -39
  42. package/src/class/layout.ts +118 -18
  43. package/src/class/parser.ts +35 -1
  44. package/src/class/renderer.ts +58 -2
  45. package/src/class/types.ts +3 -0
  46. package/src/cli.ts +4 -4
  47. package/src/completion-types.ts +0 -1
  48. package/src/completion.ts +106 -51
  49. package/src/cycle/layout.ts +55 -72
  50. package/src/cycle/renderer.ts +11 -6
  51. package/src/d3.ts +78 -117
  52. package/src/diagnostics.ts +16 -0
  53. package/src/echarts.ts +46 -33
  54. package/src/editor/keywords.ts +4 -0
  55. package/src/er/layout.ts +114 -22
  56. package/src/er/parser.ts +28 -1
  57. package/src/er/renderer.ts +55 -2
  58. package/src/er/types.ts +3 -0
  59. package/src/gantt/renderer.ts +46 -38
  60. package/src/gantt/resolver.ts +9 -2
  61. package/src/graph/edge-spline.ts +29 -0
  62. package/src/graph/flowchart-parser.ts +35 -2
  63. package/src/graph/flowchart-renderer.ts +80 -52
  64. package/src/graph/layout.ts +206 -23
  65. package/src/graph/notes.ts +21 -0
  66. package/src/graph/state-parser.ts +26 -1
  67. package/src/graph/state-renderer.ts +80 -52
  68. package/src/graph/types.ts +13 -0
  69. package/src/index.ts +1 -1
  70. package/src/infra/layout.ts +46 -26
  71. package/src/infra/parser.ts +1 -1
  72. package/src/infra/renderer.ts +16 -7
  73. package/src/journey-map/layout.ts +38 -49
  74. package/src/journey-map/renderer.ts +22 -45
  75. package/src/kanban/renderer.ts +15 -6
  76. package/src/label-layout.ts +3 -3
  77. package/src/map/completion.ts +77 -22
  78. package/src/map/context-labels.ts +57 -12
  79. package/src/map/data/PROVENANCE.json +1 -1
  80. package/src/map/data/airport-collisions.json +1 -0
  81. package/src/map/data/airports.json +1 -0
  82. package/src/map/data/types.ts +19 -0
  83. package/src/map/layout.ts +1196 -90
  84. package/src/map/legend-band.ts +2 -2
  85. package/src/map/load-data.ts +10 -1
  86. package/src/map/parser.ts +61 -32
  87. package/src/map/renderer.ts +284 -12
  88. package/src/map/resolved-types.ts +15 -1
  89. package/src/map/resolver.ts +132 -12
  90. package/src/map/types.ts +28 -8
  91. package/src/migrate/embedded.ts +9 -7
  92. package/src/mindmap/text-wrap.ts +13 -14
  93. package/src/org/layout.ts +19 -17
  94. package/src/org/renderer.ts +11 -4
  95. package/src/palettes/color-utils.ts +82 -21
  96. package/src/palettes/index.ts +0 -19
  97. package/src/palettes/registry.ts +1 -1
  98. package/src/palettes/types.ts +2 -2
  99. package/src/pert/layout.ts +48 -40
  100. package/src/pert/parser.ts +0 -14
  101. package/src/pert/renderer.ts +30 -43
  102. package/src/pyramid/renderer.ts +4 -5
  103. package/src/raci/renderer.ts +42 -70
  104. package/src/render.ts +1 -1
  105. package/src/ring/renderer.ts +1 -2
  106. package/src/sequence/parser.ts +100 -22
  107. package/src/sequence/renderer.ts +75 -50
  108. package/src/sitemap/layout.ts +27 -19
  109. package/src/sitemap/renderer.ts +12 -5
  110. package/src/tech-radar/renderer.ts +11 -35
  111. package/src/utils/arrow-markers.ts +51 -0
  112. package/src/utils/fit-canvas.ts +64 -0
  113. package/src/utils/legend-constants.ts +8 -54
  114. package/src/utils/legend-d3.ts +10 -7
  115. package/src/utils/legend-layout.ts +7 -4
  116. package/src/utils/legend-types.ts +10 -4
  117. package/src/utils/note-box/constants.ts +25 -0
  118. package/src/utils/note-box/index.ts +11 -0
  119. package/src/utils/note-box/metrics.ts +90 -0
  120. package/src/utils/note-box/svg.ts +331 -0
  121. package/src/utils/notes/bounds.ts +30 -0
  122. package/src/utils/notes/build.ts +131 -0
  123. package/src/utils/notes/index.ts +18 -0
  124. package/src/utils/notes/model.ts +19 -0
  125. package/src/utils/notes/parse.ts +131 -0
  126. package/src/utils/notes/place.ts +177 -0
  127. package/src/utils/notes/resolve.ts +88 -0
  128. package/src/utils/number-format.ts +36 -0
  129. package/src/utils/parsing.ts +41 -0
  130. package/src/utils/reserved-key-registry.ts +4 -0
  131. package/src/utils/text-measure.ts +122 -0
  132. package/src/wireframe/layout.ts +4 -2
  133. package/src/wireframe/renderer.ts +8 -6
  134. package/src/palettes/dracula.ts +0 -68
  135. package/src/palettes/gruvbox.ts +0 -85
  136. package/src/palettes/monokai.ts +0 -68
  137. package/src/palettes/one-dark.ts +0 -70
  138. package/src/palettes/rose-pine.ts +0 -84
  139. package/src/palettes/solarized.ts +0 -77
@@ -31,6 +31,7 @@ import {
31
31
  } from './types';
32
32
  import { computeCycleLayout, wrapEdgeLabelText } from './layout';
33
33
  import { ScaleContext } from '../utils/scaling';
34
+ import { measureText } from '../utils/text-measure';
34
35
 
35
36
  // ── Constants ────────────────────────────────────────────────
36
37
  const NODE_FONT_SIZE = 13;
@@ -502,12 +503,16 @@ export function renderCycle(
502
503
  const anchor = isRight ? 'start' : isLeft ? 'end' : 'middle';
503
504
 
504
505
  const lineCount = labelLines.length + descLines.length;
505
- let maxCharLen = 0;
506
- for (const l of labelLines) maxCharLen = Math.max(maxCharLen, l.length);
507
- for (const l of descLines) maxCharLen = Math.max(maxCharLen, l.length);
508
-
509
- const edgeCharW = Math.max(4, 7 * layout.scale);
510
- const bgW = maxCharLen * edgeCharW + 12;
506
+ // Measure the widest rendered line in pixels at the scaled edge-label font
507
+ // so the background box matches the actual ink (same measurer the layout
508
+ // uses to size + place the label).
509
+ let maxLineW = 0;
510
+ for (const l of labelLines)
511
+ maxLineW = Math.max(maxLineW, measureText(l, scaledEdgeLabelFont));
512
+ for (const l of descLines)
513
+ maxLineW = Math.max(maxLineW, measureText(l, scaledDescFont));
514
+
515
+ const bgW = maxLineW + 12;
511
516
  const bgH = lineCount * scaledEdgeLineH + 6;
512
517
  const bgX = isRight
513
518
  ? le.labelX - 4
package/src/d3.ts CHANGED
@@ -6,6 +6,7 @@ import cloud from 'd3-cloud';
6
6
  import { FONT_FAMILY } from './fonts';
7
7
  import { computeQuadrantPointLabels, type LabelRect } from './label-layout';
8
8
  import { MONTH_ABBR, computeTimeTicks } from './utils/time-ticks';
9
+ import { measureText, wrapTextToWidth } from './utils/text-measure';
9
10
  import type { D3ExportDimensions } from './utils/d3-types';
10
11
  import { ScaleContext } from './utils/scaling';
11
12
 
@@ -140,6 +141,8 @@ export interface ParsedVisualization {
140
141
  timelineDefaultSwimlaneTG?: string;
141
142
  timelineScale: boolean;
142
143
  timelineSwimlanes: boolean;
144
+ /** Authored `active-tag <group|none|metric>` directive (§15.6); resolved at render. */
145
+ timelineActiveTag?: string;
143
146
  vennSets: VennSet[];
144
147
  vennOverlaps: VennOverlap[];
145
148
  // Quadrant chart fields
@@ -181,6 +184,7 @@ import {
181
184
  import {
182
185
  collectIndentedValues,
183
186
  extractColor,
187
+ measureIndent,
184
188
  normalizeNumericToken,
185
189
  parseFirstLine,
186
190
  peelTrailingColorName,
@@ -396,7 +400,7 @@ export function parseVisualization(
396
400
  // In-bounds by loop guard.
397
401
  const rawLine = lines[i]!;
398
402
  const line = rawLine.trim();
399
- const indent = rawLine.length - rawLine.trimStart().length;
403
+ const indent = measureIndent(rawLine);
400
404
  const lineNumber = i + 1;
401
405
 
402
406
  // Skip empty lines
@@ -800,6 +804,27 @@ export function parseVisualization(
800
804
  }
801
805
  }
802
806
 
807
+ // Timeline display directives (§15.6, space-separated, no colon):
808
+ // no-scale — drop the date axis/scale
809
+ // swimlanes — force lane backgrounds
810
+ // active-tag <X> — authored active tag group / `none` / value-ramp label
811
+ if (result.type === 'timeline') {
812
+ const lower = line.toLowerCase();
813
+ if (lower === 'no-scale') {
814
+ result.timelineScale = false;
815
+ continue;
816
+ }
817
+ if (lower === 'swimlanes') {
818
+ result.timelineSwimlanes = true;
819
+ continue;
820
+ }
821
+ const activeTagMatch = line.match(/^active-tag\s+(.+)$/i);
822
+ if (activeTagMatch) {
823
+ result.timelineActiveTag = activeTagMatch[1]!.trim();
824
+ continue;
825
+ }
826
+ }
827
+
803
828
  // Timeline event lines: date-first syntax
804
829
  if (result.type === 'timeline') {
805
830
  const tlRegistry = withTagAliases(
@@ -1821,7 +1846,6 @@ export function resolveVerticalCollisions(
1821
1846
 
1822
1847
  const SLOPE_MARGIN = { top: 80, bottom: 40, left: 80 };
1823
1848
  const SLOPE_LABEL_FONT_SIZE = 14;
1824
- const SLOPE_CHAR_WIDTH = 8; // approximate px per character at 14px
1825
1849
 
1826
1850
  /**
1827
1851
  * Renders a slope chart into the given container using D3.
@@ -1850,7 +1874,6 @@ export function renderSlopeChart(
1850
1874
  const sMarginTop = ctx.aesthetic(SLOPE_MARGIN.top);
1851
1875
  const sMarginBottom = ctx.aesthetic(SLOPE_MARGIN.bottom);
1852
1876
  const sMarginLeft = ctx.aesthetic(SLOPE_MARGIN.left);
1853
- const sCharWidth = ctx.structural(SLOPE_CHAR_WIDTH);
1854
1877
  const sLabelFontSize = ctx.text(SLOPE_LABEL_FONT_SIZE);
1855
1878
  const sPeriodFont = ctx.text(18);
1856
1879
  const sValueFont = ctx.text(16);
@@ -1873,7 +1896,7 @@ export function renderSlopeChart(
1873
1896
  const text = `${item.values[item.values.length - 1]} — ${item.label}`;
1874
1897
  return text.length > longest.length ? text : longest;
1875
1898
  }, '');
1876
- const estimatedLabelWidth = maxLabelText.length * sCharWidth;
1899
+ const estimatedLabelWidth = measureText(maxLabelText, sLabelFontSize);
1877
1900
  const maxRightMargin = Math.floor(width * 0.35);
1878
1901
  const rightMargin = Math.min(
1879
1902
  Math.max(estimatedLabelWidth + ctx.aesthetic(30), ctx.aesthetic(120)),
@@ -1965,24 +1988,11 @@ export function renderSlopeChart(
1965
1988
  const lastX = xScale(periods[periods.length - 1]!)!;
1966
1989
  const labelText = `${lastVal} — ${item.label}`;
1967
1990
  const availableWidth = rightMargin - ctx.aesthetic(15);
1968
- const maxChars = Math.floor(availableWidth / sCharWidth);
1969
1991
 
1970
1992
  let labelLineCount = 1;
1971
1993
  let wrappedLines: string[] | null = null;
1972
- if (labelText.length > maxChars) {
1973
- const words = labelText.split(/\s+/);
1974
- const lines: string[] = [];
1975
- let current = '';
1976
- for (const word of words) {
1977
- const test = current ? `${current} ${word}` : word;
1978
- if (test.length > maxChars && current) {
1979
- lines.push(current);
1980
- current = word;
1981
- } else {
1982
- current = test;
1983
- }
1984
- }
1985
- if (current) lines.push(current);
1994
+ if (measureText(labelText, sLabelFontSize) > availableWidth) {
1995
+ const lines = wrapTextToWidth(labelText, sLabelFontSize, availableWidth);
1986
1996
  labelLineCount = lines.length;
1987
1997
  wrappedLines = lines;
1988
1998
  }
@@ -1999,7 +2009,6 @@ export function renderSlopeChart(
1999
2009
  tipHtml,
2000
2010
  lastX,
2001
2011
  labelText,
2002
- maxChars,
2003
2012
  wrappedLines,
2004
2013
  labelHeight,
2005
2014
  };
@@ -2790,7 +2799,7 @@ function renderEras(
2790
2799
  ): void {
2791
2800
  const eraColors = palette
2792
2801
  ? getEraColors(palette)
2793
- : ['#5e81ac', '#a3be8c', '#ebcb8b', '#d08770', '#b48ead'];
2802
+ : ['#3b6ea5', '#5b9357', '#c9a227', '#cc7a33', '#7d5ba6'];
2794
2803
  eras.forEach((era, i) => {
2795
2804
  const startVal = parseTimelineDate(era.startDate);
2796
2805
  const endVal = parseTimelineDate(era.endDate);
@@ -2907,7 +2916,7 @@ function renderMarkers(
2907
2916
  reservedLabelY?: number
2908
2917
  ): void {
2909
2918
  // Default marker color - bright orange/red that "pops"
2910
- const defaultColor = palette?.accent || '#d08770';
2919
+ const defaultColor = palette?.accent || '#3a9188';
2911
2920
 
2912
2921
  // Pre-compute marker positions so each can size its label based on the
2913
2922
  // distance to its nearest neighbor (or chart edge).
@@ -3995,6 +4004,15 @@ function renderTimelineTagLegendOverlay(
3995
4004
  .attr('transform', `translate(${x}, ${y})`)
3996
4005
  .style('cursor', 'pointer');
3997
4006
 
4007
+ // Transparent hit area so the whole icon (not just the 2px bars) is clickable
4008
+ iconG
4009
+ .append('rect')
4010
+ .attr('x', -5)
4011
+ .attr('y', -5)
4012
+ .attr('width', 22)
4013
+ .attr('height', 20)
4014
+ .attr('fill', 'transparent');
4015
+
3998
4016
  const barColor = isSwimActive ? palette.primary : palette.textMuted;
3999
4017
  const barOpacity = isSwimActive ? 1 : 0.35;
4000
4018
  const bars = [
@@ -5789,12 +5807,8 @@ function hasCanvas2d(): boolean {
5789
5807
  }
5790
5808
  }
5791
5809
 
5792
- /** Average glyph advance for Inter as a fraction of the font size. Slightly
5793
- * generous so estimated boxes err toward not overlapping. */
5794
- const WORDCLOUD_GLYPH_ADVANCE = 0.62;
5795
-
5796
5810
  function estimateWordWidth(text: string, size: number): number {
5797
- return text.length * size * WORDCLOUD_GLYPH_ADVANCE;
5811
+ return measureText(text, size);
5798
5812
  }
5799
5813
 
5800
5814
  type PlacedCloudWord = WordCloudWord & {
@@ -6290,12 +6304,15 @@ export function renderVenn(
6290
6304
  const edgePad = ctx.aesthetic(8);
6291
6305
  const labelTextPad = ctx.aesthetic(4);
6292
6306
 
6293
- const sCharW = ctx.structural(8.5);
6307
+ const sSetLabelFont = ctx.text(14);
6294
6308
 
6295
6309
  for (let i = 0; i < n; i++) {
6296
6310
  // In-bounds by loop guard (n === vennSets.length === rawCircles.length).
6297
6311
  const estimatedWidth =
6298
- vennSets[i]!.name.length * sCharW + stubLen + edgePad + labelTextPad;
6312
+ measureText(vennSets[i]!.name, sSetLabelFont) +
6313
+ stubLen +
6314
+ edgePad +
6315
+ labelTextPad;
6299
6316
  const dx = rawCircles[i]!.x - clusterCx;
6300
6317
  const dy = rawCircles[i]!.y - clusterCy;
6301
6318
  if (Math.abs(dx) >= Math.abs(dy)) {
@@ -6313,7 +6330,6 @@ export function renderVenn(
6313
6330
  // to leave readable space outside for leader+text. Wrap target scales
6314
6331
  // with the canvas so labels stay narrow on small windows.
6315
6332
  const OVERLAP_FONT = ctx.text(13);
6316
- const OVERLAP_CH_W = ctx.structural(7);
6317
6333
  const OVERLAP_LINE_H = ctx.structural(16);
6318
6334
  const OVERLAP_LEADER_PAD = ctx.structural(18);
6319
6335
  const OVERLAP_TEXT_GAP = ctx.aesthetic(6);
@@ -6322,27 +6338,6 @@ export function renderVenn(
6322
6338
  ctx.structural(80),
6323
6339
  Math.min(ctx.structural(170), width * 0.18)
6324
6340
  );
6325
- const MAX_WRAP_CHARS = Math.max(
6326
- 8,
6327
- Math.floor(OVERLAP_WRAP_TARGET_W / OVERLAP_CH_W)
6328
- );
6329
-
6330
- function wrapLabel(text: string, maxChars: number): string[] {
6331
- const words = text.split(/\s+/).filter(Boolean);
6332
- const lines: string[] = [];
6333
- let cur = '';
6334
- for (const w of words) {
6335
- const cand = cur ? cur + ' ' + w : w;
6336
- if (cand.length > maxChars && cur) {
6337
- lines.push(cur);
6338
- cur = w;
6339
- } else {
6340
- cur = cand;
6341
- }
6342
- }
6343
- if (cur) lines.push(cur);
6344
- return lines.length ? lines : [text];
6345
- }
6346
6341
 
6347
6342
  function predictOverlapDirRaw(idxs: number[]): { x: number; y: number } {
6348
6343
  const excluded = rawCircles
@@ -6381,12 +6376,18 @@ export function renderVenn(
6381
6376
  if (!ov.label) continue;
6382
6377
  const idxs = ov.sets.map((s) => vennSets.findIndex((vs) => vs.name === s));
6383
6378
  if (idxs.some((idx) => idx < 0)) continue;
6384
- const lines = wrapLabel(ov.label, MAX_WRAP_CHARS);
6379
+ const lines = wrapTextToWidth(
6380
+ ov.label,
6381
+ OVERLAP_FONT,
6382
+ OVERLAP_WRAP_TARGET_W
6383
+ );
6385
6384
  wrappedOverlapLabels.set(ov, lines);
6386
6385
 
6387
6386
  const dir = predictOverlapDirRaw(idxs);
6388
- const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
6389
- const labelW = longest * OVERLAP_CH_W;
6387
+ const labelW = lines.reduce(
6388
+ (m, l) => Math.max(m, measureText(l, OVERLAP_FONT)),
6389
+ 0
6390
+ );
6390
6391
  const labelH = lines.length * OVERLAP_LINE_H;
6391
6392
  const baseLeader =
6392
6393
  OVERLAP_LEADER_PAD + OVERLAP_TEXT_GAP + OVERLAP_MARGIN_PAD;
@@ -6651,7 +6652,6 @@ export function renderVenn(
6651
6652
  return Math.max(0, right - left);
6652
6653
  }
6653
6654
 
6654
- const CH_RATIO = 0.6;
6655
6655
  const MIN_FONT = ctx.text(10);
6656
6656
  const MAX_FONT = ctx.text(22);
6657
6657
  const INTERNAL_PAD = ctx.aesthetic(12);
@@ -6671,11 +6671,13 @@ export function renderVenn(
6671
6671
  const centroid = regionCentroid(circles, inside);
6672
6672
 
6673
6673
  const availW = exclusiveHSpan(centroid.x, centroid.y, i);
6674
+ // Width of `text` at fontSize 1; scale to solve for the largest fitting font.
6675
+ const textWidthPerPx = measureText(text, 1);
6674
6676
  const fitFont = Math.min(
6675
6677
  MAX_FONT,
6676
- Math.max(MIN_FONT, (availW - INTERNAL_PAD * 2) / (text.length * CH_RATIO))
6678
+ Math.max(MIN_FONT, (availW - INTERNAL_PAD * 2) / textWidthPerPx)
6677
6679
  );
6678
- const estTextW = text.length * CH_RATIO * fitFont;
6680
+ const estTextW = measureText(text, fitFont);
6679
6681
 
6680
6682
  const fitsInside =
6681
6683
  estTextW + INTERNAL_PAD * 2 < availW &&
@@ -6739,8 +6741,7 @@ export function renderVenn(
6739
6741
  const textAnchor = isRight ? 'start' : 'end';
6740
6742
  let textX = stubEndX + (isRight ? labelTextPad : -labelTextPad);
6741
6743
  const textY = stubEndY;
6742
- const sSetLabelFont = ctx.text(14);
6743
- const estW = text.length * sCharW;
6744
+ const estW = measureText(text, sSetLabelFont);
6744
6745
  if (isRight) textX = Math.min(textX, width - estW - 4);
6745
6746
  else textX = Math.max(textX, estW + 4);
6746
6747
 
@@ -6758,7 +6759,7 @@ export function renderVenn(
6758
6759
  .attr('font-size', `${sSetLabelFont}px`)
6759
6760
  .attr('font-weight', 'bold')
6760
6761
  .text(text);
6761
- const externalEstW = text.length * sCharW;
6762
+ const externalEstW = measureText(text, sSetLabelFont);
6762
6763
  setLabelBBoxes[i] = {
6763
6764
  x: isRight ? textX : textX - externalEstW,
6764
6765
  y: renderedTextY - sSetLabelFont / 2,
@@ -6968,8 +6969,10 @@ export function renderVenn(
6968
6969
  textY = stubEndY + sign * OVERLAP_TEXT_GAP;
6969
6970
  }
6970
6971
 
6971
- const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
6972
- const blockW = longest * OVERLAP_CH_W;
6972
+ const blockW = lines.reduce(
6973
+ (m, l) => Math.max(m, measureText(l, OVERLAP_FONT)),
6974
+ 0
6975
+ );
6973
6976
  const blockH = lines.length * OVERLAP_LINE_H;
6974
6977
 
6975
6978
  if (textAnchor === 'start') textX = Math.min(textX, width - blockW - 4);
@@ -7238,29 +7241,6 @@ export function renderQuadrant(
7238
7241
  .append('g')
7239
7242
  .attr('transform', `translate(${margin.left}, ${margin.top})`);
7240
7243
 
7241
- // Mix two hex colors: pct=100 → all `a`, pct=0 → all `b`
7242
- const mixHex = (a: string, b: string, pct: number): string => {
7243
- const parse = (h: string): [number, number, number] => {
7244
- const r = h.replace('#', '');
7245
- // In-bounds: 3-char path indexes [0],[1],[2].
7246
- const f =
7247
- r.length === 3 ? r[0]! + r[0]! + r[1]! + r[1]! + r[2]! + r[2]! : r;
7248
- return [
7249
- parseInt(f.substring(0, 2), 16),
7250
- parseInt(f.substring(2, 4), 16),
7251
- parseInt(f.substring(4, 6), 16),
7252
- ];
7253
- };
7254
- const [ar, ag, ab] = parse(a),
7255
- [br, bg, bb] = parse(b),
7256
- t = pct / 100;
7257
- const c = (x: number, y: number) =>
7258
- Math.round(x * t + y * (1 - t))
7259
- .toString(16)
7260
- .padStart(2, '0');
7261
- return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
7262
- };
7263
-
7264
7244
  const bg = isDark ? palette.surface : palette.bg;
7265
7245
 
7266
7246
  // Full palette color for a quadrant (used for border and label tinting)
@@ -7277,7 +7257,7 @@ export function renderQuadrant(
7277
7257
  label: QuadrantLabel | null,
7278
7258
  defaultIdx: number
7279
7259
  ): string => {
7280
- return mixHex(getQuadrantColor(label, defaultIdx), bg, 30);
7260
+ return mix(getQuadrantColor(label, defaultIdx), bg, 30);
7281
7261
  };
7282
7262
 
7283
7263
  // Quadrant definitions: position, rect bounds, label position
@@ -7357,16 +7337,12 @@ export function renderQuadrant(
7357
7337
  const shadowColor = 'rgba(0,0,0,0.4)';
7358
7338
 
7359
7339
  // Single muted shade of textColor — watermark-style, readable against any quadrant fill
7360
- const quadrantLabelColor = mixHex(textColor, bg, 35);
7340
+ const quadrantLabelColor = mix(textColor, bg, 35);
7361
7341
 
7362
7342
  // Scale label font size to fit within quadrant bounds, wrapping into multiple lines if needed
7363
7343
  const LABEL_MAX_FONT = ctx.text(48);
7364
7344
  const LABEL_MIN_FONT = ctx.text(14);
7365
7345
  const LABEL_PAD = ctx.aesthetic(40);
7366
- const CHAR_WIDTH_RATIO = 0.6;
7367
-
7368
- const estTextWidth = (text: string, fontSize: number): number =>
7369
- text.length * fontSize * CHAR_WIDTH_RATIO;
7370
7346
 
7371
7347
  interface QuadrantLabelLayout {
7372
7348
  lines: string[];
@@ -7380,10 +7356,9 @@ export function renderQuadrant(
7380
7356
  ): QuadrantLabelLayout => {
7381
7357
  const availW = qw - LABEL_PAD;
7382
7358
  const availH = qh - LABEL_PAD;
7383
- const words = text.split(/\s+/);
7384
7359
 
7385
7360
  // Try single line first
7386
- if (estTextWidth(text, LABEL_MAX_FONT) <= availW) {
7361
+ if (measureText(text, LABEL_MAX_FONT) <= availW) {
7387
7362
  const fs = Math.min(LABEL_MAX_FONT, availH);
7388
7363
  return {
7389
7364
  lines: [text],
@@ -7392,21 +7367,8 @@ export function renderQuadrant(
7392
7367
  }
7393
7368
 
7394
7369
  // Try wrapping into 2+ lines: greedily pack words so each line fits availW
7395
- const wrapLines = (fs: number): string[] => {
7396
- const result: string[] = [];
7397
- let cur = '';
7398
- for (const w of words) {
7399
- const trial = cur ? `${cur} ${w}` : w;
7400
- if (estTextWidth(trial, fs) > availW && cur) {
7401
- result.push(cur);
7402
- cur = w;
7403
- } else {
7404
- cur = trial;
7405
- }
7406
- }
7407
- if (cur) result.push(cur);
7408
- return result;
7409
- };
7370
+ const wrapLines = (fs: number): string[] =>
7371
+ wrapTextToWidth(text, fs, availW);
7410
7372
 
7411
7373
  // Binary-search for largest font size where wrapped text fits both width and height
7412
7374
  let lo = LABEL_MIN_FONT;
@@ -7417,7 +7379,7 @@ export function renderQuadrant(
7417
7379
  const mid = Math.round((lo + hi) / 2);
7418
7380
  const lines = wrapLines(mid);
7419
7381
  const totalH = lines.length * mid * 1.2; // line height ~1.2em
7420
- const maxLineW = Math.max(...lines.map((l) => estTextWidth(l, mid)));
7382
+ const maxLineW = Math.max(...lines.map((l) => measureText(l, mid)));
7421
7383
  if (maxLineW <= availW && totalH <= availH) {
7422
7384
  bestFs = mid;
7423
7385
  bestLines = lines;
@@ -7649,10 +7611,9 @@ export function renderQuadrant(
7649
7611
  const POINT_LABEL_FONT_SIZE = ctx.text(12);
7650
7612
  const quadrantLabelObstacles: LabelRect[] = quadrantDefsWithLabel.map((d) => {
7651
7613
  const layout = labelLayouts.get(d.label!.text)!;
7652
- const totalW =
7653
- Math.max(...layout.lines.map((l) => l.length)) *
7654
- layout.fontSize *
7655
- CHAR_WIDTH_RATIO;
7614
+ const totalW = Math.max(
7615
+ ...layout.lines.map((l) => measureText(l, layout.fontSize))
7616
+ );
7656
7617
  const totalH = layout.lines.length * layout.fontSize * 1.2;
7657
7618
  return {
7658
7619
  x: d.labelX - totalW / 2,
@@ -8117,7 +8078,7 @@ export async function renderForExport(
8117
8078
  if (detectedType === 'boxes-and-lines') {
8118
8079
  const { parseBoxesAndLines } = await import('./boxes-and-lines/parser');
8119
8080
  const effectivePalette = await resolveExportPalette(theme, palette);
8120
- const blParsed = parseBoxesAndLines(content);
8081
+ const blParsed = parseBoxesAndLines(content, effectivePalette);
8121
8082
  if (blParsed.error || blParsed.nodes.length === 0) return '';
8122
8083
 
8123
8084
  // Convert viewState.htv (Record<string, string[]>) to Map<string, Set<string>>
@@ -8611,7 +8572,7 @@ export async function renderForExport(
8611
8572
  const { mapExportDimensions } = await import('./map/dimensions');
8612
8573
 
8613
8574
  const effectivePalette = await resolveExportPalette(theme, palette);
8614
- const mapParsed = parseMap(content);
8575
+ const mapParsed = parseMap(content, effectivePalette);
8615
8576
  // Always render — an empty or partially-resolved map still draws the
8616
8577
  // inferred base map (§24B.10 / layout AC23); diagnostics surface separately.
8617
8578
  // Prefer injected `mapData` (browser bundles it; the fs loader can't run
@@ -8741,7 +8702,7 @@ export async function renderForExport(
8741
8702
  if (parsed.type === 'sequence') {
8742
8703
  const { parseSequenceDgmo } = await import('./sequence/parser');
8743
8704
  const { renderSequenceDiagram } = await import('./sequence/renderer');
8744
- const seqParsed = parseSequenceDgmo(content);
8705
+ const seqParsed = parseSequenceDgmo(content, effectivePalette);
8745
8706
  if (seqParsed.error || seqParsed.participants.length === 0) return '';
8746
8707
  // Apply interactive view state from share links (read from unified viewState).
8747
8708
  // Sequences key both sections and groups by source line number; `cg` is the
@@ -8793,7 +8754,7 @@ export async function renderForExport(
8793
8754
  dims,
8794
8755
  resolveActiveTagGroup(
8795
8756
  parsed.timelineTagGroups,
8796
- undefined,
8757
+ parsed.timelineActiveTag,
8797
8758
  viewState?.tag ?? options?.tagGroup
8798
8759
  ),
8799
8760
  viewState?.swim,
@@ -277,6 +277,12 @@ export const METADATA_DIAGNOSTIC_CODES = {
277
277
  * after a layer name. Replaced by the `description: <text>` key.
278
278
  */
279
279
  RING_BARE_DESCRIPTION_REMOVED: 'E_RING_BARE_DESCRIPTION_REMOVED',
280
+ /**
281
+ * Error: legacy sequence bare-keyword `position N` participant
282
+ * ordering shorthand. Replaced by the colon-keyed `position: <N>`
283
+ * metadata form, consistent with §1.4 same-line metadata.
284
+ */
285
+ SEQUENCE_BARE_POSITION_REMOVED: 'E_SEQUENCE_BARE_POSITION_REMOVED',
280
286
  /**
281
287
  * Error: a `tag` declaration appears after the first non-tag
282
288
  * content line. The reserved-key registry is finalized before
@@ -347,6 +353,16 @@ export function bareDescriptionRemovedMessage(args: {
347
353
  return `'|' description shorthand removed in ${args.chartType} — use 'description: ${quoted}'`;
348
354
  }
349
355
 
356
+ /**
357
+ * Canonical message for `E_SEQUENCE_BARE_POSITION_REMOVED`. Emitted
358
+ * when a sequence participant uses the legacy bare-keyword
359
+ * `position N` ordering shorthand instead of the colon-keyed
360
+ * `position: N` metadata form.
361
+ */
362
+ export function sequenceBarePositionRemovedMessage(n: string): string {
363
+ return `Bare 'position ${n}' removed — use 'position: ${n}' (colon required, per §1.4 metadata)`;
364
+ }
365
+
350
366
  /**
351
367
  * Canonical message for `W_EMPTY_METADATA_VALUE`. Emitted when a
352
368
  * `key:` token has no value following the colon.
package/src/echarts.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  rectCircleOverlap,
53
53
  } from './label-layout';
54
54
  import { ScaleContext } from './utils/scaling';
55
+ import { measureText, wrapTextToWidth } from './utils/text-measure';
55
56
 
56
57
  // ============================================================
57
58
  // Types
@@ -1458,7 +1459,7 @@ export function computeScatterLabelGraphics(
1458
1459
  const pt = points[i]!;
1459
1460
  const ptSize = pt.size ?? symbolSize;
1460
1461
  const minGap = ptSize / 2 + 4;
1461
- const labelWidth = pt.name.length * fontSize * 0.6 + 8;
1462
+ const labelWidth = measureText(pt.name, fontSize) + 8;
1462
1463
  const labelX = pt.px - labelWidth / 2; // centered horizontally
1463
1464
 
1464
1465
  // Try both directions, pick whichever keeps the label closest to the point
@@ -2037,15 +2038,18 @@ function buildHeatmapOption(
2037
2038
  });
2038
2039
 
2039
2040
  // Rotate column labels only when they'd overlap at the default font size.
2040
- // Estimate: each char ~7px at 12px font; rotate if longest label exceeds
2041
- // an even share of a ~900px-wide chart.
2042
- const CHAR_WIDTH = 7;
2041
+ // Measure the widest label at the actual 12px axis font; rotate if it
2042
+ // exceeds an even share of a ~900px-wide chart.
2043
2043
  const ESTIMATED_CHART_WIDTH = 900;
2044
2044
  const scaledChartWidth = sc.structural(ESTIMATED_CHART_WIDTH);
2045
- const longestCol = Math.max(...columns.map((c) => c.length), 0);
2045
+ const colLabelFontSize = sc.text(12);
2046
+ const longestColWidth = Math.max(
2047
+ 0,
2048
+ ...columns.map((c) => measureText(c, colLabelFontSize))
2049
+ );
2046
2050
  const slotWidth =
2047
2051
  columns.length > 0 ? scaledChartWidth / columns.length : Infinity;
2048
- const needsRotation = longestCol * CHAR_WIDTH > slotWidth * 0.85;
2052
+ const needsRotation = longestColWidth > slotWidth * 0.85;
2049
2053
 
2050
2054
  const ESTIMATED_PLOT_W = 770;
2051
2055
  const ESTIMATED_PLOT_H = 380;
@@ -2054,11 +2058,15 @@ function buildHeatmapOption(
2054
2058
  const cellW = columns.length > 0 ? scaledPlotW / columns.length : scaledPlotW;
2055
2059
  const cellH =
2056
2060
  heatmapRows.length > 0 ? scaledPlotH / heatmapRows.length : scaledPlotH;
2057
- const maxValueChars = Math.max(
2058
- 1,
2059
- ...heatmapRows.flatMap((r) => r.values.map((v) => String(v).length))
2061
+ // Width (in fontSize units) of the widest cell value string, measured with
2062
+ // accurate glyph widths so the fitted font size matches what renders.
2063
+ const maxValueWidthPerUnit = Math.max(
2064
+ measureText('0', 1),
2065
+ ...heatmapRows.flatMap((r) =>
2066
+ r.values.map((v) => measureText(String(v), 1))
2067
+ )
2060
2068
  );
2061
- const fontFromWidth = (cellW * 0.75) / (maxValueChars * 0.55);
2069
+ const fontFromWidth = (cellW * 0.75) / maxValueWidthPerUnit;
2062
2070
  const fontFromHeight = (cellH * 0.75) / 0.72;
2063
2071
  const cellFontFloor = sc.text(16);
2064
2072
  const labelFontSize = Math.max(
@@ -2540,21 +2548,9 @@ function makeChartGrid(options: {
2540
2548
  };
2541
2549
  }
2542
2550
 
2543
- /** Wrap a label string at word boundaries to fit within `maxChars` per line. */
2544
- function wrapLabel(text: string, maxChars: number): string {
2545
- const words = text.split(' ');
2546
- const lines: string[] = [];
2547
- let current = '';
2548
- for (const word of words) {
2549
- if (current && current.length + 1 + word.length > maxChars) {
2550
- lines.push(current);
2551
- current = word;
2552
- } else {
2553
- current = current ? current + ' ' + word : word;
2554
- }
2555
- }
2556
- if (current) lines.push(current);
2557
- return lines.join('\n');
2551
+ /** Wrap a label string at word boundaries to fit within `maxWidthPx` per line. */
2552
+ function wrapLabel(text: string, fontSize: number, maxWidthPx: number): string {
2553
+ return wrapTextToWidth(text, fontSize, maxWidthPx).join('\n');
2558
2554
  }
2559
2555
 
2560
2556
  // ── Bar ──────────────────────────────────────────────────────
@@ -2592,15 +2588,26 @@ function buildBarOption(
2592
2588
  });
2593
2589
 
2594
2590
  // For horizontal bars, wrap long category labels at word boundaries so they
2595
- // don't consume too much horizontal space on the y-axis.
2596
- const catLabels = isHorizontal ? labels.map((l) => wrapLabel(l, 12)) : labels;
2597
-
2598
- // Compute the max visible line width after wrapping for nameGap calculation.
2599
- const maxVisibleLen = Math.max(
2600
- ...catLabels.map((l) => Math.max(...l.split('\n').map((seg) => seg.length)))
2591
+ // don't consume too much horizontal space on the y-axis. The wrap budget is
2592
+ // a ~12-char line measured at the actual category-label font (16px) so
2593
+ // narrow/wide glyphs wrap by real rendered width, not raw char count.
2594
+ const catLabelFontSize = (sc ?? ScaleContext.identity()).text(16);
2595
+ const catWrapWidth = measureText('0'.repeat(12), catLabelFontSize);
2596
+ const catLabels = isHorizontal
2597
+ ? labels.map((l) => wrapLabel(l, catLabelFontSize, catWrapWidth))
2598
+ : labels;
2599
+
2600
+ // Compute the max visible line width (px) after wrapping for nameGap.
2601
+ const maxVisibleWidth = Math.max(
2602
+ 0,
2603
+ ...catLabels.map((l) =>
2604
+ Math.max(
2605
+ ...l.split('\n').map((seg) => measureText(seg, catLabelFontSize))
2606
+ )
2607
+ )
2601
2608
  );
2602
2609
  const hCatGap =
2603
- isHorizontal && yLabel ? Math.max(40, maxVisibleLen * 8 + 16) : undefined;
2610
+ isHorizontal && yLabel ? Math.max(40, maxVisibleWidth + 16) : undefined;
2604
2611
  const categoryAxis = makeGridAxis(
2605
2612
  'category',
2606
2613
  textColor,
@@ -3309,9 +3316,15 @@ function buildBarStackedOption(
3309
3316
  };
3310
3317
  });
3311
3318
 
3319
+ const stackCatLabelFontSize = s.text(16);
3312
3320
  const hCatGap =
3313
3321
  isHorizontal && yLabel
3314
- ? Math.max(40, Math.max(...labels.map((l) => l.length)) * 8 + 16)
3322
+ ? Math.max(
3323
+ 40,
3324
+ Math.max(
3325
+ ...labels.map((l) => measureText(l, stackCatLabelFontSize))
3326
+ ) + 16
3327
+ )
3315
3328
  : undefined;
3316
3329
  const categoryAxis = makeGridAxis(
3317
3330
  'category',
@@ -164,8 +164,10 @@ export const DIRECTIVE_KEYWORDS = new Set([
164
164
  'no-relief',
165
165
  'no-context-labels',
166
166
  'no-region-labels',
167
+ 'no-region-value',
167
168
  'no-poi-labels',
168
169
  'no-colorize',
170
+ 'no-cities',
169
171
  'no-cluster-pois',
170
172
  'poi',
171
173
  'route',
@@ -207,6 +209,8 @@ export const DIRECTIVE_KEYWORDS = new Set([
207
209
  'color',
208
210
  // Title suppression (cross-chart-type)
209
211
  'no-title',
212
+ // Note suppression (cross-chart-type — graph notes)
213
+ 'no-notes',
210
214
  // Flowchart layout
211
215
  'orientation-vertical',
212
216
  // RACI