@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.
Files changed (91) hide show
  1. package/dist/category-Ct2BzADB.js +753 -0
  2. package/dist/category-Ct2BzADB.js.map +1 -0
  3. package/dist/change-column-Bhcxmp-r.js +1148 -0
  4. package/dist/change-column-Bhcxmp-r.js.map +1 -0
  5. package/dist/echart-B10dhEaM.js +262 -0
  6. package/dist/echart-B10dhEaM.js.map +1 -0
  7. package/dist/legend/stores.js +1 -1
  8. package/dist/{legend-store-registry-Bo8U_qWM.js → legend-store-registry-p1tSwJXH.js} +151 -147
  9. package/dist/legend-store-registry-p1tSwJXH.js.map +1 -0
  10. package/dist/legend.js +473 -415
  11. package/dist/legend.js.map +1 -1
  12. package/dist/types/legend/components/legend-actions/legend-actions.d.ts +2 -2
  13. package/dist/types/legend/components/legend-group/styles.d.ts +31 -20
  14. package/dist/types/legend/components/legend-row/styles.d.ts +24 -4
  15. package/dist/types/legend/components/legend-sortable/styles.d.ts +4 -3
  16. package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
  17. package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
  18. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
  19. package/dist/types/widgets-v2/category/style.d.ts +8 -0
  20. package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
  21. package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
  22. package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
  23. package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
  24. package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
  25. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
  26. package/dist/types/widgets-v2/table/index.d.ts +1 -0
  27. package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
  28. package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
  29. package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
  30. package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
  31. package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
  32. package/dist/widgets-v2/actions.js +1 -1
  33. package/dist/widgets-v2/category.js +1 -1
  34. package/dist/widgets-v2/echart.js +1 -1
  35. package/dist/widgets-v2/echart.js.map +1 -1
  36. package/dist/widgets-v2/histogram.js +181 -158
  37. package/dist/widgets-v2/histogram.js.map +1 -1
  38. package/dist/widgets-v2/pie.js +155 -116
  39. package/dist/widgets-v2/pie.js.map +1 -1
  40. package/dist/widgets-v2/scatterplot.js +91 -85
  41. package/dist/widgets-v2/scatterplot.js.map +1 -1
  42. package/dist/widgets-v2/table.js +55 -45
  43. package/dist/widgets-v2/table.js.map +1 -1
  44. package/dist/widgets-v2/timeseries.js +96 -85
  45. package/dist/widgets-v2/timeseries.js.map +1 -1
  46. package/dist/widgets-v2.js +4 -4
  47. package/package.json +3 -3
  48. package/src/legend/components/legend-actions/legend-actions.tsx +2 -2
  49. package/src/legend/components/legend-group/styles.ts +59 -36
  50. package/src/legend/components/legend-row/legend-row.tsx +10 -7
  51. package/src/legend/components/legend-row/styles.ts +41 -10
  52. package/src/legend/components/legend-sortable/styles.ts +13 -4
  53. package/src/legend/stores/legend-store-registry.ts +28 -4
  54. package/src/legend/stores/legend-store.test.ts +23 -0
  55. package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
  56. package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
  57. package/src/widgets-v2/category/category-ui.test.tsx +55 -0
  58. package/src/widgets-v2/category/category-ui.tsx +65 -6
  59. package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
  60. package/src/widgets-v2/category/components/category-legend.tsx +55 -12
  61. package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
  62. package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
  63. package/src/widgets-v2/category/style.ts +10 -2
  64. package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
  65. package/src/widgets-v2/echart/echart-ui.tsx +20 -0
  66. package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
  67. package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
  68. package/src/widgets-v2/histogram/index.ts +4 -1
  69. package/src/widgets-v2/histogram/options.test.ts +86 -0
  70. package/src/widgets-v2/histogram/options.ts +50 -0
  71. package/src/widgets-v2/histogram/transforms.test.ts +36 -1
  72. package/src/widgets-v2/histogram/transforms.ts +23 -0
  73. package/src/widgets-v2/histogram/types.ts +9 -0
  74. package/src/widgets-v2/pie/options.test.ts +262 -0
  75. package/src/widgets-v2/pie/options.ts +80 -5
  76. package/src/widgets-v2/scatterplot/index.ts +4 -1
  77. package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
  78. package/src/widgets-v2/scatterplot/transforms.ts +23 -0
  79. package/src/widgets-v2/table/index.ts +1 -0
  80. package/src/widgets-v2/table/transforms.test.ts +40 -0
  81. package/src/widgets-v2/table/transforms.ts +26 -0
  82. package/src/widgets-v2/timeseries/index.ts +1 -0
  83. package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
  84. package/src/widgets-v2/timeseries/transforms.ts +31 -0
  85. package/dist/category-CGS_eHr4.js +0 -719
  86. package/dist/category-CGS_eHr4.js.map +0 -1
  87. package/dist/change-column-CiVAjOUB.js +0 -1143
  88. package/dist/change-column-CiVAjOUB.js.map +0 -1
  89. package/dist/echart-Bdvbfx9s.js +0 -250
  90. package/dist/echart-Bdvbfx9s.js.map +0 -1
  91. 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={row.values.map((_, i) => fill(i))}
346
- seriesNames={series?.map((s) => s.name) ?? []}
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={row.values.map((_, i) => fill(i))}
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 series={series} colorAt={(i) => colorAt(i)} />
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 series={[]} colorAt={noColors} />,
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({ series, colorAt }: CategoryLegendProps) {
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
- <Box key={s.name} sx={styles.legendItem}>
49
+ {series.map((s, i) => {
50
+ const hidden = hiddenSeries.has(i)
51
+ return (
28
52
  <Box
29
- sx={styles.legendDot}
30
- style={{ backgroundColor: s.color ?? colorAt(i) }}
31
- />
32
- <Typography variant='caption' sx={styles.legendLabel}>
33
- {s.name}
34
- </Typography>
35
- </Box>
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('falls back to "Series N" for missing series names (1-indexed)', () => {
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 2: 32')).toBeTruthy()
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
- /** Series names for the breakdown row. Missing entries fall back to `Series N`. */
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] ?? `series-${i}`}`}
94
+ key={`breakdown-${seriesNames[i]}`}
90
95
  variant='body2'
91
96
  sx={styles.stackedBreakdownItem}
92
97
  >
93
- {`${seriesNames[i] ?? `Series ${i + 1}`}: ${formatter(v)}`}
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: 8,
266
- height: 8,
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('reports null `selection` when the consumer passes nothing or an empty list', () => {
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
- /** Controlled selection. `undefined` is treated the same as `[]`. */
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` when the consumer didn't pass one or it's
36
- * empty. Mergers should treat `null` as "no dimming" and a non-null array
37
- * as "render only these as fully opaque, dim the rest".
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 { toRelativeHistogramData } from './transforms'
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],