@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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
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,
|
|
@@ -55,6 +55,13 @@ export function pieOptions({
|
|
|
55
55
|
left: 'center',
|
|
56
56
|
type: 'scroll',
|
|
57
57
|
itemGap: 16,
|
|
58
|
+
// The legend is presentational only here — the pie has its own
|
|
59
|
+
// click-to-select affordance on the slices themselves, so legend
|
|
60
|
+
// clicks toggling series visibility would be a second, redundant/
|
|
61
|
+
// conflicting interaction model. Hover-highlight is disabled per
|
|
62
|
+
// series via `legendHoverLink` (ECharts has no legend-level knob
|
|
63
|
+
// for it).
|
|
64
|
+
selectedMode: false,
|
|
58
65
|
},
|
|
59
66
|
tooltip: {
|
|
60
67
|
// Pie uses item-trigger (no axis); the rest of the styling mirrors
|
|
@@ -89,6 +96,7 @@ export function pieOptions({
|
|
|
89
96
|
colorBy: 'data',
|
|
90
97
|
avoidLabelOverlap: true,
|
|
91
98
|
selectedOffset: 0,
|
|
99
|
+
legendHoverLink: false,
|
|
92
100
|
emphasis: { disabled: true },
|
|
93
101
|
itemStyle: {
|
|
94
102
|
borderColor: theme.palette.background.paper,
|
|
@@ -129,10 +137,14 @@ export function createPieOptionFactory(
|
|
|
129
137
|
const { theme, formatter, labelFormatter, optionsOverride, series } = options
|
|
130
138
|
const radius = options.radius ?? DEFAULT_RADIUS
|
|
131
139
|
const selection = options.selection
|
|
140
|
+
// An explicit `[]` (deselected down to zero via chart clicks) is treated
|
|
141
|
+
// the same as no selection: the pie has no bar/histogram-style "nothing
|
|
142
|
+
// selected" affordance, so dimming every slice would just look broken —
|
|
143
|
+
// it renders fully active, same as the no-interaction state.
|
|
132
144
|
const selectionSet =
|
|
133
|
-
selection
|
|
134
|
-
?
|
|
135
|
-
:
|
|
145
|
+
selection === undefined || selection === null || selection.length === 0
|
|
146
|
+
? null
|
|
147
|
+
: new Set<string | number>(selection)
|
|
136
148
|
return (option, data, ctx) => {
|
|
137
149
|
if (option == null) {
|
|
138
150
|
const structural = pieOptions({ theme, formatter, labelFormatter })
|
|
@@ -217,6 +229,13 @@ function buildSingleSeriesPieFusion(
|
|
|
217
229
|
// not conditionally — same anti-stale-callback rationale as bar /
|
|
218
230
|
// histogram. The slice border + width come from the structural
|
|
219
231
|
// template's `itemStyle` and survive via the spread below.
|
|
232
|
+
//
|
|
233
|
+
// `selectionSet` is the *included* set, same direct semantics as
|
|
234
|
+
// bar/histogram/timeseries (no ADR-014 exclusion inversion). Dim a slice
|
|
235
|
+
// when its name is NOT in the set. `selectionSet` is never a non-null
|
|
236
|
+
// empty Set — the collapse above (`selection.length === 0 → null`) rules
|
|
237
|
+
// that out — so there's no "dim everything" case here, unlike a widget
|
|
238
|
+
// that gives `[]` its own "selected down to nothing" meaning.
|
|
220
239
|
const colorFn = (params: CallbackDataParams): string => {
|
|
221
240
|
const base = resolvePaletteColor(params)
|
|
222
241
|
if (!selectionSet) return base
|
|
@@ -227,12 +246,33 @@ function buildSingleSeriesPieFusion(
|
|
|
227
246
|
: echarts.color.modifyAlpha(base, 0.15)
|
|
228
247
|
}
|
|
229
248
|
|
|
249
|
+
// Which slice renders the centre value+name: the greatest value among
|
|
250
|
+
// the *included* slices (all of them when there's no active
|
|
251
|
+
// selection), computed locally — no query round-trip. Only that one
|
|
252
|
+
// slice's formatter call returns text; every other slice returns ''
|
|
253
|
+
// so exactly one label ever renders at the shared centre anchor. Only
|
|
254
|
+
// `typeof value === 'number'` participates — if every included slice's
|
|
255
|
+
// value is non-numeric, `centerMaxName` stays `undefined` and no slice
|
|
256
|
+
// matches it in `labelTextFormatter` below, so the chart renders with no
|
|
257
|
+
// centre label at all (graceful no-op, not a crash).
|
|
258
|
+
const centerData = seriesArr[0] ?? []
|
|
259
|
+
let centerMaxName: string | number | undefined
|
|
260
|
+
let centerMaxValue = -Infinity
|
|
261
|
+
for (const d of centerData) {
|
|
262
|
+
if (selectionSet && !selectionSet.has(d.name)) continue
|
|
263
|
+
if (typeof d.value === 'number' && d.value > centerMaxValue) {
|
|
264
|
+
centerMaxValue = d.value
|
|
265
|
+
centerMaxName = d.name
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
230
269
|
// Center label formatter — lives in the merger because it reads
|
|
231
270
|
// reactive `formatter` / `labelFormatter` from ctx. The rich tags
|
|
232
271
|
// `{c|…}` and `{b|…}` reference the rich styles baked into the
|
|
233
272
|
// structural label template.
|
|
234
273
|
const labelTextFormatter = (params: CallbackDataParams): string => {
|
|
235
274
|
const { name } = params
|
|
275
|
+
if (name == null || name !== centerMaxName) return ''
|
|
236
276
|
const encodeIndex = params.encode?.value?.[0]
|
|
237
277
|
if (encodeIndex === undefined) return ''
|
|
238
278
|
const value = (Object.values(params.data ?? {}) as unknown[]).at(
|
|
@@ -269,6 +309,7 @@ function buildSingleSeriesPieFusion(
|
|
|
269
309
|
return {
|
|
270
310
|
...templateObj,
|
|
271
311
|
type: 'pie' as const,
|
|
312
|
+
legendHoverLink: false,
|
|
272
313
|
datasetIndex: i,
|
|
273
314
|
name: series?.[i]?.name ?? `Series ${i + 1}`,
|
|
274
315
|
radius: [...radius],
|
|
@@ -291,7 +332,27 @@ function buildSingleSeriesPieFusion(
|
|
|
291
332
|
}),
|
|
292
333
|
// Legend always shows for pie (mirrors v1) — slice names drive the
|
|
293
334
|
// entries, so even a single donut benefits from a category list.
|
|
294
|
-
|
|
335
|
+
//
|
|
336
|
+
// Exclusion is conveyed purely via `colorFn`/`labelTextFormatter`
|
|
337
|
+
// dimming above, never via the legend's `selected` map: for a pie
|
|
338
|
+
// series, ECharts treats `legend.selected[name] = false` as "remove
|
|
339
|
+
// this data point," which recomputes every remaining slice's angle —
|
|
340
|
+
// the excluded slice disappears instead of rendering dimmed. Forcing
|
|
341
|
+
// every name to `true` on every render also overrides ECharts' own
|
|
342
|
+
// internal toggle state from a native legend click (which flips a
|
|
343
|
+
// name to `false` and hides it before our `legendselectchanged`
|
|
344
|
+
// handler gets a chance to react), so a legend click never leaves a
|
|
345
|
+
// slice structurally removed — only the color/label dimming reflects
|
|
346
|
+
// exclusion, and it stays in sync with the slice itself since both
|
|
347
|
+
// read `selectionSet` on the same render.
|
|
348
|
+
legend: {
|
|
349
|
+
...baseLegend,
|
|
350
|
+
show: true,
|
|
351
|
+
selectedMode: false,
|
|
352
|
+
selected: Object.fromEntries(
|
|
353
|
+
(seriesArr[0] ?? []).map((d) => [String(d.name), true]),
|
|
354
|
+
),
|
|
355
|
+
},
|
|
295
356
|
tooltip: {
|
|
296
357
|
...baseTooltip,
|
|
297
358
|
formatter: buildPieTooltipFormatter(formatter, labelFormatter),
|
|
@@ -372,6 +433,7 @@ function buildMultiSeriesBarFusion(
|
|
|
372
433
|
type: 'bar' as const,
|
|
373
434
|
name: series?.[i]?.name ?? `Series ${i + 1}`,
|
|
374
435
|
barMaxWidth: 100,
|
|
436
|
+
legendHoverLink: false,
|
|
375
437
|
emphasis: { focus: 'series' },
|
|
376
438
|
...buildSeriesLabelConfig(formatter, 'x'),
|
|
377
439
|
itemStyle: { color: barColorFn },
|
|
@@ -422,7 +484,20 @@ function buildMultiSeriesBarFusion(
|
|
|
422
484
|
}),
|
|
423
485
|
},
|
|
424
486
|
},
|
|
425
|
-
|
|
487
|
+
// Mirrors the donut branch's `selected` reset above — `selectedMode:
|
|
488
|
+
// false` blocks the native legend click that would otherwise undo an
|
|
489
|
+
// ECharts-internal toggle, so without forcing every entry back to
|
|
490
|
+
// `true` here a prior native toggle (from before `selectedMode` took
|
|
491
|
+
// effect, or a stale `keepAlive` instance) would stay hidden forever
|
|
492
|
+
// with no way to restore it.
|
|
493
|
+
legend: {
|
|
494
|
+
...baseLegend,
|
|
495
|
+
show: true,
|
|
496
|
+
selectedMode: false,
|
|
497
|
+
selected: Object.fromEntries(
|
|
498
|
+
seriesArr.map((_, i) => [series?.[i]?.name ?? `Series ${i + 1}`, true]),
|
|
499
|
+
),
|
|
500
|
+
},
|
|
426
501
|
tooltip: {
|
|
427
502
|
...baseTooltip,
|
|
428
503
|
trigger: 'axis',
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export { scatterplotOptions, createScatterplotOptionFactory } from './options'
|
|
2
2
|
export { ScatterplotSkeleton, type ScatterplotSkeletonProps } from './skeleton'
|
|
3
3
|
export { createScatterplotDownloadConfig } from './download'
|
|
4
|
-
export {
|
|
4
|
+
export {
|
|
5
|
+
toRelativeScatterplotData,
|
|
6
|
+
filterScatterplotByLockedPoints,
|
|
7
|
+
} from './transforms'
|
|
5
8
|
export type {
|
|
6
9
|
ScatterplotDatum,
|
|
7
10
|
ScatterplotWidgetData,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
toRelativeScatterplotData,
|
|
4
|
+
filterScatterplotByLockedPoints,
|
|
5
|
+
} from './transforms'
|
|
3
6
|
|
|
4
7
|
describe('toRelativeScatterplotData', () => {
|
|
5
8
|
it('rewrites each tuple y as its share of the series total in 0-100, x stays raw', () => {
|
|
@@ -95,3 +98,37 @@ describe('toRelativeScatterplotData', () => {
|
|
|
95
98
|
])
|
|
96
99
|
})
|
|
97
100
|
})
|
|
101
|
+
|
|
102
|
+
describe('filterScatterplotByLockedPoints', () => {
|
|
103
|
+
const DATA = [
|
|
104
|
+
[
|
|
105
|
+
[1, 10],
|
|
106
|
+
[2, 20],
|
|
107
|
+
[3, 30],
|
|
108
|
+
],
|
|
109
|
+
[
|
|
110
|
+
[4, 40],
|
|
111
|
+
[5, 50],
|
|
112
|
+
],
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
it('returns input unchanged when locked set is empty', () => {
|
|
116
|
+
expect(filterScatterplotByLockedPoints(DATA, [])).toBe(DATA)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('keeps only points whose `${seriesIndex}:${dataIndex}` key is locked', () => {
|
|
120
|
+
expect(
|
|
121
|
+
filterScatterplotByLockedPoints(DATA, ['0:0', '0:2', '1:1']),
|
|
122
|
+
).toEqual([
|
|
123
|
+
[
|
|
124
|
+
[1, 10],
|
|
125
|
+
[3, 30],
|
|
126
|
+
],
|
|
127
|
+
[[5, 50]],
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('returns input unchanged when input is not array-shaped', () => {
|
|
132
|
+
expect(filterScatterplotByLockedPoints('nope', ['0:0'])).toBe('nope')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -36,3 +36,26 @@ function isXyTupleArray(v: unknown): v is [number, number][] {
|
|
|
36
36
|
Number.isFinite(item[1]),
|
|
37
37
|
)
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `LockSelection` filter for Scatterplot. Data is `[number, number][][]` —
|
|
42
|
+
* points have no `name`, so selection (and locking) is keyed on a synthetic
|
|
43
|
+
* `${seriesIndex}:${dataIndex}` string (same format `useChartSelection`'s
|
|
44
|
+
* `keyFromClick`/`keysFromBrush` produce for this widget). Unlike Histogram,
|
|
45
|
+
* a point's position carries no external meaning (no `ticks` to desync from),
|
|
46
|
+
* so non-locked points are removed outright rather than zeroed.
|
|
47
|
+
*/
|
|
48
|
+
export function filterScatterplotByLockedPoints(
|
|
49
|
+
input: unknown,
|
|
50
|
+
lockedItems: readonly (string | number)[],
|
|
51
|
+
): unknown {
|
|
52
|
+
if (lockedItems.length === 0) return input
|
|
53
|
+
if (!Array.isArray(input)) return input
|
|
54
|
+
const locked = new Set<string>(lockedItems.map(String))
|
|
55
|
+
return input.map((series: unknown, seriesIndex: number): unknown => {
|
|
56
|
+
if (!isXyTupleArray(series)) return series
|
|
57
|
+
return series.filter((_, dataIndex) =>
|
|
58
|
+
locked.has(`${seriesIndex}:${dataIndex}`),
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
@@ -2,6 +2,7 @@ export { Table, type TableProps } from './table'
|
|
|
2
2
|
export { TableUI, type TableUIProps } from './table-ui'
|
|
3
3
|
export { TableSkeleton, type TableSkeletonProps } from './skeleton'
|
|
4
4
|
export { createTableDownloadConfig } from './download'
|
|
5
|
+
export { filterTableByLockedRows } from './transforms'
|
|
5
6
|
export {
|
|
6
7
|
compareValues,
|
|
7
8
|
sortRows,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterTableByLockedRows } from './transforms'
|
|
3
|
+
|
|
4
|
+
describe('filterTableByLockedRows', () => {
|
|
5
|
+
const ROWS = [
|
|
6
|
+
{ id: 'a', label: 'Alpha' },
|
|
7
|
+
{ id: 'b', label: 'Beta' },
|
|
8
|
+
{ id: 'c', label: 'Gamma' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
it('returns input unchanged when locked set is empty', () => {
|
|
12
|
+
expect(filterTableByLockedRows(ROWS, [])).toBe(ROWS)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('keeps only rows whose default `id` column is locked', () => {
|
|
16
|
+
expect(filterTableByLockedRows(ROWS, ['a', 'c'])).toEqual([
|
|
17
|
+
{ id: 'a', label: 'Alpha' },
|
|
18
|
+
{ id: 'c', label: 'Gamma' },
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('uses a custom keyColumn when supplied', () => {
|
|
23
|
+
const rows = [
|
|
24
|
+
{ sku: 'X1', name: 'Widget' },
|
|
25
|
+
{ sku: 'X2', name: 'Gadget' },
|
|
26
|
+
]
|
|
27
|
+
expect(filterTableByLockedRows(rows, ['X2'], 'sku')).toEqual([
|
|
28
|
+
{ sku: 'X2', name: 'Gadget' },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('falls back to row index when the identity column is nullish', () => {
|
|
33
|
+
const rows = [{ label: 'first' }, { label: 'second' }]
|
|
34
|
+
expect(filterTableByLockedRows(rows, [1])).toEqual([{ label: 'second' }])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns input unchanged when input is not array-shaped', () => {
|
|
38
|
+
expect(filterTableByLockedRows('nope', ['a'])).toBe('nope')
|
|
39
|
+
})
|
|
40
|
+
})
|