@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
@@ -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}>
@@ -42,13 +42,19 @@ interface CategoryWidgetState extends WidgetState {
42
42
 
43
43
  const NO_HIDDEN: ReadonlySet<number> = new Set()
44
44
 
45
- function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
46
- if (!d) return 0
47
- let m = 0
48
- for (const series of d) {
49
- for (const item of series) if (item.value > m) m = item.value
45
+ /** `[min, max]` of every value, each clamped to include zero. */
46
+ function extentFromCategoryData(
47
+ d: CategoryWidgetData | undefined,
48
+ ): [min: number, max: number] {
49
+ let min = 0
50
+ let max = 0
51
+ for (const series of d ?? []) {
52
+ for (const item of series) {
53
+ if (item.value < min) min = item.value
54
+ if (item.value > max) max = item.value
55
+ }
50
56
  }
51
- return m
57
+ return [min, max]
52
58
  }
53
59
 
54
60
  export interface CategoryProps {
@@ -84,6 +90,12 @@ export interface CategoryProps {
84
90
  * regardless of the data.
85
91
  */
86
92
  maxOverride?: number
93
+ /**
94
+ * Bottom of the bar scale for negative values, the mirror of
95
+ * `maxOverride`. Auto-filled from `rawData` the same way, so the zero
96
+ * does not move when the Searcher hides every negative row.
97
+ */
98
+ minOverride?: number
87
99
  /**
88
100
  * Visual density of the bar primitive. `'small'` (default) keeps the
89
101
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -113,23 +125,27 @@ export function Category({
113
125
  labels,
114
126
  onShowAll,
115
127
  maxOverride,
128
+ minOverride,
116
129
  size,
117
130
  stacked,
118
131
  }: CategoryProps) {
119
132
  const id = useWidgetId()
120
133
  const slice = useWidgetShallow(id, categorySelector)
121
- // Auto-fill `maxOverride` from `rawData` so bar widths stay coherent
122
- // across data transforms (e.g., the Searcher filtering rows in/out).
123
- // Consumer's explicit value always wins; we only fill when omitted
124
- // AND `rawData` has at least one positive value (CategoryUI's
125
- // `maxOverride > 0` guard would reject 0 anyway, but skipping is
126
- // cleaner — it lets the live fallback path handle the empty case).
127
- const naturalMax = useMemo(
128
- () => maxFromCategoryData(slice.rawData),
134
+ // Auto-fill both ends of the scale from `rawData` so bar widths and the
135
+ // zero stay coherent across data transforms (e.g., the Searcher filtering
136
+ // rows in/out). Consumer's explicit value wins, but each end only applies
137
+ // when `rawData` reaches past zero on that side: a relative-data wiring can
138
+ // then pass `100` / `-100` unconditionally and positive-only data keeps its
139
+ // zero at the left edge, negative-only at the right. With no data on a side
140
+ // we pass nothing and CategoryUI falls back to the live `data`.
141
+ const [naturalMin, naturalMax] = useMemo(
142
+ () => extentFromCategoryData(slice.rawData),
129
143
  [slice.rawData],
130
144
  )
131
145
  const effectiveMaxOverride =
132
- maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
146
+ naturalMax > 0 ? (maxOverride ?? naturalMax) : undefined
147
+ const effectiveMinOverride =
148
+ naturalMin < 0 ? (minOverride ?? naturalMin) : undefined
133
149
 
134
150
  const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
135
151
  const stored = useWidget<
@@ -156,6 +172,7 @@ export function Category({
156
172
  labels={labels}
157
173
  onShowAll={onShowAll}
158
174
  maxOverride={effectiveMaxOverride}
175
+ minOverride={effectiveMinOverride}
159
176
  size={size}
160
177
  stacked={stacked}
161
178
  hiddenSeries={hiddenSeries}
@@ -118,4 +118,22 @@ describe('<CategoryBarStacked>', () => {
118
118
  const track = container.querySelector('[data-size]')
119
119
  expect(track!.getAttribute('data-size')).toBe('medium')
120
120
  })
121
+
122
+ it('diverges from zeroPct: positives stack rightwards, negatives leftwards', () => {
123
+ const { container } = render(
124
+ <CategoryBarStacked
125
+ values={[20, -10, 30, -20]}
126
+ colors={['a', 'b', 'c', 'd']}
127
+ maxValue={100}
128
+ zeroPct={50}
129
+ />,
130
+ )
131
+ const segs = getSegments(container)
132
+ expect(segs.map((s) => [s.style.left, s.style.width])).toEqual([
133
+ ['50%', '20%'],
134
+ ['40%', '10%'],
135
+ ['70%', '30%'],
136
+ ['20%', '20%'],
137
+ ])
138
+ })
121
139
  })
@@ -1,6 +1,7 @@
1
1
  import { Box } from '@mui/material'
2
2
  import type { CategorySize } from '../types'
3
3
  import { styles } from '../style'
4
+ import { ZeroTickWrap } from './category-bar'
4
5
 
5
6
  export interface CategoryBarStackedProps {
6
7
  /** One value per series. Zero values render no segment (visually + in the DOM). */
@@ -13,6 +14,13 @@ export interface CategoryBarStackedProps {
13
14
  * so cross-row comparison is preserved.
14
15
  */
15
16
  maxValue: number
17
+ /**
18
+ * Where the zero sits, as a percentage of the track. `0` (default) is the
19
+ * positive-only layout. Positive segments stack rightwards from it and
20
+ * negative ones leftwards, both scaled by `maxValue` against the full
21
+ * track width.
22
+ */
23
+ zeroPct?: number
16
24
  /** Visual density. Forwarded to the track styling; segments inherit height. */
17
25
  size?: CategorySize
18
26
  }
@@ -33,41 +41,46 @@ export function CategoryBarStacked({
33
41
  values,
34
42
  colors,
35
43
  maxValue,
44
+ zeroPct = 0,
36
45
  size = 'small',
37
46
  }: CategoryBarStackedProps) {
38
- // Cumulative offsets pre-computed up front so the render map function
39
- // stays pure (no mutable closure variable — keeps the React Compiler
40
- // immutability check happy).
41
- const offsets: number[] = []
47
+ // Left edges pre-computed up front so the render map function stays pure
48
+ // (no mutable closure variable — keeps the React Compiler immutability
49
+ // check happy). Each sign keeps its own running sum away from the zero.
50
+ const lefts: number[] = []
42
51
  {
43
- let acc = 0
52
+ let pos = 0
53
+ let neg = 0
44
54
  for (const v of values) {
45
- offsets.push(acc)
46
- acc += v
55
+ if (v < 0) neg -= v
56
+ lefts.push(v < 0 ? -neg : pos)
57
+ if (v > 0) pos += v
47
58
  }
48
59
  }
49
60
  return (
50
- <Box sx={styles.bar[size]} data-size={size}>
51
- {values.map((v, i) => {
52
- if (v <= 0) return null
53
- const leftPct = maxValue > 0 ? (offsets[i]! / maxValue) * 100 : 0
54
- const pct = maxValue > 0 ? (v / maxValue) * 100 : 0
55
- return (
56
- <Box
57
- // Segments are keyed by per-series colour (the only stable
58
- // distinguishing field on this side) plus the segment index for
59
- // when callers reuse palette colours across series.
60
- key={`seg-${colors[i] ?? 'none'}-${i}`}
61
- sx={styles.stackedSegment[size]}
62
- style={{
63
- left: `${leftPct}%`,
64
- width: `${pct}%`,
65
- backgroundColor: colors[i] ?? '',
66
- }}
67
- data-bar-fill='true'
68
- />
69
- )
70
- })}
71
- </Box>
61
+ <ZeroTickWrap zeroPct={zeroPct > 0 && zeroPct < 100 ? zeroPct : undefined}>
62
+ <Box sx={styles.bar[size]} data-size={size}>
63
+ {values.map((v, i) => {
64
+ if (v === 0 || maxValue <= 0) return null
65
+ const leftPct = zeroPct + (lefts[i]! / maxValue) * 100
66
+ const pct = (Math.abs(v) / maxValue) * 100
67
+ return (
68
+ <Box
69
+ // Segments are keyed by per-series colour (the only stable
70
+ // distinguishing field on this side) plus the segment index for
71
+ // when callers reuse palette colours across series.
72
+ key={`seg-${colors[i] ?? 'none'}-${i}`}
73
+ sx={styles.stackedSegment[size]}
74
+ style={{
75
+ left: `${leftPct}%`,
76
+ width: `${pct}%`,
77
+ backgroundColor: colors[i] ?? '',
78
+ }}
79
+ data-bar-fill='true'
80
+ />
81
+ )
82
+ })}
83
+ </Box>
84
+ </ZeroTickWrap>
72
85
  )
73
86
  }
@@ -61,4 +61,70 @@ describe('<CategoryBar>', () => {
61
61
  expect(track).toBeTruthy()
62
62
  expect(track!.getAttribute('data-size')).toBe('medium')
63
63
  })
64
+
65
+ describe('signed domain (minValue < 0)', () => {
66
+ it('grows a positive value rightwards from the zero', () => {
67
+ const { container } = render(
68
+ <CategoryBar
69
+ value={0.5}
70
+ minValue={-1}
71
+ maxValue={1}
72
+ color='rgb(0, 0, 0)'
73
+ />,
74
+ )
75
+ const fill = getFill(container)!
76
+ expect(fill.style.left).toBe('50%')
77
+ expect(fill.style.width).toBe('25%')
78
+ })
79
+
80
+ it('grows a negative value leftwards from the zero, in the same color', () => {
81
+ const { container } = render(
82
+ <CategoryBar
83
+ value={-0.5}
84
+ minValue={-1}
85
+ maxValue={1}
86
+ color='rgb(0, 200, 0)'
87
+ />,
88
+ )
89
+ const fill = getFill(container)!
90
+ expect(fill.style.left).toBe('25%')
91
+ expect(fill.style.width).toBe('25%')
92
+ expect(fill.style.backgroundColor).toBe('rgb(0, 200, 0)')
93
+ })
94
+
95
+ it('squares the end that grows out of the zero', () => {
96
+ const { container: pos } = render(
97
+ <CategoryBar value={1} minValue={-1} maxValue={1} color='red' />,
98
+ )
99
+ const { container: neg } = render(
100
+ <CategoryBar value={-1} minValue={-1} maxValue={1} color='red' />,
101
+ )
102
+ expect(getFill(pos)!.style.borderTopLeftRadius).toBe('0px')
103
+ expect(getFill(pos)!.style.borderTopRightRadius).toBe('')
104
+ expect(getFill(neg)!.style.borderTopRightRadius).toBe('0px')
105
+ expect(getFill(neg)!.style.borderTopLeftRadius).toBe('')
106
+ })
107
+
108
+ it('puts the zero tick before the track so the bar paints over it', () => {
109
+ const { container } = render(
110
+ <CategoryBar value={1} minValue={-1} maxValue={1} color='red' />,
111
+ )
112
+ const tick = container.querySelector('[data-zero-tick="true"]')!
113
+ expect(tick.nextElementSibling!.getAttribute('data-size')).toBe('small')
114
+ })
115
+
116
+ it('hangs from the right edge when the domain is negative-only', () => {
117
+ const { container } = render(
118
+ <CategoryBar
119
+ value={-1}
120
+ minValue={-4}
121
+ maxValue={0}
122
+ color='rgb(0, 0, 0)'
123
+ />,
124
+ )
125
+ const fill = getFill(container)!
126
+ expect(fill.style.left).toBe('75%')
127
+ expect(fill.style.width).toBe('25%')
128
+ })
129
+ })
64
130
  })
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { Box } from '@mui/material'
2
3
  import type { CategorySize } from '../types'
3
4
  import { styles } from '../style'
@@ -5,8 +6,15 @@ import { styles } from '../style'
5
6
  export interface CategoryBarProps {
6
7
  /** Numeric value driving the fill width. */
7
8
  value: number
8
- /** Denominator for the percentage fill. `0` → empty bar (no division by zero). */
9
+ /** Upper end of the domain. `maxValue === minValue` → empty bar (no division by zero). */
9
10
  maxValue: number
11
+ /**
12
+ * Lower end of the domain. `0` (default) keeps the positive-only bar
13
+ * anchored at the left edge; a negative value moves the zero to
14
+ * `-minValue / (maxValue - minValue)` and bars grow from there in either
15
+ * direction, keeping the fill `color` whatever the sign.
16
+ */
17
+ minValue?: number
10
18
  /** Fill color. Honors any CSS color or MUI theme token. */
11
19
  color: string
12
20
  /**
@@ -16,6 +24,11 @@ export interface CategoryBarProps {
16
24
  * size-invariant.
17
25
  */
18
26
  size?: CategorySize
27
+ /**
28
+ * Draw the zero tick when the domain is signed. `false` when the parent
29
+ * draws its own zero line (multi-series rows span it across every bar).
30
+ */
31
+ zeroTick?: boolean
19
32
  }
20
33
 
21
34
  /**
@@ -27,23 +40,66 @@ export interface CategoryBarProps {
27
40
  export function CategoryBar({
28
41
  value,
29
42
  maxValue,
43
+ minValue = 0,
30
44
  color,
31
45
  size = 'small',
46
+ zeroTick = true,
32
47
  }: CategoryBarProps) {
33
- const pct = maxValue > 0 ? (value / maxValue) * 100 : 0
48
+ const range = maxValue - minValue
49
+ const leftPct =
50
+ range > 0 ? ((Math.min(0, value) - minValue) / range) * 100 : 0
51
+ const pct = range > 0 ? (Math.abs(value) / range) * 100 : 0
52
+ const hasZeroTick = zeroTick && minValue < 0 && maxValue > 0
34
53
  // Width + color rendered as inline `style` (not `sx`) to avoid
35
54
  // generating a new emotion class for every distinct value/color pair
36
55
  // — also makes the rendered DOM testable without computed-style math.
37
56
  return (
38
- <Box sx={styles.bar[size]} data-size={size}>
57
+ <ZeroTickWrap zeroPct={hasZeroTick ? (-minValue / range) * 100 : undefined}>
58
+ <Box sx={styles.bar[size]} data-size={size}>
59
+ <Box
60
+ sx={styles.barFill[size]}
61
+ style={{
62
+ left: `${leftPct}%`,
63
+ width: `${pct}%`,
64
+ backgroundColor: color,
65
+ // The end that grows out of the zero is square; only the far
66
+ // end keeps the rounded corners.
67
+ ...(value < 0
68
+ ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
69
+ : { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }),
70
+ }}
71
+ // Tag the fill so an ancestor row's `:hover` selector can target
72
+ // it (`&:hover [data-bar-fill]`) — the brightening affordance now
73
+ // fires from anywhere in the row, not only the bar track.
74
+ data-bar-fill='true'
75
+ />
76
+ </Box>
77
+ </ZeroTickWrap>
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Wraps a bar track and, when `zeroPct` is set, marks the zero with a tick
83
+ * that overhangs the track. Positive-only, negative-only and all-zero bars
84
+ * pass `undefined` and get no tick.
85
+ */
86
+ export function ZeroTickWrap({
87
+ zeroPct,
88
+ children,
89
+ }: {
90
+ zeroPct: number | undefined
91
+ children: ReactNode
92
+ }) {
93
+ if (zeroPct === undefined) return children
94
+ return (
95
+ <Box sx={styles.barWrap}>
39
96
  <Box
40
- sx={styles.barFill[size]}
41
- style={{ width: `${pct}%`, backgroundColor: color }}
42
- // Tag the fill so an ancestor row's `:hover` selector can target
43
- // it (`&:hover [data-bar-fill]`) — the brightening affordance now
44
- // fires from anywhere in the row, not only the bar track.
45
- data-bar-fill='true'
97
+ aria-hidden
98
+ data-zero-tick='true'
99
+ sx={styles.zeroTick}
100
+ style={{ left: `${zeroPct}%` }}
46
101
  />
102
+ {children}
47
103
  </Box>
48
104
  )
49
105
  }
@@ -15,6 +15,8 @@ export interface CategoryRowMultiProps {
15
15
  colors: readonly string[]
16
16
  /** Shared max across all series + rows (or `maxOverride`). */
17
17
  maxValue: number
18
+ /** Lower end of the shared domain (`0` for positive-only data). */
19
+ minValue?: number
18
20
  formatter: (n: number) => string
19
21
  selected: boolean
20
22
  onToggle: (name: CategoryKey) => void
@@ -40,11 +42,13 @@ export function CategoryRowMulti({
40
42
  values,
41
43
  colors,
42
44
  maxValue,
45
+ minValue,
43
46
  formatter,
44
47
  selected,
45
48
  onToggle,
46
49
  size,
47
50
  }: CategoryRowMultiProps) {
51
+ const min = minValue ?? 0
48
52
  const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
49
53
  if (e.key === 'Enter' || e.key === ' ') {
50
54
  e.preventDefault()
@@ -63,6 +67,21 @@ export function CategoryRowMulti({
63
67
  <Typography variant='body2' sx={styles.multiName}>
64
68
  {displayName ?? name}
65
69
  </Typography>
70
+ {min < 0 && maxValue > 0 && (
71
+ <Box
72
+ aria-hidden
73
+ sx={styles.multiZeroArea}
74
+ // Row 1 is the label; the bars fill the next `values.length` rows.
75
+ // Placed before the bars so their tracks paint over the line.
76
+ style={{ gridRow: `2 / span ${values.length}` }}
77
+ >
78
+ <Box
79
+ data-zero-tick='true'
80
+ sx={styles.zeroTick}
81
+ style={{ left: `${(-min / (maxValue - min)) * 100}%` }}
82
+ />
83
+ </Box>
84
+ )}
66
85
  {values.map((v, i) => (
67
86
  // Multi-bar rows iterate per series — the bar belongs to series `i`,
68
87
  // and the row is keyed on the parent's `name`, so a composite of
@@ -72,8 +91,10 @@ export function CategoryRowMulti({
72
91
  <CategoryBar
73
92
  value={v}
74
93
  maxValue={maxValue}
94
+ minValue={minValue}
75
95
  color={colors[i] ?? ''}
76
96
  size={size}
97
+ zeroTick={false}
77
98
  />
78
99
  </Box>
79
100
  <Typography variant='body2' sx={styles.value}>
@@ -11,6 +11,8 @@ export interface CategoryRowSingleProps {
11
11
  displayName?: string | number
12
12
  value: number
13
13
  maxValue: number
14
+ /** Lower end of the shared domain (`0` for positive-only data). */
15
+ minValue?: number
14
16
  color: string
15
17
  formatter: (n: number) => string
16
18
  selected: boolean
@@ -36,6 +38,7 @@ export function CategoryRowSingle({
36
38
  displayName,
37
39
  value,
38
40
  maxValue,
41
+ minValue,
39
42
  color,
40
43
  formatter,
41
44
  selected,
@@ -68,6 +71,7 @@ export function CategoryRowSingle({
68
71
  <CategoryBar
69
72
  value={value}
70
73
  maxValue={maxValue}
74
+ minValue={minValue}
71
75
  color={color}
72
76
  size={size}
73
77
  />
@@ -25,6 +25,11 @@ export interface CategoryRowStackedProps {
25
25
  onToggle: (name: CategoryKey) => void
26
26
  /** Bar visual density. Forwarded to the inner {@link CategoryBarStacked}. */
27
27
  size?: CategorySize
28
+ /**
29
+ * Where the list's zero sits (% of the track), shared by every row so the
30
+ * zero lines up: `0` positive-only, `50` signed, `100` negative-only.
31
+ */
32
+ zeroPct?: number
28
33
  }
29
34
 
30
35
  /**
@@ -40,6 +45,10 @@ export interface CategoryRowStackedProps {
40
45
  * total relative to other rows. The total numeric value is still shown in
41
46
  * the header so the per-row magnitude isn't lost.
42
47
  *
48
+ * Signed data diverges from `zeroPct`: positives stack rightwards and
49
+ * negatives leftwards, and whichever side is longer reaches its edge of the
50
+ * track. The header total is the signed sum.
51
+ *
43
52
  * Visual selection / dim signal lives entirely in the segment colors
44
53
  * (`colors` prop) — every series in a dimmed row receives
45
54
  * `theme.palette.action.disabled`. The bar visually collapses to a single
@@ -56,6 +65,7 @@ export function CategoryRowStacked({
56
65
  selected,
57
66
  onToggle,
58
67
  size,
68
+ zeroPct = 0,
59
69
  }: CategoryRowStackedProps) {
60
70
  const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
61
71
  if (e.key === 'Enter' || e.key === ' ') {
@@ -64,6 +74,18 @@ export function CategoryRowStacked({
64
74
  }
65
75
  }
66
76
  const total = values.reduce((a, b) => a + b, 0)
77
+ let pos = 0
78
+ let neg = 0
79
+ for (const v of values) {
80
+ if (v > 0) pos += v
81
+ else neg -= v
82
+ }
83
+ // Magnitude that would fill the whole track, picked so the longer side
84
+ // just reaches its edge: `pos` spans `100 - zeroPct`, `neg` spans `zeroPct`.
85
+ const scale = Math.max(
86
+ zeroPct < 100 ? (pos * 100) / (100 - zeroPct) : 0,
87
+ zeroPct > 0 ? (neg * 100) / zeroPct : 0,
88
+ )
67
89
  return (
68
90
  <Box
69
91
  role='button'
@@ -84,7 +106,8 @@ export function CategoryRowStacked({
84
106
  <CategoryBarStacked
85
107
  values={values}
86
108
  colors={colors}
87
- maxValue={total}
109
+ maxValue={scale}
110
+ zeroPct={zeroPct}
88
111
  size={size}
89
112
  />
90
113
  <Box sx={styles.stackedBreakdown}>