@carto/ps-react-ui 4.17.3 → 4.19.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 (80) hide show
  1. package/dist/category-Ct2BzADB.js +753 -0
  2. package/dist/category-Ct2BzADB.js.map +1 -0
  3. package/dist/change-column-Fu1DCYQ3.js +1186 -0
  4. package/dist/change-column-Fu1DCYQ3.js.map +1 -0
  5. package/dist/echart-B10dhEaM.js +262 -0
  6. package/dist/echart-B10dhEaM.js.map +1 -0
  7. package/dist/types/widgets-v2/actions/change-column/labels.d.ts +1 -0
  8. package/dist/types/widgets-v2/actions/change-column/style.d.ts +28 -2
  9. package/dist/types/widgets-v2/actions/lock-selection/lock-selection.d.ts +15 -5
  10. package/dist/types/widgets-v2/category/components/category-legend.d.ts +18 -2
  11. package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +6 -1
  12. package/dist/types/widgets-v2/category/style.d.ts +8 -0
  13. package/dist/types/widgets-v2/echart/use-chart-selection.d.ts +10 -4
  14. package/dist/types/widgets-v2/histogram/index.d.ts +1 -1
  15. package/dist/types/widgets-v2/histogram/transforms.d.ts +11 -0
  16. package/dist/types/widgets-v2/histogram/types.d.ts +9 -0
  17. package/dist/types/widgets-v2/scatterplot/index.d.ts +1 -1
  18. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +9 -0
  19. package/dist/types/widgets-v2/table/index.d.ts +1 -0
  20. package/dist/types/widgets-v2/table/transforms.d.ts +13 -0
  21. package/dist/types/widgets-v2/table/transforms.test.d.ts +1 -0
  22. package/dist/types/widgets-v2/timeseries/index.d.ts +1 -0
  23. package/dist/types/widgets-v2/timeseries/transforms.d.ts +10 -0
  24. package/dist/types/widgets-v2/timeseries/transforms.test.d.ts +1 -0
  25. package/dist/widgets-v2/actions.js +1 -1
  26. package/dist/widgets-v2/category.js +1 -1
  27. package/dist/widgets-v2/echart.js +1 -1
  28. package/dist/widgets-v2/echart.js.map +1 -1
  29. package/dist/widgets-v2/histogram.js +181 -158
  30. package/dist/widgets-v2/histogram.js.map +1 -1
  31. package/dist/widgets-v2/pie.js +155 -116
  32. package/dist/widgets-v2/pie.js.map +1 -1
  33. package/dist/widgets-v2/scatterplot.js +91 -85
  34. package/dist/widgets-v2/scatterplot.js.map +1 -1
  35. package/dist/widgets-v2/table.js +55 -45
  36. package/dist/widgets-v2/table.js.map +1 -1
  37. package/dist/widgets-v2/timeseries.js +96 -85
  38. package/dist/widgets-v2/timeseries.js.map +1 -1
  39. package/dist/widgets-v2.js +4 -4
  40. package/package.json +1 -1
  41. package/src/widgets-v2/actions/change-column/change-column.tsx +10 -1
  42. package/src/widgets-v2/actions/change-column/labels.ts +2 -0
  43. package/src/widgets-v2/actions/change-column/sortable-column-item.tsx +8 -1
  44. package/src/widgets-v2/actions/change-column/style.ts +28 -2
  45. package/src/widgets-v2/actions/lock-selection/lock-selection.test.tsx +62 -0
  46. package/src/widgets-v2/actions/lock-selection/lock-selection.tsx +45 -13
  47. package/src/widgets-v2/category/category-ui.test.tsx +55 -0
  48. package/src/widgets-v2/category/category-ui.tsx +65 -6
  49. package/src/widgets-v2/category/components/category-legend.test.tsx +53 -2
  50. package/src/widgets-v2/category/components/category-legend.tsx +55 -12
  51. package/src/widgets-v2/category/components/category-row-stacked.test.tsx +7 -3
  52. package/src/widgets-v2/category/components/category-row-stacked.tsx +8 -3
  53. package/src/widgets-v2/category/style.ts +10 -2
  54. package/src/widgets-v2/echart/echart-ui.test.tsx +25 -0
  55. package/src/widgets-v2/echart/echart-ui.tsx +20 -0
  56. package/src/widgets-v2/echart/use-chart-selection.test.tsx +3 -1
  57. package/src/widgets-v2/echart/use-chart-selection.ts +10 -4
  58. package/src/widgets-v2/histogram/index.ts +4 -1
  59. package/src/widgets-v2/histogram/options.test.ts +86 -0
  60. package/src/widgets-v2/histogram/options.ts +50 -0
  61. package/src/widgets-v2/histogram/transforms.test.ts +36 -1
  62. package/src/widgets-v2/histogram/transforms.ts +23 -0
  63. package/src/widgets-v2/histogram/types.ts +9 -0
  64. package/src/widgets-v2/pie/options.test.ts +262 -0
  65. package/src/widgets-v2/pie/options.ts +80 -5
  66. package/src/widgets-v2/scatterplot/index.ts +4 -1
  67. package/src/widgets-v2/scatterplot/transforms.test.ts +38 -1
  68. package/src/widgets-v2/scatterplot/transforms.ts +23 -0
  69. package/src/widgets-v2/table/index.ts +1 -0
  70. package/src/widgets-v2/table/transforms.test.ts +40 -0
  71. package/src/widgets-v2/table/transforms.ts +26 -0
  72. package/src/widgets-v2/timeseries/index.ts +1 -0
  73. package/src/widgets-v2/timeseries/transforms.test.ts +49 -0
  74. package/src/widgets-v2/timeseries/transforms.ts +31 -0
  75. package/dist/category-CGS_eHr4.js +0 -719
  76. package/dist/category-CGS_eHr4.js.map +0 -1
  77. package/dist/change-column-CiVAjOUB.js +0 -1143
  78. package/dist/change-column-CiVAjOUB.js.map +0 -1
  79. package/dist/echart-Bdvbfx9s.js +0 -250
  80. package/dist/echart-Bdvbfx9s.js.map +0 -1
@@ -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],
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { toRelativeHistogramData } from './transforms'
2
+ import {
3
+ toRelativeHistogramData,
4
+ filterHistogramByLockedBins,
5
+ } from './transforms'
3
6
 
4
7
  describe('toRelativeHistogramData', () => {
5
8
  it('rewrites each bin count as its share of the series total in 0-100', () => {
@@ -44,3 +47,35 @@ describe('toRelativeHistogramData', () => {
44
47
  ])
45
48
  })
46
49
  })
50
+
51
+ describe('filterHistogramByLockedBins', () => {
52
+ it('returns input unchanged when locked set is empty', () => {
53
+ const data = [[10, 20, 30]]
54
+ expect(filterHistogramByLockedBins(data, [])).toBe(data)
55
+ })
56
+
57
+ it('zeroes non-locked bin counts, preserving array length and position', () => {
58
+ expect(filterHistogramByLockedBins([[10, 20, 30]], [0, 2])).toEqual([
59
+ [10, 0, 30],
60
+ ])
61
+ })
62
+
63
+ it('handles multiple series independently', () => {
64
+ expect(
65
+ filterHistogramByLockedBins(
66
+ [
67
+ [1, 2, 3],
68
+ [4, 5, 6],
69
+ ],
70
+ [1],
71
+ ),
72
+ ).toEqual([
73
+ [0, 2, 0],
74
+ [0, 5, 0],
75
+ ])
76
+ })
77
+
78
+ it('returns input unchanged when input is not array-shaped', () => {
79
+ expect(filterHistogramByLockedBins('nope', [0])).toBe('nope')
80
+ })
81
+ })
@@ -28,3 +28,26 @@ function isFlatNumberArray(v: unknown): v is number[] {
28
28
  if (!Array.isArray(v)) return false
29
29
  return v.every((item) => typeof item === 'number' && Number.isFinite(item))
30
30
  }
31
+
32
+ /**
33
+ * `LockSelection` filter for Histogram. Data is `number[][]` — a flat list
34
+ * of bin counts per series, positionally paired with `ticks` (bin `i` covers
35
+ * `[ticks[i], ticks[i+1])`). Unlike name-keyed widgets, locked-out bins can't
36
+ * be REMOVED — that would shift every later bin's index and desync it from
37
+ * its `ticks` range. Zeroing them out instead preserves array length/position,
38
+ * so locked bins just render as empty (height 0) rather than disappearing.
39
+ *
40
+ * `lockedItems` holds bin indices (numbers, from Histogram's `selection`).
41
+ */
42
+ export function filterHistogramByLockedBins(
43
+ input: unknown,
44
+ lockedItems: readonly (string | number)[],
45
+ ): unknown {
46
+ if (lockedItems.length === 0) return input
47
+ if (!Array.isArray(input)) return input
48
+ const locked = new Set<number>(lockedItems.map(Number))
49
+ return input.map((series: unknown): unknown => {
50
+ if (!isFlatNumberArray(series)) return series
51
+ return series.map((count, i) => (locked.has(i) ? count : 0))
52
+ })
53
+ }
@@ -44,6 +44,15 @@ export interface HistogramOptionFactoryInput {
44
44
  * (`itemStyle.opacity: 0.15`). `null`/empty means no selection.
45
45
  */
46
46
  selection?: readonly number[] | null
47
+ /**
48
+ * Locked bin indices (from `LockSelection`). The x-axis crops its visible
49
+ * window to `[min(lockedItems), max(lockedItems)]` so the chart actually
50
+ * zooms into the locked range instead of showing every bin label with
51
+ * zeroed-out bars outside it. `null`/empty means no crop. Histogram's
52
+ * selection is always a contiguous run (`use-widget-filters`'s BETWEEN
53
+ * envelope round-trip fills any gaps), so min/max fully describes it.
54
+ */
55
+ lockedItems?: readonly number[] | null
47
56
  /**
48
57
  * Consumer-supplied partial option merged into the structural option at
49
58
  * structural-build time. Lets stories override pieces of the theme-aware
@@ -83,6 +83,20 @@ describe('pieOptions (structural)', () => {
83
83
  // re-emits it as { ...baseLegend, show: true } at fusion time.
84
84
  expect(out.legend?.show).toBe(true)
85
85
  })
86
+
87
+ it('disables legend click-to-toggle', () => {
88
+ const out = pieOptions({ theme }) as {
89
+ legend?: { selectedMode?: boolean }
90
+ }
91
+ expect(out.legend?.selectedMode).toBe(false)
92
+ })
93
+
94
+ it('disables legend hover-highlight on the series template (no legend-level knob in ECharts)', () => {
95
+ const out = pieOptions({ theme }) as {
96
+ series?: { legendHoverLink?: boolean }[]
97
+ }
98
+ expect(out.series?.[0]?.legendHoverLink).toBe(false)
99
+ })
86
100
  })
87
101
 
88
102
  describe('createPieOptionFactory — single-series (donut)', () => {
@@ -147,6 +161,16 @@ describe('createPieOptionFactory — single-series (donut)', () => {
147
161
  expect(single.legend?.show).toBe(true)
148
162
  })
149
163
 
164
+ it('keeps legend interactivity disabled after fusion', () => {
165
+ const merge = createPieOptionFactory({ theme })
166
+ const single = merge({}, [[{ name: 'A', value: 1 }]]) as {
167
+ legend?: { selectedMode?: boolean }
168
+ series?: { legendHoverLink?: boolean }[]
169
+ }
170
+ expect(single.legend?.selectedMode).toBe(false)
171
+ expect(single.series?.[0]?.legendHoverLink).toBe(false)
172
+ })
173
+
150
174
  it('uses series[0].name for the single series name when provided', () => {
151
175
  const merge = createPieOptionFactory({
152
176
  theme,
@@ -303,6 +327,215 @@ describe('createPieOptionFactory — single-series (donut)', () => {
303
327
  ).toBe('#FACADE')
304
328
  })
305
329
 
330
+ it('label formatter renders text only for the max-valued slice when no selection is passed (regression)', () => {
331
+ const merge = createPieOptionFactory({ theme })
332
+ const out = merge({}, [
333
+ [
334
+ { name: 'A', value: 10 },
335
+ { name: 'B', value: 25 },
336
+ ],
337
+ ]) as {
338
+ series: { label: { formatter: (p: Record<string, unknown>) => string } }[]
339
+ }
340
+ const fmt = out.series[0]!.label.formatter
341
+ expect(
342
+ fmt({
343
+ name: 'A',
344
+ encode: { value: [1] },
345
+ data: { name: 'A', value: 10 },
346
+ }),
347
+ ).toBe('')
348
+ expect(
349
+ fmt({
350
+ name: 'B',
351
+ encode: { value: [1] },
352
+ data: { name: 'B', value: 25 },
353
+ }),
354
+ ).not.toBe('')
355
+ })
356
+
357
+ it('label formatter shows the centre value only for the max-valued INCLUDED slice (direct/inclusive selection, computed locally)', () => {
358
+ const merge = createPieOptionFactory({ theme, selection: ['A', 'B'] })
359
+ const out = merge({}, [
360
+ [
361
+ { name: 'A', value: 10 },
362
+ { name: 'B', value: 25 },
363
+ { name: 'C', value: 100 },
364
+ ],
365
+ ]) as {
366
+ series: { label: { formatter: (p: Record<string, unknown>) => string } }[]
367
+ }
368
+ const fmt = out.series[0]!.label.formatter
369
+ // C has the highest value overall but is not included in the selection,
370
+ // so it's excluded from the max computation — B (the max of A/B) wins.
371
+ expect(
372
+ fmt({
373
+ name: 'A',
374
+ encode: { value: [1] },
375
+ data: { name: 'A', value: 10 },
376
+ }),
377
+ ).toBe('')
378
+ expect(
379
+ fmt({
380
+ name: 'B',
381
+ encode: { value: [1] },
382
+ data: { name: 'B', value: 25 },
383
+ }),
384
+ ).not.toBe('')
385
+ expect(
386
+ fmt({
387
+ name: 'C',
388
+ encode: { value: [1] },
389
+ data: { name: 'C', value: 100 },
390
+ }),
391
+ ).toBe('')
392
+ })
393
+
394
+ it('label formatter treats an explicit empty selection as no selection (renders the true max, not blank)', () => {
395
+ const merge = createPieOptionFactory({ theme, selection: [] })
396
+ const out = merge({}, [[{ name: 'A', value: 10 }]]) as {
397
+ series: { label: { formatter: (p: Record<string, unknown>) => string } }[]
398
+ }
399
+ const html = out.series[0]!.label.formatter({
400
+ name: 'A',
401
+ encode: { value: [1] },
402
+ data: { name: 'A', value: 10 },
403
+ })
404
+ expect(html).not.toBe('')
405
+ })
406
+
407
+ it('itemStyle color dims a slice NOT in the selection (direct/inclusive model, matches multi-series bar)', () => {
408
+ const merge = createPieOptionFactory({ theme, selection: ['A'] })
409
+ const out = merge({ color: ['#AAA', '#BBB'] }, [
410
+ [
411
+ { name: 'A', value: 10 },
412
+ { name: 'B', value: 5 },
413
+ ],
414
+ ]) as {
415
+ series: {
416
+ itemStyle: {
417
+ color: (p: {
418
+ dataIndex: number
419
+ color: string
420
+ value: object
421
+ name: string
422
+ }) => string
423
+ }
424
+ }[]
425
+ }
426
+ const colorFn = out.series[0]!.itemStyle.color
427
+ expect(
428
+ colorFn({
429
+ dataIndex: 0,
430
+ color: '#000',
431
+ value: { name: 'A', value: 10 },
432
+ name: 'A',
433
+ }),
434
+ ).toBe('#AAA')
435
+ const dimmedB = colorFn({
436
+ dataIndex: 1,
437
+ color: '#000',
438
+ value: { name: 'B', value: 5 },
439
+ name: 'B',
440
+ })
441
+ expect(dimmedB).not.toBe('#BBB')
442
+ })
443
+
444
+ it('itemStyle color dims neither slice when both are in a multi-item selection (regression: locked-then-searched-and-reselected)', () => {
445
+ const merge = createPieOptionFactory({ theme, selection: ['A', 'B'] })
446
+ const out = merge({ color: ['#AAA', '#BBB'] }, [
447
+ [
448
+ { name: 'A', value: 10 },
449
+ { name: 'B', value: 5 },
450
+ ],
451
+ ]) as {
452
+ series: {
453
+ itemStyle: {
454
+ color: (p: {
455
+ dataIndex: number
456
+ color: string
457
+ value: object
458
+ name: string
459
+ }) => string
460
+ }
461
+ }[]
462
+ }
463
+ const colorFn = out.series[0]!.itemStyle.color
464
+ expect(
465
+ colorFn({
466
+ dataIndex: 0,
467
+ color: '#000',
468
+ value: { name: 'A', value: 10 },
469
+ name: 'A',
470
+ }),
471
+ ).toBe('#AAA')
472
+ expect(
473
+ colorFn({
474
+ dataIndex: 1,
475
+ color: '#000',
476
+ value: { name: 'B', value: 5 },
477
+ name: 'B',
478
+ }),
479
+ ).toBe('#BBB')
480
+ })
481
+
482
+ it('itemStyle color dims nothing when selection is an explicit empty array (renders fully active, not all-dimmed)', () => {
483
+ const merge = createPieOptionFactory({ theme, selection: [] })
484
+ const out = merge({ color: ['#AAA', '#BBB'] }, [
485
+ [
486
+ { name: 'A', value: 10 },
487
+ { name: 'B', value: 5 },
488
+ ],
489
+ ]) as {
490
+ series: {
491
+ itemStyle: {
492
+ color: (p: {
493
+ dataIndex: number
494
+ color: string
495
+ value: object
496
+ name: string
497
+ }) => string
498
+ }
499
+ }[]
500
+ }
501
+ const colorFn = out.series[0]!.itemStyle.color
502
+ expect(
503
+ colorFn({
504
+ dataIndex: 0,
505
+ color: '#000',
506
+ value: { name: 'A', value: 10 },
507
+ name: 'A',
508
+ }),
509
+ ).toBe('#AAA')
510
+ expect(
511
+ colorFn({
512
+ dataIndex: 1,
513
+ color: '#000',
514
+ value: { name: 'B', value: 5 },
515
+ name: 'B',
516
+ }),
517
+ ).toBe('#BBB')
518
+ })
519
+
520
+ it('never sets legend.selected to false for a dimmed slice (would trigger ECharts native removal instead of dimming)', () => {
521
+ const merge = createPieOptionFactory({ theme, selection: ['A'] })
522
+ const out = merge({}, [
523
+ [
524
+ { name: 'A', value: 10 },
525
+ { name: 'B', value: 5 },
526
+ ],
527
+ ]) as { legend?: { selected?: Record<string, boolean> } }
528
+ expect(out.legend?.selected).toEqual({ A: true, B: true })
529
+ })
530
+
531
+ it('forces legend.selected back to true even with no active selection (overrides ECharts internal legend-click toggle state)', () => {
532
+ const merge = createPieOptionFactory({ theme })
533
+ const out = merge({}, [[{ name: 'A', value: 10 }]]) as {
534
+ legend?: { selected?: Record<string, boolean> }
535
+ }
536
+ expect(out.legend?.selected).toEqual({ A: true })
537
+ })
538
+
306
539
  it('re-emits tooltip.formatter at fusion time so RelativeData (ctx.formatter) flows through', () => {
307
540
  const merge = createPieOptionFactory({ theme })
308
541
  const out = merge(
@@ -539,6 +772,35 @@ describe('createPieOptionFactory — multi-series fallback (horizontal bar)', ()
539
772
  expect(out.legend?.show).toBe(true)
540
773
  })
541
774
 
775
+ it('keeps legend interactivity disabled in the multi-series bar fallback', () => {
776
+ const merge = createPieOptionFactory({ theme })
777
+ const out = merge({}, MULTI) as {
778
+ legend?: { selectedMode?: boolean }
779
+ series?: { legendHoverLink?: boolean }[]
780
+ }
781
+ expect(out.legend?.selectedMode).toBe(false)
782
+ expect(out.series?.[0]?.legendHoverLink).toBe(false)
783
+ })
784
+
785
+ it('forces legend.selected back to true for every series (mirrors the donut branch — selectedMode:false blocks the native click that would otherwise undo a toggle)', () => {
786
+ const merge = createPieOptionFactory({
787
+ theme,
788
+ series: [{ name: '2024' }, { name: '2025' }],
789
+ })
790
+ const out = merge({}, MULTI) as {
791
+ legend?: { selected?: Record<string, boolean> }
792
+ }
793
+ expect(out.legend?.selected).toEqual({ '2024': true, '2025': true })
794
+ })
795
+
796
+ it('falls back to Series N for legend.selected keys when series metadata is omitted', () => {
797
+ const merge = createPieOptionFactory({ theme })
798
+ const out = merge({}, MULTI) as {
799
+ legend?: { selected?: Record<string, boolean> }
800
+ }
801
+ expect(out.legend?.selected).toEqual({ 'Series 1': true, 'Series 2': true })
802
+ })
803
+
542
804
  describe('horizontal-bar tooltip formatter', () => {
543
805
  type AxisFormatter = (
544
806
  params: unknown,