@carto/ps-react-ui 5.3.0 → 5.4.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/category-C6myUFUJ.js +936 -0
- package/dist/category-C6myUFUJ.js.map +1 -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/widgets-v2/category.js +1 -1
- package/dist/widgets-v2.js +1 -1
- package/package.json +1 -1
- 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/dist/category-D01ruG-l.js +0 -842
- package/dist/category-D01ruG-l.js.map +0 -1
package/dist/widgets-v2.js
CHANGED
|
@@ -11,7 +11,7 @@ import { i as ee, r as C } from "./formula-BIxJr1P3.js";
|
|
|
11
11
|
import { i as w, r as T } from "./markdown-oo9mnJun.js";
|
|
12
12
|
import { a as E, i as D } from "./spread-CgCTKPQD.js";
|
|
13
13
|
import { n as O, r as k } from "./range-BCMAl5xf.js";
|
|
14
|
-
import { i as A, r as te } from "./category-
|
|
14
|
+
import { i as A, r as te } from "./category-C6myUFUJ.js";
|
|
15
15
|
import { d as j, i as M } from "./table-lb5momun.js";
|
|
16
16
|
import { c as N } from "react/compiler-runtime";
|
|
17
17
|
import { Children as ne, Component as P, isValidElement as F, useEffect as I, useLayoutEffect as re, useRef as L, useState as R } from "react";
|
package/package.json
CHANGED
|
@@ -906,4 +906,143 @@ describe('<CategoryUI>', () => {
|
|
|
906
906
|
fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
|
|
907
907
|
})
|
|
908
908
|
})
|
|
909
|
+
|
|
910
|
+
// ── Signed values ──────────────────────────────────────────────────
|
|
911
|
+
describe('negative values', () => {
|
|
912
|
+
const MIXED = [
|
|
913
|
+
[
|
|
914
|
+
{ name: 'Income', value: 0.8 },
|
|
915
|
+
{ name: 'Transit', value: -0.4 },
|
|
916
|
+
{ name: 'Size', value: 0 },
|
|
917
|
+
],
|
|
918
|
+
]
|
|
919
|
+
const fills = (c: HTMLElement) =>
|
|
920
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
|
|
921
|
+
const ticks = (c: HTMLElement) =>
|
|
922
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
|
|
923
|
+
|
|
924
|
+
it('centres the zero with a symmetric domain and keeps the series color', () => {
|
|
925
|
+
const { container } = render(<CategoryUI data={MIXED} />)
|
|
926
|
+
const [income, transit, size] = fills(container)
|
|
927
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
|
|
928
|
+
expect([transit!.style.left, transit!.style.width]).toEqual([
|
|
929
|
+
'25%',
|
|
930
|
+
'25%',
|
|
931
|
+
])
|
|
932
|
+
expect(size!.style.width).toBe('0%')
|
|
933
|
+
expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('marks the zero with one tick per bar, none on "Others"', () => {
|
|
937
|
+
const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
|
|
938
|
+
expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
|
|
939
|
+
expect(screen.getByText('Others')).toBeTruthy()
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('draws one zero line per multi-series row, spanning every bar', () => {
|
|
943
|
+
const { container } = render(
|
|
944
|
+
<CategoryUI
|
|
945
|
+
data={[
|
|
946
|
+
[
|
|
947
|
+
{ name: 'A', value: 1 },
|
|
948
|
+
{ name: 'B', value: 2 },
|
|
949
|
+
],
|
|
950
|
+
[
|
|
951
|
+
{ name: 'A', value: -1 },
|
|
952
|
+
{ name: 'B', value: 0.5 },
|
|
953
|
+
],
|
|
954
|
+
[
|
|
955
|
+
{ name: 'A', value: 0 },
|
|
956
|
+
{ name: 'B', value: -2 },
|
|
957
|
+
],
|
|
958
|
+
]}
|
|
959
|
+
/>,
|
|
960
|
+
)
|
|
961
|
+
const t = ticks(container)
|
|
962
|
+
expect(t).toHaveLength(2)
|
|
963
|
+
expect(t[0]!.style.left).toBe('50%')
|
|
964
|
+
expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it.each([
|
|
968
|
+
['positive-only', [1, 2]],
|
|
969
|
+
['negative-only', [-1, -2]],
|
|
970
|
+
['all-zero', [0, 0]],
|
|
971
|
+
])('draws no zero tick for %s data', (_, values) => {
|
|
972
|
+
const { container } = render(
|
|
973
|
+
<CategoryUI
|
|
974
|
+
data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
|
|
975
|
+
/>,
|
|
976
|
+
)
|
|
977
|
+
expect(ticks(container)).toHaveLength(0)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('hangs negative-only bars from the right edge', () => {
|
|
981
|
+
const { container } = render(
|
|
982
|
+
<CategoryUI
|
|
983
|
+
data={[
|
|
984
|
+
[
|
|
985
|
+
{ name: 'A', value: -2 },
|
|
986
|
+
{ name: 'B', value: -1 },
|
|
987
|
+
],
|
|
988
|
+
]}
|
|
989
|
+
/>,
|
|
990
|
+
)
|
|
991
|
+
const [a, b] = fills(container)
|
|
992
|
+
expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
|
|
993
|
+
expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('treats maxOverride as a magnitude for signed data', () => {
|
|
997
|
+
const { container } = render(
|
|
998
|
+
<CategoryUI data={MIXED} maxOverride={1.6} />,
|
|
999
|
+
)
|
|
1000
|
+
const [income, transit] = fills(container)
|
|
1001
|
+
// domain [-1.6, 1.6]
|
|
1002
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
|
|
1003
|
+
expect(transit!.style.width).toBe('12.5%')
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('minOverride pins the zero even when the data has no negatives', () => {
|
|
1007
|
+
const { container } = render(
|
|
1008
|
+
<CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
|
|
1009
|
+
)
|
|
1010
|
+
expect(fills(container)[0]!.style.left).toBe('50%')
|
|
1011
|
+
expect(ticks(container)).toHaveLength(1)
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
it('prefixes U+2212 itself and hands the formatter |v|', () => {
|
|
1015
|
+
const formatter = vi.fn((n: number) => `$${n}`)
|
|
1016
|
+
render(
|
|
1017
|
+
<CategoryUI
|
|
1018
|
+
data={[
|
|
1019
|
+
[
|
|
1020
|
+
{ name: 'A', value: -0.5 },
|
|
1021
|
+
{ name: 'B', value: -0 },
|
|
1022
|
+
{ name: 'C', value: 0 },
|
|
1023
|
+
],
|
|
1024
|
+
]}
|
|
1025
|
+
formatter={formatter}
|
|
1026
|
+
/>,
|
|
1027
|
+
)
|
|
1028
|
+
expect(screen.getByText('\u2212$0.5')).toBeTruthy()
|
|
1029
|
+
expect(screen.getAllByText('$0')).toHaveLength(2)
|
|
1030
|
+
formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('stacked mode diverges around the shared zero and shows the signed total', () => {
|
|
1034
|
+
const { container } = render(
|
|
1035
|
+
<CategoryUI
|
|
1036
|
+
stacked
|
|
1037
|
+
data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
|
|
1038
|
+
/>,
|
|
1039
|
+
)
|
|
1040
|
+
const [pos, neg] = fills(container)
|
|
1041
|
+
// zero at 50%, longer side (3) fills its half
|
|
1042
|
+
expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
|
|
1043
|
+
expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
|
|
1044
|
+
expect(screen.getByText('2')).toBeTruthy()
|
|
1045
|
+
expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
|
|
1046
|
+
})
|
|
1047
|
+
})
|
|
909
1048
|
})
|
|
@@ -78,8 +78,16 @@ export interface CategoryUIProps {
|
|
|
78
78
|
* overflow row stays a static summary.
|
|
79
79
|
*/
|
|
80
80
|
onShowAll?: () => void
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Manual override for the top of the bar scale. With signed data it is a
|
|
83
|
+
* magnitude: the scale stays symmetric around the centred zero.
|
|
84
|
+
*/
|
|
82
85
|
maxOverride?: number
|
|
86
|
+
/**
|
|
87
|
+
* Manual override for the bottom of the bar scale, the negative mirror of
|
|
88
|
+
* `maxOverride`. Ignored unless negative.
|
|
89
|
+
*/
|
|
90
|
+
minOverride?: number
|
|
83
91
|
/**
|
|
84
92
|
* Visual density of the bar primitive. `'small'` (default) keeps the
|
|
85
93
|
* historical 4px-tall pill; `'medium'` switches to a 12px-tall track
|
|
@@ -161,6 +169,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
|
|
|
161
169
|
return order.map((k) => grouped.get(k)!)
|
|
162
170
|
}
|
|
163
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
|
|
174
|
+
* the zero at the left edge and negative-only at the right; mixed data is
|
|
175
|
+
* made symmetric so the zero sits in the middle of the track.
|
|
176
|
+
*/
|
|
177
|
+
function signedDomain(
|
|
178
|
+
rows: readonly GroupedRow[],
|
|
179
|
+
minOverride?: number,
|
|
180
|
+
maxOverride?: number,
|
|
181
|
+
): [lo: number, hi: number] {
|
|
182
|
+
let lo = 0
|
|
183
|
+
let hi = 0
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
for (const v of row.values) {
|
|
186
|
+
if (v < lo) lo = v
|
|
187
|
+
if (v > hi) hi = v
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
|
|
191
|
+
if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
|
|
192
|
+
if (lo < 0 && hi > 0) {
|
|
193
|
+
const m = Math.max(-lo, hi)
|
|
194
|
+
return [-m, m]
|
|
195
|
+
}
|
|
196
|
+
return [lo, hi]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
|
|
201
|
+
* that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
|
|
202
|
+
* `-` cannot double it. U+2212 is as wide as a tabular digit.
|
|
203
|
+
*/
|
|
204
|
+
function formatSigned(fmt: (n: number) => string, v: number): string {
|
|
205
|
+
return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
|
|
206
|
+
}
|
|
207
|
+
|
|
164
208
|
/**
|
|
165
209
|
* Pure presentational component for the Category widget. Renders
|
|
166
210
|
* single-series rows (`data.length === 1`) as a compact grid or
|
|
@@ -187,13 +231,15 @@ export function CategoryUI({
|
|
|
187
231
|
labels,
|
|
188
232
|
onShowAll,
|
|
189
233
|
maxOverride,
|
|
234
|
+
minOverride,
|
|
190
235
|
size = 'small',
|
|
191
236
|
stacked = false,
|
|
192
237
|
hiddenSeries: hiddenSeriesProp,
|
|
193
238
|
onHiddenSeriesChange,
|
|
194
239
|
}: CategoryUIProps) {
|
|
195
240
|
const theme = useTheme()
|
|
196
|
-
const
|
|
241
|
+
const baseFmt = formatter ?? ((n: number) => String(n))
|
|
242
|
+
const fmt = (n: number) => formatSigned(baseFmt, n)
|
|
197
243
|
|
|
198
244
|
const hasSelection = !!selection && selection.length > 0
|
|
199
245
|
const selectionSet = useMemo(
|
|
@@ -245,14 +291,14 @@ export function CategoryUI({
|
|
|
245
291
|
|
|
246
292
|
const grouped = useMemo(() => generateGroupedData(data), [data])
|
|
247
293
|
|
|
248
|
-
const maxValue = useMemo(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
294
|
+
const [minValue, maxValue] = useMemo(
|
|
295
|
+
() => signedDomain(grouped, minOverride, maxOverride),
|
|
296
|
+
[grouped, minOverride, maxOverride],
|
|
297
|
+
)
|
|
298
|
+
// Where the zero sits on the track, handed to stacked rows so their zero
|
|
299
|
+
// matches the other rows'. Positive-only data keeps it at the left edge.
|
|
300
|
+
const zeroPct =
|
|
301
|
+
maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
|
|
256
302
|
|
|
257
303
|
// Three modes:
|
|
258
304
|
// * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
|
|
@@ -382,7 +428,11 @@ export function CategoryUI({
|
|
|
382
428
|
<Box sx={styles.root}>
|
|
383
429
|
<Box
|
|
384
430
|
ref={listRefCallback}
|
|
385
|
-
sx={
|
|
431
|
+
sx={{
|
|
432
|
+
...styles.list,
|
|
433
|
+
...(isMulti && !stacked && styles.listMulti),
|
|
434
|
+
...(scrollMode && styles.listScroll),
|
|
435
|
+
}}
|
|
386
436
|
style={
|
|
387
437
|
scrollMaxHeight !== undefined
|
|
388
438
|
? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
|
|
@@ -432,6 +482,7 @@ export function CategoryUI({
|
|
|
432
482
|
selected={selected}
|
|
433
483
|
onToggle={toggle}
|
|
434
484
|
size={size}
|
|
485
|
+
zeroPct={zeroPct}
|
|
435
486
|
/>
|
|
436
487
|
)
|
|
437
488
|
}
|
|
@@ -443,6 +494,7 @@ export function CategoryUI({
|
|
|
443
494
|
values={visibleIdx.map((i) => row.values[i]!)}
|
|
444
495
|
colors={visibleIdx.map((i) => fill(i))}
|
|
445
496
|
maxValue={maxValue}
|
|
497
|
+
minValue={minValue}
|
|
446
498
|
formatter={fmt}
|
|
447
499
|
selected={selected}
|
|
448
500
|
onToggle={toggle}
|
|
@@ -457,6 +509,7 @@ export function CategoryUI({
|
|
|
457
509
|
displayName={displayName}
|
|
458
510
|
value={row.values[0] ?? 0}
|
|
459
511
|
maxValue={maxValue}
|
|
512
|
+
minValue={minValue}
|
|
460
513
|
color={fill(0)}
|
|
461
514
|
formatter={fmt}
|
|
462
515
|
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}>
|
|
@@ -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
|
})
|