@opendata-ai/openchart-engine 7.6.1 → 7.8.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 (42) hide show
  1. package/dist/index.d.ts +13 -0
  2. package/dist/index.js +1037 -187
  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__/facet.test.ts +215 -0
  9. package/src/annotations/__tests__/compute.test.ts +18 -0
  10. package/src/annotations/__tests__/thinning.test.ts +137 -0
  11. package/src/annotations/compute.ts +3 -1
  12. package/src/annotations/thinning.ts +116 -0
  13. package/src/barlist/compile-barlist.ts +1 -0
  14. package/src/charts/bar/labels.ts +1 -0
  15. package/src/charts/column/labels.ts +1 -0
  16. package/src/charts/dot/labels.ts +1 -0
  17. package/src/charts/line/area.ts +16 -2
  18. package/src/charts/line/compute.ts +17 -10
  19. package/src/charts/pie/labels.ts +1 -0
  20. package/src/compile/__tests__/color-scale-range.test.ts +80 -1
  21. package/src/compile/color-scale-range.ts +44 -2
  22. package/src/compile.ts +314 -5
  23. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  24. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  25. package/src/compiler/normalize.ts +67 -2
  26. package/src/compiler/types.ts +10 -0
  27. package/src/compiler/validate.ts +79 -0
  28. package/src/endpoint-labels/__tests__/compute.test.ts +57 -0
  29. package/src/endpoint-labels/compute.ts +179 -5
  30. package/src/endpoint-labels/predict.ts +17 -0
  31. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  32. package/src/layout/axes.ts +8 -2
  33. package/src/layout/dimensions.ts +6 -1
  34. package/src/layout/facet.ts +80 -0
  35. package/src/layout/plan.ts +16 -0
  36. package/src/legend/__tests__/redundancy.test.ts +108 -0
  37. package/src/legend/__tests__/suppression.test.ts +72 -0
  38. package/src/legend/compute.ts +18 -0
  39. package/src/legend/suppression.ts +27 -6
  40. package/src/tilemap/__tests__/compile-tilemap.test.ts +210 -0
  41. package/src/tilemap/compile-tilemap.ts +379 -117
  42. 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
  }
@@ -11009,7 +11440,7 @@ function estimateYAxisLabelWidth(data, encoding, baseFontSize, measure) {
11009
11440
  const yEnc = encoding.y;
11010
11441
  const yField = yEnc.field;
11011
11442
  if (!yField) return 40;
11012
- const m = measure ?? estimateTextWidth12;
11443
+ const m = measure ?? estimateTextWidth13;
11013
11444
  const yType = yEnc.type;
11014
11445
  if (yType === "nominal" || yType === "ordinal") {
11015
11446
  let maxWidth = 0;
@@ -11808,17 +12239,17 @@ var DEFAULT_COLLISION_PADDING = 5;
11808
12239
  import {
11809
12240
  axisTitleOffset as axisTitleOffset2,
11810
12241
  computeXAxisExtentFromLabels as computeXAxisExtentFromLabels2,
11811
- estimateTextWidth as estimateTextWidth14,
12242
+ estimateTextWidth as estimateTextWidth15,
11812
12243
  getAxisTitleOffset as getAxisTitleOffset2,
11813
12244
  X_AXIS_TITLE_BAND_ROTATED
11814
12245
  } from "@opendata-ai/openchart-core";
11815
12246
 
11816
12247
  // src/layout/axes/thinning.ts
11817
- import { estimateTextWidth as estimateTextWidth13 } from "@opendata-ai/openchart-core";
12248
+ import { estimateTextWidth as estimateTextWidth14 } from "@opendata-ai/openchart-core";
11818
12249
  var MIN_TICK_GAP_FACTOR = 0.5;
11819
12250
  var MIN_TICK_COUNT = 2;
11820
12251
  function measureLabel(text, fontSize, fontWeight, measureText) {
11821
- return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth13(text, fontSize, fontWeight);
12252
+ return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth14(text, fontSize, fontWeight);
11822
12253
  }
11823
12254
  function ticksOverlap(ticks2, fontSize, fontWeight, measureText, orientation = "horizontal") {
11824
12255
  if (ticks2.length < 2) return false;
@@ -11863,6 +12294,7 @@ var HEIGHT_REDUCED_THRESHOLD = 100;
11863
12294
  var WIDTH_MINIMAL_THRESHOLD = 150;
11864
12295
  var WIDTH_REDUCED_THRESHOLD = 300;
11865
12296
  var DENSITY_ORDER = ["full", "reduced", "minimal"];
12297
+ var GROUNDED_MARKS = /* @__PURE__ */ new Set(["bar", "tick", "lollipop"]);
11866
12298
  function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
11867
12299
  let density = baseDensity;
11868
12300
  if (axisLength < minimalThreshold) {
@@ -12027,9 +12459,10 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12027
12459
  const axisTitle = axisConfig?.title;
12028
12460
  const xLabelColor = axisConfig?.labelColor;
12029
12461
  const xTickPosition = axisConfig?.tickPosition ?? "gutter";
12462
+ const xDomainLine = axisConfig?.domain ?? (GROUNDED_MARKS.has(dataContext?.markType ?? "") ? void 0 : false);
12030
12463
  result.x = {
12031
12464
  ticks: ticks2,
12032
- gridlines: axisConfig?.grid ? gridlines : [],
12465
+ gridlines: axisConfig?.grid ?? dataContext?.markType === "point" ? gridlines : [],
12033
12466
  label: axisTitle,
12034
12467
  labelStyle: xLabelColor ? { ...axisLabelStyle, fill: xLabelColor } : axisLabelStyle,
12035
12468
  tickLabelStyle: xLabelColor ? { ...tickLabelStyle, fill: xLabelColor } : tickLabelStyle,
@@ -12037,7 +12470,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12037
12470
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
12038
12471
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
12039
12472
  orient: axisConfig?.orient,
12040
- domainLine: axisConfig?.domain,
12473
+ domainLine: xDomainLine,
12041
12474
  tickMarks: axisConfig?.ticks,
12042
12475
  offset: axisConfig?.offset,
12043
12476
  titlePadding: axisConfig?.titlePadding,
@@ -12150,7 +12583,7 @@ function computeAxes(scales, chartArea, strategy, theme, measureText, dataContex
12150
12583
  };
12151
12584
  } else {
12152
12585
  const maxTickLabelWidth = result.y.ticks.reduce((max4, t) => {
12153
- const w = estimateTextWidth14(
12586
+ const w = estimateTextWidth15(
12154
12587
  t.label,
12155
12588
  result.y.tickLabelStyle.fontSize,
12156
12589
  result.y.tickLabelStyle.fontWeight ?? 400
@@ -12187,55 +12620,6 @@ import {
12187
12620
  TOP_PAD_EXTRA_NARROW as TOP_PAD_EXTRA_NARROW2
12188
12621
  } from "@opendata-ai/openchart-core";
12189
12622
 
12190
- // src/endpoint-labels/predict.ts
12191
- import { estimateTextWidth as estimateTextWidth15, wrapText as wrapText2 } from "@opendata-ai/openchart-core";
12192
- function predictEndpointLabelsWidth(spec, _theme) {
12193
- if (spec.endpointLabels === false) return 0;
12194
- if (spec.markType !== "line" && spec.markType !== "area") return 0;
12195
- const colorEnc = spec.encoding.color;
12196
- if (!colorEnc) return 0;
12197
- if ("condition" in colorEnc) return 0;
12198
- if (colorEnc.type === "quantitative") return 0;
12199
- const colorField = "field" in colorEnc ? colorEnc.field : void 0;
12200
- if (!colorField) return 0;
12201
- const seriesNames = /* @__PURE__ */ new Set();
12202
- for (const row of spec.data) {
12203
- seriesNames.add(String(row[colorField]));
12204
- }
12205
- if (seriesNames.size < 2) return 0;
12206
- if (typeof spec.endpointLabels === "object" && spec.endpointLabels?.show === false) return 0;
12207
- const config = typeof spec.endpointLabels === "object" ? spec.endpointLabels : void 0;
12208
- const wrapWidth = config?.width ?? ENDPOINT_WRAP_WIDTH_DEFAULT;
12209
- let maxLabelWidth = 0;
12210
- for (const name of seriesNames) {
12211
- const lines = wrapText2(name, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT, wrapWidth);
12212
- for (const line of lines) {
12213
- const w = estimateTextWidth15(line, ENDPOINT_LABEL_FONT_SIZE, ENDPOINT_LABEL_FONT_WEIGHT);
12214
- if (w > maxLabelWidth) maxLabelWidth = w;
12215
- }
12216
- }
12217
- const yField = config?.valueField ?? spec.encoding.y?.field;
12218
- const yFormat = config?.format ?? spec.encoding.y?.axis?.format;
12219
- let valueWidth = 0;
12220
- if (yField) {
12221
- let maxAbs = 0;
12222
- for (const row of spec.data) {
12223
- const v = Number(row[yField]);
12224
- if (Number.isFinite(v) && Math.abs(v) > maxAbs) maxAbs = Math.abs(v);
12225
- }
12226
- let sample;
12227
- if (yFormat) {
12228
- sample = formatEndpointValue(maxAbs, yFormat);
12229
- } else if (maxAbs >= 1e9) sample = "1.5B";
12230
- else if (maxAbs >= 1e6) sample = "1.5M";
12231
- else if (maxAbs >= 1e3) sample = "1.5K";
12232
- else sample = String(Math.round(maxAbs * 100) / 100);
12233
- valueWidth = estimateTextWidth15(sample, ENDPOINT_VALUE_FONT_SIZE, ENDPOINT_VALUE_FONT_WEIGHT);
12234
- }
12235
- const textColumn = Math.max(maxLabelWidth, valueWidth);
12236
- return ENDPOINT_SWATCH_SIZE + ENDPOINT_GAP + textColumn + 4;
12237
- }
12238
-
12239
12623
  // src/layout/metrics.ts
12240
12624
  import { estimateTextWidth as estimateTextWidth16 } from "@opendata-ai/openchart-core";
12241
12625
  var DEFAULT_LABEL_FONT_SIZE = 10;
@@ -12419,13 +12803,17 @@ function computeDimensions(spec, options, legendLayout, theme, strategy, waterma
12419
12803
  const sup = resolveSuppression(spec, {
12420
12804
  seriesCount,
12421
12805
  labelsHiddenByStrategy,
12422
- labelsDensityNone: labelDensity === "none"
12806
+ labelsDensityNone: labelDensity === "none",
12807
+ endpointLabelsDemoted: plan?.endpointLabelsDemoted
12423
12808
  });
12424
12809
  let endpointWidth = 0;
12425
12810
  if (sup.showEndpointLabels && !labelsHiddenByStrategy) {
12426
12811
  endpointWidth = predictEndpointLabelsWidth(spec, theme);
12427
12812
  if (endpointWidth > 0) {
12428
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
+ }
12429
12817
  }
12430
12818
  }
12431
12819
  if (endpointWidth === 0 && sup.showEndOfLineLabels && (spec.markType === "line" || spec.markType === "area") && labelDensity !== "none" && !labelsHiddenByStrategy) {
@@ -12701,6 +13089,57 @@ function resolveMetrics(spec, metricsTopY, metricsArea, remainingChartHeight, me
12701
13089
  );
12702
13090
  }
12703
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
+
12704
13143
  // src/layout/gridlines.ts
12705
13144
  function computeGridlines(axes, chartArea, showVertical = false) {
12706
13145
  const horizontal = [];
@@ -14339,14 +14778,19 @@ function compileTableLayout(spec, options, theme) {
14339
14778
  import {
14340
14779
  adaptTheme as adaptTheme4,
14341
14780
  buildD3Formatter as buildD3Formatter8,
14781
+ CATEGORICAL_PALETTE,
14342
14782
  computeChrome as computeChrome7,
14343
14783
  estimateTextWidth as estimateTextWidth20,
14344
14784
  formatNumber as formatNumber8,
14785
+ pickLabelColor as pickLabelColor2,
14345
14786
  resolveTheme as resolveTheme5,
14346
14787
  SEQUENTIAL_PALETTES
14347
14788
  } from "@opendata-ai/openchart-core";
14348
14789
  var TILE_CORNER_RADIUS = 6;
14349
14790
  var TILE_STROKE_WIDTH = 1;
14791
+ var LEGEND_SWATCH_SIZE = 10;
14792
+ var LEGEND_SWATCH_GAP = 6;
14793
+ var LEGEND_ENTRY_GAP = 16;
14350
14794
  function compileTileMap(spec, options) {
14351
14795
  const { spec: normalized } = compile(spec);
14352
14796
  if (!("type" in normalized) || normalized.type !== "tilemap") {
@@ -14389,6 +14833,13 @@ function compileTileMap(spec, options) {
14389
14833
  if (fullArea.width <= 0 || fullArea.height <= 0) {
14390
14834
  return emptyLayout4(chrome, theme, options, watermark);
14391
14835
  }
14836
+ const isCategorical = tilemapSpec.encoding.color !== void 0 || tilemapSpec.colors !== void 0;
14837
+ if (isCategorical) {
14838
+ return compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
14839
+ }
14840
+ return compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark);
14841
+ }
14842
+ function compileQuantitative(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark) {
14392
14843
  const stateField = tilemapSpec.encoding.state.field;
14393
14844
  const valueField = tilemapSpec.encoding.value.field;
14394
14845
  const stateValueMap = /* @__PURE__ */ new Map();
@@ -14422,12 +14873,8 @@ function compileTileMap(spec, options) {
14422
14873
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
14423
14874
  const legendWidth = tilePositions.gridWidth;
14424
14875
  const formatter = buildD3Formatter8(tilemapSpec.valueFormat) ?? formatNumber8;
14425
- const neutralFillLight = "#e0e0e0";
14426
- const neutralFillDark = "#1e2a30";
14427
- const neutralStrokeLight = "#d0d0d0";
14428
- const neutralStrokeDark = "rgba(255,255,255,0.08)";
14429
- const neutralFill = isDarkMode ? neutralFillDark : neutralFillLight;
14430
- const neutralStroke = isDarkMode ? neutralStrokeDark : neutralStrokeLight;
14876
+ const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
14877
+ const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
14431
14878
  const tiles = [];
14432
14879
  for (const { state: stateCode } of US_STATE_TILES) {
14433
14880
  const pos = tilePositions.positions.get(stateCode);
@@ -14438,71 +14885,25 @@ function compileTileMap(spec, options) {
14438
14885
  const fill = hasData ? baseColor : neutralFill;
14439
14886
  const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
14440
14887
  const formattedValue = hasData ? formatter(value2) : "\u2013";
14441
- const labelStyle = {
14442
- fontFamily: theme.fonts.family,
14443
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
14444
- fontWeight: 700,
14445
- fill: "#ffffff",
14446
- lineHeight: 1.2
14447
- };
14448
- const valueLabelStyle = {
14449
- fontFamily: theme.fonts.family,
14450
- fontSize: tilePositions.tileSize > 24 ? 10 : 7,
14451
- fontWeight: 300,
14452
- fill: "rgba(255,255,255,0.6)",
14453
- lineHeight: 1.2
14454
- };
14455
- const tileCenterX = tileGridOffsetX + pos.x + tilePositions.tileSize / 2;
14456
- const tileTopY = tileGridOffsetY + pos.y;
14457
- const sz = tilePositions.tileSize;
14458
- const valueLabel = sz < 24 ? { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false } : {
14459
- text: formattedValue,
14460
- x: tileCenterX,
14461
- y: tileTopY + sz * 0.78,
14462
- style: valueLabelStyle,
14463
- visible: true
14464
- };
14465
- const tile = {
14466
- type: "tile",
14467
- stateCode,
14468
- x: tileGridOffsetX + pos.x,
14469
- y: tileTopY,
14470
- size: sz,
14471
- fill,
14472
- fillOpacity: hasData ? opacity : 1,
14473
- stroke,
14474
- strokeWidth: TILE_STROKE_WIDTH,
14475
- cornerRadius: TILE_CORNER_RADIUS,
14476
- value: value2 ?? null,
14477
- formattedValue,
14478
- hasData,
14479
- label: {
14480
- text: stateCode,
14481
- x: tileCenterX,
14482
- y: tileTopY + sz * 0.28,
14483
- style: labelStyle,
14484
- visible: true
14485
- },
14486
- valueLabel,
14487
- data: { state: stateCode, value: value2, stateName: STATE_NAMES[stateCode] ?? stateCode },
14488
- aria: {
14489
- role: "img",
14490
- label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`
14491
- },
14492
- animationIndex: 0
14493
- };
14494
- tiles.push(tile);
14495
- }
14496
- const indices = Array.from({ length: tiles.length }, (_, i) => i);
14497
- let seed = 42;
14498
- for (let i = indices.length - 1; i > 0; i--) {
14499
- seed = seed * 1103515245 + 12345 & 2147483647;
14500
- const j = seed % (i + 1);
14501
- [indices[i], indices[j]] = [indices[j], indices[i]];
14502
- }
14503
- for (let i = 0; i < tiles.length; i++) {
14504
- tiles[i].animationIndex = indices[i];
14888
+ tiles.push(
14889
+ buildTileMark({
14890
+ stateCode,
14891
+ pos,
14892
+ tileSize: tilePositions.tileSize,
14893
+ gridOffsetX: tileGridOffsetX,
14894
+ gridOffsetY: tileGridOffsetY,
14895
+ fill,
14896
+ fillOpacity: hasData ? opacity : 1,
14897
+ stroke,
14898
+ value: value2,
14899
+ formattedValue,
14900
+ hasData,
14901
+ theme,
14902
+ isDarkMode: !!isDarkMode
14903
+ })
14904
+ );
14505
14905
  }
14906
+ assignAnimationIndices2(tiles);
14506
14907
  let gradientLegend = null;
14507
14908
  if (showLegend) {
14508
14909
  const numStops = 5;
@@ -14527,19 +14928,7 @@ function compileTileMap(spec, options) {
14527
14928
  maxLabel: formatter(max4)
14528
14929
  };
14529
14930
  }
14530
- const tooltipDescriptors = /* @__PURE__ */ new Map();
14531
- for (const tile of tiles) {
14532
- const fields = [
14533
- {
14534
- label: "Value",
14535
- value: tile.formattedValue
14536
- }
14537
- ];
14538
- tooltipDescriptors.set(tile.stateCode, {
14539
- title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
14540
- fields
14541
- });
14542
- }
14931
+ const tooltipDescriptors = buildTooltips(tiles);
14543
14932
  const a11y = {
14544
14933
  altText: `Tile map of US states showing values from ${formatter(min4)} to ${formatter(max4)}`,
14545
14934
  dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
@@ -14547,12 +14936,145 @@ function compileTileMap(spec, options) {
14547
14936
  keyboardNavigable: tiles.length > 0
14548
14937
  };
14549
14938
  const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
14939
+ const padding = theme.spacing.padding;
14550
14940
  const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
14551
14941
  return {
14552
14942
  area: fullArea,
14553
14943
  chrome,
14554
14944
  tiles,
14555
14945
  gradientLegend,
14946
+ categoricalLegend: null,
14947
+ tooltipDescriptors,
14948
+ a11y,
14949
+ theme,
14950
+ width: options.width,
14951
+ height: contentHeight,
14952
+ animation: resolvedAnimation,
14953
+ watermark,
14954
+ measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth20(text, fontSize), height: fontSize }))
14955
+ };
14956
+ }
14957
+ function compileCategorical(tilemapSpec, options, theme, chrome, fullArea, isDarkMode, watermark) {
14958
+ const stateField = tilemapSpec.encoding.state.field;
14959
+ const colorField = tilemapSpec.encoding.color?.field ?? tilemapSpec.encoding.value.field;
14960
+ const stateCategoryMap = /* @__PURE__ */ new Map();
14961
+ for (const row of tilemapSpec.data) {
14962
+ const stateCode = String(row[stateField]);
14963
+ const raw = row[colorField];
14964
+ if (STATE_CODE_SET.has(stateCode) && raw !== null && raw !== void 0) {
14965
+ stateCategoryMap.set(stateCode, String(raw));
14966
+ }
14967
+ }
14968
+ const categories = [];
14969
+ const seen = /* @__PURE__ */ new Set();
14970
+ for (const row of tilemapSpec.data) {
14971
+ const raw = row[colorField];
14972
+ if (raw !== null && raw !== void 0) {
14973
+ const cat = String(raw);
14974
+ if (!seen.has(cat)) {
14975
+ seen.add(cat);
14976
+ categories.push(cat);
14977
+ }
14978
+ }
14979
+ }
14980
+ const categoryColors = /* @__PURE__ */ new Map();
14981
+ if (tilemapSpec.colors) {
14982
+ for (const cat of categories) {
14983
+ categoryColors.set(
14984
+ cat,
14985
+ tilemapSpec.colors[cat] ?? CATEGORICAL_PALETTE[categoryColors.size % CATEGORICAL_PALETTE.length]
14986
+ );
14987
+ }
14988
+ } else {
14989
+ for (let i = 0; i < categories.length; i++) {
14990
+ categoryColors.set(categories[i], CATEGORICAL_PALETTE[i % CATEGORICAL_PALETTE.length]);
14991
+ }
14992
+ }
14993
+ const showLegend = tilemapSpec.legend?.show !== false;
14994
+ const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
14995
+ const legendTotalHeight = showLegend ? legendRowHeight : 0;
14996
+ const legendGap2 = showLegend ? 8 : 0;
14997
+ const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap2;
14998
+ const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
14999
+ const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
15000
+ const tileGridOffsetY = fullArea.y;
15001
+ const legendX = tileGridOffsetX;
15002
+ const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap2;
15003
+ const legendWidth = tilePositions.gridWidth;
15004
+ const neutralFill = isDarkMode ? "#1e2a30" : "#e0e0e0";
15005
+ const neutralStroke = isDarkMode ? "rgba(255,255,255,0.08)" : "#d0d0d0";
15006
+ const tiles = [];
15007
+ for (const { state: stateCode } of US_STATE_TILES) {
15008
+ const pos = tilePositions.positions.get(stateCode);
15009
+ if (!pos) continue;
15010
+ const hasData = stateCategoryMap.has(stateCode);
15011
+ const category = hasData ? stateCategoryMap.get(stateCode) : null;
15012
+ const fill = hasData ? categoryColors.get(category) ?? neutralFill : neutralFill;
15013
+ const stroke = hasData ? isDarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)" : neutralStroke;
15014
+ const formattedValue = category ? formatCategoryLabel(category) : "\u2013";
15015
+ tiles.push(
15016
+ buildTileMark({
15017
+ stateCode,
15018
+ pos,
15019
+ tileSize: tilePositions.tileSize,
15020
+ gridOffsetX: tileGridOffsetX,
15021
+ gridOffsetY: tileGridOffsetY,
15022
+ fill,
15023
+ fillOpacity: 1,
15024
+ stroke,
15025
+ value: null,
15026
+ formattedValue,
15027
+ hasData,
15028
+ theme,
15029
+ isDarkMode: !!isDarkMode,
15030
+ category: hasData ? category : void 0
15031
+ })
15032
+ );
15033
+ }
15034
+ assignAnimationIndices2(tiles);
15035
+ let categoricalLegend = null;
15036
+ if (showLegend) {
15037
+ const labelStyle = {
15038
+ fontFamily: theme.fonts.family,
15039
+ fontSize: 11,
15040
+ fontWeight: 400,
15041
+ fill: theme.colors.text,
15042
+ lineHeight: 1.2
15043
+ };
15044
+ const entries = categories.map((cat) => ({
15045
+ label: formatCategoryLabel(cat),
15046
+ color: categoryColors.get(cat),
15047
+ shape: "square"
15048
+ }));
15049
+ categoricalLegend = {
15050
+ type: "categorical",
15051
+ position: "bottom",
15052
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
15053
+ labelStyle,
15054
+ entries,
15055
+ swatchSize: LEGEND_SWATCH_SIZE,
15056
+ swatchGap: LEGEND_SWATCH_GAP,
15057
+ entryGap: LEGEND_ENTRY_GAP,
15058
+ swatchChipFill: "transparent"
15059
+ };
15060
+ }
15061
+ const tooltipDescriptors = buildTooltips(tiles, "Category");
15062
+ const categoryList = categories.map(formatCategoryLabel).join(", ");
15063
+ const a11y = {
15064
+ altText: `Tile map of US states showing categories: ${categoryList}`,
15065
+ dataTableFallback: tiles.map((t) => [t.stateCode, t.formattedValue]),
15066
+ role: "img",
15067
+ keyboardNavigable: tiles.length > 0
15068
+ };
15069
+ const resolvedAnimation = resolveAnimation(tilemapSpec.animation);
15070
+ const padding = theme.spacing.padding;
15071
+ const contentHeight = tileGridOffsetY + tilePositions.gridHeight + legendGap2 + legendTotalHeight + chrome.bottomHeight + padding;
15072
+ return {
15073
+ area: fullArea,
15074
+ chrome,
15075
+ tiles,
15076
+ gradientLegend: null,
15077
+ categoricalLegend,
14556
15078
  tooltipDescriptors,
14557
15079
  a11y,
14558
15080
  theme,
@@ -14563,6 +15085,112 @@ function compileTileMap(spec, options) {
14563
15085
  measureText: options.measureText ?? ((text, fontSize) => ({ width: estimateTextWidth20(text, fontSize), height: fontSize }))
14564
15086
  };
14565
15087
  }
15088
+ function buildTileMark(opts) {
15089
+ const {
15090
+ stateCode,
15091
+ pos,
15092
+ tileSize,
15093
+ gridOffsetX,
15094
+ gridOffsetY,
15095
+ fill,
15096
+ fillOpacity,
15097
+ stroke,
15098
+ value: value2,
15099
+ formattedValue,
15100
+ hasData,
15101
+ theme,
15102
+ isDarkMode,
15103
+ category
15104
+ } = opts;
15105
+ const tileCenterX = gridOffsetX + pos.x + tileSize / 2;
15106
+ const tileTopY = gridOffsetY + pos.y;
15107
+ const textColor = hasData ? pickLabelColor2(fill, isDarkMode) : "#ffffff";
15108
+ const isLightText = textColor === "#ffffff";
15109
+ const labelStyle = {
15110
+ fontFamily: theme.fonts.family,
15111
+ fontSize: tileSize > 24 ? 10 : 7,
15112
+ fontWeight: 700,
15113
+ fill: textColor,
15114
+ lineHeight: 1.2
15115
+ };
15116
+ const valueLabelStyle = {
15117
+ fontFamily: theme.fonts.family,
15118
+ fontSize: tileSize > 24 ? 10 : 7,
15119
+ fontWeight: 300,
15120
+ fill: isLightText ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)",
15121
+ lineHeight: 1.2
15122
+ };
15123
+ const valueLabel = tileSize < 24 ? { text: "", x: 0, y: 0, style: valueLabelStyle, visible: false } : {
15124
+ text: formattedValue,
15125
+ x: tileCenterX,
15126
+ y: tileTopY + tileSize * 0.78,
15127
+ style: valueLabelStyle,
15128
+ visible: true
15129
+ };
15130
+ const data = {
15131
+ state: stateCode,
15132
+ value: value2,
15133
+ stateName: STATE_NAMES[stateCode] ?? stateCode
15134
+ };
15135
+ if (category !== void 0) {
15136
+ data.category = category;
15137
+ }
15138
+ return {
15139
+ type: "tile",
15140
+ stateCode,
15141
+ x: gridOffsetX + pos.x,
15142
+ y: tileTopY,
15143
+ size: tileSize,
15144
+ fill,
15145
+ fillOpacity,
15146
+ stroke,
15147
+ strokeWidth: TILE_STROKE_WIDTH,
15148
+ cornerRadius: TILE_CORNER_RADIUS,
15149
+ value: value2,
15150
+ formattedValue,
15151
+ hasData,
15152
+ label: {
15153
+ text: stateCode,
15154
+ x: tileCenterX,
15155
+ y: tileTopY + tileSize * 0.28,
15156
+ style: labelStyle,
15157
+ visible: true
15158
+ },
15159
+ valueLabel,
15160
+ data,
15161
+ aria: {
15162
+ role: "img",
15163
+ label: `${STATE_NAMES[stateCode] ?? stateCode}: ${formattedValue}`
15164
+ },
15165
+ animationIndex: 0
15166
+ };
15167
+ }
15168
+ function assignAnimationIndices2(tiles) {
15169
+ const indices = Array.from({ length: tiles.length }, (_, i) => i);
15170
+ let seed = 42;
15171
+ for (let i = indices.length - 1; i > 0; i--) {
15172
+ seed = seed * 1103515245 + 12345 & 2147483647;
15173
+ const j = seed % (i + 1);
15174
+ [indices[i], indices[j]] = [indices[j], indices[i]];
15175
+ }
15176
+ for (let i = 0; i < tiles.length; i++) {
15177
+ tiles[i].animationIndex = indices[i];
15178
+ }
15179
+ }
15180
+ function buildTooltips(tiles, fieldLabel = "Value") {
15181
+ const tooltipDescriptors = /* @__PURE__ */ new Map();
15182
+ for (const tile of tiles) {
15183
+ const fields = [{ label: fieldLabel, value: tile.formattedValue }];
15184
+ tooltipDescriptors.set(tile.stateCode, {
15185
+ title: STATE_NAMES[tile.stateCode] ?? tile.stateCode,
15186
+ fields
15187
+ });
15188
+ }
15189
+ return tooltipDescriptors;
15190
+ }
15191
+ function formatCategoryLabel(category) {
15192
+ return category.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
15193
+ }
14566
15194
  function emptyLayout4(chrome, theme, options, watermark) {
14567
15195
  return {
14568
15196
  area: { x: 0, y: 0, width: 0, height: 0 },
@@ -14583,6 +15211,7 @@ function emptyLayout4(chrome, theme, options, watermark) {
14583
15211
  minLabel: "0",
14584
15212
  maxLabel: "0"
14585
15213
  },
15214
+ categoricalLegend: null,
14586
15215
  tooltipDescriptors: /* @__PURE__ */ new Map(),
14587
15216
  a11y: {
14588
15217
  altText: "Empty tile map",
@@ -15552,6 +16181,20 @@ function compileChart(spec, options) {
15552
16181
  }
15553
16182
  };
15554
16183
  }
16184
+ const facetChannel = renderSpec.encoding.facet;
16185
+ if (facetChannel?.field) {
16186
+ return compileFaceted(
16187
+ chartSpec,
16188
+ renderSpec,
16189
+ facetChannel,
16190
+ options,
16191
+ theme,
16192
+ strategy,
16193
+ watermark,
16194
+ resolvedAnimation,
16195
+ crosshair
16196
+ );
16197
+ }
15555
16198
  const measure = createMeasureFn(options.measureText);
15556
16199
  const plan = resolveLayoutPlan(
15557
16200
  chartSpec,
@@ -15588,7 +16231,7 @@ function compileChart(spec, options) {
15588
16231
  };
15589
16232
  }
15590
16233
  const scales = computeScales(renderSpec, scaleArea, renderSpec.data);
15591
- applyColorScaleRange(scales, renderSpec.encoding, theme);
16234
+ applyColorScaleRange(scales, renderSpec.encoding, theme, renderSpec.highlight);
15592
16235
  scales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
15593
16236
  const isRadial = chartSpec.markType === "arc";
15594
16237
  const skipX = chartSpec.display === "sparkline" && !chartSpec.userExplicit.xAxis;
@@ -15614,7 +16257,14 @@ function compileChart(spec, options) {
15614
16257
  );
15615
16258
  const renderer = getChartRenderer(rendererKey);
15616
16259
  const marks = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width) : [];
15617
- const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
16260
+ const endpointLabels = computeEndpointLabels(
16261
+ chartSpec,
16262
+ marks,
16263
+ theme,
16264
+ chartArea,
16265
+ strategy,
16266
+ plan.endpointLabelsDemoted
16267
+ );
15618
16268
  const obstacles = [];
15619
16269
  if (finalLegend.bounds.width > 0) {
15620
16270
  obstacles.push({ ...finalLegend.bounds, kind: "legend" });
@@ -15631,15 +16281,24 @@ function compileChart(spec, options) {
15631
16281
  obstacles.push({ ...endpointLabels.bounds, kind: "endpoint-label" });
15632
16282
  }
15633
16283
  const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
15634
- const annotations = computeAnnotations(chartSpec, {
16284
+ let annotations = computeAnnotations(chartSpec, {
15635
16285
  scales,
15636
16286
  chartArea,
15637
16287
  strategy,
15638
16288
  isDark: theme.isDark,
15639
16289
  obstacles,
15640
16290
  svg: { width: dims.total.width, height: dims.total.height },
15641
- measure: annotationMeasure
16291
+ measure: annotationMeasure,
16292
+ autoThin: chartSpec.autoThin
15642
16293
  });
16294
+ let footnotes;
16295
+ if (chartSpec.autoThin && annotations.length > 1) {
16296
+ const result = thinAnnotations(annotations, chartSpec.annotations, annotationMeasure);
16297
+ annotations = result.annotations;
16298
+ if (result.footnotes.length > 0) {
16299
+ footnotes = result.footnotes;
16300
+ }
16301
+ }
15643
16302
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
15644
16303
  const altText = generateAltText(
15645
16304
  {
@@ -15659,9 +16318,10 @@ function compileChart(spec, options) {
15659
16318
  chartSpec.data
15660
16319
  );
15661
16320
  assignAnimationIndices(marks, resolvedAnimation);
16321
+ const chrome = footnotes ? { ...dims.chrome, footnotes } : dims.chrome;
15662
16322
  return {
15663
16323
  area: chartArea,
15664
- chrome: dims.chrome,
16324
+ chrome,
15665
16325
  metrics: dims.metrics,
15666
16326
  axes: {
15667
16327
  x: axes.x,
@@ -15690,6 +16350,196 @@ function compileChart(spec, options) {
15690
16350
  measureText: options.measureText
15691
16351
  };
15692
16352
  }
16353
+ function compileFaceted(chartSpec, renderSpec, facetChannel, options, theme, strategy, watermark, resolvedAnimation, crosshair) {
16354
+ const measure = createMeasureFn(options.measureText);
16355
+ const panelEncoding = { ...renderSpec.encoding };
16356
+ delete panelEncoding.facet;
16357
+ const panelSpec = { ...renderSpec, encoding: panelEncoding };
16358
+ const panelChartSpec = { ...chartSpec, encoding: panelEncoding };
16359
+ const groups = groupByField(renderSpec.data, facetChannel.field);
16360
+ const facetValues = Array.from(groups.keys());
16361
+ if (facetChannel.sort !== null) {
16362
+ const dir = facetChannel.sort === "descending" ? -1 : 1;
16363
+ facetValues.sort((a, b) => dir * a.localeCompare(b));
16364
+ }
16365
+ const plan = resolveLayoutPlan(
16366
+ panelChartSpec,
16367
+ panelSpec,
16368
+ options,
16369
+ theme,
16370
+ strategy,
16371
+ watermark,
16372
+ measure
16373
+ );
16374
+ const legendStub = placeLegend(
16375
+ plan.legendContent,
16376
+ { x: 0, y: 0, width: options.width, height: options.height },
16377
+ options.width,
16378
+ theme,
16379
+ plan.xAxisExtent
16380
+ );
16381
+ const dims = computeDimensions(
16382
+ panelChartSpec,
16383
+ options,
16384
+ legendStub,
16385
+ theme,
16386
+ strategy,
16387
+ watermark,
16388
+ plan
16389
+ );
16390
+ const chartArea = dims.chartArea;
16391
+ const finalLegend = placeLegend(
16392
+ plan.legendContent,
16393
+ chartArea,
16394
+ options.width,
16395
+ theme,
16396
+ plan.xAxisExtent
16397
+ );
16398
+ const leftReservation = plan.leftGutter;
16399
+ const bottomReservation = plan.xAxisExtent;
16400
+ const grid = computeFacetGrid(facetValues, facetChannel.columns, chartArea, {
16401
+ left: leftReservation,
16402
+ bottom: bottomReservation
16403
+ });
16404
+ const resolveConfig = chartSpec.resolve;
16405
+ const yResolve = resolveConfig?.scale?.y ?? "shared";
16406
+ const xResolve = resolveConfig?.scale?.x ?? "shared";
16407
+ const fullScales = computeScales(panelSpec, chartArea, renderSpec.data);
16408
+ const sharedXDomain = xResolve === "shared" && fullScales.x ? fullScales.x.scale.domain() : void 0;
16409
+ const sharedYDomain = yResolve === "shared" && fullScales.y ? fullScales.y.scale.domain() : void 0;
16410
+ const isRadial = chartSpec.markType === "arc";
16411
+ const rendererKey = resolveRendererKey(
16412
+ renderSpec.markType,
16413
+ panelEncoding,
16414
+ renderSpec.markDef
16415
+ );
16416
+ const renderer = getChartRenderer(rendererKey);
16417
+ const allMarks = [];
16418
+ const panelLayouts = [];
16419
+ for (const gridPanel of grid.panels) {
16420
+ const panelData = groups.get(gridPanel.key) ?? [];
16421
+ const panelSpecLocal = { ...panelSpec, data: panelData };
16422
+ let panelEnc = { ...panelEncoding };
16423
+ if (sharedXDomain && panelEnc.x) {
16424
+ const xCh = panelEnc.x;
16425
+ panelEnc = {
16426
+ ...panelEnc,
16427
+ x: { ...xCh, scale: { ...xCh.scale ?? {}, domain: sharedXDomain } }
16428
+ };
16429
+ }
16430
+ if (sharedYDomain && panelEnc.y) {
16431
+ const yCh = panelEnc.y;
16432
+ panelEnc = {
16433
+ ...panelEnc,
16434
+ y: { ...yCh, scale: { ...yCh.scale ?? {}, domain: sharedYDomain } }
16435
+ };
16436
+ }
16437
+ const panelSpecWithDomains = {
16438
+ ...panelSpecLocal,
16439
+ encoding: panelEnc
16440
+ };
16441
+ const panelScales = computeScales(panelSpecWithDomains, gridPanel.area, panelData);
16442
+ applyColorScaleRange(panelScales, panelSpecWithDomains.encoding, theme, renderSpec.highlight);
16443
+ panelScales.defaultColor = chartSpec.markDef.fill ?? chartSpec.markDef.stroke ?? theme.colors.categorical[0];
16444
+ const isLeftCol = gridPanel.col === 0;
16445
+ const isBottomRow = gridPanel.row === grid.rows - 1;
16446
+ const panelAxes = isRadial ? { x: void 0, y: void 0 } : computeAxes(panelScales, gridPanel.area, strategy, theme, options.measureText, {
16447
+ data: panelData,
16448
+ encoding: panelSpecWithDomains.encoding,
16449
+ skipX: xResolve === "shared" ? !isBottomRow : false,
16450
+ skipY: yResolve === "shared" ? !isLeftCol : false,
16451
+ markType: chartSpec.markType,
16452
+ totalWidth: gridPanel.area.width
16453
+ });
16454
+ if (!isRadial) {
16455
+ computeGridlines(panelAxes, gridPanel.area);
16456
+ }
16457
+ const panelMarks = renderer ? renderer(
16458
+ panelSpecWithDomains,
16459
+ panelScales,
16460
+ gridPanel.area,
16461
+ strategy,
16462
+ theme,
16463
+ gridPanel.area.width
16464
+ ) : [];
16465
+ const annotationMeasure = options.measureText ? (text, font) => options.measureText(text, font.fontSize, font.fontWeight).width : heuristicMeasure;
16466
+ const panelAnnotations = computeAnnotations(panelSpecWithDomains, {
16467
+ scales: panelScales,
16468
+ chartArea: gridPanel.area,
16469
+ strategy,
16470
+ isDark: theme.isDark,
16471
+ obstacles: [],
16472
+ svg: { width: options.width, height: options.height },
16473
+ measure: annotationMeasure,
16474
+ autoThin: panelSpecWithDomains.autoThin
16475
+ });
16476
+ allMarks.push(...panelMarks);
16477
+ panelLayouts.push({
16478
+ key: gridPanel.key,
16479
+ area: gridPanel.area,
16480
+ marks: panelMarks,
16481
+ axes: { x: panelAxes.x, y: panelAxes.y },
16482
+ annotations: panelAnnotations,
16483
+ header: {
16484
+ text: gridPanel.key,
16485
+ x: gridPanel.headerPos.x,
16486
+ y: gridPanel.headerPos.y,
16487
+ fontSize: theme.fonts.sizes.body,
16488
+ fontWeight: theme.fonts.weights.medium
16489
+ }
16490
+ });
16491
+ }
16492
+ const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks);
16493
+ const panelCount = facetValues.length;
16494
+ const baseAltText = generateAltText(
16495
+ {
16496
+ mark: chartSpec.markType,
16497
+ data: chartSpec.data,
16498
+ encoding: chartSpec.encoding,
16499
+ chrome: chartSpec.chrome
16500
+ },
16501
+ chartSpec.data
16502
+ );
16503
+ const altText = `${baseAltText} Faceted into ${panelCount} panels by ${facetChannel.field}.`;
16504
+ const dataTableFallback = generateDataTable(
16505
+ {
16506
+ mark: chartSpec.markType,
16507
+ data: chartSpec.data,
16508
+ encoding: chartSpec.encoding
16509
+ },
16510
+ chartSpec.data
16511
+ );
16512
+ assignAnimationIndices(allMarks, resolvedAnimation);
16513
+ return {
16514
+ area: chartArea,
16515
+ chrome: dims.chrome,
16516
+ metrics: dims.metrics,
16517
+ axes: { x: void 0, y: void 0 },
16518
+ marks: allMarks,
16519
+ annotations: [],
16520
+ legend: finalLegend,
16521
+ tooltipDescriptors,
16522
+ a11y: {
16523
+ altText,
16524
+ dataTableFallback,
16525
+ role: "img",
16526
+ keyboardNavigable: allMarks.length > 0
16527
+ },
16528
+ theme,
16529
+ dimensions: { width: options.width, height: options.height },
16530
+ animation: resolvedAnimation,
16531
+ watermark,
16532
+ display: chartSpec.display,
16533
+ crosshair,
16534
+ measureText: options.measureText,
16535
+ facet: {
16536
+ panels: panelLayouts,
16537
+ facetField: facetChannel.field,
16538
+ columns: grid.columns,
16539
+ sharedScales: yResolve === "shared" && xResolve === "shared"
16540
+ }
16541
+ };
16542
+ }
15693
16543
  function compileLayer2(spec, options) {
15694
16544
  return compileLayer(spec, options, compileChart);
15695
16545
  }