@svgrid/grid 3.0.1 → 3.0.3

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 (77) hide show
  1. package/dist/GridFooter.svelte +14 -5
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/GridMenus.svelte +18 -10
  4. package/dist/GridMenus.svelte.d.ts +2 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  6. package/dist/SvGrid.controller.svelte.js +128 -14
  7. package/dist/SvGrid.css +79 -10
  8. package/dist/SvGrid.svelte +191 -186
  9. package/dist/SvGrid.types.d.ts +42 -1
  10. package/dist/SvGridChart.svelte +571 -41
  11. package/dist/SvGridChart.svelte.d.ts +63 -1
  12. package/dist/SvGridChartPanel.svelte +169 -21
  13. package/dist/SvGridChartView.svelte +9 -0
  14. package/dist/SvGridChartView.svelte.d.ts +7 -0
  15. package/dist/SvGroupCell.svelte +28 -3
  16. package/dist/SvGroupCell.svelte.d.ts +8 -0
  17. package/dist/SvRowGroupPanel.svelte +20 -4
  18. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  19. package/dist/ai.d.ts +11 -2
  20. package/dist/ai.js +51 -7
  21. package/dist/cdn/GridMenus-CHOMnW-c.js +602 -0
  22. package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-DhdPF700.js} +152 -150
  23. package/dist/cdn/SvGridChart-1PMSp2LZ.js +1481 -0
  24. package/dist/cdn/SvGridChart-D02avhyr.js +1480 -0
  25. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +834 -0
  26. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +810 -0
  27. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-C--HhdUo.js} +5 -3
  28. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-KyyFG-wY.js} +5 -3
  29. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  30. package/dist/cdn/chart-DLg3_zTQ.js +1652 -0
  31. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  32. package/dist/cdn/{src-C8QjSaq-.js → src-Bugf5XlI.js} +3608 -3414
  33. package/dist/cdn/{src-D0hO2eYN.js → src-D6XvA1Ij.js} +8617 -8423
  34. package/dist/cdn/svgrid.js +16 -15
  35. package/dist/cdn/svgrid.svelte-external.js +16 -15
  36. package/dist/cell-formatting.d.ts +7 -0
  37. package/dist/cell-formatting.js +7 -1
  38. package/dist/chart-export.d.ts +23 -2
  39. package/dist/chart-export.js +80 -6
  40. package/dist/chart.d.ts +410 -18
  41. package/dist/chart.js +824 -24
  42. package/dist/grid-icons.d.ts +49 -0
  43. package/dist/grid-icons.js +86 -0
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.js +3 -2
  46. package/package.json +11 -11
  47. package/src/GridFooter.svelte +14 -5
  48. package/src/GridMenus.svelte +18 -10
  49. package/src/SvGrid.controller.svelte.ts +146 -13
  50. package/src/SvGrid.css +79 -10
  51. package/src/SvGrid.svelte +191 -186
  52. package/src/SvGrid.types.ts +42 -1
  53. package/src/SvGridChart.svelte +571 -41
  54. package/src/SvGridChart.test.ts +243 -1
  55. package/src/SvGridChartPanel.svelte +169 -21
  56. package/src/SvGridChartView.svelte +9 -0
  57. package/src/SvGroupCell.svelte +28 -3
  58. package/src/SvRowGroupPanel.svelte +20 -4
  59. package/src/ai.test.ts +60 -1
  60. package/src/ai.ts +59 -6
  61. package/src/cell-formatting.ts +7 -1
  62. package/src/chart-export.test.ts +112 -0
  63. package/src/chart-export.ts +76 -8
  64. package/src/chart.test.ts +830 -1
  65. package/src/chart.ts +1053 -32
  66. package/src/grid-icons.test.ts +46 -0
  67. package/src/grid-icons.ts +184 -0
  68. package/src/icon-overrides.grid.test.ts +188 -0
  69. package/src/icon-seam.test.ts +100 -0
  70. package/src/index.ts +19 -0
  71. package/src/svgrid.charting.test.ts +120 -0
  72. package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
  73. package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
  74. package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
  75. package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
  76. package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
  77. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest'
2
- import { mount, unmount } from 'svelte'
2
+ import { mount, unmount, createRawSnippet, flushSync } from 'svelte'
3
3
  import SvGridChart from './SvGridChart.svelte'
4
4
  import type { ChartSpec } from './chart'
5
5
 
@@ -67,3 +67,245 @@ describe('SvGridChart horizontal bars', () => {
67
67
  expect(axisText).toContain('Grace')
68
68
  })
69
69
  })
70
+
71
+ describe('SvGridChart candlesticks', () => {
72
+ const bars = [
73
+ { o: 100, h: 110, l: 95, c: 105 },
74
+ { o: 105, h: 108, l: 99, c: 101 },
75
+ ]
76
+ const priced = (type: 'candlestick' | 'ohlc'): ChartSpec => ({
77
+ type,
78
+ categories: ['2026-03-02', '2026-03-03'],
79
+ series: [{ label: 'ACME', values: bars.map((b) => b.c), ohlc: bars }],
80
+ })
81
+
82
+ it('draws a body and a wick per bar', () => {
83
+ const el = render(priced('candlestick'))
84
+ expect(el.querySelectorAll('rect.sv-grid-chart-candle')).toHaveLength(2)
85
+ expect(el.querySelectorAll('line.sv-grid-chart-wick')).toHaveLength(2)
86
+ })
87
+
88
+ it('a rising candle is hollow, a falling one filled', () => {
89
+ const el = render(priced('candlestick'))
90
+ const [up, down] = [...el.querySelectorAll('rect.sv-grid-chart-candle')]
91
+ expect(up!.getAttribute('fill')).toBe('none')
92
+ expect(down!.getAttribute('fill')).not.toBe('none')
93
+ })
94
+
95
+ it('ohlc draws three ticks per bar and no body', () => {
96
+ const el = render(priced('ohlc'))
97
+ expect(el.querySelectorAll('line.sv-grid-chart-ohlc')).toHaveLength(6)
98
+ expect(el.querySelectorAll('rect.sv-grid-chart-candle')).toHaveLength(0)
99
+ })
100
+
101
+ it('gives a screen reader the four real prices, not just the closes', () => {
102
+ const el = render(priced('candlestick'))
103
+ const head = [...el.querySelectorAll('th')].map((t) => t.textContent)
104
+ expect(head).toEqual(['Date', 'Series', 'Open', 'High', 'Low', 'Close'])
105
+ })
106
+ })
107
+
108
+ describe('dense charts (more categories than pixels)', () => {
109
+ const line = (n: number): ChartSpec => ({
110
+ type: 'line',
111
+ categories: Array.from({ length: n }, (_, i) => `c${i}`),
112
+ series: [{ label: 's', values: Array.from({ length: n }, (_, i) => Math.sin(i / 7) * 50 + 60) }],
113
+ width: 800,
114
+ height: 400,
115
+ })
116
+
117
+ it('draws a dot and a hit rect per point while there is room', () => {
118
+ const el = render(line(40)) // 800px / 40 = 20px a category
119
+ expect(el.querySelectorAll('.sv-grid-chart-dot').length).toBe(40)
120
+ expect(el.querySelectorAll('.sv-grid-chart-cat-hit').length).toBe(40)
121
+ })
122
+
123
+ it('collapses the dots and the hit layer once categories go sub-pixel', () => {
124
+ // 800px / 5000 = 0.16px a category. Per-category machinery here is not just
125
+ // slow, it is useless: the dots overlap into a smear and a hit rect is
126
+ // narrower than the pointer.
127
+ const el = render(line(5000))
128
+ expect(el.querySelectorAll('.sv-grid-chart-dot').length).toBe(0)
129
+ const hits = el.querySelectorAll('.sv-grid-chart-cat-hit')
130
+ expect(hits.length).toBe(1)
131
+ expect(hits[0]!.getAttribute('data-dense')).toBe('true')
132
+ // The line itself is still drawn in full - this is about the marks on it,
133
+ // not about dropping data.
134
+ expect(el.querySelector('.sv-grid-chart-linepath')).toBeTruthy()
135
+ })
136
+
137
+ it('keeps the aria-label off the render path when dense', () => {
138
+ // Each per-category rect labels itself by calling catRows(), so 5000 rects
139
+ // meant 5000 tooltip-row computations before anything was hovered.
140
+ const el = render(line(5000))
141
+ const label = el.querySelector('.sv-grid-chart-cat-hit')!.getAttribute('aria-label')!
142
+ expect(label).toContain('5000 points')
143
+ expect(label).toContain('Arrow keys')
144
+ })
145
+
146
+ it('caps the screen-reader table and says so in the caption', () => {
147
+ const el = render(line(5000))
148
+ const rows = el.querySelectorAll('.sv-grid-chart-sr-only tbody tr')
149
+ expect(rows.length).toBe(1000)
150
+ expect(el.querySelector('.sv-grid-chart-sr-only caption')!.textContent).toContain('first 1000 of 5000')
151
+ })
152
+
153
+ it('leaves a small chart entirely alone', () => {
154
+ const el = render(line(40))
155
+ expect(el.querySelector('.sv-grid-chart-sr-only tbody')!.children.length).toBe(40)
156
+ expect(el.querySelector('.sv-grid-chart-sr-only caption')!.textContent).not.toContain('first')
157
+ expect(el.querySelector('[data-dense]')).toBeNull()
158
+ })
159
+
160
+ it('does not collapse a bar chart, whose marks ARE the data', () => {
161
+ // Dropping dots loses nothing because the line still shows the shape.
162
+ // Dropping bars would draw an empty chart, so density only governs the
163
+ // dot / hit / label / table layers, never the marks themselves.
164
+ //
165
+ // 1200 rather than 5000 only because jsdom takes seconds to build that many
166
+ // <rect> nodes; 1200 at 800px is already 0.67px a bar, well inside dense.
167
+ const el = render({ ...line(1200), type: 'bar' })
168
+ expect(el.querySelectorAll('.sv-grid-chart-bar').length).toBe(1200)
169
+ expect(el.querySelector('[data-dense]')).toBeTruthy()
170
+ })
171
+ })
172
+
173
+ describe('custom-series seam (underlay / overlay snippets)', () => {
174
+ const spec: ChartSpec = {
175
+ type: 'bar',
176
+ categories: ['a', 'b', 'c'],
177
+ series: [{ label: 's', values: [10, 20, 30] }],
178
+ width: 500,
179
+ height: 300,
180
+ }
181
+
182
+ it('renders both snippets inside the chart svg', () => {
183
+ const el = render(spec, {
184
+ underlay: createRawSnippet(() => ({ render: () => `<rect class="my-under" />` })),
185
+ overlay: createRawSnippet(() => ({ render: () => `<rect class="my-over" />` })),
186
+ })
187
+ expect(el.querySelector('.sv-grid-chart-svg .my-under')).toBeTruthy()
188
+ expect(el.querySelector('.sv-grid-chart-svg .my-over')).toBeTruthy()
189
+ })
190
+
191
+ it('puts the underlay beneath the bars and the overlay above them', () => {
192
+ const el = render(spec, {
193
+ underlay: createRawSnippet(() => ({ render: () => `<rect class="my-under" />` })),
194
+ overlay: createRawSnippet(() => ({ render: () => `<rect class="my-over" />` })),
195
+ })
196
+ // SVG paints in document order, so position IS z-order.
197
+ const nodes = [...el.querySelectorAll('.sv-grid-chart-svg *')]
198
+ const under = nodes.findIndex((n) => n.classList.contains('my-under'))
199
+ const bar = nodes.findIndex((n) => n.classList.contains('sv-grid-chart-bar'))
200
+ const over = nodes.findIndex((n) => n.classList.contains('my-over'))
201
+ expect(under).toBeGreaterThanOrEqual(0)
202
+ expect(under).toBeLessThan(bar)
203
+ expect(bar).toBeLessThan(over)
204
+ })
205
+
206
+ it('keeps the hit layer above the overlay, so tooltips still work', () => {
207
+ // A custom mark must not be able to swallow the tooltips, the keyboard
208
+ // navigation or the drill clicks.
209
+ const el = render(spec, {
210
+ overlay: createRawSnippet(() => ({ render: () => `<rect class="my-over" />` })),
211
+ })
212
+ const nodes = [...el.querySelectorAll('.sv-grid-chart-svg *')]
213
+ const over = nodes.findIndex((n) => n.classList.contains('my-over'))
214
+ const hit = nodes.findIndex((n) => n.classList.contains('sv-grid-chart-cat-hit'))
215
+ expect(hit).toBeGreaterThan(over)
216
+ })
217
+
218
+ it('draws nothing extra when no snippet is passed', () => {
219
+ const el = render(spec)
220
+ expect(el.querySelector('.my-over')).toBeNull()
221
+ expect(el.querySelector('.sv-grid-chart-bar')).toBeTruthy()
222
+ })
223
+ })
224
+
225
+ describe('interactive annotations', () => {
226
+ const spec: ChartSpec = {
227
+ type: 'bar',
228
+ categories: ['a', 'b', 'c'],
229
+ series: [{ label: 's', values: [10, 20, 30] }],
230
+ width: 500,
231
+ height: 300,
232
+ }
233
+ const noted: ChartSpec = { ...spec, annotations: [{ at: { category: 'b' }, label: 'deploy' }] }
234
+
235
+ it('shows no toggle unless asked for', () => {
236
+ const el = render(spec, { toolbar: true })
237
+ expect([...el.querySelectorAll('.sv-grid-chart-tool')].some((b) => b.textContent?.includes('Annotate'))).toBe(false)
238
+ })
239
+
240
+ it('adds an Annotate toggle, off to begin with', () => {
241
+ const el = render(spec, { annotatable: true })
242
+ const btn = [...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent?.includes('Annotate'))!
243
+ expect(btn).toBeTruthy()
244
+ expect(btn.getAttribute('aria-pressed')).toBe('false')
245
+ })
246
+
247
+ it('reports the data point picked, not a pixel', () => {
248
+ const seen: unknown[] = []
249
+ const el = render(spec, { annotatable: true, onAnnotate: (a: unknown) => seen.push(a) })
250
+ const btn = [...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent?.includes('Annotate'))! as HTMLButtonElement
251
+ btn.click()
252
+ flushSync()
253
+ ;(el.querySelectorAll('.sv-grid-chart-cat-hit')[1] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
254
+ flushSync()
255
+ // Anchored to a category, so the note survives a re-layout, a re-sort or a
256
+ // zoom - which a pixel would not.
257
+ expect(seen).toEqual([{ category: 'b', index: 1, value: 20 }])
258
+ })
259
+
260
+ it('takes over the drill gesture rather than firing both', () => {
261
+ const drills: unknown[] = []
262
+ const notes: unknown[] = []
263
+ const el = render(spec, {
264
+ annotatable: true,
265
+ onSelect: (s: unknown) => drills.push(s),
266
+ onAnnotate: (a: unknown) => notes.push(a),
267
+ })
268
+ const hit = () => (el.querySelectorAll('.sv-grid-chart-cat-hit')[0] as SVGElement).dispatchEvent(new MouseEvent('click', { bubbles: true }))
269
+ hit()
270
+ flushSync()
271
+ expect(drills).toHaveLength(1)
272
+ expect(notes).toHaveLength(0)
273
+
274
+ const btn = [...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent?.includes('Annotate'))! as HTMLButtonElement
275
+ btn.click()
276
+ flushSync()
277
+ hit()
278
+ flushSync()
279
+ expect(notes).toHaveLength(1)
280
+ expect(drills).toHaveLength(1) // not both
281
+ })
282
+
283
+ it('makes existing markers removable, by mouse and by keyboard', () => {
284
+ const removed: number[] = []
285
+ const el = render(noted, { annotatable: true, onAnnotationRemove: (i: number) => removed.push(i) })
286
+ const marker = () => el.querySelector('.sv-grid-chart-annotation-marker') as SVGElement
287
+ // Inert until the mode is on.
288
+ expect(marker().getAttribute('role')).toBeNull()
289
+ marker().dispatchEvent(new MouseEvent('click', { bubbles: true }))
290
+ expect(removed).toEqual([])
291
+
292
+ const btn = [...el.querySelectorAll('.sv-grid-chart-tool')].find((b) => b.textContent?.includes('Annotate'))! as HTMLButtonElement
293
+ btn.click()
294
+ flushSync()
295
+ expect(marker().getAttribute('role')).toBe('button')
296
+ expect(marker().getAttribute('tabindex')).toBe('0')
297
+ marker().dispatchEvent(new MouseEvent('click', { bubbles: true }))
298
+ flushSync()
299
+ expect(removed).toEqual([0])
300
+
301
+ marker().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
302
+ flushSync()
303
+ expect(removed).toEqual([0, 0])
304
+ })
305
+
306
+ it('renders annotations normally when the mode is off', () => {
307
+ const el = render(noted)
308
+ expect(el.querySelectorAll('.sv-grid-chart-annotation-marker')).toHaveLength(1)
309
+ expect(el.querySelector('.sv-grid-chart-annotation-label')!.textContent).toBe('deploy')
310
+ })
311
+ })
@@ -15,7 +15,7 @@
15
15
  import { fly } from "svelte/transition";
16
16
  import { cubicOut } from "svelte/easing";
17
17
  import SvGridChart from "./SvGridChart.svelte";
18
- import { downloadChartPng, downloadChartSvg, downloadChartCsv, chartToPngBlob } from "./chart-export";
18
+ import { downloadChartPng, downloadChartSvg, downloadChartCsv, chartCsvExportable, chartToPngBlob } from "./chart-export";
19
19
  import type { ChartType, ChartSelection, RowData, TableFeatures } from "./index";
20
20
  import type { SvGridController } from "./SvGrid.controller.svelte";
21
21
 
@@ -126,14 +126,103 @@
126
126
  const chartW = $derived(bodyW > 24 ? bodyW - 8 : undefined);
127
127
  const chartH = $derived(bodyH > 24 ? Math.max(120, bodyH - 8 - reserve) : undefined);
128
128
 
129
- // Aggregation-friendly types offered in the picker; exotic types come via
130
- // `buildSpec`, where the picker is hidden.
131
- const TYPES: Array<{ value: ChartType; label: string }> = [
132
- { value: "bar", label: "Bar" },
133
- { value: "line", label: "Line" },
134
- { value: "area", label: "Area" },
135
- { value: "pie", label: "Pie" },
129
+ /**
130
+ * The chart types the picker offers, grouped by what they are FOR.
131
+ *
132
+ * This used to be four entries while the engine drew fifteen, so nine types
133
+ * were reachable only by hand-writing a spec - and `charting.defaultType`
134
+ * accepted them all, which meant setting one left the picker showing
135
+ * something else entirely.
136
+ *
137
+ * `needs` gates on COLUMN SHAPE only, never on the data. Column shape is
138
+ * stable while the user filters; row counts are not, and a picker whose
139
+ * options vanish mid-session because a filter emptied a group is worse than
140
+ * one that explains itself. Data-level problems get a sentence instead, see
141
+ * `typeIssue` below.
142
+ */
143
+ type TypeDef = {
144
+ value: ChartType;
145
+ label: string;
146
+ group: string;
147
+ needs?: (c: { dims: unknown[]; measures: unknown[]; dates: unknown[] }) => boolean;
148
+ };
149
+ const TYPES: TypeDef[] = [
150
+ { value: "bar", label: "Bar", group: "Compare" },
151
+ { value: "line", label: "Line", group: "Compare" },
152
+ { value: "area", label: "Area", group: "Compare" },
153
+ { value: "pie", label: "Pie", group: "Part of a whole" },
154
+ { value: "treemap", label: "Tree map", group: "Part of a whole" },
155
+ { value: "funnel", label: "Funnel", group: "Flow" },
156
+ { value: "waterfall", label: "Waterfall", group: "Flow" },
157
+ { value: "sankey", label: "Sankey", group: "Flow", needs: (c) => c.dims.length >= 2 },
158
+ { value: "radar", label: "Radar", group: "Distribution" },
159
+ { value: "heatmap", label: "Heat map", group: "Distribution", needs: (c) => c.dims.length >= 2 },
160
+ { value: "scatter", label: "Scatter", group: "Distribution", needs: (c) => c.measures.length >= 2 },
161
+ { value: "boxplot", label: "Box plot", group: "Distribution" },
162
+ { value: "gauge", label: "Gauge", group: "Single value" },
163
+ { value: "calendar", label: "Calendar", group: "Over time", needs: (c) => c.dates.length >= 1 },
136
164
  ];
165
+ const availableTypes = $derived(TYPES.filter((t) => !t.needs || t.needs(columns)));
166
+
167
+ // Which controls this type can actually use. Allow-lists rather than the old
168
+ // "not pie" guards: with thirteen types, "everything except pie" hands a
169
+ // gauge a Stacked checkbox.
170
+ const CARTESIAN: ChartType[] = ["bar", "line", "area"];
171
+
172
+ /** Named series palettes. "Theme" defers to --sg-chart-1..8 and the defaults. */
173
+ const PALETTES: Array<{ key: string; label: string; colors: string[] | null }> = [
174
+ { key: "theme", label: "Theme", colors: null },
175
+ { key: "ocean", label: "Ocean", colors: ["#0ea5e9", "#14b8a6", "#6366f1", "#8b5cf6", "#06b6d4"] },
176
+ { key: "sunset", label: "Sunset", colors: ["#f59e0b", "#ef4444", "#ec4899", "#8b5cf6", "#f97316"] },
177
+ { key: "forest", label: "Forest", colors: ["#16a34a", "#65a30d", "#0d9488", "#4d7c0f", "#0891b2"] },
178
+ { key: "slate", label: "Slate", colors: ["#475569", "#0ea5e9", "#64748b", "#38bdf8", "#94a3b8"] },
179
+ ];
180
+ const activePalette = $derived(
181
+ PALETTES.find((x) => x.colors && ctrl.chartPalette && x.colors.join() === ctrl.chartPalette.join())?.key ??
182
+ "theme",
183
+ );
184
+ const wantsHorizontal = $derived(ctrl.chartType === "bar");
185
+ const wantsDonut = $derived(ctrl.chartType === "pie");
186
+ const wantsGroupBy = $derived(ctrl.chartType !== "gauge");
187
+ const wantsSplitBy = $derived(
188
+ columns.dims.length > 1 &&
189
+ !["pie", "gauge", "waterfall", "funnel", "calendar"].includes(ctrl.chartType),
190
+ );
191
+ const wantsStacked = $derived(CARTESIAN.includes(ctrl.chartType));
192
+ const wantsLogScale = $derived(
193
+ CARTESIAN.includes(ctrl.chartType) || ctrl.chartType === "scatter" || ctrl.chartType === "boxplot",
194
+ );
195
+ const wantsDataLabels = $derived(
196
+ !["gauge", "sankey", "calendar", "treemap", "scatter", "boxplot"].includes(ctrl.chartType),
197
+ );
198
+ const wantsSecondMeasure = $derived(ctrl.chartType === "scatter");
199
+ /**
200
+ * A box plot is the one type that reduces its groups itself: the whole point
201
+ * is the spread of the sample, so `sum | avg | count` has nothing to say
202
+ * about it. Showing an Aggregate select that changes nothing is worse than
203
+ * not showing one.
204
+ */
205
+ const wantsReduce = $derived(!wantsSecondMeasure && ctrl.chartType !== "boxplot");
206
+ const typeGroups = $derived([...new Set(availableTypes.map((t) => t.group))]);
207
+
208
+ /**
209
+ * Why the current type cannot draw anything useful yet, as a sentence.
210
+ *
211
+ * The alternative - removing the option - means a chart can disappear from
212
+ * under the user when they filter. Saying what is missing keeps the picker
213
+ * honest without making it unstable.
214
+ */
215
+ const typeIssue = $derived.by<string | null>(() => {
216
+ const t = ctrl.chartType;
217
+ const cats = ctrl.chartSpec?.categories.length ?? 0;
218
+ if (t === "heatmap" && !ctrl.chartSeriesId && columns.dims.length > 1)
219
+ return "Heat map needs a Split by column for its rows.";
220
+ if (t === "sankey" && !ctrl.chartSeriesId)
221
+ return "Sankey needs a Split by column: Group by is the source, Split by the target.";
222
+ if (t === "radar" && cats > 0 && cats < 3) return "Radar needs at least 3 groups.";
223
+ if (t === "funnel" && cats === 1) return "Funnel needs at least 2 stages.";
224
+ return null;
225
+ });
137
226
  const REDUCERS = [
138
227
  { value: "sum", label: "Sum" },
139
228
  { value: "avg", label: "Average" },
@@ -154,7 +243,9 @@
154
243
  let exportOpen = $state(false);
155
244
  let copied = $state(false);
156
245
  const svgEl = () => bodyEl?.querySelector("svg") ?? null;
157
- const csvExportable = $derived(!!(spec && spec.categories?.length && spec.series?.length));
246
+ // One source of truth with the serializer: a scatter chart has no categories
247
+ // but does have points, so the old shape test greyed out an export that works.
248
+ const csvExportable = $derived(!!spec && chartCsvExportable(spec));
158
249
  function exportPng() {
159
250
  const el = svgEl();
160
251
  if (el) void downloadChartPng(el, "chart.png");
@@ -386,18 +477,28 @@
386
477
  <label class="sv-grid-chart-ctl">
387
478
  <span class="sv-grid-chart-ctl-lbl">Type</span>
388
479
  <select value={ctrl.chartType} onchange={(e) => (ctrl.chartType = e.currentTarget.value as ChartType)}>
389
- {#each TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}
480
+ <!-- Grouped: a flat thirteen-item list is a wall. optgroup is
481
+ native, so this costs no new component. -->
482
+ {#each typeGroups as g (g)}
483
+ <optgroup label={g}>
484
+ {#each availableTypes.filter((t) => t.group === g) as t (t.value)}
485
+ <option value={t.value}>{t.label}</option>
486
+ {/each}
487
+ </optgroup>
488
+ {/each}
390
489
  </select>
391
490
  </label>
491
+ {#if wantsGroupBy}
392
492
  <label class="sv-grid-chart-ctl">
393
- <span class="sv-grid-chart-ctl-lbl">Group by</span>
493
+ <span class="sv-grid-chart-ctl-lbl">{ctrl.chartType === "sankey" ? "From" : ctrl.chartType === "heatmap" ? "Columns" : "Group by"}</span>
394
494
  <select value={ctrl.chartDimensionId ?? ""} onchange={(e) => (ctrl.chartDimensionId = e.currentTarget.value || null)}>
395
495
  {#each columns.dims as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
396
496
  </select>
397
497
  </label>
398
- {#if columns.dims.length > 1}
498
+ {/if}
499
+ {#if wantsSplitBy}
399
500
  <label class="sv-grid-chart-ctl">
400
- <span class="sv-grid-chart-ctl-lbl">Split by</span>
501
+ <span class="sv-grid-chart-ctl-lbl">{ctrl.chartType === "sankey" ? "To" : ctrl.chartType === "heatmap" ? "Rows" : "Split by"}</span>
401
502
  <select value={ctrl.chartSeriesId ?? ""} onchange={(e) => (ctrl.chartSeriesId = e.currentTarget.value || null)}>
402
503
  <option value="">(none)</option>
403
504
  {#each columns.dims as d (d.id)}<option value={d.id}>{d.label}</option>{/each}
@@ -405,51 +506,98 @@
405
506
  </label>
406
507
  {/if}
407
508
  <label class="sv-grid-chart-ctl">
408
- <span class="sv-grid-chart-ctl-lbl">Value</span>
509
+ <span class="sv-grid-chart-ctl-lbl">{wantsSecondMeasure ? "X" : ctrl.chartType === "gauge" ? "Metric" : "Value"}</span>
409
510
  <select value={ctrl.chartMeasureId ?? ""} onchange={(e) => (ctrl.chartMeasureId = e.currentTarget.value || null)}>
410
511
  {#each columns.measures as m (m.id)}<option value={m.id}>{m.label}</option>{/each}
411
512
  </select>
412
513
  </label>
514
+ {#if wantsSecondMeasure}
515
+ <label class="sv-grid-chart-ctl">
516
+ <span class="sv-grid-chart-ctl-lbl">Y</span>
517
+ <select value={ctrl.effectiveChartMeasure2Id ?? ""} onchange={(e) => (ctrl.chartMeasure2Id = e.currentTarget.value || null)}>
518
+ {#each columns.measures as m (m.id)}<option value={m.id}>{m.label}</option>{/each}
519
+ </select>
520
+ </label>
521
+ {/if}
522
+ {#if wantsReduce}
413
523
  <label class="sv-grid-chart-ctl">
414
524
  <span class="sv-grid-chart-ctl-lbl">Aggregate</span>
415
525
  <select value={ctrl.chartReduce} onchange={(e) => (ctrl.chartReduce = e.currentTarget.value as "sum" | "avg" | "count")}>
416
526
  {#each REDUCERS as r (r.value)}<option value={r.value}>{r.label}</option>{/each}
417
527
  </select>
418
528
  </label>
529
+ {/if}
419
530
  <label class="sv-grid-chart-ctl">
420
531
  <span class="sv-grid-chart-ctl-lbl">Format</span>
421
532
  <select value={ctrl.chartValueFormat} onchange={(e) => (ctrl.chartValueFormat = e.currentTarget.value as "number" | "currency" | "percent")}>
422
533
  {#each FORMATS as f (f.value)}<option value={f.value}>{f.label}</option>{/each}
423
534
  </select>
424
535
  </label>
425
- {#if ctrl.chartType !== "pie"}
536
+ {#if wantsStacked}
426
537
  <label class="sv-grid-chart-toggle">
427
538
  <input type="checkbox" checked={ctrl.chartStacked} onchange={(e) => (ctrl.chartStacked = e.currentTarget.checked)} />
428
539
  Stacked
429
540
  </label>
541
+ <label class="sv-grid-chart-toggle" title="Normalise each category to 100 percent">
542
+ <input type="checkbox" checked={ctrl.chartStacked100} onchange={(e) => (ctrl.chartStacked100 = e.currentTarget.checked)} />
543
+ 100%
544
+ </label>
545
+ {/if}
546
+ {#if wantsHorizontal}
547
+ <label class="sv-grid-chart-toggle" title="Bars grow rightwards - suits long category labels">
548
+ <input
549
+ type="checkbox"
550
+ checked={ctrl.chartOrientation === "horizontal"}
551
+ onchange={(e) => (ctrl.chartOrientation = e.currentTarget.checked ? "horizontal" : "vertical")}
552
+ />
553
+ Horizontal
554
+ </label>
430
555
  {/if}
431
- <label class="sv-grid-chart-toggle">
432
- <input type="checkbox" checked={ctrl.chartDataLabels} onchange={(e) => (ctrl.chartDataLabels = e.currentTarget.checked)} />
433
- Labels
556
+ {#if wantsDonut}
557
+ <label class="sv-grid-chart-toggle">
558
+ <input type="checkbox" checked={ctrl.chartDonut} onchange={(e) => (ctrl.chartDonut = e.currentTarget.checked)} />
559
+ Donut
560
+ </label>
561
+ {/if}
562
+ <label class="sv-grid-chart-ctl">
563
+ <span class="sv-grid-chart-ctl-lbl">Colours</span>
564
+ <select
565
+ value={activePalette}
566
+ onchange={(e) => (ctrl.chartPalette = PALETTES.find((x) => x.key === e.currentTarget.value)?.colors ?? null)}
567
+ >
568
+ {#each PALETTES as pal (pal.key)}<option value={pal.key}>{pal.label}</option>{/each}
569
+ </select>
434
570
  </label>
435
- {#if ctrl.chartType !== "pie"}
571
+ {#if wantsDataLabels}
572
+ <label class="sv-grid-chart-toggle">
573
+ <input type="checkbox" checked={ctrl.chartDataLabels} onchange={(e) => (ctrl.chartDataLabels = e.currentTarget.checked)} />
574
+ Labels
575
+ </label>
576
+ {/if}
577
+ {#if wantsLogScale}
436
578
  <label class="sv-grid-chart-toggle" title="Logarithmic value axis - flattens wide-range data">
437
579
  <input type="checkbox" checked={ctrl.chartLogScale} onchange={(e) => (ctrl.chartLogScale = e.currentTarget.checked)} />
438
580
  Log scale
439
581
  </label>
440
582
  {/if}
441
- {#if ctrl.chartDimensionIsDate && ctrl.chartType !== "pie"}
583
+ {#if ctrl.chartDimensionIsDate && CARTESIAN.includes(ctrl.chartType)}
442
584
  <label class="sv-grid-chart-toggle" title="Space points by actual date - real time ticks and proportional gaps">
443
585
  <input type="checkbox" checked={ctrl.chartTimeAxis} onchange={(e) => (ctrl.chartTimeAxis = e.currentTarget.checked)} />
444
586
  Date axis
445
587
  </label>
446
588
  {/if}
447
589
  {/if}
590
+ {#if typeIssue}
591
+ <p class="sv-grid-chart-hint" role="note">{typeIssue}</p>
592
+ {/if}
448
593
  </div>
449
594
  </header>
450
595
 
451
596
  <div class="sv-grid-chart-panel-body" bind:this={bodyEl} bind:clientWidth={bodyW} bind:clientHeight={bodyH}>
452
- {#if spec && (spec.series.length || isCustom)}
597
+ <!-- A gauge is one number, so it legitimately carries no series and no
598
+ categories. Gating purely on `series.length` sent it to the "No data"
599
+ message even with a perfectly good spec. -->
600
+ {#if spec && (spec.series.length || spec.gaugeValue != null || isCustom)}
453
601
  <SvGridChart
454
602
  {spec}
455
603
  width={chartW}
@@ -17,12 +17,18 @@
17
17
  let {
18
18
  data,
19
19
  chart,
20
+ localization,
20
21
  }: {
21
22
  data: ReadonlyArray<Record<string, unknown>>;
22
23
  /** Present for parity with board/scheduler renderers; unused here. */
23
24
  columns?: unknown;
24
25
  getRowId?: unknown;
25
26
  chart: ChartViewConfig;
27
+ /** The grid's localization, so a localized grid gets a localized chart
28
+ * without saying so twice. Resolved here rather than in SvGrid because
29
+ * this renderer is lazy: a grid that never charts should not carry the
30
+ * fallback in its base bundle. */
31
+ localization?: { locale?: string | ReadonlyArray<string> };
26
32
  } = $props();
27
33
 
28
34
  // Container size -> chart viewBox, so the chart fills the grid area and
@@ -44,6 +50,9 @@
44
50
  palette: chart.palette,
45
51
  });
46
52
  if (chart.valueFormat) s.valueFormat = chart.valueFormat;
53
+ const locale = chart.locale ?? localization?.locale;
54
+ if (locale) s.locale = locale;
55
+ if (chart.currency) s.currency = chart.currency;
47
56
  return s;
48
57
  });
49
58
  </script>
@@ -10,6 +10,7 @@
10
10
  -->
11
11
  <script lang="ts" generics="TData">
12
12
  import type { ServerDisplayRow } from './server-data-source'
13
+ import type { GridIcons } from './grid-icons'
13
14
 
14
15
  type GroupGridRow = { __group?: ServerDisplayRow<TData> } & Record<string, unknown>
15
16
  type Props = {
@@ -24,9 +25,17 @@
24
25
  leafField?: string
25
26
  /** Pixels of indentation per level. Default 18. */
26
27
  indent?: number
28
+ /**
29
+ * Icon overrides, the same map `<SvGrid icons>` takes. Only
30
+ * `chevron-right` is drawn here. You mount this component yourself, so it
31
+ * cannot inherit the grid's icons - pass the same object to both to keep
32
+ * the expander consistent with the rest of the chrome.
33
+ */
34
+ icons?: GridIcons
27
35
  }
28
36
 
29
- let { row, onToggle, leafField, indent = 18 }: Props = $props()
37
+ let { row, onToggle, leafField, indent = 18, icons }: Props = $props()
38
+ const chevron = $derived(icons?.['chevron-right'])
30
39
  const meta = $derived(row.__group)
31
40
  </script>
32
41
 
@@ -42,8 +51,12 @@
42
51
  <!-- Same chevron the grid's own expanders and SvTree draw, so every
43
52
  expander in the library looks identical. -->
44
53
  <span class="sv-group-chev" class:open={meta.expanded} aria-hidden="true">
45
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
46
- stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
54
+ {#if chevron}
55
+ {@render chevron()}
56
+ {:else}
57
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
58
+ stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
59
+ {/if}
47
60
  </span>
48
61
  <span class="sv-group-key">{meta.key}</span>
49
62
  {#if meta.loading}<span class="sv-group-load" aria-hidden="true">…</span>{/if}
@@ -84,9 +97,21 @@
84
97
  display: inline-flex;
85
98
  align-items: center;
86
99
  justify-content: center;
100
+ width: 12px;
101
+ height: 12px;
87
102
  transition: transform 0.15s;
88
103
  color: var(--sg-muted, #64748b);
89
104
  }
105
+ /* An `icons` override renders inside this wrapper, and this style block is
106
+ scoped, so its markup needs :global() to be reached. Sizing it here means
107
+ a custom chevron lands in the same box as the built-in and rotates with
108
+ the wrapper rather than needing to know the rule exists. */
109
+ .sv-group-chev :global(svg),
110
+ .sv-group-chev :global(img) {
111
+ width: 100%;
112
+ height: 100%;
113
+ display: block;
114
+ }
90
115
  .sv-group-chev.open { transform: rotate(90deg); }
91
116
  .sv-group-key { font-weight: 600; }
92
117
  .sv-group-load { color: var(--sg-muted, #64748b); font-size: 12px; }