@carto/ps-react-ui 6.2.0 → 6.3.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-CueYrfVr.js → actions-CkxKe8-J.js} +56 -46
- package/dist/actions-CkxKe8-J.js.map +1 -0
- package/dist/{option-builders-DQivvvbb.js → item-color-B7xkIpDZ.js} +26 -9
- package/dist/item-color-B7xkIpDZ.js.map +1 -0
- package/dist/types/widgets/bar/types.d.ts +6 -0
- package/dist/types/widgets/histogram/bins.d.ts +7 -0
- package/dist/types/widgets/histogram/download.d.ts +2 -2
- package/dist/types/widgets/histogram/index.d.ts +2 -1
- package/dist/types/widgets/histogram/options.d.ts +1 -1
- package/dist/types/widgets/histogram/transforms.d.ts +5 -4
- package/dist/types/widgets/histogram/types.d.ts +22 -5
- package/dist/types/widgets/index.d.ts +1 -1
- package/dist/types/widgets/pie/types.d.ts +6 -0
- package/dist/types/widgets/scatterplot/download.d.ts +2 -2
- package/dist/types/widgets/scatterplot/index.d.ts +2 -1
- package/dist/types/widgets/scatterplot/points.d.ts +7 -0
- package/dist/types/widgets/scatterplot/transforms.d.ts +4 -3
- package/dist/types/widgets/scatterplot/types.d.ts +21 -2
- package/dist/types/widgets/timeseries/types.d.ts +6 -0
- package/dist/types/widgets/utils/item-color.d.ts +26 -0
- package/dist/widgets/actions.js +1 -1
- package/dist/widgets/bar.js +78 -70
- package/dist/widgets/bar.js.map +1 -1
- package/dist/widgets/histogram.js +116 -85
- package/dist/widgets/histogram.js.map +1 -1
- package/dist/widgets/pie.js +108 -89
- package/dist/widgets/pie.js.map +1 -1
- package/dist/widgets/scatterplot.js +131 -92
- package/dist/widgets/scatterplot.js.map +1 -1
- package/dist/widgets/timeseries.js +82 -74
- package/dist/widgets/timeseries.js.map +1 -1
- package/dist/widgets.js +1 -1
- package/dist/widgets.js.map +1 -1
- package/package.json +1 -1
- package/src/widgets/actions/fullscreen/fullscreen-store.test.tsx +77 -13
- package/src/widgets/actions/fullscreen/fullscreen.tsx +48 -5
- package/src/widgets/bar/options.test.ts +106 -0
- package/src/widgets/bar/options.ts +25 -14
- package/src/widgets/bar/types.ts +6 -0
- package/src/widgets/histogram/bins.ts +22 -0
- package/src/widgets/histogram/download.test.ts +10 -0
- package/src/widgets/histogram/download.ts +7 -3
- package/src/widgets/histogram/index.ts +3 -0
- package/src/widgets/histogram/options.test.ts +62 -0
- package/src/widgets/histogram/options.ts +38 -17
- package/src/widgets/histogram/transforms.test.ts +21 -7
- package/src/widgets/histogram/transforms.ts +26 -12
- package/src/widgets/histogram/types.ts +25 -5
- package/src/widgets/index.ts +6 -1
- package/src/widgets/pie/options.test.ts +91 -0
- package/src/widgets/pie/options.ts +50 -20
- package/src/widgets/pie/types.ts +6 -0
- package/src/widgets/scatterplot/download.test.ts +9 -0
- package/src/widgets/scatterplot/download.ts +5 -3
- package/src/widgets/scatterplot/index.ts +3 -0
- package/src/widgets/scatterplot/options.test.ts +78 -0
- package/src/widgets/scatterplot/options.ts +35 -11
- package/src/widgets/scatterplot/points.ts +35 -0
- package/src/widgets/scatterplot/transforms.test.ts +19 -0
- package/src/widgets/scatterplot/transforms.ts +23 -18
- package/src/widgets/scatterplot/types.ts +25 -2
- package/src/widgets/timeseries/options.test.ts +65 -0
- package/src/widgets/timeseries/options.ts +31 -14
- package/src/widgets/timeseries/types.ts +6 -0
- package/src/widgets/utils/item-color.test.ts +74 -0
- package/src/widgets/utils/item-color.ts +55 -0
- package/dist/actions-CueYrfVr.js.map +0 -1
- package/dist/option-builders-DQivvvbb.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
|
})
|
|
@@ -312,15 +312,17 @@ function DialogStore({
|
|
|
312
312
|
const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
|
|
313
313
|
// Published from inside the chart's init, before its first
|
|
314
314
|
// setOption, so the dialog's own option merges on top of the copy.
|
|
315
|
+
// The empty chart takes the card's whole option: the legend, dataZoom
|
|
316
|
+
// and brush areas need the card's components (axes, brush) to land on.
|
|
315
317
|
const card = liveChart(sourceId)
|
|
316
|
-
if (chart && card) copyChartState(card, chart)
|
|
318
|
+
if (chart && card) copyChartState(card, chart, card.getOption())
|
|
317
319
|
dialogChart = chart
|
|
318
320
|
})
|
|
319
321
|
return () => {
|
|
320
322
|
unsubscribe()
|
|
321
323
|
const card = liveChart(sourceId)
|
|
322
324
|
if (card && dialogChart && !dialogChart.isDisposed()) {
|
|
323
|
-
copyChartState(dialogChart, card)
|
|
325
|
+
copyChartState(dialogChart, card, viewState(dialogChart))
|
|
324
326
|
}
|
|
325
327
|
}
|
|
326
328
|
}, [dialogId, sourceId])
|
|
@@ -340,10 +342,15 @@ function liveChart(id: string): ECharts | null {
|
|
|
340
342
|
/**
|
|
341
343
|
* Put on `chart` what `peer` is showing: the legend selection, the dataZoom
|
|
342
344
|
* window and any drawn brush areas, which live in the ECharts instance
|
|
343
|
-
* rather than the widget store.
|
|
345
|
+
* rather than the widget store. `option` is what of `peer`'s option to merge
|
|
346
|
+
* into `chart`.
|
|
344
347
|
*/
|
|
345
|
-
function copyChartState(
|
|
346
|
-
|
|
348
|
+
function copyChartState(
|
|
349
|
+
peer: ECharts,
|
|
350
|
+
chart: ECharts,
|
|
351
|
+
option: Parameters<ECharts['setOption']>[0],
|
|
352
|
+
): void {
|
|
353
|
+
chart.setOption(option)
|
|
347
354
|
// The pointer was over `peer`, not `chart`: drop the tooltip and axis
|
|
348
355
|
// pointer that came along, or they'd stay drawn until the next hover.
|
|
349
356
|
chart.dispatchAction({ type: 'hideTip' })
|
|
@@ -363,6 +370,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
|
|
|
363
370
|
if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
|
|
364
371
|
}
|
|
365
372
|
|
|
373
|
+
interface ViewStateOption {
|
|
374
|
+
// setOption takes an indexed option.
|
|
375
|
+
[key: string]: unknown
|
|
376
|
+
legend?: { selected?: Record<string, boolean> }[]
|
|
377
|
+
dataZoom?: Record<string, unknown>[]
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The part of `peer`'s option that is user view state. Only this goes back
|
|
384
|
+
* to the card on close: the dialog's full option also carries its own
|
|
385
|
+
* styling (value labels, bigger axis text), and merging it into the card
|
|
386
|
+
* would leave that styling behind once the dialog closes.
|
|
387
|
+
*/
|
|
388
|
+
function viewState(peer: ECharts): ViewStateOption {
|
|
389
|
+
const { legend, dataZoom } = peer.getOption() as ViewStateOption
|
|
390
|
+
const state: ViewStateOption = {}
|
|
391
|
+
if (legend?.some((l) => l.selected)) {
|
|
392
|
+
state.legend = legend.map((l) =>
|
|
393
|
+
l.selected ? { selected: l.selected } : {},
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
if (dataZoom?.length) {
|
|
397
|
+
state.dataZoom = dataZoom.map((zoom) =>
|
|
398
|
+
Object.fromEntries(
|
|
399
|
+
ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
|
|
400
|
+
k,
|
|
401
|
+
zoom[k],
|
|
402
|
+
]),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
return state
|
|
407
|
+
}
|
|
408
|
+
|
|
366
409
|
/**
|
|
367
410
|
* Merge a caller's `paperSx` over the default paper.
|
|
368
411
|
*
|
|
@@ -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
|
}),
|
package/src/widgets/bar/types.ts
CHANGED
|
@@ -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}`. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HistogramBin } from './types'
|
|
2
|
+
|
|
3
|
+
/** Count of a {@link HistogramBin}, whichever form it takes. */
|
|
4
|
+
export function getHistogramBinValue(bin: HistogramBin): number {
|
|
5
|
+
return typeof bin === 'number' ? bin : bin.value
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Whether `v` is a valid {@link HistogramBin} with a finite count. */
|
|
9
|
+
export function isHistogramBin(v: unknown): v is HistogramBin {
|
|
10
|
+
if (typeof v === 'number') return Number.isFinite(v)
|
|
11
|
+
if (v == null || typeof v !== 'object') return false
|
|
12
|
+
const value = (v as { value?: unknown }).value
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Same bin with its count replaced; keeps the object form (and `color`). */
|
|
17
|
+
export function withHistogramBinValue(
|
|
18
|
+
bin: HistogramBin,
|
|
19
|
+
value: number,
|
|
20
|
+
): HistogramBin {
|
|
21
|
+
return typeof bin === 'number' ? value : { ...bin, value }
|
|
22
|
+
}
|
|
@@ -61,6 +61,16 @@ describe('createHistogramDownloadConfig', () => {
|
|
|
61
61
|
)
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
it('CSV writes the count of object bins (colour is not exported)', async () => {
|
|
65
|
+
const items = createHistogramDownloadConfig({
|
|
66
|
+
filename: 'h',
|
|
67
|
+
getData: () => [[{ value: 1, color: '#f00' }, 2, { value: 3 }]],
|
|
68
|
+
getTicks: () => ticks,
|
|
69
|
+
})
|
|
70
|
+
await items.find((i) => i.id === 'csv')!.resolve({ id: null })
|
|
71
|
+
expect(csvText).toBe('bin_low,bin_high,series_1\n0,10,1\n10,20,2\n20,30,3')
|
|
72
|
+
})
|
|
73
|
+
|
|
64
74
|
it('CSV honours supplied seriesNames', async () => {
|
|
65
75
|
const items = createHistogramDownloadConfig({
|
|
66
76
|
filename: 'h',
|
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
type DownloadItem,
|
|
5
5
|
type WidgetDownloadArgs,
|
|
6
6
|
} from '../actions/download'
|
|
7
|
-
import
|
|
7
|
+
import { getHistogramBinValue } from './bins'
|
|
8
|
+
import type { HistogramBin, HistogramWidgetData } from './types'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Download menu items for histograms. Always includes a CSV item with
|
|
@@ -13,7 +14,7 @@ import type { HistogramWidgetData } from './types'
|
|
|
13
14
|
* rasterises the captured element via `html2canvas`.
|
|
14
15
|
*/
|
|
15
16
|
export function createHistogramDownloadConfig(
|
|
16
|
-
args: WidgetDownloadArgs<HistogramWidgetData
|
|
17
|
+
args: WidgetDownloadArgs<HistogramWidgetData<HistogramBin>> & {
|
|
17
18
|
getTicks: () => readonly number[]
|
|
18
19
|
seriesNames?: readonly string[]
|
|
19
20
|
},
|
|
@@ -33,7 +34,10 @@ export function createHistogramDownloadConfig(
|
|
|
33
34
|
const rows: unknown[][] = [header]
|
|
34
35
|
for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
|
|
35
36
|
const row: unknown[] = [ticks[bin], ticks[bin + 1]]
|
|
36
|
-
for (let s = 0; s < seriesCount; s++)
|
|
37
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
38
|
+
const value = data[s]?.[bin]
|
|
39
|
+
row.push(value == null ? 0 : getHistogramBinValue(value))
|
|
40
|
+
}
|
|
37
41
|
rows.push(row)
|
|
38
42
|
}
|
|
39
43
|
return rows
|
|
@@ -5,7 +5,10 @@ export {
|
|
|
5
5
|
toRelativeHistogramData,
|
|
6
6
|
filterHistogramByLockedBins,
|
|
7
7
|
} from './transforms'
|
|
8
|
+
export { getHistogramBinValue } from './bins'
|
|
8
9
|
export type {
|
|
10
|
+
HistogramBin,
|
|
11
|
+
HistogramBinObject,
|
|
9
12
|
HistogramWidgetData,
|
|
10
13
|
HistogramOptionsInput,
|
|
11
14
|
HistogramOptionFactoryInput,
|
|
@@ -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 { createHistogramOptionFactory, histogramOptions } from './options'
|
|
4
5
|
|
|
@@ -409,3 +410,64 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
|
|
|
409
410
|
})
|
|
410
411
|
})
|
|
411
412
|
})
|
|
413
|
+
|
|
414
|
+
describe('createHistogramOptionFactory — per-item colour', () => {
|
|
415
|
+
interface Out {
|
|
416
|
+
dataset: { source: (string | number)[][] }[]
|
|
417
|
+
series: { itemStyle: { color: (p: unknown) => string } }[]
|
|
418
|
+
}
|
|
419
|
+
const ticks = [0, 10, 20, 30]
|
|
420
|
+
const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
|
|
421
|
+
|
|
422
|
+
it('accepts object bins: dataset carries the counts', () => {
|
|
423
|
+
const merge = createHistogramOptionFactory({ theme, ticks })
|
|
424
|
+
const out = merge({}, [[{ value: 1, color: '#00ff00' }, 2, { value: 3 }]])
|
|
425
|
+
expect((out as Out).dataset[0]!.source.map(([, v]) => v)).toEqual([1, 2, 3])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('bin colour wins over the series colour; template next', () => {
|
|
429
|
+
const merge = createHistogramOptionFactory({
|
|
430
|
+
theme,
|
|
431
|
+
ticks,
|
|
432
|
+
series: [{ name: 'S', color: '#0000ff' }],
|
|
433
|
+
selection: [0, 1],
|
|
434
|
+
})
|
|
435
|
+
const out = merge(
|
|
436
|
+
{ series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
|
|
437
|
+
[[{ value: 1, color: '#00ff00' }, 2, { value: 3 }]],
|
|
438
|
+
) as Out
|
|
439
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
440
|
+
expect(colorFn({ dataIndex: 0, color: '#0000ff' })).toBe('#00ff00')
|
|
441
|
+
expect(colorFn({ dataIndex: 1, color: '#0000ff' })).toBe('#123456')
|
|
442
|
+
expect(colorFn({ dataIndex: 2, color: '#0000ff' })).toBe(dim('#123456'))
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('calls a function template itemStyle.color via optionsOverride', () => {
|
|
446
|
+
const factory = createHistogramOptionFactory({
|
|
447
|
+
theme,
|
|
448
|
+
ticks,
|
|
449
|
+
optionsOverride: {
|
|
450
|
+
series: [
|
|
451
|
+
{
|
|
452
|
+
type: 'bar',
|
|
453
|
+
itemStyle: {
|
|
454
|
+
color: (p: { dataIndex: number }) => `#c${p.dataIndex}`,
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
},
|
|
459
|
+
})
|
|
460
|
+
const out = factory(factory(undefined, undefined), [[1, 2, 3]]) as Out
|
|
461
|
+
expect(out.series[0]!.itemStyle.color({ dataIndex: 2, color: '#x' })).toBe(
|
|
462
|
+
'#c2',
|
|
463
|
+
)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('keeps the series / palette colour for plain number bins', () => {
|
|
467
|
+
const merge = createHistogramOptionFactory({ theme, ticks, selection: [1] })
|
|
468
|
+
const out = merge({}, [[1, 2, 3]]) as Out
|
|
469
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
470
|
+
expect(colorFn({ dataIndex: 0, color: '#abcdef' })).toBe(dim('#abcdef'))
|
|
471
|
+
expect(colorFn({ dataIndex: 1, color: '#abcdef' })).toBe('#abcdef')
|
|
472
|
+
})
|
|
473
|
+
})
|
|
@@ -12,11 +12,14 @@ 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'
|
|
16
|
+
import { getHistogramBinValue } from './bins'
|
|
15
17
|
import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
|
|
16
18
|
import type {
|
|
17
19
|
HistogramEChartsOption,
|
|
18
20
|
HistogramOptionFactoryInput,
|
|
19
21
|
HistogramOptionsInput,
|
|
22
|
+
HistogramBin,
|
|
20
23
|
HistogramWidgetData,
|
|
21
24
|
} from './types'
|
|
22
25
|
|
|
@@ -147,7 +150,7 @@ export function histogramOptions({
|
|
|
147
150
|
* the consumer-supplied `optionsOverride`. Called once by Provider to
|
|
148
151
|
* seed `rawOptions` in the store.
|
|
149
152
|
* - **Merge phase** (`option != null`) — fuses post-pipeline `state.data`
|
|
150
|
-
* (`HistogramWidgetData` — `
|
|
153
|
+
* (`HistogramWidgetData` — `HistogramBin[][]`) into the option via the
|
|
151
154
|
* dataset API. Each series gets its own dataset of `[binLabel, count]`
|
|
152
155
|
* pairs derived from `ticks`; series reference their dataset by
|
|
153
156
|
* `datasetIndex` and are encoded by column position. Reactive
|
|
@@ -199,8 +202,8 @@ export function createHistogramOptionFactory(
|
|
|
199
202
|
: structural
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
const seriesArr: HistogramWidgetData = Array.isArray(data)
|
|
203
|
-
? (data as HistogramWidgetData)
|
|
205
|
+
const seriesArr: HistogramWidgetData<HistogramBin> = Array.isArray(data)
|
|
206
|
+
? (data as HistogramWidgetData<HistogramBin>)
|
|
204
207
|
: []
|
|
205
208
|
if (seriesArr.length === 0 || binLabels.length === 0) {
|
|
206
209
|
return { ...option, dataset: [], series: [] }
|
|
@@ -261,14 +264,28 @@ export function createHistogramOptionFactory(
|
|
|
261
264
|
// and bins would stay dimmed forever after an external clear. Always
|
|
262
265
|
// emitting lets normal merge swap the callback in place, no
|
|
263
266
|
// `replaceMerge` and no entry-animation flash on selection on/off.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
267
|
+
//
|
|
268
|
+
// The base colour is the bin's own `color` (object bins), else the
|
|
269
|
+
// template's `itemStyle.color`, else the series colour / palette.
|
|
270
|
+
const makeDimItemStyle = (
|
|
271
|
+
datums: readonly unknown[],
|
|
272
|
+
template: unknown,
|
|
273
|
+
) => {
|
|
274
|
+
const baseColor = createItemColorResolver({
|
|
275
|
+
theme,
|
|
276
|
+
datums,
|
|
277
|
+
template,
|
|
278
|
+
fallback: (params) => params.color as string,
|
|
279
|
+
})
|
|
280
|
+
return {
|
|
281
|
+
color: (params: CallbackDataParams) => {
|
|
282
|
+
const base = baseColor(params)
|
|
283
|
+
if (!selectionSet) return base
|
|
284
|
+
return selectionSet.has(params.dataIndex)
|
|
285
|
+
? base
|
|
286
|
+
: echarts.color.modifyAlpha(base, 0.15)
|
|
287
|
+
},
|
|
288
|
+
}
|
|
272
289
|
}
|
|
273
290
|
|
|
274
291
|
// A consumer's `optionsOverride.xAxis` may legitimately be an array
|
|
@@ -306,12 +323,16 @@ export function createHistogramOptionFactory(
|
|
|
306
323
|
return {
|
|
307
324
|
...option,
|
|
308
325
|
xAxis: nextXAxis,
|
|
309
|
-
dataset: seriesArr.map((
|
|
310
|
-
source: binLabels.map(
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
dataset: seriesArr.map((bins) => ({
|
|
327
|
+
source: binLabels.map((label, i) => {
|
|
328
|
+
const bin = bins[i]
|
|
329
|
+
return [label, bin == null ? 0 : getHistogramBinValue(bin)] as [
|
|
330
|
+
string,
|
|
331
|
+
number,
|
|
332
|
+
]
|
|
333
|
+
}),
|
|
313
334
|
})),
|
|
314
|
-
series: seriesArr.map((
|
|
335
|
+
series: seriesArr.map((bins, i) => {
|
|
315
336
|
const template =
|
|
316
337
|
(seriesTemplates[i] as object | undefined) ??
|
|
317
338
|
(broadcastTemplate as object)
|
|
@@ -324,7 +345,7 @@ export function createHistogramOptionFactory(
|
|
|
324
345
|
encode: { x: 0, y: 1 },
|
|
325
346
|
barCategoryGap: '0%',
|
|
326
347
|
emphasis: { focus: 'series' },
|
|
327
|
-
itemStyle:
|
|
348
|
+
itemStyle: makeDimItemStyle(bins, template),
|
|
328
349
|
...(overrideColor ? { color: overrideColor } : {}),
|
|
329
350
|
}
|
|
330
351
|
}),
|