@carto/ps-react-ui 6.1.1 → 6.3.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 (119) hide show
  1. package/dist/{actions-BWlS5tpP.js → actions-CkxKe8-J.js} +57 -47
  2. package/dist/actions-CkxKe8-J.js.map +1 -0
  3. package/dist/category-DULnoxvk.js +933 -0
  4. package/dist/category-DULnoxvk.js.map +1 -0
  5. package/dist/chat.js +1 -1
  6. package/dist/chat.js.map +1 -1
  7. package/dist/components-CTUpdQuj.js.map +1 -1
  8. package/dist/download-B4sykOFm.js.map +1 -1
  9. package/dist/{echart-CV8F50gn.js → echart-ysYFLa_T.js} +2 -2
  10. package/dist/echart-ysYFLa_T.js.map +1 -0
  11. package/dist/formula-DJS-JLpk.js.map +1 -1
  12. package/dist/{option-builders-DQivvvbb.js → item-color-B7xkIpDZ.js} +26 -9
  13. package/dist/item-color-B7xkIpDZ.js.map +1 -0
  14. package/dist/legend.js.map +1 -1
  15. package/dist/{markdown-Bn2nKCbn.js → markdown-DmvLi3vx.js} +2 -2
  16. package/dist/markdown-DmvLi3vx.js.map +1 -0
  17. package/dist/{markdown-content-kYNLOX0T.js → markdown-content-BpAH_V6S.js} +5 -4
  18. package/dist/markdown-content-BpAH_V6S.js.map +1 -0
  19. package/dist/range-BpU2zBmq.js.map +1 -1
  20. package/dist/smart-tooltip-Dc_rHhyI.js.map +1 -1
  21. package/dist/spread-CQwFIwuz.js.map +1 -1
  22. package/dist/stores-DcBOZlU3.js.map +1 -1
  23. package/dist/stores-mad-9yFn.js.map +1 -1
  24. package/dist/styles-BCEBm7xh.js.map +1 -1
  25. package/dist/table-DEdW_fDj.js.map +1 -1
  26. package/dist/types/widgets/bar/types.d.ts +6 -0
  27. package/dist/types/widgets/category/category-ui.d.ts +10 -2
  28. package/dist/types/widgets/category/category.d.ts +7 -1
  29. package/dist/types/widgets/category/components/category-bar-stacked.d.ts +8 -1
  30. package/dist/types/widgets/category/components/category-bar.d.ts +24 -2
  31. package/dist/types/widgets/category/components/category-row-multi.d.ts +3 -1
  32. package/dist/types/widgets/category/components/category-row-single.d.ts +3 -1
  33. package/dist/types/widgets/category/components/category-row-stacked.d.ts +10 -1
  34. package/dist/types/widgets/category/style.d.ts +33 -6
  35. package/dist/types/widgets/histogram/bins.d.ts +7 -0
  36. package/dist/types/widgets/histogram/download.d.ts +2 -2
  37. package/dist/types/widgets/histogram/index.d.ts +2 -1
  38. package/dist/types/widgets/histogram/options.d.ts +1 -1
  39. package/dist/types/widgets/histogram/transforms.d.ts +5 -4
  40. package/dist/types/widgets/histogram/types.d.ts +22 -5
  41. package/dist/types/widgets/index.d.ts +1 -1
  42. package/dist/types/widgets/pie/types.d.ts +6 -0
  43. package/dist/types/widgets/scatterplot/download.d.ts +2 -2
  44. package/dist/types/widgets/scatterplot/index.d.ts +2 -1
  45. package/dist/types/widgets/scatterplot/points.d.ts +7 -0
  46. package/dist/types/widgets/scatterplot/transforms.d.ts +4 -3
  47. package/dist/types/widgets/scatterplot/types.d.ts +21 -2
  48. package/dist/types/widgets/timeseries/types.d.ts +6 -0
  49. package/dist/types/widgets/utils/item-color.d.ts +26 -0
  50. package/dist/utils-C-eWwOhE.js.map +1 -1
  51. package/dist/widgets/actions.js +1 -1
  52. package/dist/widgets/bar.js +78 -70
  53. package/dist/widgets/bar.js.map +1 -1
  54. package/dist/widgets/category.js +1 -1
  55. package/dist/widgets/echart.js +1 -1
  56. package/dist/widgets/histogram.js +116 -85
  57. package/dist/widgets/histogram.js.map +1 -1
  58. package/dist/widgets/markdown.js +2 -2
  59. package/dist/widgets/pie.js +109 -90
  60. package/dist/widgets/pie.js.map +1 -1
  61. package/dist/widgets/scatterplot.js +131 -92
  62. package/dist/widgets/scatterplot.js.map +1 -1
  63. package/dist/widgets/timeseries.js +82 -74
  64. package/dist/widgets/timeseries.js.map +1 -1
  65. package/dist/widgets.js +5 -5
  66. package/dist/widgets.js.map +1 -1
  67. package/dist/zoom-toggle-MmLjMTEw.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  70. package/src/widgets/actions/fullscreen/fullscreen.tsx +48 -5
  71. package/src/widgets/bar/options.test.ts +106 -0
  72. package/src/widgets/bar/options.ts +25 -14
  73. package/src/widgets/bar/types.ts +6 -0
  74. package/src/widgets/category/category-ui.test.tsx +139 -0
  75. package/src/widgets/category/category-ui.tsx +64 -11
  76. package/src/widgets/category/category.test.tsx +67 -0
  77. package/src/widgets/category/category.tsx +32 -15
  78. package/src/widgets/category/components/category-bar-stacked.test.tsx +18 -0
  79. package/src/widgets/category/components/category-bar-stacked.tsx +42 -29
  80. package/src/widgets/category/components/category-bar.test.tsx +66 -0
  81. package/src/widgets/category/components/category-bar.tsx +65 -9
  82. package/src/widgets/category/components/category-row-multi.tsx +21 -0
  83. package/src/widgets/category/components/category-row-single.tsx +4 -0
  84. package/src/widgets/category/components/category-row-stacked.tsx +24 -1
  85. package/src/widgets/category/style.ts +58 -14
  86. package/src/widgets/histogram/bins.ts +22 -0
  87. package/src/widgets/histogram/download.test.ts +10 -0
  88. package/src/widgets/histogram/download.ts +7 -3
  89. package/src/widgets/histogram/index.ts +3 -0
  90. package/src/widgets/histogram/options.test.ts +62 -0
  91. package/src/widgets/histogram/options.ts +38 -17
  92. package/src/widgets/histogram/transforms.test.ts +21 -7
  93. package/src/widgets/histogram/transforms.ts +26 -12
  94. package/src/widgets/histogram/types.ts +25 -5
  95. package/src/widgets/index.ts +6 -1
  96. package/src/widgets/pie/options.test.ts +91 -0
  97. package/src/widgets/pie/options.ts +50 -20
  98. package/src/widgets/pie/types.ts +6 -0
  99. package/src/widgets/scatterplot/download.test.ts +9 -0
  100. package/src/widgets/scatterplot/download.ts +5 -3
  101. package/src/widgets/scatterplot/index.ts +3 -0
  102. package/src/widgets/scatterplot/options.test.ts +78 -0
  103. package/src/widgets/scatterplot/options.ts +35 -11
  104. package/src/widgets/scatterplot/points.ts +35 -0
  105. package/src/widgets/scatterplot/transforms.test.ts +19 -0
  106. package/src/widgets/scatterplot/transforms.ts +23 -18
  107. package/src/widgets/scatterplot/types.ts +25 -2
  108. package/src/widgets/timeseries/options.test.ts +65 -0
  109. package/src/widgets/timeseries/options.ts +31 -14
  110. package/src/widgets/timeseries/types.ts +6 -0
  111. package/src/widgets/utils/item-color.test.ts +74 -0
  112. package/src/widgets/utils/item-color.ts +55 -0
  113. package/dist/actions-BWlS5tpP.js.map +0 -1
  114. package/dist/category-alzjKzvQ.js +0 -839
  115. package/dist/category-alzjKzvQ.js.map +0 -1
  116. package/dist/echart-CV8F50gn.js.map +0 -1
  117. package/dist/markdown-Bn2nKCbn.js.map +0 -1
  118. package/dist/markdown-content-kYNLOX0T.js.map +0 -1
  119. package/dist/option-builders-DQivvvbb.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"zoom-toggle-MmLjMTEw.js","names":["Icon = ZoomInIcon","{\n initialEnabled = false,\n labels,\n icon: Icon = ZoomInIcon,\n iconProps,\n axes,\n}: ZoomToggleProps","initialEnabled = false","{ ...DEFAULT_ZOOM_TOGGLE_LABELS, ...labels }","axes ? axes.includes('x') : true","axes ? axes.includes('y') : false","createAddZoom(theme, { axes: resolved })","useMemo(() => {\n const resolved: ZoomAxis[] = []\n if (includeX) resolved.push('x')\n if (includeY) resolved.push('y')\n return createAddZoom(theme, { axes: resolved })\n }, [theme, includeX, includeY])","{ initialEnabled }","() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }","useCallback(() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }, [enabled, toggle, id])","{ ...styles.toggle, ...(enabled && styles.toggleActive) }","enabled && styles.toggleActive","<Icon fontSize='small' {...iconProps} />","<IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>","enabled ? 'active' : undefined","<Tooltip title={tooltip}>\n <IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>\n </Tooltip>"],"sources":["../src/widgets/actions/zoom-toggle/transforms.ts","../src/widgets/actions/zoom-toggle/labels.ts","../src/widgets/actions/zoom-toggle/style.ts","../src/widgets/actions/zoom-toggle/zoom-toggle.tsx"],"sourcesContent":["import type { Theme } from '@mui/material'\n\n/**\n * v1's slider handle — 9×36 SVG with a white pill body, secondary-colored\n * stroke, and three grip lines. Inlined as a data URL so the chart can\n * render it without a network fetch.\n */\nconst SLIDER_HANDLE_ICON =\n 'image://data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSIzNiIgdmlld0JveD0iMCAwIDkgMzYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjAuNSIgeT0iLTAuNSIgd2lkdGg9IjgiIGhlaWdodD0iMTgiIHJ4PSI0IiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAwIDI3KSIgZmlsbD0id2hpdGUiIHN0cm9rZT0iIzM1OEJFNyIvPgo8cmVjdCB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMyAyMykiIGZpbGw9IiMzNThCRTciLz4KPHJlY3Qgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDMgMTkpIiBmaWxsPSIjMzU4QkU3Ii8+CjxyZWN0IHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAzIDE1KSIgZmlsbD0iIzM1OEJFNyIvPgo8L3N2Zz4='\n\nconst SLIDER_HEIGHT = 32\nconst SLIDER_BOTTOM = 0\n\n/** Which chart axis (or axes) zoom applies to. */\nexport type ZoomAxis = 'x' | 'y'\n\nexport interface CreateAddZoomOptions {\n /**\n * Axes to enable zoom on. Default `['x']` (matches bar / histogram /\n * timeseries — horizontal pan/zoom on the category axis). Pass\n * `['x', 'y']` for a scatterplot-style 2D zoom (mouse-wheel scales both\n * axes; a horizontal slider sits at the bottom for x-range and a\n * vertical slider sits on the right for y-range).\n */\n axes?: readonly ZoomAxis[]\n}\n\n/**\n * Theme-bound zoom config transform: adds one inside `dataZoom`\n * (mouse-wheel + drag) per axis plus a styled slider per axis with the\n * v1 chrome (blue-tinted filler, secondary-color data preview, white\n * pill handles, CARTO typography). The x-slider is horizontal at the\n * bottom; the y-slider is vertical on the right.\n *\n * Returned function has stable identity for a given theme + axes\n * combination; pair with `useMemo` at the call site.\n *\n * **No `start` / `end`** are emitted: ECharts defaults to `0–100` on the\n * first `setOption` and then keeps the runtime slider state across\n * subsequent merges (no `replaceMerge` for `dataZoom`). Re-running the\n * transform on data/formatter/RelativeData updates therefore preserves the\n * user's dragged range instead of snapping it back.\n */\nexport function createAddZoom(\n theme: Theme,\n options: CreateAddZoomOptions = {},\n): (option: unknown) => unknown {\n const axes = options.axes ?? ['x']\n const includeX = axes.includes('x')\n const includeY = axes.includes('y')\n const sliderStyles = getZoomSliderStyles(theme)\n const baseSliderProps = {\n throttle: 0,\n showDetail: false,\n brushSelect: false,\n moveHandleSize: 8,\n handleSize: '100%',\n ...sliderStyles,\n }\n return (option: unknown): unknown => {\n if (option == null || typeof option !== 'object') return option\n const cfg = option as Record<string, unknown>\n const dataZoom: object[] = []\n if (includeX) {\n dataZoom.push({ type: 'inside', xAxisIndex: [0], throttle: 0 })\n }\n if (includeY) {\n dataZoom.push({ type: 'inside', yAxisIndex: [0], throttle: 0 })\n }\n if (includeX) {\n dataZoom.push({\n type: 'slider',\n xAxisIndex: [0],\n height: SLIDER_HEIGHT,\n bottom: SLIDER_BOTTOM,\n handleIcon: SLIDER_HANDLE_ICON,\n ...baseSliderProps,\n })\n }\n if (includeY) {\n // Vertical slider — `width` is its thickness, `right` pins it to\n // the right edge. We omit `handleIcon` so ECharts renders its\n // default vertical handle (the inline SVG is shaped for a\n // horizontal slider and looks wrong when rotated).\n dataZoom.push({\n type: 'slider',\n yAxisIndex: [0],\n width: SLIDER_HEIGHT,\n right: SLIDER_BOTTOM,\n ...baseSliderProps,\n })\n }\n return { ...cfg, dataZoom }\n }\n}\n\n/**\n * Back-compat untyped transform — produces the bare `dataZoom` config from\n * the original v2 implementation. Use {@link createAddZoom} when a theme is\n * available so the slider matches the v1 styling.\n *\n * `start` / `end` are intentionally omitted so ECharts preserves the user's\n * runtime range across subsequent `setOption` merges (see {@link createAddZoom}).\n */\nexport const addZoom = (option: unknown): unknown => {\n if (option == null || typeof option !== 'object') return option\n const cfg = option as Record<string, unknown>\n return {\n ...cfg,\n dataZoom: [\n { type: 'inside' },\n {\n type: 'slider',\n height: SLIDER_HEIGHT,\n bottom: SLIDER_BOTTOM,\n },\n ],\n }\n}\n\n/** Style block matching v1's `getEChartZoomSliderStyles`. */\nfunction getZoomSliderStyles(theme: Theme) {\n const secondary = theme.palette.secondary?.main ?? '#358BE7'\n return {\n fillerColor: 'rgba(53, 139, 231, 0.25)',\n borderColor: 'rgba(53, 139, 231, 0.3)',\n borderWidth: 0.5,\n backgroundColor: 'transparent',\n borderRadius: 4,\n dataBackground: {\n lineStyle: { opacity: 0 },\n areaStyle: { opacity: 1, color: secondary },\n },\n selectedDataBackground: {\n lineStyle: { opacity: 0 },\n areaStyle: { opacity: 1, color: secondary },\n },\n handleStyle: {\n color: theme.palette.common?.white ?? '#fff',\n borderColor: 'rgba(3, 111, 226, 0.08)',\n borderWidth: 1,\n shadowBlur: 3,\n shadowColor: 'rgba(0, 0, 0, 0.1)',\n shadowOffsetX: 0,\n shadowOffsetY: 1,\n },\n textStyle: {\n color: theme.palette.black?.[60] ?? theme.palette.text?.primary,\n fontSize: parseInt(\n (theme.typography.overlineDelicate?.fontSize as string | undefined) ??\n '11',\n ),\n fontFamily:\n theme.typography.overlineDelicate?.fontFamily ??\n theme.typography.caption?.fontFamily,\n },\n }\n}\n\n/**\n * Visual layout constants used by the data-fusion mergers (bar, histogram,\n * timeseries) when laying out the slider against the legend / grid.\n */\nexport const ZOOM_LAYOUT = {\n /** Slider height in px (matches `createAddZoom` output). */\n sliderHeight: SLIDER_HEIGHT,\n /** Vertical gap between chart grid and the slider. */\n sliderGap: 8,\n /** Slider `bottom` when a legend is shown — sits above the legend row. */\n sliderBottomWithLegend: 28,\n} as const\n","export interface ZoomToggleLabels {\n on: string\n off: string\n}\n\nexport const DEFAULT_ZOOM_TOGGLE_LABELS: ZoomToggleLabels = {\n on: 'Disable zoom',\n off: 'Enable zoom',\n}\n","import type { SxProps, Theme } from '@mui/material'\n\nexport const styles = {\n toggle: {\n p: 0.5,\n '& .MuiSvgIcon-root': { fontSize: 20 },\n },\n toggleActive: {\n background: (theme: Theme) => theme.palette.primary.relatedLight,\n },\n} satisfies Record<string, SxProps<Theme>>\n","import { useCallback, useMemo, type ComponentType } from 'react'\nimport { IconButton, useTheme, type SvgIconProps } from '@mui/material'\nimport { ZoomIn as ZoomInIcon } from '@mui/icons-material'\nimport { Tooltip } from '../../../components'\nimport {\n getEchartInstance,\n useSingleTransform,\n useWidgetId,\n} from '../../stores'\nimport { createAddZoom, type ZoomAxis } from './transforms'\nimport { DEFAULT_ZOOM_TOGGLE_LABELS, type ZoomToggleLabels } from './labels'\nimport { styles } from './style'\n\nconst ZOOM_DESCRIPTOR = {\n id: 'zoom-toggle',\n type: 'config' as const,\n order: 20,\n}\n\nexport interface ZoomToggleProps {\n initialEnabled?: boolean\n labels?: Partial<ZoomToggleLabels>\n icon?: ComponentType<SvgIconProps>\n iconProps?: SvgIconProps\n /**\n * Which axes the zoom controls. Default `['x']` — matches bar /\n * histogram / timeseries (horizontal pan on the category axis). Pass\n * `['x', 'y']` for 2D zoom on a scatterplot-style chart (mouse-wheel\n * scales both axes; horizontal slider at bottom + vertical slider on\n * the right).\n */\n axes?: readonly ZoomAxis[]\n}\n\n/**\n * Toggle a `dataZoom` slider + wheel-zoom on the chart. The transform is\n * theme-bound so the slider gets v1's CARTO chrome (blue-tinted filler,\n * secondary-color preview, white pill handles).\n *\n * `dataZoom` is intentionally NOT in `replaceMergeKeys` — ECharts merges\n * subsequent `setOption` calls so the user's dragged range survives\n * unrelated re-renders (data updates, formatter swaps, RelativeData toggles).\n *\n * On disable the transform is unregistered, but ECharts' merge semantics\n * would otherwise *keep* the live `dataZoom` component visible. To remove\n * the slider immediately we issue a one-shot\n * `setOption({}, { replaceMerge: ['dataZoom'] })` against the live chart\n * instance via {@link getEchartInstance} — this bypasses the pipeline so\n * unrelated transforms aren't disturbed.\n */\nexport function ZoomToggle({\n initialEnabled = false,\n labels,\n icon: Icon = ZoomInIcon,\n iconProps,\n axes,\n}: ZoomToggleProps) {\n const id = useWidgetId()\n const theme = useTheme()\n const _labels = { ...DEFAULT_ZOOM_TOGGLE_LABELS, ...labels }\n // Reduce `axes` to two structural booleans before memoizing so the memo\n // depends on stable primitive values, not an array reference (callers\n // typically pass an inline `axes={['x', 'y']}` literal that changes\n // identity each render). Default — no `axes` prop — is x-axis only,\n // matching `createAddZoom`'s default.\n const includeX = axes ? axes.includes('x') : true\n const includeY = axes ? axes.includes('y') : false\n const transform = useMemo(() => {\n const resolved: ZoomAxis[] = []\n if (includeX) resolved.push('x')\n if (includeY) resolved.push('y')\n return createAddZoom(theme, { axes: resolved })\n }, [theme, includeX, includeY])\n const { enabled, toggle } = useSingleTransform(\n id,\n ZOOM_DESCRIPTOR,\n transform,\n { initialEnabled },\n )\n\n const handleToggle = useCallback(() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }, [enabled, toggle, id])\n\n const tooltip = enabled ? _labels.on : _labels.off\n\n return (\n <Tooltip title={tooltip}>\n <IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>\n </Tooltip>\n )\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"zoom-toggle-MmLjMTEw.js","names":["cfg","Icon = ZoomInIcon","{\n initialEnabled = false,\n labels,\n icon: Icon = ZoomInIcon,\n iconProps,\n axes,\n}: ZoomToggleProps","initialEnabled = false","{ ...DEFAULT_ZOOM_TOGGLE_LABELS, ...labels }","axes ? axes.includes('x') : true","axes ? axes.includes('y') : false","createAddZoom(theme, { axes: resolved })","useMemo(() => {\n const resolved: ZoomAxis[] = []\n if (includeX) resolved.push('x')\n if (includeY) resolved.push('y')\n return createAddZoom(theme, { axes: resolved })\n }, [theme, includeX, includeY])","{ initialEnabled }","() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }","useCallback(() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }, [enabled, toggle, id])","{ ...styles.toggle, ...(enabled && styles.toggleActive) }","enabled && styles.toggleActive","<Icon fontSize='small' {...iconProps} />","<IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>","enabled ? 'active' : undefined","<Tooltip title={tooltip}>\n <IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>\n </Tooltip>","_c","ZoomInIcon","Tooltip","Tooltip$1","useWidgetId","getEchartInstance","useSingleTransform","SLIDER_HANDLE_ICON","SLIDER_HEIGHT","SLIDER_BOTTOM","createAddZoom","axes","options","includeX","includeY","baseSliderProps","getZoomSliderStyles","theme","option","cfg","dataZoom","addZoom","secondary","ZOOM_LAYOUT","DEFAULT_ZOOM_TOGGLE_LABELS","styles","ZOOM_DESCRIPTOR","ZoomToggle","$","c","t1","t2","t0","initialEnabled","Icon","ZoomIn","id","useTheme","t3","labels","_labels","t4","t5","t6","resolved","transform","t7","t8","enabled","toggle","handleToggle","tooltip","t9","t10","t11","t12","iconProps","t13","IconButton","t14"],"sources":["../src/widgets/actions/zoom-toggle/transforms.ts","../src/widgets/actions/zoom-toggle/labels.ts","../src/widgets/actions/zoom-toggle/style.ts","../src/widgets/actions/zoom-toggle/zoom-toggle.tsx"],"sourcesContent":["import type { Theme } from '@mui/material'\n\n/**\n * v1's slider handle — 9×36 SVG with a white pill body, secondary-colored\n * stroke, and three grip lines. Inlined as a data URL so the chart can\n * render it without a network fetch.\n */\nconst SLIDER_HANDLE_ICON =\n 'image://data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOSIgaGVpZ2h0PSIzNiIgdmlld0JveD0iMCAwIDkgMzYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHg9IjAuNSIgeT0iLTAuNSIgd2lkdGg9IjgiIGhlaWdodD0iMTgiIHJ4PSI0IiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAwIDI3KSIgZmlsbD0id2hpdGUiIHN0cm9rZT0iIzM1OEJFNyIvPgo8cmVjdCB3aWR0aD0iMyIgaGVpZ2h0PSIyIiByeD0iMSIgdHJhbnNmb3JtPSJtYXRyaXgoMSAwIDAgLTEgMyAyMykiIGZpbGw9IiMzNThCRTciLz4KPHJlY3Qgd2lkdGg9IjMiIGhlaWdodD0iMiIgcng9IjEiIHRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDMgMTkpIiBmaWxsPSIjMzU4QkU3Ii8+CjxyZWN0IHdpZHRoPSIzIiBoZWlnaHQ9IjIiIHJ4PSIxIiB0cmFuc2Zvcm09Im1hdHJpeCgxIDAgMCAtMSAzIDE1KSIgZmlsbD0iIzM1OEJFNyIvPgo8L3N2Zz4='\n\nconst SLIDER_HEIGHT = 32\nconst SLIDER_BOTTOM = 0\n\n/** Which chart axis (or axes) zoom applies to. */\nexport type ZoomAxis = 'x' | 'y'\n\nexport interface CreateAddZoomOptions {\n /**\n * Axes to enable zoom on. Default `['x']` (matches bar / histogram /\n * timeseries — horizontal pan/zoom on the category axis). Pass\n * `['x', 'y']` for a scatterplot-style 2D zoom (mouse-wheel scales both\n * axes; a horizontal slider sits at the bottom for x-range and a\n * vertical slider sits on the right for y-range).\n */\n axes?: readonly ZoomAxis[]\n}\n\n/**\n * Theme-bound zoom config transform: adds one inside `dataZoom`\n * (mouse-wheel + drag) per axis plus a styled slider per axis with the\n * v1 chrome (blue-tinted filler, secondary-color data preview, white\n * pill handles, CARTO typography). The x-slider is horizontal at the\n * bottom; the y-slider is vertical on the right.\n *\n * Returned function has stable identity for a given theme + axes\n * combination; pair with `useMemo` at the call site.\n *\n * **No `start` / `end`** are emitted: ECharts defaults to `0–100` on the\n * first `setOption` and then keeps the runtime slider state across\n * subsequent merges (no `replaceMerge` for `dataZoom`). Re-running the\n * transform on data/formatter/RelativeData updates therefore preserves the\n * user's dragged range instead of snapping it back.\n */\nexport function createAddZoom(\n theme: Theme,\n options: CreateAddZoomOptions = {},\n): (option: unknown) => unknown {\n const axes = options.axes ?? ['x']\n const includeX = axes.includes('x')\n const includeY = axes.includes('y')\n const sliderStyles = getZoomSliderStyles(theme)\n const baseSliderProps = {\n throttle: 0,\n showDetail: false,\n brushSelect: false,\n moveHandleSize: 8,\n handleSize: '100%',\n ...sliderStyles,\n }\n return (option: unknown): unknown => {\n if (option == null || typeof option !== 'object') return option\n const cfg = option as Record<string, unknown>\n const dataZoom: object[] = []\n if (includeX) {\n dataZoom.push({ type: 'inside', xAxisIndex: [0], throttle: 0 })\n }\n if (includeY) {\n dataZoom.push({ type: 'inside', yAxisIndex: [0], throttle: 0 })\n }\n if (includeX) {\n dataZoom.push({\n type: 'slider',\n xAxisIndex: [0],\n height: SLIDER_HEIGHT,\n bottom: SLIDER_BOTTOM,\n handleIcon: SLIDER_HANDLE_ICON,\n ...baseSliderProps,\n })\n }\n if (includeY) {\n // Vertical slider — `width` is its thickness, `right` pins it to\n // the right edge. We omit `handleIcon` so ECharts renders its\n // default vertical handle (the inline SVG is shaped for a\n // horizontal slider and looks wrong when rotated).\n dataZoom.push({\n type: 'slider',\n yAxisIndex: [0],\n width: SLIDER_HEIGHT,\n right: SLIDER_BOTTOM,\n ...baseSliderProps,\n })\n }\n return { ...cfg, dataZoom }\n }\n}\n\n/**\n * Back-compat untyped transform — produces the bare `dataZoom` config from\n * the original v2 implementation. Use {@link createAddZoom} when a theme is\n * available so the slider matches the v1 styling.\n *\n * `start` / `end` are intentionally omitted so ECharts preserves the user's\n * runtime range across subsequent `setOption` merges (see {@link createAddZoom}).\n */\nexport const addZoom = (option: unknown): unknown => {\n if (option == null || typeof option !== 'object') return option\n const cfg = option as Record<string, unknown>\n return {\n ...cfg,\n dataZoom: [\n { type: 'inside' },\n {\n type: 'slider',\n height: SLIDER_HEIGHT,\n bottom: SLIDER_BOTTOM,\n },\n ],\n }\n}\n\n/** Style block matching v1's `getEChartZoomSliderStyles`. */\nfunction getZoomSliderStyles(theme: Theme) {\n const secondary = theme.palette.secondary?.main ?? '#358BE7'\n return {\n fillerColor: 'rgba(53, 139, 231, 0.25)',\n borderColor: 'rgba(53, 139, 231, 0.3)',\n borderWidth: 0.5,\n backgroundColor: 'transparent',\n borderRadius: 4,\n dataBackground: {\n lineStyle: { opacity: 0 },\n areaStyle: { opacity: 1, color: secondary },\n },\n selectedDataBackground: {\n lineStyle: { opacity: 0 },\n areaStyle: { opacity: 1, color: secondary },\n },\n handleStyle: {\n color: theme.palette.common?.white ?? '#fff',\n borderColor: 'rgba(3, 111, 226, 0.08)',\n borderWidth: 1,\n shadowBlur: 3,\n shadowColor: 'rgba(0, 0, 0, 0.1)',\n shadowOffsetX: 0,\n shadowOffsetY: 1,\n },\n textStyle: {\n color: theme.palette.black?.[60] ?? theme.palette.text?.primary,\n fontSize: parseInt(\n (theme.typography.overlineDelicate?.fontSize as string | undefined) ??\n '11',\n ),\n fontFamily:\n theme.typography.overlineDelicate?.fontFamily ??\n theme.typography.caption?.fontFamily,\n },\n }\n}\n\n/**\n * Visual layout constants used by the data-fusion mergers (bar, histogram,\n * timeseries) when laying out the slider against the legend / grid.\n */\nexport const ZOOM_LAYOUT = {\n /** Slider height in px (matches `createAddZoom` output). */\n sliderHeight: SLIDER_HEIGHT,\n /** Vertical gap between chart grid and the slider. */\n sliderGap: 8,\n /** Slider `bottom` when a legend is shown — sits above the legend row. */\n sliderBottomWithLegend: 28,\n} as const\n","export interface ZoomToggleLabels {\n on: string\n off: string\n}\n\nexport const DEFAULT_ZOOM_TOGGLE_LABELS: ZoomToggleLabels = {\n on: 'Disable zoom',\n off: 'Enable zoom',\n}\n","import type { SxProps, Theme } from '@mui/material'\n\nexport const styles = {\n toggle: {\n p: 0.5,\n '& .MuiSvgIcon-root': { fontSize: 20 },\n },\n toggleActive: {\n background: (theme: Theme) => theme.palette.primary.relatedLight,\n },\n} satisfies Record<string, SxProps<Theme>>\n","import { useCallback, useMemo, type ComponentType } from 'react'\nimport { IconButton, useTheme, type SvgIconProps } from '@mui/material'\nimport { ZoomIn as ZoomInIcon } from '@mui/icons-material'\nimport { Tooltip } from '../../../components'\nimport {\n getEchartInstance,\n useSingleTransform,\n useWidgetId,\n} from '../../stores'\nimport { createAddZoom, type ZoomAxis } from './transforms'\nimport { DEFAULT_ZOOM_TOGGLE_LABELS, type ZoomToggleLabels } from './labels'\nimport { styles } from './style'\n\nconst ZOOM_DESCRIPTOR = {\n id: 'zoom-toggle',\n type: 'config' as const,\n order: 20,\n}\n\nexport interface ZoomToggleProps {\n initialEnabled?: boolean\n labels?: Partial<ZoomToggleLabels>\n icon?: ComponentType<SvgIconProps>\n iconProps?: SvgIconProps\n /**\n * Which axes the zoom controls. Default `['x']` — matches bar /\n * histogram / timeseries (horizontal pan on the category axis). Pass\n * `['x', 'y']` for 2D zoom on a scatterplot-style chart (mouse-wheel\n * scales both axes; horizontal slider at bottom + vertical slider on\n * the right).\n */\n axes?: readonly ZoomAxis[]\n}\n\n/**\n * Toggle a `dataZoom` slider + wheel-zoom on the chart. The transform is\n * theme-bound so the slider gets v1's CARTO chrome (blue-tinted filler,\n * secondary-color preview, white pill handles).\n *\n * `dataZoom` is intentionally NOT in `replaceMergeKeys` — ECharts merges\n * subsequent `setOption` calls so the user's dragged range survives\n * unrelated re-renders (data updates, formatter swaps, RelativeData toggles).\n *\n * On disable the transform is unregistered, but ECharts' merge semantics\n * would otherwise *keep* the live `dataZoom` component visible. To remove\n * the slider immediately we issue a one-shot\n * `setOption({}, { replaceMerge: ['dataZoom'] })` against the live chart\n * instance via {@link getEchartInstance} — this bypasses the pipeline so\n * unrelated transforms aren't disturbed.\n */\nexport function ZoomToggle({\n initialEnabled = false,\n labels,\n icon: Icon = ZoomInIcon,\n iconProps,\n axes,\n}: ZoomToggleProps) {\n const id = useWidgetId()\n const theme = useTheme()\n const _labels = { ...DEFAULT_ZOOM_TOGGLE_LABELS, ...labels }\n // Reduce `axes` to two structural booleans before memoizing so the memo\n // depends on stable primitive values, not an array reference (callers\n // typically pass an inline `axes={['x', 'y']}` literal that changes\n // identity each render). Default — no `axes` prop — is x-axis only,\n // matching `createAddZoom`'s default.\n const includeX = axes ? axes.includes('x') : true\n const includeY = axes ? axes.includes('y') : false\n const transform = useMemo(() => {\n const resolved: ZoomAxis[] = []\n if (includeX) resolved.push('x')\n if (includeY) resolved.push('y')\n return createAddZoom(theme, { axes: resolved })\n }, [theme, includeX, includeY])\n const { enabled, toggle } = useSingleTransform(\n id,\n ZOOM_DESCRIPTOR,\n transform,\n { initialEnabled },\n )\n\n const handleToggle = useCallback(() => {\n if (enabled) {\n // About to disable — clear the dataZoom component on the live\n // instance so the slider disappears immediately.\n getEchartInstance(id)?.setOption({}, { replaceMerge: ['dataZoom'] })\n }\n toggle()\n }, [enabled, toggle, id])\n\n const tooltip = enabled ? _labels.on : _labels.off\n\n return (\n <Tooltip title={tooltip}>\n <IconButton\n size='small'\n aria-label={tooltip}\n aria-pressed={enabled}\n onClick={handleToggle}\n className={enabled ? 'active' : undefined}\n sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}\n >\n <Icon fontSize='small' {...iconProps} />\n </IconButton>\n </Tooltip>\n )\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carto/ps-react-ui",
3
- "version": "6.1.1",
3
+ "version": "6.3.0",
4
4
  "description": "CARTO's Professional Service React Material library",
5
5
  "type": "module",
6
6
  "devDependencies": {
@@ -12,7 +12,7 @@
12
12
  "@mui/icons-material": "9.4.0",
13
13
  "@mui/material": "9.4.0",
14
14
  "@mui/system": "9.4.0",
15
- "@mui/x-date-pickers": "9.13.0",
15
+ "@mui/x-date-pickers": "9.14.0",
16
16
  "@types/jscodeshift": "^17.3.0",
17
17
  "echarts": "6.1.0",
18
18
  "html2canvas": "1.4.1",
@@ -130,26 +130,90 @@ describe('<FullScreen.Slot> — the dialog runs on its own store', () => {
130
130
  // Legend / dataZoom / brush live in the chart instance, not the store.
131
131
  // The Slot moves them through the instance registry, so Widget.Echart
132
132
  // doesn't need to know about fullscreen.
133
+ const fakeChart = (option: object, brushAreas?: unknown[]) =>
134
+ ({
135
+ getOption: vi.fn(() => option),
136
+ setOption: vi.fn(),
137
+ dispatchAction: vi.fn(),
138
+ getModel: () => ({
139
+ getComponent: (type: string) =>
140
+ type === 'brush' ? { areas: brushAreas } : undefined,
141
+ }),
142
+ isDisposed: () => false,
143
+ }) as unknown as ECharts & {
144
+ setOption: ReturnType<typeof vi.fn>
145
+ dispatchAction: ReturnType<typeof vi.fn>
146
+ }
147
+
133
148
  it('starts the dialog chart from the card chart and hands it back on close', async () => {
134
- const fakeChart = (option: object) =>
135
- ({
136
- getOption: vi.fn(() => option),
137
- setOption: vi.fn(),
138
- dispatchAction: vi.fn(),
139
- isDisposed: () => false,
140
- }) as unknown as ECharts & {
141
- setOption: ReturnType<typeof vi.fn>
142
- }
143
- const card = fakeChart({ from: 'card' })
144
- const dialog = fakeChart({ from: 'dialog' })
149
+ const cardOption = {
150
+ series: [{ type: 'bar' }],
151
+ xAxis: [{ type: 'category' }],
152
+ legend: [{ selected: { a: true, b: false } }],
153
+ dataZoom: [{ start: 10, end: 60 }],
154
+ }
155
+ const card = fakeChart(cardOption)
156
+ const dialog = fakeChart({
157
+ legend: [{ selected: { a: false, b: true } }],
158
+ dataZoom: [{ start: 20, end: 80 }],
159
+ })
160
+ render(<Widget />)
161
+ act(() => setEchartInstance('w', card))
162
+
163
+ // The empty dialog chart takes the whole option, so the view state has
164
+ // the card's axes and series to land on.
165
+ fireEvent.click(screen.getByLabelText('OpenT'))
166
+ act(() => setEchartInstance(dialogId, dialog))
167
+ expect(dialog.setOption).toHaveBeenCalledWith(cardOption)
168
+
169
+ await close()
170
+ expect(card.setOption).toHaveBeenCalledWith({
171
+ legend: [{ selected: { a: false, b: true } }],
172
+ dataZoom: [{ start: 20, end: 80 }],
173
+ })
174
+ })
175
+
176
+ // The dialog often renders with its own styling (bigger axis labels,
177
+ // value labels on the series). Copying the whole option back would leave
178
+ // that styling on the card after closing.
179
+ it('hands back only the view state, not the dialog styling', async () => {
180
+ const card = fakeChart({ series: [{ type: 'bar' }] })
181
+ const dialog = fakeChart({
182
+ series: [{ type: 'bar', label: { show: true, rotate: 90 } }],
183
+ xAxis: [{ axisLabel: { fontSize: 13 } }],
184
+ dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
185
+ })
186
+ render(<Widget />)
187
+ act(() => setEchartInstance('w', card))
188
+ fireEvent.click(screen.getByLabelText('OpenT'))
189
+ act(() => setEchartInstance(dialogId, dialog))
190
+
191
+ await close()
192
+ expect(card.setOption).toHaveBeenCalledWith({
193
+ dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
194
+ })
195
+ })
196
+
197
+ // Brush areas aren't in getOption(); they travel through the brush action.
198
+ it('starts the dialog chart with the card brush areas and hands them back on close', async () => {
199
+ const cardAreas = [{ brushType: 'lineX', coordRange: [1, 3] }]
200
+ const dialogAreas = [{ brushType: 'lineX', coordRange: [4, 7] }]
201
+ const card = fakeChart({}, cardAreas)
202
+ const dialog = fakeChart({}, dialogAreas)
145
203
  render(<Widget />)
146
204
  act(() => setEchartInstance('w', card))
147
205
 
148
206
  fireEvent.click(screen.getByLabelText('OpenT'))
149
207
  act(() => setEchartInstance(dialogId, dialog))
150
- expect(dialog.setOption).toHaveBeenCalledWith({ from: 'card' })
208
+ expect(dialog.dispatchAction).toHaveBeenCalledWith({
209
+ type: 'brush',
210
+ areas: cardAreas,
211
+ })
151
212
 
152
213
  await close()
153
- expect(card.setOption).toHaveBeenCalledWith({ from: 'dialog' })
214
+ expect(card.dispatchAction).toHaveBeenCalledWith({
215
+ type: 'brush',
216
+ areas: dialogAreas,
217
+ })
154
218
  })
155
219
  })
@@ -312,15 +312,17 @@ function DialogStore({
312
312
  const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
313
313
  // Published from inside the chart's init, before its first
314
314
  // setOption, so the dialog's own option merges on top of the copy.
315
+ // The empty chart takes the card's whole option: the legend, dataZoom
316
+ // and brush areas need the card's components (axes, brush) to land on.
315
317
  const card = liveChart(sourceId)
316
- if (chart && card) copyChartState(card, chart)
318
+ if (chart && card) copyChartState(card, chart, card.getOption())
317
319
  dialogChart = chart
318
320
  })
319
321
  return () => {
320
322
  unsubscribe()
321
323
  const card = liveChart(sourceId)
322
324
  if (card && dialogChart && !dialogChart.isDisposed()) {
323
- copyChartState(dialogChart, card)
325
+ copyChartState(dialogChart, card, viewState(dialogChart))
324
326
  }
325
327
  }
326
328
  }, [dialogId, sourceId])
@@ -340,10 +342,15 @@ function liveChart(id: string): ECharts | null {
340
342
  /**
341
343
  * Put on `chart` what `peer` is showing: the legend selection, the dataZoom
342
344
  * window and any drawn brush areas, which live in the ECharts instance
343
- * rather than the widget store.
345
+ * rather than the widget store. `option` is what of `peer`'s option to merge
346
+ * into `chart`.
344
347
  */
345
- function copyChartState(peer: ECharts, chart: ECharts): void {
346
- chart.setOption(peer.getOption())
348
+ function copyChartState(
349
+ peer: ECharts,
350
+ chart: ECharts,
351
+ option: Parameters<ECharts['setOption']>[0],
352
+ ): void {
353
+ chart.setOption(option)
347
354
  // The pointer was over `peer`, not `chart`: drop the tooltip and axis
348
355
  // pointer that came along, or they'd stay drawn until the next hover.
349
356
  chart.dispatchAction({ type: 'hideTip' })
@@ -363,6 +370,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
363
370
  if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
364
371
  }
365
372
 
373
+ interface ViewStateOption {
374
+ // setOption takes an indexed option.
375
+ [key: string]: unknown
376
+ legend?: { selected?: Record<string, boolean> }[]
377
+ dataZoom?: Record<string, unknown>[]
378
+ }
379
+
380
+ const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
381
+
382
+ /**
383
+ * The part of `peer`'s option that is user view state. Only this goes back
384
+ * to the card on close: the dialog's full option also carries its own
385
+ * styling (value labels, bigger axis text), and merging it into the card
386
+ * would leave that styling behind once the dialog closes.
387
+ */
388
+ function viewState(peer: ECharts): ViewStateOption {
389
+ const { legend, dataZoom } = peer.getOption() as ViewStateOption
390
+ const state: ViewStateOption = {}
391
+ if (legend?.some((l) => l.selected)) {
392
+ state.legend = legend.map((l) =>
393
+ l.selected ? { selected: l.selected } : {},
394
+ )
395
+ }
396
+ if (dataZoom?.length) {
397
+ state.dataZoom = dataZoom.map((zoom) =>
398
+ Object.fromEntries(
399
+ ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
400
+ k,
401
+ zoom[k],
402
+ ]),
403
+ ),
404
+ )
405
+ }
406
+ return state
407
+ }
408
+
366
409
  /**
367
410
  * Merge a caller's `paperSx` over the default paper.
368
411
  *
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import * as echarts from 'echarts'
2
3
  import { createTheme, type Theme } from '@mui/material'
3
4
  import { barOptions, createBarOptionFactory } from './options'
4
5
 
@@ -359,3 +360,108 @@ describe('createBarOptionFactory', () => {
359
360
  expect(html).toContain('[ITEM:A]')
360
361
  })
361
362
  })
363
+
364
+ describe('createBarOptionFactory — per-item colour', () => {
365
+ interface Out {
366
+ series: { itemStyle: { color: (p: unknown) => string } }[]
367
+ }
368
+ const data = [
369
+ [
370
+ { name: 'A', value: 1, color: '#00ff00' },
371
+ { name: 'B', value: -2 },
372
+ ],
373
+ ]
374
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
375
+
376
+ it('datum colour wins over the series colour', () => {
377
+ const merge = createBarOptionFactory({
378
+ theme,
379
+ series: [{ name: 'S', color: '#0000ff' }],
380
+ })
381
+ const out = merge({}, data) as Out
382
+ const colorFn = out.series[0]!.itemStyle.color
383
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
384
+ '#00ff00',
385
+ )
386
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
387
+ '#0000ff',
388
+ )
389
+ })
390
+
391
+ it('uses a string template itemStyle.color from optionsOverride', () => {
392
+ const factory = createBarOptionFactory({
393
+ theme,
394
+ series: [{ name: 'S', color: '#0000ff' }],
395
+ optionsOverride: {
396
+ series: [{ type: 'bar', itemStyle: { color: '#123456' } }],
397
+ },
398
+ })
399
+ const out = factory(factory(undefined, undefined), data) as Out
400
+ const colorFn = out.series[0]!.itemStyle.color
401
+ expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
402
+ '#00ff00',
403
+ )
404
+ expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
405
+ '#123456',
406
+ )
407
+ })
408
+
409
+ it('calls a function template itemStyle.color with the ECharts params', () => {
410
+ const merge = createBarOptionFactory({ theme })
411
+ const out = merge(
412
+ {
413
+ series: [
414
+ {
415
+ type: 'bar',
416
+ itemStyle: {
417
+ color: (p: { dataIndex: number }) =>
418
+ p.dataIndex === 1 ? '#ff0000' : '#00ff00',
419
+ },
420
+ },
421
+ ],
422
+ },
423
+ [
424
+ [
425
+ { name: 'A', value: 1 },
426
+ { name: 'B', value: -2 },
427
+ ],
428
+ ],
429
+ ) as Out
430
+ const colorFn = out.series[0]!.itemStyle.color
431
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe('#00ff00')
432
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe('#ff0000')
433
+ })
434
+
435
+ it('resolves theme palette paths on the datum colour', () => {
436
+ const merge = createBarOptionFactory({ theme })
437
+ const out = merge({}, [[{ name: 'A', value: 1, color: 'primary.main' }]])
438
+ const colorFn = (out as Out).series[0]!.itemStyle.color
439
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
440
+ theme.palette.primary.main,
441
+ )
442
+ })
443
+
444
+ it('dims both datum and template colours outside the selection', () => {
445
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
446
+ const out = merge(
447
+ { series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
448
+ data,
449
+ ) as Out
450
+ const colorFn = out.series[0]!.itemStyle.color
451
+ expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
452
+ dim('#00ff00'),
453
+ )
454
+ expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe(
455
+ dim('#123456'),
456
+ )
457
+ })
458
+
459
+ it('keeps the series / palette colour when no item colour is given', () => {
460
+ const merge = createBarOptionFactory({ theme, selection: ['Z'] })
461
+ const out = merge({}, [[{ name: 'A', value: 1 }]]) as Out
462
+ const colorFn = out.series[0]!.itemStyle.color
463
+ expect(colorFn({ dataIndex: 0, color: '#abcdef', name: 'A' })).toBe(
464
+ dim('#abcdef'),
465
+ )
466
+ })
467
+ })
@@ -12,6 +12,7 @@ import {
12
12
  import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
13
13
  import type { OptionFactory } from '../echart'
14
14
  import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import { createItemColorResolver } from '../utils/item-color'
15
16
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
16
17
  import type {
17
18
  BarEChartsOption,
@@ -234,10 +235,12 @@ export function createBarOptionFactory(
234
235
  : baseBottom
235
236
 
236
237
  // When a selection is active, dim non-selected bars by routing the
237
- // resolved palette color through `modifyAlpha`. Per-row `itemStyle` on
238
+ // item's base colour through `modifyAlpha`. Per-row `itemStyle` on
238
239
  // dataset object-rows is silently ignored when `series.encode` is in
239
240
  // play — the callback approach is the standard ECharts pattern for
240
- // per-data styling derived from a dataset.
241
+ // per-data styling derived from a dataset. The base colour is the
242
+ // datum's `color`, else the template's `itemStyle.color`, else the
243
+ // series colour / palette (`params.color`).
241
244
  //
242
245
  // We *always* emit `itemStyle.color` (a passthrough when nothing is
243
246
  // selected), not conditionally — dropping the key between renders
@@ -245,22 +248,30 @@ export function createBarOptionFactory(
245
248
  // and bars would stay dimmed forever after an external clear. Always
246
249
  // emitting lets normal merge swap the callback in place, no
247
250
  // `replaceMerge` and no entry-animation flash on selection on/off.
248
- const dimItemStyle = {
249
- color: (params: CallbackDataParams) => {
250
- const base = params.color as string
251
- if (!selectionSet) return base
252
- const datum = params.value as { name?: string | number } | undefined
253
- const name = datum?.name ?? params.name
254
- return name != null && selectionSet.has(name)
255
- ? base
256
- : echarts.color.modifyAlpha(base, 0.15)
257
- },
251
+ const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
252
+ const baseColor = createItemColorResolver({
253
+ theme,
254
+ datums,
255
+ template,
256
+ fallback: (params) => params.color as string,
257
+ })
258
+ return {
259
+ color: (params: CallbackDataParams) => {
260
+ const base = baseColor(params)
261
+ if (!selectionSet) return base
262
+ const datum = params.value as { name?: string | number } | undefined
263
+ const name = datum?.name ?? params.name
264
+ return name != null && selectionSet.has(name)
265
+ ? base
266
+ : echarts.color.modifyAlpha(base, 0.15)
267
+ },
268
+ }
258
269
  }
259
270
 
260
271
  return {
261
272
  ...option,
262
273
  dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
263
- series: seriesArr.map((_, i) => {
274
+ series: seriesArr.map((s, i) => {
264
275
  const template =
265
276
  (seriesTemplates[i] as object | undefined) ??
266
277
  (broadcastTemplate as object)
@@ -277,7 +288,7 @@ export function createBarOptionFactory(
277
288
  encode: { x: 'name', y: 'value' },
278
289
  barMaxWidth: 100,
279
290
  emphasis: { focus: 'series' },
280
- itemStyle: dimItemStyle,
291
+ itemStyle: makeDimItemStyle(s, template),
281
292
  ...(overrideColor ? { color: overrideColor } : {}),
282
293
  }
283
294
  }),
@@ -6,6 +6,12 @@ import type { WidgetSeries } from '../types'
6
6
  export interface BarDatum {
7
7
  name: string | number
8
8
  value: number
9
+ /**
10
+ * Optional colour for this item (CSS colour or theme palette path such as
11
+ * `'primary.main'`). Wins over the template's `itemStyle.color` and the
12
+ * series colour; selection dimming still applies on top.
13
+ */
14
+ color?: string
9
15
  }
10
16
 
11
17
  /** v2 bar widget data shape: array of series, each a flat list of `{name, value}`. */
@@ -925,4 +925,143 @@ describe('<CategoryUI>', () => {
925
925
  fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
926
926
  })
927
927
  })
928
+
929
+ // ── Signed values ──────────────────────────────────────────────────
930
+ describe('negative values', () => {
931
+ const MIXED = [
932
+ [
933
+ { name: 'Income', value: 0.8 },
934
+ { name: 'Transit', value: -0.4 },
935
+ { name: 'Size', value: 0 },
936
+ ],
937
+ ]
938
+ const fills = (c: HTMLElement) =>
939
+ Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
940
+ const ticks = (c: HTMLElement) =>
941
+ Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
942
+
943
+ it('centres the zero with a symmetric domain and keeps the series color', () => {
944
+ const { container } = render(<CategoryUI data={MIXED} />)
945
+ const [income, transit, size] = fills(container)
946
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
947
+ expect([transit!.style.left, transit!.style.width]).toEqual([
948
+ '25%',
949
+ '25%',
950
+ ])
951
+ expect(size!.style.width).toBe('0%')
952
+ expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
953
+ })
954
+
955
+ it('marks the zero with one tick per bar, none on "Others"', () => {
956
+ const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
957
+ expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
958
+ expect(screen.getByText('Others')).toBeTruthy()
959
+ })
960
+
961
+ it('draws one zero line per multi-series row, spanning every bar', () => {
962
+ const { container } = render(
963
+ <CategoryUI
964
+ data={[
965
+ [
966
+ { name: 'A', value: 1 },
967
+ { name: 'B', value: 2 },
968
+ ],
969
+ [
970
+ { name: 'A', value: -1 },
971
+ { name: 'B', value: 0.5 },
972
+ ],
973
+ [
974
+ { name: 'A', value: 0 },
975
+ { name: 'B', value: -2 },
976
+ ],
977
+ ]}
978
+ />,
979
+ )
980
+ const t = ticks(container)
981
+ expect(t).toHaveLength(2)
982
+ expect(t[0]!.style.left).toBe('50%')
983
+ expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
984
+ })
985
+
986
+ it.each([
987
+ ['positive-only', [1, 2]],
988
+ ['negative-only', [-1, -2]],
989
+ ['all-zero', [0, 0]],
990
+ ])('draws no zero tick for %s data', (_, values) => {
991
+ const { container } = render(
992
+ <CategoryUI
993
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
994
+ />,
995
+ )
996
+ expect(ticks(container)).toHaveLength(0)
997
+ })
998
+
999
+ it('hangs negative-only bars from the right edge', () => {
1000
+ const { container } = render(
1001
+ <CategoryUI
1002
+ data={[
1003
+ [
1004
+ { name: 'A', value: -2 },
1005
+ { name: 'B', value: -1 },
1006
+ ],
1007
+ ]}
1008
+ />,
1009
+ )
1010
+ const [a, b] = fills(container)
1011
+ expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
1012
+ expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
1013
+ })
1014
+
1015
+ it('treats maxOverride as a magnitude for signed data', () => {
1016
+ const { container } = render(
1017
+ <CategoryUI data={MIXED} maxOverride={1.6} />,
1018
+ )
1019
+ const [income, transit] = fills(container)
1020
+ // domain [-1.6, 1.6]
1021
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
1022
+ expect(transit!.style.width).toBe('12.5%')
1023
+ })
1024
+
1025
+ it('minOverride pins the zero even when the data has no negatives', () => {
1026
+ const { container } = render(
1027
+ <CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
1028
+ )
1029
+ expect(fills(container)[0]!.style.left).toBe('50%')
1030
+ expect(ticks(container)).toHaveLength(1)
1031
+ })
1032
+
1033
+ it('prefixes U+2212 itself and hands the formatter |v|', () => {
1034
+ const formatter = vi.fn((n: number) => `$${n}`)
1035
+ render(
1036
+ <CategoryUI
1037
+ data={[
1038
+ [
1039
+ { name: 'A', value: -0.5 },
1040
+ { name: 'B', value: -0 },
1041
+ { name: 'C', value: 0 },
1042
+ ],
1043
+ ]}
1044
+ formatter={formatter}
1045
+ />,
1046
+ )
1047
+ expect(screen.getByText('\u2212$0.5')).toBeTruthy()
1048
+ expect(screen.getAllByText('$0')).toHaveLength(2)
1049
+ formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
1050
+ })
1051
+
1052
+ it('stacked mode diverges around the shared zero and shows the signed total', () => {
1053
+ const { container } = render(
1054
+ <CategoryUI
1055
+ stacked
1056
+ data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
1057
+ />,
1058
+ )
1059
+ const [pos, neg] = fills(container)
1060
+ // zero at 50%, longer side (3) fills its half
1061
+ expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
1062
+ expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
1063
+ expect(screen.getByText('2')).toBeTruthy()
1064
+ expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
1065
+ })
1066
+ })
928
1067
  })