@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.
- package/dist/{actions-Dhlz_adK.js → actions-CwjqEm8Y.js} +56 -46
- package/dist/actions-CwjqEm8Y.js.map +1 -0
- package/dist/category-C6myUFUJ.js +936 -0
- package/dist/category-C6myUFUJ.js.map +1 -0
- package/dist/item-color-ZYxH3NM5.js +20 -0
- package/dist/item-color-ZYxH3NM5.js.map +1 -0
- package/dist/types/widgets-v2/bar/types.d.ts +6 -0
- package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
- package/dist/types/widgets-v2/category/category.d.ts +7 -1
- package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
- package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
- package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
- package/dist/types/widgets-v2/category/style.d.ts +33 -6
- package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
- package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
- package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
- package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
- package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
- package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
- package/dist/types/widgets-v2/index.d.ts +1 -1
- package/dist/types/widgets-v2/pie/types.d.ts +6 -0
- package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
- package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
- package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
- package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
- package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
- package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
- package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/bar.js +66 -57
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/histogram.js +103 -71
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/pie.js +98 -78
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +119 -79
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +70 -61
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +2 -2
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +3 -3
- package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
- package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
- package/src/widgets-v2/bar/options.test.ts +106 -0
- package/src/widgets-v2/bar/options.ts +25 -14
- package/src/widgets-v2/bar/types.ts +6 -0
- package/src/widgets-v2/category/category-ui.test.tsx +139 -0
- package/src/widgets-v2/category/category-ui.tsx +64 -11
- package/src/widgets-v2/category/category.test.tsx +67 -0
- package/src/widgets-v2/category/category.tsx +32 -15
- package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
- package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
- package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
- package/src/widgets-v2/category/components/category-bar.tsx +65 -9
- package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
- package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
- package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
- package/src/widgets-v2/category/style.ts +58 -14
- package/src/widgets-v2/histogram/bins.ts +22 -0
- package/src/widgets-v2/histogram/download.test.ts +10 -0
- package/src/widgets-v2/histogram/download.ts +7 -3
- package/src/widgets-v2/histogram/index.ts +3 -0
- package/src/widgets-v2/histogram/options.test.ts +62 -0
- package/src/widgets-v2/histogram/options.ts +38 -17
- package/src/widgets-v2/histogram/transforms.test.ts +21 -7
- package/src/widgets-v2/histogram/transforms.ts +26 -12
- package/src/widgets-v2/histogram/types.ts +25 -5
- package/src/widgets-v2/index.ts +6 -1
- package/src/widgets-v2/pie/options.test.ts +91 -0
- package/src/widgets-v2/pie/options.ts +50 -20
- package/src/widgets-v2/pie/types.ts +6 -0
- package/src/widgets-v2/scatterplot/download.test.ts +9 -0
- package/src/widgets-v2/scatterplot/download.ts +5 -3
- package/src/widgets-v2/scatterplot/index.ts +3 -0
- package/src/widgets-v2/scatterplot/options.test.ts +78 -0
- package/src/widgets-v2/scatterplot/options.ts +35 -11
- package/src/widgets-v2/scatterplot/points.ts +35 -0
- package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
- package/src/widgets-v2/scatterplot/transforms.ts +23 -18
- package/src/widgets-v2/scatterplot/types.ts +25 -2
- package/src/widgets-v2/timeseries/options.test.ts +65 -0
- package/src/widgets-v2/timeseries/options.ts +31 -14
- package/src/widgets-v2/timeseries/types.ts +6 -0
- package/src/widgets-v2/utils/item-color.test.ts +74 -0
- package/src/widgets-v2/utils/item-color.ts +55 -0
- package/dist/actions-Dhlz_adK.js.map +0 -1
- package/dist/category-D01ruG-l.js +0 -842
- 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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
122
|
-
// across data transforms (e.g., the Searcher filtering
|
|
123
|
-
// Consumer's explicit value
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const
|
|
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
|
|
52
|
+
let pos = 0
|
|
53
|
+
let neg = 0
|
|
44
54
|
for (const v of values) {
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
<
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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={
|
|
109
|
+
maxValue={scale}
|
|
110
|
+
zeroPct={zeroPct}
|
|
88
111
|
size={size}
|
|
89
112
|
/>
|
|
90
113
|
<Box sx={styles.stackedBreakdown}>
|