@carto/ps-react-ui 4.23.0 → 4.25.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-D7LB9DvT.js → actions-B1ss030_.js} +187 -181
- package/dist/actions-B1ss030_.js.map +1 -0
- package/dist/category-DuKx_CJX.js +933 -0
- package/dist/category-DuKx_CJX.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 +6 -11
- 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-D7LB9DvT.js.map +0 -1
- package/dist/category-Ca5XDx0N.js +0 -839
- package/dist/category-Ca5XDx0N.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
|
})
|
|
@@ -814,4 +814,143 @@ describe('<CategoryUI>', () => {
|
|
|
814
814
|
fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
|
|
815
815
|
})
|
|
816
816
|
})
|
|
817
|
+
|
|
818
|
+
// ── Signed values ──────────────────────────────────────────────────
|
|
819
|
+
describe('negative values', () => {
|
|
820
|
+
const MIXED = [
|
|
821
|
+
[
|
|
822
|
+
{ name: 'Income', value: 0.8 },
|
|
823
|
+
{ name: 'Transit', value: -0.4 },
|
|
824
|
+
{ name: 'Size', value: 0 },
|
|
825
|
+
],
|
|
826
|
+
]
|
|
827
|
+
const fills = (c: HTMLElement) =>
|
|
828
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
|
|
829
|
+
const ticks = (c: HTMLElement) =>
|
|
830
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
|
|
831
|
+
|
|
832
|
+
it('centres the zero with a symmetric domain and keeps the series color', () => {
|
|
833
|
+
const { container } = render(<CategoryUI data={MIXED} />)
|
|
834
|
+
const [income, transit, size] = fills(container)
|
|
835
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
|
|
836
|
+
expect([transit!.style.left, transit!.style.width]).toEqual([
|
|
837
|
+
'25%',
|
|
838
|
+
'25%',
|
|
839
|
+
])
|
|
840
|
+
expect(size!.style.width).toBe('0%')
|
|
841
|
+
expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('marks the zero with one tick per bar, none on "Others"', () => {
|
|
845
|
+
const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
|
|
846
|
+
expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
|
|
847
|
+
expect(screen.getByText('Others')).toBeTruthy()
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('draws one zero line per multi-series row, spanning every bar', () => {
|
|
851
|
+
const { container } = render(
|
|
852
|
+
<CategoryUI
|
|
853
|
+
data={[
|
|
854
|
+
[
|
|
855
|
+
{ name: 'A', value: 1 },
|
|
856
|
+
{ name: 'B', value: 2 },
|
|
857
|
+
],
|
|
858
|
+
[
|
|
859
|
+
{ name: 'A', value: -1 },
|
|
860
|
+
{ name: 'B', value: 0.5 },
|
|
861
|
+
],
|
|
862
|
+
[
|
|
863
|
+
{ name: 'A', value: 0 },
|
|
864
|
+
{ name: 'B', value: -2 },
|
|
865
|
+
],
|
|
866
|
+
]}
|
|
867
|
+
/>,
|
|
868
|
+
)
|
|
869
|
+
const t = ticks(container)
|
|
870
|
+
expect(t).toHaveLength(2)
|
|
871
|
+
expect(t[0]!.style.left).toBe('50%')
|
|
872
|
+
expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it.each([
|
|
876
|
+
['positive-only', [1, 2]],
|
|
877
|
+
['negative-only', [-1, -2]],
|
|
878
|
+
['all-zero', [0, 0]],
|
|
879
|
+
])('draws no zero tick for %s data', (_, values) => {
|
|
880
|
+
const { container } = render(
|
|
881
|
+
<CategoryUI
|
|
882
|
+
data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
|
|
883
|
+
/>,
|
|
884
|
+
)
|
|
885
|
+
expect(ticks(container)).toHaveLength(0)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('hangs negative-only bars from the right edge', () => {
|
|
889
|
+
const { container } = render(
|
|
890
|
+
<CategoryUI
|
|
891
|
+
data={[
|
|
892
|
+
[
|
|
893
|
+
{ name: 'A', value: -2 },
|
|
894
|
+
{ name: 'B', value: -1 },
|
|
895
|
+
],
|
|
896
|
+
]}
|
|
897
|
+
/>,
|
|
898
|
+
)
|
|
899
|
+
const [a, b] = fills(container)
|
|
900
|
+
expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
|
|
901
|
+
expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
it('treats maxOverride as a magnitude for signed data', () => {
|
|
905
|
+
const { container } = render(
|
|
906
|
+
<CategoryUI data={MIXED} maxOverride={1.6} />,
|
|
907
|
+
)
|
|
908
|
+
const [income, transit] = fills(container)
|
|
909
|
+
// domain [-1.6, 1.6]
|
|
910
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
|
|
911
|
+
expect(transit!.style.width).toBe('12.5%')
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('minOverride pins the zero even when the data has no negatives', () => {
|
|
915
|
+
const { container } = render(
|
|
916
|
+
<CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
|
|
917
|
+
)
|
|
918
|
+
expect(fills(container)[0]!.style.left).toBe('50%')
|
|
919
|
+
expect(ticks(container)).toHaveLength(1)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('prefixes U+2212 itself and hands the formatter |v|', () => {
|
|
923
|
+
const formatter = vi.fn((n: number) => `$${n}`)
|
|
924
|
+
render(
|
|
925
|
+
<CategoryUI
|
|
926
|
+
data={[
|
|
927
|
+
[
|
|
928
|
+
{ name: 'A', value: -0.5 },
|
|
929
|
+
{ name: 'B', value: -0 },
|
|
930
|
+
{ name: 'C', value: 0 },
|
|
931
|
+
],
|
|
932
|
+
]}
|
|
933
|
+
formatter={formatter}
|
|
934
|
+
/>,
|
|
935
|
+
)
|
|
936
|
+
expect(screen.getByText('\u2212$0.5')).toBeTruthy()
|
|
937
|
+
expect(screen.getAllByText('$0')).toHaveLength(2)
|
|
938
|
+
formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('stacked mode diverges around the shared zero and shows the signed total', () => {
|
|
942
|
+
const { container } = render(
|
|
943
|
+
<CategoryUI
|
|
944
|
+
stacked
|
|
945
|
+
data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
|
|
946
|
+
/>,
|
|
947
|
+
)
|
|
948
|
+
const [pos, neg] = fills(container)
|
|
949
|
+
// zero at 50%, longer side (3) fills its half
|
|
950
|
+
expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
|
|
951
|
+
expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
|
|
952
|
+
expect(screen.getByText('2')).toBeTruthy()
|
|
953
|
+
expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
|
|
954
|
+
})
|
|
955
|
+
})
|
|
817
956
|
})
|
|
@@ -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
|
|
@@ -147,6 +155,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
|
|
|
147
155
|
return order.map((k) => grouped.get(k)!)
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
|
|
160
|
+
* the zero at the left edge and negative-only at the right; mixed data is
|
|
161
|
+
* made symmetric so the zero sits in the middle of the track.
|
|
162
|
+
*/
|
|
163
|
+
function signedDomain(
|
|
164
|
+
rows: readonly GroupedRow[],
|
|
165
|
+
minOverride?: number,
|
|
166
|
+
maxOverride?: number,
|
|
167
|
+
): [lo: number, hi: number] {
|
|
168
|
+
let lo = 0
|
|
169
|
+
let hi = 0
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
for (const v of row.values) {
|
|
172
|
+
if (v < lo) lo = v
|
|
173
|
+
if (v > hi) hi = v
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
|
|
177
|
+
if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
|
|
178
|
+
if (lo < 0 && hi > 0) {
|
|
179
|
+
const m = Math.max(-lo, hi)
|
|
180
|
+
return [-m, m]
|
|
181
|
+
}
|
|
182
|
+
return [lo, hi]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
|
|
187
|
+
* that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
|
|
188
|
+
* `-` cannot double it. U+2212 is as wide as a tabular digit.
|
|
189
|
+
*/
|
|
190
|
+
function formatSigned(fmt: (n: number) => string, v: number): string {
|
|
191
|
+
return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
/**
|
|
151
195
|
* Pure presentational component for the Category widget. Renders
|
|
152
196
|
* single-series rows (`data.length === 1`) as a compact grid or
|
|
@@ -173,13 +217,15 @@ export function CategoryUI({
|
|
|
173
217
|
labels,
|
|
174
218
|
onShowAll,
|
|
175
219
|
maxOverride,
|
|
220
|
+
minOverride,
|
|
176
221
|
size = 'small',
|
|
177
222
|
stacked = false,
|
|
178
223
|
hiddenSeries: hiddenSeriesProp,
|
|
179
224
|
onHiddenSeriesChange,
|
|
180
225
|
}: CategoryUIProps) {
|
|
181
226
|
const theme = useTheme()
|
|
182
|
-
const
|
|
227
|
+
const baseFmt = formatter ?? ((n: number) => String(n))
|
|
228
|
+
const fmt = (n: number) => formatSigned(baseFmt, n)
|
|
183
229
|
|
|
184
230
|
const hasSelection = !!selection && selection.length > 0
|
|
185
231
|
const selectionSet = useMemo(
|
|
@@ -231,14 +277,14 @@ export function CategoryUI({
|
|
|
231
277
|
|
|
232
278
|
const grouped = useMemo(() => generateGroupedData(data), [data])
|
|
233
279
|
|
|
234
|
-
const maxValue = useMemo(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
280
|
+
const [minValue, maxValue] = useMemo(
|
|
281
|
+
() => signedDomain(grouped, minOverride, maxOverride),
|
|
282
|
+
[grouped, minOverride, maxOverride],
|
|
283
|
+
)
|
|
284
|
+
// Where the zero sits on the track, handed to stacked rows so their zero
|
|
285
|
+
// matches the other rows'. Positive-only data keeps it at the left edge.
|
|
286
|
+
const zeroPct =
|
|
287
|
+
maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
|
|
242
288
|
|
|
243
289
|
// Three modes:
|
|
244
290
|
// * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
|
|
@@ -363,7 +409,11 @@ export function CategoryUI({
|
|
|
363
409
|
<Box sx={styles.root}>
|
|
364
410
|
<Box
|
|
365
411
|
ref={listRefCallback}
|
|
366
|
-
sx={
|
|
412
|
+
sx={{
|
|
413
|
+
...styles.list,
|
|
414
|
+
...(isMulti && !stacked && styles.listMulti),
|
|
415
|
+
...(scrollMode && styles.listScroll),
|
|
416
|
+
}}
|
|
367
417
|
style={
|
|
368
418
|
scrollMaxHeight !== undefined
|
|
369
419
|
? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
|
|
@@ -413,6 +463,7 @@ export function CategoryUI({
|
|
|
413
463
|
selected={selected}
|
|
414
464
|
onToggle={toggle}
|
|
415
465
|
size={size}
|
|
466
|
+
zeroPct={zeroPct}
|
|
416
467
|
/>
|
|
417
468
|
)
|
|
418
469
|
}
|
|
@@ -424,6 +475,7 @@ export function CategoryUI({
|
|
|
424
475
|
values={visibleIdx.map((i) => row.values[i]!)}
|
|
425
476
|
colors={visibleIdx.map((i) => fill(i))}
|
|
426
477
|
maxValue={maxValue}
|
|
478
|
+
minValue={minValue}
|
|
427
479
|
formatter={fmt}
|
|
428
480
|
selected={selected}
|
|
429
481
|
onToggle={toggle}
|
|
@@ -438,6 +490,7 @@ export function CategoryUI({
|
|
|
438
490
|
displayName={displayName}
|
|
439
491
|
value={row.values[0] ?? 0}
|
|
440
492
|
maxValue={maxValue}
|
|
493
|
+
minValue={minValue}
|
|
441
494
|
color={fill(0)}
|
|
442
495
|
formatter={fmt}
|
|
443
496
|
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
|
})
|