@carto/ps-react-ui 5.2.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/{actions-CF8RNKd6.js → actions-Dhlz_adK.js} +187 -181
- package/dist/actions-Dhlz_adK.js.map +1 -0
- package/dist/category-C6myUFUJ.js +936 -0
- package/dist/category-C6myUFUJ.js.map +1 -0
- package/dist/legend.js +34 -21
- package/dist/legend.js.map +1 -1
- package/dist/types/legend/stores/types.d.ts +4 -2
- package/dist/types/widgets-v2/actions/searcher/filter.d.ts +3 -2
- 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/wrapper/style.d.ts +31 -2
- package/dist/types/widgets-v2/wrapper/widget-actions.d.ts +2 -2
- package/dist/types/widgets-v2/wrapper/widget-wrapper.d.ts +2 -2
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2.js +342 -295
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +1 -1
- package/src/legend/components/legend-item/legend-item-ui.test.tsx +22 -1
- package/src/legend/components/legend-ramp/legend-ramp-ui.tsx +22 -6
- package/src/legend/stores/types.ts +4 -2
- package/src/widgets-v2/actions/searcher/filter.test.ts +28 -0
- package/src/widgets-v2/actions/searcher/filter.ts +22 -4
- package/src/widgets-v2/actions/searcher/searcher-toggle.tsx +9 -4
- package/src/widgets-v2/actions/searcher/searcher.test.tsx +22 -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/wrapper/style.ts +38 -2
- package/src/widgets-v2/wrapper/widget-actions.tsx +2 -2
- package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +32 -2
- package/src/widgets-v2/wrapper/widget-wrapper.tsx +58 -32
- package/dist/actions-CF8RNKd6.js.map +0 -1
- package/dist/category-D01ruG-l.js +0 -842
- package/dist/category-D01ruG-l.js.map +0 -1
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterBySearchText } from './filter'
|
|
3
|
+
|
|
4
|
+
const DATA = [
|
|
5
|
+
[
|
|
6
|
+
{ name: 'ES', value: 1 },
|
|
7
|
+
{ name: 'FR', value: 2 },
|
|
8
|
+
],
|
|
9
|
+
]
|
|
10
|
+
const COUNTRIES: Record<string, string> = { ES: 'Spain', FR: 'France' }
|
|
11
|
+
const label = (v: string | number) => COUNTRIES[v] ?? v
|
|
12
|
+
|
|
13
|
+
describe('filterBySearchText', () => {
|
|
14
|
+
it('matches the raw name when there is no label formatter', () => {
|
|
15
|
+
expect(filterBySearchText(DATA, 'es')).toEqual([[{ name: 'ES', value: 1 }]])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('matches the formatted label, which is what the user sees', () => {
|
|
19
|
+
expect(filterBySearchText(DATA, 'spa', label)).toEqual([
|
|
20
|
+
[{ name: 'ES', value: 1 }],
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('no longer matches a raw name the label does not show', () => {
|
|
25
|
+
// "es" is in the raw code "ES" but in neither "Spain" nor "France".
|
|
26
|
+
expect(filterBySearchText(DATA, 'es', label)).toEqual([[]])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filters a 2D series of `{name, ...}` records by case-insensitive substring on
|
|
3
|
-
* the
|
|
3
|
+
* the label the user sees: `labelFormatter(name)` when a formatter is given,
|
|
4
|
+
* `name` otherwise. Keeps the dataset shape (one inner array per series).
|
|
4
5
|
* Used by the Searcher action's data transform.
|
|
5
6
|
*/
|
|
6
|
-
export function filterBySearchText(
|
|
7
|
+
export function filterBySearchText(
|
|
8
|
+
input: unknown,
|
|
9
|
+
search: string,
|
|
10
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
11
|
+
): unknown {
|
|
7
12
|
if (!search) return input
|
|
8
13
|
const needle = search.toLowerCase()
|
|
9
14
|
if (!Array.isArray(input)) return input
|
|
@@ -13,14 +18,27 @@ export function filterBySearchText(input: unknown, search: string): unknown {
|
|
|
13
18
|
if (item == null) return false
|
|
14
19
|
if (typeof item === 'object' && 'name' in item) {
|
|
15
20
|
const rawName = (item as { name: unknown }).name
|
|
16
|
-
|
|
17
|
-
return name.toLowerCase().includes(needle)
|
|
21
|
+
return labelOf(rawName, labelFormatter).toLowerCase().includes(needle)
|
|
18
22
|
}
|
|
19
23
|
return stringifyPrimitive(item).toLowerCase().includes(needle)
|
|
20
24
|
})
|
|
21
25
|
})
|
|
22
26
|
}
|
|
23
27
|
|
|
28
|
+
function labelOf(
|
|
29
|
+
name: unknown,
|
|
30
|
+
labelFormatter?: (value: string | number) => string | number,
|
|
31
|
+
): string {
|
|
32
|
+
if (name == null) return ''
|
|
33
|
+
if (
|
|
34
|
+
labelFormatter &&
|
|
35
|
+
(typeof name === 'string' || typeof name === 'number')
|
|
36
|
+
) {
|
|
37
|
+
return stringifyPrimitive(labelFormatter(name))
|
|
38
|
+
}
|
|
39
|
+
return stringifyPrimitive(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
function stringifyPrimitive(v: unknown): string {
|
|
25
43
|
if (typeof v === 'string') return v
|
|
26
44
|
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
|
@@ -46,11 +46,16 @@ export function SearcherToggle({
|
|
|
46
46
|
(s) => (s.transformStates.searcher?.searchText as string | undefined) ?? '',
|
|
47
47
|
)
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// Matched against the label the user sees, so a formatted name is found
|
|
50
|
+
// by what's on screen rather than by its raw value.
|
|
51
|
+
const labelFormatter = useWidget(id, (s) => s.labelFormatter)
|
|
52
|
+
|
|
53
|
+
// The transform fn closes over `text` and `labelFormatter`. Memoize via
|
|
54
|
+
// useCallback so identity only changes when either does, allowing
|
|
55
|
+
// useTransform to re-register.
|
|
51
56
|
const fn = useCallback(
|
|
52
|
-
(data: unknown) => filterBySearchText(data, text),
|
|
53
|
-
[text],
|
|
57
|
+
(data: unknown) => filterBySearchText(data, text, labelFormatter),
|
|
58
|
+
[text, labelFormatter],
|
|
54
59
|
)
|
|
55
60
|
|
|
56
61
|
const { enabled, toggle } = useSingleTransform(id, SEARCHER_DESCRIPTOR, fn, {
|
|
@@ -89,4 +89,26 @@ describe('<Searcher> + <SearcherToggle>', () => {
|
|
|
89
89
|
vi.useRealTimers()
|
|
90
90
|
}
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
it('filters by the formatted label the widget shows', () => {
|
|
94
|
+
const labelFormatter = (v: string | number) =>
|
|
95
|
+
({ apple: 'Manzana', banana: 'Plátano' })[String(v)] ?? v
|
|
96
|
+
render(
|
|
97
|
+
<Provider id='sr-label' data={DATA} labelFormatter={labelFormatter}>
|
|
98
|
+
<SearcherToggle initialEnabled />
|
|
99
|
+
<Searcher />
|
|
100
|
+
</Provider>,
|
|
101
|
+
)
|
|
102
|
+
act(() =>
|
|
103
|
+
getWidgetStore('sr-label').setState((s) => ({
|
|
104
|
+
transformStates: {
|
|
105
|
+
...s.transformStates,
|
|
106
|
+
searcher: { ...s.transformStates.searcher!, searchText: 'manz' },
|
|
107
|
+
},
|
|
108
|
+
})),
|
|
109
|
+
)
|
|
110
|
+
expect(getWidgetStore('sr-label').getState().data).toEqual([
|
|
111
|
+
[{ name: 'apple', value: 1 }],
|
|
112
|
+
])
|
|
113
|
+
})
|
|
92
114
|
})
|
|
@@ -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
|
})
|