@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.
- package/dist/GridFooter.svelte +14 -5
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridChart.svelte +571 -41
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +169 -21
- package/dist/SvGridChartView.svelte +9 -0
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGroupCell.svelte +28 -3
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvRowGroupPanel.svelte +20 -4
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-CHOMnW-c.js +602 -0
- package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-DhdPF700.js} +152 -150
- package/dist/cdn/SvGridChart-1PMSp2LZ.js +1481 -0
- package/dist/cdn/SvGridChart-D02avhyr.js +1480 -0
- package/dist/cdn/SvGridChartPanel-DJnViOv4.js +834 -0
- package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +810 -0
- package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-C--HhdUo.js} +5 -3
- package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-KyyFG-wY.js} +5 -3
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-DLg3_zTQ.js +1652 -0
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-C8QjSaq-.js → src-Bugf5XlI.js} +3608 -3414
- package/dist/cdn/{src-D0hO2eYN.js → src-D6XvA1Ij.js} +8617 -8423
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +80 -6
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +11 -11
- package/src/GridFooter.svelte +14 -5
- package/src/GridMenus.svelte +18 -10
- package/src/SvGrid.controller.svelte.ts +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +42 -1
- package/src/SvGridChart.svelte +571 -41
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +169 -21
- package/src/SvGridChartView.svelte +9 -0
- package/src/SvGroupCell.svelte +28 -3
- package/src/SvRowGroupPanel.svelte +20 -4
- package/src/ai.test.ts +60 -1
- package/src/ai.ts +59 -6
- package/src/cell-formatting.ts +7 -1
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +76 -8
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +1053 -32
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/svgrid.charting.test.ts +120 -0
- package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
- package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
- package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
- package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
- package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/SvGridChart.test.ts
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
432
|
-
<
|
|
433
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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>
|
package/src/SvGroupCell.svelte
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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; }
|