@svgrid/grid 3.0.1 → 3.0.2

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-DyFA-QxA.js +602 -0
  22. package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-M2XP9t0_.js} +152 -150
  23. package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
  24. package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
  25. package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
  26. package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
  27. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-CxfakxR0.js} +5 -3
  28. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-Dm_YEx9L.js} +5 -3
  29. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  30. package/dist/cdn/chart-CKJRCPOk.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-BJ2opd0j.js} +3608 -3414
  33. package/dist/cdn/{src-D0hO2eYN.js → src-DNbz94Ct.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
@@ -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">›</span>{/if}
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">⠿</span>
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)}`}>×</button>
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
- /** Chart shapes the model may choose from when planning a visualisation. */
839
- export type AIChartType = 'bar' | 'line' | 'area' | 'pie'
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[] = ['bar', 'line', 'area', 'pie']
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
- /\bline\b|\btrend\b|over time/.test(q) ? 'line'
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
@@ -74,7 +74,13 @@ type NumericFormatInput = {
74
74
  * + currency + options).
75
75
  */
76
76
  const numberFormatterCache = new Map<string, Intl.NumberFormat>()
77
- function getNumberFormatter(
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 {
@@ -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
+ })
@@ -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>, ...`). Empty for exotic specs (sankey/treemap/gauge/
180
- * calendar) that don't carry a rectangular categories x series grid.
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
- const header = ['Category', ...series.map((s) => s.label)].map(csvCell).join(',')
187
- const rows = cats.map((cat, i) =>
188
- [cat, ...series.map((s) => s.values[i] ?? '')].map(csvCell).join(','),
189
- )
190
- return [header, ...rows].join('\n')
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