@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.
- package/dist/{actions-BWlS5tpP.js → actions-CkxKe8-J.js} +57 -47
- package/dist/actions-CkxKe8-J.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/{option-builders-DQivvvbb.js → item-color-B7xkIpDZ.js} +26 -9
- package/dist/item-color-B7xkIpDZ.js.map +1 -0
- 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/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/bar/types.d.ts +6 -0
- 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/types/widgets/histogram/bins.d.ts +7 -0
- package/dist/types/widgets/histogram/download.d.ts +2 -2
- package/dist/types/widgets/histogram/index.d.ts +2 -1
- package/dist/types/widgets/histogram/options.d.ts +1 -1
- package/dist/types/widgets/histogram/transforms.d.ts +5 -4
- package/dist/types/widgets/histogram/types.d.ts +22 -5
- package/dist/types/widgets/index.d.ts +1 -1
- package/dist/types/widgets/pie/types.d.ts +6 -0
- package/dist/types/widgets/scatterplot/download.d.ts +2 -2
- package/dist/types/widgets/scatterplot/index.d.ts +2 -1
- package/dist/types/widgets/scatterplot/points.d.ts +7 -0
- package/dist/types/widgets/scatterplot/transforms.d.ts +4 -3
- package/dist/types/widgets/scatterplot/types.d.ts +21 -2
- package/dist/types/widgets/timeseries/types.d.ts +6 -0
- package/dist/types/widgets/utils/item-color.d.ts +26 -0
- package/dist/utils-C-eWwOhE.js.map +1 -1
- package/dist/widgets/actions.js +1 -1
- package/dist/widgets/bar.js +78 -70
- 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 +116 -85
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/markdown.js +2 -2
- package/dist/widgets/pie.js +109 -90
- package/dist/widgets/pie.js.map +1 -1
- package/dist/widgets/scatterplot.js +131 -92
- package/dist/widgets/scatterplot.js.map +1 -1
- package/dist/widgets/timeseries.js +82 -74
- 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/actions/fullscreen/fullscreen-store.test.tsx +77 -13
- package/src/widgets/actions/fullscreen/fullscreen.tsx +48 -5
- package/src/widgets/bar/options.test.ts +106 -0
- package/src/widgets/bar/options.ts +25 -14
- package/src/widgets/bar/types.ts +6 -0
- 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/src/widgets/histogram/bins.ts +22 -0
- package/src/widgets/histogram/download.test.ts +10 -0
- package/src/widgets/histogram/download.ts +7 -3
- package/src/widgets/histogram/index.ts +3 -0
- package/src/widgets/histogram/options.test.ts +62 -0
- package/src/widgets/histogram/options.ts +38 -17
- package/src/widgets/histogram/transforms.test.ts +21 -7
- package/src/widgets/histogram/transforms.ts +26 -12
- package/src/widgets/histogram/types.ts +25 -5
- package/src/widgets/index.ts +6 -1
- package/src/widgets/pie/options.test.ts +91 -0
- package/src/widgets/pie/options.ts +50 -20
- package/src/widgets/pie/types.ts +6 -0
- package/src/widgets/scatterplot/download.test.ts +9 -0
- package/src/widgets/scatterplot/download.ts +5 -3
- package/src/widgets/scatterplot/index.ts +3 -0
- package/src/widgets/scatterplot/options.test.ts +78 -0
- package/src/widgets/scatterplot/options.ts +35 -11
- package/src/widgets/scatterplot/points.ts +35 -0
- package/src/widgets/scatterplot/transforms.test.ts +19 -0
- package/src/widgets/scatterplot/transforms.ts +23 -18
- package/src/widgets/scatterplot/types.ts +25 -2
- package/src/widgets/timeseries/options.test.ts +65 -0
- package/src/widgets/timeseries/options.ts +31 -14
- package/src/widgets/timeseries/types.ts +6 -0
- package/src/widgets/utils/item-color.test.ts +74 -0
- package/src/widgets/utils/item-color.ts +55 -0
- 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
- 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
|
-
/**
|
|
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
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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={
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
|
121
|
-
// across data transforms (e.g., the Searcher filtering
|
|
122
|
-
// Consumer's explicit value
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
/>
|