@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
@@ -80,8 +80,16 @@ export interface CategoryUIProps {
80
80
  * overflow row stays a static summary.
81
81
  */
82
82
  onShowAll?: () => void
83
- /** Manual override for the bar-width denominator. */
83
+ /**
84
+ * Manual override for the top of the bar scale. With signed data it is a
85
+ * magnitude: the scale stays symmetric around the centred zero.
86
+ */
84
87
  maxOverride?: number
88
+ /**
89
+ * Manual override for the bottom of the bar scale, the negative mirror of
90
+ * `maxOverride`. Ignored unless negative.
91
+ */
92
+ minOverride?: number
85
93
  /**
86
94
  * Visual density of the bar primitive. `'small'` (default) keeps the
87
95
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -163,6 +171,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
163
171
  return order.map((k) => grouped.get(k)!)
164
172
  }
165
173
 
174
+ /**
175
+ * Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
176
+ * the zero at the left edge and negative-only at the right; mixed data is
177
+ * made symmetric so the zero sits in the middle of the track.
178
+ */
179
+ function signedDomain(
180
+ rows: readonly GroupedRow[],
181
+ minOverride?: number,
182
+ maxOverride?: number,
183
+ ): [lo: number, hi: number] {
184
+ let lo = 0
185
+ let hi = 0
186
+ for (const row of rows) {
187
+ for (const v of row.values) {
188
+ if (v < lo) lo = v
189
+ if (v > hi) hi = v
190
+ }
191
+ }
192
+ if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
193
+ if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
194
+ if (lo < 0 && hi > 0) {
195
+ const m = Math.max(-lo, hi)
196
+ return [-m, m]
197
+ }
198
+ return [lo, hi]
199
+ }
200
+
201
+ /**
202
+ * The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
203
+ * that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
204
+ * `-` cannot double it. U+2212 is as wide as a tabular digit.
205
+ */
206
+ function formatSigned(fmt: (n: number) => string, v: number): string {
207
+ return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
208
+ }
209
+
166
210
  /**
167
211
  * Pure presentational component for the Category widget. Renders
168
212
  * single-series rows (`data.length === 1`) as a compact grid or
@@ -189,13 +233,15 @@ export function CategoryUI({
189
233
  labels,
190
234
  onShowAll,
191
235
  maxOverride,
236
+ minOverride,
192
237
  size = 'small',
193
238
  stacked = false,
194
239
  hiddenSeries: hiddenSeriesProp,
195
240
  onHiddenSeriesChange,
196
241
  }: CategoryUIProps) {
197
242
  const theme = useTheme()
198
- const fmt = formatter ?? ((n: number) => String(n))
243
+ const baseFmt = formatter ?? ((n: number) => String(n))
244
+ const fmt = (n: number) => formatSigned(baseFmt, n)
199
245
 
200
246
  const hasSelection = !!selection && selection.length > 0
201
247
  const selectionSet = useMemo(
@@ -250,14 +296,14 @@ export function CategoryUI({
250
296
 
251
297
  const grouped = useMemo(() => generateGroupedData(data), [data])
252
298
 
253
- const maxValue = useMemo(() => {
254
- if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
255
- let m = 0
256
- for (const row of grouped) {
257
- for (const v of row.values) if (v > m) m = v
258
- }
259
- return m
260
- }, [grouped, maxOverride])
299
+ const [minValue, maxValue] = useMemo(
300
+ () => signedDomain(grouped, minOverride, maxOverride),
301
+ [grouped, minOverride, maxOverride],
302
+ )
303
+ // Where the zero sits on the track, handed to stacked rows so their zero
304
+ // matches the other rows'. Positive-only data keeps it at the left edge.
305
+ const zeroPct =
306
+ maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
261
307
 
262
308
  // Three modes:
263
309
  // * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
@@ -387,7 +433,11 @@ export function CategoryUI({
387
433
  <Box sx={styles.root}>
388
434
  <Box
389
435
  ref={listRefCallback}
390
- sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
436
+ sx={{
437
+ ...styles.list,
438
+ ...(isMulti && !stacked && styles.listMulti),
439
+ ...(scrollMode && styles.listScroll),
440
+ }}
391
441
  style={
392
442
  scrollMaxHeight !== undefined
393
443
  ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
@@ -437,6 +487,7 @@ export function CategoryUI({
437
487
  selected={selected}
438
488
  onToggle={toggle}
439
489
  size={size}
490
+ zeroPct={zeroPct}
440
491
  />
441
492
  )
442
493
  }
@@ -448,6 +499,7 @@ export function CategoryUI({
448
499
  values={visibleIdx.map((i) => row.values[i]!)}
449
500
  colors={visibleIdx.map((i) => fill(i))}
450
501
  maxValue={maxValue}
502
+ minValue={minValue}
451
503
  formatter={fmt}
452
504
  selected={selected}
453
505
  onToggle={toggle}
@@ -462,6 +514,7 @@ export function CategoryUI({
462
514
  displayName={displayName}
463
515
  value={row.values[0] ?? 0}
464
516
  maxValue={maxValue}
517
+ minValue={minValue}
465
518
  color={fill(0)}
466
519
  formatter={fmt}
467
520
  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}>
@@ -41,13 +41,19 @@ interface CategoryWidgetState extends WidgetState {
41
41
  categoryHiddenSeries?: { key: string; indices: ReadonlySet<number> }
42
42
  }
43
43
 
44
- function maxFromCategoryData(d: CategoryWidgetData | undefined): number {
45
- if (!d) return 0
46
- let m = 0
47
- for (const series of d) {
48
- for (const item of series) if (item.value > m) m = item.value
44
+ /** `[min, max]` of every value, each clamped to include zero. */
45
+ function extentFromCategoryData(
46
+ d: CategoryWidgetData | undefined,
47
+ ): [min: number, max: number] {
48
+ let min = 0
49
+ let max = 0
50
+ for (const series of d ?? []) {
51
+ for (const item of series) {
52
+ if (item.value < min) min = item.value
53
+ if (item.value > max) max = item.value
54
+ }
49
55
  }
50
- return m
56
+ return [min, max]
51
57
  }
52
58
 
53
59
  export interface CategoryProps {
@@ -83,6 +89,12 @@ export interface CategoryProps {
83
89
  * regardless of the data.
84
90
  */
85
91
  maxOverride?: number
92
+ /**
93
+ * Bottom of the bar scale for negative values, the mirror of
94
+ * `maxOverride`. Auto-filled from `rawData` the same way, so the zero
95
+ * does not move when the Searcher hides every negative row.
96
+ */
97
+ minOverride?: number
86
98
  /**
87
99
  * Visual density of the bar primitive. `'small'` (default) keeps the
88
100
  * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
@@ -112,23 +124,27 @@ export function Category({
112
124
  labels,
113
125
  onShowAll,
114
126
  maxOverride,
127
+ minOverride,
115
128
  size,
116
129
  stacked,
117
130
  }: CategoryProps) {
118
131
  const id = useWidgetId()
119
132
  const slice = useWidgetShallow(id, categorySelector)
120
- // Auto-fill `maxOverride` from `rawData` so bar widths stay coherent
121
- // across data transforms (e.g., the Searcher filtering rows in/out).
122
- // Consumer's explicit value always wins; we only fill when omitted
123
- // AND `rawData` has at least one positive value (CategoryUI's
124
- // `maxOverride > 0` guard would reject 0 anyway, but skipping is
125
- // cleaner — it lets the live fallback path handle the empty case).
126
- const naturalMax = useMemo(
127
- () => maxFromCategoryData(slice.rawData),
133
+ // Auto-fill both ends of the scale from `rawData` so bar widths and the
134
+ // zero stay coherent across data transforms (e.g., the Searcher filtering
135
+ // rows in/out). Consumer's explicit value wins, but each end only applies
136
+ // when `rawData` reaches past zero on that side: a relative-data wiring can
137
+ // then pass `100` / `-100` unconditionally and positive-only data keeps its
138
+ // zero at the left edge, negative-only at the right. With no data on a side
139
+ // we pass nothing and CategoryUI falls back to the live `data`.
140
+ const [naturalMin, naturalMax] = useMemo(
141
+ () => extentFromCategoryData(slice.rawData),
128
142
  [slice.rawData],
129
143
  )
130
144
  const effectiveMaxOverride =
131
- maxOverride ?? (naturalMax > 0 ? naturalMax : undefined)
145
+ naturalMax > 0 ? (maxOverride ?? naturalMax) : undefined
146
+ const effectiveMinOverride =
147
+ naturalMin < 0 ? (minOverride ?? naturalMin) : undefined
132
148
 
133
149
  const seriesKey = categorySeriesKey(series)
134
150
  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
  })
@@ -2,6 +2,7 @@ import type { CssColor } from '../../../components/types'
2
2
  import { Box } from '@mui/material'
3
3
  import type { CategorySize } from '../types'
4
4
  import { styles } from '../style'
5
+ import { ZeroTickWrap } from './category-bar'
5
6
 
6
7
  export interface CategoryBarStackedProps {
7
8
  /** One value per series. Zero values render no segment (visually + in the DOM). */
@@ -14,6 +15,13 @@ export interface CategoryBarStackedProps {
14
15
  * so cross-row comparison is preserved.
15
16
  */
16
17
  maxValue: number
18
+ /**
19
+ * Where the zero sits, as a percentage of the track. `0` (default) is the
20
+ * positive-only layout. Positive segments stack rightwards from it and
21
+ * negative ones leftwards, both scaled by `maxValue` against the full
22
+ * track width.
23
+ */
24
+ zeroPct?: number
17
25
  /** Visual density. Forwarded to the track styling; segments inherit height. */
18
26
  size?: CategorySize
19
27
  }
@@ -34,41 +42,46 @@ export function CategoryBarStacked({
34
42
  values,
35
43
  colors,
36
44
  maxValue,
45
+ zeroPct = 0,
37
46
  size = 'small',
38
47
  }: CategoryBarStackedProps) {
39
- // Cumulative offsets pre-computed up front so the render map function
40
- // stays pure (no mutable closure variable — keeps the React Compiler
41
- // immutability check happy).
42
- const offsets: number[] = []
48
+ // Left edges pre-computed up front so the render map function stays pure
49
+ // (no mutable closure variable — keeps the React Compiler immutability
50
+ // check happy). Each sign keeps its own running sum away from the zero.
51
+ const lefts: number[] = []
43
52
  {
44
- let acc = 0
53
+ let pos = 0
54
+ let neg = 0
45
55
  for (const v of values) {
46
- offsets.push(acc)
47
- acc += v
56
+ if (v < 0) neg -= v
57
+ lefts.push(v < 0 ? -neg : pos)
58
+ if (v > 0) pos += v
48
59
  }
49
60
  }
50
61
  return (
51
- <Box sx={styles.bar[size]} data-size={size}>
52
- {values.map((v, i) => {
53
- if (v <= 0) return null
54
- const leftPct = maxValue > 0 ? (offsets[i]! / maxValue) * 100 : 0
55
- const pct = maxValue > 0 ? (v / maxValue) * 100 : 0
56
- return (
57
- <Box
58
- // Segments are keyed by per-series colour (the only stable
59
- // distinguishing field on this side) plus the segment index for
60
- // when callers reuse palette colours across series.
61
- key={`seg-${colors[i] ?? 'none'}-${i}`}
62
- sx={styles.stackedSegment[size]}
63
- style={{
64
- left: `${leftPct}%`,
65
- width: `${pct}%`,
66
- backgroundColor: colors[i] ?? '',
67
- }}
68
- data-bar-fill='true'
69
- />
70
- )
71
- })}
72
- </Box>
62
+ <ZeroTickWrap zeroPct={zeroPct > 0 && zeroPct < 100 ? zeroPct : undefined}>
63
+ <Box sx={styles.bar[size]} data-size={size}>
64
+ {values.map((v, i) => {
65
+ if (v === 0 || maxValue <= 0) return null
66
+ const leftPct = zeroPct + (lefts[i]! / maxValue) * 100
67
+ const pct = (Math.abs(v) / maxValue) * 100
68
+ return (
69
+ <Box
70
+ // Segments are keyed by per-series colour (the only stable
71
+ // distinguishing field on this side) plus the segment index for
72
+ // when callers reuse palette colours across series.
73
+ key={`seg-${colors[i] ?? 'none'}-${i}`}
74
+ sx={styles.stackedSegment[size]}
75
+ style={{
76
+ left: `${leftPct}%`,
77
+ width: `${pct}%`,
78
+ backgroundColor: colors[i] ?? '',
79
+ }}
80
+ data-bar-fill='true'
81
+ />
82
+ )
83
+ })}
84
+ </Box>
85
+ </ZeroTickWrap>
73
86
  )
74
87
  }
@@ -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,4 +1,5 @@
1
1
  import type { CssColor } from '../../../components/types'
2
+ import type { ReactNode } from 'react'
2
3
  import { Box } from '@mui/material'
3
4
  import type { CategorySize } from '../types'
4
5
  import { styles } from '../style'
@@ -6,8 +7,15 @@ import { styles } from '../style'
6
7
  export interface CategoryBarProps {
7
8
  /** Numeric value driving the fill width. */
8
9
  value: number
9
- /** Denominator for the percentage fill. `0` → empty bar (no division by zero). */
10
+ /** Upper end of the domain. `maxValue === minValue` → empty bar (no division by zero). */
10
11
  maxValue: number
12
+ /**
13
+ * Lower end of the domain. `0` (default) keeps the positive-only bar
14
+ * anchored at the left edge; a negative value moves the zero to
15
+ * `-minValue / (maxValue - minValue)` and bars grow from there in either
16
+ * direction, keeping the fill `color` whatever the sign.
17
+ */
18
+ minValue?: number
11
19
  /** Fill color, already resolved by `CategoryUI.colorAt`. */
12
20
  color: CssColor
13
21
  /**
@@ -17,6 +25,11 @@ export interface CategoryBarProps {
17
25
  * size-invariant.
18
26
  */
19
27
  size?: CategorySize
28
+ /**
29
+ * Draw the zero tick when the domain is signed. `false` when the parent
30
+ * draws its own zero line (multi-series rows span it across every bar).
31
+ */
32
+ zeroTick?: boolean
20
33
  }
21
34
 
22
35
  /**
@@ -28,23 +41,66 @@ export interface CategoryBarProps {
28
41
  export function CategoryBar({
29
42
  value,
30
43
  maxValue,
44
+ minValue = 0,
31
45
  color,
32
46
  size = 'small',
47
+ zeroTick = true,
33
48
  }: CategoryBarProps) {
34
- const pct = maxValue > 0 ? (value / maxValue) * 100 : 0
49
+ const range = maxValue - minValue
50
+ const leftPct =
51
+ range > 0 ? ((Math.min(0, value) - minValue) / range) * 100 : 0
52
+ const pct = range > 0 ? (Math.abs(value) / range) * 100 : 0
53
+ const hasZeroTick = zeroTick && minValue < 0 && maxValue > 0
35
54
  // Width + color rendered as inline `style` (not `sx`) to avoid
36
55
  // generating a new emotion class for every distinct value/color pair
37
56
  // — also makes the rendered DOM testable without computed-style math.
38
57
  return (
39
- <Box sx={styles.bar[size]} data-size={size}>
58
+ <ZeroTickWrap zeroPct={hasZeroTick ? (-minValue / range) * 100 : undefined}>
59
+ <Box sx={styles.bar[size]} data-size={size}>
60
+ <Box
61
+ sx={styles.barFill[size]}
62
+ style={{
63
+ left: `${leftPct}%`,
64
+ width: `${pct}%`,
65
+ backgroundColor: color,
66
+ // The end that grows out of the zero is square; only the far
67
+ // end keeps the rounded corners.
68
+ ...(value < 0
69
+ ? { borderTopRightRadius: 0, borderBottomRightRadius: 0 }
70
+ : { borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }),
71
+ }}
72
+ // Tag the fill so an ancestor row's `:hover` selector can target
73
+ // it (`&:hover [data-bar-fill]`) — the brightening affordance now
74
+ // fires from anywhere in the row, not only the bar track.
75
+ data-bar-fill='true'
76
+ />
77
+ </Box>
78
+ </ZeroTickWrap>
79
+ )
80
+ }
81
+
82
+ /**
83
+ * Wraps a bar track and, when `zeroPct` is set, marks the zero with a tick
84
+ * that overhangs the track. Positive-only, negative-only and all-zero bars
85
+ * pass `undefined` and get no tick.
86
+ */
87
+ export function ZeroTickWrap({
88
+ zeroPct,
89
+ children,
90
+ }: {
91
+ zeroPct: number | undefined
92
+ children: ReactNode
93
+ }) {
94
+ if (zeroPct === undefined) return children
95
+ return (
96
+ <Box sx={styles.barWrap}>
40
97
  <Box
41
- sx={styles.barFill[size]}
42
- style={{ width: `${pct}%`, backgroundColor: color }}
43
- // Tag the fill so an ancestor row's `:hover` selector can target
44
- // it (`&:hover [data-bar-fill]`) — the brightening affordance now
45
- // fires from anywhere in the row, not only the bar track.
46
- data-bar-fill='true'
98
+ aria-hidden
99
+ data-zero-tick='true'
100
+ sx={styles.zeroTick}
101
+ style={{ left: `${zeroPct}%` }}
47
102
  />
103
+ {children}
48
104
  </Box>
49
105
  )
50
106
  }
@@ -16,6 +16,8 @@ export interface CategoryRowMultiProps {
16
16
  colors: readonly CssColor[]
17
17
  /** Shared max across all series + rows (or `maxOverride`). */
18
18
  maxValue: number
19
+ /** Lower end of the shared domain (`0` for positive-only data). */
20
+ minValue?: number
19
21
  formatter: (n: number) => string
20
22
  selected: boolean
21
23
  onToggle: (name: CategoryKey) => void
@@ -41,11 +43,13 @@ export function CategoryRowMulti({
41
43
  values,
42
44
  colors,
43
45
  maxValue,
46
+ minValue,
44
47
  formatter,
45
48
  selected,
46
49
  onToggle,
47
50
  size,
48
51
  }: CategoryRowMultiProps) {
52
+ const min = minValue ?? 0
49
53
  const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
50
54
  if (e.key === 'Enter' || e.key === ' ') {
51
55
  e.preventDefault()
@@ -64,6 +68,21 @@ export function CategoryRowMulti({
64
68
  <Typography variant='body2' sx={styles.multiName}>
65
69
  {displayName ?? name}
66
70
  </Typography>
71
+ {min < 0 && maxValue > 0 && (
72
+ <Box
73
+ aria-hidden
74
+ sx={styles.multiZeroArea}
75
+ // Row 1 is the label; the bars fill the next `values.length` rows.
76
+ // Placed before the bars so their tracks paint over the line.
77
+ style={{ gridRow: `2 / span ${values.length}` }}
78
+ >
79
+ <Box
80
+ data-zero-tick='true'
81
+ sx={styles.zeroTick}
82
+ style={{ left: `${(-min / (maxValue - min)) * 100}%` }}
83
+ />
84
+ </Box>
85
+ )}
67
86
  {values.map((v, i) => (
68
87
  // Multi-bar rows iterate per series — the bar belongs to series `i`,
69
88
  // and the row is keyed on the parent's `name`, so a composite of
@@ -73,8 +92,10 @@ export function CategoryRowMulti({
73
92
  <CategoryBar
74
93
  value={v}
75
94
  maxValue={maxValue}
95
+ minValue={minValue}
76
96
  color={colors[i] ?? ''}
77
97
  size={size}
98
+ zeroTick={false}
78
99
  />
79
100
  </Box>
80
101
  <Typography variant='body2' sx={styles.value}>
@@ -12,6 +12,8 @@ export interface CategoryRowSingleProps {
12
12
  displayName?: string | number
13
13
  value: number
14
14
  maxValue: number
15
+ /** Lower end of the shared domain (`0` for positive-only data). */
16
+ minValue?: number
15
17
  color: CssColor
16
18
  formatter: (n: number) => string
17
19
  selected: boolean
@@ -37,6 +39,7 @@ export function CategoryRowSingle({
37
39
  displayName,
38
40
  value,
39
41
  maxValue,
42
+ minValue,
40
43
  color,
41
44
  formatter,
42
45
  selected,
@@ -69,6 +72,7 @@ export function CategoryRowSingle({
69
72
  <CategoryBar
70
73
  value={value}
71
74
  maxValue={maxValue}
75
+ minValue={minValue}
72
76
  color={color}
73
77
  size={size}
74
78
  />