@pixerate/editor 0.165.0 → 0.167.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/THIRD_PARTY_NOTICES.md +11 -0
  2. package/dist/agent/index.cjs +34 -34
  3. package/dist/agent/index.js +5 -5
  4. package/dist/canvas/index.cjs +6 -6
  5. package/dist/canvas/index.js +2 -2
  6. package/dist/chart/controller.d.cts +29 -0
  7. package/dist/chart/controller.d.ts +29 -0
  8. package/dist/chart/index.cjs +352 -179
  9. package/dist/chart/index.d.cts +2 -0
  10. package/dist/chart/index.d.ts +2 -0
  11. package/dist/chart/index.js +183 -10
  12. package/dist/chart/layout-families.d.cts +15 -0
  13. package/dist/chart/layout-families.d.ts +15 -0
  14. package/dist/chart/layout-shared.d.cts +5 -0
  15. package/dist/chart/layout-shared.d.ts +5 -0
  16. package/dist/chart/maps.d.cts +43 -0
  17. package/dist/chart/maps.d.ts +43 -0
  18. package/dist/chart/race.d.cts +26 -0
  19. package/dist/chart/race.d.ts +26 -0
  20. package/dist/chart/schemas.d.cts +2 -0
  21. package/dist/chart/schemas.d.ts +2 -0
  22. package/dist/chart/spec.d.cts +1 -1
  23. package/dist/chart/spec.d.ts +1 -1
  24. package/dist/chart/types.d.cts +14 -2
  25. package/dist/chart/types.d.ts +14 -2
  26. package/dist/chart-maps/europe.d.cts +7 -0
  27. package/dist/chart-maps/europe.d.ts +7 -0
  28. package/dist/chart-maps/index.cjs +66 -0
  29. package/dist/chart-maps/index.d.cts +1 -0
  30. package/dist/chart-maps/index.d.ts +1 -0
  31. package/dist/chart-maps/index.js +66 -0
  32. package/dist/{chunk-IMRXC7OA.cjs → chunk-2F4GAZL5.cjs} +186 -96
  33. package/dist/{chunk-FEU2XVQH.js → chunk-34UCPHYO.js} +8 -2
  34. package/dist/{chunk-ODFCS7HF.cjs → chunk-E45AIIWY.cjs} +8 -8
  35. package/dist/{chunk-N7NBGT2Y.js → chunk-FKYGM35R.js} +2 -2
  36. package/dist/{chunk-64VWWR5U.cjs → chunk-GD34ICEX.cjs} +16 -16
  37. package/dist/{chunk-TVQW26AV.cjs → chunk-GTF4RYXJ.cjs} +29 -23
  38. package/dist/{chunk-V5GSY3NC.js → chunk-M52UCHVZ.js} +203 -3
  39. package/dist/{chunk-FRBRLKNQ.js → chunk-N3ACT6VJ.js} +92 -2
  40. package/dist/{chunk-CRFSQWKQ.js → chunk-OLCHKGGO.js} +7 -1
  41. package/dist/{chunk-2ZRN43IN.js → chunk-ORZF4JOO.js} +32 -4
  42. package/dist/{chunk-UVXTOZ2B.cjs → chunk-OUO3DSRT.cjs} +32 -4
  43. package/dist/{chunk-MKXK6E4O.cjs → chunk-SBAPXFPB.cjs} +51 -51
  44. package/dist/{chunk-2J5VLJHC.js → chunk-T62BRXMS.js} +3 -3
  45. package/dist/{chunk-Q25WAFTD.cjs → chunk-VKHJJUBK.cjs} +24 -18
  46. package/dist/{chunk-QO2T6KA5.cjs → chunk-WOIXNDHV.cjs} +247 -47
  47. package/dist/{chunk-X5W645RI.js → chunk-ZUPP4NKM.js} +3 -3
  48. package/dist/export/index.cjs +4 -4
  49. package/dist/export/index.js +3 -3
  50. package/dist/extensions/index.cjs +7 -7
  51. package/dist/extensions/index.js +6 -6
  52. package/dist/index.cjs +13 -13
  53. package/dist/index.js +7 -7
  54. package/package.json +11 -1
  55. package/skills/pixerate-chart/SKILL.md +36 -2
@@ -12,7 +12,7 @@ import {
12
12
  chartTreeTotal,
13
13
  chartVariesColors,
14
14
  shadeChartColor
15
- } from "./chunk-2ZRN43IN.js";
15
+ } from "./chunk-ORZF4JOO.js";
16
16
  import {
17
17
  NumberFormat
18
18
  } from "./chunk-II2WIF5Z.js";
@@ -948,7 +948,7 @@ function drawCallouts(ctx, plot) {
948
948
  }
949
949
  if (unplaced) ctx.warn("annotations-skipped", `${unplaced} callout${unplaced === 1 ? " points" : "s point"} at a gap or a hidden value and ${unplaced === 1 ? "is" : "are"} not drawn.`);
950
950
  }
951
- var MUTED = /* @__PURE__ */ new Set(["node", "bar", "line", "area", "point", "slice", "cell", "box", "whisker", "outlier", "connector", "trendline", "trendline-label", "error-bar", "data-label", "legend-swatch", "legend-label"]);
951
+ var MUTED = /* @__PURE__ */ new Set(["node", "region", "bar", "line", "area", "point", "slice", "cell", "box", "whisker", "outlier", "connector", "trendline", "trendline-label", "error-bar", "data-label", "legend-swatch", "legend-label"]);
952
952
  function applyHighlight(ctx) {
953
953
  const { spec, marks } = ctx;
954
954
  if (!spec.highlight.length) return;
@@ -1677,6 +1677,83 @@ function trendlineLegendItems(spec, color) {
1677
1677
  );
1678
1678
  }
1679
1679
 
1680
+ // src/chart/maps.ts
1681
+ var maps = /* @__PURE__ */ new Map();
1682
+ function registerChartMap(map) {
1683
+ maps.set(map.id, map);
1684
+ }
1685
+ function getChartMap(id) {
1686
+ return maps.get(id);
1687
+ }
1688
+ function listChartMaps() {
1689
+ return [...maps.values()];
1690
+ }
1691
+ var fold = (text) => text.normalize("NFKD").replace(/[̀-ͯ]/g, "").trim().toLowerCase();
1692
+ function matchChartMapRegions(map, categories) {
1693
+ const byKey = /* @__PURE__ */ new Map();
1694
+ for (const r of map.regions) {
1695
+ byKey.set(fold(r.id), r);
1696
+ byKey.set(fold(r.name), r);
1697
+ }
1698
+ const taken = /* @__PURE__ */ new Set();
1699
+ return categories.map((c) => {
1700
+ const r = byKey.get(fold(c));
1701
+ if (!r || taken.has(r.id)) return null;
1702
+ taken.add(r.id);
1703
+ return r;
1704
+ });
1705
+ }
1706
+ function chartMapBreaks(values, steps, thresholds) {
1707
+ if (thresholds.length) return [...thresholds];
1708
+ if (!values.length) return [];
1709
+ const lo = Math.min(...values);
1710
+ const hi = Math.max(...values);
1711
+ if (!(hi > lo)) return [];
1712
+ const exact = Array.from({ length: steps - 1 }, (_, k) => lo + (hi - lo) * (k + 1) / steps);
1713
+ for (let digits = 2; digits <= 6; digits++) {
1714
+ const rounded = exact.map((b) => Number(b.toPrecision(digits)));
1715
+ if (rounded.every((b, i) => i === 0 || b > rounded[i - 1])) return rounded;
1716
+ }
1717
+ return exact;
1718
+ }
1719
+ var chartMapClass = (value, breaks) => breaks.filter((b) => value >= b).length;
1720
+ function chartMapRamp(base, count) {
1721
+ if (count <= 1) return [base];
1722
+ return Array.from({ length: count }, (_, k) => shadeChartColor(base, 0.82 - 1.17 * k / (count - 1)));
1723
+ }
1724
+
1725
+ // src/chart/race.ts
1726
+ function chartRaceValueAt(series, index) {
1727
+ for (let i = Math.min(index, series.values.length - 1); i >= 0; i--) {
1728
+ const v = series.values[i];
1729
+ if (v !== null && v !== void 0 && Number.isFinite(v)) return v;
1730
+ }
1731
+ return 0;
1732
+ }
1733
+ function chartRaceMoment(count, frame) {
1734
+ const last = Math.max(0, count - 1);
1735
+ const at = frame === null || frame === void 0 || !Number.isFinite(frame) ? last : Math.max(0, Math.min(last, frame));
1736
+ const from = Math.floor(at);
1737
+ const to = Math.min(last, from + 1);
1738
+ return { from, to, t: at - from, at };
1739
+ }
1740
+ function chartRaceRanking(spec, frame) {
1741
+ const visible = spec.series.filter((s) => !s.hidden);
1742
+ const { from, to, t } = chartRaceMoment(spec.categories.length, frame);
1743
+ const rankAt = (i) => {
1744
+ const order2 = visible.map((s, k) => ({ id: s.id, v: chartRaceValueAt(s, i), k })).sort((a, b) => b.v - a.v || a.k - b.k);
1745
+ return new Map(order2.map((o, r) => [o.id, r]));
1746
+ };
1747
+ const r0 = rankAt(from);
1748
+ const r1 = rankAt(to);
1749
+ const near = t < 0.5 ? r0 : r1;
1750
+ return visible.map((s) => {
1751
+ const a = chartRaceValueAt(s, from);
1752
+ const b = chartRaceValueAt(s, to);
1753
+ return { seriesId: s.id, name: s.name, value: a + (b - a) * t, position: r0.get(s.id) + (r1.get(s.id) - r0.get(s.id)) * t, rank: near.get(s.id) };
1754
+ }).sort((a, b) => a.position - b.position || b.value - a.value);
1755
+ }
1756
+
1680
1757
  // src/chart/layout-families.ts
1681
1758
  var sliceColor = (ctx, series, i) => series.pointColors?.[i] || chartSliceColor(ctx.spec, i);
1682
1759
  function layoutPie(ctx, area, series) {
@@ -2441,11 +2518,110 @@ function layoutSankey(ctx, area, series) {
2441
2518
  if (hidden) ctx.warn("labels-truncated", `${hidden} node label${hidden === 1 ? " was" : "s were"} shortened to fit between the columns.`);
2442
2519
  return { plot: area };
2443
2520
  }
2521
+ function layoutBarRace(ctx, area, frame) {
2522
+ const { spec, theme, marks, fs, measure } = ctx;
2523
+ const top = spec.raceTopN;
2524
+ const moment = chartRaceMoment(spec.categories.length, frame);
2525
+ const ranking = chartRaceRanking(spec, frame);
2526
+ const shown = ranking.filter((e) => e.position < top);
2527
+ const labelW = Math.min(area.width * 0.3, Math.max(0, ...ranking.map((e) => measure(e.name, fs)))) + 8;
2528
+ const tickH = spec.yAxis.show ? fs * 1.6 : 4;
2529
+ const plot = { x: area.x + labelW, y: area.y + tickH, width: Math.max(1, area.width - labelW - fs * 4), height: Math.max(1, area.height - tickH) };
2530
+ const lead = Math.max(1e-9, ...shown.map((e) => e.value));
2531
+ const max = spec.yAxis.max !== null && spec.yAxis.max > 0 ? spec.yAxis.max : lead * 1.08;
2532
+ const xOf = (v) => plot.x + Math.max(0, Math.min(max, v)) / max * plot.width;
2533
+ const ticks = niceScale(0, max, 5).ticks.filter((t) => t <= max);
2534
+ const axisTarget = { kind: "axis", axis: "y" };
2535
+ const format = spec.yAxis.format;
2536
+ ticks.forEach((t, k) => {
2537
+ if (spec.yAxis.gridlines) marks.push({ kind: "line", key: `grid-y-${k}`, role: "grid", x1: xOf(t), y1: plot.y, x2: xOf(t), y2: plot.y + plot.height, stroke: theme.grid, strokeWidth: 1 });
2538
+ if (spec.yAxis.show) marks.push({ kind: "text", key: `tick-y-${k}`, role: "tick-label", x: xOf(t), y: area.y, text: formatChartNumber(t, format, format.decimals ?? stepDecimals(ticks[1] - ticks[0] || 1)), anchor: "middle", baseline: "top", size: fs - 1, fill: theme.mutedText, target: axisTarget });
2539
+ });
2540
+ const label = spec.categories[moment.t < 0.5 ? moment.from : moment.to] ?? "";
2541
+ const timeSize = Math.min(theme.titleSize * 3, plot.height * 0.2, plot.width * 0.5 / Math.max(1, label.length * 0.6));
2542
+ marks.push({ kind: "text", key: "race-time", role: "race-time", x: plot.x + plot.width, y: plot.y + plot.height - 4, text: label, anchor: "end", baseline: "bottom", size: timeSize, weight: 700, fill: theme.mutedText, opacity: 0.55, target: { kind: "category", index: moment.t < 0.5 ? moment.from : moment.to } });
2543
+ const band = plot.height / top;
2544
+ const barH = Math.max(2, band * (1 - Math.min(0.8, spec.gapWidth)));
2545
+ const index = Math.round(moment.at);
2546
+ for (const e of shown) {
2547
+ const si = spec.series.findIndex((s2) => s2.id === e.seriesId);
2548
+ const s = spec.series[si];
2549
+ const y = plot.y + e.position * band + (band - barH) / 2;
2550
+ const target = { kind: "point", seriesId: e.seriesId, index };
2551
+ const selected = ctx.isSelected(target) || ctx.seriesSelected(e.seriesId);
2552
+ const width = Math.max(0, xOf(e.value) - plot.x);
2553
+ marks.push({ kind: "rect", key: `bar-${e.seriesId}`, role: "bar", x: plot.x, y, width, height: barH, fill: chartSeriesColor(spec, si), radius: Math.min(3, barH / 4), target, selected, ...selected ? { stroke: theme.selection, strokeWidth: 1.5 } : {} });
2554
+ marks.push({ kind: "text", key: `label-name-${e.seriesId}`, role: "tick-label", x: plot.x - 6, y: y + barH / 2, text: truncate(e.name, labelW - 8, fs, measure), anchor: "end", baseline: "middle", size: fs, fill: selected ? theme.selection : theme.text, target: { kind: "series", seriesId: e.seriesId } });
2555
+ if (s.dataLabels ?? true) marks.push({ kind: "text", key: `label-${e.seriesId}`, role: "data-label", x: plot.x + width + 5, y: y + barH / 2, text: formatChartNumber(e.value, s.format ?? format, (s.format ?? format).decimals ?? (Math.abs(e.value) >= 100 ? 0 : void 0)), anchor: "start", baseline: "middle", size: fs - 1, fill: theme.text, target });
2556
+ }
2557
+ if (ranking.length > top) ctx.warn("values-skipped", `The race shows the top ${top} of ${ranking.length} names at each moment (raceTopN).`);
2558
+ return { plot, valueScale: { axis: "x", domain: [0, max], range: [plot.x, plot.x + plot.width], ticks } };
2559
+ }
2560
+ function placePath(d, s, ox, oy) {
2561
+ return d.replace(/(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/g, (_, x, y) => `${r2(ox + Number(x) * s)},${r2(oy + Number(y) * s)}`);
2562
+ }
2563
+ function layoutChoropleth(ctx, area, series) {
2564
+ const { spec, theme, marks, fs, measure } = ctx;
2565
+ const map = getChartMap(spec.map);
2566
+ const matches = matchChartMapRegions(map, spec.categories);
2567
+ const unmatched = spec.categories.filter((_, i) => !matches[i]);
2568
+ if (unmatched.length)
2569
+ ctx.warn("values-skipped", `${unmatched.length} categor${unmatched.length === 1 ? "y names" : "ies name"} no region of ${map.name}: ${unmatched.slice(0, 8).map((c) => `"${c}"`).join(", ")}${unmatched.length > 8 ? ", \u2026" : ""}. Use codes (such as FRA) or names (France).`);
2570
+ const indexOf = /* @__PURE__ */ new Map();
2571
+ matches.forEach((r, i) => r && indexOf.set(r.id, i));
2572
+ const valueAt = (i) => {
2573
+ const v = series.values[i];
2574
+ return v !== null && v !== void 0 && Number.isFinite(v) ? v : null;
2575
+ };
2576
+ const present = [...indexOf.values()].map(valueAt).filter((v) => v !== null);
2577
+ const breaks = chartMapBreaks(present, spec.mapSteps, spec.mapThresholds);
2578
+ const ramp = chartMapRamp(series.color ?? chartSeriesColor(spec, spec.series.indexOf(series)), breaks.length + 1);
2579
+ const format = series.format ?? spec.yAxis.format;
2580
+ const legendH = fs * 2.2;
2581
+ const s = Math.max(1e-6, Math.min(area.width / map.width, (area.height - legendH) / map.height));
2582
+ const ox = area.x + (area.width - map.width * s) / 2;
2583
+ const oy = area.y;
2584
+ if (map.land) marks.push({ kind: "path", key: "map-land", role: "map-land", d: placePath(map.land, s, ox, oy), fill: theme.grid, opacity: 0.55 });
2585
+ const markers = [];
2586
+ const labels = [];
2587
+ for (const region of map.regions) {
2588
+ const i = indexOf.get(region.id);
2589
+ const value = i === void 0 ? null : valueAt(i);
2590
+ const fill = i !== void 0 && series.pointColors?.[i] ? series.pointColors[i] : value === null ? theme.grid : ramp[chartMapClass(value, breaks)];
2591
+ const target = i === void 0 ? void 0 : { kind: "category", index: i };
2592
+ const selected = target ? ctx.isSelected(target) || ctx.isSelected({ kind: "point", seriesId: series.id, index: i }) : false;
2593
+ const [cx, cy] = [ox + region.center[0] * s, oy + region.center[1] * s];
2594
+ if (region.d) marks.push({ kind: "path", key: `region-${region.id}`, role: "region", d: placePath(region.d, s, ox, oy), fill, stroke: selected ? theme.selection : theme.surface, strokeWidth: selected ? 2 : 0.6, ...target ? { target, selected } : {} });
2595
+ if (!region.d || region.area * s * s < 30)
2596
+ markers.push({ kind: "circle", key: `region-marker-${region.id}`, role: "region", cx, cy, r: 3.5, fill, stroke: selected ? theme.selection : theme.text, strokeWidth: selected ? 1.5 : 0.75, ...target ? { target, selected } : {} });
2597
+ if (value !== null && target && (series.dataLabels ?? spec.dataLabels) && region.area * s * s > (fs * 3) ** 2) {
2598
+ const text = formatChartNumber(value, format);
2599
+ labels.push({ kind: "text", key: `label-${region.id}`, role: "data-label", x: cx, y: cy, text, anchor: "middle", baseline: "middle", size: fs - 1, weight: 600, fill: contrastingTextColor(fill), target });
2600
+ }
2601
+ }
2602
+ marks.push(...markers, ...labels);
2603
+ const fmt = (v) => formatChartNumber(v, format);
2604
+ const items = ramp.map((color, k) => ({
2605
+ color,
2606
+ text: breaks.length === 0 ? present.length ? fmt(present[0]) : "" : k === 0 ? `< ${fmt(breaks[0])}` : k === breaks.length ? `\u2265 ${fmt(breaks[k - 1])}` : `${fmt(breaks[k - 1])}\u2013${fmt(breaks[k])}`
2607
+ }));
2608
+ items.push({ color: theme.grid, text: "No data" });
2609
+ let x = area.x;
2610
+ const y = area.y + area.height - fs * 0.9;
2611
+ items.forEach((item, k) => {
2612
+ marks.push({ kind: "rect", key: `legend-swatch-${k}`, role: "legend-swatch", x, y: y - 5, width: 10, height: 10, radius: 2, fill: item.color });
2613
+ marks.push({ kind: "text", key: `legend-label-${k}`, role: "legend-label", x: x + 14, y, text: item.text, anchor: "start", baseline: "middle", size: fs - 1, fill: theme.text });
2614
+ x += 14 + measure(item.text, fs - 1) + 14;
2615
+ });
2616
+ if (x > area.x + area.width) ctx.warn("legend-truncated", "The map legend is wider than the chart; widen the chart or use fewer classes.");
2617
+ if (map.attribution) marks.push({ kind: "text", key: "map-attribution", role: "note", x: area.x + area.width, y, text: map.attribution, anchor: "end", baseline: "middle", size: Math.max(7, fs - 3), fill: theme.mutedText });
2618
+ return { plot: { x: ox, y: oy, width: map.width * s, height: map.height * s } };
2619
+ }
2444
2620
 
2445
2621
  // src/chart/layout.ts
2446
2622
  function legendItems(spec, ctx) {
2447
2623
  const family = chartFamily(spec);
2448
- if (spec.type === "funnel" || spec.type === "slope" || spec.type === "sankey") return [];
2624
+ if (spec.type === "funnel" || spec.type === "slope" || spec.type === "sankey" || spec.type === "barrace" || spec.type === "choropleth") return [];
2449
2625
  if (chartSpecColorsByCategory(spec)) {
2450
2626
  const first = spec.series.find((s) => !s.hidden);
2451
2627
  const seats = (i) => Math.max(0, Math.round(first?.values[i] ?? 0));
@@ -2585,6 +2761,20 @@ function layoutChart(spec, options) {
2585
2761
  return finish(layoutFunnel(ctx, area, visible[0]));
2586
2762
  case "treemap":
2587
2763
  return finish(spec.tree.length ? layoutTreemapTree(ctx, area, spec.series[0]) : layoutTreemap(ctx, area, visible[0]));
2764
+ case "choropleth": {
2765
+ const map = getChartMap(spec.map);
2766
+ if (!map) {
2767
+ ctx.warn("type-fallback", `The map "${spec.map}" is not registered, so the values are drawn as bars: register it with registerChartMap (the Europe outlines are EUROPE_CHART_MAP in @pixerate/editor/chart-maps).`);
2768
+ return finish(layoutCartesian(ctx, area, visible));
2769
+ }
2770
+ if (!matchChartMapRegions(map, spec.categories).some(Boolean)) {
2771
+ ctx.warn("type-fallback", `No category names a region of ${map.name}, so the values are drawn as bars: use codes (such as FRA) or names (France) as categories.`);
2772
+ return finish(layoutCartesian(ctx, area, visible));
2773
+ }
2774
+ return finish(layoutChoropleth(ctx, area, visible[0]));
2775
+ }
2776
+ case "barrace":
2777
+ return finish(layoutBarRace(ctx, area, options.raceFrame));
2588
2778
  case "sankey":
2589
2779
  if (!spec.links.length) {
2590
2780
  ctx.warn("type-fallback", "A Sankey diagram needs flows (set_chart_links); it is drawn as a bar chart until then.");
@@ -2794,6 +2984,16 @@ export {
2794
2984
  MAX_CHART_DATA_LABELS,
2795
2985
  MAX_CHART_LINE_POINTS,
2796
2986
  MAX_CHART_MARKERS,
2987
+ registerChartMap,
2988
+ getChartMap,
2989
+ listChartMaps,
2990
+ matchChartMapRegions,
2991
+ chartMapBreaks,
2992
+ chartMapClass,
2993
+ chartMapRamp,
2994
+ chartRaceValueAt,
2995
+ chartRaceMoment,
2996
+ chartRaceRanking,
2797
2997
  layoutChart,
2798
2998
  chartValueAtPoint,
2799
2999
  chartXAtPoint,
@@ -4,14 +4,17 @@ import {
4
4
  } from "./chunk-KGRZMHZB.js";
5
5
  import {
6
6
  chartMovingAverage,
7
+ chartRaceRanking,
7
8
  chartSupportsTrendlines,
8
9
  fitChartTrendline,
9
10
  formatChartNumber,
11
+ getChartMap,
10
12
  layoutChart,
13
+ matchChartMapRegions,
11
14
  r2,
12
15
  sameChartSelection,
13
16
  summarizeChartSeries
14
- } from "./chunk-V5GSY3NC.js";
17
+ } from "./chunk-M52UCHVZ.js";
15
18
  import {
16
19
  CHART_TYPE_LABELS,
17
20
  DEFAULT_CHART_PALETTE,
@@ -44,7 +47,7 @@ import {
44
47
  parseChartValue,
45
48
  shadeChartColor,
46
49
  uniqueChartSeriesId
47
- } from "./chunk-2ZRN43IN.js";
50
+ } from "./chunk-ORZF4JOO.js";
48
51
  import {
49
52
  parseCsv,
50
53
  parseTsv
@@ -549,6 +552,22 @@ function describeChart(spec, series = spec.series.filter((s) => !s.hidden).map((
549
552
  }
550
553
  return parts.join(" ");
551
554
  }
555
+ if (family === "choropleth") {
556
+ const s = spec.series.find((x) => !x.hidden);
557
+ const map = getChartMap(spec.map);
558
+ const matches = map ? matchChartMapRegions(map, spec.categories) : spec.categories.map(() => null);
559
+ const rows = spec.categories.flatMap((c, i) => matches[i] && s.values[i] !== null ? [{ name: matches[i].name, v: s.values[i] }] : []).sort((a, b) => b.v - a.v);
560
+ const missing = spec.categories.filter((_, i) => !matches[i]).length;
561
+ if (!rows.length) return `${head} of ${map?.name ?? spec.map} with no regions matched.`;
562
+ return `${head} of ${map?.name ?? spec.map} shading ${rows.length} region${rows.length === 1 ? "" : "s"} by ${s.name}: highest ${rows[0].name} (${fmt(rows[0].v)}), lowest ${rows[rows.length - 1].name} (${fmt(rows[rows.length - 1].v)}).${missing ? ` ${missing} categor${missing === 1 ? "y names" : "ies name"} no region.` : ""}`;
563
+ }
564
+ if (family === "barrace") {
565
+ const n = spec.categories.length;
566
+ const first = chartRaceRanking(spec, 0)[0];
567
+ const last = chartRaceRanking(spec, n - 1)[0];
568
+ const lead = (e, at) => e ? `${e.name} leads at ${at} with ${fmt(e.value)}` : "";
569
+ return `${head} of ${series.length} names over ${n} time points (${spec.categories[0]} to ${spec.categories[n - 1]}), showing the top ${Math.min(spec.raceTopN, series.length)}. ${lead(first, spec.categories[0])}; ${lead(last, spec.categories[n - 1])}.`;
570
+ }
552
571
  if (family === "sankey") {
553
572
  if (!spec.links.length) return `${head} with no flows yet.`;
554
573
  const total = spec.links.reduce((t, l) => t + l.value, 0);
@@ -776,6 +795,17 @@ function sameChartJson(a, b) {
776
795
  if (keys.length !== Object.keys(bo).filter((k) => bo[k] !== void 0).length) return false;
777
796
  return keys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && sameChartJson(ao[k], bo[k]));
778
797
  }
798
+ var defaultRaceTicker = {
799
+ now: () => typeof performance !== "undefined" ? performance.now() : Date.now(),
800
+ next: (step) => {
801
+ if (typeof requestAnimationFrame === "function") {
802
+ const id2 = requestAnimationFrame(() => step());
803
+ return () => cancelAnimationFrame(id2);
804
+ }
805
+ const id = setTimeout(step, 16);
806
+ return () => clearTimeout(id);
807
+ }
808
+ };
779
809
  var fail = (message, issues) => ({ ok: false, message, ...issues ? { issues } : {} });
780
810
  var ChartController = class {
781
811
  history;
@@ -788,12 +818,16 @@ var ChartController = class {
788
818
  revision = 0;
789
819
  applying = false;
790
820
  onChange;
821
+ raceTicker;
822
+ raceFrame = null;
823
+ raceStop = null;
791
824
  listeners = /* @__PURE__ */ new Set();
792
825
  unsubscribeHistory;
793
826
  state;
794
827
  constructor(options = {}) {
795
828
  this.history = options.history ?? new HistoryManager();
796
829
  this.onChange = options.onChange;
830
+ this.raceTicker = options.raceTicker ?? defaultRaceTicker;
797
831
  const { spec, issues } = normalizeChartSpec(options.spec ?? {});
798
832
  this.spec = spec;
799
833
  this.issues = issues;
@@ -843,6 +877,8 @@ var ChartController = class {
843
877
  canRedo: h.canRedo,
844
878
  undoLabel: h.undoName,
845
879
  redoLabel: h.redoName,
880
+ raceFrame: this.raceFrame === null ? null : Math.min(this.raceFrame, Math.max(0, this.spec.categories.length - 1)),
881
+ racePlaying: this.raceStop !== null,
846
882
  revision: this.revision
847
883
  };
848
884
  }
@@ -1651,6 +1687,55 @@ var ChartController = class {
1651
1687
  // -------------------------------------------------------------------------
1652
1688
  // Selection and gestures
1653
1689
  // -------------------------------------------------------------------------
1690
+ /**
1691
+ * Shows a bar race at a moment: a category index, fractional between time points (`null`:
1692
+ * the last one). View state like the selection, so not an undo step. Stops playback.
1693
+ */
1694
+ setRaceFrame(frame) {
1695
+ const n = this.spec.categories.length;
1696
+ if (frame !== null && !(Number.isFinite(frame) && frame >= 0 && frame <= Math.max(0, n - 1)))
1697
+ return fail(n ? `The race has time points 0 to ${n - 1}${n > 1 ? ` ("${this.spec.categories[0]}" to "${this.spec.categories[n - 1]}")` : ""}; got ${frame}.` : "The chart has no time points (categories) yet.");
1698
+ this.stopRace();
1699
+ if (frame === this.raceFrame) return { ok: true };
1700
+ this.raceFrame = frame;
1701
+ this.notify(false);
1702
+ return { ok: true };
1703
+ }
1704
+ /**
1705
+ * Plays the bar race from the moment shown (from the start when it is at the end), moving
1706
+ * one time point every `msPerStep` (default 1000) until the last. Not an undo step.
1707
+ */
1708
+ playRace(options = {}) {
1709
+ const last = this.spec.categories.length - 1;
1710
+ if (last < 1) return fail("A race needs at least two time points (categories) to play.");
1711
+ this.stopRace();
1712
+ const per = Math.max(50, options.msPerStep ?? 1e3);
1713
+ const from = this.raceFrame === null || this.raceFrame >= last ? 0 : this.raceFrame;
1714
+ const start = this.raceTicker.now();
1715
+ const step = () => {
1716
+ const at = Math.min(last, from + (this.raceTicker.now() - start) / per);
1717
+ this.raceFrame = at;
1718
+ if (at >= last) {
1719
+ this.raceStop = null;
1720
+ this.raceFrame = null;
1721
+ } else this.raceStop = this.raceTicker.next(step);
1722
+ this.notify(false);
1723
+ };
1724
+ this.raceFrame = from;
1725
+ this.raceStop = this.raceTicker.next(step);
1726
+ this.notify(false);
1727
+ return { ok: true };
1728
+ }
1729
+ /** Pauses bar race playback where it is. */
1730
+ pauseRace() {
1731
+ if (!this.raceStop) return;
1732
+ this.stopRace();
1733
+ this.notify(false);
1734
+ }
1735
+ stopRace() {
1736
+ this.raceStop?.();
1737
+ this.raceStop = null;
1738
+ }
1654
1739
  /** Selects a chart element (view state; not an undo step). */
1655
1740
  select(selection) {
1656
1741
  const next = this.validSelection(selection, this.spec);
@@ -1773,6 +1858,7 @@ var ChartController = class {
1773
1858
  }
1774
1859
  /** Stops listening to the history (reconnected by the next `subscribe`). */
1775
1860
  destroy() {
1861
+ this.stopRace();
1776
1862
  this.unsubscribeHistory?.();
1777
1863
  this.unsubscribeHistory = void 0;
1778
1864
  this.listeners.clear();
@@ -1808,6 +1894,10 @@ function updateLabel(patch) {
1808
1894
  parliamentArc: "Change hemicycle sweep",
1809
1895
  parliamentMajority: "Change majority",
1810
1896
  parliamentLabel: "Edit seat caption",
1897
+ raceTopN: "Change bars shown",
1898
+ map: "Change map",
1899
+ mapSteps: "Change colour classes",
1900
+ mapThresholds: "Change class breaks",
1811
1901
  sunburstLabel: "Edit centre caption",
1812
1902
  tree: "Replace grouped data",
1813
1903
  links: "Replace flows",
@@ -8,7 +8,7 @@ import {
8
8
  isCartesianChart,
9
9
  normalizeChartSpec,
10
10
  parseChartColor
11
- } from "./chunk-2ZRN43IN.js";
11
+ } from "./chunk-ORZF4JOO.js";
12
12
 
13
13
  // src/chart/ooxml.ts
14
14
  var CHART_OOXML_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart";
@@ -475,6 +475,12 @@ function chartToOoxml(spec) {
475
475
  } else if (family === "sunburst") {
476
476
  issues.push(`Sunburst charts are written as doughnut charts of the ${spec.tree.length ? "top-level group totals" : "values"}: DrawingML has no sunburst type (Office 2016 stores it as chartex).`);
477
477
  type = "donut";
478
+ } else if (family === "choropleth") {
479
+ issues.push("Maps are written as bar charts of the values by region: DrawingML has no map chart (Office 2016 stores region maps as chartex with Bing geodata).");
480
+ type = "bar";
481
+ } else if (family === "barrace") {
482
+ issues.push("Bar chart races are written as clustered bar charts of every name at every time point: DrawingML has no animation.");
483
+ type = "bar";
478
484
  } else if (family === "sankey") {
479
485
  issues.push("Sankey diagrams are written as bar charts of how much passes through each node: DrawingML has no flows.");
480
486
  type = "bar";
@@ -426,7 +426,7 @@ function removeChartTreeRow(rows, row) {
426
426
  }
427
427
 
428
428
  // src/chart/spec.ts
429
- var CHART_TYPES = ["bar", "line", "area", "scatter", "bubble", "pie", "donut", "radar", "waterfall", "histogram", "box", "funnel", "treemap", "sunburst", "sankey", "slope", "likert", "parliament"];
429
+ var CHART_TYPES = ["bar", "line", "area", "scatter", "bubble", "pie", "donut", "radar", "waterfall", "histogram", "box", "funnel", "treemap", "sunburst", "sankey", "slope", "likert", "parliament", "barrace", "choropleth"];
430
430
  var CHART_MARKERS = ["circle", "square", "diamond", "triangle", "cross", "none"];
431
431
  var CHART_TRENDLINE_TYPES = ["linear", "exponential", "logarithmic", "power", "polynomial", "moving-average"];
432
432
  var CHART_LABEL_POSITIONS = ["auto", "inside", "outside", "center"];
@@ -461,7 +461,9 @@ var CHART_TYPE_LABELS = {
461
461
  likert: "Likert",
462
462
  parliament: "Parliament",
463
463
  sunburst: "Sunburst",
464
- sankey: "Sankey"
464
+ sankey: "Sankey",
465
+ barrace: "Bar race",
466
+ choropleth: "Map"
465
467
  };
466
468
  var DEFAULT_CHART_PALETTE = ["#4269d0", "#efb118", "#ff725c", "#6cc5b0", "#3ca951", "#ff8ab7", "#a463f2", "#97bbf5", "#9c6b4e", "#9498a0"];
467
469
  var DEFAULT_CHART_THEME = {
@@ -513,6 +515,8 @@ function chartFamily(spec) {
513
515
  case "parliament":
514
516
  case "sunburst":
515
517
  case "sankey":
518
+ case "barrace":
519
+ case "choropleth":
516
520
  return spec.type;
517
521
  case "bubble":
518
522
  return "xy";
@@ -984,6 +988,25 @@ function normalizeChartSpec(input) {
984
988
  if (Number.isInteger(m) && m >= 1) parliamentMajority = m;
985
989
  else issues.push({ path: "parliamentMajority", message: "parliamentMajority must be a whole number of seats, 1 or more, or null for half plus one." });
986
990
  }
991
+ let mapSteps = 5;
992
+ if (value.mapSteps !== void 0) {
993
+ const n = Number(value.mapSteps);
994
+ if (value.mapSteps !== null && Number.isInteger(n) && n >= 2 && n <= 9) mapSteps = n;
995
+ else issues.push({ path: "mapSteps", message: "mapSteps must be a whole number of colour classes from 2 to 9." });
996
+ }
997
+ let mapThresholds = [];
998
+ if (value.mapThresholds !== void 0 && value.mapThresholds !== null) {
999
+ const list = Array.isArray(value.mapThresholds) ? value.mapThresholds.map((t) => typeof t === "number" ? t : Number(t)) : null;
1000
+ if (!list || list.some((t) => !Number.isFinite(t)) || list.length > 8 || list.some((t, i) => i > 0 && t <= list[i - 1]))
1001
+ issues.push({ path: "mapThresholds", message: "mapThresholds must be up to 8 numbers in ascending order (the class breaks), or [] for equal classes." });
1002
+ else mapThresholds = list;
1003
+ }
1004
+ let raceTopN = 10;
1005
+ if (value.raceTopN !== void 0) {
1006
+ const n = Number(value.raceTopN);
1007
+ if (value.raceTopN !== null && Number.isInteger(n) && n >= 1 && n <= 30) raceTopN = n;
1008
+ else issues.push({ path: "raceTopN", message: "raceTopN must be a whole number of bars from 1 to 30." });
1009
+ }
987
1010
  let radarTicks = null;
988
1011
  if (value.radarTicks !== void 0 && value.radarTicks !== null) {
989
1012
  const t = Number(value.radarTicks);
@@ -1029,7 +1052,11 @@ function normalizeChartSpec(input) {
1029
1052
  sunburstLabel: text(value.sunburstLabel, "", "sunburstLabel", issues),
1030
1053
  links,
1031
1054
  sankeyNodes: links.length ? sankeyNodes : [],
1032
- sankeyLinkColor: pick(value.sankeyLinkColor, CHART_SANKEY_LINK_COLORS, "source", "sankeyLinkColor", issues)
1055
+ sankeyLinkColor: pick(value.sankeyLinkColor, CHART_SANKEY_LINK_COLORS, "source", "sankeyLinkColor", issues),
1056
+ raceTopN,
1057
+ map: text(value.map, "europe", "map", issues).trim() || "europe",
1058
+ mapSteps,
1059
+ mapThresholds
1033
1060
  };
1034
1061
  if (spec.stacking === "percent")
1035
1062
  for (const name of ["yAxis", "yAxis2"]) {
@@ -1055,7 +1082,8 @@ function cloneChartSpec(spec) {
1055
1082
  referenceBands: spec.referenceBands.map((b) => ({ ...b })),
1056
1083
  tree: cloneChartTree(spec.tree),
1057
1084
  links: spec.links.map((l) => ({ ...l })),
1058
- sankeyNodes: [...spec.sankeyNodes]
1085
+ sankeyNodes: [...spec.sankeyNodes],
1086
+ mapThresholds: [...spec.mapThresholds]
1059
1087
  };
1060
1088
  }
1061
1089
  function cloneChartSeries(series) {
@@ -426,7 +426,7 @@ function removeChartTreeRow(rows, row) {
426
426
  }
427
427
 
428
428
  // src/chart/spec.ts
429
- var CHART_TYPES = ["bar", "line", "area", "scatter", "bubble", "pie", "donut", "radar", "waterfall", "histogram", "box", "funnel", "treemap", "sunburst", "sankey", "slope", "likert", "parliament"];
429
+ var CHART_TYPES = ["bar", "line", "area", "scatter", "bubble", "pie", "donut", "radar", "waterfall", "histogram", "box", "funnel", "treemap", "sunburst", "sankey", "slope", "likert", "parliament", "barrace", "choropleth"];
430
430
  var CHART_MARKERS = ["circle", "square", "diamond", "triangle", "cross", "none"];
431
431
  var CHART_TRENDLINE_TYPES = ["linear", "exponential", "logarithmic", "power", "polynomial", "moving-average"];
432
432
  var CHART_LABEL_POSITIONS = ["auto", "inside", "outside", "center"];
@@ -461,7 +461,9 @@ var CHART_TYPE_LABELS = {
461
461
  likert: "Likert",
462
462
  parliament: "Parliament",
463
463
  sunburst: "Sunburst",
464
- sankey: "Sankey"
464
+ sankey: "Sankey",
465
+ barrace: "Bar race",
466
+ choropleth: "Map"
465
467
  };
466
468
  var DEFAULT_CHART_PALETTE = ["#4269d0", "#efb118", "#ff725c", "#6cc5b0", "#3ca951", "#ff8ab7", "#a463f2", "#97bbf5", "#9c6b4e", "#9498a0"];
467
469
  var DEFAULT_CHART_THEME = {
@@ -513,6 +515,8 @@ function chartFamily(spec) {
513
515
  case "parliament":
514
516
  case "sunburst":
515
517
  case "sankey":
518
+ case "barrace":
519
+ case "choropleth":
516
520
  return spec.type;
517
521
  case "bubble":
518
522
  return "xy";
@@ -984,6 +988,25 @@ function normalizeChartSpec(input) {
984
988
  if (Number.isInteger(m) && m >= 1) parliamentMajority = m;
985
989
  else issues.push({ path: "parliamentMajority", message: "parliamentMajority must be a whole number of seats, 1 or more, or null for half plus one." });
986
990
  }
991
+ let mapSteps = 5;
992
+ if (value.mapSteps !== void 0) {
993
+ const n = Number(value.mapSteps);
994
+ if (value.mapSteps !== null && Number.isInteger(n) && n >= 2 && n <= 9) mapSteps = n;
995
+ else issues.push({ path: "mapSteps", message: "mapSteps must be a whole number of colour classes from 2 to 9." });
996
+ }
997
+ let mapThresholds = [];
998
+ if (value.mapThresholds !== void 0 && value.mapThresholds !== null) {
999
+ const list = Array.isArray(value.mapThresholds) ? value.mapThresholds.map((t) => typeof t === "number" ? t : Number(t)) : null;
1000
+ if (!list || list.some((t) => !Number.isFinite(t)) || list.length > 8 || list.some((t, i) => i > 0 && t <= list[i - 1]))
1001
+ issues.push({ path: "mapThresholds", message: "mapThresholds must be up to 8 numbers in ascending order (the class breaks), or [] for equal classes." });
1002
+ else mapThresholds = list;
1003
+ }
1004
+ let raceTopN = 10;
1005
+ if (value.raceTopN !== void 0) {
1006
+ const n = Number(value.raceTopN);
1007
+ if (value.raceTopN !== null && Number.isInteger(n) && n >= 1 && n <= 30) raceTopN = n;
1008
+ else issues.push({ path: "raceTopN", message: "raceTopN must be a whole number of bars from 1 to 30." });
1009
+ }
987
1010
  let radarTicks = null;
988
1011
  if (value.radarTicks !== void 0 && value.radarTicks !== null) {
989
1012
  const t = Number(value.radarTicks);
@@ -1029,7 +1052,11 @@ function normalizeChartSpec(input) {
1029
1052
  sunburstLabel: text(value.sunburstLabel, "", "sunburstLabel", issues),
1030
1053
  links,
1031
1054
  sankeyNodes: links.length ? sankeyNodes : [],
1032
- sankeyLinkColor: pick(value.sankeyLinkColor, CHART_SANKEY_LINK_COLORS, "source", "sankeyLinkColor", issues)
1055
+ sankeyLinkColor: pick(value.sankeyLinkColor, CHART_SANKEY_LINK_COLORS, "source", "sankeyLinkColor", issues),
1056
+ raceTopN,
1057
+ map: text(value.map, "europe", "map", issues).trim() || "europe",
1058
+ mapSteps,
1059
+ mapThresholds
1033
1060
  };
1034
1061
  if (spec.stacking === "percent")
1035
1062
  for (const name of ["yAxis", "yAxis2"]) {
@@ -1055,7 +1082,8 @@ function cloneChartSpec(spec) {
1055
1082
  referenceBands: spec.referenceBands.map((b) => ({ ...b })),
1056
1083
  tree: cloneChartTree(spec.tree),
1057
1084
  links: spec.links.map((l) => ({ ...l })),
1058
- sankeyNodes: [...spec.sankeyNodes]
1085
+ sankeyNodes: [...spec.sankeyNodes],
1086
+ mapThresholds: [...spec.mapThresholds]
1059
1087
  };
1060
1088
  }
1061
1089
  function cloneChartSeries(series) {