@opendata-ai/openchart-engine 7.6.0 → 7.7.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 (44) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1039 -189
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +16 -0
  7. package/src/__tests__/axes.test.ts +3 -2
  8. package/src/__tests__/compile-layer.test.ts +94 -0
  9. package/src/__tests__/facet.test.ts +215 -0
  10. package/src/annotations/__tests__/compute.test.ts +18 -0
  11. package/src/annotations/__tests__/thinning.test.ts +137 -0
  12. package/src/annotations/compute.ts +3 -1
  13. package/src/annotations/thinning.ts +116 -0
  14. package/src/barlist/compile-barlist.ts +1 -0
  15. package/src/charts/bar/labels.ts +1 -0
  16. package/src/charts/column/labels.ts +1 -0
  17. package/src/charts/dot/labels.ts +1 -0
  18. package/src/charts/line/area.ts +16 -2
  19. package/src/charts/line/compute.ts +17 -10
  20. package/src/charts/pie/labels.ts +1 -0
  21. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  22. package/src/compile/color-scale-range.ts +44 -2
  23. package/src/compile/layer.ts +11 -1
  24. package/src/compile.ts +317 -6
  25. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  26. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  27. package/src/compiler/normalize.ts +67 -2
  28. package/src/compiler/types.ts +10 -0
  29. package/src/compiler/validate.ts +79 -0
  30. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  31. package/src/endpoint-labels/compute.ts +179 -5
  32. package/src/endpoint-labels/predict.ts +17 -0
  33. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  34. package/src/layout/axes.ts +8 -2
  35. package/src/layout/dimensions.ts +6 -1
  36. package/src/layout/facet.ts +80 -0
  37. package/src/layout/plan.ts +16 -0
  38. package/src/legend/__tests__/redundancy.test.ts +108 -0
  39. package/src/legend/__tests__/suppression.test.ts +72 -0
  40. package/src/legend/compute.ts +18 -0
  41. package/src/legend/suppression.ts +27 -6
  42. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  43. package/src/tilemap/compile-tilemap.ts +371 -117
  44. package/src/tilemap/types.ts +1 -0
package/dist/index.js CHANGED
@@ -1158,7 +1158,7 @@ function computeAnnotations(spec, scalesOrCtx, chartArea, strategy, isDark, obst
1158
1158
  measure: heuristicMeasure
1159
1159
  };
1160
1160
  }
1161
- const isCompact = ctx.strategy.annotationPosition === "tooltip-only";
1161
+ const isCompact = ctx.strategy.annotationPosition === "tooltip-only" && !ctx.autoThin;
1162
1162
  const svgRect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height };
1163
1163
  const annotations = [];
1164
1164
  const autoQueue = [];
@@ -1347,6 +1347,65 @@ function computeAnnotations(spec, scalesOrCtx, chartArea, strategy, isDark, obst
1347
1347
  return annotations;
1348
1348
  }
1349
1349
 
1350
+ // src/annotations/thinning.ts
1351
+ import { detectCollision as detectCollision2 } from "@opendata-ai/openchart-core";
1352
+ function thinAnnotations(annotations, specs, measure) {
1353
+ if (annotations.length <= 1) {
1354
+ return { annotations, footnotes: [] };
1355
+ }
1356
+ const entries = annotations.map((a, i) => ({
1357
+ resolved: a,
1358
+ spec: i < specs.length && specs[i].type === "text" ? specs[i] : void 0,
1359
+ index: i
1360
+ }));
1361
+ const pinned = [];
1362
+ const candidates = [];
1363
+ for (const entry of entries) {
1364
+ if (entry.resolved.type !== "text" || !entry.resolved.label) {
1365
+ pinned.push(entry);
1366
+ } else if (entry.spec?.responsive === false) {
1367
+ pinned.push(entry);
1368
+ } else {
1369
+ candidates.push(entry);
1370
+ }
1371
+ }
1372
+ candidates.sort((a, b) => {
1373
+ const pa = a.spec?.priority ?? Number.MAX_SAFE_INTEGER;
1374
+ const pb = b.spec?.priority ?? Number.MAX_SAFE_INTEGER;
1375
+ if (pa !== pb) return pa - pb;
1376
+ return a.index - b.index;
1377
+ });
1378
+ const placedBounds = [];
1379
+ for (const entry of pinned) {
1380
+ if (entry.resolved.type === "text" && entry.resolved.label) {
1381
+ const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label, measure);
1382
+ placedBounds.push(bounds);
1383
+ }
1384
+ }
1385
+ const footnotes = [];
1386
+ for (const entry of candidates) {
1387
+ const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label, measure);
1388
+ if (bounds.width <= 0 || bounds.height <= 0) {
1389
+ placedBounds.push(bounds);
1390
+ continue;
1391
+ }
1392
+ const overlaps = placedBounds.some(
1393
+ (pb) => pb.width > 0 && pb.height > 0 && detectCollision2(bounds, pb)
1394
+ );
1395
+ if (overlaps) {
1396
+ const footnoteIndex = footnotes.length + 1;
1397
+ footnotes.push({
1398
+ index: footnoteIndex,
1399
+ text: entry.resolved.label.text
1400
+ });
1401
+ entry.resolved.footnoteIndex = footnoteIndex;
1402
+ } else {
1403
+ placedBounds.push(bounds);
1404
+ }
1405
+ }
1406
+ return { annotations, footnotes };
1407
+ }
1408
+
1350
1409
  // src/endpoint-labels/compute.ts
1351
1410
  import { estimateTextWidth as estimateTextWidth2, wrapText } from "@opendata-ai/openchart-core";
1352
1411
 
@@ -1359,13 +1418,14 @@ function legendShownExplicitly(spec) {
1359
1418
  function legendHiddenExplicitly(spec) {
1360
1419
  return spec.legend?.show === false;
1361
1420
  }
1421
+ var EP_CONFIG_ONLY_KEYS = /* @__PURE__ */ new Set(["show", "maxSeries"]);
1362
1422
  function endpointLabelsExplicitlyOn(spec) {
1363
1423
  const ep = spec.endpointLabels;
1364
1424
  if (ep === true) return true;
1365
1425
  if (ep === false || ep == null) return false;
1366
1426
  if (ep.show === true) return true;
1367
1427
  if (ep.show === false) return false;
1368
- return Object.keys(ep).some((k) => k !== "show");
1428
+ return Object.keys(ep).some((k) => !EP_CONFIG_ONLY_KEYS.has(k));
1369
1429
  }
1370
1430
  function endpointLabelsExplicitlyOff(spec) {
1371
1431
  const ep = spec.endpointLabels;
@@ -1373,6 +1433,12 @@ function endpointLabelsExplicitlyOff(spec) {
1373
1433
  if (typeof ep === "object" && ep != null && ep.show === false) return true;
1374
1434
  return false;
1375
1435
  }
1436
+ var DEFAULT_MAX_SERIES = 8;
1437
+ function getMaxSeries(spec) {
1438
+ const ep = spec.endpointLabels;
1439
+ if (typeof ep === "object" && ep != null && ep.maxSeries != null) return ep.maxSeries;
1440
+ return DEFAULT_MAX_SERIES;
1441
+ }
1376
1442
  function resolveSuppression(spec, ctx) {
1377
1443
  const isLineOrArea2 = spec.markType === "line" || spec.markType === "area";
1378
1444
  const hasColorEncoding = spec.encoding.color != null;
@@ -1398,7 +1464,9 @@ function resolveSuppression(spec, ctx) {
1398
1464
  showEndOfLineLabels: false
1399
1465
  };
1400
1466
  }
1401
- const epExplicitOff = endpointLabelsExplicitlyOff(spec);
1467
+ const exceedsCutoff = !endpointLabelsExplicitlyOn(spec) && ctx.seriesCount > getMaxSeries(spec);
1468
+ const demoted = !endpointLabelsExplicitlyOn(spec) && !!ctx.endpointLabelsDemoted;
1469
+ const epExplicitOff = endpointLabelsExplicitlyOff(spec) || exceedsCutoff || demoted;
1402
1470
  const legShown = legendShownExplicitly(spec);
1403
1471
  const legHidden = legendHiddenExplicitly(spec);
1404
1472
  const showEndpointLabels = !epExplicitOff;
@@ -1717,7 +1785,8 @@ function emptyLayout(theme) {
1717
1785
  fontSize: ENDPOINT_VALUE_FONT_SIZE,
1718
1786
  fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
1719
1787
  fill: theme.colors.annotationText ?? theme.colors.text,
1720
- lineHeight: ENDPOINT_LINE_HEIGHT
1788
+ lineHeight: ENDPOINT_LINE_HEIGHT,
1789
+ fontVariant: "tabular-nums"
1721
1790
  };
1722
1791
  return {
1723
1792
  entries: [],
@@ -1748,6 +1817,21 @@ function lastDataPoint(mark) {
1748
1817
  }
1749
1818
  return null;
1750
1819
  }
1820
+ function firstDataPoint(mark) {
1821
+ if (mark.dataPoints && mark.dataPoints.length > 0) {
1822
+ const first = mark.dataPoints[0];
1823
+ return { x: first.x, y: first.y };
1824
+ }
1825
+ if (mark.type === "line" && mark.points.length > 0) {
1826
+ const first = mark.points[0];
1827
+ return { x: first.x, y: first.y };
1828
+ }
1829
+ if (mark.type === "area" && mark.topPoints.length > 0) {
1830
+ const first = mark.topPoints[0];
1831
+ return { x: first.x, y: first.y };
1832
+ }
1833
+ return null;
1834
+ }
1751
1835
  function readValue(mark, valueField) {
1752
1836
  if (!valueField) return null;
1753
1837
  const dp = mark.dataPoints;
@@ -1762,6 +1846,20 @@ function readValue(mark, valueField) {
1762
1846
  }
1763
1847
  return null;
1764
1848
  }
1849
+ function readFirstValue(mark, valueField) {
1850
+ if (!valueField) return null;
1851
+ const dp = mark.dataPoints;
1852
+ if (dp && dp.length > 0) {
1853
+ const datum = dp[0].datum;
1854
+ const v = datum[valueField];
1855
+ return typeof v === "number" || typeof v === "string" ? v : null;
1856
+ }
1857
+ if (mark.data.length > 0) {
1858
+ const v = mark.data[0][valueField];
1859
+ return typeof v === "number" || typeof v === "string" ? v : null;
1860
+ }
1861
+ return null;
1862
+ }
1765
1863
  function bidirectionalSweep(entries, areaTop, areaBottom) {
1766
1864
  const n = entries.length;
1767
1865
  if (n === 0) return [];
@@ -1788,16 +1886,20 @@ function bidirectionalSweep(entries, areaTop, areaBottom) {
1788
1886
  }
1789
1887
  return result;
1790
1888
  }
1791
- function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1889
+ function computeEndpointLabels(spec, marks, theme, chartArea, strategy, endpointLabelsDemoted) {
1792
1890
  if (strategy?.labelMode === "none") return emptyLayout(theme);
1891
+ const highlight = spec.highlight ?? [];
1892
+ const highlightActive = highlight.length > 0;
1793
1893
  const seriesCount = countColorSeries(spec);
1794
1894
  const sup = resolveSuppression(spec, {
1795
1895
  seriesCount,
1796
1896
  // The 'none' branch above returned; by here labelMode is not 'none'.
1797
1897
  labelsHiddenByStrategy: false,
1798
- labelsDensityNone: spec.labels.density === "none"
1898
+ labelsDensityNone: spec.labels.density === "none",
1899
+ endpointLabelsDemoted
1799
1900
  });
1800
- if (!sup.showEndpointLabels) return emptyLayout(theme);
1901
+ const epExplicitlyOff = spec.endpointLabels === false || typeof spec.endpointLabels === "object" && spec.endpointLabels?.show === false;
1902
+ if (!sup.showEndpointLabels && !(highlightActive && !epExplicitlyOff)) return emptyLayout(theme);
1801
1903
  const bySeriesKey = /* @__PURE__ */ new Map();
1802
1904
  for (const mark of marks) {
1803
1905
  if (!isLineOrArea(mark) || !mark.seriesKey) continue;
@@ -1828,7 +1930,8 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1828
1930
  fontSize: ENDPOINT_VALUE_FONT_SIZE,
1829
1931
  fontWeight: ENDPOINT_VALUE_FONT_WEIGHT,
1830
1932
  fill: theme.colors.annotationText ?? theme.colors.text,
1831
- lineHeight: ENDPOINT_LINE_HEIGHT
1933
+ lineHeight: ENDPOINT_LINE_HEIGHT,
1934
+ fontVariant: "tabular-nums"
1832
1935
  };
1833
1936
  const provisional = [];
1834
1937
  const labelLineHeight = ENDPOINT_LABEL_FONT_SIZE * ENDPOINT_LINE_HEIGHT;
@@ -1836,6 +1939,7 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1836
1939
  for (const mark of lineOrAreaMarks) {
1837
1940
  const seriesKey = mark.seriesKey;
1838
1941
  if (!seriesKey) continue;
1942
+ if (highlightActive && !highlight.includes(seriesKey)) continue;
1839
1943
  const last = lastDataPoint(mark);
1840
1944
  if (!last) continue;
1841
1945
  const labelLines = wrapText(
@@ -1868,7 +1972,8 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1868
1972
  width: entryWidth
1869
1973
  });
1870
1974
  }
1871
- if (provisional.length < 2) return emptyLayout(theme);
1975
+ if (provisional.length === 0) return emptyLayout(theme);
1976
+ if (provisional.length < 2 && !highlightActive) return emptyLayout(theme);
1872
1977
  const sweepInput = provisional.map((p, i) => ({
1873
1978
  naturalTop: p.dataY - ENDPOINT_LABEL_FONT_SIZE / 2,
1874
1979
  height: p.height + ENDPOINT_ENTRY_GAP,
@@ -1907,6 +2012,97 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1907
2012
  }
1908
2013
  return entry;
1909
2014
  });
2015
+ const endsBoth = config?.ends === "both";
2016
+ let leading;
2017
+ let leadingBounds;
2018
+ if (endsBoth) {
2019
+ const leadingProvisional = [];
2020
+ for (const mark of lineOrAreaMarks) {
2021
+ const seriesKey = mark.seriesKey;
2022
+ if (!seriesKey) continue;
2023
+ if (highlightActive && !highlight.includes(seriesKey)) continue;
2024
+ const first = firstDataPoint(mark);
2025
+ if (!first) continue;
2026
+ const labelLines = wrapText(
2027
+ seriesKey,
2028
+ ENDPOINT_LABEL_FONT_SIZE,
2029
+ ENDPOINT_LABEL_FONT_WEIGHT,
2030
+ wrapWidth
2031
+ );
2032
+ const rawValue = readFirstValue(mark, valueField);
2033
+ const value2 = formatEndpointValue(rawValue, formatString);
2034
+ let entryWidth = 0;
2035
+ for (const line of labelLines) {
2036
+ const w = estimateTextWidth2(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
2037
+ if (w > entryWidth) entryWidth = w;
2038
+ }
2039
+ const valueWidth = value2 ? estimateTextWidth2(value2, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT) : 0;
2040
+ if (valueWidth > entryWidth) entryWidth = valueWidth;
2041
+ const labelHeight = labelLines.length * labelLineHeight;
2042
+ const valueHeight = value2 ? valueLineHeight : 0;
2043
+ const entryHeight = labelHeight + (value2 ? ENDPOINT_VALUE_GAP : 0) + valueHeight;
2044
+ const color2 = mark.type === "line" ? mark.stroke : mark.stroke ?? (typeof mark.fill === "string" ? mark.fill : theme.colors.categorical[0]);
2045
+ leadingProvisional.push({
2046
+ seriesKey,
2047
+ labelLines,
2048
+ value: value2,
2049
+ color: color2,
2050
+ dataX: first.x,
2051
+ dataY: first.y,
2052
+ height: entryHeight,
2053
+ width: entryWidth
2054
+ });
2055
+ }
2056
+ if (leadingProvisional.length >= 2 || leadingProvisional.length === 1 && highlightActive) {
2057
+ const leadingSweepInput = leadingProvisional.map((p, i) => ({
2058
+ naturalTop: p.dataY - ENDPOINT_LABEL_FONT_SIZE / 2,
2059
+ height: p.height + ENDPOINT_ENTRY_GAP,
2060
+ index: i
2061
+ }));
2062
+ const leadingSweptTops = bidirectionalSweep(
2063
+ leadingSweepInput,
2064
+ chartArea.y,
2065
+ chartArea.y + chartArea.height
2066
+ );
2067
+ const leadingColumnWidth = Math.max(
2068
+ ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + Math.max(...leadingProvisional.map((p) => p.width), 0) + 4,
2069
+ 32
2070
+ );
2071
+ const leadingColumnX = chartArea.x - ENDPOINT_COLUMN_GAP - leadingColumnWidth;
2072
+ leading = leadingProvisional.map((p, i) => {
2073
+ const labelY = leadingSweptTops[i];
2074
+ const swatchRowY = labelY + ENDPOINT_LABEL_FONT_SIZE / 2;
2075
+ const displaced = Math.abs(swatchRowY - p.dataY) > ENDPOINT_LEADER_THRESHOLD;
2076
+ const entry = {
2077
+ seriesKey: p.seriesKey,
2078
+ labelLines: p.labelLines,
2079
+ value: p.value,
2080
+ color: p.color,
2081
+ dataY: p.dataY,
2082
+ labelY,
2083
+ showLeader: showLeader && displaced
2084
+ };
2085
+ if (showMarker) {
2086
+ entry.marker = {
2087
+ x: p.dataX - markerRadius,
2088
+ y: p.dataY,
2089
+ dataX: p.dataX,
2090
+ fill: markerFill,
2091
+ stroke: config?.markerStyle?.stroke ?? p.color,
2092
+ strokeWidth: markerStrokeWidth,
2093
+ radius: markerRadius
2094
+ };
2095
+ }
2096
+ return entry;
2097
+ });
2098
+ leadingBounds = {
2099
+ x: leadingColumnX,
2100
+ y: chartArea.y,
2101
+ width: leadingColumnWidth,
2102
+ height: chartArea.height
2103
+ };
2104
+ }
2105
+ }
1910
2106
  return {
1911
2107
  entries,
1912
2108
  bounds: {
@@ -1920,7 +2116,9 @@ function computeEndpointLabels(spec, marks, theme, chartArea, strategy) {
1920
2116
  swatchSize: ENDPOINT_SWATCH_SIZE,
1921
2117
  gap: ENDPOINT_GAP,
1922
2118
  valueGap: ENDPOINT_VALUE_GAP,
1923
- swatchChipFill: theme.colors.annotationFill ?? theme.colors.background
2119
+ swatchChipFill: theme.colors.annotationFill ?? theme.colors.background,
2120
+ leading,
2121
+ leadingBounds
1924
2122
  };
1925
2123
  }
1926
2124
 
@@ -2512,7 +2710,8 @@ function computeBarLabels(marks, _chartArea, density = "auto", labelFormat, labe
2512
2710
  fill,
2513
2711
  lineHeight: 1.2,
2514
2712
  textAnchor,
2515
- dominantBaseline: "central"
2713
+ dominantBaseline: "central",
2714
+ fontVariant: "tabular-nums"
2516
2715
  }
2517
2716
  });
2518
2717
  }
@@ -2935,7 +3134,8 @@ function computeColumnLabels(marks, _chartArea, density = "auto", labelFormat, l
2935
3134
  fontWeight: LABEL_FONT_WEIGHT2,
2936
3135
  fill: labelColor ?? getRepresentativeColor2(mark.fill),
2937
3136
  lineHeight: 1.2,
2938
- textAnchor: "middle"
3137
+ textAnchor: "middle",
3138
+ fontVariant: "tabular-nums"
2939
3139
  }
2940
3140
  });
2941
3141
  }
@@ -3174,7 +3374,8 @@ function computeDotLabels(marks, _chartArea, density = "auto", labelPrefix, labe
3174
3374
  fill: getRepresentativeColor3(mark.fill),
3175
3375
  lineHeight: 1.2,
3176
3376
  textAnchor: "start",
3177
- dominantBaseline: "central"
3377
+ dominantBaseline: "central",
3378
+ fontVariant: "tabular-nums"
3178
3379
  }
3179
3380
  });
3180
3381
  }
@@ -4363,10 +4564,23 @@ function computeStackedArea(spec, scales, chartArea, darkMode) {
4363
4564
  function computeAreaMarks(spec, scales, chartArea, darkMode) {
4364
4565
  const encoding = spec.encoding;
4365
4566
  const yChannel = encoding.y;
4567
+ let marks;
4366
4568
  if (yChannel && isStacked(yChannel.stack)) {
4367
- return computeStackedArea(spec, scales, chartArea, darkMode);
4569
+ marks = computeStackedArea(spec, scales, chartArea, darkMode);
4570
+ } else {
4571
+ marks = computeSingleArea(spec, scales, chartArea);
4572
+ }
4573
+ const highlight = spec.highlight ?? [];
4574
+ if (highlight.length > 0) {
4575
+ const highlightSet = new Set(highlight);
4576
+ marks.sort((a, b) => {
4577
+ const aMuted = a.seriesKey != null && !highlightSet.has(a.seriesKey);
4578
+ const bMuted = b.seriesKey != null && !highlightSet.has(b.seriesKey);
4579
+ if (aMuted === bMuted) return 0;
4580
+ return aMuted ? -1 : 1;
4581
+ });
4368
4582
  }
4369
- return computeSingleArea(spec, scales, chartArea);
4583
+ return marks;
4370
4584
  }
4371
4585
 
4372
4586
  // src/charts/line/compute.ts
@@ -4385,7 +4599,10 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4385
4599
  const colorField = isSequentialColor ? void 0 : colorEnc?.field;
4386
4600
  const sequentialColorField = isSequentialColor ? colorEnc.field : void 0;
4387
4601
  const groups = groupByField(spec.data, colorField);
4388
- const marks = [];
4602
+ const mutedMarks = [];
4603
+ const highlightedMarks = [];
4604
+ const highlight = spec.highlight ?? [];
4605
+ const highlightActive = highlight.length > 0;
4389
4606
  for (const [seriesKey, rows] of groups) {
4390
4607
  const color2 = isSequentialColor ? getSequentialColor(scales, _getMidValue(rows, sequentialColorField)) : getColor(scales, seriesKey);
4391
4608
  const strokeColor = spec.markDef.stroke ?? getRepresentativeColor5(color2);
@@ -4430,6 +4647,11 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4430
4647
  const combinedPath = pathParts.join(" ");
4431
4648
  const seriesStyleKey = seriesKey === "__default__" ? void 0 : seriesKey;
4432
4649
  const styleOverride = seriesStyleKey ? spec.seriesStyles?.[seriesStyleKey] : void 0;
4650
+ const isMuted = highlightActive && !highlight.includes(seriesKey);
4651
+ let resolvedStrokeWidth = styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH;
4652
+ if (isMuted) {
4653
+ resolvedStrokeWidth = Math.max(1, resolvedStrokeWidth * 0.75);
4654
+ }
4433
4655
  let strokeDasharray;
4434
4656
  if (styleOverride?.lineStyle === "dashed") strokeDasharray = "6 4";
4435
4657
  else if (styleOverride?.lineStyle === "dotted") strokeDasharray = "2 3";
@@ -4438,7 +4660,7 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4438
4660
  points: allPoints,
4439
4661
  path: combinedPath,
4440
4662
  stroke: strokeColor,
4441
- strokeWidth: styleOverride?.strokeWidth ?? spec.markDef.strokeWidth ?? DEFAULT_STROKE_WIDTH,
4663
+ strokeWidth: resolvedStrokeWidth,
4442
4664
  strokeDasharray,
4443
4665
  opacity: styleOverride?.opacity,
4444
4666
  seriesKey: seriesStyleKey,
@@ -4446,7 +4668,8 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4446
4668
  dataPoints: pointsWithData.map((p) => ({ x: p.x, y: p.y, datum: p.row })),
4447
4669
  aria
4448
4670
  };
4449
- marks.push(lineMark);
4671
+ const bucket = isMuted ? mutedMarks : highlightedMarks;
4672
+ bucket.push(lineMark);
4450
4673
  const markPoint = spec.markDef.point;
4451
4674
  const isSingleEndpoint = markPoint === "last" || markPoint === "first";
4452
4675
  const showPoints = markPoint === true || markPoint === "transparent" || markPoint === "endpoints" || isSingleEndpoint || isSequentialColor;
@@ -4473,20 +4696,16 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4473
4696
  const hollow = isEndpoints && visible;
4474
4697
  const pointColorStr = getRepresentativeColor5(pointColor);
4475
4698
  if (isSingleEndpoint) {
4476
- marks.push({
4699
+ bucket.push({
4477
4700
  type: "point",
4478
4701
  cx: p.x,
4479
4702
  cy: p.y,
4480
- // 3.5 reads as a deliberate terminator against the 2px sparkline
4481
- // line — smaller dots blur into the stroke at typical card sizes.
4482
4703
  r: 3.5,
4483
4704
  fill: strokeColor,
4484
4705
  stroke: "transparent",
4485
4706
  strokeWidth: 0,
4486
4707
  fillOpacity: 1,
4487
4708
  data: p.row,
4488
- // No label: decorative marks render with aria-hidden="true" and
4489
- // don't participate in the accessible data table.
4490
4709
  aria: { decorative: true }
4491
4710
  });
4492
4711
  continue;
@@ -4505,11 +4724,11 @@ function computeLineMarks(spec, scales, _chartArea, _strategy) {
4505
4724
  label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`
4506
4725
  }
4507
4726
  };
4508
- marks.push(pointMark);
4727
+ bucket.push(pointMark);
4509
4728
  }
4510
4729
  }
4511
4730
  }
4512
- return marks;
4731
+ return [...mutedMarks, ...highlightedMarks];
4513
4732
  }
4514
4733
  function _getMidValue(rows, field) {
4515
4734
  const values = rows.map((r) => Number(r[field])).filter(Number.isFinite);
@@ -4853,7 +5072,8 @@ function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333
4853
5072
  fill: _textFill,
4854
5073
  lineHeight: 1.2,
4855
5074
  textAnchor: isRight ? "start" : "end",
4856
- dominantBaseline: "central"
5075
+ dominantBaseline: "central",
5076
+ fontVariant: "tabular-nums"
4857
5077
  }
4858
5078
  });
4859
5079
  targetMarkIndices.push(mi);
@@ -8007,6 +8227,30 @@ function normalizeLabels(labels) {
8007
8227
  fontSize: labels.fontSize
8008
8228
  };
8009
8229
  }
8230
+ function normalizeHighlight(encoding, data, warnings) {
8231
+ const color2 = encoding.color;
8232
+ if (!color2 || !("field" in color2)) return [];
8233
+ const raw = color2.highlight;
8234
+ if (raw == null) return [];
8235
+ const highlight = Array.isArray(raw) ? raw : [raw];
8236
+ if (highlight.length > 0 && data.length > 0) {
8237
+ const colorField = color2.field;
8238
+ const knownValues = new Set(data.map((row) => String(row[colorField])));
8239
+ for (const v of highlight) {
8240
+ if (!knownValues.has(v)) {
8241
+ warnings.push(
8242
+ `[openchart] encoding.color.highlight value "${v}" does not match any value in the "${colorField}" data column`
8243
+ );
8244
+ }
8245
+ }
8246
+ }
8247
+ if (highlight.length > 0 && color2.scale?.range) {
8248
+ warnings.push(
8249
+ "[openchart] encoding.color.highlight has no effect on color assignment when encoding.color.scale.range is explicitly provided"
8250
+ );
8251
+ }
8252
+ return highlight;
8253
+ }
8010
8254
  function normalizeChartSpec(spec, warnings) {
8011
8255
  const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
8012
8256
  const markType = resolveMarkType(spec.mark);
@@ -8028,13 +8272,16 @@ function normalizeChartSpec(spec, warnings) {
8028
8272
  labels: normalizeLabels(spec.labels),
8029
8273
  legend: spec.legend,
8030
8274
  endpointLabels: spec.endpointLabels,
8031
- responsive: spec.responsive ?? true,
8275
+ responsive: typeof spec.responsive === "object" ? true : spec.responsive ?? true,
8276
+ autoThin: typeof spec.responsive === "object" ? spec.responsive.autoThin ?? true : spec.responsive ?? true,
8032
8277
  theme: spec.theme ?? {},
8033
8278
  darkMode: spec.darkMode ?? "off",
8034
8279
  hiddenSeries: spec.hiddenSeries ?? [],
8035
8280
  seriesStyles: spec.seriesStyles ?? {},
8036
8281
  watermark: spec.watermark ?? true,
8282
+ highlight: normalizeHighlight(encoding, spec.data, warnings),
8037
8283
  display,
8284
+ resolve: spec.resolve,
8038
8285
  // Default empty userExplicit; compileChart overwrites this with the real
8039
8286
  // descriptor built from the raw expanded spec before normalize runs.
8040
8287
  userExplicit: {
@@ -8119,13 +8366,26 @@ function normalizeTileMapSpec(spec, warnings) {
8119
8366
  if (!Array.isArray(spec.data)) {
8120
8367
  data = Object.entries(spec.data).map(([state, value2]) => ({ state, value: value2 }));
8121
8368
  }
8369
+ const hasStringValues = !Array.isArray(spec.data) && Object.values(spec.data).some((v) => typeof v === "string");
8370
+ const isCategorical = spec.colors !== void 0 || spec.encoding?.color !== void 0 || hasStringValues;
8371
+ if (hasStringValues && !Array.isArray(spec.data)) {
8372
+ const hasNumericValues = Object.values(spec.data).some((v) => typeof v === "number");
8373
+ if (hasNumericValues) {
8374
+ warnings.push(
8375
+ "TileMap data: record map contains mixed number and string values. Treating as categorical. Use all numbers for quantitative or all strings for categorical."
8376
+ );
8377
+ }
8378
+ }
8122
8379
  let encoding = spec.encoding;
8123
8380
  if (!encoding) {
8124
8381
  encoding = {
8125
8382
  state: { field: "state", type: "nominal" },
8126
- value: { field: "value", type: "quantitative" }
8383
+ value: { field: "value", type: isCategorical ? "nominal" : "quantitative" }
8127
8384
  };
8128
8385
  }
8386
+ if (isCategorical && !encoding.color) {
8387
+ encoding = { ...encoding, color: encoding.value };
8388
+ }
8129
8389
  let matchedCount = 0;
8130
8390
  for (const row of data) {
8131
8391
  const stateCode = String(row[encoding.state.field]);
@@ -8144,6 +8404,7 @@ function normalizeTileMapSpec(spec, warnings) {
8144
8404
  data,
8145
8405
  encoding,
8146
8406
  palette: spec.palette ?? "blue",
8407
+ colors: spec.colors,
8147
8408
  chrome: normalizeChrome(spec.chrome),
8148
8409
  legend: spec.legend,
8149
8410
  theme: spec.theme ?? {},
@@ -8238,6 +8499,7 @@ function flattenLayers(spec, parentData, parentEncoding, parentTransforms, paren
8238
8499
 
8239
8500
  // src/compiler/validate.ts
8240
8501
  import {
8502
+ inferFieldType as inferFieldType2,
8241
8503
  MARK_ENCODING_RULES,
8242
8504
  MARK_TYPES
8243
8505
  } from "@opendata-ai/openchart-core";
@@ -8440,6 +8702,68 @@ function validateChartSpec(spec, errors) {
8440
8702
  }
8441
8703
  }
8442
8704
  }
8705
+ if (encoding) {
8706
+ const colorCh = encoding.color;
8707
+ if (colorCh && colorCh.highlight != null) {
8708
+ const hlVal = colorCh.highlight;
8709
+ const isValidType = typeof hlVal === "string" || Array.isArray(hlVal) && hlVal.every((v) => typeof v === "string");
8710
+ if (!isValidType) {
8711
+ errors.push({
8712
+ message: "Spec error: encoding.color.highlight must be a string or array of strings",
8713
+ path: "encoding.color.highlight",
8714
+ code: "INVALID_TYPE",
8715
+ suggestion: "Provide a series name (string) or an array of series names."
8716
+ });
8717
+ } else {
8718
+ const colorType = colorCh.type ?? (colorCh.field && Array.isArray(spec.data) && spec.data.length > 0 ? inferFieldType2(spec.data, colorCh.field) : void 0);
8719
+ if (colorType === "quantitative") {
8720
+ errors.push({
8721
+ message: "Spec error: encoding.color.highlight is not supported on quantitative color channels",
8722
+ path: "encoding.color.highlight",
8723
+ code: "INVALID_VALUE",
8724
+ suggestion: "Highlight works with nominal/ordinal color (categorical series). Remove highlight or change encoding.color.type to nominal."
8725
+ });
8726
+ }
8727
+ }
8728
+ }
8729
+ }
8730
+ if (encoding.facet && typeof encoding.facet === "object") {
8731
+ const facet = encoding.facet;
8732
+ if (!facet.field || typeof facet.field !== "string") {
8733
+ errors.push({
8734
+ message: "Spec error: encoding.facet.field is required and must be a non-empty string",
8735
+ path: "encoding.facet.field",
8736
+ code: "MISSING_FIELD",
8737
+ suggestion: 'Add a field property to encoding.facet (e.g. { field: "category", type: "nominal" })'
8738
+ });
8739
+ } else if (!dataColumns.has(facet.field) && !transformFields.has(facet.field)) {
8740
+ errors.push({
8741
+ message: `Spec error: encoding.facet.field "${facet.field}" does not exist in data. Available columns: ${availableColumns}`,
8742
+ path: "encoding.facet.field",
8743
+ code: "DATA_FIELD_MISSING",
8744
+ suggestion: `Use one of the available data columns: ${availableColumns}`
8745
+ });
8746
+ }
8747
+ if (!facet.type || facet.type !== "nominal" && facet.type !== "ordinal") {
8748
+ errors.push({
8749
+ message: `Spec error: encoding.facet.type must be "nominal" or "ordinal"${facet.type ? `, got "${facet.type}"` : ""}`,
8750
+ path: "encoding.facet.type",
8751
+ code: facet.type ? "INVALID_VALUE" : "MISSING_FIELD",
8752
+ suggestion: 'Set encoding.facet.type to "nominal" or "ordinal"'
8753
+ });
8754
+ }
8755
+ if (facet.columns !== void 0) {
8756
+ const cols = Number(facet.columns);
8757
+ if (!Number.isInteger(cols) || cols < 1) {
8758
+ errors.push({
8759
+ message: "Spec error: encoding.facet.columns must be a positive integer",
8760
+ path: "encoding.facet.columns",
8761
+ code: "INVALID_VALUE",
8762
+ suggestion: "Set columns to a positive integer (e.g. 3)"
8763
+ });
8764
+ }
8765
+ }
8766
+ }
8443
8767
  if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
8444
8768
  errors.push({
8445
8769
  message: `Spec error: darkMode must be "auto", "force", or "off"`,
@@ -9262,7 +9586,8 @@ function compileBarList(spec, options) {
9262
9586
  fontSize: VALUE_FONT_SIZE,
9263
9587
  fontWeight: VALUE_FONT_WEIGHT,
9264
9588
  fill: valueColor,
9265
- lineHeight: 1.4
9589
+ lineHeight: 1.4,
9590
+ fontVariant: "tabular-nums"
9266
9591
  };
9267
9592
  const rowMark = {
9268
9593
  type: "barlist-row",
@@ -9507,7 +9832,9 @@ function assignAnimationIndices(marks, animation) {
9507
9832
  }
9508
9833
 
9509
9834
  // src/compile/color-scale-range.ts
9510
- function applyColorScaleRange(scales, encoding, theme) {
9835
+ import { ACHROMATIC_RAMP } from "@opendata-ai/openchart-core";
9836
+ var MUTED_COLOR = "#bfc3c8";
9837
+ function applyColorScaleRange(scales, encoding, theme, highlight) {
9511
9838
  if (!scales.color) return;
9512
9839
  const hasExplicitRange = !!(encoding.color && "field" in encoding.color && encoding.color.scale?.range?.length);
9513
9840
  if (hasExplicitRange) return;
@@ -9518,7 +9845,34 @@ function applyColorScaleRange(scales, encoding, theme) {
9518
9845
  seqStops[seqStops.length - 1]
9519
9846
  ]);
9520
9847
  } else {
9521
- scales.color.scale.range(theme.colors.categorical);
9848
+ const ordinalScale = scales.color.scale;
9849
+ const palette = theme.colors.categorical;
9850
+ const domain = ordinalScale.domain();
9851
+ if (highlight && highlight.length > 0) {
9852
+ const highlightSet = new Set(highlight);
9853
+ const colors = domain.map(
9854
+ (v, i) => highlightSet.has(v) ? palette[i % palette.length] : MUTED_COLOR
9855
+ );
9856
+ ordinalScale.range(colors);
9857
+ } else {
9858
+ const strategy = theme.seriesStrategy ?? "palette";
9859
+ if (strategy !== "accent-neutral") {
9860
+ ordinalScale.range(palette);
9861
+ } else {
9862
+ const count = domain.length;
9863
+ if (count <= 1) {
9864
+ ordinalScale.range([palette[0]]);
9865
+ } else if (count <= 4) {
9866
+ const neutrals = theme.isDark ? [ACHROMATIC_RAMP.fgMuted, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgFaint] : [ACHROMATIC_RAMP.fgFaint, ACHROMATIC_RAMP.fgSubtle, ACHROMATIC_RAMP.fgMuted];
9867
+ const colors = domain.map(
9868
+ (_, i) => i === 0 ? palette[0] : neutrals[(i - 1) % neutrals.length]
9869
+ );
9870
+ ordinalScale.range(colors);
9871
+ } else {
9872
+ ordinalScale.range(palette);
9873
+ }
9874
+ }
9875
+ }
9522
9876
  }
9523
9877
  }
9524
9878
 
@@ -9546,7 +9900,7 @@ import {
9546
9900
  AXIS_TITLE_TRAILING_PAD as AXIS_TITLE_TRAILING_PAD2,
9547
9901
  abbreviateNumber as abbreviateNumber4,
9548
9902
  BREAKPOINT_COMPACT_MAX as BREAKPOINT_COMPACT_MAX2,
9549
- estimateTextWidth as estimateTextWidth12,
9903
+ estimateTextWidth as estimateTextWidth13,
9550
9904
  formatNumber as formatNumber5,
9551
9905
  getAxisTitleOffset,
9552
9906
  resolveTheme as resolveTheme2,
@@ -9561,7 +9915,7 @@ import {
9561
9915
  BREAKPOINT_COMPACT_MAX,
9562
9916
  computeChrome as computeChrome2,
9563
9917
  computeXAxisExtentFromLabels,
9564
- estimateTextWidth as estimateTextWidth11,
9918
+ estimateTextWidth as estimateTextWidth12,
9565
9919
  formatNumber as formatNumber4,
9566
9920
  HPAD_COMPACT_FRACTION,
9567
9921
  HPAD_COMPACT_MIN,
@@ -9575,11 +9929,66 @@ import {
9575
9929
  TOP_PAD_EXTRA_NARROW
9576
9930
  } from "@opendata-ai/openchart-core";
9577
9931
 
9932
+ // src/endpoint-labels/predict.ts
9933
+ import { estimateTextWidth as estimateTextWidth9, wrapText as wrapText2 } from "@opendata-ai/openchart-core";
9934
+ function predictEndpointLabelsWidth(spec, _theme) {
9935
+ if (spec.endpointLabels === false) return 0;
9936
+ if (spec.markType !== "line" && spec.markType !== "area") return 0;
9937
+ const colorEnc = spec.encoding.color;
9938
+ if (!colorEnc) return 0;
9939
+ if ("condition" in colorEnc) return 0;
9940
+ if (colorEnc.type === "quantitative") return 0;
9941
+ const colorField = "field" in colorEnc ? colorEnc.field : void 0;
9942
+ if (!colorField) return 0;
9943
+ const seriesNames = /* @__PURE__ */ new Set();
9944
+ for (const row of spec.data) {
9945
+ seriesNames.add(String(row[colorField]));
9946
+ }
9947
+ if (seriesNames.size < 2) return 0;
9948
+ if (typeof spec.endpointLabels === "object" && spec.endpointLabels?.show === false) return 0;
9949
+ const maxSeries = typeof spec.endpointLabels === "object" && spec.endpointLabels?.maxSeries || 8;
9950
+ if (!endpointLabelsExplicitlyOn(spec) && seriesNames.size > maxSeries) return 0;
9951
+ const config = typeof spec.endpointLabels === "object" ? spec.endpointLabels : void 0;
9952
+ const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
9953
+ let maxLabelWidth = 0;
9954
+ for (const name of seriesNames) {
9955
+ const lines = wrapText2(name, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
9956
+ for (const line of lines) {
9957
+ const w = estimateTextWidth9(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
9958
+ if (w > maxLabelWidth) maxLabelWidth = w;
9959
+ }
9960
+ }
9961
+ const yField = config?.valueField ?? spec.encoding.y?.field;
9962
+ const yFormat = config?.format ?? spec.encoding.y?.axis?.format;
9963
+ let valueWidth = 0;
9964
+ if (yField) {
9965
+ let maxAbs = 0;
9966
+ for (const row of spec.data) {
9967
+ const v = Number(row[yField]);
9968
+ if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
9969
+ }
9970
+ let sample;
9971
+ if (yFormat) {
9972
+ sample = formatEndpointValue(maxAbs, yFormat);
9973
+ } else if (maxAbs >= 1e9) sample = "1.5B";
9974
+ else if (maxAbs >= 1e6) sample = "1.5M";
9975
+ else if (maxAbs >= 1e3) sample = "1.5K";
9976
+ else sample = String(Math.round(maxAbs * 100) / 100);
9977
+ valueWidth = estimateTextWidth9(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
9978
+ }
9979
+ const textColumn = Math.max(maxLabelWidth, valueWidth);
9980
+ return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
9981
+ }
9982
+ function isEndsBoth(spec) {
9983
+ const ep = spec.endpointLabels;
9984
+ return typeof ep === "object" && ep != null && ep.ends === "both";
9985
+ }
9986
+
9578
9987
  // src/legend/compute.ts
9579
- import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH as COMPACT_WIDTH2, estimateTextWidth as estimateTextWidth10 } from "@opendata-ai/openchart-core";
9988
+ import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH as COMPACT_WIDTH2, estimateTextWidth as estimateTextWidth11 } from "@opendata-ai/openchart-core";
9580
9989
 
9581
9990
  // src/legend/wrap.ts
9582
- import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
9991
+ import { COMPACT_WIDTH, estimateTextWidth as estimateTextWidth10 } from "@opendata-ai/openchart-core";
9583
9992
  var SWATCH_SIZE = 12;
9584
9993
  var SWATCH_GAP = 6;
9585
9994
  var ENTRY_GAP = 16;
@@ -9592,7 +10001,7 @@ function measureLegendWrap(entries, maxWidth, labelStyle, maxRows, entryGap = EN
9592
10001
  if (entries.length === 0) {
9593
10002
  return { rowCount: 0, fittingCount: 0, rowWidths: [], placements: [] };
9594
10003
  }
9595
- const measureWidth = measure ?? estimateTextWidth9;
10004
+ const measureWidth = measure ?? estimateTextWidth10;
9596
10005
  let rowCount = 1;
9597
10006
  let rowWidth = 0;
9598
10007
  const rowWidths = [];
@@ -9721,8 +10130,8 @@ function emptyContent(position, theme) {
9721
10130
  ...categoricalDefaults(theme)
9722
10131
  };
9723
10132
  }
9724
- function computeLegendContent(spec, strategy, theme, availableWidth, availableHeight, watermark = true, measure) {
9725
- const measureWidth = measure ?? estimateTextWidth10;
10133
+ function computeLegendContent(spec, strategy, theme, availableWidth, availableHeight, watermark = true, measure, endpointLabelsDemoted) {
10134
+ const measureWidth = measure ?? estimateTextWidth11;
9726
10135
  const sparklineHidden = spec.display === "sparkline" && !spec.userExplicit.legend;
9727
10136
  if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
9728
10137
  return emptyContent("top", theme);
@@ -9732,11 +10141,23 @@ function computeLegendContent(spec, strategy, theme, availableWidth, availableHe
9732
10141
  const suppression = resolveSuppression(spec, {
9733
10142
  seriesCount,
9734
10143
  labelsHiddenByStrategy: strategy.labelMode === "none",
9735
- labelsDensityNone: spec.labels.density === "none"
10144
+ labelsDensityNone: spec.labels.density === "none",
10145
+ endpointLabelsDemoted
9736
10146
  });
9737
10147
  if (!suppression.showTraditionalLegend) {
9738
10148
  entries = [];
9739
10149
  }
10150
+ const hasRedundantLegend = spec.markType === "bar" || spec.markType === "lollipop";
10151
+ if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
10152
+ const colorEnc = spec.encoding.color;
10153
+ const colorField = colorEnc && "field" in colorEnc ? colorEnc.field : void 0;
10154
+ if (colorField) {
10155
+ const categoryField = spec.encoding.y?.type === "quantitative" ? spec.encoding.x?.field : spec.encoding.y?.field;
10156
+ if (colorField === categoryField) {
10157
+ entries = [];
10158
+ }
10159
+ }
10160
+ }
9740
10161
  const labelStyle = {
9741
10162
  fontFamily: theme.fonts.family,
9742
10163
  fontSize: theme.fonts.sizes.small,
@@ -10611,7 +11032,7 @@ function createMeasureFn(measureText) {
10611
11032
  if (measureText) {
10612
11033
  return (text, fontSize, fontWeight) => measureText(text, fontSize, fontWeight).width;
10613
11034
  }
10614
- return estimateTextWidth11;
11035
+ return estimateTextWidth12;
10615
11036
  }
10616
11037
  function sampleLabelFromMagnitude(maxAbsVal) {
10617
11038
  return maxAbsVal >= 1e3 ? abbreviateNumber3(maxAbsVal) : formatNumber4(maxAbsVal);
@@ -10743,6 +11164,14 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
10743
11164
  }
10744
11165
  let prev = null;
10745
11166
  let finalPlan = null;
11167
+ const MAX_ENDPOINT_FRACTION = 0.3;
11168
+ let endpointLabelsDemoted = false;
11169
+ if (chartSpec.autoThin) {
11170
+ const epWidth = predictEndpointLabelsWidth(chartSpec, theme);
11171
+ if (epWidth > 0 && epWidth / width > MAX_ENDPOINT_FRACTION) {
11172
+ endpointLabelsDemoted = true;
11173
+ }
11174
+ }
10746
11175
  for (let iter = 0; iter < 2; iter++) {
10747
11176
  const legendAvailWidth = width - padding - leftGutter;
10748
11177
  const legendContent = computeLegendContent(
@@ -10752,7 +11181,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
10752
11181
  legendAvailWidth,
10753
11182
  height,
10754
11183
  watermark,
10755
- measure
11184
+ measure,
11185
+ endpointLabelsDemoted
10756
11186
  );
10757
11187
  const bottomLegendReservation = legendContent.entries.length > 0 && legendContent.position === "bottom" ? legendContent.height + legendGap(width) : 0;
10758
11188
  const chrome = computeChrome2(
@@ -10938,7 +11368,8 @@ function resolveLayoutPlan(chartSpec, renderSpec, options, theme, strategy, wate
10938
11368
  yTickValues,
10939
11369
  yTickCount,
10940
11370
  converged: prev === null || converged,
10941
- inlineYLabelInset
11371
+ inlineYLabelInset,
11372
+ endpointLabelsDemoted
10942
11373
  };
10943
11374
  break;
10944
11375
  }
@@ -10976,8 +11407,14 @@ function compileLayer(spec, options, compileChart2) {
10976
11407
  zIndex: leaf.zIndex ?? i
10977
11408
  }));
10978
11409
  indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
11410
+ const leafOptions = { ...options, frozenChartArea: primaryLayout.area };
10979
11411
  for (const { leaf } of indexedLeaves) {
10980
- const leafLayout = compileChart2(leaf, options);
11412
+ const themedLeaf = {
11413
+ ...leaf,
11414
+ theme: leaf.theme ?? spec.theme,
11415
+ darkMode: leaf.darkMode ?? spec.darkMode
11416
+ };
11417
+ const leafLayout = compileChart2(themedLeaf, leafOptions);
10981
11418
  allMarks.push(...leafLayout.marks);
10982
11419
  const leafLeg = leafLayout.legend;
10983
11420
  if ("entries" in leafLeg) {
@@ -11003,7 +11440,7 @@ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
11003
11440
  const yEnc = encoding.y;
11004
11441
  const yField = yEnc.field;
11005
11442
  if (!yField) return 40;
11006
- const m = measure ?? estimateTextWidth12;
11443
+ const m = measure ?? estimateTextWidth13;
11007
11444
  const yType = yEnc.type;
11008
11445
  if (yType === "nominal" || yType === "ordinal") {
11009
11446
  let maxWidth = 0;
@@ -11802,17 +12239,17 @@ var DEFAULT_COLLISION_PADDING = 5;
11802
12239
  import {
11803
12240
  axisTitleOffset as axisTitleOffset2,
11804
12241
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
11805
- estimateTextWidth as estimateTextWidth14,
12242
+ estimateTextWidth as estimateTextWidth15,
11806
12243
  getAxisTitleOffset as getAxisTitleOffset2,
11807
12244
  X_AXIS_TITLE_BAND_ROTATED
11808
12245
  } from "@opendata-ai/openchart-core";
11809
12246
 
11810
12247
  // src/layout/axes/thinning.ts
11811
- import { estimateTextWidth as estimateTextWidth13 } from "@opendata-ai/openchart-core";
12248
+ import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
11812
12249
  var MIN_TICK_GAP_FACTOR = 0.5;
11813
12250
  var MIN_TICK_COUNT = 2;
11814
12251
  function measureLabel(text, fontSize, fontWeight, measureText) {
11815
- return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth13(text, fontSize, fontWeight);
12252
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth14(text, fontSize, fontWeight);
11816
12253
  }
11817
12254
  function ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
11818
12255
  if (ticks2.length < 2) return false;
@@ -11857,6 +12294,7 @@ var HEIGHT_REDUCED_THRESHOLD = 100;
11857
12294
  var WIDTH_MINIMAL_THRESHOLD = 150;
11858
12295
  var WIDTH_REDUCED_THRESHOLD = 300;
11859
12296
  var DENSITY_ORDER = ["full", "reduced", "minimal"];
12297
+ var GROUNDED_MARKS = /* @__PURE__ */ new Set(["bar", "tick", "lollipop"]);
11860
12298
  function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
11861
12299
  let density = baseDensity;
11862
12300
  if (axisLength < minimalThreshold) {
@@ -12021,9 +12459,10 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12021
12459
  const axisTitle = axisConfig?.title;
12022
12460
  const xLabelColor = axisConfig?.labelColor;
12023
12461
  const xTickPosition = axisConfig?.tickPosition ?? "gutter";
12462
+ const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
12024
12463
  result.x = {
12025
12464
  ticks: ticks2,
12026
- gridlines: axisConfig?.grid ? gridlines : [],
12465
+ gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
12027
12466
  label: axisTitle,
12028
12467
  labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
12029
12468
  tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
@@ -12031,7 +12470,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12031
12470
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
12032
12471
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
12033
12472
  orient: axisConfig?.orient,
12034
- domainLine: axisConfig?.domain,
12473
+ domainLine: xDomainLine,
12035
12474
  tickMarks: axisConfig?.ticks,
12036
12475
  offset: axisConfig?.offset,
12037
12476
  titlePadding: axisConfig?.titlePadding,
@@ -12144,7 +12583,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12144
12583
  };
12145
12584
  } else {
12146
12585
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12147
- const w = estimateTextWidth14(
12586
+ const w = estimateTextWidth15(
12148
12587
  t.label,
12149
12588
  result.y.tickLabelStyle.fontSize,
12150
12589
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12181,55 +12620,6 @@ import {
12181
12620
  TOP_PAD_EXTRA_NARROW as TOP_PAD_EXTRA_NARROW2
12182
12621
  } from "@opendata-ai/openchart-core";
12183
12622
 
12184
- // src/endpoint-labels/predict.ts
12185
- import { estimateTextWidth as estimateTextWidth15, wrapText as wrapText2 } from "@opendata-ai/openchart-core";
12186
- function predictEndpointLabelsWidth(spec, _theme) {
12187
- if (spec.endpointLabels === false) return 0;
12188
- if (spec.markType !== "line" && spec.markType !== "area") return 0;
12189
- const colorEnc = spec.encoding.color;
12190
- if (!colorEnc) return 0;
12191
- if ("condition" in colorEnc) return 0;
12192
- if (colorEnc.type === "quantitative") return 0;
12193
- const colorField = "field" in colorEnc ? colorEnc.field : void 0;
12194
- if (!colorField) return 0;
12195
- const seriesNames = /* @__PURE__ */ new Set();
12196
- for (const row of spec.data) {
12197
- seriesNames.add(String(row[colorField]));
12198
- }
12199
- if (seriesNames.size < 2) return 0;
12200
- if (typeof spec.endpointLabels === "object" && spec.endpointLabels?.show === false) return 0;
12201
- const config = typeof spec.endpointLabels === "object" ? spec.endpointLabels : void 0;
12202
- const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
12203
- let maxLabelWidth = 0;
12204
- for (const name of seriesNames) {
12205
- const lines = wrapText2(name, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
12206
- for (const line of lines) {
12207
- const w = estimateTextWidth15(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
12208
- if (w > maxLabelWidth) maxLabelWidth = w;
12209
- }
12210
- }
12211
- const yField = config?.valueField ?? spec.encoding.y?.field;
12212
- const yFormat = config?.format ?? spec.encoding.y?.axis?.format;
12213
- let valueWidth = 0;
12214
- if (yField) {
12215
- let maxAbs = 0;
12216
- for (const row of spec.data) {
12217
- const v = Number(row[yField]);
12218
- if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
12219
- }
12220
- let sample;
12221
- if (yFormat) {
12222
- sample = formatEndpointValue(maxAbs, yFormat);
12223
- } else if (maxAbs >= 1e9) sample = "1.5B";
12224
- else if (maxAbs >= 1e6) sample = "1.5M";
12225
- else if (maxAbs >= 1e3) sample = "1.5K";
12226
- else sample = String(Math.round(maxAbs * 100) / 100);
12227
- valueWidth = estimateTextWidth15(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
12228
- }
12229
- const textColumn = Math.max(maxLabelWidth, valueWidth);
12230
- return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
12231
- }
12232
-
12233
12623
  // src/layout/metrics.ts
12234
12624
  import { estimateTextWidth as estimateTextWidth16 } from "@opendata-ai/openchart-core";
12235
12625
  var DEFAULT_LABEL_FONT_SIZE = 10;
@@ -12413,13 +12803,17 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12413
12803
  const sup = resolveSuppression(spec, {
12414
12804
  seriesCount,
12415
12805
  labelsHiddenByStrategy,
12416
- labelsDensityNone: labelDensity === "none"
12806
+ labelsDensityNone: labelDensity === "none",
12807
+ endpointLabelsDemoted: plan?.endpointLabelsDemoted
12417
12808
  });
12418
12809
  let endpointWidth = 0;
12419
12810
  if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
12420
12811
  endpointWidth = predictEndpointLabelsWidth(spec, theme);
12421
12812
  if (endpointWidth > 0) {
12422
12813
  margins.right = Math.max(margins.right, hPad) + endpointWidth + 16;
12814
+ if (isEndsBoth(spec)) {
12815
+ margins.left = Math.max(margins.left, hPad) + endpointWidth + 16;
12816
+ }
12423
12817
  }
12424
12818
  }
12425
12819
  if (endpointWidth === 0 && sup.showEndOfLineLabels && (spec.markType === "line" || spec.markType === "area") && labelDensity !== "none" && !labelsHiddenByStrategy) {
@@ -12695,6 +13089,57 @@ function resolveMetrics(spec, metricsTopY, metricsArea, remainingChartHeight, me
12695
13089
  );
12696
13090
  }
12697
13091
 
13092
+ // src/layout/facet.ts
13093
+ var MIN_PANEL_WIDTH = 200;
13094
+ var PANEL_GAP = 12;
13095
+ var HEADER_HEIGHT = 20;
13096
+ function autoColumns(panelCount, availableWidth) {
13097
+ const sqrtCols = Math.ceil(Math.sqrt(panelCount));
13098
+ const fitCols = Math.max(1, Math.floor(availableWidth / MIN_PANEL_WIDTH));
13099
+ return Math.min(sqrtCols, fitCols, panelCount);
13100
+ }
13101
+ function computeFacetGrid(facetValues, requestedColumns, totalArea, outerAxisReservation) {
13102
+ const n = facetValues.length;
13103
+ if (n === 0) return { panels: [], columns: 0, rows: 0 };
13104
+ const usableWidth = totalArea.width - outerAxisReservation.left;
13105
+ let columns = Math.min(requestedColumns ?? autoColumns(n, usableWidth), n);
13106
+ while (columns > 1) {
13107
+ const panelWidth2 = (usableWidth - (columns - 1) * PANEL_GAP) / columns;
13108
+ if (panelWidth2 >= MIN_PANEL_WIDTH) break;
13109
+ columns--;
13110
+ }
13111
+ const rows = Math.ceil(n / columns);
13112
+ const panelWidth = Math.max(0, (usableWidth - (columns - 1) * PANEL_GAP) / columns);
13113
+ const usableHeight = totalArea.height - outerAxisReservation.bottom;
13114
+ const panelHeight = Math.max(
13115
+ 0,
13116
+ (usableHeight - rows * HEADER_HEIGHT - (rows - 1) * PANEL_GAP) / rows
13117
+ );
13118
+ const panels = [];
13119
+ for (let i = 0; i < n; i++) {
13120
+ const col = i % columns;
13121
+ const row = Math.floor(i / columns);
13122
+ const x2 = totalArea.x + outerAxisReservation.left + col * (panelWidth + PANEL_GAP);
13123
+ const y2 = totalArea.y + row * (HEADER_HEIGHT + panelHeight + PANEL_GAP);
13124
+ panels.push({
13125
+ key: facetValues[i],
13126
+ area: {
13127
+ x: x2,
13128
+ y: y2 + HEADER_HEIGHT,
13129
+ width: panelWidth,
13130
+ height: panelHeight
13131
+ },
13132
+ headerPos: {
13133
+ x: x2 + panelWidth / 2,
13134
+ y: y2 + HEADER_HEIGHT * 0.6
13135
+ },
13136
+ row,
13137
+ col
13138
+ });
13139
+ }
13140
+ return { panels, columns, rows };
13141
+ }
13142
+
12698
13143
  // src/layout/gridlines.ts
12699
13144
  function computeGridlines(axes, chartArea, showVertical = false) {
12700
13145
  const horizontal = [];
@@ -14333,6 +14778,7 @@ function compileTableLayout(spec, options, theme) {
14333
14778
  import {
14334
14779
  adaptTheme as adaptTheme4,
14335
14780
  buildD3Formatter as buildD3Formatter8,
14781
+ CATEGORICAL_PALETTE,
14336
14782
  computeChrome as computeChrome7,
14337
14783
  estimateTextWidth as estimateTextWidth20,
14338
14784
  formatNumber as formatNumber8,
@@ -14341,6 +14787,9 @@ import {
14341
14787
  } from "@opendata-ai/openchart-core";
14342
14788
  var TILE_CORNER_RADIUS = 6;
14343
14789
  var TILE_STROKE_WIDTH = 1;
14790
+ var LEGEND_SWATCH_SIZE = 10;
14791
+ var LEGEND_SWATCH_GAP = 6;
14792
+ var LEGEND_ENTRY_GAP = 16;
14344
14793
  function compileTileMap(spec, options) {
14345
14794
  const { spec: normalized } = compile(spec);
14346
14795
  if (!("type" in normalized) || normalized.type !== "tilemap") {
@@ -14383,6 +14832,13 @@ function compileTileMap(spec, options) {
14383
14832
  if (fullArea.width <= 0 || fullArea.height <= 0) {
14384
14833
  return emptyLayout4(chrome, theme, options, watermark);
14385
14834
  }
14835
+ const isCategorical = tilemapSpec.encoding.color !== void 0 || tilemapSpec.colors !== void 0;
14836
+ if (isCategorical) {
14837
+ return compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
14838
+ }
14839
+ return compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
14840
+ }
14841
+ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark) {
14386
14842
  const stateField = tilemapSpec.encoding.state.field;
14387
14843
  const valueField = tilemapSpec.encoding.value.field;
14388
14844
  const stateValueMap = /* @__PURE__ */ new Map();
@@ -14416,12 +14872,8 @@ function compileTileMap(spec, options) {
14416
14872
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
14417
14873
  const legendWidth = tilePositions.gridWidth;
14418
14874
  const formatter = buildD3Formatter8(tilemapSpec.valueFormat) ?? formatNumber8;
14419
- const neutralFillLight = "#e0e0e0";
14420
- const neutralFillDark = "#1e2a30";
14421
- const neutralStrokeLight = "#d0d0d0";
14422
- const neutralStrokeDark = "rgba(255,255,255,0.08)";
14423
- const neutralFill = isDarkMode ? neutralFillDark : neutralFillLight;
14424
- const neutralStroke = isDarkMode ? neutralStrokeDark : neutralStrokeLight;
14875
+ const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
14876
+ const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
14425
14877
  const tiles = [];
14426
14878
  for (const { state: stateCode } of US_STATE_TILES) {
14427
14879
  const pos = tilePositions.positions.get(stateCode);
@@ -14432,71 +14884,24 @@ function compileTileMap(spec, options) {
14432
14884
  const fill = hasData ? baseColor : neutralFill;
14433
14885
  const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
14434
14886
  const formattedValue = hasData ? formatter(value2) : "\u2013";
14435
- const labelStyle = {
14436
- fontFamily: theme.fonts.family,
14437
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
14438
- fontWeight: 700,
14439
- fill: "#ffffff",
14440
- lineHeight: 1.2
14441
- };
14442
- const valueLabelStyle = {
14443
- fontFamily: theme.fonts.family,
14444
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
14445
- fontWeight: 300,
14446
- fill: "rgba(255,255,255,0.6)",
14447
- lineHeight: 1.2
14448
- };
14449
- const tileCenterX = tileGridOffsetX + pos.x + tilePositions.tileSize / 2;
14450
- const tileTopY = tileGridOffsetY + pos.y;
14451
- const sz = tilePositions.tileSize;
14452
- const valueLabel = sz < 24 ? { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false } : {
14453
- text: formattedValue,
14454
- x: tileCenterX,
14455
- y: tileTopY + sz * 0.78,
14456
- style: valueLabelStyle,
14457
- visible: true
14458
- };
14459
- const tile = {
14460
- type: "tile",
14461
- stateCode,
14462
- x: tileGridOffsetX + pos.x,
14463
- y: tileTopY,
14464
- size: sz,
14465
- fill,
14466
- fillOpacity: hasData ? opacity : 1,
14467
- stroke,
14468
- strokeWidth: TILE_STROKE_WIDTH,
14469
- cornerRadius: TILE_CORNER_RADIUS,
14470
- value: value2 ?? null,
14471
- formattedValue,
14472
- hasData,
14473
- label: {
14474
- text: stateCode,
14475
- x: tileCenterX,
14476
- y: tileTopY + sz * 0.28,
14477
- style: labelStyle,
14478
- visible: true
14479
- },
14480
- valueLabel,
14481
- data: { state: stateCode, value: value2, stateName: STATE_NAMES[stateCode] ?? stateCode },
14482
- aria: {
14483
- role: "img",
14484
- label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`
14485
- },
14486
- animationIndex: 0
14487
- };
14488
- tiles.push(tile);
14489
- }
14490
- const indices = Array.from({ length: tiles.length }, (_, i) => i);
14491
- let seed = 42;
14492
- for (let i = indices.length - 1; i > 0; i--) {
14493
- seed = seed * 1103515245 + 12345 & 2147483647;
14494
- const j = seed % (i + 1);
14495
- [indices[i], indices[j]] = [indices[j], indices[i]];
14496
- }
14497
- for (let i = 0; i < tiles.length; i++) {
14498
- tiles[i].animationIndex = indices[i];
14887
+ tiles.push(
14888
+ buildTileMark({
14889
+ stateCode,
14890
+ pos,
14891
+ tileSize: tilePositions.tileSize,
14892
+ gridOffsetX: tileGridOffsetX,
14893
+ gridOffsetY: tileGridOffsetY,
14894
+ fill,
14895
+ fillOpacity: hasData ? opacity : 1,
14896
+ stroke,
14897
+ value: value2,
14898
+ formattedValue,
14899
+ hasData,
14900
+ theme
14901
+ })
14902
+ );
14499
14903
  }
14904
+ assignAnimationIndices2(tiles);
14500
14905
  let gradientLegend = null;
14501
14906
  if (showLegend) {
14502
14907
  const numStops = 5;
@@ -14521,19 +14926,7 @@ function compileTileMap(spec, options) {
14521
14926
  maxLabel: formatter(max4)
14522
14927
  };
14523
14928
  }
14524
- const tooltipDescriptors = /* @__PURE__ */ new Map();
14525
- for (const tile of tiles) {
14526
- const fields = [
14527
- {
14528
- label: "Value",
14529
- value: tile.formattedValue
14530
- }
14531
- ];
14532
- tooltipDescriptors.set(tile.stateCode, {
14533
- title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
14534
- fields
14535
- });
14536
- }
14929
+ const tooltipDescriptors = buildTooltips(tiles);
14537
14930
  const a11y = {
14538
14931
  altText: `Tile map of US states showing values from ${formatter(min4)} to ${formatter(max4)}`,
14539
14932
  dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
@@ -14541,12 +14934,144 @@ function compileTileMap(spec, options) {
14541
14934
  keyboardNavigable: tiles.length > 0
14542
14935
  };
14543
14936
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
14937
+ const padding = theme.spacing.padding;
14544
14938
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
14545
14939
  return {
14546
14940
  area: fullArea,
14547
14941
  chrome,
14548
14942
  tiles,
14549
14943
  gradientLegend,
14944
+ categoricalLegend: null,
14945
+ tooltipDescriptors,
14946
+ a11y,
14947
+ theme,
14948
+ width: options.width,
14949
+ height: contentHeight,
14950
+ animation: resolvedAnimation,
14951
+ watermark,
14952
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth20(text, fontSize), height: fontSize }))
14953
+ };
14954
+ }
14955
+ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark) {
14956
+ const stateField = tilemapSpec.encoding.state.field;
14957
+ const colorField = tilemapSpec.encoding.color?.field ?? tilemapSpec.encoding.value.field;
14958
+ const stateCategoryMap = /* @__PURE__ */ new Map();
14959
+ for (const row of tilemapSpec.data) {
14960
+ const stateCode = String(row[stateField]);
14961
+ const raw = row[colorField];
14962
+ if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== void 0) {
14963
+ stateCategoryMap.set(stateCode, String(raw));
14964
+ }
14965
+ }
14966
+ const categories = [];
14967
+ const seen = /* @__PURE__ */ new Set();
14968
+ for (const row of tilemapSpec.data) {
14969
+ const raw = row[colorField];
14970
+ if (raw !== null && raw !== void 0) {
14971
+ const cat = String(raw);
14972
+ if (!seen.has(cat)) {
14973
+ seen.add(cat);
14974
+ categories.push(cat);
14975
+ }
14976
+ }
14977
+ }
14978
+ const categoryColors = /* @__PURE__ */ new Map();
14979
+ if (tilemapSpec.colors) {
14980
+ for (const cat of categories) {
14981
+ categoryColors.set(
14982
+ cat,
14983
+ tilemapSpec.colors[cat] ?? CATEGORICAL_PALETTE[categoryColors.size % CATEGORICAL_PALETTE.length]
14984
+ );
14985
+ }
14986
+ } else {
14987
+ for (let i = 0; i < categories.length; i++) {
14988
+ categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
14989
+ }
14990
+ }
14991
+ const showLegend = tilemapSpec.legend?.show !== false;
14992
+ const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
14993
+ const legendTotalHeight = showLegend ? legendRowHeight : 0;
14994
+ const legendGap2 = showLegend ? 8 : 0;
14995
+ const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap2;
14996
+ const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
14997
+ const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
14998
+ const tileGridOffsetY = fullArea.y;
14999
+ const legendX = tileGridOffsetX;
15000
+ const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
15001
+ const legendWidth = tilePositions.gridWidth;
15002
+ const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
15003
+ const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
15004
+ const tiles = [];
15005
+ for (const { state: stateCode } of US_STATE_TILES) {
15006
+ const pos = tilePositions.positions.get(stateCode);
15007
+ if (!pos) continue;
15008
+ const hasData = stateCategoryMap.has(stateCode);
15009
+ const category = hasData ? stateCategoryMap.get(stateCode) : null;
15010
+ const fill = hasData ? categoryColors.get(category) ?? neutralFill : neutralFill;
15011
+ const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
15012
+ const formattedValue = category ? formatCategoryLabel(category) : "\u2013";
15013
+ tiles.push(
15014
+ buildTileMark({
15015
+ stateCode,
15016
+ pos,
15017
+ tileSize: tilePositions.tileSize,
15018
+ gridOffsetX: tileGridOffsetX,
15019
+ gridOffsetY: tileGridOffsetY,
15020
+ fill,
15021
+ fillOpacity: 1,
15022
+ stroke,
15023
+ value: null,
15024
+ formattedValue,
15025
+ hasData,
15026
+ theme,
15027
+ category: hasData ? category : void 0
15028
+ })
15029
+ );
15030
+ }
15031
+ assignAnimationIndices2(tiles);
15032
+ let categoricalLegend = null;
15033
+ if (showLegend) {
15034
+ const labelStyle = {
15035
+ fontFamily: theme.fonts.family,
15036
+ fontSize: 11,
15037
+ fontWeight: 400,
15038
+ fill: theme.colors.text,
15039
+ lineHeight: 1.2
15040
+ };
15041
+ const entries = categories.map((cat) => ({
15042
+ label: formatCategoryLabel(cat),
15043
+ color: categoryColors.get(cat),
15044
+ shape: "square"
15045
+ }));
15046
+ categoricalLegend = {
15047
+ type: "categorical",
15048
+ position: "bottom",
15049
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
15050
+ labelStyle,
15051
+ entries,
15052
+ swatchSize: LEGEND_SWATCH_SIZE,
15053
+ swatchGap: LEGEND_SWATCH_GAP,
15054
+ entryGap: LEGEND_ENTRY_GAP,
15055
+ swatchChipFill: "transparent"
15056
+ };
15057
+ }
15058
+ const tooltipDescriptors = buildTooltips(tiles, "Category");
15059
+ const categoryList = categories.map(formatCategoryLabel).join(", ");
15060
+ const a11y = {
15061
+ altText: `Tile map of US states showing categories: ${categoryList}`,
15062
+ dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
15063
+ role: "img",
15064
+ keyboardNavigable: tiles.length > 0
15065
+ };
15066
+ const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
15067
+ const padding = theme.spacing.padding;
15068
+ const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
15069
+ return {
15070
+ area: fullArea,
15071
+ chrome,
15072
+ tiles,
15073
+ gradientLegend: null,
15074
+ categoricalLegend,
14550
15075
  tooltipDescriptors,
14551
15076
  a11y,
14552
15077
  theme,
@@ -14557,6 +15082,109 @@ function compileTileMap(spec, options) {
14557
15082
  measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth20(text, fontSize), height: fontSize }))
14558
15083
  };
14559
15084
  }
15085
+ function buildTileMark(opts) {
15086
+ const {
15087
+ stateCode,
15088
+ pos,
15089
+ tileSize,
15090
+ gridOffsetX,
15091
+ gridOffsetY,
15092
+ fill,
15093
+ fillOpacity,
15094
+ stroke,
15095
+ value: value2,
15096
+ formattedValue,
15097
+ hasData,
15098
+ theme,
15099
+ category
15100
+ } = opts;
15101
+ const tileCenterX = gridOffsetX + pos.x + tileSize / 2;
15102
+ const tileTopY = gridOffsetY + pos.y;
15103
+ const labelStyle = {
15104
+ fontFamily: theme.fonts.family,
15105
+ fontSize: tileSize > 24 ? 10 : 7,
15106
+ fontWeight: 700,
15107
+ fill: "#ffffff",
15108
+ lineHeight: 1.2
15109
+ };
15110
+ const valueLabelStyle = {
15111
+ fontFamily: theme.fonts.family,
15112
+ fontSize: tileSize > 24 ? 10 : 7,
15113
+ fontWeight: 300,
15114
+ fill: "rgba(255,255,255,0.6)",
15115
+ lineHeight: 1.2
15116
+ };
15117
+ const valueLabel = tileSize < 24 ? { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false } : {
15118
+ text: formattedValue,
15119
+ x: tileCenterX,
15120
+ y: tileTopY + tileSize * 0.78,
15121
+ style: valueLabelStyle,
15122
+ visible: true
15123
+ };
15124
+ const data = {
15125
+ state: stateCode,
15126
+ value: value2,
15127
+ stateName: STATE_NAMES[stateCode] ?? stateCode
15128
+ };
15129
+ if (category !== void 0) {
15130
+ data.category = category;
15131
+ }
15132
+ return {
15133
+ type: "tile",
15134
+ stateCode,
15135
+ x: gridOffsetX + pos.x,
15136
+ y: tileTopY,
15137
+ size: tileSize,
15138
+ fill,
15139
+ fillOpacity,
15140
+ stroke,
15141
+ strokeWidth: TILE_STROKE_WIDTH,
15142
+ cornerRadius: TILE_CORNER_RADIUS,
15143
+ value: value2,
15144
+ formattedValue,
15145
+ hasData,
15146
+ label: {
15147
+ text: stateCode,
15148
+ x: tileCenterX,
15149
+ y: tileTopY + tileSize * 0.28,
15150
+ style: labelStyle,
15151
+ visible: true
15152
+ },
15153
+ valueLabel,
15154
+ data,
15155
+ aria: {
15156
+ role: "img",
15157
+ label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`
15158
+ },
15159
+ animationIndex: 0
15160
+ };
15161
+ }
15162
+ function assignAnimationIndices2(tiles) {
15163
+ const indices = Array.from({ length: tiles.length }, (_, i) => i);
15164
+ let seed = 42;
15165
+ for (let i = indices.length - 1; i > 0; i--) {
15166
+ seed = seed * 1103515245 + 12345 & 2147483647;
15167
+ const j = seed % (i + 1);
15168
+ [indices[i], indices[j]] = [indices[j], indices[i]];
15169
+ }
15170
+ for (let i = 0; i < tiles.length; i++) {
15171
+ tiles[i].animationIndex = indices[i];
15172
+ }
15173
+ }
15174
+ function buildTooltips(tiles, fieldLabel = "Value") {
15175
+ const tooltipDescriptors = /* @__PURE__ */ new Map();
15176
+ for (const tile of tiles) {
15177
+ const fields = [{ label: fieldLabel, value: tile.formattedValue }];
15178
+ tooltipDescriptors.set(tile.stateCode, {
15179
+ title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
15180
+ fields
15181
+ });
15182
+ }
15183
+ return tooltipDescriptors;
15184
+ }
15185
+ function formatCategoryLabel(category) {
15186
+ return category.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
15187
+ }
14560
15188
  function emptyLayout4(chrome, theme, options, watermark) {
14561
15189
  return {
14562
15190
  area: { x: 0, y: 0, width: 0, height: 0 },
@@ -14577,6 +15205,7 @@ function emptyLayout4(chrome, theme, options, watermark) {
14577
15205
  minLabel: "0",
14578
15206
  maxLabel: "0"
14579
15207
  },
15208
+ categoricalLegend: null,
14580
15209
  tooltipDescriptors: /* @__PURE__ */ new Map(),
14581
15210
  a11y: {
14582
15211
  altText: "Empty tile map",
@@ -15546,6 +16175,20 @@ function compileChart(spec, options) {
15546
16175
  }
15547
16176
  };
15548
16177
  }
16178
+ const facetChannel = renderSpec.encoding.facet;
16179
+ if (facetChannel?.field) {
16180
+ return compileFaceted(
16181
+ chartSpec,
16182
+ renderSpec,
16183
+ facetChannel,
16184
+ options,
16185
+ theme,
16186
+ strategy,
16187
+ watermark,
16188
+ resolvedAnimation,
16189
+ crosshair
16190
+ );
16191
+ }
15549
16192
  const measure = createMeasureFn(options.measureText);
15550
16193
  const plan = resolveLayoutPlan(
15551
16194
  chartSpec,
@@ -15564,7 +16207,7 @@ function compileChart(spec, options) {
15564
16207
  plan.xAxisExtent
15565
16208
  );
15566
16209
  const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
15567
- const chartArea = dims.chartArea;
16210
+ const chartArea = options.frozenChartArea ?? dims.chartArea;
15568
16211
  const finalLegend = placeLegend(
15569
16212
  plan.legendContent,
15570
16213
  chartArea,
@@ -15582,7 +16225,7 @@ function compileChart(spec, options) {
15582
16225
  };
15583
16226
  }
15584
16227
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
15585
- applyColorScaleRange(scales, renderSpec.encoding, theme);
16228
+ applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
15586
16229
  scales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
15587
16230
  const isRadial = chartSpec.markType === "arc";
15588
16231
  const skipX = chartSpec.display === "sparkline" && !chartSpec.userExplicit.xAxis;
@@ -15608,7 +16251,14 @@ function compileChart(spec, options) {
15608
16251
  );
15609
16252
  const renderer = getChartRenderer(rendererKey);
15610
16253
  const marks = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width) : [];
15611
- const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
16254
+ const endpointLabels = computeEndpointLabels(
16255
+ chartSpec,
16256
+ marks,
16257
+ theme,
16258
+ chartArea,
16259
+ strategy,
16260
+ plan.endpointLabelsDemoted
16261
+ );
15612
16262
  const obstacles = [];
15613
16263
  if (finalLegend.bounds.width > 0) {
15614
16264
  obstacles.push({ ...finalLegend.bounds, kind: "legend" });
@@ -15625,15 +16275,24 @@ function compileChart(spec, options) {
15625
16275
  obstacles.push({ ...endpointLabels.bounds, kind: "endpoint-label" });
15626
16276
  }
15627
16277
  const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
15628
- const annotations = computeAnnotations(chartSpec, {
16278
+ let annotations = computeAnnotations(chartSpec, {
15629
16279
  scales,
15630
16280
  chartArea,
15631
16281
  strategy,
15632
16282
  isDark: theme.isDark,
15633
16283
  obstacles,
15634
16284
  svg: { width: dims.total.width, height: dims.total.height },
15635
- measure: annotationMeasure
16285
+ measure: annotationMeasure,
16286
+ autoThin: chartSpec.autoThin
15636
16287
  });
16288
+ let footnotes;
16289
+ if (chartSpec.autoThin && annotations.length > 1) {
16290
+ const result = thinAnnotations(annotations, chartSpec.annotations, annotationMeasure);
16291
+ annotations = result.annotations;
16292
+ if (result.footnotes.length > 0) {
16293
+ footnotes = result.footnotes;
16294
+ }
16295
+ }
15637
16296
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
15638
16297
  const altText = generateAltText(
15639
16298
  {
@@ -15653,9 +16312,10 @@ function compileChart(spec, options) {
15653
16312
  chartSpec.data
15654
16313
  );
15655
16314
  assignAnimationIndices(marks, resolvedAnimation);
16315
+ const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
15656
16316
  return {
15657
16317
  area: chartArea,
15658
- chrome: dims.chrome,
16318
+ chrome,
15659
16319
  metrics: dims.metrics,
15660
16320
  axes: {
15661
16321
  x: axes.x,
@@ -15684,6 +16344,196 @@ function compileChart(spec, options) {
15684
16344
  measureText: options.measureText
15685
16345
  };
15686
16346
  }
16347
+ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, strategy, watermark, resolvedAnimation, crosshair) {
16348
+ const measure = createMeasureFn(options.measureText);
16349
+ const panelEncoding = { ...renderSpec.encoding };
16350
+ delete panelEncoding.facet;
16351
+ const panelSpec = { ...renderSpec, encoding: panelEncoding };
16352
+ const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
16353
+ const groups = groupByField(renderSpec.data, facetChannel.field);
16354
+ const facetValues = Array.from(groups.keys());
16355
+ if (facetChannel.sort !== null) {
16356
+ const dir = facetChannel.sort === "descending" ? -1 : 1;
16357
+ facetValues.sort((a, b) => dir * a.localeCompare(b));
16358
+ }
16359
+ const plan = resolveLayoutPlan(
16360
+ panelChartSpec,
16361
+ panelSpec,
16362
+ options,
16363
+ theme,
16364
+ strategy,
16365
+ watermark,
16366
+ measure
16367
+ );
16368
+ const legendStub = placeLegend(
16369
+ plan.legendContent,
16370
+ { x: 0, y: 0, width: options.width, height: options.height },
16371
+ options.width,
16372
+ theme,
16373
+ plan.xAxisExtent
16374
+ );
16375
+ const dims = computeDimensions(
16376
+ panelChartSpec,
16377
+ options,
16378
+ legendStub,
16379
+ theme,
16380
+ strategy,
16381
+ watermark,
16382
+ plan
16383
+ );
16384
+ const chartArea = dims.chartArea;
16385
+ const finalLegend = placeLegend(
16386
+ plan.legendContent,
16387
+ chartArea,
16388
+ options.width,
16389
+ theme,
16390
+ plan.xAxisExtent
16391
+ );
16392
+ const leftReservation = plan.leftGutter;
16393
+ const bottomReservation = plan.xAxisExtent;
16394
+ const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
16395
+ left: leftReservation,
16396
+ bottom: bottomReservation
16397
+ });
16398
+ const resolveConfig = chartSpec.resolve;
16399
+ const yResolve = resolveConfig?.scale?.y ?? "shared";
16400
+ const xResolve = resolveConfig?.scale?.x ?? "shared";
16401
+ const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
16402
+ const sharedXDomain = xResolve === "shared" && fullScales.x ? fullScales.x.scale.domain() : void 0;
16403
+ const sharedYDomain = yResolve === "shared" && fullScales.y ? fullScales.y.scale.domain() : void 0;
16404
+ const isRadial = chartSpec.markType === "arc";
16405
+ const rendererKey = resolveRendererKey(
16406
+ renderSpec.markType,
16407
+ panelEncoding,
16408
+ renderSpec.markDef
16409
+ );
16410
+ const renderer = getChartRenderer(rendererKey);
16411
+ const allMarks = [];
16412
+ const panelLayouts = [];
16413
+ for (const gridPanel of grid.panels) {
16414
+ const panelData = groups.get(gridPanel.key) ?? [];
16415
+ const panelSpecLocal = { ...panelSpec, data: panelData };
16416
+ let panelEnc = { ...panelEncoding };
16417
+ if (sharedXDomain && panelEnc.x) {
16418
+ const xCh = panelEnc.x;
16419
+ panelEnc = {
16420
+ ...panelEnc,
16421
+ x: { ...xCh, scale: { ...xCh.scale ?? {}, domain: sharedXDomain } }
16422
+ };
16423
+ }
16424
+ if (sharedYDomain && panelEnc.y) {
16425
+ const yCh = panelEnc.y;
16426
+ panelEnc = {
16427
+ ...panelEnc,
16428
+ y: { ...yCh, scale: { ...yCh.scale ?? {}, domain: sharedYDomain } }
16429
+ };
16430
+ }
16431
+ const panelSpecWithDomains = {
16432
+ ...panelSpecLocal,
16433
+ encoding: panelEnc
16434
+ };
16435
+ const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
16436
+ applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme);
16437
+ panelScales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
16438
+ const isLeftCol = gridPanel.col === 0;
16439
+ const isBottomRow = gridPanel.row === grid.rows - 1;
16440
+ const panelAxes = isRadial ? { x: void 0, y: void 0 } : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
16441
+ data: panelData,
16442
+ encoding: panelSpecWithDomains.encoding,
16443
+ skipX: xResolve === "shared" ? !isBottomRow : false,
16444
+ skipY: yResolve === "shared" ? !isLeftCol : false,
16445
+ markType: chartSpec.markType,
16446
+ totalWidth: gridPanel.area.width
16447
+ });
16448
+ if (!isRadial) {
16449
+ computeGridlines(panelAxes, gridPanel.area);
16450
+ }
16451
+ const panelMarks = renderer ? renderer(
16452
+ panelSpecWithDomains,
16453
+ panelScales,
16454
+ gridPanel.area,
16455
+ strategy,
16456
+ theme,
16457
+ gridPanel.area.width
16458
+ ) : [];
16459
+ const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
16460
+ const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
16461
+ scales: panelScales,
16462
+ chartArea: gridPanel.area,
16463
+ strategy,
16464
+ isDark: theme.isDark,
16465
+ obstacles: [],
16466
+ svg: { width: options.width, height: options.height },
16467
+ measure: annotationMeasure,
16468
+ autoThin: panelSpecWithDomains.autoThin
16469
+ });
16470
+ allMarks.push(...panelMarks);
16471
+ panelLayouts.push({
16472
+ key: gridPanel.key,
16473
+ area: gridPanel.area,
16474
+ marks: panelMarks,
16475
+ axes: { x: panelAxes.x, y: panelAxes.y },
16476
+ annotations: panelAnnotations,
16477
+ header: {
16478
+ text: gridPanel.key,
16479
+ x: gridPanel.headerPos.x,
16480
+ y: gridPanel.headerPos.y,
16481
+ fontSize: theme.fonts.sizes.body,
16482
+ fontWeight: theme.fonts.weights.medium
16483
+ }
16484
+ });
16485
+ }
16486
+ const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
16487
+ const panelCount = facetValues.length;
16488
+ const baseAltText = generateAltText(
16489
+ {
16490
+ mark: chartSpec.markType,
16491
+ data: chartSpec.data,
16492
+ encoding: chartSpec.encoding,
16493
+ chrome: chartSpec.chrome
16494
+ },
16495
+ chartSpec.data
16496
+ );
16497
+ const altText = `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
16498
+ const dataTableFallback = generateDataTable(
16499
+ {
16500
+ mark: chartSpec.markType,
16501
+ data: chartSpec.data,
16502
+ encoding: chartSpec.encoding
16503
+ },
16504
+ chartSpec.data
16505
+ );
16506
+ assignAnimationIndices(allMarks, resolvedAnimation);
16507
+ return {
16508
+ area: chartArea,
16509
+ chrome: dims.chrome,
16510
+ metrics: dims.metrics,
16511
+ axes: { x: void 0, y: void 0 },
16512
+ marks: allMarks,
16513
+ annotations: [],
16514
+ legend: finalLegend,
16515
+ tooltipDescriptors,
16516
+ a11y: {
16517
+ altText,
16518
+ dataTableFallback,
16519
+ role: "img",
16520
+ keyboardNavigable: allMarks.length > 0
16521
+ },
16522
+ theme,
16523
+ dimensions: { width: options.width, height: options.height },
16524
+ animation: resolvedAnimation,
16525
+ watermark,
16526
+ display: chartSpec.display,
16527
+ crosshair,
16528
+ measureText: options.measureText,
16529
+ facet: {
16530
+ panels: panelLayouts,
16531
+ facetField: facetChannel.field,
16532
+ columns: grid.columns,
16533
+ sharedScales: yResolve === "shared" && xResolve === "shared"
16534
+ }
16535
+ };
16536
+ }
15687
16537
  function compileLayer2(spec, options) {
15688
16538
  return compileLayer(spec, options, compileChart);
15689
16539
  }