@carto/ps-react-ui 5.3.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{actions-Dhlz_adK.js → actions-CwjqEm8Y.js} +56 -46
- package/dist/actions-CwjqEm8Y.js.map +1 -0
- package/dist/category-C6myUFUJ.js +936 -0
- package/dist/category-C6myUFUJ.js.map +1 -0
- package/dist/item-color-ZYxH3NM5.js +20 -0
- package/dist/item-color-ZYxH3NM5.js.map +1 -0
- package/dist/types/widgets-v2/bar/types.d.ts +6 -0
- package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
- package/dist/types/widgets-v2/category/category.d.ts +7 -1
- package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
- package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
- package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
- package/dist/types/widgets-v2/category/style.d.ts +33 -6
- package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
- package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
- package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
- package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
- package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
- package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
- package/dist/types/widgets-v2/index.d.ts +1 -1
- package/dist/types/widgets-v2/pie/types.d.ts +6 -0
- package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
- package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
- package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
- package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
- package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
- package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
- package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/bar.js +66 -57
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/histogram.js +103 -71
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/pie.js +98 -78
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +119 -79
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +70 -61
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +2 -2
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +3 -3
- package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
- package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
- package/src/widgets-v2/bar/options.test.ts +106 -0
- package/src/widgets-v2/bar/options.ts +25 -14
- package/src/widgets-v2/bar/types.ts +6 -0
- package/src/widgets-v2/category/category-ui.test.tsx +139 -0
- package/src/widgets-v2/category/category-ui.tsx +64 -11
- package/src/widgets-v2/category/category.test.tsx +67 -0
- package/src/widgets-v2/category/category.tsx +32 -15
- package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
- package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
- package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
- package/src/widgets-v2/category/components/category-bar.tsx +65 -9
- package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
- package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
- package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
- package/src/widgets-v2/category/style.ts +58 -14
- package/src/widgets-v2/histogram/bins.ts +22 -0
- package/src/widgets-v2/histogram/download.test.ts +10 -0
- package/src/widgets-v2/histogram/download.ts +7 -3
- package/src/widgets-v2/histogram/index.ts +3 -0
- package/src/widgets-v2/histogram/options.test.ts +62 -0
- package/src/widgets-v2/histogram/options.ts +38 -17
- package/src/widgets-v2/histogram/transforms.test.ts +21 -7
- package/src/widgets-v2/histogram/transforms.ts +26 -12
- package/src/widgets-v2/histogram/types.ts +25 -5
- package/src/widgets-v2/index.ts +6 -1
- package/src/widgets-v2/pie/options.test.ts +91 -0
- package/src/widgets-v2/pie/options.ts +50 -20
- package/src/widgets-v2/pie/types.ts +6 -0
- package/src/widgets-v2/scatterplot/download.test.ts +9 -0
- package/src/widgets-v2/scatterplot/download.ts +5 -3
- package/src/widgets-v2/scatterplot/index.ts +3 -0
- package/src/widgets-v2/scatterplot/options.test.ts +78 -0
- package/src/widgets-v2/scatterplot/options.ts +35 -11
- package/src/widgets-v2/scatterplot/points.ts +35 -0
- package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
- package/src/widgets-v2/scatterplot/transforms.ts +23 -18
- package/src/widgets-v2/scatterplot/types.ts +25 -2
- package/src/widgets-v2/timeseries/options.test.ts +65 -0
- package/src/widgets-v2/timeseries/options.ts +31 -14
- package/src/widgets-v2/timeseries/types.ts +6 -0
- package/src/widgets-v2/utils/item-color.test.ts +74 -0
- package/src/widgets-v2/utils/item-color.ts +55 -0
- package/dist/actions-Dhlz_adK.js.map +0 -1
- package/dist/category-D01ruG-l.js +0 -842
- package/dist/category-D01ruG-l.js.map +0 -1
|
@@ -130,26 +130,90 @@ describe('<FullScreen.Slot> — the dialog runs on its own store', () => {
|
|
|
130
130
|
// Legend / dataZoom / brush live in the chart instance, not the store.
|
|
131
131
|
// The Slot moves them through the instance registry, so Widget.Echart
|
|
132
132
|
// doesn't need to know about fullscreen.
|
|
133
|
+
const fakeChart = (option: object, brushAreas?: unknown[]) =>
|
|
134
|
+
({
|
|
135
|
+
getOption: vi.fn(() => option),
|
|
136
|
+
setOption: vi.fn(),
|
|
137
|
+
dispatchAction: vi.fn(),
|
|
138
|
+
getModel: () => ({
|
|
139
|
+
getComponent: (type: string) =>
|
|
140
|
+
type === 'brush' ? { areas: brushAreas } : undefined,
|
|
141
|
+
}),
|
|
142
|
+
isDisposed: () => false,
|
|
143
|
+
}) as unknown as ECharts & {
|
|
144
|
+
setOption: ReturnType<typeof vi.fn>
|
|
145
|
+
dispatchAction: ReturnType<typeof vi.fn>
|
|
146
|
+
}
|
|
147
|
+
|
|
133
148
|
it('starts the dialog chart from the card chart and hands it back on close', async () => {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
const cardOption = {
|
|
150
|
+
series: [{ type: 'bar' }],
|
|
151
|
+
xAxis: [{ type: 'category' }],
|
|
152
|
+
legend: [{ selected: { a: true, b: false } }],
|
|
153
|
+
dataZoom: [{ start: 10, end: 60 }],
|
|
154
|
+
}
|
|
155
|
+
const card = fakeChart(cardOption)
|
|
156
|
+
const dialog = fakeChart({
|
|
157
|
+
legend: [{ selected: { a: false, b: true } }],
|
|
158
|
+
dataZoom: [{ start: 20, end: 80 }],
|
|
159
|
+
})
|
|
160
|
+
render(<Widget />)
|
|
161
|
+
act(() => setEchartInstance('w', card))
|
|
162
|
+
|
|
163
|
+
// The empty dialog chart takes the whole option, so the view state has
|
|
164
|
+
// the card's axes and series to land on.
|
|
165
|
+
fireEvent.click(screen.getByLabelText('OpenT'))
|
|
166
|
+
act(() => setEchartInstance(dialogId, dialog))
|
|
167
|
+
expect(dialog.setOption).toHaveBeenCalledWith(cardOption)
|
|
168
|
+
|
|
169
|
+
await close()
|
|
170
|
+
expect(card.setOption).toHaveBeenCalledWith({
|
|
171
|
+
legend: [{ selected: { a: false, b: true } }],
|
|
172
|
+
dataZoom: [{ start: 20, end: 80 }],
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// The dialog often renders with its own styling (bigger axis labels,
|
|
177
|
+
// value labels on the series). Copying the whole option back would leave
|
|
178
|
+
// that styling on the card after closing.
|
|
179
|
+
it('hands back only the view state, not the dialog styling', async () => {
|
|
180
|
+
const card = fakeChart({ series: [{ type: 'bar' }] })
|
|
181
|
+
const dialog = fakeChart({
|
|
182
|
+
series: [{ type: 'bar', label: { show: true, rotate: 90 } }],
|
|
183
|
+
xAxis: [{ axisLabel: { fontSize: 13 } }],
|
|
184
|
+
dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
|
|
185
|
+
})
|
|
186
|
+
render(<Widget />)
|
|
187
|
+
act(() => setEchartInstance('w', card))
|
|
188
|
+
fireEvent.click(screen.getByLabelText('OpenT'))
|
|
189
|
+
act(() => setEchartInstance(dialogId, dialog))
|
|
190
|
+
|
|
191
|
+
await close()
|
|
192
|
+
expect(card.setOption).toHaveBeenCalledWith({
|
|
193
|
+
dataZoom: [{ start: 0, end: 50, startValue: 3, endValue: 9 }],
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Brush areas aren't in getOption(); they travel through the brush action.
|
|
198
|
+
it('starts the dialog chart with the card brush areas and hands them back on close', async () => {
|
|
199
|
+
const cardAreas = [{ brushType: 'lineX', coordRange: [1, 3] }]
|
|
200
|
+
const dialogAreas = [{ brushType: 'lineX', coordRange: [4, 7] }]
|
|
201
|
+
const card = fakeChart({}, cardAreas)
|
|
202
|
+
const dialog = fakeChart({}, dialogAreas)
|
|
145
203
|
render(<Widget />)
|
|
146
204
|
act(() => setEchartInstance('w', card))
|
|
147
205
|
|
|
148
206
|
fireEvent.click(screen.getByLabelText('OpenT'))
|
|
149
207
|
act(() => setEchartInstance(dialogId, dialog))
|
|
150
|
-
expect(dialog.
|
|
208
|
+
expect(dialog.dispatchAction).toHaveBeenCalledWith({
|
|
209
|
+
type: 'brush',
|
|
210
|
+
areas: cardAreas,
|
|
211
|
+
})
|
|
151
212
|
|
|
152
213
|
await close()
|
|
153
|
-
expect(card.
|
|
214
|
+
expect(card.dispatchAction).toHaveBeenCalledWith({
|
|
215
|
+
type: 'brush',
|
|
216
|
+
areas: dialogAreas,
|
|
217
|
+
})
|
|
154
218
|
})
|
|
155
219
|
})
|
|
@@ -315,15 +315,17 @@ function DialogStore({
|
|
|
315
315
|
const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
|
|
316
316
|
// Published from inside the chart's init, before its first
|
|
317
317
|
// setOption, so the dialog's own option merges on top of the copy.
|
|
318
|
+
// The empty chart takes the card's whole option: the legend, dataZoom
|
|
319
|
+
// and brush areas need the card's components (axes, brush) to land on.
|
|
318
320
|
const card = liveChart(sourceId)
|
|
319
|
-
if (chart && card) copyChartState(card, chart)
|
|
321
|
+
if (chart && card) copyChartState(card, chart, card.getOption())
|
|
320
322
|
dialogChart = chart
|
|
321
323
|
})
|
|
322
324
|
return () => {
|
|
323
325
|
unsubscribe()
|
|
324
326
|
const card = liveChart(sourceId)
|
|
325
327
|
if (card && dialogChart && !dialogChart.isDisposed()) {
|
|
326
|
-
copyChartState(dialogChart, card)
|
|
328
|
+
copyChartState(dialogChart, card, viewState(dialogChart))
|
|
327
329
|
}
|
|
328
330
|
}
|
|
329
331
|
}, [dialogId, sourceId])
|
|
@@ -351,10 +353,15 @@ function liveChart(id: string): ECharts | null {
|
|
|
351
353
|
/**
|
|
352
354
|
* Put on `chart` what `peer` is showing: the legend selection, the dataZoom
|
|
353
355
|
* window and any drawn brush areas, which live in the ECharts instance
|
|
354
|
-
* rather than the widget store.
|
|
356
|
+
* rather than the widget store. `option` is what of `peer`'s option to merge
|
|
357
|
+
* into `chart`.
|
|
355
358
|
*/
|
|
356
|
-
function copyChartState(
|
|
357
|
-
|
|
359
|
+
function copyChartState(
|
|
360
|
+
peer: ECharts,
|
|
361
|
+
chart: ECharts,
|
|
362
|
+
option: Parameters<ECharts['setOption']>[0],
|
|
363
|
+
): void {
|
|
364
|
+
chart.setOption(option)
|
|
358
365
|
// The pointer was over `peer`, not `chart`: drop the tooltip and axis
|
|
359
366
|
// pointer that came along, or they'd stay drawn until the next hover.
|
|
360
367
|
chart.dispatchAction({ type: 'hideTip' })
|
|
@@ -374,6 +381,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
|
|
|
374
381
|
if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
|
|
375
382
|
}
|
|
376
383
|
|
|
384
|
+
interface ViewStateOption {
|
|
385
|
+
// setOption takes an indexed option.
|
|
386
|
+
[key: string]: unknown
|
|
387
|
+
legend?: { selected?: Record<string, boolean> }[]
|
|
388
|
+
dataZoom?: Record<string, unknown>[]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The part of `peer`'s option that is user view state. Only this goes back
|
|
395
|
+
* to the card on close: the dialog's full option also carries its own
|
|
396
|
+
* styling (value labels, bigger axis text), and merging it into the card
|
|
397
|
+
* would leave that styling behind once the dialog closes.
|
|
398
|
+
*/
|
|
399
|
+
function viewState(peer: ECharts): ViewStateOption {
|
|
400
|
+
const { legend, dataZoom } = peer.getOption() as ViewStateOption
|
|
401
|
+
const state: ViewStateOption = {}
|
|
402
|
+
if (legend?.some((l) => l.selected)) {
|
|
403
|
+
state.legend = legend.map((l) =>
|
|
404
|
+
l.selected ? { selected: l.selected } : {},
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
if (dataZoom?.length) {
|
|
408
|
+
state.dataZoom = dataZoom.map((zoom) =>
|
|
409
|
+
Object.fromEntries(
|
|
410
|
+
ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
|
|
411
|
+
k,
|
|
412
|
+
zoom[k],
|
|
413
|
+
]),
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
return state
|
|
418
|
+
}
|
|
419
|
+
|
|
377
420
|
/**
|
|
378
421
|
* Merge a caller's `paperSx` over the default paper.
|
|
379
422
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import * as echarts from 'echarts'
|
|
2
3
|
import { createTheme, type Theme } from '@mui/material'
|
|
3
4
|
import { barOptions, createBarOptionFactory } from './options'
|
|
4
5
|
|
|
@@ -359,3 +360,108 @@ describe('createBarOptionFactory', () => {
|
|
|
359
360
|
expect(html).toContain('[ITEM:A]')
|
|
360
361
|
})
|
|
361
362
|
})
|
|
363
|
+
|
|
364
|
+
describe('createBarOptionFactory — per-item colour', () => {
|
|
365
|
+
interface Out {
|
|
366
|
+
series: { itemStyle: { color: (p: unknown) => string } }[]
|
|
367
|
+
}
|
|
368
|
+
const data = [
|
|
369
|
+
[
|
|
370
|
+
{ name: 'A', value: 1, color: '#00ff00' },
|
|
371
|
+
{ name: 'B', value: -2 },
|
|
372
|
+
],
|
|
373
|
+
]
|
|
374
|
+
const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
|
|
375
|
+
|
|
376
|
+
it('datum colour wins over the series colour', () => {
|
|
377
|
+
const merge = createBarOptionFactory({
|
|
378
|
+
theme,
|
|
379
|
+
series: [{ name: 'S', color: '#0000ff' }],
|
|
380
|
+
})
|
|
381
|
+
const out = merge({}, data) as Out
|
|
382
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
383
|
+
expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
|
|
384
|
+
'#00ff00',
|
|
385
|
+
)
|
|
386
|
+
expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
|
|
387
|
+
'#0000ff',
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('uses a string template itemStyle.color from optionsOverride', () => {
|
|
392
|
+
const factory = createBarOptionFactory({
|
|
393
|
+
theme,
|
|
394
|
+
series: [{ name: 'S', color: '#0000ff' }],
|
|
395
|
+
optionsOverride: {
|
|
396
|
+
series: [{ type: 'bar', itemStyle: { color: '#123456' } }],
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
const out = factory(factory(undefined, undefined), data) as Out
|
|
400
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
401
|
+
expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
|
|
402
|
+
'#00ff00',
|
|
403
|
+
)
|
|
404
|
+
expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
|
|
405
|
+
'#123456',
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('calls a function template itemStyle.color with the ECharts params', () => {
|
|
410
|
+
const merge = createBarOptionFactory({ theme })
|
|
411
|
+
const out = merge(
|
|
412
|
+
{
|
|
413
|
+
series: [
|
|
414
|
+
{
|
|
415
|
+
type: 'bar',
|
|
416
|
+
itemStyle: {
|
|
417
|
+
color: (p: { dataIndex: number }) =>
|
|
418
|
+
p.dataIndex === 1 ? '#ff0000' : '#00ff00',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
[
|
|
424
|
+
[
|
|
425
|
+
{ name: 'A', value: 1 },
|
|
426
|
+
{ name: 'B', value: -2 },
|
|
427
|
+
],
|
|
428
|
+
],
|
|
429
|
+
) as Out
|
|
430
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
431
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe('#00ff00')
|
|
432
|
+
expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe('#ff0000')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('resolves theme palette paths on the datum colour', () => {
|
|
436
|
+
const merge = createBarOptionFactory({ theme })
|
|
437
|
+
const out = merge({}, [[{ name: 'A', value: 1, color: 'primary.main' }]])
|
|
438
|
+
const colorFn = (out as Out).series[0]!.itemStyle.color
|
|
439
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
|
|
440
|
+
theme.palette.primary.main,
|
|
441
|
+
)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('dims both datum and template colours outside the selection', () => {
|
|
445
|
+
const merge = createBarOptionFactory({ theme, selection: ['Z'] })
|
|
446
|
+
const out = merge(
|
|
447
|
+
{ series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
|
|
448
|
+
data,
|
|
449
|
+
) as Out
|
|
450
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
451
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
|
|
452
|
+
dim('#00ff00'),
|
|
453
|
+
)
|
|
454
|
+
expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe(
|
|
455
|
+
dim('#123456'),
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('keeps the series / palette colour when no item colour is given', () => {
|
|
460
|
+
const merge = createBarOptionFactory({ theme, selection: ['Z'] })
|
|
461
|
+
const out = merge({}, [[{ name: 'A', value: 1 }]]) as Out
|
|
462
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
463
|
+
expect(colorFn({ dataIndex: 0, color: '#abcdef', name: 'A' })).toBe(
|
|
464
|
+
dim('#abcdef'),
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
})
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
|
|
13
13
|
import type { OptionFactory } from '../echart'
|
|
14
14
|
import { mergeOptions, resolveThemeColor } from '../utils'
|
|
15
|
+
import { createItemColorResolver } from '../utils/item-color'
|
|
15
16
|
import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
|
|
16
17
|
import type {
|
|
17
18
|
BarEChartsOption,
|
|
@@ -234,10 +235,12 @@ export function createBarOptionFactory(
|
|
|
234
235
|
: baseBottom
|
|
235
236
|
|
|
236
237
|
// When a selection is active, dim non-selected bars by routing the
|
|
237
|
-
//
|
|
238
|
+
// item's base colour through `modifyAlpha`. Per-row `itemStyle` on
|
|
238
239
|
// dataset object-rows is silently ignored when `series.encode` is in
|
|
239
240
|
// play — the callback approach is the standard ECharts pattern for
|
|
240
|
-
// per-data styling derived from a dataset.
|
|
241
|
+
// per-data styling derived from a dataset. The base colour is the
|
|
242
|
+
// datum's `color`, else the template's `itemStyle.color`, else the
|
|
243
|
+
// series colour / palette (`params.color`).
|
|
241
244
|
//
|
|
242
245
|
// We *always* emit `itemStyle.color` (a passthrough when nothing is
|
|
243
246
|
// selected), not conditionally — dropping the key between renders
|
|
@@ -245,22 +248,30 @@ export function createBarOptionFactory(
|
|
|
245
248
|
// and bars would stay dimmed forever after an external clear. Always
|
|
246
249
|
// emitting lets normal merge swap the callback in place, no
|
|
247
250
|
// `replaceMerge` and no entry-animation flash on selection on/off.
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
251
|
+
const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
|
|
252
|
+
const baseColor = createItemColorResolver({
|
|
253
|
+
theme,
|
|
254
|
+
datums,
|
|
255
|
+
template,
|
|
256
|
+
fallback: (params) => params.color as string,
|
|
257
|
+
})
|
|
258
|
+
return {
|
|
259
|
+
color: (params: CallbackDataParams) => {
|
|
260
|
+
const base = baseColor(params)
|
|
261
|
+
if (!selectionSet) return base
|
|
262
|
+
const datum = params.value as { name?: string | number } | undefined
|
|
263
|
+
const name = datum?.name ?? params.name
|
|
264
|
+
return name != null && selectionSet.has(name)
|
|
265
|
+
? base
|
|
266
|
+
: echarts.color.modifyAlpha(base, 0.15)
|
|
267
|
+
},
|
|
268
|
+
}
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
return {
|
|
261
272
|
...option,
|
|
262
273
|
dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
|
|
263
|
-
series: seriesArr.map((
|
|
274
|
+
series: seriesArr.map((s, i) => {
|
|
264
275
|
const template =
|
|
265
276
|
(seriesTemplates[i] as object | undefined) ??
|
|
266
277
|
(broadcastTemplate as object)
|
|
@@ -277,7 +288,7 @@ export function createBarOptionFactory(
|
|
|
277
288
|
encode: { x: 'name', y: 'value' },
|
|
278
289
|
barMaxWidth: 100,
|
|
279
290
|
emphasis: { focus: 'series' },
|
|
280
|
-
itemStyle:
|
|
291
|
+
itemStyle: makeDimItemStyle(s, template),
|
|
281
292
|
...(overrideColor ? { color: overrideColor } : {}),
|
|
282
293
|
}
|
|
283
294
|
}),
|
|
@@ -6,6 +6,12 @@ import type { WidgetSeries } from '../types'
|
|
|
6
6
|
export interface BarDatum {
|
|
7
7
|
name: string | number
|
|
8
8
|
value: number
|
|
9
|
+
/**
|
|
10
|
+
* Optional colour for this item (CSS colour or theme palette path such as
|
|
11
|
+
* `'primary.main'`). Wins over the template's `itemStyle.color` and the
|
|
12
|
+
* series colour; selection dimming still applies on top.
|
|
13
|
+
*/
|
|
14
|
+
color?: string
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/** v2 bar widget data shape: array of series, each a flat list of `{name, value}`. */
|
|
@@ -906,4 +906,143 @@ describe('<CategoryUI>', () => {
|
|
|
906
906
|
fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
|
|
907
907
|
})
|
|
908
908
|
})
|
|
909
|
+
|
|
910
|
+
// ── Signed values ──────────────────────────────────────────────────
|
|
911
|
+
describe('negative values', () => {
|
|
912
|
+
const MIXED = [
|
|
913
|
+
[
|
|
914
|
+
{ name: 'Income', value: 0.8 },
|
|
915
|
+
{ name: 'Transit', value: -0.4 },
|
|
916
|
+
{ name: 'Size', value: 0 },
|
|
917
|
+
],
|
|
918
|
+
]
|
|
919
|
+
const fills = (c: HTMLElement) =>
|
|
920
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
|
|
921
|
+
const ticks = (c: HTMLElement) =>
|
|
922
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
|
|
923
|
+
|
|
924
|
+
it('centres the zero with a symmetric domain and keeps the series color', () => {
|
|
925
|
+
const { container } = render(<CategoryUI data={MIXED} />)
|
|
926
|
+
const [income, transit, size] = fills(container)
|
|
927
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
|
|
928
|
+
expect([transit!.style.left, transit!.style.width]).toEqual([
|
|
929
|
+
'25%',
|
|
930
|
+
'25%',
|
|
931
|
+
])
|
|
932
|
+
expect(size!.style.width).toBe('0%')
|
|
933
|
+
expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('marks the zero with one tick per bar, none on "Others"', () => {
|
|
937
|
+
const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
|
|
938
|
+
expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
|
|
939
|
+
expect(screen.getByText('Others')).toBeTruthy()
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('draws one zero line per multi-series row, spanning every bar', () => {
|
|
943
|
+
const { container } = render(
|
|
944
|
+
<CategoryUI
|
|
945
|
+
data={[
|
|
946
|
+
[
|
|
947
|
+
{ name: 'A', value: 1 },
|
|
948
|
+
{ name: 'B', value: 2 },
|
|
949
|
+
],
|
|
950
|
+
[
|
|
951
|
+
{ name: 'A', value: -1 },
|
|
952
|
+
{ name: 'B', value: 0.5 },
|
|
953
|
+
],
|
|
954
|
+
[
|
|
955
|
+
{ name: 'A', value: 0 },
|
|
956
|
+
{ name: 'B', value: -2 },
|
|
957
|
+
],
|
|
958
|
+
]}
|
|
959
|
+
/>,
|
|
960
|
+
)
|
|
961
|
+
const t = ticks(container)
|
|
962
|
+
expect(t).toHaveLength(2)
|
|
963
|
+
expect(t[0]!.style.left).toBe('50%')
|
|
964
|
+
expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
it.each([
|
|
968
|
+
['positive-only', [1, 2]],
|
|
969
|
+
['negative-only', [-1, -2]],
|
|
970
|
+
['all-zero', [0, 0]],
|
|
971
|
+
])('draws no zero tick for %s data', (_, values) => {
|
|
972
|
+
const { container } = render(
|
|
973
|
+
<CategoryUI
|
|
974
|
+
data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
|
|
975
|
+
/>,
|
|
976
|
+
)
|
|
977
|
+
expect(ticks(container)).toHaveLength(0)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('hangs negative-only bars from the right edge', () => {
|
|
981
|
+
const { container } = render(
|
|
982
|
+
<CategoryUI
|
|
983
|
+
data={[
|
|
984
|
+
[
|
|
985
|
+
{ name: 'A', value: -2 },
|
|
986
|
+
{ name: 'B', value: -1 },
|
|
987
|
+
],
|
|
988
|
+
]}
|
|
989
|
+
/>,
|
|
990
|
+
)
|
|
991
|
+
const [a, b] = fills(container)
|
|
992
|
+
expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
|
|
993
|
+
expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('treats maxOverride as a magnitude for signed data', () => {
|
|
997
|
+
const { container } = render(
|
|
998
|
+
<CategoryUI data={MIXED} maxOverride={1.6} />,
|
|
999
|
+
)
|
|
1000
|
+
const [income, transit] = fills(container)
|
|
1001
|
+
// domain [-1.6, 1.6]
|
|
1002
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
|
|
1003
|
+
expect(transit!.style.width).toBe('12.5%')
|
|
1004
|
+
})
|
|
1005
|
+
|
|
1006
|
+
it('minOverride pins the zero even when the data has no negatives', () => {
|
|
1007
|
+
const { container } = render(
|
|
1008
|
+
<CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
|
|
1009
|
+
)
|
|
1010
|
+
expect(fills(container)[0]!.style.left).toBe('50%')
|
|
1011
|
+
expect(ticks(container)).toHaveLength(1)
|
|
1012
|
+
})
|
|
1013
|
+
|
|
1014
|
+
it('prefixes U+2212 itself and hands the formatter |v|', () => {
|
|
1015
|
+
const formatter = vi.fn((n: number) => `$${n}`)
|
|
1016
|
+
render(
|
|
1017
|
+
<CategoryUI
|
|
1018
|
+
data={[
|
|
1019
|
+
[
|
|
1020
|
+
{ name: 'A', value: -0.5 },
|
|
1021
|
+
{ name: 'B', value: -0 },
|
|
1022
|
+
{ name: 'C', value: 0 },
|
|
1023
|
+
],
|
|
1024
|
+
]}
|
|
1025
|
+
formatter={formatter}
|
|
1026
|
+
/>,
|
|
1027
|
+
)
|
|
1028
|
+
expect(screen.getByText('\u2212$0.5')).toBeTruthy()
|
|
1029
|
+
expect(screen.getAllByText('$0')).toHaveLength(2)
|
|
1030
|
+
formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('stacked mode diverges around the shared zero and shows the signed total', () => {
|
|
1034
|
+
const { container } = render(
|
|
1035
|
+
<CategoryUI
|
|
1036
|
+
stacked
|
|
1037
|
+
data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
|
|
1038
|
+
/>,
|
|
1039
|
+
)
|
|
1040
|
+
const [pos, neg] = fills(container)
|
|
1041
|
+
// zero at 50%, longer side (3) fills its half
|
|
1042
|
+
expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
|
|
1043
|
+
expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
|
|
1044
|
+
expect(screen.getByText('2')).toBeTruthy()
|
|
1045
|
+
expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
|
|
1046
|
+
})
|
|
1047
|
+
})
|
|
909
1048
|
})
|
|
@@ -78,8 +78,16 @@ export interface CategoryUIProps {
|
|
|
78
78
|
* overflow row stays a static summary.
|
|
79
79
|
*/
|
|
80
80
|
onShowAll?: () => void
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Manual override for the top of the bar scale. With signed data it is a
|
|
83
|
+
* magnitude: the scale stays symmetric around the centred zero.
|
|
84
|
+
*/
|
|
82
85
|
maxOverride?: number
|
|
86
|
+
/**
|
|
87
|
+
* Manual override for the bottom of the bar scale, the negative mirror of
|
|
88
|
+
* `maxOverride`. Ignored unless negative.
|
|
89
|
+
*/
|
|
90
|
+
minOverride?: number
|
|
83
91
|
/**
|
|
84
92
|
* Visual density of the bar primitive. `'small'` (default) keeps the
|
|
85
93
|
* historical 4px-tall pill; `'medium'` switches to a 12px-tall track
|
|
@@ -161,6 +169,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
|
|
|
161
169
|
return order.map((k) => grouped.get(k)!)
|
|
162
170
|
}
|
|
163
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
|
|
174
|
+
* the zero at the left edge and negative-only at the right; mixed data is
|
|
175
|
+
* made symmetric so the zero sits in the middle of the track.
|
|
176
|
+
*/
|
|
177
|
+
function signedDomain(
|
|
178
|
+
rows: readonly GroupedRow[],
|
|
179
|
+
minOverride?: number,
|
|
180
|
+
maxOverride?: number,
|
|
181
|
+
): [lo: number, hi: number] {
|
|
182
|
+
let lo = 0
|
|
183
|
+
let hi = 0
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
for (const v of row.values) {
|
|
186
|
+
if (v < lo) lo = v
|
|
187
|
+
if (v > hi) hi = v
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
|
|
191
|
+
if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
|
|
192
|
+
if (lo < 0 && hi > 0) {
|
|
193
|
+
const m = Math.max(-lo, hi)
|
|
194
|
+
return [-m, m]
|
|
195
|
+
}
|
|
196
|
+
return [lo, hi]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
|
|
201
|
+
* that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
|
|
202
|
+
* `-` cannot double it. U+2212 is as wide as a tabular digit.
|
|
203
|
+
*/
|
|
204
|
+
function formatSigned(fmt: (n: number) => string, v: number): string {
|
|
205
|
+
return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
|
|
206
|
+
}
|
|
207
|
+
|
|
164
208
|
/**
|
|
165
209
|
* Pure presentational component for the Category widget. Renders
|
|
166
210
|
* single-series rows (`data.length === 1`) as a compact grid or
|
|
@@ -187,13 +231,15 @@ export function CategoryUI({
|
|
|
187
231
|
labels,
|
|
188
232
|
onShowAll,
|
|
189
233
|
maxOverride,
|
|
234
|
+
minOverride,
|
|
190
235
|
size = 'small',
|
|
191
236
|
stacked = false,
|
|
192
237
|
hiddenSeries: hiddenSeriesProp,
|
|
193
238
|
onHiddenSeriesChange,
|
|
194
239
|
}: CategoryUIProps) {
|
|
195
240
|
const theme = useTheme()
|
|
196
|
-
const
|
|
241
|
+
const baseFmt = formatter ?? ((n: number) => String(n))
|
|
242
|
+
const fmt = (n: number) => formatSigned(baseFmt, n)
|
|
197
243
|
|
|
198
244
|
const hasSelection = !!selection && selection.length > 0
|
|
199
245
|
const selectionSet = useMemo(
|
|
@@ -245,14 +291,14 @@ export function CategoryUI({
|
|
|
245
291
|
|
|
246
292
|
const grouped = useMemo(() => generateGroupedData(data), [data])
|
|
247
293
|
|
|
248
|
-
const maxValue = useMemo(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
294
|
+
const [minValue, maxValue] = useMemo(
|
|
295
|
+
() => signedDomain(grouped, minOverride, maxOverride),
|
|
296
|
+
[grouped, minOverride, maxOverride],
|
|
297
|
+
)
|
|
298
|
+
// Where the zero sits on the track, handed to stacked rows so their zero
|
|
299
|
+
// matches the other rows'. Positive-only data keeps it at the left edge.
|
|
300
|
+
const zeroPct =
|
|
301
|
+
maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
|
|
256
302
|
|
|
257
303
|
// Three modes:
|
|
258
304
|
// * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
|
|
@@ -382,7 +428,11 @@ export function CategoryUI({
|
|
|
382
428
|
<Box sx={styles.root}>
|
|
383
429
|
<Box
|
|
384
430
|
ref={listRefCallback}
|
|
385
|
-
sx={
|
|
431
|
+
sx={{
|
|
432
|
+
...styles.list,
|
|
433
|
+
...(isMulti && !stacked && styles.listMulti),
|
|
434
|
+
...(scrollMode && styles.listScroll),
|
|
435
|
+
}}
|
|
386
436
|
style={
|
|
387
437
|
scrollMaxHeight !== undefined
|
|
388
438
|
? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
|
|
@@ -432,6 +482,7 @@ export function CategoryUI({
|
|
|
432
482
|
selected={selected}
|
|
433
483
|
onToggle={toggle}
|
|
434
484
|
size={size}
|
|
485
|
+
zeroPct={zeroPct}
|
|
435
486
|
/>
|
|
436
487
|
)
|
|
437
488
|
}
|
|
@@ -443,6 +494,7 @@ export function CategoryUI({
|
|
|
443
494
|
values={visibleIdx.map((i) => row.values[i]!)}
|
|
444
495
|
colors={visibleIdx.map((i) => fill(i))}
|
|
445
496
|
maxValue={maxValue}
|
|
497
|
+
minValue={minValue}
|
|
446
498
|
formatter={fmt}
|
|
447
499
|
selected={selected}
|
|
448
500
|
onToggle={toggle}
|
|
@@ -457,6 +509,7 @@ export function CategoryUI({
|
|
|
457
509
|
displayName={displayName}
|
|
458
510
|
value={row.values[0] ?? 0}
|
|
459
511
|
maxValue={maxValue}
|
|
512
|
+
minValue={minValue}
|
|
460
513
|
color={fill(0)}
|
|
461
514
|
formatter={fmt}
|
|
462
515
|
selected={selected}
|