@pixerate/editor 0.162.1 → 0.164.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 (75) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/index.cjs +34 -34
  3. package/dist/agent/index.js +5 -5
  4. package/dist/canvas/displacement.d.cts +34 -2
  5. package/dist/canvas/displacement.d.ts +34 -2
  6. package/dist/canvas/explosion-tools.d.cts +43 -0
  7. package/dist/canvas/explosion-tools.d.ts +43 -0
  8. package/dist/canvas/explosionLayout.d.cts +50 -0
  9. package/dist/canvas/explosionLayout.d.ts +50 -0
  10. package/dist/canvas/explosionState.d.cts +52 -0
  11. package/dist/canvas/explosionState.d.ts +52 -0
  12. package/dist/canvas/index.cjs +430 -88
  13. package/dist/canvas/index.d.cts +3 -0
  14. package/dist/canvas/index.d.ts +3 -0
  15. package/dist/canvas/index.js +425 -83
  16. package/dist/canvas/types.d.cts +27 -2
  17. package/dist/canvas/types.d.ts +27 -2
  18. package/dist/chart/commands.d.cts +2 -1
  19. package/dist/chart/commands.d.ts +2 -1
  20. package/dist/chart/controller.d.cts +109 -1
  21. package/dist/chart/controller.d.ts +109 -1
  22. package/dist/chart/index.cjs +605 -130
  23. package/dist/chart/index.d.cts +1 -0
  24. package/dist/chart/index.d.ts +1 -0
  25. package/dist/chart/index.js +490 -15
  26. package/dist/chart/layout-annotations.d.cts +29 -0
  27. package/dist/chart/layout-annotations.d.ts +29 -0
  28. package/dist/chart/layout-families.d.cts +23 -0
  29. package/dist/chart/layout-families.d.ts +23 -0
  30. package/dist/chart/mermaid.d.cts +2 -1
  31. package/dist/chart/mermaid.d.ts +2 -1
  32. package/dist/chart/schemas.d.cts +13 -0
  33. package/dist/chart/schemas.d.ts +13 -0
  34. package/dist/chart/spec.d.cts +30 -5
  35. package/dist/chart/spec.d.ts +30 -5
  36. package/dist/chart/stats.d.cts +9 -1
  37. package/dist/chart/stats.d.ts +9 -1
  38. package/dist/chart/tools.d.cts +1 -1
  39. package/dist/chart/tools.d.ts +1 -1
  40. package/dist/chart/tree.d.cts +62 -0
  41. package/dist/chart/tree.d.ts +62 -0
  42. package/dist/chart/types.d.cts +77 -3
  43. package/dist/chart/types.d.ts +77 -3
  44. package/dist/{chunk-7YBDRJXT.cjs → chunk-22KLZZGH.cjs} +8 -8
  45. package/dist/{chunk-HLOKB7WW.js → chunk-3NEZJCBY.js} +366 -6
  46. package/dist/{chunk-EVPZ5IKN.js → chunk-5FNGLV2D.js} +65 -3
  47. package/dist/{chunk-EQ46F75O.cjs → chunk-D54JHQK2.cjs} +51 -51
  48. package/dist/{chunk-NO5QQDHZ.js → chunk-DAPQGA55.js} +2 -2
  49. package/dist/{chunk-3IHNXRUV.cjs → chunk-GKNEJ6EN.cjs} +399 -67
  50. package/dist/{chunk-4NGVHHJB.cjs → chunk-NZD2MZJC.cjs} +80 -18
  51. package/dist/{chunk-CX6JLDFC.js → chunk-OXERWBKE.js} +3 -3
  52. package/dist/{chunk-7CKPUUN4.js → chunk-POR54B55.js} +61 -10
  53. package/dist/{chunk-LL3H5XUH.cjs → chunk-R3P2M7HU.cjs} +367 -7
  54. package/dist/{chunk-HDGBM7EA.cjs → chunk-SBQVEBSU.cjs} +580 -53
  55. package/dist/{chunk-XDCFJEU7.js → chunk-SZA42R4N.js} +346 -14
  56. package/dist/{chunk-GLMP22FT.js → chunk-TALQONZ5.js} +3 -3
  57. package/dist/{chunk-NA4UVVJ4.cjs → chunk-YGG7WDWT.cjs} +76 -25
  58. package/dist/{chunk-EY22AY6R.cjs → chunk-YSAGBIRW.cjs} +16 -16
  59. package/dist/{chunk-DTSTNRPH.js → chunk-Z4P2RBMJ.js} +536 -9
  60. package/dist/export/index.cjs +4 -4
  61. package/dist/export/index.js +3 -3
  62. package/dist/extensions/index.cjs +7 -7
  63. package/dist/extensions/index.js +6 -6
  64. package/dist/index.cjs +13 -13
  65. package/dist/index.js +7 -7
  66. package/dist/paint-editor/index.cjs +0 -20
  67. package/dist/paint-editor/index.js +0 -20
  68. package/package.json +1 -1
  69. package/skills/README.md +12 -0
  70. package/skills/pixerate-agent-tools/SKILL.md +34 -1
  71. package/skills/pixerate-canvas/SKILL.md +16 -0
  72. package/skills/pixerate-chart/SKILL.md +41 -9
  73. package/skills/pixerate-image-editor/SKILL.md +2 -0
  74. package/skills/pixerate-paint-editor/SKILL.md +2 -0
  75. package/skills/pixerate-tokens/SKILL.md +5 -0
@@ -1,13 +1,18 @@
1
1
  import {
2
2
  DEFAULT_CHART_THEME,
3
- chartColorsByCategory,
4
3
  chartErrorAmounts,
5
4
  chartFamily,
6
5
  chartPointColor,
7
6
  chartSeriesColor,
8
7
  chartSeriesType,
9
- chartSliceColor
10
- } from "./chunk-HLOKB7WW.js";
8
+ chartSliceColor,
9
+ chartSpecColorsByCategory,
10
+ chartTreeDepth,
11
+ chartTreeFromSeries,
12
+ chartTreeTotal,
13
+ chartVariesColors,
14
+ shadeChartColor
15
+ } from "./chunk-3NEZJCBY.js";
11
16
  import {
12
17
  NumberFormat
13
18
  } from "./chunk-II2WIF5Z.js";
@@ -507,6 +512,15 @@ function chartQuantile(sorted, q) {
507
512
  const b = sorted[i + 1] ?? a;
508
513
  return a + (b - a) * (pos - i);
509
514
  }
515
+ var roundish = (n) => Number(n.toPrecision(3));
516
+ function suggestChartReferences(spec) {
517
+ const values = spec.series.filter((s) => !s.hidden && s.axis !== "secondary").flatMap((s) => s.values).filter((v) => v !== null && Number.isFinite(v)).sort((a, b) => a - b);
518
+ if (!values.length) return { line: { value: 0, label: "Target" }, band: { from: 0, to: 1, label: "Target range" } };
519
+ const mean = values.reduce((t, v) => t + v, 0) / values.length;
520
+ const lo = roundish(chartQuantile(values, 0.25));
521
+ const hi = roundish(chartQuantile(values, 0.75));
522
+ return { line: { value: roundish(mean), label: "Average" }, band: { from: lo, to: hi > lo ? hi : lo + 1, label: "Middle half" } };
523
+ }
510
524
  function chartBoxStats(values) {
511
525
  const v = values.filter((x) => x !== null && Number.isFinite(x)).sort((a, b) => a - b);
512
526
  if (!v.length) return null;
@@ -822,6 +836,157 @@ function chartSupportsTrendlines(type, stacking) {
822
836
  return (type === "bar" || type === "line" || type === "area" || type === "scatter" || type === "bubble") && stacking === "none";
823
837
  }
824
838
 
839
+ // src/chart/layout-annotations.ts
840
+ var axisOf = (item) => item.axis ?? "y";
841
+ var chartAnnotationFamily = (family) => family === "cartesian" || family === "xy";
842
+ function referenceValues(spec, axis) {
843
+ const out = [];
844
+ for (const l of spec.referenceLines) if (axisOf(l) === axis) out.push(l.value);
845
+ for (const b of spec.referenceBands) if (axisOf(b) === axis) out.push(b.from, b.to);
846
+ return out;
847
+ }
848
+ function drawReferences(ctx, plot, axes, layer) {
849
+ const { spec, theme, marks, fs, measure } = ctx;
850
+ const items = layer === "bands" ? spec.referenceBands : spec.referenceLines;
851
+ const missing = /* @__PURE__ */ new Set();
852
+ items.forEach((item, i) => {
853
+ const axis = axes[axisOf(item)];
854
+ if (!axis) {
855
+ missing.add(axisOf(item));
856
+ return;
857
+ }
858
+ const { info, vertical } = axis;
859
+ const color = item.color ?? theme.text;
860
+ const lo = vertical ? plot.y : plot.x;
861
+ const hi = vertical ? plot.y + plot.height : plot.x + plot.width;
862
+ const inside = (p2) => Number.isFinite(p2) && p2 >= lo - 0.5 && p2 <= hi + 0.5;
863
+ const at = (v) => info.logBase && !(v > 0) ? NaN : info.raw(v);
864
+ if ("from" in item) {
865
+ const a = at(item.from);
866
+ const b = at(item.to);
867
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return;
868
+ const p0 = Math.max(lo, Math.min(a, b));
869
+ const p1 = Math.min(hi, Math.max(a, b));
870
+ if (p1 <= p0) return;
871
+ const rect = vertical ? { x: plot.x, y: p0, width: plot.width, height: p1 - p0 } : { x: p0, y: plot.y, width: p1 - p0, height: plot.height };
872
+ marks.push({ kind: "rect", key: `reference-band-${i}`, role: "reference-band", ...rect, fill: color, opacity: item.color ? 0.16 : 0.07 });
873
+ if (item.label) {
874
+ const text = truncate(item.label, (vertical ? plot.width : rect.width) - 8, fs - 1, measure);
875
+ marks.push({ kind: "text", key: `reference-label-band-${i}`, role: "reference-label", x: rect.x + 4, y: rect.y + 3, text, anchor: "start", baseline: "top", size: fs - 1, fill: color, opacity: 0.85 });
876
+ }
877
+ return;
878
+ }
879
+ const p = at(item.value);
880
+ if (!inside(p)) {
881
+ ctx.warn("annotations-skipped", `The reference line at ${item.value} is outside the ${axisOf(item) === "x" ? "x" : "value"} axis range and is not drawn.`);
882
+ return;
883
+ }
884
+ const line = vertical ? { x1: plot.x, y1: p, x2: plot.x + plot.width, y2: p } : { x1: p, y1: plot.y, x2: p, y2: plot.y + plot.height };
885
+ marks.push({ kind: "line", key: `reference-line-${i}`, role: "reference-line", ...line, stroke: color, strokeWidth: 1.25, dash: "5 3", opacity: 0.8 });
886
+ if (item.label) {
887
+ const text = truncate(item.label, (vertical ? plot.width : plot.width / 2) - 8, fs - 1, measure);
888
+ const label = vertical ? { x: plot.x + plot.width - 4, y: p - 3, anchor: "end", baseline: "bottom" } : { x: p + 4, y: plot.y + 2, anchor: "start", baseline: "top" };
889
+ marks.push({ kind: "text", key: `reference-label-line-${i}`, role: "reference-label", ...label, text, size: fs - 1, weight: 500, fill: color });
890
+ }
891
+ });
892
+ for (const axis of missing)
893
+ ctx.warn("annotations-skipped", axis === "x" ? "Reference lines and bands on the x axis need a scatter or bubble chart with numeric x values; they are not drawn." : "Reference lines and bands on the secondary axis need a series on that axis; they are not drawn.");
894
+ }
895
+ function anchorOf(mark, horizontal) {
896
+ if (mark.kind === "rect") return horizontal ? { x: mark.x + mark.width, y: mark.y + mark.height / 2 } : { x: mark.x + mark.width / 2, y: mark.y };
897
+ if (mark.kind === "circle") return { x: mark.cx, y: mark.cy - mark.r };
898
+ return null;
899
+ }
900
+ function drawCallouts(ctx, plot) {
901
+ const { spec, marks, theme, fs, measure } = ctx;
902
+ const horizontal = spec.orientation === "horizontal";
903
+ const placed = [];
904
+ const overlaps = (a) => placed.some((b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height);
905
+ let unplaced = 0;
906
+ for (const s of spec.series) {
907
+ if (s.hidden || !s.callouts) continue;
908
+ s.callouts.forEach((label, i) => {
909
+ if (!label) return;
910
+ const mark = marks.find((m) => (m.role === "bar" || m.role === "point") && m.target?.kind === "point" && m.target.seriesId === s.id && m.target.index === i);
911
+ const anchor = mark ? anchorOf(mark, horizontal && mark.kind === "rect") : null;
912
+ if (!anchor) {
913
+ unplaced++;
914
+ return;
915
+ }
916
+ const size = fs - 1;
917
+ const text = truncate(label, plot.width * 0.6, size, measure);
918
+ const w = measure(text, size, 600) + 10;
919
+ const h = size + 8;
920
+ const sideways = horizontal && mark.kind === "rect";
921
+ let bx = sideways ? anchor.x + 14 : anchor.x - w / 2;
922
+ let by = sideways ? anchor.y - h / 2 : anchor.y - h - 14;
923
+ const clamp = () => {
924
+ bx = Math.max(plot.x, Math.min(plot.x + plot.width - w, bx));
925
+ by = Math.max(plot.y - fs / 2, Math.min(plot.y + plot.height - h, by));
926
+ };
927
+ clamp();
928
+ const covers = anchor.x >= bx - 2 && anchor.x <= bx + w + 2 && anchor.y >= by - 2 && anchor.y <= by + h + 2;
929
+ if (covers) {
930
+ if (sideways) bx = anchor.x - w - 14;
931
+ else by = anchor.y + 14;
932
+ clamp();
933
+ }
934
+ for (let k = 0; k < 6 && overlaps({ x: bx, y: by, width: w, height: h }); k++) {
935
+ if (sideways) by += h + 2;
936
+ else by -= h + 2;
937
+ clamp();
938
+ }
939
+ const box = { x: bx, y: by, width: w, height: h };
940
+ placed.push(box);
941
+ const target = { kind: "point", seriesId: s.id, index: i };
942
+ const ex = Math.max(bx, Math.min(bx + w, anchor.x));
943
+ const ey = Math.max(by, Math.min(by + h, anchor.y));
944
+ if (Math.hypot(ex - anchor.x, ey - anchor.y) > 2) marks.push({ kind: "line", key: `callout-leader-${s.id}-${i}`, role: "callout", x1: anchor.x, y1: anchor.y, x2: ex, y2: ey, stroke: theme.axis, strokeWidth: 1 });
945
+ marks.push({ kind: "rect", key: `callout-box-${s.id}-${i}`, role: "callout", ...box, fill: theme.surface, stroke: theme.axis, strokeWidth: 1, radius: 3, target });
946
+ marks.push({ kind: "text", key: `callout-${s.id}-${i}`, role: "callout", x: bx + w / 2, y: by + h / 2, text, anchor: "middle", baseline: "middle", size, weight: 600, fill: theme.text, target });
947
+ });
948
+ }
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
+ }
951
+ var MUTED = /* @__PURE__ */ new Set(["bar", "line", "area", "point", "slice", "cell", "box", "whisker", "outlier", "connector", "trendline", "trendline-label", "error-bar", "data-label", "legend-swatch", "legend-label"]);
952
+ function applyHighlight(ctx) {
953
+ const { spec, marks } = ctx;
954
+ if (!spec.highlight.length) return;
955
+ const names = new Set(spec.highlight);
956
+ const categories = /* @__PURE__ */ new Set();
957
+ spec.categories.forEach((c, i) => names.has(c) && categories.add(i));
958
+ const series = new Set(spec.series.filter((s) => names.has(s.id) || names.has(s.name)).map((s) => s.id));
959
+ const unmatched = spec.highlight.filter((h) => !spec.categories.includes(h) && !spec.series.some((s) => s.id === h || s.name === h));
960
+ if (unmatched.length) ctx.warn("highlight-unmatched", `Highlight names ${unmatched.map((u) => `"${u}"`).join(", ")}, which ${unmatched.length === 1 ? "is" : "are"} neither a category nor a series.`);
961
+ if (!categories.size && !series.size) return;
962
+ const bright = (t) => {
963
+ switch (t.kind) {
964
+ case "point":
965
+ return series.has(t.seriesId) || categories.has(t.index);
966
+ case "series":
967
+ case "trendline":
968
+ return !series.size || series.has(t.seriesId);
969
+ case "category":
970
+ return !categories.size || categories.has(t.index);
971
+ default:
972
+ return true;
973
+ }
974
+ };
975
+ for (let k = 0; k < marks.length; k++) {
976
+ const m = marks[k];
977
+ if (!m.target || !MUTED.has(m.role) || bright(m.target)) continue;
978
+ marks[k] = { ...m, opacity: (m.opacity ?? 1) * 0.25 };
979
+ }
980
+ }
981
+ var noteHeight = (spec, fs) => spec.note ? fs * 1.5 : 0;
982
+ function drawNote(ctx, pad) {
983
+ const { spec, marks, theme, fs, measure, width, height } = ctx;
984
+ if (!spec.note) return;
985
+ const text = truncate(spec.note, width - pad * 2, fs - 1, measure);
986
+ if (text !== spec.note) ctx.warn("labels-truncated", "The note was shortened to fit the width.");
987
+ marks.push({ kind: "text", key: "note", role: "note", x: pad, y: height - pad + 2, text, anchor: "start", baseline: "bottom", size: fs - 1, fill: theme.mutedText });
988
+ }
989
+
825
990
  // src/chart/layout-cartesian.ts
826
991
  var MAX_CHART_DATA_LABELS = 200;
827
992
  var MAX_CHART_LINE_POINTS = 2e3;
@@ -1137,6 +1302,7 @@ function layoutCartesian(ctx, area, visible) {
1137
1302
  else s.values.forEach((v) => v !== null && see(v));
1138
1303
  errors.get(s.id)?.forEach((e) => e && (see(e.lo), see(e.hi)));
1139
1304
  }
1305
+ for (const v of referenceValues(spec, secondary ? "y2" : "y")) see(v);
1140
1306
  if (log) {
1141
1307
  const skipped = group.reduce((t, s) => t + s.values.filter((v) => v !== null && Number.isFinite(v) && v <= 0).length, 0);
1142
1308
  if (skipped) ctx.warn("values-skipped", `${skipped} value${skipped === 1 ? "" : "s"} of 0 or less cannot be drawn on the logarithmic ${secondary ? "secondary " : ""}value axis and ${skipped === 1 ? "was" : "were"} skipped.`);
@@ -1163,6 +1329,8 @@ function layoutCartesian(ctx, area, visible) {
1163
1329
  });
1164
1330
  const { plot, band, catCenter, map, catStart } = frame;
1165
1331
  const infoOf = (s) => onSecondary(s) && frame.value2 ? frame.value2 : frame.value;
1332
+ const refAxes = { y: { info: frame.value, vertical: !horizontal }, ...frame.value2 ? { y2: { info: frame.value2, vertical: !horizontal } } : {} };
1333
+ drawReferences(ctx, plot, refAxes, "bands");
1166
1334
  const bars = visible.filter((s) => types.get(s.id) === "bar");
1167
1335
  const barAxes = [...new Set(bars.map((s) => onSecondary(s)))];
1168
1336
  const slots = stacked ? barAxes.length : bars.length;
@@ -1327,6 +1495,7 @@ function layoutCartesian(ctx, area, visible) {
1327
1495
  drawTrendlines(ctx, plot, s, color, points, toPx, trendLabels, !!info.logBase);
1328
1496
  }
1329
1497
  }
1498
+ drawReferences(ctx, plot, refAxes, "lines");
1330
1499
  marks.push(...errorMarks, ...labelMarks);
1331
1500
  drawTrendLabels(ctx, plot, trendLabels);
1332
1501
  if (hiddenLabels) ctx.warn("data-labels-hidden", `${hiddenLabels} data label${hiddenLabels === 1 ? "" : "s"} did not fit and ${hiddenLabels === 1 ? "is" : "are"} hidden.`);
@@ -1389,6 +1558,8 @@ function layoutXy(ctx, area, visible) {
1389
1558
  seeX(Math.max(...pts.map((p) => p.x)) + (t.forward ?? 0));
1390
1559
  }
1391
1560
  }
1561
+ for (const v of referenceValues(spec, "y")) seeY(v);
1562
+ for (const v of referenceValues(spec, "x")) seeX(v);
1392
1563
  if (!Number.isFinite(xlo)) [xlo, xhi] = xLog ? [1, 10] : [0, 1];
1393
1564
  if (!Number.isFinite(ylo)) [ylo, yhi] = yLog ? [1, 10] : [0, 1];
1394
1565
  const titleBand = fs * 1.3 + 6;
@@ -1430,6 +1601,8 @@ function layoutXy(ctx, area, visible) {
1430
1601
  if (!ctx.isSelected({ kind: "axis", axis })) continue;
1431
1602
  ctx.outline(`axis-${axis}-selection`, axis === "y" ? { x: area.x, y: plot.y - fs / 2, width: left - 2, height: plot.height + fs } : { x: plot.x - 4, y: plot.y + plot.height + 2, width: plot.width + 8, height: area.y + area.height - plot.y - plot.height - 2 });
1432
1603
  }
1604
+ const refAxes = { y: { info: yInfo, vertical: true }, x: { info: xInfo, vertical: false } };
1605
+ drawReferences(ctx, plot, refAxes, "bands");
1433
1606
  const labelMarks = [];
1434
1607
  const errorMarks = [];
1435
1608
  const trendLabels = [];
@@ -1488,6 +1661,7 @@ function layoutXy(ctx, area, visible) {
1488
1661
  }
1489
1662
  if (s.trendlines?.length) drawTrendlines(ctx, plot, s, color, pts.map((p) => [p.x, p.y]), (x, y) => [xRaw(x), yRaw(y)], trendLabels, xLog || yLog);
1490
1663
  }
1664
+ drawReferences(ctx, plot, refAxes, "lines");
1491
1665
  marks.push(...errorMarks, ...labelMarks);
1492
1666
  drawTrendLabels(ctx, plot, trendLabels);
1493
1667
  return {
@@ -1843,15 +2017,348 @@ function layoutTreemap(ctx, area, series) {
1843
2017
  if (unlabelled) ctx.warn("data-labels-hidden", `${unlabelled} treemap cell${unlabelled === 1 ? " is" : "s are"} too small for a label.`);
1844
2018
  return { plot: area };
1845
2019
  }
2020
+ function dodge(ys, gap, top, bottom) {
2021
+ const out = [...ys];
2022
+ for (let k = 1; k < out.length; k++) out[k] = Math.max(out[k], out[k - 1] + gap);
2023
+ const over = out.length ? out[out.length - 1] - bottom : 0;
2024
+ if (over > 0) for (let k = out.length - 1; k >= 0; k--) out[k] = Math.max(top, Math.min(out[k] - over, k < out.length - 1 ? out[k + 1] - gap : Infinity));
2025
+ return out;
2026
+ }
2027
+ function layoutSlope(ctx, area, visible) {
2028
+ const { spec, theme, marks, fs, measure } = ctx;
2029
+ const [a, b] = visible;
2030
+ if (visible.length > 2) ctx.warn("values-skipped", `A slope chart joins two series; ${visible.length - 2} more ${visible.length === 3 ? "is" : "are"} not drawn.`);
2031
+ const at = (s, i) => {
2032
+ const v = s.values[i];
2033
+ return v !== null && Number.isFinite(v) ? v : null;
2034
+ };
2035
+ const all = spec.categories.flatMap((_, i) => [at(a, i), at(b, i)]).filter((v) => v !== null);
2036
+ const lo = Math.min(...all);
2037
+ const hi = Math.max(...all);
2038
+ const labelsOn = a.dataLabels ?? b.dataLabels ?? spec.dataLabels;
2039
+ const fmtA = (v) => formatChartNumber(v, a.format ?? spec.yAxis.format);
2040
+ const fmtB = (v) => formatChartNumber(v, b.format ?? spec.yAxis.format);
2041
+ const leftText = (i) => {
2042
+ const v = at(a, i);
2043
+ return labelsOn && v !== null ? `${spec.categories[i]} ${fmtA(v)}` : spec.categories[i];
2044
+ };
2045
+ const rightText = (i) => {
2046
+ const v = at(b, i);
2047
+ return labelsOn && v !== null ? `${fmtB(v)} ${spec.categories[i]}` : spec.categories[i];
2048
+ };
2049
+ const maxW = area.width * 0.3;
2050
+ const leftW = Math.min(maxW, Math.max(0, ...spec.categories.map((_, i) => measure(leftText(i), fs))));
2051
+ const rightW = Math.min(maxW, Math.max(0, ...spec.categories.map((_, i) => measure(rightText(i), fs))));
2052
+ const headerH = fs * 1.8;
2053
+ const plot = { x: area.x + leftW + 10, y: area.y + headerH + fs / 2, width: Math.max(1, area.width - leftW - rightW - 20), height: Math.max(1, area.height - headerH - fs) };
2054
+ const scale = axisScale(lo, hi, plot.height, spec.yAxis, ctx.options.valueDomain, true);
2055
+ const yOf = (v) => linearMap(Math.min(scale.max, Math.max(scale.min, v)), [scale.min, scale.max], [plot.y + plot.height, plot.y]);
2056
+ const xL = plot.x;
2057
+ const xR = plot.x + plot.width;
2058
+ for (const [s, x, anchor] of [[a, xL, "end"], [b, xR, "start"]]) {
2059
+ const target = { kind: "series", seriesId: s.id };
2060
+ const selected = ctx.seriesSelected(s.id);
2061
+ marks.push({ kind: "line", key: `axis-slope-${s.id}`, role: "axis", x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height, stroke: theme.axis, strokeWidth: 1, target });
2062
+ marks.push({ kind: "text", key: `axis-title-${s.id}`, role: "axis-title", x, y: area.y, text: truncate(s.name, maxW + plot.width / 2, fs + 1, measure), anchor: anchor === "end" ? "end" : "start", baseline: "top", size: fs + 1, weight: 600, fill: selected ? theme.selection : theme.text, target, selected });
2063
+ }
2064
+ const leftLabels = [];
2065
+ const rightLabels = [];
2066
+ spec.categories.forEach((_, i) => {
2067
+ const va = at(a, i);
2068
+ const vb = at(b, i);
2069
+ const color = sliceColor(ctx, a, i);
2070
+ const catTarget = { kind: "category", index: i };
2071
+ const catSelected = ctx.isSelected(catTarget);
2072
+ if (va !== null && vb !== null)
2073
+ marks.push({ kind: "line", key: `line-slope-${i}`, role: "line", x1: xL, y1: yOf(va), x2: xR, y2: yOf(vb), stroke: color, strokeWidth: catSelected ? 3.5 : 2, target: catTarget, selected: catSelected });
2074
+ for (const [s, v, x, labels] of [[a, va, xL, leftLabels], [b, vb, xR, rightLabels]]) {
2075
+ if (v === null) continue;
2076
+ const target = { kind: "point", seriesId: s.id, index: i };
2077
+ const selected = ctx.isSelected(target);
2078
+ marks.push({ kind: "circle", key: `point-${s.id}-${i}`, role: "point", cx: x, cy: yOf(v), r: 4, fill: color, stroke: theme.surface, strokeWidth: 1.5, target, selected: selected || catSelected || ctx.seriesSelected(s.id) });
2079
+ if (selected) marks.push({ kind: "circle", key: `point-${s.id}-${i}-selection`, role: "selection", cx: x, cy: yOf(v), r: 7.5, fill: "none", stroke: theme.selection, strokeWidth: 1.5 });
2080
+ labels.push({ i, y: yOf(v) });
2081
+ }
2082
+ });
2083
+ let moved = 0;
2084
+ for (const [labels, x, anchor, text, width] of [[leftLabels, xL - 8, "end", leftText, leftW], [rightLabels, xR + 8, "start", rightText, rightW]]) {
2085
+ labels.sort((p, q) => p.y - q.y);
2086
+ const ys = dodge(labels.map((l) => l.y), fs * 1.15, plot.y - fs / 2, plot.y + plot.height + fs / 2);
2087
+ labels.forEach((l, k) => {
2088
+ if (Math.abs(ys[k] - l.y) > 0.5) moved++;
2089
+ const target = { kind: "category", index: l.i };
2090
+ const selected = ctx.isSelected(target);
2091
+ marks.push({ kind: "text", key: `label-slope-${anchor === "end" ? "left" : "right"}-${l.i}`, role: "data-label", x, y: ys[k], text: truncate(text(l.i), width, fs, measure), anchor, baseline: "middle", size: fs, fill: selected ? theme.selection : theme.text, weight: selected ? 600 : void 0, target, selected });
2092
+ });
2093
+ }
2094
+ if (moved) ctx.warn("labels-truncated", `${moved} slope label${moved === 1 ? " was" : "s were"} moved off its point so the labels do not overlap.`);
2095
+ return { plot, valueScale: { axis: "y", domain: [scale.min, scale.max], range: [plot.y + plot.height, plot.y], ticks: scale.ticks } };
2096
+ }
2097
+ function layoutLikert(ctx, area, visible) {
2098
+ const { spec, theme, marks, fs, measure } = ctx;
2099
+ const n = visible.length;
2100
+ const side = Math.floor(n / 2);
2101
+ const neutral = n % 2 === 1 ? side : -1;
2102
+ const separate = neutral >= 0 && spec.likertNeutral === "separate";
2103
+ if (spec.likertNeutral === "separate" && neutral < 0) ctx.warn("type-fallback", "A separate neutral column needs an odd number of answers; the middle answer is not set apart.");
2104
+ const shares = spec.categories.map((_, i) => {
2105
+ const raw = visible.map((s) => {
2106
+ const v = s.values[i];
2107
+ return v !== null && Number.isFinite(v) ? Math.max(0, v) : 0;
2108
+ });
2109
+ const total = raw.reduce((t, v) => t + v, 0);
2110
+ return raw.map((v) => total ? v / total : 0);
2111
+ });
2112
+ const sum = (p, from, to) => p.slice(from, to).reduce((t, v) => t + v, 0);
2113
+ const negOf = (p) => sum(p, 0, side) + (neutral >= 0 && !separate ? p[neutral] / 2 : 0);
2114
+ const posOf = (p) => sum(p, n - side, n) + (neutral >= 0 && !separate ? p[neutral] / 2 : 0);
2115
+ const rows = spec.categories.map((_, i) => i);
2116
+ if (spec.likertSort === "positive") rows.sort((p, q) => sum(shares[q], n - side, n) - sum(shares[p], n - side, n));
2117
+ else if (spec.likertSort === "negative") rows.sort((p, q) => sum(shares[q], 0, side) - sum(shares[p], 0, side));
2118
+ const extent = Math.max(0.01, ...shares.map((p) => Math.max(negOf(p), posOf(p))));
2119
+ const labelW = spec.xAxis.show ? Math.min(area.width * 0.35, Math.max(0, ...spec.categories.map((c) => measure(c, fs)))) + 10 : 4;
2120
+ const neutralW = separate ? Math.max(40, area.width * 0.18) : 0;
2121
+ const axisH = spec.yAxis.show ? fs * 1.6 : 2;
2122
+ const headH = separate ? fs * 1.5 : 0;
2123
+ const plot = { x: area.x + labelW, y: area.y + headH, width: Math.max(1, area.width - labelW - (separate ? neutralW + 14 : 0)), height: Math.max(1, area.height - axisH - headH) };
2124
+ const half = axisScale(0, extent, plot.width / 2, { ...spec.yAxis, min: 0, max: null, scale: void 0, reverse: void 0 });
2125
+ const m = half.max;
2126
+ const xOf = (v) => plot.x + (v + m) / (2 * m) * plot.width;
2127
+ const percent = { style: "percent", decimals: spec.yAxis.format.decimals };
2128
+ const ticks = [...half.ticks.slice(1).map((t) => -t).reverse(), ...half.ticks];
2129
+ const axisTarget = { kind: "axis", axis: "y" };
2130
+ ticks.forEach((t, k) => {
2131
+ const x = xOf(t);
2132
+ if (spec.yAxis.gridlines && t !== 0) marks.push({ kind: "line", key: `grid-y-${k}`, role: "grid", x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height, stroke: theme.grid, strokeWidth: 1 });
2133
+ if (spec.yAxis.show) marks.push({ kind: "text", key: `tick-y-${k}`, role: "tick-label", x, y: plot.y + plot.height + 6, text: formatChartNumber(Math.abs(t), percent, percent.decimals ?? stepDecimals(half.step * 100)), anchor: "middle", baseline: "top", size: fs, fill: theme.mutedText, target: axisTarget });
2134
+ });
2135
+ const band = plot.height / Math.max(1, rows.length);
2136
+ const barH = Math.max(2, band * (1 - Math.min(0.9, spec.gapWidth)));
2137
+ const neutralMax = separate ? Math.max(0.01, ...shares.map((p) => p[neutral])) : 1;
2138
+ const neutralX = plot.x + plot.width + 14;
2139
+ if (separate) {
2140
+ const s = visible[neutral];
2141
+ marks.push({ kind: "text", key: "likert-neutral-title", role: "axis-title", x: neutralX, y: area.y, text: truncate(s.name, neutralW, fs, measure), anchor: "start", baseline: "top", size: fs, weight: 600, fill: theme.mutedText, target: { kind: "series", seriesId: s.id } });
2142
+ }
2143
+ let hidden = 0;
2144
+ rows.forEach((i, r) => {
2145
+ const p = shares[i];
2146
+ const y = plot.y + r * band + (band - barH) / 2;
2147
+ const catTarget = { kind: "category", index: i };
2148
+ const catSelected = ctx.isSelected(catTarget);
2149
+ if (spec.xAxis.show)
2150
+ marks.push({ kind: "text", key: `tick-x-${i}`, role: "tick-label", x: plot.x - 8, y: y + barH / 2, text: truncate(spec.categories[i], labelW - 10, fs, measure), anchor: "end", baseline: "middle", size: fs, fill: catSelected ? theme.selection : theme.mutedText, target: catTarget, selected: catSelected });
2151
+ const segment = (j, x0, x1, key) => {
2152
+ const s = visible[j];
2153
+ if (x1 - x0 <= 0) return;
2154
+ const target = { kind: "point", seriesId: s.id, index: i };
2155
+ const selected = ctx.isSelected(target);
2156
+ const fill = s.pointColors?.[i] || chartSeriesColor(spec, spec.series.indexOf(s));
2157
+ const rect = { x: x0, y, width: x1 - x0, height: barH };
2158
+ marks.push({ kind: "rect", key, role: "bar", ...rect, fill, target, selected: selected || ctx.seriesSelected(s.id) || catSelected });
2159
+ if (selected) ctx.outline(`${key}-selection`, { x: rect.x - 2, y: rect.y - 2, width: rect.width + 4, height: rect.height + 4 });
2160
+ if (!(s.dataLabels ?? spec.dataLabels)) return;
2161
+ const text = formatChartNumber(p[j], percent, percent.decimals ?? 0);
2162
+ if (measure(text, fs - 1) + 6 > rect.width || barH < fs) {
2163
+ hidden++;
2164
+ return;
2165
+ }
2166
+ marks.push({ kind: "text", key: `label-${s.id}-${i}`, role: "data-label", x: rect.x + rect.width / 2, y: y + barH / 2, text, anchor: "middle", baseline: "middle", size: fs - 1, fill: contrastingTextColor(fill), target });
2167
+ };
2168
+ let cursor = -negOf(p);
2169
+ visible.forEach((s, j) => {
2170
+ if (separate && j === neutral) return;
2171
+ segment(j, xOf(cursor), xOf(cursor + p[j]), `bar-${s.id}-${i}`);
2172
+ cursor += p[j];
2173
+ });
2174
+ if (separate) segment(neutral, neutralX, neutralX + p[neutral] / neutralMax * neutralW, `bar-${visible[neutral].id}-${i}`);
2175
+ });
2176
+ marks.push({ kind: "line", key: "axis-likert-centre", role: "axis", x1: xOf(0), y1: plot.y, x2: xOf(0), y2: plot.y + plot.height, stroke: theme.axis, strokeWidth: 1.25, target: axisTarget });
2177
+ if (hidden) ctx.warn("data-labels-hidden", `${hidden} data label${hidden === 1 ? "" : "s"} did not fit and ${hidden === 1 ? "is" : "are"} hidden.`);
2178
+ return { plot, percent: true };
2179
+ }
2180
+ function layoutParliament(ctx, area, series) {
2181
+ const { spec, theme, marks, fs } = ctx;
2182
+ const seats = spec.categories.map((_, i) => {
2183
+ const v = series.values[i];
2184
+ return v !== null && Number.isFinite(v) ? Math.max(0, Math.round(v)) : 0;
2185
+ });
2186
+ if (series.values.some((v) => v !== null && Number.isFinite(v) && (v < 0 || !Number.isInteger(v)))) ctx.warn("values-skipped", "Seats are whole numbers of 0 or more; other values were rounded.");
2187
+ const total = seats.reduce((t, v) => t + v, 0);
2188
+ const empty = (text) => {
2189
+ marks.push({ kind: "text", key: "empty", role: "empty", x: area.x + area.width / 2, y: area.y + area.height / 2, text, anchor: "middle", baseline: "middle", size: fs + 1, fill: theme.mutedText });
2190
+ return { plot: area };
2191
+ };
2192
+ if (!total) return empty("No seats");
2193
+ if (total > 2e3) return empty("Too many seats to draw (more than 2,000)");
2194
+ const arc = spec.parliamentArc * Math.PI / 180;
2195
+ const start = Math.PI / 2 + arc / 2;
2196
+ const angles = [start, start - arc, ...[0, 0.5, 1, 1.5, 2, 2.5].map((k) => k * Math.PI).filter((t) => t < start && t > start - arc)];
2197
+ const xs = [0, ...angles.map(Math.cos)];
2198
+ const ys = [0, ...angles.map(Math.sin)];
2199
+ const pad = fs * 1.2;
2200
+ const radius = Math.max(8, Math.min((area.width - pad * 2) / (Math.max(...xs) - Math.min(...xs)), (area.height - pad * 2) / (Math.max(...ys) - Math.min(...ys))));
2201
+ const cx = area.x + area.width / 2 - (Math.max(...xs) + Math.min(...xs)) / 2 * radius;
2202
+ const cy = area.y + area.height / 2 + (Math.max(...ys) + Math.min(...ys)) / 2 * radius;
2203
+ const inner = radius * 0.4;
2204
+ let rows = 1;
2205
+ for (; rows < 60; rows++) {
2206
+ const gap2 = (radius - inner) / rows;
2207
+ const length = Array.from({ length: rows }, (_, k) => (inner + gap2 * (k + 0.5)) * arc).reduce((t, l) => t + l, 0);
2208
+ if (length / total >= gap2 * 0.9) break;
2209
+ }
2210
+ const gap = (radius - inner) / rows;
2211
+ const radii = Array.from({ length: rows }, (_, k) => inner + gap * (k + 0.5));
2212
+ const lengths = radii.map((r) => r * arc);
2213
+ const all = lengths.reduce((t, l) => t + l, 0);
2214
+ const exact = lengths.map((l) => l / all * total);
2215
+ const counts = exact.map(Math.floor);
2216
+ exact.map((e, k) => ({ k, rest: e - counts[k] })).sort((p, q) => q.rest - p.rest).slice(0, total - counts.reduce((t, c) => t + c, 0)).forEach(({ k }) => counts[k]++);
2217
+ const places = [];
2218
+ radii.forEach((r, k) => {
2219
+ for (let j = 0; j < counts[k]; j++) places.push({ angle: counts[k] === 1 ? Math.PI / 2 : start - arc * (j + 0.5) / counts[k], r });
2220
+ });
2221
+ places.sort((p, q) => q.angle - p.angle || p.r - q.r);
2222
+ const dot = Math.max(1, Math.min(gap, all / total) * 0.42);
2223
+ let next = 0;
2224
+ seats.forEach((count, i) => {
2225
+ const target = { kind: "point", seriesId: series.id, index: i };
2226
+ const selected = ctx.isSelected(target) || ctx.isSelected({ kind: "category", index: i }) || ctx.seriesSelected(series.id);
2227
+ const fill = sliceColor(ctx, series, i);
2228
+ for (let k = 0; k < count; k++, next++) {
2229
+ const { angle, r } = places[next];
2230
+ marks.push({ kind: "circle", key: `seat-${i}-${k}`, role: "point", cx: cx + r * Math.cos(angle), cy: cy - r * Math.sin(angle), r: dot, fill, ...selected ? { stroke: theme.selection, strokeWidth: 1.5 } : {}, target, selected });
2231
+ }
2232
+ });
2233
+ const majority = spec.parliamentMajority ?? Math.floor(total / 2) + 1;
2234
+ if (majority <= total) {
2235
+ const angle = start - arc * (majority / total);
2236
+ const r0 = inner - gap * 0.3;
2237
+ const r1 = radius + gap * 0.3;
2238
+ marks.push({ kind: "line", key: "majority-line", role: "reference-line", x1: cx + r0 * Math.cos(angle), y1: cy - r0 * Math.sin(angle), x2: cx + r1 * Math.cos(angle), y2: cy - r1 * Math.sin(angle), stroke: theme.text, strokeWidth: 1.25, dash: "4 3", opacity: 0.8 });
2239
+ const lr = r1 + fs * 0.4;
2240
+ const lx = cx + lr * Math.cos(angle);
2241
+ const ly = cy - lr * Math.sin(angle);
2242
+ marks.push({ kind: "text", key: "majority-label", role: "reference-label", x: lx, y: ly, text: `Majority ${majority}`, anchor: Math.abs(Math.cos(angle)) < 0.25 ? "middle" : Math.cos(angle) > 0 ? "start" : "end", baseline: Math.sin(angle) >= 0 ? "bottom" : "top", size: fs - 1, weight: 500, fill: theme.text });
2243
+ } else ctx.warn("values-skipped", `The majority of ${majority} is more than the ${total} seats; the majority line is not drawn.`);
2244
+ if (inner > fs * 2.2) {
2245
+ const size = Math.min(theme.titleSize + 8, inner * 0.6);
2246
+ const ty = spec.parliamentArc <= 200 ? cy - size * 0.2 : cy;
2247
+ const totalTarget = { kind: "series", seriesId: series.id };
2248
+ marks.push({ kind: "text", key: "parliament-total", role: "data-label", x: cx, y: ty, text: formatChartNumber(total, {}), anchor: "middle", baseline: spec.parliamentArc <= 200 ? "bottom" : "middle", size, weight: 700, fill: theme.text, target: totalTarget });
2249
+ marks.push({ kind: "text", key: "parliament-caption", role: "data-label", x: cx, y: spec.parliamentArc <= 200 ? ty + 2 : ty + size * 0.6, text: spec.parliamentLabel || "seats", anchor: "middle", baseline: "top", size: fs, fill: theme.mutedText, target: totalTarget });
2250
+ }
2251
+ return { plot: { x: cx - radius, y: cy - radius, width: radius * 2, height: radius * 2 } };
2252
+ }
2253
+ function treeColor(ctx, series, node, group, base, shade) {
2254
+ if (node.color) return node.color;
2255
+ if (base === null) return series?.pointColors?.[group] || chartSliceColor(ctx.spec, group);
2256
+ return shadeChartColor(base, shade);
2257
+ }
2258
+ function layoutTreemapTree(ctx, area, series) {
2259
+ const { spec, marks, theme, fs, measure } = ctx;
2260
+ const format = series?.format ?? spec.yAxis.format;
2261
+ const labelsOn = series?.dataLabels ?? true;
2262
+ let unlabelled = 0;
2263
+ const draw = (nodes, rect, key, group, base) => {
2264
+ const rects = chartSquarify(nodes.map(chartTreeTotal), rect);
2265
+ nodes.forEach((node, k) => {
2266
+ const r = rects[k];
2267
+ if (r.width <= 0.5 || r.height <= 0.5) return;
2268
+ const g = group ?? k;
2269
+ const target = { kind: "category", index: g };
2270
+ const selected = ctx.isSelected(target) || ctx.isSelected({ kind: "point", seriesId: series?.id ?? "", index: g });
2271
+ const id = `${key}-${k}`;
2272
+ const fill = treeColor(ctx, series, node, g, base, base === null ? 0 : (k % 4 - 1.5) * 0.14);
2273
+ if (node.children) {
2274
+ const header = r.height > fs * 3.2 && r.width > fs * 3 ? fs * 1.5 : 0;
2275
+ marks.push({ kind: "rect", key: `group-${id}`, role: "cell", ...r, fill, stroke: selected ? theme.selection : theme.surface, strokeWidth: selected ? 2.5 : 2, target, selected });
2276
+ if (header) marks.push({ kind: "text", key: `label-group-${id}`, role: "data-label", x: r.x + 5, y: r.y + 4, text: truncate(node.name, r.width - 10, fs, measure), anchor: "start", baseline: "top", size: fs, weight: 700, fill: contrastingTextColor(fill), target });
2277
+ const inner = { x: r.x + 2, y: r.y + (header || 2), width: Math.max(0, r.width - 4), height: Math.max(0, r.height - (header || 2) - 2) };
2278
+ draw(node.children, inner, id, g, base ?? fill);
2279
+ return;
2280
+ }
2281
+ marks.push({ kind: "rect", key: `cell-${id}`, role: "cell", ...r, fill, stroke: selected ? theme.selection : theme.surface, strokeWidth: selected ? 2.5 : 1, target, selected });
2282
+ if (!labelsOn) return;
2283
+ const room = r.width - 8;
2284
+ if (room < fs * 2 || r.height < fs * 1.5) {
2285
+ unlabelled++;
2286
+ return;
2287
+ }
2288
+ const color = contrastingTextColor(fill);
2289
+ marks.push({ kind: "text", key: `label-cell-${id}`, role: "data-label", x: r.x + 4, y: r.y + 4, text: truncate(node.name, room, fs, measure), anchor: "start", baseline: "top", size: fs, weight: 600, fill: color, target });
2290
+ if (r.height >= fs * 3) marks.push({ kind: "text", key: `label-value-${id}`, role: "data-label", x: r.x + 4, y: r.y + 6 + fs * 1.2, text: truncate(formatChartNumber(node.value ?? 0, format), room, fs - 1, measure), anchor: "start", baseline: "top", size: fs - 1, fill: color, target });
2291
+ });
2292
+ };
2293
+ draw(spec.tree, area, "n", null, null);
2294
+ if (unlabelled) ctx.warn("data-labels-hidden", `${unlabelled} treemap cell${unlabelled === 1 ? " is" : "s are"} too small for a label.`);
2295
+ return { plot: area };
2296
+ }
2297
+ function layoutSunburst(ctx, area, series) {
2298
+ const { spec, marks, theme, fs, measure } = ctx;
2299
+ const tree = spec.tree.length ? spec.tree : chartTreeFromSeries(spec.categories, series?.values ?? []);
2300
+ const indexOf = new Map(spec.categories.map((c, i) => [c.trim() || `${i + 1}`, i]));
2301
+ const depth = chartTreeDepth(tree);
2302
+ const total = tree.reduce((t, n) => t + chartTreeTotal(n), 0);
2303
+ const cx = area.x + area.width / 2;
2304
+ const cy = area.y + area.height / 2;
2305
+ const outer = Math.max(8, Math.min(area.width, area.height) / 2 - 4);
2306
+ const plot = { x: cx - outer, y: cy - outer, width: outer * 2, height: outer * 2 };
2307
+ if (!(total > 0)) {
2308
+ marks.push({ kind: "text", key: "empty", role: "empty", x: cx, y: cy, text: "No positive values", anchor: "middle", baseline: "middle", size: fs + 1, fill: theme.mutedText });
2309
+ return { plot };
2310
+ }
2311
+ const hole = outer * 0.28;
2312
+ const ring = (outer - hole) / Math.max(1, depth);
2313
+ const format = series?.format ?? spec.yAxis.format;
2314
+ let unlabelled = 0;
2315
+ const walk = (nodes, start, sweep, level, key, group, base) => {
2316
+ const sum = nodes.reduce((t, n) => t + chartTreeTotal(n), 0);
2317
+ let at = start;
2318
+ nodes.forEach((node, k) => {
2319
+ const value = chartTreeTotal(node);
2320
+ if (!(value > 0) || !sum) return;
2321
+ const a0 = at;
2322
+ const a1 = at + sweep * value / sum;
2323
+ at = a1;
2324
+ const g = group ?? indexOf.get(node.name) ?? k;
2325
+ const target = { kind: "category", index: g };
2326
+ const selected = ctx.isSelected(target) || ctx.isSelected({ kind: "point", seriesId: series?.id ?? "", index: g });
2327
+ const fill = treeColor(ctx, series, node, g, base, 0.22 * level);
2328
+ const id = `${key}-${k}`;
2329
+ const r0 = hole + ring * level;
2330
+ const r1 = r0 + ring;
2331
+ marks.push({ kind: "path", key: `slice-${id}`, role: "slice", d: arcPath(cx, cy, r1, r0, a0, a1), fill, stroke: selected ? theme.selection : theme.surface, strokeWidth: selected ? 2 : 1, target, selected });
2332
+ const mid = (a0 + a1) / 2;
2333
+ const rm = (r0 + r1) / 2;
2334
+ const text = node.name;
2335
+ const width = measure(text, fs - 1);
2336
+ if (ring >= fs * 1.3 && 2 * rm * Math.sin(Math.min(Math.PI / 2, (a1 - a0) / 2)) >= width + 6) marks.push({ kind: "text", key: `label-slice-${id}`, role: "data-label", x: cx + rm * Math.sin(mid), y: cy - rm * Math.cos(mid), text, anchor: "middle", baseline: "middle", size: fs - 1, weight: level === 0 ? 600 : void 0, fill: contrastingTextColor(fill), target });
2337
+ else unlabelled++;
2338
+ if (node.children) walk(node.children, a0, a1 - a0, level + 1, id, g, base ?? fill);
2339
+ });
2340
+ };
2341
+ walk(tree, 0, Math.PI * 2, 0, "n", null, null);
2342
+ if (unlabelled) ctx.warn("data-labels-hidden", `${unlabelled} sunburst arc${unlabelled === 1 ? " is" : "s are"} too narrow for a label.`);
2343
+ if (hole > fs * 2) {
2344
+ const size = Math.min(theme.titleSize + 4, hole * 0.45);
2345
+ const centre = series ? { kind: "series", seriesId: series.id } : void 0;
2346
+ const caption = spec.sunburstLabel;
2347
+ marks.push({ kind: "text", key: "sunburst-total", role: "data-label", x: cx, y: caption ? cy - 2 : cy, text: formatChartNumber(total, format), anchor: "middle", baseline: caption ? "bottom" : "middle", size, weight: 700, fill: theme.text, ...centre ? { target: centre } : {} });
2348
+ if (caption) marks.push({ kind: "text", key: "sunburst-caption", role: "data-label", x: cx, y: cy + 2, text: truncate(caption, hole * 1.8, fs - 1, measure), anchor: "middle", baseline: "top", size: fs - 1, fill: theme.mutedText, ...centre ? { target: centre } : {} });
2349
+ }
2350
+ return { plot };
2351
+ }
1846
2352
 
1847
2353
  // src/chart/layout.ts
1848
2354
  function legendItems(spec, ctx) {
1849
2355
  const family = chartFamily(spec);
1850
- if (spec.type === "funnel") return [];
1851
- if (chartColorsByCategory(spec.type)) {
2356
+ if (spec.type === "funnel" || spec.type === "slope") return [];
2357
+ if (chartSpecColorsByCategory(spec)) {
1852
2358
  const first = spec.series.find((s) => !s.hidden);
2359
+ const seats = (i) => Math.max(0, Math.round(first?.values[i] ?? 0));
1853
2360
  return spec.categories.map((c, i) => ({
1854
- label: c,
2361
+ label: spec.type === "parliament" ? `${c} ${seats(i)}` : c,
1855
2362
  color: first?.pointColors?.[i] || chartSliceColor(spec, i),
1856
2363
  target: { kind: "category", index: i },
1857
2364
  faded: false,
@@ -1931,12 +2438,19 @@ function layoutChart(spec, options) {
1931
2438
  const family = chartFamily(spec);
1932
2439
  const visible = spec.series.filter((s) => !s.hidden);
1933
2440
  const items = legendItems(spec, ctx);
1934
- let area = { x: pad, y: top, width: width - pad * 2, height: height - top - pad };
2441
+ let area = { x: pad, y: top, width: width - pad * 2, height: height - top - pad - noteHeight(spec, fs) };
1935
2442
  const seriesLegend = family === "cartesian" || family === "xy" || family === "radar" || family === "histogram";
1936
2443
  const hasTrend = items.some((i) => i.shape === "dash");
1937
- const showLegend = spec.legend.show && items.length > 0 && (!seriesLegend || spec.series.length > 1 || spec.series.some((s) => s.hidden) || hasTrend);
2444
+ const showLegend = spec.legend.show && items.length > 0 && (!seriesLegend || chartVariesColors(spec) || spec.series.length > 1 || spec.series.some((s) => s.hidden) || hasTrend);
1938
2445
  if (showLegend) area = layoutLegend(items, spec.legend.position, area, ctx, ctx.isSelected({ kind: "legend" }));
1939
2446
  const finish = (result) => {
2447
+ if (result.plot.width > 0) {
2448
+ if (chartAnnotationFamily(chartFamily(spec))) drawCallouts(ctx, result.plot);
2449
+ else if (spec.referenceLines.length || spec.referenceBands.length || spec.series.some((s) => s.callouts))
2450
+ ctx.warn("annotations-skipped", `Reference lines, bands and callouts are drawn on bar, line, area, scatter and bubble charts; this ${spec.type} chart leaves them out.`);
2451
+ }
2452
+ applyHighlight(ctx);
2453
+ drawNote(ctx, pad);
1940
2454
  if (options.readout) addReadout(ctx, options.readout, result.plot);
1941
2455
  const kept = finalizeMarks(marks, width, height);
1942
2456
  if (kept.length < marks.length) ctx.warn("values-skipped", `${marks.length - kept.length} mark${marks.length - kept.length === 1 ? " was" : "s were"} left out: their values are not finite numbers.`);
@@ -1978,7 +2492,19 @@ function layoutChart(spec, options) {
1978
2492
  case "funnel":
1979
2493
  return finish(layoutFunnel(ctx, area, visible[0]));
1980
2494
  case "treemap":
1981
- return finish(layoutTreemap(ctx, area, visible[0]));
2495
+ return finish(spec.tree.length ? layoutTreemapTree(ctx, area, spec.series[0]) : layoutTreemap(ctx, area, visible[0]));
2496
+ case "sunburst":
2497
+ return finish(layoutSunburst(ctx, area, spec.tree.length ? spec.series[0] : visible[0]));
2498
+ case "slope":
2499
+ if (visible.length < 2) {
2500
+ ctx.warn("type-fallback", "A slope chart needs two series (before and after); it is drawn as a line chart until then.");
2501
+ return finish(layoutCartesian(ctx, area, visible));
2502
+ }
2503
+ return finish(layoutSlope(ctx, area, visible));
2504
+ case "likert":
2505
+ return finish(layoutLikert(ctx, area, visible));
2506
+ case "parliament":
2507
+ return finish(layoutParliament(ctx, area, visible[0]));
1982
2508
  case "xy":
1983
2509
  return finish(layoutXy(ctx, area, visible));
1984
2510
  default:
@@ -2157,6 +2683,7 @@ export {
2157
2683
  r2,
2158
2684
  chartHistogram,
2159
2685
  chartQuantile,
2686
+ suggestChartReferences,
2160
2687
  chartBoxStats,
2161
2688
  chartSquarify,
2162
2689
  decimateChartPoints,