@carto/ps-react-ui 4.24.0 → 4.26.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-B1ss030_.js → actions-DXfQMrll.js} +56 -46
  2. package/dist/actions-DXfQMrll.js.map +1 -0
  3. package/dist/category-DuKx_CJX.js +933 -0
  4. package/dist/category-DuKx_CJX.js.map +1 -0
  5. package/dist/item-color-C1cu6EJd.js +20 -0
  6. package/dist/item-color-C1cu6EJd.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 +32 -17
  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-B1ss030_.js.map +0 -1
  91. package/dist/category-Ca5XDx0N.js +0 -839
  92. package/dist/category-Ca5XDx0N.js.map +0 -1
@@ -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}`. */
@@ -814,4 +814,143 @@ describe('<CategoryUI>', () => {
814
814
  fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
815
815
  })
816
816
  })
817
+
818
+ // ── Signed values ──────────────────────────────────────────────────
819
+ describe('negative values', () => {
820
+ const MIXED = [
821
+ [
822
+ { name: 'Income', value: 0.8 },
823
+ { name: 'Transit', value: -0.4 },
824
+ { name: 'Size', value: 0 },
825
+ ],
826
+ ]
827
+ const fills = (c: HTMLElement) =>
828
+ Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
829
+ const ticks = (c: HTMLElement) =>
830
+ Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
831
+
832
+ it('centres the zero with a symmetric domain and keeps the series color', () => {
833
+ const { container } = render(<CategoryUI data={MIXED} />)
834
+ const [income, transit, size] = fills(container)
835
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
836
+ expect([transit!.style.left, transit!.style.width]).toEqual([
837
+ '25%',
838
+ '25%',
839
+ ])
840
+ expect(size!.style.width).toBe('0%')
841
+ expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
842
+ })
843
+
844
+ it('marks the zero with one tick per bar, none on "Others"', () => {
845
+ const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
846
+ expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
847
+ expect(screen.getByText('Others')).toBeTruthy()
848
+ })
849
+
850
+ it('draws one zero line per multi-series row, spanning every bar', () => {
851
+ const { container } = render(
852
+ <CategoryUI
853
+ data={[
854
+ [
855
+ { name: 'A', value: 1 },
856
+ { name: 'B', value: 2 },
857
+ ],
858
+ [
859
+ { name: 'A', value: -1 },
860
+ { name: 'B', value: 0.5 },
861
+ ],
862
+ [
863
+ { name: 'A', value: 0 },
864
+ { name: 'B', value: -2 },
865
+ ],
866
+ ]}
867
+ />,
868
+ )
869
+ const t = ticks(container)
870
+ expect(t).toHaveLength(2)
871
+ expect(t[0]!.style.left).toBe('50%')
872
+ expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
873
+ })
874
+
875
+ it.each([
876
+ ['positive-only', [1, 2]],
877
+ ['negative-only', [-1, -2]],
878
+ ['all-zero', [0, 0]],
879
+ ])('draws no zero tick for %s data', (_, values) => {
880
+ const { container } = render(
881
+ <CategoryUI
882
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
883
+ />,
884
+ )
885
+ expect(ticks(container)).toHaveLength(0)
886
+ })
887
+
888
+ it('hangs negative-only bars from the right edge', () => {
889
+ const { container } = render(
890
+ <CategoryUI
891
+ data={[
892
+ [
893
+ { name: 'A', value: -2 },
894
+ { name: 'B', value: -1 },
895
+ ],
896
+ ]}
897
+ />,
898
+ )
899
+ const [a, b] = fills(container)
900
+ expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
901
+ expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
902
+ })
903
+
904
+ it('treats maxOverride as a magnitude for signed data', () => {
905
+ const { container } = render(
906
+ <CategoryUI data={MIXED} maxOverride={1.6} />,
907
+ )
908
+ const [income, transit] = fills(container)
909
+ // domain [-1.6, 1.6]
910
+ expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
911
+ expect(transit!.style.width).toBe('12.5%')
912
+ })
913
+
914
+ it('minOverride pins the zero even when the data has no negatives', () => {
915
+ const { container } = render(
916
+ <CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
917
+ )
918
+ expect(fills(container)[0]!.style.left).toBe('50%')
919
+ expect(ticks(container)).toHaveLength(1)
920
+ })
921
+
922
+ it('prefixes U+2212 itself and hands the formatter |v|', () => {
923
+ const formatter = vi.fn((n: number) => `$${n}`)
924
+ render(
925
+ <CategoryUI
926
+ data={[
927
+ [
928
+ { name: 'A', value: -0.5 },
929
+ { name: 'B', value: -0 },
930
+ { name: 'C', value: 0 },
931
+ ],
932
+ ]}
933
+ formatter={formatter}
934
+ />,
935
+ )
936
+ expect(screen.getByText('\u2212$0.5')).toBeTruthy()
937
+ expect(screen.getAllByText('$0')).toHaveLength(2)
938
+ formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
939
+ })
940
+
941
+ it('stacked mode diverges around the shared zero and shows the signed total', () => {
942
+ const { container } = render(
943
+ <CategoryUI
944
+ stacked
945
+ data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
946
+ />,
947
+ )
948
+ const [pos, neg] = fills(container)
949
+ // zero at 50%, longer side (3) fills its half
950
+ expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
951
+ expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
952
+ expect(screen.getByText('2')).toBeTruthy()
953
+ expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
954
+ })
955
+ })
817
956
  })
@@ -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
@@ -147,6 +155,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
147
155
  return order.map((k) => grouped.get(k)!)
148
156
  }
149
157
 
158
+ /**
159
+ * Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
160
+ * the zero at the left edge and negative-only at the right; mixed data is
161
+ * made symmetric so the zero sits in the middle of the track.
162
+ */
163
+ function signedDomain(
164
+ rows: readonly GroupedRow[],
165
+ minOverride?: number,
166
+ maxOverride?: number,
167
+ ): [lo: number, hi: number] {
168
+ let lo = 0
169
+ let hi = 0
170
+ for (const row of rows) {
171
+ for (const v of row.values) {
172
+ if (v < lo) lo = v
173
+ if (v > hi) hi = v
174
+ }
175
+ }
176
+ if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
177
+ if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
178
+ if (lo < 0 && hi > 0) {
179
+ const m = Math.max(-lo, hi)
180
+ return [-m, m]
181
+ }
182
+ return [lo, hi]
183
+ }
184
+
185
+ /**
186
+ * The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
187
+ * that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
188
+ * `-` cannot double it. U+2212 is as wide as a tabular digit.
189
+ */
190
+ function formatSigned(fmt: (n: number) => string, v: number): string {
191
+ return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
192
+ }
193
+
150
194
  /**
151
195
  * Pure presentational component for the Category widget. Renders
152
196
  * single-series rows (`data.length === 1`) as a compact grid or
@@ -173,13 +217,15 @@ export function CategoryUI({
173
217
  labels,
174
218
  onShowAll,
175
219
  maxOverride,
220
+ minOverride,
176
221
  size = 'small',
177
222
  stacked = false,
178
223
  hiddenSeries: hiddenSeriesProp,
179
224
  onHiddenSeriesChange,
180
225
  }: CategoryUIProps) {
181
226
  const theme = useTheme()
182
- const fmt = formatter ?? ((n: number) => String(n))
227
+ const baseFmt = formatter ?? ((n: number) => String(n))
228
+ const fmt = (n: number) => formatSigned(baseFmt, n)
183
229
 
184
230
  const hasSelection = !!selection && selection.length > 0
185
231
  const selectionSet = useMemo(
@@ -231,14 +277,14 @@ export function CategoryUI({
231
277
 
232
278
  const grouped = useMemo(() => generateGroupedData(data), [data])
233
279
 
234
- const maxValue = useMemo(() => {
235
- if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
236
- let m = 0
237
- for (const row of grouped) {
238
- for (const v of row.values) if (v > m) m = v
239
- }
240
- return m
241
- }, [grouped, maxOverride])
280
+ const [minValue, maxValue] = useMemo(
281
+ () => signedDomain(grouped, minOverride, maxOverride),
282
+ [grouped, minOverride, maxOverride],
283
+ )
284
+ // Where the zero sits on the track, handed to stacked rows so their zero
285
+ // matches the other rows'. Positive-only data keeps it at the left edge.
286
+ const zeroPct =
287
+ maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
242
288
 
243
289
  // Three modes:
244
290
  // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
@@ -363,7 +409,11 @@ export function CategoryUI({
363
409
  <Box sx={styles.root}>
364
410
  <Box
365
411
  ref={listRefCallback}
366
- sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
412
+ sx={{
413
+ ...styles.list,
414
+ ...(isMulti && !stacked && styles.listMulti),
415
+ ...(scrollMode && styles.listScroll),
416
+ }}
367
417
  style={
368
418
  scrollMaxHeight !== undefined
369
419
  ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
@@ -413,6 +463,7 @@ export function CategoryUI({
413
463
  selected={selected}
414
464
  onToggle={toggle}
415
465
  size={size}
466
+ zeroPct={zeroPct}
416
467
  />
417
468
  )
418
469
  }
@@ -424,6 +475,7 @@ export function CategoryUI({
424
475
  values={visibleIdx.map((i) => row.values[i]!)}
425
476
  colors={visibleIdx.map((i) => fill(i))}
426
477
  maxValue={maxValue}
478
+ minValue={minValue}
427
479
  formatter={fmt}
428
480
  selected={selected}
429
481
  onToggle={toggle}
@@ -438,6 +490,7 @@ export function CategoryUI({
438
490
  displayName={displayName}
439
491
  value={row.values[0] ?? 0}
440
492
  maxValue={maxValue}
493
+ minValue={minValue}
441
494
  color={fill(0)}
442
495
  formatter={fmt}
443
496
  selected={selected}
@@ -238,6 +238,73 @@ describe('<Category> bridge', () => {
238
238
  expect(fills[0]!.style.width).toBe('25%')
239
239
  })
240
240
 
241
+ it('keeps the zero centred when a transform hides every negative row', () => {
242
+ render(
243
+ <Provider
244
+ id='cat-bridge-auto-neg'
245
+ data={[
246
+ [
247
+ { name: 'A', value: 1 },
248
+ { name: 'B', value: -1 },
249
+ ],
250
+ ]}
251
+ >
252
+ <Category />
253
+ </Provider>,
254
+ )
255
+ act(() => {
256
+ getWidgetStore('cat-bridge-auto-neg').setState({
257
+ data: [[{ name: 'A', value: 1 }]],
258
+ })
259
+ })
260
+ const fills = getBarFills()
261
+ expect(fills).toHaveLength(1)
262
+ expect(fills[0]!.style.left).toBe('50%')
263
+ expect(fills[0]!.style.width).toBe('50%')
264
+ })
265
+
266
+ it.each([
267
+ [
268
+ 'positive-only',
269
+ [50, 25],
270
+ [
271
+ ['0%', '50%'],
272
+ ['0%', '25%'],
273
+ ],
274
+ ],
275
+ [
276
+ 'negative-only',
277
+ [-50, -25],
278
+ [
279
+ ['50%', '50%'],
280
+ ['75%', '25%'],
281
+ ],
282
+ ],
283
+ [
284
+ 'mixed',
285
+ [50, -25],
286
+ [
287
+ ['50%', '25%'],
288
+ ['37.5%', '12.5%'],
289
+ ],
290
+ ],
291
+ ])(
292
+ 'applies ±100 overrides only on the side %s data reaches',
293
+ (_, values, expected) => {
294
+ render(
295
+ <Provider
296
+ id={`cat-bridge-side-${values.join('')}`}
297
+ data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
298
+ >
299
+ <Category maxOverride={100} minOverride={-100} />
300
+ </Provider>,
301
+ )
302
+ expect(getBarFills().map((f) => [f.style.left, f.style.width])).toEqual(
303
+ expected,
304
+ )
305
+ },
306
+ )
307
+
241
308
  it('consumer-supplied maxOverride wins over the rawData auto-fill', () => {
242
309
  render(
243
310
  <Provider id='cat-bridge-auto-3' data={PEAK_DATA}>