@svgrid/grid 1.0.2 → 1.1.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/LICENSE +1 -1
- package/README.md +137 -39
- package/dist/GridFooter.svelte +164 -0
- package/dist/GridFooter.svelte.d.ts +29 -0
- package/dist/GridMenus.svelte +570 -0
- package/dist/GridMenus.svelte.d.ts +31 -0
- package/dist/SvGrid.controller.svelte.d.ts +429 -0
- package/dist/SvGrid.controller.svelte.js +1732 -0
- package/dist/SvGrid.css +1709 -0
- package/dist/SvGrid.helpers.d.ts +35 -0
- package/dist/SvGrid.helpers.js +160 -0
- package/dist/SvGrid.svelte +344 -7043
- package/dist/SvGrid.svelte.d.ts +4 -357
- package/dist/SvGrid.types.d.ts +436 -0
- package/dist/SvGrid.types.js +1 -0
- package/dist/SvGridChart.svelte +1060 -23
- package/dist/SvGridChart.svelte.d.ts +17 -0
- package/dist/build-api.d.ts +5 -0
- package/dist/build-api.js +527 -0
- package/dist/cell-render.d.ts +15 -0
- package/dist/cell-render.js +246 -0
- package/dist/cell-values.d.ts +28 -0
- package/dist/cell-values.js +89 -0
- package/dist/chart.d.ts +370 -3
- package/dist/chart.js +1135 -42
- package/dist/clipboard.d.ts +15 -0
- package/dist/clipboard.js +356 -0
- package/dist/columns.d.ts +30 -0
- package/dist/columns.js +277 -0
- package/dist/core.d.ts +1 -1
- package/dist/css.d.ts +3 -0
- package/dist/editing.d.ts +24 -0
- package/dist/editing.js +343 -0
- package/dist/editors/cell-editors.d.ts +1 -1
- package/dist/facet-buckets.d.ts +13 -0
- package/dist/facet-buckets.js +54 -0
- package/dist/features.d.ts +5 -0
- package/dist/features.js +30 -0
- package/dist/filter-operators.d.ts +16 -0
- package/dist/filter-operators.js +69 -0
- package/dist/hyperformula-adapter.d.ts +82 -0
- package/dist/hyperformula-adapter.js +73 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/keyboard-handlers.d.ts +7 -0
- package/dist/keyboard-handlers.js +197 -0
- package/dist/menus.d.ts +40 -0
- package/dist/menus.js +389 -0
- package/dist/named-views.d.ts +27 -0
- package/dist/named-views.js +39 -0
- package/dist/row-resize.d.ts +43 -0
- package/dist/row-resize.js +158 -0
- package/dist/scroll-sync.d.ts +9 -0
- package/dist/scroll-sync.js +86 -0
- package/dist/selection.d.ts +26 -0
- package/dist/selection.js +387 -0
- package/dist/spreadsheet.d.ts +80 -0
- package/dist/spreadsheet.js +194 -0
- package/dist/summaries.d.ts +12 -0
- package/dist/summaries.js +65 -0
- package/dist/svgrid-wrapper.types.d.ts +12 -1
- package/dist/svgrid.comments-autocomplete.test.d.ts +1 -0
- package/dist/svgrid.comments-autocomplete.test.js +96 -0
- package/dist/svgrid.context-menu.test.d.ts +1 -0
- package/dist/svgrid.context-menu.test.js +102 -0
- package/dist/svgrid.new-features.wrapper.test.js +30 -4
- package/dist/svgrid.wrapper.test.js +27 -1
- package/dist/virtualization/column-virtualizer.d.ts +2 -0
- package/dist/virtualization/scroll-scaling.d.ts +28 -0
- package/dist/virtualization/scroll-scaling.js +64 -0
- package/dist/virtualization/scroll-scaling.test.d.ts +1 -0
- package/dist/virtualization/scroll-scaling.test.js +86 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +2 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +2 -0
- package/dist/virtualization/virtualizer.d.ts +7 -0
- package/dist/virtualization/virtualizer.js +30 -0
- package/package.json +1 -1
- package/src/GridFooter.svelte +164 -0
- package/src/GridMenus.svelte +570 -0
- package/src/SvGrid.controller.svelte.ts +2195 -0
- package/src/SvGrid.css +1747 -0
- package/src/SvGrid.helpers.test.ts +415 -0
- package/src/SvGrid.helpers.ts +185 -0
- package/src/SvGrid.svelte +348 -7043
- package/src/SvGrid.types.ts +456 -0
- package/src/SvGridChart.svelte +1060 -23
- package/src/build-api.coverage.test.ts +532 -0
- package/src/build-api.ts +663 -0
- package/src/cell-render.test.ts +451 -0
- package/src/cell-render.ts +426 -0
- package/src/cell-values.ts +114 -0
- package/src/chart-export.test.ts +370 -0
- package/src/chart.coverage.test.ts +814 -0
- package/src/chart.ts +1352 -47
- package/src/clipboard.test.ts +731 -0
- package/src/clipboard.ts +524 -0
- package/src/collaboration.coverage.test.ts +220 -0
- package/src/columns.test.ts +702 -0
- package/src/columns.ts +419 -0
- package/src/core.ts +8 -0
- package/src/css.d.ts +3 -0
- package/src/editing.test.ts +837 -0
- package/src/editing.ts +513 -0
- package/src/editors/cell-editors.coverage.test.ts +156 -0
- package/src/editors/cell-editors.ts +1 -0
- package/src/facet-buckets.test.ts +353 -0
- package/src/facet-buckets.ts +67 -0
- package/src/features.ts +128 -0
- package/src/filter-operators.test.ts +174 -0
- package/src/filter-operators.ts +87 -0
- package/src/hyperformula-adapter.test.ts +256 -0
- package/src/hyperformula-adapter.ts +124 -0
- package/src/index.ts +37 -0
- package/src/keyboard-handlers.coverage.test.ts +560 -0
- package/src/keyboard-handlers.ts +353 -0
- package/src/keyboard.ts +97 -97
- package/src/menus.test.ts +620 -0
- package/src/menus.ts +554 -0
- package/src/named-views.coverage.test.ts +210 -0
- package/src/named-views.ts +48 -0
- package/src/row-resize.test.ts +369 -0
- package/src/row-resize.ts +171 -0
- package/src/scroll-sync.test.ts +330 -0
- package/src/scroll-sync.ts +216 -0
- package/src/selection.test.ts +722 -0
- package/src/selection.ts +545 -0
- package/src/server-data-source.coverage.test.ts +180 -0
- package/src/spreadsheet.test.ts +445 -0
- package/src/spreadsheet.ts +246 -0
- package/src/summaries.ts +204 -0
- package/src/sv-grid-scrollbar.ts +13 -1
- package/src/svgrid-wrapper.types.ts +12 -1
- package/src/svgrid.behavior.test.ts +22 -0
- package/src/svgrid.comments-autocomplete.test.ts +112 -0
- package/src/svgrid.context-menu.test.ts +126 -0
- package/src/svgrid.interaction.test.ts +30 -0
- package/src/svgrid.new-features.wrapper.test.ts +65 -4
- package/src/svgrid.wrapper.test.ts +27 -1
- package/src/test-setup.ts +9 -6
- package/src/virtualization/scroll-scaling.test.ts +148 -0
- package/src/virtualization/scroll-scaling.ts +121 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +2 -0
- package/src/virtualization/virtualizer.ts +26 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildChart,
|
|
4
|
+
rowsToChartSpec,
|
|
5
|
+
niceLogScale,
|
|
6
|
+
niceScale,
|
|
7
|
+
sampleGradient,
|
|
8
|
+
pickContrastText,
|
|
9
|
+
linearTrend,
|
|
10
|
+
simpleMovingAverage,
|
|
11
|
+
exponentialMovingAverage,
|
|
12
|
+
computeOverlay,
|
|
13
|
+
buildLinePath,
|
|
14
|
+
type ChartSpec,
|
|
15
|
+
} from "./chart";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Pure scale + color helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
describe("niceLogScale", () => {
|
|
22
|
+
it("snaps to decade boundaries spanning the data", () => {
|
|
23
|
+
const s = niceLogScale(3, 4000);
|
|
24
|
+
expect(s.min).toBe(1);
|
|
25
|
+
expect(s.max).toBe(10000);
|
|
26
|
+
expect(s.ticks).toEqual([1, 10, 100, 1000, 10000]);
|
|
27
|
+
});
|
|
28
|
+
it("repairs a non-positive / non-finite min", () => {
|
|
29
|
+
const s = niceLogScale(0, 100);
|
|
30
|
+
expect(s.min).toBe(1);
|
|
31
|
+
expect(s.ticks[0]).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
it("repairs a max that is <= min", () => {
|
|
34
|
+
const s = niceLogScale(10, 5);
|
|
35
|
+
// max forced to min*10 -> decade [10, 100]
|
|
36
|
+
expect(s.max).toBeGreaterThanOrEqual(100);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("niceScale edge handling", () => {
|
|
41
|
+
it("non-finite inputs collapse to a [0,1] domain", () => {
|
|
42
|
+
const s = niceScale(NaN, Infinity);
|
|
43
|
+
expect(s.min).toBe(0);
|
|
44
|
+
expect(s.max).toBeGreaterThanOrEqual(1);
|
|
45
|
+
});
|
|
46
|
+
it("min === max === 0 widens to [0,1]", () => {
|
|
47
|
+
const s = niceScale(0, 0);
|
|
48
|
+
expect(s.min).toBe(0);
|
|
49
|
+
expect(s.max).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
it("min === max (non-zero positive) includes 0 as the floor", () => {
|
|
52
|
+
const s = niceScale(5, 5);
|
|
53
|
+
expect(s.min).toBeLessThanOrEqual(0);
|
|
54
|
+
expect(s.max).toBeGreaterThanOrEqual(5);
|
|
55
|
+
});
|
|
56
|
+
it("min === max (non-zero negative) includes 0 as the ceiling", () => {
|
|
57
|
+
const s = niceScale(-5, -5);
|
|
58
|
+
expect(s.min).toBeLessThanOrEqual(-5);
|
|
59
|
+
expect(s.max).toBeGreaterThanOrEqual(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("sampleGradient", () => {
|
|
64
|
+
it("returns a placeholder for an empty stop list", () => {
|
|
65
|
+
expect(sampleGradient([], 0.5)).toBe("#888");
|
|
66
|
+
});
|
|
67
|
+
it("a single stop ignores t", () => {
|
|
68
|
+
expect(sampleGradient(["#ff0000"], 0.3)).toBe("#ff0000");
|
|
69
|
+
});
|
|
70
|
+
it("clamps t below 0 to the first stop and above 1 to the last", () => {
|
|
71
|
+
expect(sampleGradient(["#000000", "#ffffff"], -2)).toBe("#000000");
|
|
72
|
+
expect(sampleGradient(["#000000", "#ffffff"], 5)).toBe("#ffffff");
|
|
73
|
+
});
|
|
74
|
+
it("interpolates linearly in RGB at the midpoint", () => {
|
|
75
|
+
// halfway between black and white -> grey ~#7f7f7f / #808080
|
|
76
|
+
const mid = sampleGradient(["#000000", "#ffffff"], 0.5);
|
|
77
|
+
expect(mid).toMatch(/^#(7f7f7f|808080)$/);
|
|
78
|
+
});
|
|
79
|
+
it("falls back gracefully when a stop is not parseable hex", () => {
|
|
80
|
+
// 'red' is not #rrggbb -> hexToRgb null -> returns the i-th stop
|
|
81
|
+
const out = sampleGradient(["red", "#ffffff"], 0.25);
|
|
82
|
+
expect(out).toBe("red");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("pickContrastText", () => {
|
|
87
|
+
it("returns dark text on a light background", () => {
|
|
88
|
+
expect(pickContrastText("#ffffff")).toBe("#0f172a");
|
|
89
|
+
});
|
|
90
|
+
it("returns light text on a dark background", () => {
|
|
91
|
+
expect(pickContrastText("#000000")).toBe("#ffffff");
|
|
92
|
+
});
|
|
93
|
+
it("falls back to dark text for an unparseable color", () => {
|
|
94
|
+
expect(pickContrastText("not-a-color")).toBe("#0f172a");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Overlay math (trend / moving averages)
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
describe("linearTrend", () => {
|
|
103
|
+
it("fits a straight line through perfectly linear data", () => {
|
|
104
|
+
const out = linearTrend([0, 2, 4, 6]);
|
|
105
|
+
expect(out).toEqual([0, 2, 4, 6]);
|
|
106
|
+
});
|
|
107
|
+
it("returns NaNs when fewer than 2 finite points exist", () => {
|
|
108
|
+
expect(linearTrend([5]).every(Number.isNaN)).toBe(true);
|
|
109
|
+
expect(linearTrend([NaN, NaN]).every(Number.isNaN)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
it("returns the mean when the x-variance is degenerate", () => {
|
|
112
|
+
// Two identical-index points can't happen here, but a flat series with a
|
|
113
|
+
// single repeated value still fits a horizontal line at the mean.
|
|
114
|
+
const out = linearTrend([3, 3, 3]);
|
|
115
|
+
for (const v of out) expect(v).toBeCloseTo(3);
|
|
116
|
+
});
|
|
117
|
+
it("ignores non-finite points when fitting", () => {
|
|
118
|
+
const out = linearTrend([0, NaN, 4]);
|
|
119
|
+
expect(out[0]).toBeCloseTo(0);
|
|
120
|
+
expect(out[2]).toBeCloseTo(4);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("simpleMovingAverage", () => {
|
|
125
|
+
it("period < 1 returns a copy of the input", () => {
|
|
126
|
+
const input = [1, 2, 3];
|
|
127
|
+
const out = simpleMovingAverage(input, 0);
|
|
128
|
+
expect(out).toEqual(input);
|
|
129
|
+
expect(out).not.toBe(input);
|
|
130
|
+
});
|
|
131
|
+
it("trails NaN until the window is full, then averages", () => {
|
|
132
|
+
const out = simpleMovingAverage([2, 4, 6, 8], 2);
|
|
133
|
+
expect(Number.isNaN(out[0]!)).toBe(true);
|
|
134
|
+
expect(out[1]).toBeCloseTo(3);
|
|
135
|
+
expect(out[2]).toBeCloseTo(5);
|
|
136
|
+
expect(out[3]).toBeCloseTo(7);
|
|
137
|
+
});
|
|
138
|
+
it("skips non-finite values inside the window", () => {
|
|
139
|
+
const out = simpleMovingAverage([2, NaN, 6, 8], 2);
|
|
140
|
+
expect(out.length).toBe(4);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("exponentialMovingAverage", () => {
|
|
145
|
+
it("seeds on the first finite value then smooths", () => {
|
|
146
|
+
const out = exponentialMovingAverage([10, 20], 1);
|
|
147
|
+
expect(out[0]).toBeCloseTo(10);
|
|
148
|
+
expect(out[1]).toBeGreaterThan(10);
|
|
149
|
+
});
|
|
150
|
+
it("carries the previous value across a non-finite point", () => {
|
|
151
|
+
const out = exponentialMovingAverage([10, NaN, 20], 3);
|
|
152
|
+
expect(out[1]).toBeCloseTo(out[0]!);
|
|
153
|
+
});
|
|
154
|
+
it("yields NaN before the first finite value", () => {
|
|
155
|
+
const out = exponentialMovingAverage([NaN, 5], 2);
|
|
156
|
+
expect(Number.isNaN(out[0]!)).toBe(true);
|
|
157
|
+
expect(out[1]).toBeCloseTo(5);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("computeOverlay", () => {
|
|
162
|
+
it("dispatches 'linear' to the regression fit", () => {
|
|
163
|
+
expect(computeOverlay([0, 2, 4], "linear")).toEqual([0, 2, 4]);
|
|
164
|
+
});
|
|
165
|
+
it("dispatches sma:N to the simple moving average", () => {
|
|
166
|
+
const out = computeOverlay([2, 4, 6], "sma:2");
|
|
167
|
+
expect(Number.isNaN(out[0]!)).toBe(true);
|
|
168
|
+
expect(out[1]).toBeCloseTo(3);
|
|
169
|
+
});
|
|
170
|
+
it("dispatches ema:N to the exponential moving average", () => {
|
|
171
|
+
const out = computeOverlay([10, 20, 30], "ema:2");
|
|
172
|
+
expect(out[0]).toBeCloseTo(10);
|
|
173
|
+
});
|
|
174
|
+
it("returns all-NaN for an unrecognised overlay spec", () => {
|
|
175
|
+
const out = computeOverlay([1, 2, 3], "bogus:7" as any);
|
|
176
|
+
expect(out.every(Number.isNaN)).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("buildLinePath", () => {
|
|
181
|
+
it("breaks the polyline at undefined gaps (multiple M commands)", () => {
|
|
182
|
+
const path = buildLinePath(
|
|
183
|
+
[
|
|
184
|
+
{ x: 0, y: 0, defined: true },
|
|
185
|
+
{ x: 1, y: 1, defined: false },
|
|
186
|
+
{ x: 2, y: 2, defined: true },
|
|
187
|
+
],
|
|
188
|
+
false,
|
|
189
|
+
);
|
|
190
|
+
expect((path.match(/M/g) ?? []).length).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
it("smooth mode emits cubic bezier (C) segments", () => {
|
|
193
|
+
const path = buildLinePath(
|
|
194
|
+
[
|
|
195
|
+
{ x: 0, y: 0, defined: true },
|
|
196
|
+
{ x: 1, y: 2, defined: true },
|
|
197
|
+
{ x: 2, y: 1, defined: true },
|
|
198
|
+
],
|
|
199
|
+
true,
|
|
200
|
+
);
|
|
201
|
+
expect(path).toContain("C");
|
|
202
|
+
});
|
|
203
|
+
it("smooth two-point run is a straight line", () => {
|
|
204
|
+
const path = buildLinePath(
|
|
205
|
+
[
|
|
206
|
+
{ x: 0, y: 0, defined: true },
|
|
207
|
+
{ x: 1, y: 1, defined: true },
|
|
208
|
+
],
|
|
209
|
+
true,
|
|
210
|
+
);
|
|
211
|
+
expect(path).toContain("L");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// buildChart: log scales, time vs category, overlays, annotations, patterns
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
describe("buildChart: log y-scale", () => {
|
|
220
|
+
it("plots bars from the axis floor up; ticks are decade boundaries", () => {
|
|
221
|
+
const g = buildChart({
|
|
222
|
+
type: "bar",
|
|
223
|
+
yScale: "log",
|
|
224
|
+
categories: ["A", "B", "C"],
|
|
225
|
+
series: [{ label: "s", values: [10, 1000, 100000] }],
|
|
226
|
+
});
|
|
227
|
+
// decade ticks
|
|
228
|
+
expect(g.yTicks.some((t) => t.value === 10)).toBe(true);
|
|
229
|
+
expect(g.yTicks.some((t) => t.value === 100000)).toBe(true);
|
|
230
|
+
expect(g.bars).toHaveLength(3);
|
|
231
|
+
});
|
|
232
|
+
it("drops non-positive bar values on a log axis", () => {
|
|
233
|
+
const g = buildChart({
|
|
234
|
+
type: "bar",
|
|
235
|
+
yScale: "log",
|
|
236
|
+
categories: ["A", "B"],
|
|
237
|
+
series: [{ label: "s", values: [0, 100] }],
|
|
238
|
+
});
|
|
239
|
+
// the zero-value bar is omitted (invalid in log space)
|
|
240
|
+
expect(g.bars).toHaveLength(1);
|
|
241
|
+
expect(g.bars[0]!.value).toBe(100);
|
|
242
|
+
});
|
|
243
|
+
it("a dual-axis chart can log-scale the right axis independently", () => {
|
|
244
|
+
const g = buildChart({
|
|
245
|
+
type: "bar",
|
|
246
|
+
y2Scale: "log",
|
|
247
|
+
categories: ["A", "B"],
|
|
248
|
+
series: [
|
|
249
|
+
{ label: "rev", values: [10, 20], axis: "left" },
|
|
250
|
+
{ label: "hits", values: [100, 100000], axis: "right", type: "line" },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
expect(g.hasRightAxis).toBe(true);
|
|
254
|
+
expect(g.y2Ticks.length).toBeGreaterThan(1);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("buildChart: time axis edge cases", () => {
|
|
259
|
+
it("falls back to category spacing when no date parses", () => {
|
|
260
|
+
const g = buildChart({
|
|
261
|
+
type: "line",
|
|
262
|
+
xType: "time",
|
|
263
|
+
categories: ["not-a-date", "also-bad"],
|
|
264
|
+
series: [{ label: "s", values: [1, 2] }],
|
|
265
|
+
});
|
|
266
|
+
// unparseable -> uniform category positions, labels are the raw strings
|
|
267
|
+
expect(g.xTicks.map((t) => t.label)).toEqual(["not-a-date", "also-bad"]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("buildChart: overlays", () => {
|
|
272
|
+
it("adds a dashed trend overlay line parallel to the series", () => {
|
|
273
|
+
const g = buildChart({
|
|
274
|
+
type: "line",
|
|
275
|
+
categories: ["A", "B", "C", "D"],
|
|
276
|
+
series: [{ label: "s", values: [1, 3, 2, 5], overlay: "linear" }],
|
|
277
|
+
});
|
|
278
|
+
expect(g.overlays).toHaveLength(1);
|
|
279
|
+
expect(g.overlays[0]!.label).toContain("linear");
|
|
280
|
+
expect(g.overlays[0]!.path.startsWith("M")).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
it("honours overlayColor when provided", () => {
|
|
283
|
+
const g = buildChart({
|
|
284
|
+
type: "line",
|
|
285
|
+
categories: ["A", "B"],
|
|
286
|
+
series: [
|
|
287
|
+
{
|
|
288
|
+
label: "s",
|
|
289
|
+
values: [1, 2],
|
|
290
|
+
overlay: "sma:2",
|
|
291
|
+
overlayColor: "#123456",
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
expect(g.overlays[0]!.color).toBe("#123456");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("buildChart: annotations", () => {
|
|
300
|
+
it("anchors a category annotation to a named series value", () => {
|
|
301
|
+
const g = buildChart({
|
|
302
|
+
type: "line",
|
|
303
|
+
categories: ["A", "B", "C"],
|
|
304
|
+
series: [{ label: "rev", values: [10, 20, 30] }],
|
|
305
|
+
annotations: [{ at: { category: "B", series: "rev" }, label: "Spike" }],
|
|
306
|
+
});
|
|
307
|
+
expect(g.annotations).toHaveLength(1);
|
|
308
|
+
expect(g.annotations[0]!.label).toBe("Spike");
|
|
309
|
+
expect(Number.isFinite(g.annotations[0]!.x)).toBe(true);
|
|
310
|
+
expect(g.annotations[0]!.placement).toBe("top");
|
|
311
|
+
});
|
|
312
|
+
it("skips annotations whose category is not present", () => {
|
|
313
|
+
const g = buildChart({
|
|
314
|
+
type: "line",
|
|
315
|
+
categories: ["A", "B"],
|
|
316
|
+
series: [{ label: "s", values: [1, 2] }],
|
|
317
|
+
annotations: [{ at: { category: "Z" }, label: "Nope" }],
|
|
318
|
+
});
|
|
319
|
+
expect(g.annotations).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
it("resolves a raw x/y data-space annotation against the left axis", () => {
|
|
322
|
+
const g = buildChart({
|
|
323
|
+
type: "line",
|
|
324
|
+
categories: ["A", "B"],
|
|
325
|
+
series: [{ label: "s", values: [10, 20] }],
|
|
326
|
+
annotations: [
|
|
327
|
+
{ at: { x: 0, y: 15 }, label: "Mid", color: "#abcdef", placement: "bottom" },
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
expect(g.annotations).toHaveLength(1);
|
|
331
|
+
expect(g.annotations[0]!.color).toBe("#abcdef");
|
|
332
|
+
expect(g.annotations[0]!.placement).toBe("bottom");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("buildChart: confidence band", () => {
|
|
337
|
+
it("builds a band path when upper+lower envelopes are supplied", () => {
|
|
338
|
+
const g = buildChart({
|
|
339
|
+
type: "line",
|
|
340
|
+
categories: ["A", "B", "C"],
|
|
341
|
+
series: [
|
|
342
|
+
{
|
|
343
|
+
label: "s",
|
|
344
|
+
values: [10, 20, 30],
|
|
345
|
+
upperValues: [12, 23, 34],
|
|
346
|
+
lowerValues: [8, 17, 26],
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
expect(g.lines[0]!.bandPath).toBeTruthy();
|
|
351
|
+
expect(g.lines[0]!.bandPath!.endsWith("Z")).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
it("omits the band when the envelope lengths don't align", () => {
|
|
354
|
+
const g = buildChart({
|
|
355
|
+
type: "line",
|
|
356
|
+
categories: ["A", "B"],
|
|
357
|
+
series: [
|
|
358
|
+
{ label: "s", values: [1, 2], upperValues: [3], lowerValues: [0] },
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
expect(g.lines[0]!.bandPath).toBe("");
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("buildChart: smooth line", () => {
|
|
366
|
+
it("smooth produces a curved (C) path", () => {
|
|
367
|
+
const g = buildChart({
|
|
368
|
+
type: "line",
|
|
369
|
+
categories: ["A", "B", "C"],
|
|
370
|
+
series: [{ label: "s", values: [1, 3, 2], smooth: true }],
|
|
371
|
+
});
|
|
372
|
+
expect(g.lines[0]!.path).toContain("C");
|
|
373
|
+
});
|
|
374
|
+
it("smooth area builds a closed filled path", () => {
|
|
375
|
+
const g = buildChart({
|
|
376
|
+
type: "area",
|
|
377
|
+
categories: ["A", "B", "C"],
|
|
378
|
+
series: [{ label: "s", values: [1, 3, 2], smooth: "monotone" }],
|
|
379
|
+
});
|
|
380
|
+
expect(g.lines[0]!.areaPath.endsWith("Z")).toBe(true);
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// buildChart: specialty chart types
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
describe("buildChart: waterfall", () => {
|
|
389
|
+
it("running totals + connectors + total bars", () => {
|
|
390
|
+
const g = buildChart({
|
|
391
|
+
type: "waterfall",
|
|
392
|
+
categories: ["Start", "Up", "Down", "End"],
|
|
393
|
+
series: [{ label: "s", values: [100, 50, -30, 0] }],
|
|
394
|
+
waterfallTotals: [false, false, false, true],
|
|
395
|
+
});
|
|
396
|
+
expect(g.bars).toHaveLength(4);
|
|
397
|
+
// total bar (index 3) spans from 0 to the running cumulative (120)
|
|
398
|
+
expect(g.bars[3]!.value).toBeCloseTo(120);
|
|
399
|
+
// a connector line exists between bar tops
|
|
400
|
+
expect(g.lines[0]!.path).toContain("M");
|
|
401
|
+
});
|
|
402
|
+
it("colors positive/negative/total via waterfallColors overrides", () => {
|
|
403
|
+
const g = buildChart({
|
|
404
|
+
type: "waterfall",
|
|
405
|
+
categories: ["A", "B", "T"],
|
|
406
|
+
series: [{ label: "s", values: [10, -5, 0] }],
|
|
407
|
+
waterfallTotals: [false, false, true],
|
|
408
|
+
waterfallColors: { positive: "#0f0", negative: "#f00", total: "#00f" },
|
|
409
|
+
});
|
|
410
|
+
expect(g.bars[0]!.color).toBe("#0f0");
|
|
411
|
+
expect(g.bars[1]!.color).toBe("#f00");
|
|
412
|
+
expect(g.bars[2]!.color).toBe("#00f");
|
|
413
|
+
});
|
|
414
|
+
it("returns the empty geometry when there is no series", () => {
|
|
415
|
+
const g = buildChart({ type: "waterfall", categories: ["A"], series: [] });
|
|
416
|
+
expect(g.bars).toHaveLength(0);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("buildChart: funnel", () => {
|
|
421
|
+
it("trapezoids with conversion + dropoff metrics", () => {
|
|
422
|
+
const g = buildChart({
|
|
423
|
+
type: "funnel",
|
|
424
|
+
categories: ["Visits", "Signups", "Paid"],
|
|
425
|
+
series: [{ label: "s", values: [1000, 400, 100] }],
|
|
426
|
+
});
|
|
427
|
+
expect(g.funnelSegments).toHaveLength(3);
|
|
428
|
+
expect(g.funnelSegments[0]!.conversion).toBeCloseTo(1);
|
|
429
|
+
expect(g.funnelSegments[2]!.conversion).toBeCloseTo(0.1);
|
|
430
|
+
// dropoff from 1000 -> 400 is 60%
|
|
431
|
+
expect(g.funnelSegments[1]!.dropoff).toBeCloseTo(0.6);
|
|
432
|
+
expect(g.funnelSegments[0]!.path).toContain("Z");
|
|
433
|
+
});
|
|
434
|
+
it("returns empty geometry for an empty series", () => {
|
|
435
|
+
const g = buildChart({ type: "funnel", categories: [], series: [{ label: "s", values: [] }] });
|
|
436
|
+
expect(g.funnelSegments).toHaveLength(0);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("buildChart: radar", () => {
|
|
441
|
+
it("one polygon per series + axis spokes + rings", () => {
|
|
442
|
+
const g = buildChart({
|
|
443
|
+
type: "radar",
|
|
444
|
+
categories: ["Speed", "Power", "Range", "Cost"],
|
|
445
|
+
series: [
|
|
446
|
+
{ label: "X", values: [3, 5, 2, 4] },
|
|
447
|
+
{ label: "Y", values: [5, 1, 4, 3] },
|
|
448
|
+
],
|
|
449
|
+
});
|
|
450
|
+
expect(g.radarAxes).toHaveLength(4);
|
|
451
|
+
expect(g.radarSeries).toHaveLength(2);
|
|
452
|
+
expect(g.radarRings).toHaveLength(5);
|
|
453
|
+
expect(g.radarSeries[0]!.path.endsWith("Z")).toBe(true);
|
|
454
|
+
expect(g.radarCenter).not.toBeNull();
|
|
455
|
+
});
|
|
456
|
+
it("returns empty geometry with no series or categories", () => {
|
|
457
|
+
expect(buildChart({ type: "radar", categories: [], series: [] }).radarSeries).toHaveLength(0);
|
|
458
|
+
expect(
|
|
459
|
+
buildChart({ type: "radar", categories: ["A"], series: [] }).radarAxes,
|
|
460
|
+
).toHaveLength(0);
|
|
461
|
+
});
|
|
462
|
+
it("guards against an all-zero max (vMax defaults to 1)", () => {
|
|
463
|
+
const g = buildChart({
|
|
464
|
+
type: "radar",
|
|
465
|
+
categories: ["A", "B"],
|
|
466
|
+
series: [{ label: "z", values: [0, 0] }],
|
|
467
|
+
});
|
|
468
|
+
expect(g.radarSeries[0]!.path).toBeTruthy();
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("buildChart: calendar heatmap", () => {
|
|
473
|
+
it("emits one cell per day with month ticks + a legend", () => {
|
|
474
|
+
const g = buildChart({
|
|
475
|
+
type: "calendar",
|
|
476
|
+
categories: [],
|
|
477
|
+
series: [],
|
|
478
|
+
calendarValues: [
|
|
479
|
+
{ date: "2026-01-05", value: 3 },
|
|
480
|
+
{ date: "2026-01-06", value: 9 },
|
|
481
|
+
{ date: "2026-02-02", value: 5 },
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
expect(g.calendarCells.length).toBeGreaterThan(7);
|
|
485
|
+
expect(g.calendarLegend).toHaveLength(5);
|
|
486
|
+
// a defined day carries its value + a non-transparent color
|
|
487
|
+
const defined = g.calendarCells.find((c) => c.date === "2026-01-05");
|
|
488
|
+
expect(defined!.defined).toBe(true);
|
|
489
|
+
expect(defined!.value).toBe(3);
|
|
490
|
+
expect(defined!.color).not.toBe("transparent");
|
|
491
|
+
// an undefined day in the range is blank
|
|
492
|
+
const blank = g.calendarCells.find((c) => !c.defined);
|
|
493
|
+
expect(blank!.color).toBe("transparent");
|
|
494
|
+
expect(g.calendarMonthTicks.length).toBeGreaterThanOrEqual(1);
|
|
495
|
+
});
|
|
496
|
+
it("honours explicit calendarStart even with no values", () => {
|
|
497
|
+
const g = buildChart({
|
|
498
|
+
type: "calendar",
|
|
499
|
+
categories: [],
|
|
500
|
+
series: [],
|
|
501
|
+
calendarStart: "2026-03-01",
|
|
502
|
+
calendarEnd: "2026-03-14",
|
|
503
|
+
});
|
|
504
|
+
expect(g.calendarCells.length).toBeGreaterThan(0);
|
|
505
|
+
});
|
|
506
|
+
it("returns empty geometry with neither values nor a start", () => {
|
|
507
|
+
const g = buildChart({ type: "calendar", categories: [], series: [] });
|
|
508
|
+
expect(g.calendarCells).toHaveLength(0);
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("buildChart: gauge", () => {
|
|
513
|
+
it("track + value arcs, ticks, needle, clamped value", () => {
|
|
514
|
+
const g = buildChart({
|
|
515
|
+
type: "gauge",
|
|
516
|
+
categories: [],
|
|
517
|
+
series: [],
|
|
518
|
+
gaugeValue: 150, // clamped to max
|
|
519
|
+
gaugeMin: 0,
|
|
520
|
+
gaugeMax: 100,
|
|
521
|
+
gaugeTarget: 80,
|
|
522
|
+
gaugeRanges: [
|
|
523
|
+
{ from: 0, to: 50, color: "#f00" },
|
|
524
|
+
{ from: 50, to: 100, color: "#0f0" },
|
|
525
|
+
],
|
|
526
|
+
gaugeUnit: "%",
|
|
527
|
+
});
|
|
528
|
+
expect(g.gauge).not.toBeNull();
|
|
529
|
+
expect(g.gauge!.value).toBe(100); // clamped
|
|
530
|
+
expect(g.gauge!.unit).toBe("%");
|
|
531
|
+
expect(g.gauge!.trackPath).toContain("A");
|
|
532
|
+
expect(g.gauge!.valuePath).toContain("A");
|
|
533
|
+
expect(g.gauge!.ticks.length).toBeGreaterThan(0);
|
|
534
|
+
expect(g.gauge!.target).not.toBeNull();
|
|
535
|
+
// value 100 sits in the second band
|
|
536
|
+
expect(g.gauge!.valueColor).toBe("#0f0");
|
|
537
|
+
expect(g.gauge!.needle.path).toContain("Z");
|
|
538
|
+
});
|
|
539
|
+
it("no target marker when gaugeTarget is unset", () => {
|
|
540
|
+
const g = buildChart({
|
|
541
|
+
type: "gauge",
|
|
542
|
+
categories: [],
|
|
543
|
+
series: [],
|
|
544
|
+
gaugeValue: 40,
|
|
545
|
+
});
|
|
546
|
+
expect(g.gauge!.target).toBeNull();
|
|
547
|
+
expect(g.gauge!.valueColor).toBeNull();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe("buildChart: treemap", () => {
|
|
552
|
+
it("lays out leaf + branch cells with depth", () => {
|
|
553
|
+
const g = buildChart({
|
|
554
|
+
type: "treemap",
|
|
555
|
+
categories: [],
|
|
556
|
+
series: [],
|
|
557
|
+
width: 400,
|
|
558
|
+
height: 300,
|
|
559
|
+
treemap: {
|
|
560
|
+
name: "root",
|
|
561
|
+
children: [
|
|
562
|
+
{
|
|
563
|
+
name: "A",
|
|
564
|
+
children: [
|
|
565
|
+
{ name: "A1", value: 30 },
|
|
566
|
+
{ name: "A2", value: 20 },
|
|
567
|
+
],
|
|
568
|
+
},
|
|
569
|
+
{ name: "B", value: 50 },
|
|
570
|
+
],
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
expect(g.treemapCells.length).toBeGreaterThan(0);
|
|
574
|
+
// both leaves + a parent cell present; depths >= 0
|
|
575
|
+
const names = g.treemapCells.map((c) => c.name);
|
|
576
|
+
expect(names).toContain("B");
|
|
577
|
+
expect(g.treemapCells.every((c) => c.depth >= 0)).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
it("returns empty geometry with no root", () => {
|
|
580
|
+
const g = buildChart({ type: "treemap", categories: [], series: [] });
|
|
581
|
+
expect(g.treemapCells).toHaveLength(0);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("buildChart: sankey", () => {
|
|
586
|
+
it("places nodes in columns and builds curved ribbon links", () => {
|
|
587
|
+
const g = buildChart({
|
|
588
|
+
type: "sankey",
|
|
589
|
+
categories: [],
|
|
590
|
+
series: [],
|
|
591
|
+
sankeyNodes: [
|
|
592
|
+
{ id: "a", label: "A" },
|
|
593
|
+
{ id: "b", label: "B" },
|
|
594
|
+
{ id: "c", label: "C" },
|
|
595
|
+
],
|
|
596
|
+
sankeyLinks: [
|
|
597
|
+
{ source: "a", target: "b", value: 10 },
|
|
598
|
+
{ source: "b", target: "c", value: 6 },
|
|
599
|
+
{ source: "a", target: "c", value: 4 },
|
|
600
|
+
],
|
|
601
|
+
});
|
|
602
|
+
expect(g.sankeyNodes.length).toBe(3);
|
|
603
|
+
expect(g.sankeyLinks.length).toBe(3);
|
|
604
|
+
// columns increase along the flow
|
|
605
|
+
const colOf = (id: string) => g.sankeyNodes.find((n) => n.id === id)!.column;
|
|
606
|
+
expect(colOf("a")).toBeLessThan(colOf("c"));
|
|
607
|
+
expect(g.sankeyLinks[0]!.path).toContain("C");
|
|
608
|
+
});
|
|
609
|
+
it("returns empty geometry when nodes or links are missing", () => {
|
|
610
|
+
expect(
|
|
611
|
+
buildChart({ type: "sankey", categories: [], series: [], sankeyNodes: [], sankeyLinks: [] })
|
|
612
|
+
.sankeyNodes,
|
|
613
|
+
).toHaveLength(0);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("buildChart: heatmap", () => {
|
|
618
|
+
it("one cell per (row,col), legend + axis ticks, contrast text", () => {
|
|
619
|
+
const g = buildChart({
|
|
620
|
+
type: "heatmap",
|
|
621
|
+
categories: ["Q1", "Q2", "Q3"],
|
|
622
|
+
series: [
|
|
623
|
+
{ label: "North", values: [1, 5, 9] },
|
|
624
|
+
{ label: "South", values: [2, 6, 8] },
|
|
625
|
+
],
|
|
626
|
+
});
|
|
627
|
+
expect(g.heatmapCells).toHaveLength(6);
|
|
628
|
+
expect(g.heatmapRowTicks.map((t) => t.label)).toEqual(["North", "South"]);
|
|
629
|
+
expect(g.heatmapColTicks.map((t) => t.label)).toEqual(["Q1", "Q2", "Q3"]);
|
|
630
|
+
expect(g.heatmapLegend).toHaveLength(5);
|
|
631
|
+
for (const c of g.heatmapCells) {
|
|
632
|
+
expect(["#0f172a", "#ffffff"]).toContain(c.textColor);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
it("diverging scale is auto-picked for signed data", () => {
|
|
636
|
+
const g = buildChart({
|
|
637
|
+
type: "heatmap",
|
|
638
|
+
categories: ["A", "B"],
|
|
639
|
+
series: [{ label: "r", values: [-5, 5] }],
|
|
640
|
+
});
|
|
641
|
+
expect(g.heatmapCells).toHaveLength(2);
|
|
642
|
+
});
|
|
643
|
+
it("a custom color-scale array is honoured", () => {
|
|
644
|
+
const g = buildChart({
|
|
645
|
+
type: "heatmap",
|
|
646
|
+
categories: ["A", "B"],
|
|
647
|
+
series: [{ label: "r", values: [0, 10] }],
|
|
648
|
+
colorScale: ["#000000", "#ffffff"],
|
|
649
|
+
});
|
|
650
|
+
expect(g.heatmapCells).toHaveLength(2);
|
|
651
|
+
});
|
|
652
|
+
it("returns empty geometry with no series", () => {
|
|
653
|
+
const g = buildChart({ type: "heatmap", categories: ["A"], series: [] });
|
|
654
|
+
expect(g.heatmapCells).toHaveLength(0);
|
|
655
|
+
});
|
|
656
|
+
it("dark theme uses the dark ramps without error", () => {
|
|
657
|
+
const g = buildChart(
|
|
658
|
+
{
|
|
659
|
+
type: "heatmap",
|
|
660
|
+
categories: ["A"],
|
|
661
|
+
series: [{ label: "r", values: [3] }],
|
|
662
|
+
},
|
|
663
|
+
"dark",
|
|
664
|
+
);
|
|
665
|
+
expect(g.heatmapCells).toHaveLength(1);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe("buildChart: stacked horizontal + dual-axis stacks", () => {
|
|
670
|
+
it("stacked horizontal bars share a category band", () => {
|
|
671
|
+
const g = buildChart({
|
|
672
|
+
type: "bar",
|
|
673
|
+
orientation: "horizontal",
|
|
674
|
+
stacked: true,
|
|
675
|
+
categories: ["A"],
|
|
676
|
+
series: [
|
|
677
|
+
{ label: "x", values: [10] },
|
|
678
|
+
{ label: "y", values: [20] },
|
|
679
|
+
],
|
|
680
|
+
});
|
|
681
|
+
expect(g.orientation).toBe("horizontal");
|
|
682
|
+
expect(g.bars).toHaveLength(2);
|
|
683
|
+
// stacked: second segment starts where the first ended
|
|
684
|
+
expect(g.bars[1]!.x).toBeGreaterThanOrEqual(g.bars[0]!.x);
|
|
685
|
+
});
|
|
686
|
+
it("stacked100 horizontal renders percent value ticks", () => {
|
|
687
|
+
const g = buildChart({
|
|
688
|
+
type: "bar",
|
|
689
|
+
orientation: "horizontal",
|
|
690
|
+
stacked100: true,
|
|
691
|
+
categories: ["A"],
|
|
692
|
+
series: [
|
|
693
|
+
{ label: "x", values: [1] },
|
|
694
|
+
{ label: "y", values: [3] },
|
|
695
|
+
],
|
|
696
|
+
});
|
|
697
|
+
expect(g.valueTicks.some((t) => t.label.includes("%"))).toBe(true);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// rowsToChartSpec: remaining branches
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
describe("rowsToChartSpec: sort + idField + topN rowIds", () => {
|
|
706
|
+
const rows = [
|
|
707
|
+
{ id: "r1", region: "EMEA", revenue: 100 },
|
|
708
|
+
{ id: "r2", region: "APAC", revenue: 200 },
|
|
709
|
+
{ id: "r3", region: "AMER", revenue: 50 },
|
|
710
|
+
{ id: "r4", region: "EMEA", revenue: 80 },
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
it("sort 'value-asc' orders categories by ascending total", () => {
|
|
714
|
+
const spec = rowsToChartSpec(rows, {
|
|
715
|
+
type: "bar",
|
|
716
|
+
category: "region",
|
|
717
|
+
value: "revenue",
|
|
718
|
+
sort: "value-asc",
|
|
719
|
+
});
|
|
720
|
+
// totals: EMEA=180, APAC=200, AMER=50 -> asc: AMER, EMEA, APAC
|
|
721
|
+
expect(spec.categories).toEqual(["AMER", "EMEA", "APAC"]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("sort 'category' orders categories alphabetically", () => {
|
|
725
|
+
const spec = rowsToChartSpec(rows, {
|
|
726
|
+
type: "bar",
|
|
727
|
+
category: "region",
|
|
728
|
+
value: "revenue",
|
|
729
|
+
sort: "category",
|
|
730
|
+
});
|
|
731
|
+
expect(spec.categories).toEqual(["AMER", "APAC", "EMEA"]);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("idField populates rowIds parallel to values", () => {
|
|
735
|
+
const spec = rowsToChartSpec(rows, {
|
|
736
|
+
type: "bar",
|
|
737
|
+
category: "region",
|
|
738
|
+
value: "revenue",
|
|
739
|
+
idField: "id",
|
|
740
|
+
});
|
|
741
|
+
const emea = spec.categories.indexOf("EMEA");
|
|
742
|
+
expect(spec.series[0]!.rowIds![emea]).toEqual(["r1", "r4"]);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("topN with idField buckets remainder rowIds into 'Other'", () => {
|
|
746
|
+
const spec = rowsToChartSpec(rows, {
|
|
747
|
+
type: "bar",
|
|
748
|
+
category: "region",
|
|
749
|
+
value: "revenue",
|
|
750
|
+
idField: "id",
|
|
751
|
+
topN: 1,
|
|
752
|
+
otherLabel: "Rest",
|
|
753
|
+
});
|
|
754
|
+
// top category by total is APAC (200); the rest collapse into "Rest"
|
|
755
|
+
expect(spec.categories).toEqual(["APAC", "Rest"]);
|
|
756
|
+
const otherIds = spec.series[0]!.rowIds![1]!;
|
|
757
|
+
// EMEA (r1,r4) + AMER (r3) bucketed together
|
|
758
|
+
expect(otherIds.sort()).toEqual(["r1", "r3", "r4"]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("seriesLabel renames the single value series", () => {
|
|
762
|
+
const spec = rowsToChartSpec(rows, {
|
|
763
|
+
type: "bar",
|
|
764
|
+
category: "region",
|
|
765
|
+
value: "revenue",
|
|
766
|
+
seriesLabel: "Sales",
|
|
767
|
+
});
|
|
768
|
+
expect(spec.series[0]!.label).toBe("Sales");
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("pivot via the series field with idField tracks ids per series", () => {
|
|
772
|
+
const spec = rowsToChartSpec(rows, {
|
|
773
|
+
type: "bar",
|
|
774
|
+
category: "region",
|
|
775
|
+
value: "revenue",
|
|
776
|
+
series: "region",
|
|
777
|
+
idField: "id",
|
|
778
|
+
});
|
|
779
|
+
expect(spec.series.length).toBeGreaterThan(0);
|
|
780
|
+
expect(spec.series[0]!.rowIds).toBeTruthy();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("non-numeric value cells are skipped from the aggregate", () => {
|
|
784
|
+
const dirty = [
|
|
785
|
+
{ region: "A", revenue: 10 },
|
|
786
|
+
{ region: "A", revenue: "n/a" as any },
|
|
787
|
+
{ region: "A", revenue: 20 },
|
|
788
|
+
];
|
|
789
|
+
const spec = rowsToChartSpec(dirty, {
|
|
790
|
+
type: "bar",
|
|
791
|
+
category: "region",
|
|
792
|
+
value: "revenue",
|
|
793
|
+
reduce: "count",
|
|
794
|
+
});
|
|
795
|
+
// only 2 finite numbers counted
|
|
796
|
+
expect(spec.series[0]!.values).toEqual([2]);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("passes width/height/stacked/palette through to the spec", () => {
|
|
800
|
+
const spec: ChartSpec = rowsToChartSpec(rows, {
|
|
801
|
+
type: "bar",
|
|
802
|
+
category: "region",
|
|
803
|
+
value: "revenue",
|
|
804
|
+
width: 600,
|
|
805
|
+
height: 400,
|
|
806
|
+
stacked: true,
|
|
807
|
+
palette: ["#111", "#222"],
|
|
808
|
+
});
|
|
809
|
+
expect(spec.width).toBe(600);
|
|
810
|
+
expect(spec.height).toBe(400);
|
|
811
|
+
expect(spec.stacked).toBe(true);
|
|
812
|
+
expect(spec.palette).toEqual(["#111", "#222"]);
|
|
813
|
+
});
|
|
814
|
+
});
|