@carto/ps-react-ui 4.17.2 → 4.18.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-Ct2BzADB.js +753 -0
- package/dist/category-Ct2BzADB.js.map +1 -0
- package/dist/change-column-Bhcxmp-r.js +1148 -0
- package/dist/change-column-Bhcxmp-r.js.map +1 -0
- package/dist/echart-B10dhEaM.js +262 -0
- package/dist/echart-B10dhEaM.js.map +1 -0
- package/dist/legend/stores.js +1 -1
- package/dist/{legend-store-registry-Bo8U_qWM.js → legend-store-registry-p1tSwJXH.js} +151 -147
- package/dist/legend-store-registry-p1tSwJXH.js.map +1 -0
- package/dist/legend.js +473 -415
- package/dist/legend.js.map +1 -1
- package/dist/types/legend/components/legend-actions/legend-actions.d.ts +2 -2
- package/dist/types/legend/components/legend-group/styles.d.ts +31 -20
- package/dist/types/legend/components/legend-row/styles.d.ts +24 -4
- package/dist/types/legend/components/legend-sortable/styles.d.ts +4 -3
- package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
- package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
- package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
- package/dist/types/widgets-v2/category/style.d.ts +8 -0
- package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
- package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
- package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
- package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
- package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
- package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
- package/dist/types/widgets-v2/table/index.d.ts +1 -0
- package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
- package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
- package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
- package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
- package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/echart.js +1 -1
- package/dist/widgets-v2/echart.js.map +1 -1
- package/dist/widgets-v2/histogram.js +181 -158
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/pie.js +155 -116
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +91 -85
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/table.js +55 -45
- package/dist/widgets-v2/table.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +96 -85
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +4 -4
- package/package.json +3 -3
- package/src/legend/components/legend-actions/legend-actions.tsx +2 -2
- package/src/legend/components/legend-group/styles.ts +59 -36
- package/src/legend/components/legend-row/legend-row.tsx +10 -7
- package/src/legend/components/legend-row/styles.ts +41 -10
- package/src/legend/components/legend-sortable/styles.ts +13 -4
- package/src/legend/stores/legend-store-registry.ts +28 -4
- package/src/legend/stores/legend-store.test.ts +23 -0
- package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
- package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
- package/src/widgets-v2/category/category-ui.test.tsx +55 -0
- package/src/widgets-v2/category/category-ui.tsx +65 -6
- package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
- package/src/widgets-v2/category/components/category-legend.tsx +55 -12
- package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
- package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
- package/src/widgets-v2/category/style.ts +10 -2
- package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
- package/src/widgets-v2/echart/echart-ui.tsx +20 -0
- package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
- package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
- package/src/widgets-v2/histogram/index.ts +4 -1
- package/src/widgets-v2/histogram/options.test.ts +86 -0
- package/src/widgets-v2/histogram/options.ts +50 -0
- package/src/widgets-v2/histogram/transforms.test.ts +36 -1
- package/src/widgets-v2/histogram/transforms.ts +23 -0
- package/src/widgets-v2/histogram/types.ts +9 -0
- package/src/widgets-v2/pie/options.test.ts +262 -0
- package/src/widgets-v2/pie/options.ts +80 -5
- package/src/widgets-v2/scatterplot/index.ts +4 -1
- package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
- package/src/widgets-v2/scatterplot/transforms.ts +23 -0
- package/src/widgets-v2/table/index.ts +1 -0
- package/src/widgets-v2/table/transforms.test.ts +40 -0
- package/src/widgets-v2/table/transforms.ts +26 -0
- package/src/widgets-v2/timeseries/index.ts +1 -0
- package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
- package/src/widgets-v2/timeseries/transforms.ts +31 -0
- package/dist/category-CGS_eHr4.js +0 -719
- package/dist/category-CGS_eHr4.js.map +0 -1
- package/dist/change-column-CiVAjOUB.js +0 -1143
- package/dist/change-column-CiVAjOUB.js.map +0 -1
- package/dist/echart-Bdvbfx9s.js +0 -250
- package/dist/echart-Bdvbfx9s.js.map +0 -1
- package/dist/legend-store-registry-Bo8U_qWM.js.map +0 -1
|
@@ -281,6 +281,48 @@ export function CategoryUI({
|
|
|
281
281
|
[scrollMode],
|
|
282
282
|
)
|
|
283
283
|
|
|
284
|
+
// Which series are hidden via a legend click. Local UI-only state — not
|
|
285
|
+
// synced to `selection`/`onSelectionChange` (that's row-level filter
|
|
286
|
+
// selection, unrelated). Never allowed to reach "every series hidden":
|
|
287
|
+
// toggling the last visible one instead resets to "all visible" rather
|
|
288
|
+
// than leaving the widget blank.
|
|
289
|
+
const [hiddenSeries, setHiddenSeries] = useState<ReadonlySet<number>>(
|
|
290
|
+
() => new Set(),
|
|
291
|
+
)
|
|
292
|
+
// A reload with a different series breakdown invalidates hidden indices —
|
|
293
|
+
// index 2 may now point at an entirely different series. Reset rather
|
|
294
|
+
// than keep hiding whatever now occupies a stale index. Adjusted directly
|
|
295
|
+
// during render (React's documented "adjusting state when a prop changes"
|
|
296
|
+
// pattern) instead of in a `useEffect`, which would commit the stale
|
|
297
|
+
// hidden-set for one extra frame before the effect fires. Compared by a
|
|
298
|
+
// content-derived key, not the raw `series` reference — an inline
|
|
299
|
+
// `series={[...]}` literal (no useMemo) gets a new reference on every
|
|
300
|
+
// unrelated parent re-render, which would otherwise reset hiddenSeries
|
|
301
|
+
// right after the user hides one.
|
|
302
|
+
const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
|
|
303
|
+
const [prevSeriesKey, setPrevSeriesKey] = useState(seriesKey)
|
|
304
|
+
if (seriesKey !== prevSeriesKey) {
|
|
305
|
+
setPrevSeriesKey(seriesKey)
|
|
306
|
+
setHiddenSeries(new Set())
|
|
307
|
+
}
|
|
308
|
+
// Real number of data series — NOT `series?.length`, which is legend
|
|
309
|
+
// metadata and may legitimately be shorter than the actual series count
|
|
310
|
+
// (unnamed trailing series fall back to `Series N`). Using the metadata
|
|
311
|
+
// length here would trip the "never fully hidden" guard early whenever
|
|
312
|
+
// `series` is shorter than `data`.
|
|
313
|
+
const seriesCount = Math.max(data.length, 1)
|
|
314
|
+
const toggleSeries = useCallback(
|
|
315
|
+
(index: number) => {
|
|
316
|
+
setHiddenSeries((prev) => {
|
|
317
|
+
const next = new Set(prev)
|
|
318
|
+
if (next.has(index)) next.delete(index)
|
|
319
|
+
else next.add(index)
|
|
320
|
+
return next.size >= seriesCount ? new Set() : next
|
|
321
|
+
})
|
|
322
|
+
},
|
|
323
|
+
[seriesCount],
|
|
324
|
+
)
|
|
325
|
+
|
|
284
326
|
if (grouped.length === 0) return null
|
|
285
327
|
|
|
286
328
|
const hasCap =
|
|
@@ -290,6 +332,12 @@ export function CategoryUI({
|
|
|
290
332
|
|
|
291
333
|
const isMulti = data.length > 1
|
|
292
334
|
const hasLegend = isMulti && !!series && series.length > 0
|
|
335
|
+
// Invariant across every row in this render — only depends on
|
|
336
|
+
// `hiddenSeries`/`seriesCount`, not on any particular row's data — so
|
|
337
|
+
// it's computed once here instead of per row inside the render loop.
|
|
338
|
+
const visibleIdx = Array.from({ length: seriesCount }, (_, i) => i).filter(
|
|
339
|
+
(i) => !hiddenSeries.has(i),
|
|
340
|
+
)
|
|
293
341
|
|
|
294
342
|
// Fallback viewport height matches the documented "8 × ROW_HEIGHT_PX"
|
|
295
343
|
// (see the constants at the top of the file). Earlier code multiplied
|
|
@@ -323,6 +371,10 @@ export function CategoryUI({
|
|
|
323
371
|
// rows — no row-level opacity, no `rowSelected` bg tint.
|
|
324
372
|
const fill = (i: number): string =>
|
|
325
373
|
dimmed ? dimColor : colorAt(i, row.rowColors[i])
|
|
374
|
+
// Series hidden via the legend are dropped before reaching the
|
|
375
|
+
// row components — they render whatever arrays they're given
|
|
376
|
+
// with no notion of "hidden" themselves. `visibleIdx` is hoisted
|
|
377
|
+
// above this loop (same for every row).
|
|
326
378
|
if (isMulti) {
|
|
327
379
|
if (stacked) {
|
|
328
380
|
// Stacked-mode branch: one segmented bar per row. Toggling
|
|
@@ -341,9 +393,11 @@ export function CategoryUI({
|
|
|
341
393
|
key={String(row.name)}
|
|
342
394
|
name={row.name}
|
|
343
395
|
displayName={displayName}
|
|
344
|
-
values={row.values}
|
|
345
|
-
colors={
|
|
346
|
-
seriesNames={
|
|
396
|
+
values={visibleIdx.map((i) => row.values[i]!)}
|
|
397
|
+
colors={visibleIdx.map((i) => fill(i))}
|
|
398
|
+
seriesNames={visibleIdx.map(
|
|
399
|
+
(i) => series?.[i]?.name ?? `Series ${i + 1}`,
|
|
400
|
+
)}
|
|
347
401
|
formatter={fmt}
|
|
348
402
|
selected={selected}
|
|
349
403
|
onToggle={toggle}
|
|
@@ -356,8 +410,8 @@ export function CategoryUI({
|
|
|
356
410
|
key={String(row.name)}
|
|
357
411
|
name={row.name}
|
|
358
412
|
displayName={displayName}
|
|
359
|
-
values={row.values}
|
|
360
|
-
colors={
|
|
413
|
+
values={visibleIdx.map((i) => row.values[i]!)}
|
|
414
|
+
colors={visibleIdx.map((i) => fill(i))}
|
|
361
415
|
maxValue={maxValue}
|
|
362
416
|
formatter={fmt}
|
|
363
417
|
selected={selected}
|
|
@@ -392,7 +446,12 @@ export function CategoryUI({
|
|
|
392
446
|
)}
|
|
393
447
|
</Box>
|
|
394
448
|
{hasLegend && series && (
|
|
395
|
-
<CategoryLegend
|
|
449
|
+
<CategoryLegend
|
|
450
|
+
series={series}
|
|
451
|
+
colorAt={(i) => colorAt(i)}
|
|
452
|
+
hiddenSeries={hiddenSeries}
|
|
453
|
+
onToggle={toggleSeries}
|
|
454
|
+
/>
|
|
396
455
|
)}
|
|
397
456
|
</Box>
|
|
398
457
|
)
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
3
4
|
import { CategoryLegend } from './category-legend'
|
|
4
5
|
|
|
5
6
|
const noColors = () => undefined
|
|
7
|
+
const noHidden = new Set<number>()
|
|
8
|
+
const noop = vi.fn()
|
|
6
9
|
|
|
7
10
|
describe('<CategoryLegend>', () => {
|
|
8
11
|
it('renders nothing when series is empty', () => {
|
|
9
12
|
const { container } = render(
|
|
10
|
-
<CategoryLegend
|
|
13
|
+
<CategoryLegend
|
|
14
|
+
series={[]}
|
|
15
|
+
colorAt={noColors}
|
|
16
|
+
hiddenSeries={noHidden}
|
|
17
|
+
onToggle={noop}
|
|
18
|
+
/>,
|
|
11
19
|
)
|
|
12
20
|
expect(container.firstChild).toBeNull()
|
|
13
21
|
})
|
|
@@ -17,6 +25,8 @@ describe('<CategoryLegend>', () => {
|
|
|
17
25
|
<CategoryLegend
|
|
18
26
|
series={[{ name: '2024' }, { name: '2025' }]}
|
|
19
27
|
colorAt={(i) => (i === 0 ? 'rgb(1, 1, 1)' : 'rgb(2, 2, 2)')}
|
|
28
|
+
hiddenSeries={noHidden}
|
|
29
|
+
onToggle={noop}
|
|
20
30
|
/>,
|
|
21
31
|
)
|
|
22
32
|
expect(screen.getByText('2024')).toBeTruthy()
|
|
@@ -28,6 +38,8 @@ describe('<CategoryLegend>', () => {
|
|
|
28
38
|
<CategoryLegend
|
|
29
39
|
series={[{ name: 'S1', color: 'rgb(99, 0, 0)' }]}
|
|
30
40
|
colorAt={() => 'rgb(0, 0, 0)'}
|
|
41
|
+
hiddenSeries={noHidden}
|
|
42
|
+
onToggle={noop}
|
|
31
43
|
/>,
|
|
32
44
|
)
|
|
33
45
|
const dot = Array.from(container.querySelectorAll('div')).find(
|
|
@@ -41,6 +53,8 @@ describe('<CategoryLegend>', () => {
|
|
|
41
53
|
<CategoryLegend
|
|
42
54
|
series={[{ name: 'S1' }]}
|
|
43
55
|
colorAt={() => 'rgb(7, 7, 7)'}
|
|
56
|
+
hiddenSeries={noHidden}
|
|
57
|
+
onToggle={noop}
|
|
44
58
|
/>,
|
|
45
59
|
)
|
|
46
60
|
const dot = Array.from(container.querySelectorAll('div')).find(
|
|
@@ -48,4 +62,41 @@ describe('<CategoryLegend>', () => {
|
|
|
48
62
|
)
|
|
49
63
|
expect(dot).toBeTruthy()
|
|
50
64
|
})
|
|
65
|
+
|
|
66
|
+
it('clicking a legend item calls onToggle with its series index', async () => {
|
|
67
|
+
const onToggle = vi.fn()
|
|
68
|
+
render(
|
|
69
|
+
<CategoryLegend
|
|
70
|
+
series={[{ name: '2024' }, { name: '2025' }]}
|
|
71
|
+
colorAt={noColors}
|
|
72
|
+
hiddenSeries={noHidden}
|
|
73
|
+
onToggle={onToggle}
|
|
74
|
+
/>,
|
|
75
|
+
)
|
|
76
|
+
await userEvent.click(screen.getByText('2025'))
|
|
77
|
+
expect(onToggle).toHaveBeenCalledWith(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('marks a hidden series as aria-checked=false (dimmed)', () => {
|
|
81
|
+
render(
|
|
82
|
+
<CategoryLegend
|
|
83
|
+
series={[{ name: '2024' }, { name: '2025' }]}
|
|
84
|
+
colorAt={noColors}
|
|
85
|
+
hiddenSeries={new Set([1])}
|
|
86
|
+
onToggle={noop}
|
|
87
|
+
/>,
|
|
88
|
+
)
|
|
89
|
+
expect(
|
|
90
|
+
screen
|
|
91
|
+
.getByText('2024')
|
|
92
|
+
.closest('[role="checkbox"]')
|
|
93
|
+
?.getAttribute('aria-checked'),
|
|
94
|
+
).toBe('true')
|
|
95
|
+
expect(
|
|
96
|
+
screen
|
|
97
|
+
.getByText('2025')
|
|
98
|
+
.closest('[role="checkbox"]')
|
|
99
|
+
?.getAttribute('aria-checked'),
|
|
100
|
+
).toBe('false')
|
|
101
|
+
})
|
|
51
102
|
})
|
|
@@ -13,27 +13,70 @@ export interface CategoryLegendProps {
|
|
|
13
13
|
* same source).
|
|
14
14
|
*/
|
|
15
15
|
colorAt: (index: number) => string | undefined
|
|
16
|
+
/**
|
|
17
|
+
* Series indices currently hidden from the chart. Renders dimmed.
|
|
18
|
+
* Optional — defaults to none hidden. Added after this component's first
|
|
19
|
+
* release; kept optional (not required) so it stays a non-breaking
|
|
20
|
+
* addition for any direct consumer of `CategoryLegend` (a public
|
|
21
|
+
* subpath export) that predates the hide feature.
|
|
22
|
+
*/
|
|
23
|
+
hiddenSeries?: ReadonlySet<number>
|
|
24
|
+
/**
|
|
25
|
+
* Fires with the series index when its legend item is clicked. Optional
|
|
26
|
+
* — omitting it renders a legend with no hide affordance (same
|
|
27
|
+
* reasoning as `hiddenSeries`).
|
|
28
|
+
*/
|
|
29
|
+
onToggle?: (index: number) => void
|
|
16
30
|
}
|
|
17
31
|
|
|
32
|
+
const NO_HIDDEN_SERIES: ReadonlySet<number> = new Set()
|
|
33
|
+
|
|
18
34
|
/**
|
|
19
35
|
* Sticky color legend rendered below a multi-series Category list.
|
|
20
|
-
* Per-series `color` overrides the matching `colorAt(i)` result.
|
|
36
|
+
* Per-series `color` overrides the matching `colorAt(i)` result. Clicking an
|
|
37
|
+
* item toggles that series' visibility in the rows above (mirrors ECharts'
|
|
38
|
+
* native legend click-to-hide).
|
|
21
39
|
*/
|
|
22
|
-
export function CategoryLegend({
|
|
40
|
+
export function CategoryLegend({
|
|
41
|
+
series,
|
|
42
|
+
colorAt,
|
|
43
|
+
hiddenSeries = NO_HIDDEN_SERIES,
|
|
44
|
+
onToggle,
|
|
45
|
+
}: CategoryLegendProps) {
|
|
23
46
|
if (series.length === 0) return null
|
|
24
47
|
return (
|
|
25
48
|
<Box sx={styles.legend}>
|
|
26
|
-
{series.map((s, i) =>
|
|
27
|
-
|
|
49
|
+
{series.map((s, i) => {
|
|
50
|
+
const hidden = hiddenSeries.has(i)
|
|
51
|
+
return (
|
|
28
52
|
<Box
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
53
|
+
key={s.name}
|
|
54
|
+
// `checkbox`/`aria-checked`, not `button`/`aria-pressed` — this
|
|
55
|
+
// toggles a persistent visibility state (closer to a checkbox)
|
|
56
|
+
// AND keeps it out of `getByRole('button')` queries that count
|
|
57
|
+
// category ROWS throughout this widget's test suite.
|
|
58
|
+
role='checkbox'
|
|
59
|
+
aria-checked={!hidden}
|
|
60
|
+
tabIndex={0}
|
|
61
|
+
onClick={() => onToggle?.(i)}
|
|
62
|
+
onKeyDown={(e) => {
|
|
63
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
onToggle?.(i)
|
|
66
|
+
}
|
|
67
|
+
}}
|
|
68
|
+
sx={hidden ? styles.legendItemHidden : styles.legendItem}
|
|
69
|
+
>
|
|
70
|
+
<Box
|
|
71
|
+
sx={styles.legendDot}
|
|
72
|
+
style={{ backgroundColor: s.color ?? colorAt(i) }}
|
|
73
|
+
/>
|
|
74
|
+
<Typography variant='caption' sx={styles.legendLabel}>
|
|
75
|
+
{s.name}
|
|
76
|
+
</Typography>
|
|
77
|
+
</Box>
|
|
78
|
+
)
|
|
79
|
+
})}
|
|
37
80
|
</Box>
|
|
38
81
|
)
|
|
39
82
|
}
|
|
@@ -69,20 +69,24 @@ describe('<CategoryRowStacked>', () => {
|
|
|
69
69
|
expect(screen.getByText('Men: 32')).toBeTruthy()
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
-
it(
|
|
72
|
+
it("renders whatever seriesNames it is given — fallback resolution is the caller's responsibility", () => {
|
|
73
|
+
// The component has no notion of which series were hidden upstream, so
|
|
74
|
+
// it must not recompute a positional "Series N" fallback itself — the
|
|
75
|
+
// caller (category-ui.tsx) resolves names, including fallbacks, using
|
|
76
|
+
// each series' ORIGINAL index before this component ever sees them.
|
|
73
77
|
render(
|
|
74
78
|
<CategoryRowStacked
|
|
75
79
|
name='Cat'
|
|
76
80
|
values={[45, 32]}
|
|
77
81
|
colors={COLORS}
|
|
78
|
-
seriesNames={[]}
|
|
82
|
+
seriesNames={['Series 1', 'Series 3']}
|
|
79
83
|
formatter={(n) => String(n)}
|
|
80
84
|
selected={false}
|
|
81
85
|
onToggle={vi.fn()}
|
|
82
86
|
/>,
|
|
83
87
|
)
|
|
84
88
|
expect(screen.getByText('Series 1: 45')).toBeTruthy()
|
|
85
|
-
expect(screen.getByText('Series
|
|
89
|
+
expect(screen.getByText('Series 3: 32')).toBeTruthy()
|
|
86
90
|
})
|
|
87
91
|
|
|
88
92
|
it('per-row normalization: bar fills 100% with segments proportional to the row sum', () => {
|
|
@@ -13,7 +13,12 @@ export interface CategoryRowStackedProps {
|
|
|
13
13
|
values: readonly number[]
|
|
14
14
|
/** One color per series (palette + per-series overrides resolved upstream). */
|
|
15
15
|
colors: readonly string[]
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Series names for the breakdown row, one per visible series, already
|
|
18
|
+
* resolved by the caller (unnamed series fall back to `Series N` using
|
|
19
|
+
* their ORIGINAL series index — this component has no notion of which
|
|
20
|
+
* series were hidden, so it must not recompute a positional fallback).
|
|
21
|
+
*/
|
|
17
22
|
seriesNames: readonly string[]
|
|
18
23
|
formatter: (n: number) => string
|
|
19
24
|
selected: boolean
|
|
@@ -86,11 +91,11 @@ export function CategoryRowStacked({
|
|
|
86
91
|
{values.map((v, i) => (
|
|
87
92
|
// Series name is the stable identity for the breakdown line.
|
|
88
93
|
<Typography
|
|
89
|
-
key={`breakdown-${seriesNames[i]
|
|
94
|
+
key={`breakdown-${seriesNames[i]}`}
|
|
90
95
|
variant='body2'
|
|
91
96
|
sx={styles.stackedBreakdownItem}
|
|
92
97
|
>
|
|
93
|
-
{`${seriesNames[i]
|
|
98
|
+
{`${seriesNames[i]}: ${formatter(v)}`}
|
|
94
99
|
</Typography>
|
|
95
100
|
))}
|
|
96
101
|
</Box>
|
|
@@ -260,10 +260,18 @@ export const styles = {
|
|
|
260
260
|
display: 'flex',
|
|
261
261
|
alignItems: 'center',
|
|
262
262
|
gap: 1,
|
|
263
|
+
cursor: 'pointer',
|
|
264
|
+
},
|
|
265
|
+
legendItemHidden: {
|
|
266
|
+
display: 'flex',
|
|
267
|
+
alignItems: 'center',
|
|
268
|
+
gap: 1,
|
|
269
|
+
cursor: 'pointer',
|
|
270
|
+
opacity: 0.5,
|
|
263
271
|
},
|
|
264
272
|
legendDot: {
|
|
265
|
-
width:
|
|
266
|
-
height:
|
|
273
|
+
width: 14,
|
|
274
|
+
height: 14,
|
|
267
275
|
borderRadius: '50%',
|
|
268
276
|
},
|
|
269
277
|
legendLabel: {
|
|
@@ -11,6 +11,7 @@ const mockChart = {
|
|
|
11
11
|
resize: vi.fn(),
|
|
12
12
|
on: vi.fn(),
|
|
13
13
|
off: vi.fn(),
|
|
14
|
+
dispatchAction: vi.fn(),
|
|
14
15
|
// Stubbed for the edge-label clamp; tests override per-case.
|
|
15
16
|
convertToPixel: vi.fn((_finder: unknown, index: number) =>
|
|
16
17
|
index === 0 ? 4 : 296,
|
|
@@ -39,6 +40,15 @@ function fireFinished(): void {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Invoke the `legendselectchanged` listener the bridge bound on the mock chart. */
|
|
44
|
+
function fireLegendSelectChanged(selected: Record<string, boolean>): void {
|
|
45
|
+
for (const [event, handler] of mockChart.on.mock.calls) {
|
|
46
|
+
if (event === 'legendselectchanged') {
|
|
47
|
+
;(handler as (params: unknown) => void)({ selected })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
// Resize observer mock — capture the callback so tests can trigger it.
|
|
43
53
|
let resizeCb: (() => void) | null = null
|
|
44
54
|
const observeResizeSpy = vi.fn((_node: HTMLElement, cb: () => void) => {
|
|
@@ -60,6 +70,7 @@ beforeEach(() => {
|
|
|
60
70
|
mockChart.resize.mockReset()
|
|
61
71
|
mockChart.on.mockReset()
|
|
62
72
|
mockChart.off.mockReset()
|
|
73
|
+
mockChart.dispatchAction.mockReset()
|
|
63
74
|
mockChart.convertToPixel.mockClear()
|
|
64
75
|
mockChart.getWidth.mockClear()
|
|
65
76
|
initSpy.mockClear()
|
|
@@ -219,6 +230,20 @@ describe('<EchartUI>', () => {
|
|
|
219
230
|
expect(mockChart.dispose).toHaveBeenCalledTimes(1)
|
|
220
231
|
})
|
|
221
232
|
|
|
233
|
+
it('restores every legend item when a legend click hides the last one', () => {
|
|
234
|
+
render(<EchartUI option={{}} />)
|
|
235
|
+
fireLegendSelectChanged({ A: false, B: false, C: false })
|
|
236
|
+
expect(mockChart.dispatchAction).toHaveBeenCalledWith({
|
|
237
|
+
type: 'legendAllSelect',
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('does nothing while at least one legend item stays visible', () => {
|
|
242
|
+
render(<EchartUI option={{}} />)
|
|
243
|
+
fireLegendSelectChanged({ A: true, B: false, C: false })
|
|
244
|
+
expect(mockChart.dispatchAction).not.toHaveBeenCalled()
|
|
245
|
+
})
|
|
246
|
+
|
|
222
247
|
it('clamps overflowing edge labels on `finished` (category x-axis)', () => {
|
|
223
248
|
render(<EchartUI option={categoryOption} />)
|
|
224
249
|
mockChart.setOption.mockClear()
|
|
@@ -114,8 +114,28 @@ export function EchartUI({
|
|
|
114
114
|
)
|
|
115
115
|
}
|
|
116
116
|
chart.on('finished', onFinished)
|
|
117
|
+
// Never let the legend end up with every item hidden — a user can click
|
|
118
|
+
// series off one by one until the chart goes blank. `selected` on this
|
|
119
|
+
// event is always the FULL name→boolean map (not just the clicked item),
|
|
120
|
+
// so "every value false" reliably means "nothing left visible". Restore
|
|
121
|
+
// all of them instead. `legendAllSelect` fires its own `legendselectall`
|
|
122
|
+
// event, not `legendselectchanged`, so this can't re-trigger itself.
|
|
123
|
+
// Always-on and independent of the consumer's `onEvents` — applies to
|
|
124
|
+
// every ECharts widget in the library for free. A widget that disables
|
|
125
|
+
// legend clicks entirely (e.g. Pie's `selectedMode: false`) never fires
|
|
126
|
+
// `legendselectchanged`, so this is a no-op there.
|
|
127
|
+
const onLegendSelectChanged = (params: unknown): void => {
|
|
128
|
+
const { selected } = params as { selected?: Record<string, boolean> }
|
|
129
|
+
if (!selected) return
|
|
130
|
+
const names = Object.keys(selected)
|
|
131
|
+
if (names.length > 0 && names.every((name) => !selected[name])) {
|
|
132
|
+
chart.dispatchAction({ type: 'legendAllSelect' })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
chart.on('legendselectchanged', onLegendSelectChanged)
|
|
117
136
|
return () => {
|
|
118
137
|
chart.off('finished', onFinished)
|
|
138
|
+
chart.off('legendselectchanged', onLegendSelectChanged)
|
|
119
139
|
// Notify observers *before* disposal so any queued imperative
|
|
120
140
|
// dispatches (e.g. ZoomToggle's `setOption` cleanup) see the
|
|
121
141
|
// instance disappear before its DOM is torn down.
|
|
@@ -61,7 +61,7 @@ describe('useChartSelection', () => {
|
|
|
61
61
|
expect(onSelectionChange).toHaveBeenLastCalledWith([3, 7, 11])
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
-
it('
|
|
64
|
+
it('collapses undefined/null/an explicit empty list to null, since no merger treats them differently', () => {
|
|
65
65
|
const { result, rerender } = renderHook(
|
|
66
66
|
({ selection }: { selection?: readonly string[] }) =>
|
|
67
67
|
useChartSelection<string>({
|
|
@@ -81,6 +81,8 @@ describe('useChartSelection', () => {
|
|
|
81
81
|
expect(result.current.selection).toBeNull()
|
|
82
82
|
rerender({ selection: ['A'] })
|
|
83
83
|
expect(result.current.selection).toEqual(['A'])
|
|
84
|
+
rerender({ selection: undefined })
|
|
85
|
+
expect(result.current.selection).toBeNull()
|
|
84
86
|
})
|
|
85
87
|
|
|
86
88
|
it('is a no-op when `onSelectionChange` is missing', () => {
|
|
@@ -5,7 +5,13 @@ import type { EchartsEventHandler } from './echart-ui'
|
|
|
5
5
|
export type SelectionKey = string | number
|
|
6
6
|
|
|
7
7
|
export interface UseChartSelectionOptions<K extends SelectionKey> {
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Controlled selection. `undefined`/`null`/an explicit `[]` all mean "no
|
|
10
|
+
* selection interaction" — every current merger already treats an empty
|
|
11
|
+
* selection as "nothing excluded," so this hook collapses all three to
|
|
12
|
+
* `null` rather than promising per-merger `[]` semantics it doesn't
|
|
13
|
+
* implement.
|
|
14
|
+
*/
|
|
9
15
|
selection?: readonly K[]
|
|
10
16
|
/** Receives the next selection. When omitted the wiring is a no-op. */
|
|
11
17
|
onSelectionChange?: (next: readonly K[]) => void
|
|
@@ -32,9 +38,9 @@ export interface ChartSelectionWiring<K extends SelectionKey> {
|
|
|
32
38
|
*/
|
|
33
39
|
onEvents: Record<string, EchartsEventHandler>
|
|
34
40
|
/**
|
|
35
|
-
* The current selection — `null`
|
|
36
|
-
*
|
|
37
|
-
*
|
|
41
|
+
* The current selection — `null` for "no selection interaction"
|
|
42
|
+
* (`undefined`/`null`/an explicit `[]`), or a non-empty array meaning
|
|
43
|
+
* "render only these as fully opaque, dim the rest".
|
|
38
44
|
*/
|
|
39
45
|
selection: readonly K[] | null
|
|
40
46
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export { histogramOptions, createHistogramOptionFactory } from './options'
|
|
2
2
|
export { HistogramSkeleton, type HistogramSkeletonProps } from './skeleton'
|
|
3
3
|
export { createHistogramDownloadConfig } from './download'
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
toRelativeHistogramData,
|
|
6
|
+
filterHistogramByLockedBins,
|
|
7
|
+
} from './transforms'
|
|
5
8
|
export type {
|
|
6
9
|
HistogramWidgetData,
|
|
7
10
|
HistogramOptionsInput,
|
|
@@ -273,6 +273,92 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
|
|
|
273
273
|
expect(out.series).toEqual([])
|
|
274
274
|
})
|
|
275
275
|
|
|
276
|
+
describe('lockedItems → x-axis crop', () => {
|
|
277
|
+
it('crops xAxis.min/max to the locked bin range (data stays full-length)', () => {
|
|
278
|
+
const merge = createHistogramOptionFactory({
|
|
279
|
+
theme,
|
|
280
|
+
ticks: [0, 10, 20, 30, 40],
|
|
281
|
+
lockedItems: [1, 2],
|
|
282
|
+
})
|
|
283
|
+
const out = merge({}, [[5, 10, 15, 20]]) as {
|
|
284
|
+
xAxis: { min?: string | null; max?: string | null }
|
|
285
|
+
dataset: { source: [string, number][] }[]
|
|
286
|
+
}
|
|
287
|
+
expect(out.xAxis.min).toBe('10–20')
|
|
288
|
+
expect(out.xAxis.max).toBe('20–30')
|
|
289
|
+
// Data isn't sliced — every bin still has an entry, just scrolled
|
|
290
|
+
// out of the cropped view.
|
|
291
|
+
expect(out.dataset[0]!.source).toHaveLength(4)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('resets to null (not omitted) when lockedItems is empty, clearing a prior crop', () => {
|
|
295
|
+
const merge = createHistogramOptionFactory({
|
|
296
|
+
theme,
|
|
297
|
+
ticks: [0, 10, 20, 30],
|
|
298
|
+
lockedItems: [],
|
|
299
|
+
})
|
|
300
|
+
const out = merge({}, [[1, 2, 3]]) as {
|
|
301
|
+
xAxis: { min?: string | null; max?: string | null }
|
|
302
|
+
}
|
|
303
|
+
expect(out.xAxis.min).toBeNull()
|
|
304
|
+
expect(out.xAxis.max).toBeNull()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('preserves a consumer-supplied optionsOverride.xAxis.min/max when never locked, instead of clobbering it to null', () => {
|
|
308
|
+
const merge = createHistogramOptionFactory({
|
|
309
|
+
theme,
|
|
310
|
+
ticks: [0, 10, 20, 30],
|
|
311
|
+
lockedItems: [],
|
|
312
|
+
})
|
|
313
|
+
// `option` here stands in for the post-structural-phase option, which
|
|
314
|
+
// already has `optionsOverride.xAxis` baked in — the min/max a
|
|
315
|
+
// consumer set for an unrelated reason, with locking never engaged.
|
|
316
|
+
const out = merge({ xAxis: { min: 'custom-min', max: 'custom-max' } }, [
|
|
317
|
+
[1, 2, 3],
|
|
318
|
+
]) as { xAxis: { min?: string | null; max?: string | null } }
|
|
319
|
+
expect(out.xAxis.min).toBe('custom-min')
|
|
320
|
+
expect(out.xAxis.max).toBe('custom-max')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('handles a single locked bin (min === max)', () => {
|
|
324
|
+
const merge = createHistogramOptionFactory({
|
|
325
|
+
theme,
|
|
326
|
+
ticks: [0, 10, 20, 30],
|
|
327
|
+
lockedItems: [1],
|
|
328
|
+
})
|
|
329
|
+
const out = merge({}, [[1, 2, 3]]) as {
|
|
330
|
+
xAxis: { min?: string | null; max?: string | null }
|
|
331
|
+
}
|
|
332
|
+
expect(out.xAxis.min).toBe('10–20')
|
|
333
|
+
expect(out.xAxis.max).toBe('10–20')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('merges the crop into the primary axis and preserves the rest of an array xAxis override, instead of discarding it', () => {
|
|
337
|
+
const merge = createHistogramOptionFactory({
|
|
338
|
+
theme,
|
|
339
|
+
ticks: [0, 10, 20, 30, 40],
|
|
340
|
+
lockedItems: [1, 2],
|
|
341
|
+
})
|
|
342
|
+
const out = merge(
|
|
343
|
+
{
|
|
344
|
+
xAxis: [
|
|
345
|
+
{ type: 'category', name: 'primary' },
|
|
346
|
+
{ type: 'value', name: 'secondary' },
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
[[5, 10, 15, 20]],
|
|
350
|
+
) as {
|
|
351
|
+
xAxis: { name?: string; min?: string | null; max?: string | null }[]
|
|
352
|
+
}
|
|
353
|
+
expect(Array.isArray(out.xAxis)).toBe(true)
|
|
354
|
+
expect(out.xAxis).toHaveLength(2)
|
|
355
|
+
expect(out.xAxis[0]!.name).toBe('primary')
|
|
356
|
+
expect(out.xAxis[0]!.min).toBe('10–20')
|
|
357
|
+
expect(out.xAxis[0]!.max).toBe('20–30')
|
|
358
|
+
expect(out.xAxis[1]).toEqual({ type: 'value', name: 'secondary' })
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
276
362
|
describe('tooltip formatter', () => {
|
|
277
363
|
type ItemFormatter = (
|
|
278
364
|
params: unknown,
|
|
@@ -160,6 +160,7 @@ export function createHistogramOptionFactory(
|
|
|
160
160
|
): OptionFactory {
|
|
161
161
|
const { theme, formatter, ticks, series, labelFormatter, selection } = options
|
|
162
162
|
const optionsOverride = options.optionsOverride
|
|
163
|
+
const lockedItems = options.lockedItems
|
|
163
164
|
const selectionSet =
|
|
164
165
|
selection && selection.length > 0 ? new Set<number>(selection) : null
|
|
165
166
|
|
|
@@ -171,6 +172,22 @@ export function createHistogramOptionFactory(
|
|
|
171
172
|
binLabels.push(labelFormatter ? labelFormatter(raw) : raw)
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// Locked range → x-axis crop. `min`/`max` on a category axis accept a
|
|
176
|
+
// category value (the bin label string), bounding the visible window
|
|
177
|
+
// without touching `dataset`/`series` — the zeroed-out bins from
|
|
178
|
+
// `filterHistogramByLockedBins` stay in the data, just scrolled out of
|
|
179
|
+
// view. `null` (not omitted) on unlock, since ECharts' option merge skips
|
|
180
|
+
// `undefined` keys and would otherwise leave a stale crop in place.
|
|
181
|
+
const lockedRange =
|
|
182
|
+
lockedItems && lockedItems.length > 0
|
|
183
|
+
? (() => {
|
|
184
|
+
const indices = lockedItems.map(Number)
|
|
185
|
+
const min = binLabels[Math.min(...indices)]
|
|
186
|
+
const max = binLabels[Math.max(...indices)]
|
|
187
|
+
return min != null && max != null ? { min, max } : null
|
|
188
|
+
})()
|
|
189
|
+
: null
|
|
190
|
+
|
|
174
191
|
return (option, data, ctx) => {
|
|
175
192
|
if (option == null) {
|
|
176
193
|
const structural = histogramOptions({ theme, formatter })
|
|
@@ -254,8 +271,41 @@ export function createHistogramOptionFactory(
|
|
|
254
271
|
},
|
|
255
272
|
}
|
|
256
273
|
|
|
274
|
+
// A consumer's `optionsOverride.xAxis` may legitimately be an array
|
|
275
|
+
// (ECharts supports multiple x-axes on one grid). Merge the locked-range
|
|
276
|
+
// crop into the primary (first) axis instead of discarding the rest via
|
|
277
|
+
// an object fallback — `min`/`max` here bound a category axis by label
|
|
278
|
+
// string, which is only ambiguous if `labelFormatter` produces duplicate
|
|
279
|
+
// labels across bins (a structural limitation of label-string bounds,
|
|
280
|
+
// not fixed here).
|
|
281
|
+
//
|
|
282
|
+
// `min`/`max` are ALWAYS written explicitly below (never omitted) —
|
|
283
|
+
// ECharts' merge silently keeps whatever value it last received for an
|
|
284
|
+
// omitted key, so omitting them while unlocked would leave a stale crop
|
|
285
|
+
// in place from a previous locked render. But the fallback when there's
|
|
286
|
+
// no active crop must be the CONSUMER's own `optionsOverride.xAxis.min/max`
|
|
287
|
+
// (if they set one), not an unconditional `null` — `option.xAxis` here
|
|
288
|
+
// is the post-structural-phase axis, which only ever carries the
|
|
289
|
+
// consumer's static override (never our own prior crop — the structural
|
|
290
|
+
// phase is rebuilt from scratch on every factory recreation, so it can't
|
|
291
|
+
// have picked up a merge-phase write from a previous render).
|
|
292
|
+
const currentAxis = (
|
|
293
|
+
Array.isArray(option.xAxis) ? option.xAxis[0] : option.xAxis
|
|
294
|
+
) as { min?: unknown; max?: unknown } | undefined
|
|
295
|
+
const lockedRangeAxis = {
|
|
296
|
+
min: lockedRange?.min ?? currentAxis?.min ?? null,
|
|
297
|
+
max: lockedRange?.max ?? currentAxis?.max ?? null,
|
|
298
|
+
}
|
|
299
|
+
const nextXAxis = Array.isArray(option.xAxis)
|
|
300
|
+
? [{ ...option.xAxis[0], ...lockedRangeAxis }, ...option.xAxis.slice(1)]
|
|
301
|
+
: {
|
|
302
|
+
...(typeof option.xAxis === 'object' ? option.xAxis : {}),
|
|
303
|
+
...lockedRangeAxis,
|
|
304
|
+
}
|
|
305
|
+
|
|
257
306
|
return {
|
|
258
307
|
...option,
|
|
308
|
+
xAxis: nextXAxis,
|
|
259
309
|
dataset: seriesArr.map((counts) => ({
|
|
260
310
|
source: binLabels.map(
|
|
261
311
|
(label, i) => [label, counts[i] ?? 0] as [string, number],
|