@carto/ps-react-ui 4.21.1 → 4.23.0

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 (113) hide show
  1. package/dist/actions-D7LB9DvT.js +1161 -0
  2. package/dist/actions-D7LB9DvT.js.map +1 -0
  3. package/dist/category-Ca5XDx0N.js +839 -0
  4. package/dist/category-Ca5XDx0N.js.map +1 -0
  5. package/dist/{download-C7he8mSh.js → download--wRRnf19.js} +81 -80
  6. package/dist/download--wRRnf19.js.map +1 -0
  7. package/dist/{echart-DSz2Tu0z.js → echart-CgOQw9XB.js} +29 -29
  8. package/dist/echart-CgOQw9XB.js.map +1 -0
  9. package/dist/{formula-Cqj84ZGX.js → formula-DRuMIVo8.js} +6 -6
  10. package/dist/formula-DRuMIVo8.js.map +1 -0
  11. package/dist/{markdown-CV_KP-at.js → markdown-C3OX9jyy.js} +6 -6
  12. package/dist/markdown-C3OX9jyy.js.map +1 -0
  13. package/dist/{spread-C8T12Nfh.js → spread-BT07rCJw.js} +7 -7
  14. package/dist/spread-BT07rCJw.js.map +1 -0
  15. package/dist/{table-CNQijNNB.js → table-CRG9eOs9.js} +3 -3
  16. package/dist/table-CRG9eOs9.js.map +1 -0
  17. package/dist/types/widgets-v2/actions/download/csv-item.d.ts +3 -3
  18. package/dist/types/widgets-v2/actions/download/index.d.ts +1 -1
  19. package/dist/types/widgets-v2/actions/download/png-item.d.ts +2 -2
  20. package/dist/types/widgets-v2/actions/download/types.d.ts +12 -1
  21. package/dist/types/widgets-v2/actions/fullscreen/fullscreen.d.ts +42 -16
  22. package/dist/types/widgets-v2/actions/fullscreen/style.d.ts +9 -0
  23. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  24. package/dist/types/widgets-v2/bar/download.d.ts +3 -3
  25. package/dist/types/widgets-v2/category/category-ui.d.ts +9 -1
  26. package/dist/types/widgets-v2/category/download.d.ts +3 -3
  27. package/dist/types/widgets-v2/echart/echart-ui.d.ts +2 -2
  28. package/dist/types/widgets-v2/echart/echart.d.ts +9 -1
  29. package/dist/types/widgets-v2/echart/style.d.ts +2 -0
  30. package/dist/types/widgets-v2/formula/download.d.ts +3 -3
  31. package/dist/types/widgets-v2/histogram/download.d.ts +3 -3
  32. package/dist/types/widgets-v2/index.d.ts +2 -2
  33. package/dist/types/widgets-v2/markdown/download.d.ts +3 -3
  34. package/dist/types/widgets-v2/pie/download.d.ts +3 -3
  35. package/dist/types/widgets-v2/scatterplot/download.d.ts +3 -3
  36. package/dist/types/widgets-v2/spread/download.d.ts +3 -3
  37. package/dist/types/widgets-v2/table/download.d.ts +3 -3
  38. package/dist/types/widgets-v2/timeseries/download.d.ts +3 -3
  39. package/dist/types/widgets-v2/toolbox/toolbox.d.ts +1 -1
  40. package/dist/widgets-v2/actions.js +2 -2
  41. package/dist/widgets-v2/bar.js +7 -7
  42. package/dist/widgets-v2/bar.js.map +1 -1
  43. package/dist/widgets-v2/category.js +1 -1
  44. package/dist/widgets-v2/echart.js +1 -1
  45. package/dist/widgets-v2/formula.js +1 -1
  46. package/dist/widgets-v2/histogram.js +10 -10
  47. package/dist/widgets-v2/histogram.js.map +1 -1
  48. package/dist/widgets-v2/markdown.js +1 -1
  49. package/dist/widgets-v2/pie.js +10 -11
  50. package/dist/widgets-v2/pie.js.map +1 -1
  51. package/dist/widgets-v2/scatterplot.js +9 -9
  52. package/dist/widgets-v2/scatterplot.js.map +1 -1
  53. package/dist/widgets-v2/spread.js +1 -1
  54. package/dist/widgets-v2/table.js +1 -1
  55. package/dist/widgets-v2/timeseries.js +12 -12
  56. package/dist/widgets-v2/timeseries.js.map +1 -1
  57. package/dist/widgets-v2.js +274 -333
  58. package/dist/widgets-v2.js.map +1 -1
  59. package/package.json +3 -3
  60. package/src/widgets-v2/actions/download/csv-item.test.tsx +3 -3
  61. package/src/widgets-v2/actions/download/csv-item.tsx +6 -6
  62. package/src/widgets-v2/actions/download/download.tsx +5 -3
  63. package/src/widgets-v2/actions/download/index.ts +1 -1
  64. package/src/widgets-v2/actions/download/png-item.test.tsx +5 -3
  65. package/src/widgets-v2/actions/download/png-item.tsx +4 -4
  66. package/src/widgets-v2/actions/download/types.ts +15 -1
  67. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +155 -0
  68. package/src/widgets-v2/actions/fullscreen/fullscreen.test.tsx +93 -33
  69. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +272 -86
  70. package/src/widgets-v2/actions/fullscreen/style.ts +31 -4
  71. package/src/widgets-v2/actions/index.ts +1 -0
  72. package/src/widgets-v2/actions/searcher/searcher.tsx +17 -2
  73. package/src/widgets-v2/bar/download.test.tsx +5 -3
  74. package/src/widgets-v2/bar/download.tsx +5 -4
  75. package/src/widgets-v2/category/category-ui.tsx +20 -9
  76. package/src/widgets-v2/category/category.tsx +37 -2
  77. package/src/widgets-v2/category/download.test.ts +4 -2
  78. package/src/widgets-v2/category/download.ts +5 -4
  79. package/src/widgets-v2/echart/echart-ui.test.tsx +6 -3
  80. package/src/widgets-v2/echart/echart-ui.tsx +6 -2
  81. package/src/widgets-v2/echart/echart.test.tsx +1 -1
  82. package/src/widgets-v2/echart/echart.tsx +10 -0
  83. package/src/widgets-v2/echart/style.ts +16 -1
  84. package/src/widgets-v2/formula/download.test.tsx +3 -1
  85. package/src/widgets-v2/formula/download.tsx +5 -4
  86. package/src/widgets-v2/histogram/download.test.ts +6 -4
  87. package/src/widgets-v2/histogram/download.ts +5 -4
  88. package/src/widgets-v2/index.ts +1 -0
  89. package/src/widgets-v2/markdown/download.test.ts +2 -2
  90. package/src/widgets-v2/markdown/download.tsx +5 -4
  91. package/src/widgets-v2/pie/download.test.ts +7 -3
  92. package/src/widgets-v2/pie/download.ts +5 -4
  93. package/src/widgets-v2/pie/options.test.ts +8 -0
  94. package/src/widgets-v2/pie/options.ts +3 -8
  95. package/src/widgets-v2/scatterplot/download.test.ts +4 -2
  96. package/src/widgets-v2/scatterplot/download.ts +5 -4
  97. package/src/widgets-v2/spread/download.test.ts +3 -1
  98. package/src/widgets-v2/spread/download.ts +5 -4
  99. package/src/widgets-v2/table/download.test.ts +5 -3
  100. package/src/widgets-v2/table/download.ts +4 -3
  101. package/src/widgets-v2/timeseries/download.test.ts +6 -4
  102. package/src/widgets-v2/timeseries/download.ts +5 -4
  103. package/src/widgets-v2/toolbox/toolbox.tsx +1 -1
  104. package/dist/actions-C7ZAO0FG.js +0 -1004
  105. package/dist/actions-C7ZAO0FG.js.map +0 -1
  106. package/dist/category-DZt6yiST.js +0 -826
  107. package/dist/category-DZt6yiST.js.map +0 -1
  108. package/dist/download-C7he8mSh.js.map +0 -1
  109. package/dist/echart-DSz2Tu0z.js.map +0 -1
  110. package/dist/formula-Cqj84ZGX.js.map +0 -1
  111. package/dist/markdown-CV_KP-at.js.map +0 -1
  112. package/dist/spread-C8T12Nfh.js.map +0 -1
  113. package/dist/table-CNQijNNB.js.map +0 -1
@@ -96,6 +96,14 @@ export interface CategoryUIProps {
96
96
  * No-op for single-series data — same convention as the legend.
97
97
  */
98
98
  stacked?: boolean
99
+ /**
100
+ * Series indices hidden via the legend. Leave both this and
101
+ * `onHiddenSeriesChange` unset and CategoryUI keeps the set itself; pass
102
+ * them to own it — `Category` does, so the card and the fullscreen copy
103
+ * share it through the widget store.
104
+ */
105
+ hiddenSeries?: ReadonlySet<number>
106
+ onHiddenSeriesChange?: (next: ReadonlySet<number>) => void
99
107
  }
100
108
 
101
109
  interface GroupedRow {
@@ -167,6 +175,8 @@ export function CategoryUI({
167
175
  maxOverride,
168
176
  size = 'small',
169
177
  stacked = false,
178
+ hiddenSeries: hiddenSeriesProp,
179
+ onHiddenSeriesChange,
170
180
  }: CategoryUIProps) {
171
181
  const theme = useTheme()
172
182
  const fmt = formatter ?? ((n: number) => String(n))
@@ -286,9 +296,10 @@ export function CategoryUI({
286
296
  // selection, unrelated). Never allowed to reach "every series hidden":
287
297
  // toggling the last visible one instead resets to "all visible" rather
288
298
  // than leaving the widget blank.
289
- const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<number>>(
299
+ const [localHidden, setLocalHidden] = useState<ReadonlySet<number>>(
290
300
  () => new Set(),
291
301
  )
302
+ const hiddenSeries = hiddenSeriesProp ?? localHidden
292
303
  // A reload with a different series breakdown invalidates hidden indices —
293
304
  // index 2 may now point at an entirely different series. Reset rather
294
305
  // than keep hiding whatever now occupies a stale index. Adjusted directly
@@ -303,7 +314,7 @@ export function CategoryUI({
303
314
  const [prevSeriesKey, setPrevSeriesKey] = useState(seriesKey)
304
315
  if (seriesKey !== prevSeriesKey) {
305
316
  setPrevSeriesKey(seriesKey)
306
- setHiddenSeries(new Set())
317
+ setLocalHidden(new Set())
307
318
  }
308
319
  // Real number of data series — NOT `series?.length`, which is legend
309
320
  // metadata and may legitimately be shorter than the actual series count
@@ -313,14 +324,14 @@ export function CategoryUI({
313
324
  const seriesCount = Math.max(data.length, 1)
314
325
  const toggleSeries = useCallback(
315
326
  (index: number) => {
316
- setHiddenSeries((prev) => {
317
- const next = new Set(prev)
318
- if (next.has(index)) next.delete(index)
319
- else next.add(index)
320
- return next.size >= seriesCount ? new Set() : next
321
- })
327
+ const next = new Set(hiddenSeries)
328
+ if (next.has(index)) next.delete(index)
329
+ else next.add(index)
330
+ ;(onHiddenSeriesChange ?? setLocalHidden)(
331
+ next.size >= seriesCount ? new Set() : next,
332
+ )
322
333
  },
323
- [seriesCount],
334
+ [hiddenSeries, onHiddenSeriesChange, seriesCount],
324
335
  )
325
336
 
326
337
  if (grouped.length === 0) return null
@@ -1,5 +1,11 @@
1
- import { useMemo } from 'react'
2
- import { useWidgetId, useWidgetShallow, type WidgetState } from '../stores'
1
+ import { useCallback, useMemo } from 'react'
2
+ import {
3
+ getWidgetStore,
4
+ useWidget,
5
+ useWidgetId,
6
+ useWidgetShallow,
7
+ type WidgetState,
8
+ } from '../stores'
3
9
  import { CategoryUI } from './category-ui'
4
10
  import type {
5
11
  CategoryKey,
@@ -23,6 +29,19 @@ const categorySelector = (s: WidgetState): CategorySlice => ({
23
29
  labelFormatter: s.labelFormatter,
24
30
  })
25
31
 
32
+ /**
33
+ * Legend-hidden series, kept on the widget store so every mounted Category
34
+ * of the widget — the card and its fullscreen copy — shows the same set.
35
+ * Tagged with the series breakdown it was made for: after a reload with
36
+ * different series the indices point elsewhere, so a stale set reads as
37
+ * empty instead of being reset with a write.
38
+ */
39
+ interface CategoryWidgetState extends WidgetState {
40
+ categoryHiddenSeries?: { key: string; indices: ReadonlySet<number> }
41
+ }
42
+
43
+ const NO_HIDDEN: ReadonlySet<number> = new Set()
44
+
26
45
  function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
27
46
  if (!d) return 0
28
47
  let m = 0
@@ -111,6 +130,20 @@ export function Category({
111
130
  )
112
131
  const effectiveMaxOverride =
113
132
  maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
133
+
134
+ const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
135
+ const stored = useWidget<
136
+ CategoryWidgetState['categoryHiddenSeries'],
137
+ CategoryWidgetState
138
+ >(id, (s) => s.categoryHiddenSeries)
139
+ const hiddenSeries = stored?.key === seriesKey ? stored.indices : NO_HIDDEN
140
+ const onHiddenSeriesChange = useCallback(
141
+ (indices: ReadonlySet<number>) =>
142
+ getWidgetStore(id).setState({
143
+ categoryHiddenSeries: { key: seriesKey, indices },
144
+ } as Partial<CategoryWidgetState>),
145
+ [id, seriesKey],
146
+ )
114
147
  return (
115
148
  <CategoryUI
116
149
  data={slice.data}
@@ -125,6 +158,8 @@ export function Category({
125
158
  maxOverride={effectiveMaxOverride}
126
159
  size={size}
127
160
  stacked={stacked}
161
+ hiddenSeries={hiddenSeries}
162
+ onHiddenSeriesChange={onHiddenSeriesChange}
128
163
  />
129
164
  )
130
165
  }
@@ -60,7 +60,9 @@ describe('createCategoryDownloadConfig', () => {
60
60
  filename: 'c',
61
61
  getData: () => data,
62
62
  })
63
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
63
+ const handle = await items
64
+ .find((i) => i.id === 'csv')!
65
+ .resolve({ id: null })
64
66
  expect(handle.filename).toBe('c.csv')
65
67
  expect(csvText).toBe(
66
68
  'series,name,value\nseries_1,a,1\nseries_1,b,2\nseries_2,a,10',
@@ -73,7 +75,7 @@ describe('createCategoryDownloadConfig', () => {
73
75
  getData: () => data,
74
76
  seriesNames: ['baseline', 'override'],
75
77
  })
76
- await items.find((i) => i.id === 'csv')!.resolve()
78
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
77
79
  expect(csvText.split('\n')[1]).toBe('baseline,a,1')
78
80
  expect(csvText.split('\n')[3]).toBe('override,a,10')
79
81
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { CategoryWidgetData } from './types'
@@ -13,9 +14,9 @@ import type { CategoryWidgetData } from './types'
13
14
  */
14
15
  export function createCategoryDownloadConfig(args: {
15
16
  filename: string
16
- getData: () => CategoryWidgetData
17
+ getData: (ctx: DownloadContext) => CategoryWidgetData
17
18
  seriesNames?: readonly string[]
18
- getCaptureEl?: () => HTMLElement | null
19
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
19
20
  pngPixelRatio?: number
20
21
  pngBackgroundColor?: string | null
21
22
  }): DownloadItem[] {
@@ -33,8 +34,8 @@ export function createCategoryDownloadConfig(args: {
33
34
  items.push(
34
35
  buildCsvDownloadItem({
35
36
  filename: args.filename,
36
- getRows: () => {
37
- const data = args.getData()
37
+ getRows: (ctx) => {
38
+ const data = args.getData(ctx)
38
39
  const rows: unknown[][] = [['series', 'name', 'value']]
39
40
  for (const [i, series] of data.entries()) {
40
41
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
@@ -103,11 +103,14 @@ describe('<EchartUI>', () => {
103
103
  />,
104
104
  )
105
105
  expect(initSpy).toHaveBeenCalledTimes(1)
106
- // 3rd arg merges the default `{ renderer: 'svg', height: 304 }` with the
107
- // caller's init — canvas wins.
106
+ // 3rd arg merges the default `{ renderer: 'svg' }` with the caller's
107
+ // init — canvas wins.
108
108
  const initOpts = initSpy.mock.calls[0]![2] as Record<string, unknown>
109
109
  expect(initOpts.renderer).toBe('canvas')
110
- expect(initOpts.height).toBe(304)
110
+ // No `height`: zrender would store it and every later `resize()` would
111
+ // snap back to it, so the chart could never grow into a fullscreen
112
+ // dialog. Height is container-driven instead.
113
+ expect(initOpts.height).toBeUndefined()
111
114
  expect(onInstance).toHaveBeenCalledWith(mockChart)
112
115
  })
113
116
 
@@ -9,9 +9,12 @@ import {
9
9
  import { observeResize } from './shared-resize-observer'
10
10
  import { styles } from './style'
11
11
 
12
+ // No `height`: a numeric height here is stored in zrender's init opts, and
13
+ // every later `resize()` resolves back to it — the chart would stay that tall
14
+ // inside a fullscreen dialog. Height comes from the container instead
15
+ // (`styles.root`'s `min-height` inline, `flex: 1` when the parent has one).
12
16
  export const DEFAULT_INIT_OPTS = {
13
17
  renderer: 'svg',
14
- height: 304,
15
18
  } satisfies echarts.EChartsInitOpts
16
19
 
17
20
  export type EchartsEventHandler = (event: unknown) => void
@@ -36,7 +39,8 @@ export interface EchartUIProps {
36
39
  /**
37
40
  * Init options forwarded to echarts.init. Captured at mount only — to change
38
41
  * `renderer` or `height` after mount, unmount and remount the chart.
39
- * Defaults: `{ renderer: 'svg', height: 304 }`.
42
+ * Defaults: `{ renderer: 'svg' }` — height is container-driven, so prefer
43
+ * `sx={{ minHeight }}` over pinning `height` here.
40
44
  */
41
45
  init?: echarts.EChartsInitOpts
42
46
  /**
@@ -45,7 +45,7 @@ vi.mock('./echart-ui', () => ({
45
45
  }, [onInstance])
46
46
  return null
47
47
  },
48
- DEFAULT_INIT_OPTS: { renderer: 'svg', height: 304 },
48
+ DEFAULT_INIT_OPTS: { renderer: 'svg' },
49
49
  }))
50
50
 
51
51
  beforeEach(() => {
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useMemo } from 'react'
2
+ import type { SxProps, Theme } from '@mui/material'
2
3
  import type * as echarts from 'echarts'
3
4
  import {
4
5
  applyTransforms,
@@ -77,6 +78,13 @@ export interface EchartProps {
77
78
  onEvents?: Record<string, EchartsEventHandler>
78
79
  init?: echarts.EChartsInitOpts
79
80
  className?: string
81
+ /**
82
+ * Styles for the chart container — this is where its height comes from.
83
+ * `min-height` defaults to the card height (304px) and `flex: 1` lets the
84
+ * chart fill a taller parent; override either here, e.g.
85
+ * `sx={{ minHeight: 400 }}` for a taller card.
86
+ */
87
+ sx?: SxProps<Theme>
80
88
  }
81
89
 
82
90
  interface EchartSlice {
@@ -126,6 +134,7 @@ export function Echart({
126
134
  onEvents,
127
135
  init,
128
136
  className,
137
+ sx,
129
138
  }: EchartProps) {
130
139
  const id = useWidgetId()
131
140
  const slice = useWidgetShallow(id, echartSelector)
@@ -194,6 +203,7 @@ export function Echart({
194
203
  init={init}
195
204
  onInstance={onInstance}
196
205
  className={className}
206
+ sx={sx}
197
207
  />
198
208
  )
199
209
  }
@@ -3,6 +3,21 @@ import type { SxProps, Theme } from '@mui/material'
3
3
  export const styles = {
4
4
  root: {
5
5
  width: '100%',
6
- minHeight: 0,
6
+ // `flex: 1` lets the chart fill a parent that has a definite height —
7
+ // the fullscreen dialog. Inline it has none (Widget.Content is an
8
+ // auto-height flex column), so flex-basis resolves to the content box,
9
+ // which is empty until ECharts measures it: at `min-height: 0` the
10
+ // container is 0px tall, ECharts draws nothing and the widget renders
11
+ // blank. 304 is not a new magic number — it's the height `init.height`
12
+ // used to pin, kept so cards look exactly as before. Override per widget
13
+ // with `<Widget.Echart sx={{ minHeight }} />`.
14
+ flex: 1,
15
+ minHeight: 304,
16
+ // ECharts puts its tooltip inside this box at `z-index: 9999999`. With
17
+ // no stacking context of its own, that number competes page-wide and
18
+ // beats MUI's modal layer — under `FullScreen.Slot` the card's chart
19
+ // mirrors the dialog's tooltip (echarts.connect) and it painted over
20
+ // the dialog. Isolating keeps the tooltip above its own chart only.
21
+ isolation: 'isolate',
7
22
  },
8
23
  } satisfies Record<string, SxProps<Theme>>
@@ -56,7 +56,9 @@ describe('createFormulaDownloadConfig', () => {
56
56
  filename: 'f',
57
57
  getData: () => data,
58
58
  })
59
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
59
+ const handle = await items
60
+ .find((i) => i.id === 'csv')!
61
+ .resolve({ id: null })
60
62
  expect(handle.filename).toBe('f.csv')
61
63
  expect(csvText).toBe(
62
64
  'series,prefix,value,suffix,note,delta\nRev,$,42,k,q1,12\n,,10,,,',
@@ -1,18 +1,19 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { FormulaWidgetData } from './types'
7
8
 
8
9
  export interface FormulaDownloadConfigArgs {
9
10
  filename: string
10
- getData: () => FormulaWidgetData
11
+ getData: (ctx: DownloadContext) => FormulaWidgetData
11
12
  /**
12
13
  * Optional getter for the widget's capture element (registered by
13
14
  * `Widget.State`). When provided, a PNG item is prepended.
14
15
  */
15
- getCaptureEl?: () => HTMLElement | null
16
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
16
17
  pngPixelRatio?: number
17
18
  pngBackgroundColor?: string | null
18
19
  }
@@ -40,8 +41,8 @@ export function createFormulaDownloadConfig(
40
41
  items.push(
41
42
  buildCsvDownloadItem({
42
43
  filename: args.filename,
43
- getRows: () => {
44
- const data = args.getData()
44
+ getRows: (ctx) => {
45
+ const data = args.getData(ctx)
45
46
  const rows: unknown[][] = [
46
47
  ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
47
48
  ]
@@ -52,7 +52,9 @@ describe('createHistogramDownloadConfig', () => {
52
52
  getData: () => data,
53
53
  getTicks: () => ticks,
54
54
  })
55
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
55
+ const handle = await items
56
+ .find((i) => i.id === 'csv')!
57
+ .resolve({ id: null })
56
58
  expect(handle.filename).toBe('h.csv')
57
59
  expect(csvText).toBe(
58
60
  'bin_low,bin_high,series_1,series_2\n0,10,1,4\n10,20,2,5\n20,30,3,6',
@@ -66,7 +68,7 @@ describe('createHistogramDownloadConfig', () => {
66
68
  getTicks: () => ticks,
67
69
  seriesNames: ['lo', 'hi'],
68
70
  })
69
- await items.find((i) => i.id === 'csv')!.resolve()
71
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
70
72
  expect(csvText.startsWith('bin_low,bin_high,lo,hi')).toBe(true)
71
73
  })
72
74
 
@@ -76,7 +78,7 @@ describe('createHistogramDownloadConfig', () => {
76
78
  getData: () => data,
77
79
  getTicks: () => [0],
78
80
  })
79
- await items.find((i) => i.id === 'csv')!.resolve()
81
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
80
82
  expect(csvText).toBe('bin_low,bin_high,series_1,series_2')
81
83
  })
82
84
 
@@ -87,7 +89,7 @@ describe('createHistogramDownloadConfig', () => {
87
89
  getData: () => sparse,
88
90
  getTicks: () => ticks,
89
91
  })
90
- await items.find((i) => i.id === 'csv')!.resolve()
92
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
91
93
  expect(csvText).toContain('0,10,1,0')
92
94
  expect(csvText).toContain('10,20,2,0')
93
95
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { HistogramWidgetData } from './types'
@@ -13,10 +14,10 @@ import type { HistogramWidgetData } from './types'
13
14
  */
14
15
  export function createHistogramDownloadConfig(args: {
15
16
  filename: string
16
- getData: () => HistogramWidgetData
17
+ getData: (ctx: DownloadContext) => HistogramWidgetData
17
18
  getTicks: () => readonly number[]
18
19
  seriesNames?: readonly string[]
19
- getCaptureEl?: () => HTMLElement | null
20
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
20
21
  pngPixelRatio?: number
21
22
  pngBackgroundColor?: string | null
22
23
  }): DownloadItem[] {
@@ -34,8 +35,8 @@ export function createHistogramDownloadConfig(args: {
34
35
  items.push(
35
36
  buildCsvDownloadItem({
36
37
  filename: args.filename,
37
- getRows: () => {
38
- const data = args.getData()
38
+ getRows: (ctx) => {
39
+ const data = args.getData(ctx)
39
40
  const ticks = args.getTicks()
40
41
  const seriesCount = data.length
41
42
  const header: unknown[] = ['bin_low', 'bin_high']
@@ -157,6 +157,7 @@ export type {
157
157
  RelativeDataProps,
158
158
  DownloadProps,
159
159
  DownloadItem,
160
+ DownloadContext,
160
161
  FullScreenTriggerProps,
161
162
  FullScreenSlotProps,
162
163
  LockSelectionProps,
@@ -46,7 +46,7 @@ describe('createMarkdownDownloadConfig', () => {
46
46
  filename: 'doc',
47
47
  getData: () => ({ content: '# title\n\nbody' }),
48
48
  })
49
- const handle = await items.find((i) => i.id === 'md')!.resolve()
49
+ const handle = await items.find((i) => i.id === 'md')!.resolve({ id: null })
50
50
  expect(handle.url).toBe('blob:mock')
51
51
  expect(handle.filename).toBe('doc.md')
52
52
  expect(mdText).toBe('# title\n\nbody')
@@ -60,7 +60,7 @@ describe('createMarkdownDownloadConfig', () => {
60
60
  // missing content field — exercise the `?? ''` branch
61
61
  getData: () => ({}) as { content: string },
62
62
  })
63
- await items.find((i) => i.id === 'md')!.resolve()
63
+ await items.find((i) => i.id === 'md')!.resolve({ id: null })
64
64
  expect(mdText).toBe('')
65
65
  })
66
66
  })
@@ -4,6 +4,7 @@ import {
4
4
  buildPngDownloadItem,
5
5
  toCsvString,
6
6
  triggerLinkDownload,
7
+ type DownloadContext,
7
8
  type DownloadItem,
8
9
  } from '../actions/download'
9
10
  import type { MarkdownWidgetData } from './types'
@@ -16,8 +17,8 @@ import type { MarkdownWidgetData } from './types'
16
17
  */
17
18
  export function createMarkdownDownloadConfig(args: {
18
19
  filename: string
19
- getData: () => MarkdownWidgetData
20
- getCaptureEl?: () => HTMLElement | null
20
+ getData: (ctx: DownloadContext) => MarkdownWidgetData
21
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
21
22
  pngPixelRatio?: number
22
23
  pngBackgroundColor?: string | null
23
24
  }): DownloadItem[] {
@@ -36,8 +37,8 @@ export function createMarkdownDownloadConfig(args: {
36
37
  id: DOWNLOAD_ITEM_IDS.markdown,
37
38
  label: 'Markdown',
38
39
  icon: <MarkdownIcon fontSize='small' />,
39
- resolve: () => {
40
- const data = args.getData()
40
+ resolve: (ctx) => {
41
+ const data = args.getData(ctx)
41
42
  const blob = new Blob([data.content ?? ''], {
42
43
  type: 'text/markdown;charset=utf-8;',
43
44
  })
@@ -51,7 +51,9 @@ describe('createPieDownloadConfig', () => {
51
51
  getCaptureEl: () => null,
52
52
  })
53
53
  expect(items.map((i) => i.id)).toEqual(['png', 'csv'])
54
- await expect(items[0]!.resolve()).rejects.toThrow(/No PNG capture element/)
54
+ await expect(items[0]!.resolve({ id: null })).rejects.toThrow(
55
+ /No PNG capture element/,
56
+ )
55
57
  })
56
58
 
57
59
  it('CSV resolve uses default series_<n> when seriesNames is absent', async () => {
@@ -59,7 +61,9 @@ describe('createPieDownloadConfig', () => {
59
61
  filename: 'p',
60
62
  getData: () => multi,
61
63
  })
62
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
64
+ const handle = await items
65
+ .find((i) => i.id === 'csv')!
66
+ .resolve({ id: null })
63
67
  expect(handle.filename).toBe('p.csv')
64
68
  expect(csvText).toBe('series,name,value\nseries_1,a,1\nseries_2,c,3')
65
69
  handle.revoke?.()
@@ -72,7 +76,7 @@ describe('createPieDownloadConfig', () => {
72
76
  getData: () => multi,
73
77
  seriesNames: ['alpha', 'beta'],
74
78
  })
75
- await items.find((i) => i.id === 'csv')!.resolve()
79
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
76
80
  expect(csvText).toBe('series,name,value\nalpha,a,1\nbeta,c,3')
77
81
  })
78
82
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { PieWidgetData } from './types'
@@ -14,9 +15,9 @@ import type { PieWidgetData } from './types'
14
15
  */
15
16
  export function createPieDownloadConfig(args: {
16
17
  filename: string
17
- getData: () => PieWidgetData
18
+ getData: (ctx: DownloadContext) => PieWidgetData
18
19
  seriesNames?: readonly string[]
19
- getCaptureEl?: () => HTMLElement | null
20
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
20
21
  pngPixelRatio?: number
21
22
  pngBackgroundColor?: string | null
22
23
  }): DownloadItem[] {
@@ -34,8 +35,8 @@ export function createPieDownloadConfig(args: {
34
35
  items.push(
35
36
  buildCsvDownloadItem({
36
37
  filename: args.filename,
37
- getRows: () => {
38
- const data = args.getData()
38
+ getRows: (ctx) => {
39
+ const data = args.getData(ctx)
39
40
  const rows: unknown[][] = [['series', 'name', 'value']]
40
41
  for (const [i, series] of data.entries()) {
41
42
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
@@ -84,6 +84,14 @@ describe('pieOptions (structural)', () => {
84
84
  expect(out.legend?.show).toBe(true)
85
85
  })
86
86
 
87
+ it('anchors the legend bottom-left, like every other widget', () => {
88
+ const out = pieOptions({ theme }) as {
89
+ legend?: { left?: unknown; bottom?: unknown }
90
+ }
91
+ expect(out.legend?.left).toBe(0)
92
+ expect(out.legend?.bottom).toBe(0)
93
+ })
94
+
87
95
  it('disables legend click-to-toggle', () => {
88
96
  const out = pieOptions({ theme }) as {
89
97
  legend?: { selectedMode?: boolean }
@@ -45,14 +45,9 @@ export function pieOptions({
45
45
  return {
46
46
  legend: {
47
47
  ...buildLegendConfig({ hasLegend: true, labelFormatter }),
48
- // Pie reads better with a centered legend (the donut is radially
49
- // symmetric — left-aligning the legend below it visually unbalances
50
- // the chart). Override `buildLegendConfig`'s `type: 'scroll'` with
51
- // `'plain'` so long category lists wrap to multiple rows instead
52
- // of clipping the last visible item against the chart container.
53
- // The wider `itemGap` gives chips breathing room in the typical
54
- // 3–6 category case.
55
- left: 'center',
48
+ // Bottom-left, same as every other widget — `buildLegendConfig`'s
49
+ // `left: 0` is kept deliberately. The wider `itemGap` gives chips
50
+ // breathing room in the typical 3–6 category case.
56
51
  type: 'scroll',
57
52
  itemGap: 16,
58
53
  // The legend is presentational only here — the pie has its own
@@ -52,7 +52,9 @@ describe('createScatterplotDownloadConfig', () => {
52
52
  filename: 's',
53
53
  getData: () => data,
54
54
  })
55
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
55
+ const handle = await items
56
+ .find((i) => i.id === 'csv')!
57
+ .resolve({ id: null })
56
58
  expect(handle.filename).toBe('s.csv')
57
59
  expect(csvText).toBe(
58
60
  'series,x,y\nseries_1,1,10\nseries_1,2,20\nseries_2,3,30',
@@ -65,7 +67,7 @@ describe('createScatterplotDownloadConfig', () => {
65
67
  getData: () => data,
66
68
  seriesNames: ['A', 'B'],
67
69
  })
68
- await items.find((i) => i.id === 'csv')!.resolve()
70
+ await items.find((i) => i.id === 'csv')!.resolve({ id: null })
69
71
  expect(csvText.split('\n')[1]).toBe('A,1,10')
70
72
  })
71
73
  })
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { ScatterplotWidgetData } from './types'
@@ -13,9 +14,9 @@ import type { ScatterplotWidgetData } from './types'
13
14
  */
14
15
  export function createScatterplotDownloadConfig(args: {
15
16
  filename: string
16
- getData: () => ScatterplotWidgetData
17
+ getData: (ctx: DownloadContext) => ScatterplotWidgetData
17
18
  seriesNames?: readonly string[]
18
- getCaptureEl?: () => HTMLElement | null
19
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
19
20
  pngPixelRatio?: number
20
21
  pngBackgroundColor?: string | null
21
22
  }): DownloadItem[] {
@@ -33,8 +34,8 @@ export function createScatterplotDownloadConfig(args: {
33
34
  items.push(
34
35
  buildCsvDownloadItem({
35
36
  filename: args.filename,
36
- getRows: () => {
37
- const data = args.getData()
37
+ getRows: (ctx) => {
38
+ const data = args.getData(ctx)
38
39
  const rows: unknown[][] = [['series', 'x', 'y']]
39
40
  for (const [i, series] of data.entries()) {
40
41
  const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
@@ -55,7 +55,9 @@ describe('createSpreadDownloadConfig', () => {
55
55
  filename: 's',
56
56
  getData: () => data,
57
57
  })
58
- const handle = await items.find((i) => i.id === 'csv')!.resolve()
58
+ const handle = await items
59
+ .find((i) => i.id === 'csv')!
60
+ .resolve({ id: null })
59
61
  expect(handle.filename).toBe('s.csv')
60
62
  expect(csvText).toBe(
61
63
  'series,prefix,min,max,suffix,note\nRevenue,$,1,9,k,q1\n,,0,10,,',
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
+ type DownloadContext,
4
5
  type DownloadItem,
5
6
  } from '../actions/download'
6
7
  import type { SpreadWidgetData } from './types'
@@ -13,8 +14,8 @@ import type { SpreadWidgetData } from './types'
13
14
  */
14
15
  export function createSpreadDownloadConfig(args: {
15
16
  filename: string
16
- getData: () => SpreadWidgetData
17
- getCaptureEl?: () => HTMLElement | null
17
+ getData: (ctx: DownloadContext) => SpreadWidgetData
18
+ getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
18
19
  pngPixelRatio?: number
19
20
  pngBackgroundColor?: string | null
20
21
  }): DownloadItem[] {
@@ -32,8 +33,8 @@ export function createSpreadDownloadConfig(args: {
32
33
  items.push(
33
34
  buildCsvDownloadItem({
34
35
  filename: args.filename,
35
- getRows: () => {
36
- const data = args.getData()
36
+ getRows: (ctx) => {
37
+ const data = args.getData(ctx)
37
38
  const rows: unknown[][] = [
38
39
  ['series', 'prefix', 'min', 'max', 'suffix', 'note'],
39
40
  ]