@carto/ps-react-ui 4.4.2 → 4.5.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 (157) hide show
  1. package/dist/download-config-DemuQ3Jm.js +56 -0
  2. package/dist/download-config-DemuQ3Jm.js.map +1 -0
  3. package/dist/error-Cj8eUMrl.js +40 -0
  4. package/dist/error-Cj8eUMrl.js.map +1 -0
  5. package/dist/formatter-B9Bxn1k7.js +6 -0
  6. package/dist/formatter-B9Bxn1k7.js.map +1 -0
  7. package/dist/no-data-DkIt7Qt1.js +61 -0
  8. package/dist/no-data-DkIt7Qt1.js.map +1 -0
  9. package/dist/row-D4VOhcNI.js +34 -0
  10. package/dist/row-D4VOhcNI.js.map +1 -0
  11. package/dist/series-Bola3CmD.js +90 -0
  12. package/dist/series-Bola3CmD.js.map +1 -0
  13. package/dist/styles-Y8q7Jff3.js +118 -0
  14. package/dist/styles-Y8q7Jff3.js.map +1 -0
  15. package/dist/types/widgets/actions/brush-toggle/types.d.ts +8 -2
  16. package/dist/types/widgets/category/components/category-row-multi.d.ts +2 -1
  17. package/dist/types/widgets/category/components/category-row-single.d.ts +2 -1
  18. package/dist/types/widgets/category/types.d.ts +1 -0
  19. package/dist/types/widgets/echart/shared-resize-observer.d.ts +12 -0
  20. package/dist/types/widgets/echart/types.d.ts +2 -0
  21. package/dist/types/widgets/histogram/config.d.ts +15 -3
  22. package/dist/types/widgets/histogram/index.d.ts +2 -1
  23. package/dist/types/widgets/histogram/types.d.ts +6 -3
  24. package/dist/types/widgets/stores/index.d.ts +2 -1
  25. package/dist/types/widgets/stores/types.d.ts +2 -0
  26. package/dist/types/widgets/stores/use-widget-selector.d.ts +35 -0
  27. package/dist/types/widgets/stores/widget-store-performance.test.d.ts +1 -0
  28. package/dist/types/widgets/stores/widget-store.d.ts +49 -27
  29. package/dist/types/widgets/table/types.d.ts +1 -1
  30. package/dist/types/widgets/utils/chart-config/index.d.ts +1 -1
  31. package/dist/types/widgets/utils/chart-config/option-builders.d.ts +13 -8
  32. package/dist/types/widgets/utils/formatter.d.ts +1 -0
  33. package/dist/types/widgets/utils/index.d.ts +1 -1
  34. package/dist/use-widget-ref-BFazQvJK.js +22 -0
  35. package/dist/use-widget-ref-BFazQvJK.js.map +1 -0
  36. package/dist/use-widget-selector-DqRmWQ1K.js +12 -0
  37. package/dist/use-widget-selector-DqRmWQ1K.js.map +1 -0
  38. package/dist/widget-store-CIrb9RKP.js +263 -0
  39. package/dist/widget-store-CIrb9RKP.js.map +1 -0
  40. package/dist/widgets/actions.js +799 -817
  41. package/dist/widgets/actions.js.map +1 -1
  42. package/dist/widgets/bar.js +53 -47
  43. package/dist/widgets/bar.js.map +1 -1
  44. package/dist/widgets/category.js +261 -255
  45. package/dist/widgets/category.js.map +1 -1
  46. package/dist/widgets/echart.js +109 -99
  47. package/dist/widgets/echart.js.map +1 -1
  48. package/dist/widgets/error.js +1 -1
  49. package/dist/widgets/formula.js +71 -63
  50. package/dist/widgets/formula.js.map +1 -1
  51. package/dist/widgets/histogram.js +119 -80
  52. package/dist/widgets/histogram.js.map +1 -1
  53. package/dist/widgets/loader.js +53 -60
  54. package/dist/widgets/loader.js.map +1 -1
  55. package/dist/widgets/markdown.js +51 -50
  56. package/dist/widgets/markdown.js.map +1 -1
  57. package/dist/widgets/no-data.js +1 -1
  58. package/dist/widgets/pie.js +111 -99
  59. package/dist/widgets/pie.js.map +1 -1
  60. package/dist/widgets/range.js +146 -144
  61. package/dist/widgets/range.js.map +1 -1
  62. package/dist/widgets/scatterplot.js +50 -44
  63. package/dist/widgets/scatterplot.js.map +1 -1
  64. package/dist/widgets/skeleton-loader.js +18 -17
  65. package/dist/widgets/skeleton-loader.js.map +1 -1
  66. package/dist/widgets/spread.js +110 -94
  67. package/dist/widgets/spread.js.map +1 -1
  68. package/dist/widgets/stores.js +5 -2
  69. package/dist/widgets/stores.js.map +1 -1
  70. package/dist/widgets/table.js +422 -436
  71. package/dist/widgets/table.js.map +1 -1
  72. package/dist/widgets/timeseries.js +52 -46
  73. package/dist/widgets/timeseries.js.map +1 -1
  74. package/dist/widgets/toolbar-actions.js +101 -6693
  75. package/dist/widgets/toolbar-actions.js.map +1 -1
  76. package/dist/widgets/utils.js +16 -14
  77. package/dist/widgets/utils.js.map +1 -1
  78. package/dist/widgets/wrapper.js +156 -158
  79. package/dist/widgets/wrapper.js.map +1 -1
  80. package/dist/widgets.js +4 -4
  81. package/package.json +5 -4
  82. package/src/hooks/use-widget-ref.ts +3 -4
  83. package/src/widgets/README.md +3 -3
  84. package/src/widgets/actions/brush-toggle/brush-toggle.tsx +60 -79
  85. package/src/widgets/actions/brush-toggle/types.ts +8 -2
  86. package/src/widgets/actions/change-column/change-column.tsx +15 -15
  87. package/src/widgets/actions/change-column/sortable-column-item.tsx +3 -1
  88. package/src/widgets/actions/download/download.tsx +4 -3
  89. package/src/widgets/actions/fullscreen/fullscreen.tsx +7 -11
  90. package/src/widgets/actions/lock-selection/lock-selection.tsx +12 -15
  91. package/src/widgets/actions/relative-data/relative-data.tsx +22 -26
  92. package/src/widgets/actions/searcher/searcher-toggle.tsx +11 -12
  93. package/src/widgets/actions/searcher/searcher.tsx +20 -21
  94. package/src/widgets/actions/stack-toggle/stack-toggle.tsx +15 -21
  95. package/src/widgets/actions/zoom-toggle/zoom-toggle.tsx +27 -43
  96. package/src/widgets/bar/config.ts +22 -14
  97. package/src/widgets/category/category-ui.tsx +31 -27
  98. package/src/widgets/category/components/category-row-multi.tsx +6 -2
  99. package/src/widgets/category/components/category-row-single.tsx +5 -1
  100. package/src/widgets/category/types.ts +1 -0
  101. package/src/widgets/echart/echart-ui.test.tsx +20 -16
  102. package/src/widgets/echart/echart-ui.tsx +6 -12
  103. package/src/widgets/echart/echart.tsx +13 -27
  104. package/src/widgets/echart/shared-resize-observer.ts +45 -0
  105. package/src/widgets/echart/types.ts +2 -0
  106. package/src/widgets/error/error.tsx +7 -9
  107. package/src/widgets/formula/components/prefix.tsx +4 -6
  108. package/src/widgets/formula/components/row.tsx +4 -4
  109. package/src/widgets/formula/components/series.tsx +4 -6
  110. package/src/widgets/formula/components/suffix.tsx +4 -6
  111. package/src/widgets/formula/components/value.tsx +9 -16
  112. package/src/widgets/histogram/config.ts +101 -20
  113. package/src/widgets/histogram/index.ts +6 -1
  114. package/src/widgets/histogram/types.ts +9 -3
  115. package/src/widgets/loader/loader.tsx +31 -44
  116. package/src/widgets/markdown/markdown.tsx +4 -7
  117. package/src/widgets/no-data/no-data.tsx +7 -10
  118. package/src/widgets/pie/config.ts +17 -5
  119. package/src/widgets/range/components/range-item.tsx +20 -18
  120. package/src/widgets/scatterplot/config.ts +8 -3
  121. package/src/widgets/skeleton-loader/skeleton-loader.tsx +2 -5
  122. package/src/widgets/spread/components/max-value.tsx +14 -16
  123. package/src/widgets/spread/components/min-value.tsx +14 -16
  124. package/src/widgets/stores/index.ts +2 -1
  125. package/src/widgets/stores/types.ts +2 -0
  126. package/src/widgets/stores/use-widget-selector.ts +47 -0
  127. package/src/widgets/stores/widget-store-performance.test.ts +750 -0
  128. package/src/widgets/stores/widget-store.test.ts +81 -0
  129. package/src/widgets/stores/widget-store.ts +225 -44
  130. package/src/widgets/table/config.ts +0 -1
  131. package/src/widgets/table/hooks/use-pagination.ts +28 -52
  132. package/src/widgets/table/hooks/use-selection.ts +20 -24
  133. package/src/widgets/table/hooks/use-sort.ts +22 -39
  134. package/src/widgets/table/types.ts +1 -1
  135. package/src/widgets/timeseries/config.ts +21 -13
  136. package/src/widgets/utils/chart-config/index.ts +1 -1
  137. package/src/widgets/utils/chart-config/option-builders.ts +22 -12
  138. package/src/widgets/utils/formatter.ts +2 -1
  139. package/src/widgets/utils/index.ts +1 -1
  140. package/src/widgets/wrapper/wrapper-ui.tsx +12 -13
  141. package/src/widgets/wrapper/wrapper.tsx +4 -6
  142. package/dist/error-CEkRPccv.js +0 -39
  143. package/dist/error-CEkRPccv.js.map +0 -1
  144. package/dist/formatter-B1Xh8XDH.js +0 -5
  145. package/dist/formatter-B1Xh8XDH.js.map +0 -1
  146. package/dist/no-data-hR3KcJ-_.js +0 -60
  147. package/dist/no-data-hR3KcJ-_.js.map +0 -1
  148. package/dist/row-DTCV0Ocm.js +0 -35
  149. package/dist/row-DTCV0Ocm.js.map +0 -1
  150. package/dist/series-CYNOu2Ju.js +0 -91
  151. package/dist/series-CYNOu2Ju.js.map +0 -1
  152. package/dist/styles-C_8vOEep.js +0 -167
  153. package/dist/styles-C_8vOEep.js.map +0 -1
  154. package/dist/use-widget-ref-wtFLDFCD.js +0 -25
  155. package/dist/use-widget-ref-wtFLDFCD.js.map +0 -1
  156. package/dist/widget-store-CzDt8oSK.js +0 -163
  157. package/dist/widget-store-CzDt8oSK.js.map +0 -1
@@ -5,8 +5,7 @@ import type {
5
5
  EchartOptionsProps,
6
6
  } from './types'
7
7
  import { EchartUI } from './echart-ui'
8
- import { useWidgetStore } from '../stores/widget-store'
9
- import { useShallow } from 'zustand/shallow'
8
+ import { useWidgetSelector } from '../stores/use-widget-selector'
10
9
  import { useMemo } from 'react'
11
10
 
12
11
  /**
@@ -16,31 +15,18 @@ import { useMemo } from 'react'
16
15
  * Transforms widget data into ECharts dataset format and delegates rendering to {@link EchartUI}.
17
16
  */
18
17
  export function Echart(props: EchartProps) {
19
- const id = useWidgetStore(
20
- useShallow((state) => state.getWidget<EchartWidgetState>(props.id)?.id),
21
- )
22
-
23
- const data = useWidgetStore(
24
- useShallow(
25
- (state) =>
26
- state.getWidget<EchartWidgetState>(props.id)?.data as
27
- | EchartWidgetData
28
- | undefined,
29
- ),
30
- )
31
-
32
- const widgetOption = useWidgetStore(
33
- useShallow((state) => state.getWidget<EchartWidgetState>(props.id)?.option),
34
- )
35
-
36
- const onEvents = useWidgetStore(
37
- useShallow(
38
- (state) => state.getWidget<EchartWidgetState>(props.id)?.onEvents,
39
- ),
40
- )
41
-
42
- const init = useWidgetStore(
43
- (state) => state.getWidget<EchartWidgetState>(props.id)?.init,
18
+ // Single consolidated subscription instead of 5 separate ones.
19
+ const { id, data, widgetOption, onEvents, init } = useWidgetSelector(
20
+ props.id,
21
+ (w) => ({
22
+ id: w?.id,
23
+ data: (w as EchartWidgetState | undefined)?.data as
24
+ | EchartWidgetData
25
+ | undefined,
26
+ widgetOption: (w as EchartWidgetState | undefined)?.option,
27
+ onEvents: (w as EchartWidgetState | undefined)?.onEvents,
28
+ init: (w as EchartWidgetState | undefined)?.init,
29
+ }),
44
30
  )
45
31
 
46
32
  // Memoize dataset transformation to avoid re-computing on every render
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared ResizeObserver singleton for all ECharts instances.
3
+ *
4
+ * Instead of creating 100+ individual ResizeObserver instances (one per chart),
5
+ * this module provides a single shared observer that efficiently handles resize
6
+ * callbacks for all registered elements.
7
+ */
8
+
9
+ type ResizeCallback = () => void
10
+
11
+ const callbacks = new Map<Element, ResizeCallback>()
12
+
13
+ let observer: ResizeObserver | null = null
14
+
15
+ function getObserver(): ResizeObserver {
16
+ observer ??= new ResizeObserver((entries) => {
17
+ for (const entry of entries) {
18
+ const callback = callbacks.get(entry.target)
19
+ callback?.()
20
+ }
21
+ })
22
+ return observer
23
+ }
24
+
25
+ export function observeResize(
26
+ element: Element,
27
+ callback: ResizeCallback,
28
+ ): () => void {
29
+ callbacks.set(element, callback)
30
+ getObserver().observe(element)
31
+
32
+ return () => {
33
+ callbacks.delete(element)
34
+ getObserver().unobserve(element)
35
+ }
36
+ }
37
+
38
+ /** Reset the shared observer (for testing only). */
39
+ export function resetSharedResizeObserver(): void {
40
+ if (observer) {
41
+ observer.disconnect()
42
+ observer = null
43
+ }
44
+ callbacks.clear()
45
+ }
@@ -33,6 +33,7 @@ export interface EchartWidgetOptionProps<D> {
33
33
  data?: D
34
34
  theme: typeof CartoTheme
35
35
  formatter?: (value: number) => string
36
+ labelFormatter?: (value: string | number) => string | number
36
37
  }
37
38
 
38
39
  export interface EchartWidgetProps {
@@ -40,4 +41,5 @@ export interface EchartWidgetProps {
40
41
  option: EchartUIProps['option']
41
42
  onEvents?: EchartUIProps['onEvents']
42
43
  formatter?: (value: number) => string
44
+ labelFormatter?: (value: string | number) => string | number
43
45
  }
@@ -1,6 +1,5 @@
1
1
  import { Alert, AlertTitle } from '@mui/material'
2
- import { useWidgetStore } from '../stores/widget-store'
3
- import { useShallow } from 'zustand/shallow'
2
+ import { useWidgetSelector } from '../stores/use-widget-selector'
4
3
  import type { WidgetErrorProps } from './types'
5
4
 
6
5
  /**
@@ -19,13 +18,12 @@ export function WidgetError({
19
18
  title: titleProp,
20
19
  description,
21
20
  }: WidgetErrorProps) {
22
- const isLoading = useWidgetStore(
23
- useShallow((state) => state.widgets[id]?.isLoading),
24
- )
25
- const isFetching = useWidgetStore(
26
- useShallow((state) => state.widgets[id]?.isFetching),
27
- )
28
- const error = useWidgetStore(useShallow((state) => state.widgets[id]?.error))
21
+ // Single consolidated subscription instead of 3 separate ones.
22
+ const { isLoading, isFetching, error } = useWidgetSelector(id, (w) => ({
23
+ isLoading: w?.isLoading,
24
+ isFetching: w?.isFetching,
25
+ error: w?.error,
26
+ }))
29
27
 
30
28
  // Don't show error during loading/fetching states
31
29
  if (isLoading || isFetching) {
@@ -1,16 +1,14 @@
1
1
  import { type FormulaWidgetState, type ValueProps } from '../types'
2
- import { useWidgetStore } from '../../stores/widget-store'
3
2
  import { Item } from './item'
4
- import { useShallow } from 'zustand/shallow'
3
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
5
4
 
6
5
  /**
7
6
  * Renders the prefix content (e.g., currency symbol) before a formula value, if defined in the data item.
8
7
  */
9
8
  export function Prefix({ id, index = 0, ...props }: ValueProps) {
10
- const prefix = useWidgetStore(
11
- useShallow(
12
- (state) => state.getWidget<FormulaWidgetState>(id)?.data?.[index]?.prefix,
13
- ),
9
+ const prefix = useWidgetSelector(
10
+ id,
11
+ (w) => (w as FormulaWidgetState | undefined)?.data?.[index]?.prefix,
14
12
  )
15
13
 
16
14
  if (!prefix) {
@@ -1,15 +1,15 @@
1
1
  import { Box } from '@mui/material'
2
2
  import { styles } from '../style'
3
3
  import type { FormulaWidgetState, RowProps } from '../types'
4
- import { useWidgetStore } from '../../stores/widget-store'
5
- import { useShallow } from 'zustand/shallow'
4
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
6
5
 
7
6
  /**
8
7
  * Iterates over the widget's data items and renders a row for each one using render props.
9
8
  */
10
9
  export function Row(props: RowProps) {
11
- const data = useWidgetStore(
12
- useShallow((state) => state.getWidget<FormulaWidgetState>(props.id)?.data),
10
+ const data = useWidgetSelector(
11
+ props.id,
12
+ (w) => (w as FormulaWidgetState | undefined)?.data,
13
13
  )
14
14
 
15
15
  return data?.map((_, index) => {
@@ -1,16 +1,14 @@
1
1
  import { Avatar } from '@mui/material'
2
2
  import type { FormulaWidgetState, ValueProps } from '../types'
3
- import { useWidgetStore } from '../../stores/widget-store'
4
- import { useShallow } from 'zustand/shallow'
3
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
5
4
 
6
5
  /**
7
6
  * Renders a colored avatar badge showing the first letter of the series name for multi-value formula widgets.
8
7
  */
9
8
  export function Series({ id, index = 0 }: Pick<ValueProps, 'id' | 'index'>) {
10
- const serie = useWidgetStore(
11
- useShallow(
12
- (state) => state.getWidget<FormulaWidgetState>(id)?.series?.[index],
13
- ),
9
+ const serie = useWidgetSelector(
10
+ id,
11
+ (w) => (w as FormulaWidgetState | undefined)?.series?.[index],
14
12
  )
15
13
  if (!serie) {
16
14
  return null
@@ -1,17 +1,15 @@
1
1
  import { type FormulaWidgetState, type ValueProps } from '../types'
2
- import { useWidgetStore } from '../../stores/widget-store'
3
2
  import { Item } from './item'
4
3
  import type { Theme } from '@mui/material'
5
- import { useShallow } from 'zustand/shallow'
4
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
6
5
 
7
6
  /**
8
7
  * Renders the suffix content (e.g., unit label) after a formula value, if defined in the data item.
9
8
  */
10
9
  export function Suffix({ id, index = 0, ...props }: ValueProps) {
11
- const suffix = useWidgetStore(
12
- useShallow(
13
- (state) => state.getWidget<FormulaWidgetState>(id)?.data?.[index]?.suffix,
14
- ),
10
+ const suffix = useWidgetSelector(
11
+ id,
12
+ (w) => (w as FormulaWidgetState | undefined)?.data?.[index]?.suffix,
15
13
  )
16
14
 
17
15
  if (!suffix) {
@@ -1,27 +1,20 @@
1
1
  import { type FormulaWidgetState, type ValueProps } from '../types'
2
- import { useWidgetStore } from '../../stores/widget-store'
3
2
  import { Item } from './item'
4
- import { useShallow } from 'zustand/shallow'
5
3
  import { defaultFormatter } from '../../utils/formatter'
4
+ import { useWidgetSelector } from '../../stores/use-widget-selector'
6
5
 
7
6
  /**
8
7
  * Displays the formatted numeric value for a formula widget data item, applying the widget's formatter and color.
9
8
  */
10
9
  export function Value({ id, index = 0, ...props }: ValueProps) {
11
- const value = useWidgetStore(
12
- useShallow(
13
- (state) => state.getWidget<FormulaWidgetState>(id)?.data?.[index]?.value,
14
- ),
15
- )
16
- const color = useWidgetStore(
17
- useShallow(
18
- (state) => state.getWidget<FormulaWidgetState>(id)?.data?.[index]?.color,
19
- ),
20
- )
21
- const formatter =
22
- useWidgetStore(
23
- useShallow((state) => state.getWidget<FormulaWidgetState>(id)?.formatter),
24
- ) ?? defaultFormatter
10
+ const { value, color, formatter } = useWidgetSelector(id, (w) => {
11
+ const widget = w as FormulaWidgetState | undefined
12
+ return {
13
+ value: widget?.data?.[index]?.value,
14
+ color: widget?.data?.[index]?.color,
15
+ formatter: widget?.formatter ?? defaultFormatter,
16
+ }
17
+ })
25
18
 
26
19
  return (
27
20
  <Item TypographyProps={{ color }} {...props}>
@@ -3,27 +3,104 @@ import {
3
3
  mergeEchartWidgetConfig,
4
4
  type EchartOptionsProps,
5
5
  } from '../echart'
6
- import type {
7
- HistogramConfig,
8
- HistogramWidgetConfig,
9
- HistogramWidgetData,
10
- } from './types'
6
+ import type { HistogramConfig, HistogramWidgetConfig } from './types'
11
7
  import {
12
- flattenObjectArrayToCSV,
13
8
  buildLegendConfig,
14
9
  buildGridConfig,
15
10
  createTooltipPositioner,
16
11
  createTooltipFormatter,
17
- createChartDownloadConfig,
18
12
  niceNum,
19
13
  } from '../utils/chart-config'
14
+ import { downloadToCSV, downloadToPNG, type DownloadItem } from '../actions'
15
+ import type { ConfigProps } from '../loader/types'
16
+
17
+ export interface HistogramDownloadConfigProps extends ConfigProps {
18
+ ticks: number[]
19
+ labelFormatter?: (value: string | number) => string | number
20
+ }
21
+
22
+ export function histogramDataToCSV(
23
+ data: number[][],
24
+ ticks: number[],
25
+ labelFormatter?: (value: string | number) => string | number,
26
+ ): string[][] {
27
+ if (!data?.length || data[0]?.length === 0) return []
28
+
29
+ const dataLength = data[0]?.length ?? 0
30
+ const labels = createAxisLabels(dataLength, ticks, labelFormatter)
31
+ const seriesCount = data.length
32
+ const isMulti = seriesCount > 1
33
+
34
+ const headers = isMulti
35
+ ? [
36
+ 'Bin',
37
+ ...Array.from({ length: seriesCount }, (_, i) => `Series ${i + 1}`),
38
+ ]
39
+ : ['Bin', 'Value']
40
+
41
+ return [
42
+ headers,
43
+ ...labels.map((label, i) => [
44
+ label,
45
+ ...data.map((series) => String(series[i] ?? 0)),
46
+ ]),
47
+ ]
48
+ }
49
+
50
+ export function histogramDownloadConfig({
51
+ refUI,
52
+ ticks,
53
+ labelFormatter,
54
+ }: HistogramDownloadConfigProps): DownloadItem<number[][]>[] {
55
+ return [
56
+ {
57
+ ...downloadToPNG,
58
+ modifier: () => downloadToPNG.modifier(refUI),
59
+ },
60
+ {
61
+ ...downloadToCSV,
62
+ modifier: async (data) =>
63
+ downloadToCSV.modifier(histogramDataToCSV(data, ticks, labelFormatter)),
64
+ },
65
+ ]
66
+ }
20
67
 
21
- export const histogramDownloadConfig =
22
- createChartDownloadConfig<HistogramWidgetData>(flattenObjectArrayToCSV)
23
68
  /**
24
- * Generates ECharts configuration for distribution histogram widgets with adjacent bars (minimal gap) and axis formatting styled with the CARTO theme.
69
+ * Creates formatted axis labels from tick boundaries.
25
70
  *
26
- * @param props - Histogram configuration including bin data and theme.
71
+ * @param dataLength - Number of data points (determines number of labels).
72
+ * @param ticks - Bin boundaries. If `ticks.length === dataLength + 1`, all
73
+ * bins are ranges. If `ticks.length === dataLength`, the last bin is
74
+ * open-ended (`+`). A last tick of `Infinity` also produces `+`.
75
+ * @param labelFormatter - Optional formatter applied to each individual tick
76
+ * value when building the bin range label.
77
+ */
78
+ function createAxisLabels(
79
+ dataLength: number,
80
+ ticks: number[],
81
+ labelFormatter?: (value: string | number) => string | number,
82
+ ): string[] {
83
+ const fmt = (v: number) =>
84
+ labelFormatter ? String(labelFormatter(v)) : String(v)
85
+
86
+ return Array.from({ length: dataLength }, (_, i) => {
87
+ const low = ticks[i] ?? i
88
+ const high = ticks[i + 1]
89
+ return high !== undefined && isFinite(high)
90
+ ? `${fmt(low)}-${fmt(high)}`
91
+ : `${fmt(low)}+`
92
+ })
93
+ }
94
+
95
+ /**
96
+ * Generates ECharts configuration for distribution histogram widgets with
97
+ * adjacent bars (minimal gap) and axis formatting styled with the CARTO theme.
98
+ *
99
+ * Accepts raw `number[][]` data and `ticks` boundaries. The ticks and
100
+ * `labelFormatter` are used to create the x-axis category labels; the raw
101
+ * numeric data is embedded directly in each series.
102
+ *
103
+ * @param props - Histogram configuration including raw data, ticks, and theme.
27
104
  * @returns Widget config with ECharts option object.
28
105
  */
29
106
  export function histogramConfig(props: HistogramConfig): HistogramWidgetConfig {
@@ -33,21 +110,27 @@ export function histogramConfig(props: HistogramConfig): HistogramWidgetConfig {
33
110
  formatter: props.formatter,
34
111
  }
35
112
  }
113
+
36
114
  function getOption({
37
115
  data = [],
116
+ ticks,
38
117
  theme,
39
118
  formatter,
119
+ labelFormatter,
40
120
  }: HistogramConfig): EchartOptionsProps {
41
121
  const hasLegend = (data?.length ?? 0) > 1
122
+ const dataLength = data[0]?.length ?? 0
123
+ const axisLabels = createAxisLabels(dataLength, ticks, labelFormatter)
42
124
 
43
125
  let niceMin = 0
44
126
  let niceMax = 1
45
127
 
46
128
  return {
47
- legend: buildLegendConfig(hasLegend),
129
+ legend: buildLegendConfig({ hasLegend }),
48
130
  grid: buildGridConfig(hasLegend, theme),
49
131
  xAxis: {
50
132
  type: 'category',
133
+ data: axisLabels,
51
134
  axisLine: {
52
135
  show: false,
53
136
  },
@@ -117,14 +200,12 @@ function getOption({
117
200
  tooltip: {
118
201
  position: createTooltipPositioner(theme),
119
202
  formatter: createTooltipFormatter((item) => {
120
- const value = item.value as Record<string, string | number>
121
- const index = item.dimensionNames?.[item.encode?.y?.at(0) ?? 1]
122
- const _value = value[index ?? '']
203
+ const _value = item.value as number
123
204
 
124
205
  const formattedValue =
125
206
  typeof _value === 'number' && formatter
126
207
  ? formatter(_value)
127
- : (_value ?? '')
208
+ : String(_value ?? '')
128
209
 
129
210
  const marker = typeof item.marker === 'string' ? item.marker : ''
130
211
  const seriesName = item.seriesName ? `${item.seriesName}: ` : ''
@@ -133,11 +214,11 @@ function getOption({
133
214
  return { name, seriesName, marker, value: formattedValue }
134
215
  }),
135
216
  },
136
- series: data.map((_: unknown, index: number) => ({
137
- datasetIndex: index,
217
+ series: data.map((seriesData: number[]) => ({
138
218
  type: 'bar',
139
- barGap: '1%', // No gap between bars in histogram
140
- barCategoryGap: '1%', // No gap between categories in histogram
219
+ data: seriesData,
220
+ barGap: '1%',
221
+ barCategoryGap: '1%',
141
222
  emphasis: {
142
223
  focus: 'series',
143
224
  },
@@ -4,5 +4,10 @@ export type {
4
4
  HistogramWidgetData,
5
5
  HistogramWidgetState,
6
6
  } from './types'
7
- export { histogramConfig, histogramDownloadConfig } from './config'
7
+ export type { HistogramDownloadConfigProps } from './config'
8
+ export {
9
+ histogramConfig,
10
+ histogramDataToCSV,
11
+ histogramDownloadConfig,
12
+ } from './config'
8
13
  export { HistogramSkeleton } from './skeleton'
@@ -1,11 +1,11 @@
1
- import type { EchartWidgetData, EchartWidgetState } from '../echart'
1
+ import type { EchartWidgetState } from '../echart'
2
2
  import type {
3
3
  EchartWidgetOptionProps,
4
4
  EchartWidgetProps,
5
5
  } from '../echart/types'
6
6
  import type { ConfigProps } from '../loader'
7
7
 
8
- export type HistogramWidgetData = EchartWidgetData
8
+ export type HistogramWidgetData = number[][]
9
9
 
10
10
  export type HistogramWidgetState = EchartWidgetState
11
11
 
@@ -14,4 +14,10 @@ export type HistogramWidgetConfig = EchartWidgetProps & {
14
14
  }
15
15
 
16
16
  export type HistogramConfig = ConfigProps &
17
- EchartWidgetOptionProps<HistogramWidgetData>
17
+ Pick<
18
+ EchartWidgetOptionProps<unknown>,
19
+ 'theme' | 'formatter' | 'labelFormatter'
20
+ > & {
21
+ data?: number[][]
22
+ ticks: number[]
23
+ }
@@ -1,6 +1,6 @@
1
- import { useEffect, useRef, useSyncExternalStore } from 'react'
1
+ import { useEffect, useRef } from 'react'
2
2
  import type { WidgetLoaderProps } from './types'
3
- import { useWidgetStore } from '../stores/widget-store'
3
+ import { useWidgetStore, widgetStoreActions } from '../stores/widget-store'
4
4
  import type { WrapperState } from '../wrapper'
5
5
 
6
6
  /**
@@ -28,17 +28,11 @@ import type { WrapperState } from '../wrapper'
28
28
  export function WidgetLoader<T extends object = Record<string, unknown>>(
29
29
  props: WidgetLoaderProps<T>,
30
30
  ) {
31
- const setWidget = useWidgetStore((state) => state.setWidget)
32
- const executeToolPipeline = useWidgetStore(
33
- (state) => state.executeToolPipeline,
34
- )
35
- const executeConfigPipeline = useWidgetStore(
36
- (state) => state.executeConfigPipeline,
37
- )
38
-
39
- const registeredTools = useSyncExternalStore(
40
- useWidgetStore.subscribe,
41
- () => useWidgetStore.getState().widgets[props.id]?.registeredTools,
31
+ // Subscribe only to this widget's registeredTools via Zustand selector.
32
+ // The selector returns a stable reference when other widgets change,
33
+ // avoiding unnecessary re-evaluations of Effect 4.
34
+ const registeredTools = useWidgetStore(
35
+ (state) => state.widgets[props.id]?.registeredTools,
42
36
  )
43
37
 
44
38
  const dataRef = useRef(props.data)
@@ -50,56 +44,49 @@ export function WidgetLoader<T extends object = Record<string, unknown>>(
50
44
  configRef.current = props.config
51
45
  })
52
46
 
53
- // Split into 3 effects for metadata and 1 for data pipeline:
54
- // Each property that can be modified independently gets its own effect to avoid
55
- // accidentally resetting other properties.
56
- //
57
- // - Effect 1: Type (can be modified by tools that change visualization type)
58
- // - Effect 2: Loading/Error states (change during fetch lifecycle)
59
- // - Effect 3: Config (can be modified by tools that change widget configuration)
60
- // - Effect 4: Data pipeline execution (transforms data through registered tools)
61
- // - Effect 5: Re-execute pipeline when tool state changes
62
-
63
- // Effect 1: Type updates
47
+ // Effect 1: Metadata type, loading, and error states in a single setWidget call.
48
+ // Merged to reduce store updates from 2 to 1 per widget during initialization.
64
49
  useEffect(() => {
65
- setWidget<WrapperState>(props.id, {
50
+ widgetStoreActions.setWidget<WrapperState>(props.id, {
66
51
  type: props.type,
67
- })
68
- }, [props.id, props.type, setWidget])
69
-
70
- // Effect 2: Loading and error states
71
- useEffect(() => {
72
- setWidget<WrapperState>(props.id, {
73
52
  isLoading: props.isLoading ?? false,
74
53
  isFetching: props.isFetching ?? false,
75
54
  error: props.error,
76
55
  })
77
- }, [props.id, props.isLoading, props.isFetching, props.error, setWidget])
56
+ }, [props.id, props.type, props.isLoading, props.isFetching, props.error])
78
57
 
79
- // Effect 3: Config updates — run through config pipeline
58
+ // Effect 2: Config updates — run through config pipeline
80
59
  useEffect(() => {
81
60
  if (props.config) {
82
- void executeConfigPipeline(props.id, props.config)
61
+ void widgetStoreActions.executeConfigPipeline(props.id, props.config)
83
62
  }
84
- }, [props.id, props.config, executeConfigPipeline])
63
+ }, [props.id, props.config])
85
64
 
86
- // Effect 4: Execute tool pipeline when props.data changes
65
+ // Effect 3: Execute tool pipeline when props.data changes
87
66
  useEffect(() => {
88
- void executeToolPipeline(props.id, props.data)
89
- }, [props.id, props.data, executeToolPipeline])
67
+ void widgetStoreActions.executeToolPipeline(props.id, props.data)
68
+ }, [props.id, props.data])
90
69
 
91
- // Effect 5: Re-execute pipelines when registered tools change
70
+ // Effect 4: Re-execute pipelines when registered tools change.
71
+ // Uses requestAnimationFrame to coalesce rapid successive registeredTools
72
+ // changes (e.g., 6 action components each calling registerTool on mount)
73
+ // into a single pipeline execution instead of 6 pairs.
92
74
  useEffect(() => {
93
75
  if (!isMountedRef.current) {
94
76
  isMountedRef.current = true
95
77
  return
96
78
  }
97
79
 
98
- void executeToolPipeline(props.id, dataRef.current)
99
- if (configRef.current) {
100
- void executeConfigPipeline(props.id, configRef.current)
101
- }
102
- }, [registeredTools, props.id, executeToolPipeline, executeConfigPipeline])
80
+ const rafId = requestAnimationFrame(() => {
81
+ const { executeToolPipeline, executeConfigPipeline } = widgetStoreActions
82
+ void executeToolPipeline(props.id, dataRef.current)
83
+ if (configRef.current) {
84
+ void executeConfigPipeline(props.id, configRef.current)
85
+ }
86
+ })
87
+
88
+ return () => cancelAnimationFrame(rafId)
89
+ }, [registeredTools, props.id])
103
90
 
104
91
  return props.children
105
92
  }
@@ -1,18 +1,15 @@
1
1
  import type { MarkdownUIProps } from './types'
2
- import { useWidgetStore } from '../stores/widget-store'
3
2
  import type { MarkdownWidgetData } from './types'
4
3
  import { MarkdownUI } from '.'
5
- import { useShallow } from 'zustand/shallow'
4
+ import { useWidgetSelector } from '../stores/use-widget-selector'
6
5
 
7
6
  /**
8
7
  * Stateful markdown widget component that reads content from the widget store and renders it as formatted rich text.
9
8
  */
10
9
  export function Markdown({ id }: MarkdownUIProps) {
11
- const content = useWidgetStore(
12
- useShallow(
13
- (state) =>
14
- (state.getWidget(id)?.data as MarkdownWidgetData | undefined)?.content,
15
- ),
10
+ const content = useWidgetSelector(
11
+ id,
12
+ (w) => (w?.data as MarkdownWidgetData | undefined)?.content,
16
13
  )
17
14
 
18
15
  if (!content) {
@@ -1,6 +1,5 @@
1
1
  import { Box, Typography } from '@mui/material'
2
- import { useShallow } from 'zustand/shallow'
3
- import { useWidgetStore } from '../stores/widget-store'
2
+ import { useWidgetSelector } from '../stores/use-widget-selector'
4
3
  import type { WidgetNoDataProps } from './types'
5
4
  import { styles } from './style'
6
5
 
@@ -44,14 +43,12 @@ export function WidgetNoData({
44
43
  description = 'There are no results for the combination of filters applied to your data. Try tweaking your filters, or zoom and pan the map to adjust filters',
45
44
  isEmpty = defaultIsEmpty,
46
45
  }: WidgetNoDataProps) {
47
- // Subscribe to widget store with selective subscription for optimal performance
48
- const isLoading = useWidgetStore(
49
- useShallow((state) => state.widgets[id]?.isLoading),
50
- )
51
- const isFetching = useWidgetStore(
52
- useShallow((state) => state.widgets[id]?.isFetching),
53
- )
54
- const data = useWidgetStore(useShallow((state) => state.widgets[id]?.data))
46
+ // Single consolidated subscription instead of 3 separate ones.
47
+ const { isLoading, isFetching, data } = useWidgetSelector(id, (w) => ({
48
+ isLoading: w?.isLoading,
49
+ isFetching: w?.isFetching,
50
+ data: w?.data,
51
+ }))
55
52
 
56
53
  // If loading or fetching, show children
57
54
  // SkeletonLoader handles loading state, this allows proper composition