@carto/ps-react-ui 4.11.3 → 4.12.1

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 (103) hide show
  1. package/dist/chat.js +962 -733
  2. package/dist/chat.js.map +1 -1
  3. package/dist/csv-item-hH_Gt7ur.js +32 -0
  4. package/dist/csv-item-hH_Gt7ur.js.map +1 -0
  5. package/dist/{echart-BMPpj7n_.js → echart-Bdvbfx9s.js} +2 -2
  6. package/dist/echart-Bdvbfx9s.js.map +1 -0
  7. package/dist/{option-builders-F-c9ELi1.js → option-builders-DPeoyQaM.js} +41 -33
  8. package/dist/option-builders-DPeoyQaM.js.map +1 -0
  9. package/dist/png-item-9dNbB37T.js +57 -0
  10. package/dist/png-item-9dNbB37T.js.map +1 -0
  11. package/dist/table-B3ZWWhJt.js +383 -0
  12. package/dist/table-B3ZWWhJt.js.map +1 -0
  13. package/dist/types/chat/containers/chat-footer.d.ts +1 -1
  14. package/dist/types/chat/containers/styles.d.ts +79 -12
  15. package/dist/types/chat/index.d.ts +1 -1
  16. package/dist/types/chat/types.d.ts +21 -0
  17. package/dist/types/chat/use-typewriter.d.ts +5 -3
  18. package/dist/types/widgets/utils/chart-config/index.d.ts +1 -1
  19. package/dist/types/widgets-v2/actions/download/constants.d.ts +12 -0
  20. package/dist/types/widgets-v2/actions/download/csv-item.d.ts +38 -0
  21. package/dist/types/widgets-v2/actions/download/icons.d.ts +6 -0
  22. package/dist/types/widgets-v2/actions/download/index.d.ts +3 -1
  23. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  24. package/dist/types/widgets-v2/pie/skeleton.d.ts +9 -0
  25. package/dist/widgets/bar.js +1 -1
  26. package/dist/widgets/histogram.js +1 -1
  27. package/dist/widgets/pie.js +1 -1
  28. package/dist/widgets/scatterplot.js +5 -5
  29. package/dist/widgets/timeseries.js +1 -1
  30. package/dist/widgets/utils.js +1 -1
  31. package/dist/widgets-v2/actions.js +40 -36
  32. package/dist/widgets-v2/actions.js.map +1 -1
  33. package/dist/widgets-v2/bar.js +69 -76
  34. package/dist/widgets-v2/bar.js.map +1 -1
  35. package/dist/widgets-v2/category.js +50 -55
  36. package/dist/widgets-v2/category.js.map +1 -1
  37. package/dist/widgets-v2/echart.js +1 -1
  38. package/dist/widgets-v2/formula.js +37 -43
  39. package/dist/widgets-v2/formula.js.map +1 -1
  40. package/dist/widgets-v2/histogram.js +141 -147
  41. package/dist/widgets-v2/histogram.js.map +1 -1
  42. package/dist/widgets-v2/markdown.js +18 -17
  43. package/dist/widgets-v2/markdown.js.map +1 -1
  44. package/dist/widgets-v2/pie.js +174 -126
  45. package/dist/widgets-v2/pie.js.map +1 -1
  46. package/dist/widgets-v2/scatterplot.js +156 -166
  47. package/dist/widgets-v2/scatterplot.js.map +1 -1
  48. package/dist/widgets-v2/spread.js +36 -41
  49. package/dist/widgets-v2/spread.js.map +1 -1
  50. package/dist/widgets-v2/table.js +46 -55
  51. package/dist/widgets-v2/table.js.map +1 -1
  52. package/dist/widgets-v2/timeseries.js +83 -89
  53. package/dist/widgets-v2/timeseries.js.map +1 -1
  54. package/dist/widgets-v2.js +3 -3
  55. package/package.json +1 -1
  56. package/src/chat/bubbles/styles.ts +5 -1
  57. package/src/chat/containers/chat-content.tsx +4 -1
  58. package/src/chat/containers/chat-footer.test.tsx +59 -0
  59. package/src/chat/containers/chat-footer.tsx +124 -36
  60. package/src/chat/containers/styles.ts +107 -16
  61. package/src/chat/feedback/styles.ts +11 -4
  62. package/src/chat/index.ts +1 -0
  63. package/src/chat/types.ts +22 -0
  64. package/src/chat/use-typewriter.ts +32 -24
  65. package/src/widgets/utils/chart-config/index.ts +1 -0
  66. package/src/widgets/utils/chart-config/option-builders.test.ts +34 -0
  67. package/src/widgets/utils/chart-config/option-builders.ts +21 -0
  68. package/src/widgets-v2/actions/download/constants.ts +14 -0
  69. package/src/widgets-v2/actions/download/csv-item.test.tsx +77 -0
  70. package/src/widgets-v2/actions/download/csv-item.tsx +71 -0
  71. package/src/widgets-v2/actions/download/icons.tsx +10 -1
  72. package/src/widgets-v2/actions/download/index.ts +3 -1
  73. package/src/widgets-v2/actions/download/png-item.tsx +2 -1
  74. package/src/widgets-v2/actions/index.ts +5 -0
  75. package/src/widgets-v2/bar/download.tsx +16 -22
  76. package/src/widgets-v2/bar/options.ts +3 -2
  77. package/src/widgets-v2/category/download.test.ts +9 -0
  78. package/src/widgets-v2/category/download.ts +16 -20
  79. package/src/widgets-v2/echart/edge-label-clamp.ts +7 -4
  80. package/src/widgets-v2/formula/download.tsx +23 -29
  81. package/src/widgets-v2/histogram/download.ts +22 -26
  82. package/src/widgets-v2/histogram/options.ts +3 -2
  83. package/src/widgets-v2/markdown/{download.ts → download.tsx} +5 -2
  84. package/src/widgets-v2/pie/download.ts +16 -20
  85. package/src/widgets-v2/pie/skeleton.test.tsx +6 -3
  86. package/src/widgets-v2/pie/skeleton.tsx +69 -7
  87. package/src/widgets-v2/scatterplot/download.ts +16 -20
  88. package/src/widgets-v2/scatterplot/options.ts +3 -6
  89. package/src/widgets-v2/spread/download.ts +23 -27
  90. package/src/widgets-v2/table/download.test.ts +10 -0
  91. package/src/widgets-v2/table/download.ts +11 -15
  92. package/src/widgets-v2/table/helpers.test.ts +19 -0
  93. package/src/widgets-v2/table/helpers.ts +7 -12
  94. package/src/widgets-v2/timeseries/download.ts +36 -40
  95. package/src/widgets-v2/timeseries/options.ts +3 -2
  96. package/dist/echart-BMPpj7n_.js.map +0 -1
  97. package/dist/option-builders-F-c9ELi1.js.map +0 -1
  98. package/dist/png-item-BE9uEqlD.js +0 -45
  99. package/dist/png-item-BE9uEqlD.js.map +0 -1
  100. package/dist/table-C9IMbTr0.js +0 -385
  101. package/dist/table-C9IMbTr0.js.map +0 -1
  102. package/dist/types/chat/feedback/styles.d.ts +0 -211
  103. package/dist/types/widgets/utils/chart-config/option-builders.d.ts +0 -124
@@ -0,0 +1,71 @@
1
+ import { DOWNLOAD_ITEM_IDS } from './constants'
2
+ import { downloadToCSV } from './exports'
3
+ import { CSVIcon } from './icons'
4
+ import type { DownloadItem } from './types'
5
+
6
+ interface BuildCsvDownloadItemBase {
7
+ /** Base filename (without extension). The item appends `.csv`. */
8
+ filename: string
9
+ /** Override the menu label. Default `'CSV'`. */
10
+ label?: string
11
+ }
12
+
13
+ /**
14
+ * Args for {@link buildCsvDownloadItem}. A discriminated union: a caller must
15
+ * supply exactly one content source — `getRows` (rows serialised through the
16
+ * shared `toCsvString`) or `getCsv` (a pre-built CSV string). The `?: never`
17
+ * arms make passing both — or neither — a compile-time error.
18
+ */
19
+ export type BuildCsvDownloadItemArgs =
20
+ | (BuildCsvDownloadItemBase & {
21
+ /**
22
+ * Builds the CSV rows at click time. Used by most widgets — rows are run
23
+ * through `toCsvString` so escaping (incl. the spreadsheet
24
+ * formula-injection guard) stays consistent across widgets.
25
+ */
26
+ getRows: () => readonly (readonly unknown[])[]
27
+ getCsv?: never
28
+ })
29
+ | (BuildCsvDownloadItemBase & {
30
+ /**
31
+ * Returns a pre-built CSV string at click time. Escape hatch for widgets
32
+ * (e.g. Table) that already serialise their own CSV with bespoke
33
+ * header/cell handling.
34
+ */
35
+ getCsv: () => string
36
+ getRows?: never
37
+ })
38
+
39
+ /**
40
+ * Builds the standard CSV `DownloadItem` used by every per-widget download
41
+ * config. Centralised so the menu label, icon, and `.csv` filename suffix stay
42
+ * consistent across widgets — mirrors {@link buildPngDownloadItem} so neither
43
+ * format can drift again.
44
+ */
45
+ export function buildCsvDownloadItem(
46
+ args: BuildCsvDownloadItemArgs,
47
+ ): DownloadItem {
48
+ return {
49
+ id: DOWNLOAD_ITEM_IDS.csv,
50
+ label: args.label ?? 'CSV',
51
+ icon: <CSVIcon fontSize='small' />,
52
+ resolve: () => {
53
+ if (args.getCsv) {
54
+ const csv = args.getCsv()
55
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
56
+ const url = URL.createObjectURL(blob)
57
+ return Promise.resolve({
58
+ url,
59
+ filename: `${args.filename}.csv`,
60
+ revoke: () => URL.revokeObjectURL(url),
61
+ })
62
+ }
63
+ const handle = downloadToCSV(args.getRows())
64
+ return Promise.resolve({
65
+ url: handle.url,
66
+ filename: `${args.filename}.csv`,
67
+ revoke: handle.revoke,
68
+ })
69
+ },
70
+ }
71
+ }
@@ -1,5 +1,5 @@
1
1
  import { SvgIcon, type SvgIconProps } from '@mui/material'
2
- import { ImageOutlined } from '@mui/icons-material'
2
+ import { ArticleOutlined, ImageOutlined } from '@mui/icons-material'
3
3
 
4
4
  /**
5
5
  * Generic "image" glyph used for the PNG download item. Wraps MUI's
@@ -10,6 +10,15 @@ export function PNGIcon(props: SvgIconProps) {
10
10
  return <ImageOutlined {...props} />
11
11
  }
12
12
 
13
+ /**
14
+ * Generic "document" glyph used for the Markdown download item. Wraps MUI's
15
+ * `ArticleOutlined`, mirroring {@link PNGIcon}, so the Markdown menu item
16
+ * carries an icon consistent with the other download formats.
17
+ */
18
+ export function MarkdownIcon(props: SvgIconProps) {
19
+ return <ArticleOutlined {...props} />
20
+ }
21
+
13
22
  /**
14
23
  * "CSV" rectangle with the letters spelled inside — matches v1 visual and is
15
24
  * easier to recognise in a download menu than a generic table glyph.
@@ -7,7 +7,9 @@ export {
7
7
  type DownloadHandle,
8
8
  type DownloadDOMToPNGOptions,
9
9
  } from './exports'
10
- export { CSVIcon, PNGIcon } from './icons'
10
+ export { CSVIcon, PNGIcon, MarkdownIcon } from './icons'
11
11
  export { buildPngDownloadItem, type BuildPngDownloadItemArgs } from './png-item'
12
+ export { buildCsvDownloadItem, type BuildCsvDownloadItemArgs } from './csv-item'
13
+ export { DOWNLOAD_ITEM_IDS, type DownloadItemId } from './constants'
12
14
  export type { DownloadItem } from './types'
13
15
  export { DEFAULT_DOWNLOAD_LABELS, type DownloadLabels } from './labels'
@@ -1,3 +1,4 @@
1
+ import { DOWNLOAD_ITEM_IDS } from './constants'
1
2
  import { downloadDOMToPNG } from './exports'
2
3
  import { PNGIcon } from './icons'
3
4
  import type { DownloadItem } from './types'
@@ -29,7 +30,7 @@ export function buildPngDownloadItem(
29
30
  args: BuildPngDownloadItemArgs,
30
31
  ): DownloadItem {
31
32
  return {
32
- id: 'png',
33
+ id: DOWNLOAD_ITEM_IDS.png,
33
34
  label: args.label ?? 'PNG',
34
35
  icon: <PNGIcon fontSize='small' />,
35
36
  resolve: async () => {
@@ -54,14 +54,19 @@ export {
54
54
  toCsvString,
55
55
  triggerLinkDownload,
56
56
  buildPngDownloadItem,
57
+ buildCsvDownloadItem,
57
58
  CSVIcon,
58
59
  PNGIcon,
60
+ MarkdownIcon,
59
61
  DEFAULT_DOWNLOAD_LABELS,
62
+ DOWNLOAD_ITEM_IDS,
60
63
  type DownloadProps,
61
64
  type DownloadItem,
65
+ type DownloadItemId,
62
66
  type DownloadHandle,
63
67
  type DownloadDOMToPNGOptions,
64
68
  type BuildPngDownloadItemArgs,
69
+ type BuildCsvDownloadItemArgs,
65
70
  type DownloadLabels,
66
71
  } from './download'
67
72
  export {
@@ -1,7 +1,6 @@
1
1
  import {
2
- CSVIcon,
2
+ buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
- downloadToCSV,
5
4
  type DownloadItem,
6
5
  } from '../actions/download'
7
6
  import type { BarWidgetData } from './types'
@@ -42,25 +41,20 @@ export function createBarDownloadConfig(
42
41
  }),
43
42
  )
44
43
  }
45
- items.push({
46
- id: 'csv',
47
- label: 'CSV',
48
- icon: <CSVIcon fontSize='small' />,
49
- resolve: () => {
50
- const data = args.getData()
51
- const rows: unknown[][] = []
52
- for (const [i, series] of data.entries()) {
53
- if (i > 0) rows.push([])
54
- rows.push(['name', 'value'])
55
- for (const d of series) rows.push([d.name, d.value])
56
- }
57
- const handle = downloadToCSV(rows)
58
- return Promise.resolve({
59
- url: handle.url,
60
- filename: `${args.filename}.csv`,
61
- revoke: handle.revoke,
62
- })
63
- },
64
- })
44
+ items.push(
45
+ buildCsvDownloadItem({
46
+ filename: args.filename,
47
+ getRows: () => {
48
+ const data = args.getData()
49
+ const rows: unknown[][] = []
50
+ for (const [i, series] of data.entries()) {
51
+ if (i > 0) rows.push([])
52
+ rows.push(['name', 'value'])
53
+ for (const d of series) rows.push([d.name, d.value])
54
+ }
55
+ return rows
56
+ },
57
+ }),
58
+ )
65
59
  return items
66
60
  }
@@ -2,6 +2,7 @@ import type { EChartsOption } from 'echarts'
2
2
  import * as echarts from 'echarts'
3
3
  import type { CallbackDataParams } from 'echarts/types/dist/shared'
4
4
  import {
5
+ buildAxisLabelStyle,
5
6
  buildGridConfig,
6
7
  buildLegendConfig,
7
8
  createTooltipFormatter,
@@ -86,6 +87,7 @@ export function barOptions({
86
87
  axisLine: { show: false },
87
88
  axisTick: { show: false },
88
89
  axisLabel: {
90
+ ...buildAxisLabelStyle(theme),
89
91
  padding: [parseInt(theme.spacing(0.5)), 0, 0, 0],
90
92
  margin: 0,
91
93
  hideOverlap: true,
@@ -111,8 +113,7 @@ export function barOptions({
111
113
  lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider },
112
114
  },
113
115
  axisLabel: {
114
- fontSize: theme.typography.overlineDelicate?.fontSize,
115
- fontFamily: theme.typography.overlineDelicate?.fontFamily,
116
+ ...buildAxisLabelStyle(theme),
116
117
  margin: parseInt(theme.spacing(1)),
117
118
  show: true,
118
119
  showMaxLabel: true,
@@ -37,6 +37,15 @@ describe('createCategoryDownloadConfig', () => {
37
37
  ).toEqual(['csv'])
38
38
  })
39
39
 
40
+ it('CSV item carries the canonical icon and short label', () => {
41
+ const item = createCategoryDownloadConfig({
42
+ filename: 'c',
43
+ getData: () => data,
44
+ }).find((i) => i.id === 'csv')!
45
+ expect(item.label).toBe('CSV')
46
+ expect(item.icon).toBeTruthy()
47
+ })
48
+
40
49
  it('prepends PNG when getCaptureEl is provided', () => {
41
50
  const items = createCategoryDownloadConfig({
42
51
  filename: 'c',
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { CategoryWidgetData } from './types'
@@ -30,25 +30,21 @@ export function createCategoryDownloadConfig(args: {
30
30
  }),
31
31
  )
32
32
  }
33
- items.push({
34
- id: 'csv',
35
- label: 'Download as CSV',
36
- resolve: () => {
37
- const data = args.getData()
38
- const rows: unknown[][] = [['series', 'name', 'value']]
39
- for (const [i, series] of data.entries()) {
40
- const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
- for (const item of series) {
42
- rows.push([seriesName, item.name, item.value])
33
+ items.push(
34
+ buildCsvDownloadItem({
35
+ filename: args.filename,
36
+ getRows: () => {
37
+ const data = args.getData()
38
+ const rows: unknown[][] = [['series', 'name', 'value']]
39
+ for (const [i, series] of data.entries()) {
40
+ const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
41
+ for (const item of series) {
42
+ rows.push([seriesName, item.name, item.value])
43
+ }
43
44
  }
44
- }
45
- const handle = downloadToCSV(rows)
46
- return Promise.resolve({
47
- url: handle.url,
48
- filename: `${args.filename}.csv`,
49
- revoke: handle.revoke,
50
- })
51
- },
52
- })
45
+ return rows
46
+ },
47
+ }),
48
+ )
53
49
  return items
54
50
  }
@@ -28,10 +28,13 @@ export const CENTERED: EdgeAlignment = {
28
28
  alignMaxLabel: null,
29
29
  }
30
30
 
31
- // Bias toward anchoring when borderline: clipping is worse than a hair of
32
- // off-centering, and the reconstructed font may differ slightly from what
33
- // ECharts actually renders.
34
- const SAFETY_MARGIN_PX = 2
31
+ // Small cushion biasing toward anchoring when borderline: clipping is worse
32
+ // than a hair of off-centering, and the measured width can differ slightly
33
+ // from the final render (mainly when the web font isn't loaded yet at measure
34
+ // time). Kept to 1px — a wider margin anchored labels that only just clear the
35
+ // edge, and an anchored edge label can then be dropped by the axis
36
+ // `hideOverlap`, leaving a gap. 1px guards measurement error without that.
37
+ const SAFETY_MARGIN_PX = 1
35
38
 
36
39
  /**
37
40
  * Pure overflow decision. Inputs are anchor-independent (tick centers and text
@@ -1,7 +1,6 @@
1
1
  import {
2
- CSVIcon,
2
+ buildCsvDownloadItem,
3
3
  buildPngDownloadItem,
4
- downloadToCSV,
5
4
  type DownloadItem,
6
5
  } from '../actions/download'
7
6
  import type { FormulaWidgetData } from './types'
@@ -38,32 +37,27 @@ export function createFormulaDownloadConfig(
38
37
  }),
39
38
  )
40
39
  }
41
- items.push({
42
- id: 'csv',
43
- label: 'CSV',
44
- icon: <CSVIcon fontSize='small' />,
45
- resolve: () => {
46
- const data = args.getData()
47
- const rows: unknown[][] = [
48
- ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
49
- ]
50
- for (const d of data) {
51
- rows.push([
52
- d.series?.name ?? '',
53
- d.prefix ?? '',
54
- d.value,
55
- d.suffix ?? '',
56
- d.note ?? '',
57
- d.delta?.value ?? '',
58
- ])
59
- }
60
- const handle = downloadToCSV(rows)
61
- return Promise.resolve({
62
- url: handle.url,
63
- filename: `${args.filename}.csv`,
64
- revoke: handle.revoke,
65
- })
66
- },
67
- })
40
+ items.push(
41
+ buildCsvDownloadItem({
42
+ filename: args.filename,
43
+ getRows: () => {
44
+ const data = args.getData()
45
+ const rows: unknown[][] = [
46
+ ['series', 'prefix', 'value', 'suffix', 'note', 'delta'],
47
+ ]
48
+ for (const d of data) {
49
+ rows.push([
50
+ d.series?.name ?? '',
51
+ d.prefix ?? '',
52
+ d.value,
53
+ d.suffix ?? '',
54
+ d.note ?? '',
55
+ d.delta?.value ?? '',
56
+ ])
57
+ }
58
+ return rows
59
+ },
60
+ }),
61
+ )
68
62
  return items
69
63
  }
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { HistogramWidgetData } from './types'
@@ -31,30 +31,26 @@ export function createHistogramDownloadConfig(args: {
31
31
  }),
32
32
  )
33
33
  }
34
- items.push({
35
- id: 'csv',
36
- label: 'Download as CSV',
37
- resolve: () => {
38
- const data = args.getData()
39
- const ticks = args.getTicks()
40
- const seriesCount = data.length
41
- const header: unknown[] = ['bin_low', 'bin_high']
42
- for (let i = 0; i < seriesCount; i++) {
43
- header.push(args.seriesNames?.[i] ?? `series_${i + 1}`)
44
- }
45
- const rows: unknown[][] = [header]
46
- for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
47
- const row: unknown[] = [ticks[bin], ticks[bin + 1]]
48
- for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
49
- rows.push(row)
50
- }
51
- const handle = downloadToCSV(rows)
52
- return Promise.resolve({
53
- url: handle.url,
54
- filename: `${args.filename}.csv`,
55
- revoke: handle.revoke,
56
- })
57
- },
58
- })
34
+ items.push(
35
+ buildCsvDownloadItem({
36
+ filename: args.filename,
37
+ getRows: () => {
38
+ const data = args.getData()
39
+ const ticks = args.getTicks()
40
+ const seriesCount = data.length
41
+ const header: unknown[] = ['bin_low', 'bin_high']
42
+ for (let i = 0; i < seriesCount; i++) {
43
+ header.push(args.seriesNames?.[i] ?? `series_${i + 1}`)
44
+ }
45
+ const rows: unknown[][] = [header]
46
+ for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
47
+ const row: unknown[] = [ticks[bin], ticks[bin + 1]]
48
+ for (let s = 0; s < seriesCount; s++) row.push(data[s]?.[bin] ?? 0)
49
+ rows.push(row)
50
+ }
51
+ return rows
52
+ },
53
+ }),
54
+ )
59
55
  return items
60
56
  }
@@ -2,6 +2,7 @@ import type { EChartsOption } from 'echarts'
2
2
  import * as echarts from 'echarts'
3
3
  import type { CallbackDataParams } from 'echarts/types/dist/shared'
4
4
  import {
5
+ buildAxisLabelStyle,
5
6
  buildGridConfig,
6
7
  buildLegendConfig,
7
8
  createTooltipFormatter,
@@ -92,6 +93,7 @@ export function histogramOptions({
92
93
  axisLine: { show: false },
93
94
  axisTick: { show: false },
94
95
  axisLabel: {
96
+ ...buildAxisLabelStyle(theme),
95
97
  padding: [parseInt(theme.spacing(0.5)), 0, 0, 0],
96
98
  margin: 0,
97
99
  hideOverlap: true,
@@ -114,8 +116,7 @@ export function histogramOptions({
114
116
  lineStyle: { color: theme.palette.black?.[4] ?? theme.palette.divider },
115
117
  },
116
118
  axisLabel: {
117
- fontSize: theme.typography.overlineDelicate?.fontSize,
118
- fontFamily: theme.typography.overlineDelicate?.fontFamily,
119
+ ...buildAxisLabelStyle(theme),
119
120
  margin: parseInt(theme.spacing(1)),
120
121
  show: true,
121
122
  showMaxLabel: true,
@@ -1,4 +1,6 @@
1
1
  import {
2
+ DOWNLOAD_ITEM_IDS,
3
+ MarkdownIcon,
2
4
  buildPngDownloadItem,
3
5
  toCsvString,
4
6
  triggerLinkDownload,
@@ -31,8 +33,9 @@ export function createMarkdownDownloadConfig(args: {
31
33
  )
32
34
  }
33
35
  items.push({
34
- id: 'md',
35
- label: 'Download as Markdown',
36
+ id: DOWNLOAD_ITEM_IDS.markdown,
37
+ label: 'Markdown',
38
+ icon: <MarkdownIcon fontSize='small' />,
36
39
  resolve: () => {
37
40
  const data = args.getData()
38
41
  const blob = new Blob([data.content ?? ''], {
@@ -1,6 +1,6 @@
1
1
  import {
2
+ buildCsvDownloadItem,
2
3
  buildPngDownloadItem,
3
- downloadToCSV,
4
4
  type DownloadItem,
5
5
  } from '../actions/download'
6
6
  import type { PieWidgetData } from './types'
@@ -31,25 +31,21 @@ export function createPieDownloadConfig(args: {
31
31
  }),
32
32
  )
33
33
  }
34
- items.push({
35
- id: 'csv',
36
- label: 'Download as CSV',
37
- resolve: () => {
38
- const data = args.getData()
39
- const rows: unknown[][] = [['series', 'name', 'value']]
40
- for (const [i, series] of data.entries()) {
41
- const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
42
- for (const slice of series) {
43
- rows.push([seriesName, slice.name, slice.value])
34
+ items.push(
35
+ buildCsvDownloadItem({
36
+ filename: args.filename,
37
+ getRows: () => {
38
+ const data = args.getData()
39
+ const rows: unknown[][] = [['series', 'name', 'value']]
40
+ for (const [i, series] of data.entries()) {
41
+ const seriesName = args.seriesNames?.[i] ?? `series_${i + 1}`
42
+ for (const slice of series) {
43
+ rows.push([seriesName, slice.name, slice.value])
44
+ }
44
45
  }
45
- }
46
- const handle = downloadToCSV(rows)
47
- return Promise.resolve({
48
- url: handle.url,
49
- filename: `${args.filename}.csv`,
50
- revoke: handle.revoke,
51
- })
52
- },
53
- })
46
+ return rows
47
+ },
48
+ }),
49
+ )
54
50
  return items
55
51
  }
@@ -10,8 +10,11 @@ describe('<PieSkeleton>', () => {
10
10
  ).toBeGreaterThan(0)
11
11
  })
12
12
 
13
- it('scales with count', () => {
14
- const { container } = render(<PieSkeleton count={3} />)
15
- expect(container.firstChild).not.toBeNull()
13
+ it('accepts count but always renders a single donut', () => {
14
+ const single = render(<PieSkeleton count={1} />)
15
+ const many = render(<PieSkeleton count={3} />)
16
+ const skeletonCount = (c: HTMLElement) =>
17
+ c.querySelectorAll('.MuiSkeleton-root').length
18
+ expect(skeletonCount(many.container)).toBe(skeletonCount(single.container))
16
19
  })
17
20
  })
@@ -1,19 +1,55 @@
1
1
  import { Box, Skeleton } from '@mui/material'
2
2
  import type { SxProps, Theme } from '@mui/material'
3
3
 
4
+ const OUTER_SIZE = 160
5
+ const HOLE_SIZE = 96
6
+
4
7
  const styles = {
5
8
  root: {
6
9
  display: 'flex',
10
+ flexDirection: 'column',
7
11
  alignItems: 'center',
8
12
  justifyContent: 'center',
9
13
  minHeight: 200,
10
14
  py: 1,
11
- gap: 2,
15
+ gap: ({ spacing }) => spacing(2),
12
16
  },
13
17
  donut: {
14
- width: 160,
15
- height: 160,
18
+ position: 'relative',
19
+ display: 'flex',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ },
23
+ ring: {
24
+ width: OUTER_SIZE,
25
+ height: OUTER_SIZE,
26
+ },
27
+ hole: {
28
+ position: 'absolute',
29
+ zIndex: 1,
30
+ width: HOLE_SIZE,
31
+ height: HOLE_SIZE,
16
32
  borderRadius: '50%',
33
+ bgcolor: 'background.paper',
34
+ },
35
+ label: {
36
+ position: 'absolute',
37
+ zIndex: 2,
38
+ display: 'flex',
39
+ flexDirection: 'column',
40
+ alignItems: 'center',
41
+ justifyContent: 'center',
42
+ gap: ({ spacing }) => spacing(0.5),
43
+ },
44
+ legend: {
45
+ display: 'flex',
46
+ alignItems: 'center',
47
+ gap: ({ spacing }) => spacing(2),
48
+ },
49
+ legendItem: {
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ gap: ({ spacing }) => spacing(1.5),
17
53
  },
18
54
  } satisfies Record<string, SxProps<Theme>>
19
55
 
@@ -21,12 +57,38 @@ export interface PieSkeletonProps {
21
57
  count?: number
22
58
  }
23
59
 
24
- export function PieSkeleton({ count = 1 }: PieSkeletonProps) {
60
+ /**
61
+ * Loading state for the Pie widget. Mirrors the donut silhouette — a ring
62
+ * (gray circle with a background-coloured hole punched out), a stacked
63
+ * value/name stub in the centre, and a centered legend stub below — so the
64
+ * skeleton reads as "a donut chart" rather than a solid disc.
65
+ *
66
+ * Single donut only: multi-series pie loads as a horizontal bar chart, so
67
+ * rendering one ring stays honest regardless of `count`.
68
+ */
69
+ export function PieSkeleton({ count }: PieSkeletonProps) {
70
+ // `count` is accepted for API compatibility but intentionally unused: the
71
+ // skeleton always renders a single donut, since multi-series pie loads as a
72
+ // horizontal bar chart rather than multiple donuts.
73
+ void count
25
74
  return (
26
75
  <Box sx={styles.root}>
27
- {Array.from({ length: count }).map((_, i) => (
28
- <Skeleton key={`donut-${i}`} variant='circular' sx={styles.donut} />
29
- ))}
76
+ <Box sx={styles.donut}>
77
+ <Skeleton variant='circular' sx={styles.ring} />
78
+ <Box sx={styles.hole} />
79
+ <Box sx={styles.label}>
80
+ <Skeleton width={56} height={18} />
81
+ <Skeleton width={36} height={8} />
82
+ </Box>
83
+ </Box>
84
+ <Box sx={styles.legend}>
85
+ {[0, 1].map((i) => (
86
+ <Box key={`legend-${i}`} sx={styles.legendItem}>
87
+ <Skeleton variant='circular' width={8} height={8} />
88
+ <Skeleton width={48} height={8} />
89
+ </Box>
90
+ ))}
91
+ </Box>
30
92
  </Box>
31
93
  )
32
94
  }