@svgrid/grid 1.1.0 → 1.1.1

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