@carto/ps-react-ui 5.3.0 → 5.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 (92) hide show
  1. package/dist/{actions-Dhlz_adK.js → actions-CwjqEm8Y.js} +56 -46
  2. package/dist/actions-CwjqEm8Y.js.map +1 -0
  3. package/dist/category-C6myUFUJ.js +936 -0
  4. package/dist/category-C6myUFUJ.js.map +1 -0
  5. package/dist/item-color-ZYxH3NM5.js +20 -0
  6. package/dist/item-color-ZYxH3NM5.js.map +1 -0
  7. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  8. package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
  9. package/dist/types/widgets-v2/category/category.d.ts +7 -1
  10. package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
  11. package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
  12. package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
  13. package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
  14. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
  15. package/dist/types/widgets-v2/category/style.d.ts +33 -6
  16. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  17. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  18. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  19. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  20. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  21. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  22. package/dist/types/widgets-v2/index.d.ts +1 -1
  23. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  24. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  25. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  26. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  27. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  28. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  29. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  30. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  31. package/dist/widgets-v2/actions.js +1 -1
  32. package/dist/widgets-v2/bar.js +66 -57
  33. package/dist/widgets-v2/bar.js.map +1 -1
  34. package/dist/widgets-v2/category.js +1 -1
  35. package/dist/widgets-v2/histogram.js +103 -71
  36. package/dist/widgets-v2/histogram.js.map +1 -1
  37. package/dist/widgets-v2/pie.js +98 -78
  38. package/dist/widgets-v2/pie.js.map +1 -1
  39. package/dist/widgets-v2/scatterplot.js +119 -79
  40. package/dist/widgets-v2/scatterplot.js.map +1 -1
  41. package/dist/widgets-v2/timeseries.js +70 -61
  42. package/dist/widgets-v2/timeseries.js.map +1 -1
  43. package/dist/widgets-v2.js +2 -2
  44. package/dist/widgets-v2.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  47. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  48. package/src/widgets-v2/bar/options.test.ts +106 -0
  49. package/src/widgets-v2/bar/options.ts +25 -14
  50. package/src/widgets-v2/bar/types.ts +6 -0
  51. package/src/widgets-v2/category/category-ui.test.tsx +139 -0
  52. package/src/widgets-v2/category/category-ui.tsx +64 -11
  53. package/src/widgets-v2/category/category.test.tsx +67 -0
  54. package/src/widgets-v2/category/category.tsx +32 -15
  55. package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
  56. package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
  57. package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
  58. package/src/widgets-v2/category/components/category-bar.tsx +65 -9
  59. package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
  60. package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
  61. package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
  62. package/src/widgets-v2/category/style.ts +58 -14
  63. package/src/widgets-v2/histogram/bins.ts +22 -0
  64. package/src/widgets-v2/histogram/download.test.ts +10 -0
  65. package/src/widgets-v2/histogram/download.ts +7 -3
  66. package/src/widgets-v2/histogram/index.ts +3 -0
  67. package/src/widgets-v2/histogram/options.test.ts +62 -0
  68. package/src/widgets-v2/histogram/options.ts +38 -17
  69. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  70. package/src/widgets-v2/histogram/transforms.ts +26 -12
  71. package/src/widgets-v2/histogram/types.ts +25 -5
  72. package/src/widgets-v2/index.ts +6 -1
  73. package/src/widgets-v2/pie/options.test.ts +91 -0
  74. package/src/widgets-v2/pie/options.ts +50 -20
  75. package/src/widgets-v2/pie/types.ts +6 -0
  76. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  77. package/src/widgets-v2/scatterplot/download.ts +5 -3
  78. package/src/widgets-v2/scatterplot/index.ts +3 -0
  79. package/src/widgets-v2/scatterplot/options.test.ts +78 -0
  80. package/src/widgets-v2/scatterplot/options.ts +35 -11
  81. package/src/widgets-v2/scatterplot/points.ts +35 -0
  82. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  83. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  84. package/src/widgets-v2/scatterplot/types.ts +25 -2
  85. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  86. package/src/widgets-v2/timeseries/options.ts +31 -14
  87. package/src/widgets-v2/timeseries/types.ts +6 -0
  88. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  89. package/src/widgets-v2/utils/item-color.ts +55 -0
  90. package/dist/actions-Dhlz_adK.js.map +0 -1
  91. package/dist/category-D01ruG-l.js +0 -842
  92. package/dist/category-D01ruG-l.js.map +0 -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
  })
@@ -315,15 +315,17 @@ function DialogStore({
315
315
  const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
316
316
  // Published from inside the chart's init, before its first
317
317
  // setOption, so the dialog's own option merges on top of the copy.
318
+ // The empty chart takes the card's whole option: the legend, dataZoom
319
+ // and brush areas need the card's components (axes, brush) to land on.
318
320
  const card = liveChart(sourceId)
319
- if (chart && card) copyChartState(card, chart)
321
+ if (chart && card) copyChartState(card, chart, card.getOption())
320
322
  dialogChart = chart
321
323
  })
322
324
  return () => {
323
325
  unsubscribe()
324
326
  const card = liveChart(sourceId)
325
327
  if (card && dialogChart && !dialogChart.isDisposed()) {
326
- copyChartState(dialogChart, card)
328
+ copyChartState(dialogChart, card, viewState(dialogChart))
327
329
  }
328
330
  }
329
331
  }, [dialogId, sourceId])
@@ -351,10 +353,15 @@ function liveChart(id: string): ECharts | null {
351
353
  /**
352
354
  * Put on `chart` what `peer` is showing: the legend selection, the dataZoom
353
355
  * window and any drawn brush areas, which live in the ECharts instance
354
- * rather than the widget store.
356
+ * rather than the widget store. `option` is what of `peer`'s option to merge
357
+ * into `chart`.
355
358
  */
356
- function copyChartState(peer: ECharts, chart: ECharts): void {
357
- chart.setOption(peer.getOption())
359
+ function copyChartState(
360
+ peer: ECharts,
361
+ chart: ECharts,
362
+ option: Parameters<ECharts['setOption']>[0],
363
+ ): void {
364
+ chart.setOption(option)
358
365
  // The pointer was over `peer`, not `chart`: drop the tooltip and axis
359
366
  // pointer that came along, or they'd stay drawn until the next hover.
360
367
  chart.dispatchAction({ type: 'hideTip' })
@@ -374,6 +381,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
374
381
  if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
375
382
  }
376
383
 
384
+ interface ViewStateOption {
385
+ // setOption takes an indexed option.
386
+ [key: string]: unknown
387
+ legend?: { selected?: Record<string, boolean> }[]
388
+ dataZoom?: Record<string, unknown>[]
389
+ }
390
+
391
+ const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
392
+
393
+ /**
394
+ * The part of `peer`'s option that is user view state. Only this goes back
395
+ * to the card on close: the dialog's full option also carries its own
396
+ * styling (value labels, bigger axis text), and merging it into the card
397
+ * would leave that styling behind once the dialog closes.
398
+ */
399
+ function viewState(peer: ECharts): ViewStateOption {
400
+ const { legend, dataZoom } = peer.getOption() as ViewStateOption
401
+ const state: ViewStateOption = {}
402
+ if (legend?.some((l) => l.selected)) {
403
+ state.legend = legend.map((l) =>
404
+ l.selected ? { selected: l.selected } : {},
405
+ )
406
+ }
407
+ if (dataZoom?.length) {
408
+ state.dataZoom = dataZoom.map((zoom) =>
409
+ Object.fromEntries(
410
+ ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
411
+ k,
412
+ zoom[k],
413
+ ]),
414
+ ),
415
+ )
416
+ }
417
+ return state
418
+ }
419
+
377
420
  /**
378
421
  * Merge a caller's `paperSx` over the default paper.
379
422
  *
@@ -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}`. */
@@ -906,4 +906,143 @@ describe('<CategoryUI>', () => {
906
906
  fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
907
907
  })
908
908
  })
909
+
910
+ // ── Signed values ──────────────────────────────────────────────────
911
+ describe('negative values', () => {
912
+ const MIXED = [
913
+ [
914
+ { name: 'Income', value: 0.8 },
915
+ { name: 'Transit', value: -0.4 },
916
+ { name: 'Size', value: 0 },
917
+ ],
918
+ ]
919
+ const fills = (c: HTMLElement) =>
920
+ Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
921
+ const ticks = (c: HTMLElement) =>
922
+ Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
923
+
924
+ it('centres the zero with a symmetric domain and keeps the series color', () => {
925
+ const { container } = render(<CategoryUI data={MIXED} />)
926
+ const [income, transit, size] = fills(container)
927
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
928
+ expect([transit!.style.left, transit!.style.width]).toEqual([
929
+ '25%',
930
+ '25%',
931
+ ])
932
+ expect(size!.style.width).toBe('0%')
933
+ expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
934
+ })
935
+
936
+ it('marks the zero with one tick per bar, none on "Others"', () => {
937
+ const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
938
+ expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
939
+ expect(screen.getByText('Others')).toBeTruthy()
940
+ })
941
+
942
+ it('draws one zero line per multi-series row, spanning every bar', () => {
943
+ const { container } = render(
944
+ <CategoryUI
945
+ data={[
946
+ [
947
+ { name: 'A', value: 1 },
948
+ { name: 'B', value: 2 },
949
+ ],
950
+ [
951
+ { name: 'A', value: -1 },
952
+ { name: 'B', value: 0.5 },
953
+ ],
954
+ [
955
+ { name: 'A', value: 0 },
956
+ { name: 'B', value: -2 },
957
+ ],
958
+ ]}
959
+ />,
960
+ )
961
+ const t = ticks(container)
962
+ expect(t).toHaveLength(2)
963
+ expect(t[0]!.style.left).toBe('50%')
964
+ expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
965
+ })
966
+
967
+ it.each([
968
+ ['positive-only', [1, 2]],
969
+ ['negative-only', [-1, -2]],
970
+ ['all-zero', [0, 0]],
971
+ ])('draws no zero tick for %s data', (_, values) => {
972
+ const { container } = render(
973
+ <CategoryUI
974
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
975
+ />,
976
+ )
977
+ expect(ticks(container)).toHaveLength(0)
978
+ })
979
+
980
+ it('hangs negative-only bars from the right edge', () => {
981
+ const { container } = render(
982
+ <CategoryUI
983
+ data={[
984
+ [
985
+ { name: 'A', value: -2 },
986
+ { name: 'B', value: -1 },
987
+ ],
988
+ ]}
989
+ />,
990
+ )
991
+ const [a, b] = fills(container)
992
+ expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
993
+ expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
994
+ })
995
+
996
+ it('treats maxOverride as a magnitude for signed data', () => {
997
+ const { container } = render(
998
+ <CategoryUI data={MIXED} maxOverride={1.6} />,
999
+ )
1000
+ const [income, transit] = fills(container)
1001
+ // domain [-1.6, 1.6]
1002
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
1003
+ expect(transit!.style.width).toBe('12.5%')
1004
+ })
1005
+
1006
+ it('minOverride pins the zero even when the data has no negatives', () => {
1007
+ const { container } = render(
1008
+ <CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
1009
+ )
1010
+ expect(fills(container)[0]!.style.left).toBe('50%')
1011
+ expect(ticks(container)).toHaveLength(1)
1012
+ })
1013
+
1014
+ it('prefixes U+2212 itself and hands the formatter |v|', () => {
1015
+ const formatter = vi.fn((n: number) => `$${n}`)
1016
+ render(
1017
+ <CategoryUI
1018
+ data={[
1019
+ [
1020
+ { name: 'A', value: -0.5 },
1021
+ { name: 'B', value: -0 },
1022
+ { name: 'C', value: 0 },
1023
+ ],
1024
+ ]}
1025
+ formatter={formatter}
1026
+ />,
1027
+ )
1028
+ expect(screen.getByText('\u2212$0.5')).toBeTruthy()
1029
+ expect(screen.getAllByText('$0')).toHaveLength(2)
1030
+ formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
1031
+ })
1032
+
1033
+ it('stacked mode diverges around the shared zero and shows the signed total', () => {
1034
+ const { container } = render(
1035
+ <CategoryUI
1036
+ stacked
1037
+ data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
1038
+ />,
1039
+ )
1040
+ const [pos, neg] = fills(container)
1041
+ // zero at 50%, longer side (3) fills its half
1042
+ expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
1043
+ expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
1044
+ expect(screen.getByText('2')).toBeTruthy()
1045
+ expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
1046
+ })
1047
+ })
909
1048
  })
@@ -78,8 +78,16 @@ export interface CategoryUIProps {
78
78
  * overflow row stays a static summary.
79
79
  */
80
80
  onShowAll?: () => void
81
- /** Manual override for the bar-width denominator. */
81
+ /**
82
+ * Manual override for the top of the bar scale. With signed data it is a
83
+ * magnitude: the scale stays symmetric around the centred zero.
84
+ */
82
85
  maxOverride?: number
86
+ /**
87
+ * Manual override for the bottom of the bar scale, the negative mirror of
88
+ * `maxOverride`. Ignored unless negative.
89
+ */
90
+ minOverride?: number
83
91
  /**
84
92
  * Visual density of the bar primitive. `'small'` (default) keeps the
85
93
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -161,6 +169,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
161
169
  return order.map((k) => grouped.get(k)!)
162
170
  }
163
171
 
172
+ /**
173
+ * Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
174
+ * the zero at the left edge and negative-only at the right; mixed data is
175
+ * made symmetric so the zero sits in the middle of the track.
176
+ */
177
+ function signedDomain(
178
+ rows: readonly GroupedRow[],
179
+ minOverride?: number,
180
+ maxOverride?: number,
181
+ ): [lo: number, hi: number] {
182
+ let lo = 0
183
+ let hi = 0
184
+ for (const row of rows) {
185
+ for (const v of row.values) {
186
+ if (v < lo) lo = v
187
+ if (v > hi) hi = v
188
+ }
189
+ }
190
+ if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
191
+ if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
192
+ if (lo < 0 && hi > 0) {
193
+ const m = Math.max(-lo, hi)
194
+ return [-m, m]
195
+ }
196
+ return [lo, hi]
197
+ }
198
+
199
+ /**
200
+ * The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
201
+ * that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
202
+ * `-` cannot double it. U+2212 is as wide as a tabular digit.
203
+ */
204
+ function formatSigned(fmt: (n: number) => string, v: number): string {
205
+ return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
206
+ }
207
+
164
208
  /**
165
209
  * Pure presentational component for the Category widget. Renders
166
210
  * single-series rows (`data.length === 1`) as a compact grid or
@@ -187,13 +231,15 @@ export function CategoryUI({
187
231
  labels,
188
232
  onShowAll,
189
233
  maxOverride,
234
+ minOverride,
190
235
  size = 'small',
191
236
  stacked = false,
192
237
  hiddenSeries: hiddenSeriesProp,
193
238
  onHiddenSeriesChange,
194
239
  }: CategoryUIProps) {
195
240
  const theme = useTheme()
196
- const fmt = formatter ?? ((n: number) => String(n))
241
+ const baseFmt = formatter ?? ((n: number) => String(n))
242
+ const fmt = (n: number) => formatSigned(baseFmt, n)
197
243
 
198
244
  const hasSelection = !!selection && selection.length > 0
199
245
  const selectionSet = useMemo(
@@ -245,14 +291,14 @@ export function CategoryUI({
245
291
 
246
292
  const grouped = useMemo(() => generateGroupedData(data), [data])
247
293
 
248
- const maxValue = useMemo(() => {
249
- if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
250
- let m = 0
251
- for (const row of grouped) {
252
- for (const v of row.values) if (v > m) m = v
253
- }
254
- return m
255
- }, [grouped, maxOverride])
294
+ const [minValue, maxValue] = useMemo(
295
+ () => signedDomain(grouped, minOverride, maxOverride),
296
+ [grouped, minOverride, maxOverride],
297
+ )
298
+ // Where the zero sits on the track, handed to stacked rows so their zero
299
+ // matches the other rows'. Positive-only data keeps it at the left edge.
300
+ const zeroPct =
301
+ maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
256
302
 
257
303
  // Three modes:
258
304
  // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
@@ -382,7 +428,11 @@ export function CategoryUI({
382
428
  <Box sx={styles.root}>
383
429
  <Box
384
430
  ref={listRefCallback}
385
- sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
431
+ sx={{
432
+ ...styles.list,
433
+ ...(isMulti && !stacked && styles.listMulti),
434
+ ...(scrollMode && styles.listScroll),
435
+ }}
386
436
  style={
387
437
  scrollMaxHeight !== undefined
388
438
  ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
@@ -432,6 +482,7 @@ export function CategoryUI({
432
482
  selected={selected}
433
483
  onToggle={toggle}
434
484
  size={size}
485
+ zeroPct={zeroPct}
435
486
  />
436
487
  )
437
488
  }
@@ -443,6 +494,7 @@ export function CategoryUI({
443
494
  values={visibleIdx.map((i) => row.values[i]!)}
444
495
  colors={visibleIdx.map((i) => fill(i))}
445
496
  maxValue={maxValue}
497
+ minValue={minValue}
446
498
  formatter={fmt}
447
499
  selected={selected}
448
500
  onToggle={toggle}
@@ -457,6 +509,7 @@ export function CategoryUI({
457
509
  displayName={displayName}
458
510
  value={row.values[0] ?? 0}
459
511
  maxValue={maxValue}
512
+ minValue={minValue}
460
513
  color={fill(0)}
461
514
  formatter={fmt}
462
515
  selected={selected}