@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.
- package/README.md +1 -1
- package/dist/agent/index.cjs +34 -34
- package/dist/agent/index.js +5 -5
- package/dist/canvas/displacement.d.cts +34 -2
- package/dist/canvas/displacement.d.ts +34 -2
- package/dist/canvas/explosion-tools.d.cts +43 -0
- package/dist/canvas/explosion-tools.d.ts +43 -0
- package/dist/canvas/explosionLayout.d.cts +50 -0
- package/dist/canvas/explosionLayout.d.ts +50 -0
- package/dist/canvas/explosionState.d.cts +52 -0
- package/dist/canvas/explosionState.d.ts +52 -0
- package/dist/canvas/index.cjs +430 -88
- package/dist/canvas/index.d.cts +3 -0
- package/dist/canvas/index.d.ts +3 -0
- package/dist/canvas/index.js +425 -83
- package/dist/canvas/types.d.cts +27 -2
- package/dist/canvas/types.d.ts +27 -2
- package/dist/chart/commands.d.cts +2 -1
- package/dist/chart/commands.d.ts +2 -1
- package/dist/chart/controller.d.cts +109 -1
- package/dist/chart/controller.d.ts +109 -1
- package/dist/chart/index.cjs +605 -130
- package/dist/chart/index.d.cts +1 -0
- package/dist/chart/index.d.ts +1 -0
- package/dist/chart/index.js +490 -15
- package/dist/chart/layout-annotations.d.cts +29 -0
- package/dist/chart/layout-annotations.d.ts +29 -0
- package/dist/chart/layout-families.d.cts +23 -0
- package/dist/chart/layout-families.d.ts +23 -0
- package/dist/chart/mermaid.d.cts +2 -1
- package/dist/chart/mermaid.d.ts +2 -1
- package/dist/chart/schemas.d.cts +13 -0
- package/dist/chart/schemas.d.ts +13 -0
- package/dist/chart/spec.d.cts +30 -5
- package/dist/chart/spec.d.ts +30 -5
- package/dist/chart/stats.d.cts +9 -1
- package/dist/chart/stats.d.ts +9 -1
- package/dist/chart/tools.d.cts +1 -1
- package/dist/chart/tools.d.ts +1 -1
- package/dist/chart/tree.d.cts +62 -0
- package/dist/chart/tree.d.ts +62 -0
- package/dist/chart/types.d.cts +77 -3
- package/dist/chart/types.d.ts +77 -3
- package/dist/{chunk-7YBDRJXT.cjs → chunk-22KLZZGH.cjs} +8 -8
- package/dist/{chunk-HLOKB7WW.js → chunk-3NEZJCBY.js} +366 -6
- package/dist/{chunk-EVPZ5IKN.js → chunk-5FNGLV2D.js} +65 -3
- package/dist/{chunk-EQ46F75O.cjs → chunk-D54JHQK2.cjs} +51 -51
- package/dist/{chunk-NO5QQDHZ.js → chunk-DAPQGA55.js} +2 -2
- package/dist/{chunk-3IHNXRUV.cjs → chunk-GKNEJ6EN.cjs} +399 -67
- package/dist/{chunk-4NGVHHJB.cjs → chunk-NZD2MZJC.cjs} +80 -18
- package/dist/{chunk-CX6JLDFC.js → chunk-OXERWBKE.js} +3 -3
- package/dist/{chunk-7CKPUUN4.js → chunk-POR54B55.js} +61 -10
- package/dist/{chunk-LL3H5XUH.cjs → chunk-R3P2M7HU.cjs} +367 -7
- package/dist/{chunk-HDGBM7EA.cjs → chunk-SBQVEBSU.cjs} +580 -53
- package/dist/{chunk-XDCFJEU7.js → chunk-SZA42R4N.js} +346 -14
- package/dist/{chunk-GLMP22FT.js → chunk-TALQONZ5.js} +3 -3
- package/dist/{chunk-NA4UVVJ4.cjs → chunk-YGG7WDWT.cjs} +76 -25
- package/dist/{chunk-EY22AY6R.cjs → chunk-YSAGBIRW.cjs} +16 -16
- package/dist/{chunk-DTSTNRPH.js → chunk-Z4P2RBMJ.js} +536 -9
- package/dist/export/index.cjs +4 -4
- package/dist/export/index.js +3 -3
- package/dist/extensions/index.cjs +7 -7
- package/dist/extensions/index.js +6 -6
- package/dist/index.cjs +13 -13
- package/dist/index.js +7 -7
- package/dist/paint-editor/index.cjs +0 -20
- package/dist/paint-editor/index.js +0 -20
- package/package.json +1 -1
- package/skills/README.md +12 -0
- package/skills/pixerate-agent-tools/SKILL.md +34 -1
- package/skills/pixerate-canvas/SKILL.md +16 -0
- package/skills/pixerate-chart/SKILL.md +41 -9
- package/skills/pixerate-image-editor/SKILL.md +2 -0
- package/skills/pixerate-paint-editor/SKILL.md +2 -0
- 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
|
-
|
|
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 (
|
|
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,
|