@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
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
/>
|
|
13
13
|
-->
|
|
14
14
|
<script lang="ts">
|
|
15
|
+
import { GRID_ICON_GLYPHS, type GridIconName, type GridIcons } from './grid-icons'
|
|
16
|
+
|
|
15
17
|
type Col = { id: string; label: string }
|
|
16
18
|
type Props = {
|
|
17
19
|
/** Columns that can be grouped on. */
|
|
@@ -22,8 +24,15 @@
|
|
|
22
24
|
onChange: (groupBy: string[]) => void
|
|
23
25
|
/** Text shown when nothing is grouped. */
|
|
24
26
|
placeholder?: string
|
|
27
|
+
/**
|
|
28
|
+
* Icon overrides, the same map `<SvGrid icons>` takes. This panel draws
|
|
29
|
+
* `drag-handle`, `remove` and `breadcrumb-separator`. You mount it
|
|
30
|
+
* yourself, so pass the same object you gave the grid to keep them
|
|
31
|
+
* consistent.
|
|
32
|
+
*/
|
|
33
|
+
icons?: GridIcons
|
|
25
34
|
}
|
|
26
|
-
let { columns, groupBy, onChange, placeholder = 'Drag a column here to group by it' }: Props = $props()
|
|
35
|
+
let { columns, groupBy, onChange, placeholder = 'Drag a column here to group by it', icons }: Props = $props()
|
|
27
36
|
|
|
28
37
|
const labelOf = (id: string) => columns.find((c) => c.id === id)?.label ?? id
|
|
29
38
|
const available = $derived(columns.filter((c) => !groupBy.includes(c.id)))
|
|
@@ -68,6 +77,13 @@
|
|
|
68
77
|
}
|
|
69
78
|
</script>
|
|
70
79
|
|
|
80
|
+
<!-- Same override-or-default resolution SvGrid uses, kept local because this
|
|
81
|
+
component is mounted by the consumer and never sees the grid's snippet. -->
|
|
82
|
+
{#snippet ic(name: GridIconName)}
|
|
83
|
+
{@const override = icons?.[name]}
|
|
84
|
+
{#if override}{@render override()}{:else}{GRID_ICON_GLYPHS[name]}{/if}
|
|
85
|
+
{/snippet}
|
|
86
|
+
|
|
71
87
|
<div
|
|
72
88
|
class="sv-rowgroup-panel"
|
|
73
89
|
role="group"
|
|
@@ -80,7 +96,7 @@
|
|
|
80
96
|
<span class="sv-rgp-empty">{placeholder}</span>
|
|
81
97
|
{:else}
|
|
82
98
|
{#each groupBy as id, i (id)}
|
|
83
|
-
{#if i > 0}<span class="sv-rgp-sep" aria-hidden="true"
|
|
99
|
+
{#if i > 0}<span class="sv-rgp-sep" aria-hidden="true">{@render ic('breadcrumb-separator')}</span>{/if}
|
|
84
100
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
85
101
|
<span
|
|
86
102
|
class="sv-rgp-chip"
|
|
@@ -95,9 +111,9 @@
|
|
|
95
111
|
ondrop={() => reorder(i)}
|
|
96
112
|
onkeydown={(e) => onChipKeydown(e, i)}
|
|
97
113
|
>
|
|
98
|
-
<span class="sv-rgp-grip" aria-hidden="true"
|
|
114
|
+
<span class="sv-rgp-grip" aria-hidden="true">{@render ic('drag-handle')}</span>
|
|
99
115
|
{labelOf(id)}
|
|
100
|
-
<button type="button" class="sv-rgp-x" onclick={() => remove(id)} aria-label={`Stop grouping by ${labelOf(id)}`}
|
|
116
|
+
<button type="button" class="sv-rgp-x" onclick={() => remove(id)} aria-label={`Stop grouping by ${labelOf(id)}`}>{@render ic('remove')}</button>
|
|
101
117
|
</span>
|
|
102
118
|
{/each}
|
|
103
119
|
{/if}
|
package/src/ai.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
|
2
2
|
import {
|
|
3
3
|
setAIProvider, getAIProvider, hasAIProvider,
|
|
4
4
|
mockAIProvider,
|
|
5
|
-
aiFilter, aiSmartFill, aiSummarize, aiClassify,
|
|
5
|
+
aiFilter, aiSmartFill, aiSummarize, aiClassify, aiChart,
|
|
6
6
|
type AIProvider, type AIRequest,
|
|
7
7
|
} from './ai'
|
|
8
8
|
|
|
@@ -500,3 +500,62 @@ describe('cancellation', () => {
|
|
|
500
500
|
expect(receivedSignal).toBe(controller.signal)
|
|
501
501
|
})
|
|
502
502
|
})
|
|
503
|
+
|
|
504
|
+
describe('aiChart type vocabulary', () => {
|
|
505
|
+
beforeEach(() => setAIProvider(mockAIProvider))
|
|
506
|
+
afterEach(() => setAIProvider(null))
|
|
507
|
+
|
|
508
|
+
const rows = [
|
|
509
|
+
{ region: 'EMEA', product: 'A', day: '2026-01-01', revenue: 10 },
|
|
510
|
+
{ region: 'APAC', product: 'B', day: '2026-01-02', revenue: 20 },
|
|
511
|
+
]
|
|
512
|
+
const plan = (q: string) => aiChart(fakeApi(rows).api as never, q)
|
|
513
|
+
|
|
514
|
+
it('reaches the types the panel can build, not just the original four', async () => {
|
|
515
|
+
// The prompt offered bar|line|area|pie while the picker had grown to
|
|
516
|
+
// thirteen, and anything else was coerced to 'bar' - so "chart this" could
|
|
517
|
+
// never produce nine of the types sitting right next to it.
|
|
518
|
+
expect((await plan('show the drop-off by stage')).type).toBe('funnel')
|
|
519
|
+
expect((await plan('treemap of revenue by region')).type).toBe('treemap')
|
|
520
|
+
expect((await plan('running total contribution by region')).type).toBe('waterfall')
|
|
521
|
+
expect((await plan('the spread of revenue by region, with outliers')).type).toBe('boxplot')
|
|
522
|
+
expect((await plan('a heatmap of revenue')).type).toBe('heatmap')
|
|
523
|
+
expect((await plan('flow from region to product')).type).toBe('sankey')
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('still defaults to a bar chart for a plain request', async () => {
|
|
527
|
+
expect((await plan('revenue by region')).type).toBe('bar')
|
|
528
|
+
expect((await plan('revenue over time')).type).toBe('line')
|
|
529
|
+
expect((await plan('share of revenue by region')).type).toBe('pie')
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('never returns a plan the panel would draw as an empty frame', async () => {
|
|
533
|
+
// Two-dimension types need a split; a calendar needs a real date column.
|
|
534
|
+
// Rather than render nothing and blame the request, they fall back to a
|
|
535
|
+
// bar, which any dimension and measure can draw.
|
|
536
|
+
// Asserted unconditionally. An `if (p.type === 'heatmap')` here would pass
|
|
537
|
+
// by doing nothing the day the mock stops producing one.
|
|
538
|
+
const heat = await plan('heatmap of revenue')
|
|
539
|
+
expect(heat.type).toBe('heatmap')
|
|
540
|
+
expect(heat.series).toBeTruthy()
|
|
541
|
+
const flow = await plan('flow from region to product')
|
|
542
|
+
expect(flow.type).toBe('sankey')
|
|
543
|
+
expect(flow.series).toBeTruthy()
|
|
544
|
+
const noDates = [{ region: 'EMEA', revenue: 1 }]
|
|
545
|
+
const cal = await aiChart(fakeApi(noDates).api as never, 'a calendar of revenue')
|
|
546
|
+
expect(cal.type).toBe('bar')
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('drops the dimension for a gauge, which has no category axis', async () => {
|
|
550
|
+
const g = await plan('revenue on a gauge against target')
|
|
551
|
+
expect(g.type).toBe('gauge')
|
|
552
|
+
expect(g.dimension).toBeNull()
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('coerces an unknown type rather than passing it through', async () => {
|
|
556
|
+
const bogus: AIProvider = async () =>
|
|
557
|
+
JSON.stringify({ type: 'sunburst', dimension: 'region', measure: 'revenue', reduce: 'sum' })
|
|
558
|
+
setAIProvider(bogus)
|
|
559
|
+
expect((await plan('anything')).type).toBe('bar')
|
|
560
|
+
})
|
|
561
|
+
})
|
package/src/ai.ts
CHANGED
|
@@ -835,8 +835,20 @@ export async function aiFindAnomalies<
|
|
|
835
835
|
// 7. Natural-language chart ("chart this")
|
|
836
836
|
// ---------------------------------------------------------------------------
|
|
837
837
|
|
|
838
|
-
/**
|
|
839
|
-
|
|
838
|
+
/**
|
|
839
|
+
* Chart shapes the model may choose from when planning a visualisation.
|
|
840
|
+
*
|
|
841
|
+
* These are exactly the types the built-in panel can build from a dimension, a
|
|
842
|
+
* measure and an optional split - which is all a chart plan carries. Two
|
|
843
|
+
* families are deliberately absent, because a plan cannot express them:
|
|
844
|
+
* scatter needs a SECOND measure for its y axis, and candlestick / OHLC need
|
|
845
|
+
* four under first/max/min/last. Asking for either would produce a plan the
|
|
846
|
+
* panel renders as an empty frame, which is worse than not offering it.
|
|
847
|
+
*/
|
|
848
|
+
export type AIChartType =
|
|
849
|
+
| 'bar' | 'line' | 'area' | 'pie'
|
|
850
|
+
| 'treemap' | 'funnel' | 'waterfall' | 'radar'
|
|
851
|
+
| 'heatmap' | 'boxplot' | 'gauge' | 'calendar' | 'sankey'
|
|
840
852
|
|
|
841
853
|
/** A chart the model proposed: its type, and the fields to plot. */
|
|
842
854
|
export type AIChartPlan = {
|
|
@@ -881,7 +893,7 @@ export async function aiChart<
|
|
|
881
893
|
`into a strict-JSON chart plan the grid can render.\n\n` +
|
|
882
894
|
`Columns:\n${schemaToPromptBlock(schema)}\n\n` +
|
|
883
895
|
`Output JSON schema:\n` +
|
|
884
|
-
`{ "type": "bar"|"line"|"area"|"pie", ` +
|
|
896
|
+
`{ "type": "bar"|"line"|"area"|"pie"|"treemap"|"funnel"|"waterfall"|"radar"|"heatmap"|"boxplot"|"gauge"|"calendar"|"sankey", ` +
|
|
885
897
|
`"dimension": "<a categorical column to group by>", ` +
|
|
886
898
|
`"series": "<a second categorical column to split into series, or null>", ` +
|
|
887
899
|
`"measure": "<a numeric column to aggregate>", ` +
|
|
@@ -893,6 +905,16 @@ export async function aiChart<
|
|
|
893
905
|
`"rationale": "<one-sentence explanation>" }\n\n` +
|
|
894
906
|
`Rules: pick "dimension" and "series" from text/date columns and "measure" ` +
|
|
895
907
|
`from a numeric column; use only column names from the list above; ` +
|
|
908
|
+
`default to "bar" unless the request clearly wants another type. ` +
|
|
909
|
+
`Type hints: share/proportion/breakdown -> pie; nested/hierarchy/"what makes ` +
|
|
910
|
+
`up" -> treemap; drop-off/conversion/stages/pipeline -> funnel; ` +
|
|
911
|
+
`running total/contribution/bridge -> waterfall; comparing several measures ` +
|
|
912
|
+
`per item -> radar; a grid of two dimensions -> heatmap (needs "series"); ` +
|
|
913
|
+
`spread/distribution/variance/outliers/percentiles -> boxplot; a single ` +
|
|
914
|
+
`number vs a target -> gauge (leave "dimension" null); a year of daily ` +
|
|
915
|
+
`activity -> calendar (dimension must be a date column); flow between two ` +
|
|
916
|
+
`things -> sankey (needs "series": dimension is the source, series the ` +
|
|
917
|
+
`target). ` +
|
|
896
918
|
`"stacked"/"stack" -> stacked:true; "log"/"logarithmic" -> logScale:true; ` +
|
|
897
919
|
`"over time"/"by date"/a date dimension -> timeAxis:true; ` +
|
|
898
920
|
`money/revenue/price/$ -> valueFormat:"currency"; rate/%/share -> valueFormat:"percent"; ` +
|
|
@@ -909,7 +931,11 @@ export async function aiChart<
|
|
|
909
931
|
|
|
910
932
|
const valid = new Set(schema.map((c) => c.field))
|
|
911
933
|
const numericFields = new Set(schema.filter((c) => c.type === 'number').map((c) => c.field))
|
|
912
|
-
const CHART_TYPES: AIChartType[] = [
|
|
934
|
+
const CHART_TYPES: AIChartType[] = [
|
|
935
|
+
'bar', 'line', 'area', 'pie',
|
|
936
|
+
'treemap', 'funnel', 'waterfall', 'radar',
|
|
937
|
+
'heatmap', 'boxplot', 'gauge', 'calendar', 'sankey',
|
|
938
|
+
]
|
|
913
939
|
plan.type = CHART_TYPES.includes(plan.type) ? plan.type : 'bar'
|
|
914
940
|
plan.dimension = plan.dimension && valid.has(plan.dimension) ? plan.dimension : null
|
|
915
941
|
plan.series = plan.series && valid.has(plan.series) && plan.series !== plan.dimension ? plan.series : null
|
|
@@ -917,6 +943,18 @@ export async function aiChart<
|
|
|
917
943
|
plan.measure && numericFields.has(plan.measure)
|
|
918
944
|
? plan.measure
|
|
919
945
|
: (schema.find((c) => c.type === 'number')?.field ?? null)
|
|
946
|
+
// Shape check. Some types need more than a dimension and a measure, and a
|
|
947
|
+
// plan that does not satisfy one renders an empty frame - the model asked for
|
|
948
|
+
// a chart the data cannot make. Fall back to a bar, which any dimension and
|
|
949
|
+
// measure can draw, rather than showing nothing and blaming the request.
|
|
950
|
+
const dateFields = new Set(schema.filter((c) => c.type === 'date').map((c) => c.field))
|
|
951
|
+
const needsSplit = plan.type === 'heatmap' || plan.type === 'sankey'
|
|
952
|
+
if (needsSplit && !plan.series) plan.type = 'bar'
|
|
953
|
+
if (plan.type === 'calendar' && !(plan.dimension && dateFields.has(plan.dimension))) plan.type = 'bar'
|
|
954
|
+
// A gauge is one number: it has no category axis, so a dimension is noise.
|
|
955
|
+
if (plan.type === 'gauge') plan.dimension = null
|
|
956
|
+
if (plan.type !== 'gauge' && !plan.dimension) plan.type = 'bar'
|
|
957
|
+
|
|
920
958
|
plan.reduce = ['sum', 'avg', 'count'].includes(plan.reduce) ? plan.reduce : 'sum'
|
|
921
959
|
plan.stacked = plan.stacked === true
|
|
922
960
|
plan.logScale = plan.logScale === true
|
|
@@ -1031,8 +1069,20 @@ function buildMockChart(prompt: string): AIChartPlan {
|
|
|
1031
1069
|
const textFields = typed.filter((f) => f.type === 'string' || f.type === 'date').map((f) => f.name)
|
|
1032
1070
|
const numberFields = typed.filter((f) => f.type === 'number').map((f) => f.name)
|
|
1033
1071
|
|
|
1072
|
+
// Mirrors the type hints in the real prompt, so the bundled offline provider
|
|
1073
|
+
// can reach the same charts a model can. Ordered most-specific first: "share
|
|
1074
|
+
// of the pipeline" is a funnel request, not a pie one.
|
|
1034
1075
|
const type: AIChartType =
|
|
1035
|
-
/\
|
|
1076
|
+
/\bfunnel\b|drop.?off|conversion|\bstages?\b|pipeline/.test(q) ? 'funnel'
|
|
1077
|
+
: /\bwaterfall\b|running total|contribution|\bbridge\b/.test(q) ? 'waterfall'
|
|
1078
|
+
: /\btree ?map\b|nested|hierarch/.test(q) ? 'treemap'
|
|
1079
|
+
: /\bradar\b|spider|multi.?measure/.test(q) ? 'radar'
|
|
1080
|
+
: /\bheat ?map\b/.test(q) ? 'heatmap'
|
|
1081
|
+
: /\bbox ?plot\b|spread|distribution|variance|outlier|percentile|quartile/.test(q) ? 'boxplot'
|
|
1082
|
+
: /\bgauge\b|\bdial\b|vs target|against target/.test(q) ? 'gauge'
|
|
1083
|
+
: /\bcalendar\b|daily activity|year of/.test(q) ? 'calendar'
|
|
1084
|
+
: /\bsankey\b|\bflow\b/.test(q) ? 'sankey'
|
|
1085
|
+
: /\bline\b|\btrend\b|over time/.test(q) ? 'line'
|
|
1036
1086
|
: /\barea\b/.test(q) ? 'area'
|
|
1037
1087
|
: /\bpie\b|share|proportion|breakdown/.test(q) ? 'pie'
|
|
1038
1088
|
: 'bar'
|
|
@@ -1046,8 +1096,11 @@ function buildMockChart(prompt: string): AIChartPlan {
|
|
|
1046
1096
|
return null
|
|
1047
1097
|
}
|
|
1048
1098
|
const dimension = pickText(byMatch?.[1]) ?? textFields[0] ?? null
|
|
1099
|
+
// Heatmap and sankey are two-dimension charts: without a split the shape
|
|
1100
|
+
// check downgrades them to a bar, so the mock would answer "heatmap" with a
|
|
1101
|
+
// bar chart and look broken rather than offline.
|
|
1049
1102
|
const series =
|
|
1050
|
-
/\b(split|stack(?:ed)?|by product|by category|per)\b/.test(q)
|
|
1103
|
+
/\b(split|stack(?:ed)?|by product|by category|per)\b/.test(q) || type === 'heatmap' || type === 'sankey'
|
|
1051
1104
|
? (textFields.find((t) => t !== dimension) ?? null)
|
|
1052
1105
|
: null
|
|
1053
1106
|
const measure = numberFields.find((n) => q.includes(n.toLowerCase())) ?? numberFields[0] ?? null
|
package/src/cell-formatting.ts
CHANGED
|
@@ -74,7 +74,13 @@ type NumericFormatInput = {
|
|
|
74
74
|
* + currency + options).
|
|
75
75
|
*/
|
|
76
76
|
const numberFormatterCache = new Map<string, Intl.NumberFormat>()
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* A cached `Intl.NumberFormat` for the locale and options given. Cached because
|
|
79
|
+
* constructing one is among the most expensive calls in the platform, and both
|
|
80
|
+
* callers do it per rendered value: once per cell while scrolling a number
|
|
81
|
+
* column, and once per axis tick, data label and tooltip while drawing a chart.
|
|
82
|
+
*/
|
|
83
|
+
export function getNumberFormatter(
|
|
78
84
|
locales: string | readonly string[] | undefined,
|
|
79
85
|
options: Intl.NumberFormatOptions,
|
|
80
86
|
): Intl.NumberFormat {
|
package/src/chart-export.test.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
chartToPngBlob,
|
|
5
5
|
downloadChartSvg,
|
|
6
6
|
downloadChartPng,
|
|
7
|
+
chartSpecToCsv,
|
|
8
|
+
chartCsvExportable,
|
|
7
9
|
} from './chart-export'
|
|
10
|
+
import type { ChartSpec } from './chart'
|
|
8
11
|
|
|
9
12
|
const SVG_NS = 'http://www.w3.org/2000/svg'
|
|
10
13
|
|
|
@@ -368,3 +371,112 @@ describe('downloadChartPng', () => {
|
|
|
368
371
|
expect(revoke).toHaveBeenCalledWith('blob:png-url')
|
|
369
372
|
})
|
|
370
373
|
})
|
|
374
|
+
|
|
375
|
+
describe('chartSpecToCsv: series that carry more than one number', () => {
|
|
376
|
+
it('keeps a plain series byte-identical', () => {
|
|
377
|
+
const csv = chartSpecToCsv({
|
|
378
|
+
type: 'bar',
|
|
379
|
+
categories: ['a', 'b'],
|
|
380
|
+
series: [{ label: 'rev', values: [1, 2] }, { label: 'cost', values: [3, 4] }],
|
|
381
|
+
})
|
|
382
|
+
expect(csv).toBe('Category,rev,cost\na,1,3\nb,2,4')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('writes all four prices for a candlestick, not just the closes', () => {
|
|
386
|
+
// The screen-reader table was given the four real numbers on purpose. The
|
|
387
|
+
// export is what people actually take away, and it wrote `values` - the
|
|
388
|
+
// closes - and silently dropped the rest.
|
|
389
|
+
const csv = chartSpecToCsv({
|
|
390
|
+
type: 'candlestick',
|
|
391
|
+
categories: ['2026-03-02', '2026-03-03'],
|
|
392
|
+
series: [{
|
|
393
|
+
label: 'ACME',
|
|
394
|
+
values: [105, 101],
|
|
395
|
+
ohlc: [{ o: 100, h: 110, l: 95, c: 105 }, { o: 105, h: 108, l: 99, c: 101 }],
|
|
396
|
+
}],
|
|
397
|
+
})
|
|
398
|
+
const [header, ...rows] = csv.split('\n')
|
|
399
|
+
expect(header).toBe('Category,ACME Open,ACME High,ACME Low,ACME Close')
|
|
400
|
+
expect(rows[0]).toBe('2026-03-02,100,110,95,105')
|
|
401
|
+
expect(rows[1]).toBe('2026-03-03,105,108,99,101')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('writes the whole five-number summary for a box plot', () => {
|
|
405
|
+
const csv = chartSpecToCsv({
|
|
406
|
+
type: 'boxplot',
|
|
407
|
+
categories: ['eu', 'ap'],
|
|
408
|
+
series: [{
|
|
409
|
+
label: 'ms',
|
|
410
|
+
values: [20, 30],
|
|
411
|
+
boxes: [
|
|
412
|
+
{ min: 10, q1: 15, median: 20, q3: 25, max: 30, outliers: [90, 120] },
|
|
413
|
+
{ min: 20, q1: 25, median: 30, q3: 35, max: 40 },
|
|
414
|
+
],
|
|
415
|
+
}],
|
|
416
|
+
})
|
|
417
|
+
const [header, ...rows] = csv.split('\n')
|
|
418
|
+
expect(header).toBe('Category,ms Min,ms Q1,ms Median,ms Q3,ms Max,ms Outliers')
|
|
419
|
+
expect(rows[0]).toBe('eu,10,15,20,25,30,90 120')
|
|
420
|
+
// A box with no outliers leaves the cell empty rather than writing "0".
|
|
421
|
+
expect(rows[1]).toBe('ap,20,25,30,35,40,')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('resolves error bars to absolute low / high', () => {
|
|
425
|
+
const csv = chartSpecToCsv({
|
|
426
|
+
type: 'bar',
|
|
427
|
+
categories: ['a', 'b', 'c'],
|
|
428
|
+
series: [{ label: 'mean', values: [10, 20, 30], errors: [2, { lo: 25, hi: 18 }, null] }],
|
|
429
|
+
})
|
|
430
|
+
const [header, ...rows] = csv.split('\n')
|
|
431
|
+
expect(header).toBe('Category,mean,mean Low,mean High')
|
|
432
|
+
expect(rows[0]).toBe('a,10,8,12') // symmetric margin
|
|
433
|
+
expect(rows[1]).toBe('b,20,18,25') // explicit pair, order normalised
|
|
434
|
+
expect(rows[2]).toBe('c,30,,') // null: value only
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
it('exports scatter points instead of nothing', () => {
|
|
438
|
+
// Scatter has no categories, so this used to return '' and the export
|
|
439
|
+
// button did nothing at all.
|
|
440
|
+
const csv = chartSpecToCsv({
|
|
441
|
+
type: 'scatter',
|
|
442
|
+
categories: [],
|
|
443
|
+
series: [
|
|
444
|
+
{ label: 'EMEA', values: [], points: [{ x: 1, y: 2, r: 3, label: 'Ada' }] },
|
|
445
|
+
{ label: 'APAC', values: [], points: [{ x: 4, y: 5 }] },
|
|
446
|
+
],
|
|
447
|
+
})
|
|
448
|
+
expect(csv.split('\n')).toEqual([
|
|
449
|
+
'Series,X,Y,Size,Label',
|
|
450
|
+
'EMEA,1,2,3,Ada',
|
|
451
|
+
'APAC,4,5,,',
|
|
452
|
+
])
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('still writes nothing for the specs with no rectangular data', () => {
|
|
456
|
+
expect(chartSpecToCsv({ type: 'gauge', categories: [], series: [], gaugeValue: 5 })).toBe('')
|
|
457
|
+
expect(chartSpecToCsv({ type: 'bar', categories: [], series: [] })).toBe('')
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
describe('chartCsvExportable', () => {
|
|
462
|
+
it('agrees with the serializer on every shape', () => {
|
|
463
|
+
// The panel greys out its CSV item on this predicate rather than on
|
|
464
|
+
// `chartSpecToCsv(spec) !== ''`, because that would serialise a 100k-point
|
|
465
|
+
// series on every render just to decide whether a button is enabled. The
|
|
466
|
+
// cost of a mirror is drift, so assert the two agree.
|
|
467
|
+
const specs: ChartSpec[] = [
|
|
468
|
+
{ type: 'bar', categories: ['a'], series: [{ label: 's', values: [1] }] },
|
|
469
|
+
{ type: 'bar', categories: [], series: [] },
|
|
470
|
+
{ type: 'bar', categories: ['a'], series: [] },
|
|
471
|
+
{ type: 'bar', categories: [], series: [{ label: 's', values: [] }] },
|
|
472
|
+
{ type: 'gauge', categories: [], series: [], gaugeValue: 5 },
|
|
473
|
+
{ type: 'scatter', categories: [], series: [{ label: 's', values: [], points: [{ x: 1, y: 2 }] }] },
|
|
474
|
+
{ type: 'scatter', categories: [], series: [{ label: 's', values: [], points: [] }] },
|
|
475
|
+
]
|
|
476
|
+
for (const spec of specs) {
|
|
477
|
+
expect(chartCsvExportable(spec), JSON.stringify(spec.type + ':' + spec.categories.length)).toBe(
|
|
478
|
+
chartSpecToCsv(spec) !== '',
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
})
|
|
482
|
+
})
|
package/src/chart-export.ts
CHANGED
|
@@ -176,18 +176,86 @@ const csvCell = (v: unknown): string => {
|
|
|
176
176
|
|
|
177
177
|
/**
|
|
178
178
|
* Serialize a chart's data to CSV: one row per category, one column per series
|
|
179
|
-
* (`Category, <series 1>, ...`).
|
|
180
|
-
*
|
|
179
|
+
* (`Category, <series 1>, ...`).
|
|
180
|
+
*
|
|
181
|
+
* A series that carries more than one number per category widens instead of
|
|
182
|
+
* losing them. `ohlc` becomes four columns, `boxes` five plus its outliers,
|
|
183
|
+
* `errors` two. This used to write `values` and nothing else, which quietly
|
|
184
|
+
* made "Export CSV" on a candlestick chart hand back the closing prices only -
|
|
185
|
+
* the screen-reader table was given the four real numbers on purpose and the
|
|
186
|
+
* export, which is what people actually take away, was not.
|
|
187
|
+
*
|
|
188
|
+
* Scatter has no categories, so it gets its own long-format table of points
|
|
189
|
+
* rather than exporting nothing at all.
|
|
190
|
+
*
|
|
191
|
+
* Still empty for the specs with no rectangular data to write: sankey, treemap,
|
|
192
|
+
* gauge and calendar.
|
|
181
193
|
*/
|
|
182
194
|
export function chartSpecToCsv(spec: ChartSpec): string {
|
|
183
|
-
const cats = spec.categories ?? []
|
|
184
195
|
const series = spec.series ?? []
|
|
196
|
+
// Scatter: one row per point. `categories` is empty for these, so the
|
|
197
|
+
// category path below would return '' and the export button would do nothing.
|
|
198
|
+
if (series.some((s) => s.points?.length)) {
|
|
199
|
+
const header = ['Series', 'X', 'Y', 'Size', 'Label'].map(csvCell).join(',')
|
|
200
|
+
const rows: string[] = []
|
|
201
|
+
for (const s of series) {
|
|
202
|
+
for (const p of s.points ?? []) {
|
|
203
|
+
rows.push([s.label, p.x, p.y, p.r ?? '', p.label ?? ''].map(csvCell).join(','))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return rows.length ? [header, ...rows].join('\n') : ''
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const cats = spec.categories ?? []
|
|
185
210
|
if (!cats.length || !series.length) return ''
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
211
|
+
|
|
212
|
+
// One header cell per column a series contributes. A plain series keeps its
|
|
213
|
+
// bare label, so an existing export is byte-identical.
|
|
214
|
+
const header: string[] = ['Category']
|
|
215
|
+
for (const s of series) {
|
|
216
|
+
if (s.ohlc) header.push(`${s.label} Open`, `${s.label} High`, `${s.label} Low`, `${s.label} Close`)
|
|
217
|
+
else if (s.boxes) header.push(`${s.label} Min`, `${s.label} Q1`, `${s.label} Median`, `${s.label} Q3`, `${s.label} Max`, `${s.label} Outliers`)
|
|
218
|
+
else if (s.errors) header.push(s.label, `${s.label} Low`, `${s.label} High`)
|
|
219
|
+
else header.push(s.label)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const rows = cats.map((cat, i) => {
|
|
223
|
+
const cells: unknown[] = [cat]
|
|
224
|
+
for (const s of series) {
|
|
225
|
+
if (s.ohlc) {
|
|
226
|
+
const k = s.ohlc[i]
|
|
227
|
+
cells.push(k?.o ?? '', k?.h ?? '', k?.l ?? '', k?.c ?? '')
|
|
228
|
+
} else if (s.boxes) {
|
|
229
|
+
const b = s.boxes[i]
|
|
230
|
+
// Outliers are a list inside one cell; csvCell quotes it.
|
|
231
|
+
cells.push(b?.min ?? '', b?.q1 ?? '', b?.median ?? '', b?.q3 ?? '', b?.max ?? '', b?.outliers?.join(' ') ?? '')
|
|
232
|
+
} else if (s.errors) {
|
|
233
|
+
const v = s.values[i]
|
|
234
|
+
const e = s.errors[i]
|
|
235
|
+
const lo = e == null ? '' : typeof e === 'number' ? (v ?? 0) - Math.abs(e) : Math.min(e.lo, e.hi)
|
|
236
|
+
const hi = e == null ? '' : typeof e === 'number' ? (v ?? 0) + Math.abs(e) : Math.max(e.lo, e.hi)
|
|
237
|
+
cells.push(v ?? '', lo, hi)
|
|
238
|
+
} else {
|
|
239
|
+
cells.push(s.values[i] ?? '')
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return cells.map(csvCell).join(',')
|
|
243
|
+
})
|
|
244
|
+
return [header.map(csvCell).join(','), ...rows].join('\n')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Whether {@link chartSpecToCsv} would produce anything, without building it.
|
|
249
|
+
*
|
|
250
|
+
* The chart panel disables its CSV menu item on this. It has to be a predicate
|
|
251
|
+
* rather than `chartSpecToCsv(spec) !== ''` because that runs on every render,
|
|
252
|
+
* and serialising a 100,000-point series to decide whether to grey out a button
|
|
253
|
+
* is not a thing to do sixty times a second.
|
|
254
|
+
*/
|
|
255
|
+
export function chartCsvExportable(spec: ChartSpec): boolean {
|
|
256
|
+
const series = spec.series ?? []
|
|
257
|
+
if (series.some((s) => s.points?.length)) return true
|
|
258
|
+
return !!(spec.categories?.length && series.length)
|
|
191
259
|
}
|
|
192
260
|
|
|
193
261
|
/** Download the chart's data as a `.csv` file. Returns false if there's no
|