@carto/ps-react-ui 6.1.1 → 6.2.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-BWlS5tpP.js → actions-CueYrfVr.js} +2 -2
- package/dist/actions-CueYrfVr.js.map +1 -0
- package/dist/category-DULnoxvk.js +933 -0
- package/dist/category-DULnoxvk.js.map +1 -0
- package/dist/chat.js +1 -1
- package/dist/chat.js.map +1 -1
- package/dist/components-CTUpdQuj.js.map +1 -1
- package/dist/download-B4sykOFm.js.map +1 -1
- package/dist/{echart-CV8F50gn.js → echart-ysYFLa_T.js} +2 -2
- package/dist/echart-ysYFLa_T.js.map +1 -0
- package/dist/formula-DJS-JLpk.js.map +1 -1
- package/dist/legend.js.map +1 -1
- package/dist/{markdown-Bn2nKCbn.js → markdown-DmvLi3vx.js} +2 -2
- package/dist/markdown-DmvLi3vx.js.map +1 -0
- package/dist/{markdown-content-kYNLOX0T.js → markdown-content-BpAH_V6S.js} +5 -4
- package/dist/markdown-content-BpAH_V6S.js.map +1 -0
- package/dist/option-builders-DQivvvbb.js.map +1 -1
- package/dist/range-BpU2zBmq.js.map +1 -1
- package/dist/smart-tooltip-Dc_rHhyI.js.map +1 -1
- package/dist/spread-CQwFIwuz.js.map +1 -1
- package/dist/stores-DcBOZlU3.js.map +1 -1
- package/dist/stores-mad-9yFn.js.map +1 -1
- package/dist/styles-BCEBm7xh.js.map +1 -1
- package/dist/table-DEdW_fDj.js.map +1 -1
- package/dist/types/widgets/category/category-ui.d.ts +10 -2
- package/dist/types/widgets/category/category.d.ts +7 -1
- package/dist/types/widgets/category/components/category-bar-stacked.d.ts +8 -1
- package/dist/types/widgets/category/components/category-bar.d.ts +24 -2
- package/dist/types/widgets/category/components/category-row-multi.d.ts +3 -1
- package/dist/types/widgets/category/components/category-row-single.d.ts +3 -1
- package/dist/types/widgets/category/components/category-row-stacked.d.ts +10 -1
- package/dist/types/widgets/category/style.d.ts +33 -6
- package/dist/utils-C-eWwOhE.js.map +1 -1
- package/dist/widgets/actions.js +1 -1
- package/dist/widgets/bar.js.map +1 -1
- package/dist/widgets/category.js +1 -1
- package/dist/widgets/echart.js +1 -1
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/markdown.js +2 -2
- package/dist/widgets/pie.js +2 -2
- package/dist/widgets/pie.js.map +1 -1
- package/dist/widgets/scatterplot.js.map +1 -1
- package/dist/widgets/timeseries.js.map +1 -1
- package/dist/widgets.js +5 -5
- package/dist/widgets.js.map +1 -1
- package/dist/zoom-toggle-MmLjMTEw.js.map +1 -1
- package/package.json +2 -2
- package/src/widgets/category/category-ui.test.tsx +139 -0
- package/src/widgets/category/category-ui.tsx +64 -11
- package/src/widgets/category/category.test.tsx +67 -0
- package/src/widgets/category/category.tsx +32 -15
- package/src/widgets/category/components/category-bar-stacked.test.tsx +18 -0
- package/src/widgets/category/components/category-bar-stacked.tsx +42 -29
- package/src/widgets/category/components/category-bar.test.tsx +66 -0
- package/src/widgets/category/components/category-bar.tsx +65 -9
- package/src/widgets/category/components/category-row-multi.tsx +21 -0
- package/src/widgets/category/components/category-row-single.tsx +4 -0
- package/src/widgets/category/components/category-row-stacked.tsx +24 -1
- package/src/widgets/category/style.ts +58 -14
- package/dist/actions-BWlS5tpP.js.map +0 -1
- package/dist/category-alzjKzvQ.js +0 -839
- package/dist/category-alzjKzvQ.js.map +0 -1
- package/dist/echart-CV8F50gn.js.map +0 -1
- package/dist/markdown-Bn2nKCbn.js.map +0 -1
- package/dist/markdown-content-kYNLOX0T.js.map +0 -1
|
@@ -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
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
const
|
|
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
|
|
53
|
+
let pos = 0
|
|
54
|
+
let neg = 0
|
|
45
55
|
for (const v of values) {
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
<
|
|
52
|
-
{
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
/>
|
|
@@ -26,6 +26,11 @@ export interface CategoryRowStackedProps {
|
|
|
26
26
|
onToggle: (name: CategoryKey) => void
|
|
27
27
|
/** Bar visual density. Forwarded to the inner {@link CategoryBarStacked}. */
|
|
28
28
|
size?: CategorySize
|
|
29
|
+
/**
|
|
30
|
+
* Where the list's zero sits (% of the track), shared by every row so the
|
|
31
|
+
* zero lines up: `0` positive-only, `50` signed, `100` negative-only.
|
|
32
|
+
*/
|
|
33
|
+
zeroPct?: number
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -41,6 +46,10 @@ export interface CategoryRowStackedProps {
|
|
|
41
46
|
* total relative to other rows. The total numeric value is still shown in
|
|
42
47
|
* the header so the per-row magnitude isn't lost.
|
|
43
48
|
*
|
|
49
|
+
* Signed data diverges from `zeroPct`: positives stack rightwards and
|
|
50
|
+
* negatives leftwards, and whichever side is longer reaches its edge of the
|
|
51
|
+
* track. The header total is the signed sum.
|
|
52
|
+
*
|
|
44
53
|
* Visual selection / dim signal lives entirely in the segment colors
|
|
45
54
|
* (`colors` prop) — every series in a dimmed row receives
|
|
46
55
|
* `theme.palette.action.disabled`. The bar visually collapses to a single
|
|
@@ -57,6 +66,7 @@ export function CategoryRowStacked({
|
|
|
57
66
|
selected,
|
|
58
67
|
onToggle,
|
|
59
68
|
size,
|
|
69
|
+
zeroPct = 0,
|
|
60
70
|
}: CategoryRowStackedProps) {
|
|
61
71
|
const handleKey = (e: KeyboardEvent<HTMLDivElement>): void => {
|
|
62
72
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
@@ -65,6 +75,18 @@ export function CategoryRowStacked({
|
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
const total = values.reduce((a, b) => a + b, 0)
|
|
78
|
+
let pos = 0
|
|
79
|
+
let neg = 0
|
|
80
|
+
for (const v of values) {
|
|
81
|
+
if (v > 0) pos += v
|
|
82
|
+
else neg -= v
|
|
83
|
+
}
|
|
84
|
+
// Magnitude that would fill the whole track, picked so the longer side
|
|
85
|
+
// just reaches its edge: `pos` spans `100 - zeroPct`, `neg` spans `zeroPct`.
|
|
86
|
+
const scale = Math.max(
|
|
87
|
+
zeroPct < 100 ? (pos * 100) / (100 - zeroPct) : 0,
|
|
88
|
+
zeroPct > 0 ? (neg * 100) / zeroPct : 0,
|
|
89
|
+
)
|
|
68
90
|
return (
|
|
69
91
|
<Box
|
|
70
92
|
role='button'
|
|
@@ -85,7 +107,8 @@ export function CategoryRowStacked({
|
|
|
85
107
|
<CategoryBarStacked
|
|
86
108
|
values={values}
|
|
87
109
|
colors={colors}
|
|
88
|
-
maxValue={
|
|
110
|
+
maxValue={scale}
|
|
111
|
+
zeroPct={zeroPct}
|
|
89
112
|
size={size}
|
|
90
113
|
/>
|
|
91
114
|
<Box sx={styles.stackedBreakdown}>
|
|
@@ -28,17 +28,19 @@ const bar: Record<CategorySize, SxProps<Theme>> = {
|
|
|
28
28
|
const barFill: Record<CategorySize, SxProps<Theme>> = {
|
|
29
29
|
small: {
|
|
30
30
|
position: 'absolute',
|
|
31
|
-
|
|
31
|
+
top: 0,
|
|
32
|
+
bottom: 0,
|
|
32
33
|
borderRadius: 1,
|
|
33
34
|
transition:
|
|
34
|
-
'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
|
|
35
|
+
'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
|
|
35
36
|
},
|
|
36
37
|
medium: {
|
|
37
38
|
position: 'absolute',
|
|
38
|
-
|
|
39
|
+
top: 0,
|
|
40
|
+
bottom: 0,
|
|
39
41
|
borderRadius: 0.25,
|
|
40
42
|
transition:
|
|
41
|
-
'width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
|
|
43
|
+
'left 300ms ease-in-out, width 300ms ease-in-out, background-color 150ms ease, filter 150ms ease',
|
|
42
44
|
},
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -71,13 +73,47 @@ export const styles = {
|
|
|
71
73
|
position: 'relative',
|
|
72
74
|
width: '100%',
|
|
73
75
|
},
|
|
74
|
-
// Flat list of category rows.
|
|
76
|
+
// Flat list of category rows. A grid rather than a flex column so the
|
|
77
|
+
// multi-series rows can share one value column through `subgrid`: with
|
|
78
|
+
// every bar track the same width, the zero ticks line up across rows.
|
|
75
79
|
list: {
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
position: 'relative',
|
|
81
|
+
display: 'grid',
|
|
82
|
+
gridTemplateColumns: 'minmax(0, 1fr)',
|
|
78
83
|
gap: 1,
|
|
79
84
|
py: 0.5,
|
|
80
85
|
},
|
|
86
|
+
// Layered on `list` for unstacked multi-series: bars | values.
|
|
87
|
+
listMulti: {
|
|
88
|
+
gridTemplateColumns: 'minmax(0, 1fr) max-content',
|
|
89
|
+
},
|
|
90
|
+
// Zero tick for signed data: one per bar track, drawn outside the track's
|
|
91
|
+
// `overflow: hidden` so it can stand 6px proud of it on both sides.
|
|
92
|
+
// Tracks share a width (see `listMulti`), so the ticks line up by rows.
|
|
93
|
+
barWrap: {
|
|
94
|
+
position: 'relative',
|
|
95
|
+
},
|
|
96
|
+
// Multi-series rows draw one zero line through every series bar instead
|
|
97
|
+
// of a tick per bar: this box is the row's bar column (grid area set
|
|
98
|
+
// inline) and holds a `zeroTick`, so the line stays out of the label.
|
|
99
|
+
multiZeroArea: {
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
inset: 0,
|
|
102
|
+
gridColumn: '1 / 2',
|
|
103
|
+
pointerEvents: 'none',
|
|
104
|
+
},
|
|
105
|
+
zeroTick: {
|
|
106
|
+
position: 'absolute',
|
|
107
|
+
top: -6,
|
|
108
|
+
bottom: -6,
|
|
109
|
+
width: '1px',
|
|
110
|
+
// Centred on the zero so both halves of the bar meet it evenly. No
|
|
111
|
+
// z-index: it comes before the track in the DOM, so the bar paints over
|
|
112
|
+
// it and only the overhang shows.
|
|
113
|
+
transform: 'translateX(-50%)',
|
|
114
|
+
bgcolor: 'text.disabled',
|
|
115
|
+
pointerEvents: 'none',
|
|
116
|
+
},
|
|
81
117
|
// Layered on `list` only in scroll mode (`maxItems === 0`): right padding
|
|
82
118
|
// so the bars + right-aligned values clear the scrollbar. Right side only
|
|
83
119
|
// — the left edge stays at 0 so content stays aligned with the capped
|
|
@@ -91,6 +127,7 @@ export const styles = {
|
|
|
91
127
|
// bar fills the row width below. Mirrors `rowMulti` for visual
|
|
92
128
|
// consistency.
|
|
93
129
|
rowSingle: {
|
|
130
|
+
gridColumn: '1 / -1',
|
|
94
131
|
display: 'flex',
|
|
95
132
|
flexDirection: 'column',
|
|
96
133
|
gap: 0.5,
|
|
@@ -124,10 +161,14 @@ export const styles = {
|
|
|
124
161
|
// Flex column: label fills the top row; bar+value pairs stack below.
|
|
125
162
|
// Bars get full track width — better legibility for N>=2 series than a
|
|
126
163
|
// shared grid would give.
|
|
164
|
+
// Spans the list and adopts its columns, so every row's value column is
|
|
165
|
+
// as wide as the widest value in the list.
|
|
127
166
|
rowMulti: {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
position: 'relative',
|
|
168
|
+
gridColumn: '1 / -1',
|
|
169
|
+
display: 'grid',
|
|
170
|
+
gridTemplateColumns: 'subgrid',
|
|
171
|
+
rowGap: 0.5,
|
|
131
172
|
// py: 0.5,
|
|
132
173
|
// px: 0.5,
|
|
133
174
|
cursor: 'pointer',
|
|
@@ -155,6 +196,7 @@ export const styles = {
|
|
|
155
196
|
fontWeight: 'medium',
|
|
156
197
|
},
|
|
157
198
|
multiName: {
|
|
199
|
+
gridColumn: '1 / -1',
|
|
158
200
|
overflow: 'hidden',
|
|
159
201
|
whiteSpace: 'nowrap',
|
|
160
202
|
textOverflow: 'ellipsis',
|
|
@@ -171,6 +213,7 @@ export const styles = {
|
|
|
171
213
|
// minWidth here is also harmless in that context.
|
|
172
214
|
minWidth: 48,
|
|
173
215
|
textAlign: 'right',
|
|
216
|
+
alignSelf: 'center',
|
|
174
217
|
},
|
|
175
218
|
|
|
176
219
|
// ── Bar (track + fill) ────────────────────────────────────────────
|
|
@@ -184,13 +227,12 @@ export const styles = {
|
|
|
184
227
|
|
|
185
228
|
// ── Multi-series internals ────────────────────────────────────────
|
|
186
229
|
// One [bar | value] flex row per series, inside `rowMulti`'s column.
|
|
230
|
+
// `contents` hands the bar and the value to `rowMulti`'s subgrid cells.
|
|
187
231
|
multiBarRow: {
|
|
188
|
-
display: '
|
|
189
|
-
alignItems: 'center',
|
|
190
|
-
gap: 1,
|
|
232
|
+
display: 'contents',
|
|
191
233
|
},
|
|
192
234
|
multiBarContainer: {
|
|
193
|
-
|
|
235
|
+
alignSelf: 'center',
|
|
194
236
|
},
|
|
195
237
|
|
|
196
238
|
// ── Stacked-mode row ──────────────────────────────────────────────
|
|
@@ -198,6 +240,7 @@ export const styles = {
|
|
|
198
240
|
// focus ring) but stack three children vertically: header line, bar,
|
|
199
241
|
// breakdown line.
|
|
200
242
|
rowStacked: {
|
|
243
|
+
gridColumn: '1 / -1',
|
|
201
244
|
display: 'flex',
|
|
202
245
|
flexDirection: 'column',
|
|
203
246
|
gap: 0.5,
|
|
@@ -285,6 +328,7 @@ export const styles = {
|
|
|
285
328
|
// (no parentheses). Only the label is the click target when interactive;
|
|
286
329
|
// the count is always static text beside it.
|
|
287
330
|
otherRow: {
|
|
331
|
+
gridColumn: '1 / -1',
|
|
288
332
|
display: 'flex',
|
|
289
333
|
alignItems: 'center',
|
|
290
334
|
gap: 1,
|