@carto/ps-react-ui 4.25.0 → 4.27.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-DON6fSCh.js +1289 -0
- package/dist/actions-DON6fSCh.js.map +1 -0
- package/dist/category-BoT5FHHp.js +950 -0
- package/dist/category-BoT5FHHp.js.map +1 -0
- package/dist/item-color-C1cu6EJd.js +20 -0
- package/dist/item-color-C1cu6EJd.js.map +1 -0
- package/dist/scale-0elCv3tk.js +29 -0
- package/dist/scale-0elCv3tk.js.map +1 -0
- package/dist/types/widgets-v2/actions/index.d.ts +1 -1
- package/dist/types/widgets-v2/actions/relative-data/index.d.ts +1 -0
- package/dist/types/widgets-v2/actions/relative-data/labels.d.ts +3 -0
- package/dist/types/widgets-v2/actions/relative-data/relative-data.d.ts +16 -2
- package/dist/types/widgets-v2/actions/relative-data/scale.d.ts +26 -0
- package/dist/types/widgets-v2/actions/relative-data/style.d.ts +12 -0
- package/dist/types/widgets-v2/bar/types.d.ts +6 -0
- package/dist/types/widgets-v2/category/category.d.ts +2 -1
- 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 +2 -2
- 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 +91 -81
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/histogram.js +133 -100
- 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 +151 -110
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +96 -86
- 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 +1 -1
- 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/actions/index.ts +1 -0
- package/src/widgets-v2/actions/relative-data/index.ts +1 -0
- package/src/widgets-v2/actions/relative-data/labels.ts +6 -0
- package/src/widgets-v2/actions/relative-data/relative-data.test.tsx +78 -1
- package/src/widgets-v2/actions/relative-data/relative-data.tsx +137 -20
- package/src/widgets-v2/actions/relative-data/scale.ts +48 -0
- package/src/widgets-v2/actions/relative-data/style.ts +10 -0
- package/src/widgets-v2/bar/options.test.ts +122 -0
- package/src/widgets-v2/bar/options.ts +31 -16
- package/src/widgets-v2/bar/types.ts +6 -0
- package/src/widgets-v2/category/category.relative-data.test.tsx +51 -63
- package/src/widgets-v2/category/category.tsx +25 -11
- 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 +44 -19
- 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 +7 -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 +90 -0
- package/src/widgets-v2/scatterplot/options.ts +53 -15
- 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 +38 -19
- 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-B1ss030_.js +0 -1167
- package/dist/actions-B1ss030_.js.map +0 -1
- package/dist/category-DuKx_CJX.js +0 -933
- package/dist/category-DuKx_CJX.js.map +0 -1
|
@@ -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,
|
|
@@ -19,6 +20,7 @@ import type {
|
|
|
19
20
|
BarOptionsInput,
|
|
20
21
|
BarWidgetData,
|
|
21
22
|
} from './types'
|
|
23
|
+
import { fixedAxisBounds } from '../actions/relative-data/scale'
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Builds the **structural** ECharts option for a bar widget — axes, grid,
|
|
@@ -201,6 +203,7 @@ export function createBarOptionFactory(
|
|
|
201
203
|
typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
|
|
202
204
|
? option.yAxis
|
|
203
205
|
: {}
|
|
206
|
+
const fixedY = fixedAxisBounds(baseYAxis)
|
|
204
207
|
|
|
205
208
|
const seriesTemplates = Array.isArray(option.series) ? option.series : []
|
|
206
209
|
const broadcastTemplate = seriesTemplates[0] ?? {}
|
|
@@ -234,10 +237,12 @@ export function createBarOptionFactory(
|
|
|
234
237
|
: baseBottom
|
|
235
238
|
|
|
236
239
|
// When a selection is active, dim non-selected bars by routing the
|
|
237
|
-
//
|
|
240
|
+
// item's base colour through `modifyAlpha`. Per-row `itemStyle` on
|
|
238
241
|
// dataset object-rows is silently ignored when `series.encode` is in
|
|
239
242
|
// play — the callback approach is the standard ECharts pattern for
|
|
240
|
-
// per-data styling derived from a dataset.
|
|
243
|
+
// per-data styling derived from a dataset. The base colour is the
|
|
244
|
+
// datum's `color`, else the template's `itemStyle.color`, else the
|
|
245
|
+
// series colour / palette (`params.color`).
|
|
241
246
|
//
|
|
242
247
|
// We *always* emit `itemStyle.color` (a passthrough when nothing is
|
|
243
248
|
// selected), not conditionally — dropping the key between renders
|
|
@@ -245,22 +250,30 @@ export function createBarOptionFactory(
|
|
|
245
250
|
// and bars would stay dimmed forever after an external clear. Always
|
|
246
251
|
// emitting lets normal merge swap the callback in place, no
|
|
247
252
|
// `replaceMerge` and no entry-animation flash on selection on/off.
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
253
|
+
const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
|
|
254
|
+
const baseColor = createItemColorResolver({
|
|
255
|
+
theme,
|
|
256
|
+
datums,
|
|
257
|
+
template,
|
|
258
|
+
fallback: (params) => params.color as string,
|
|
259
|
+
})
|
|
260
|
+
return {
|
|
261
|
+
color: (params: CallbackDataParams) => {
|
|
262
|
+
const base = baseColor(params)
|
|
263
|
+
if (!selectionSet) return base
|
|
264
|
+
const datum = params.value as { name?: string | number } | undefined
|
|
265
|
+
const name = datum?.name ?? params.name
|
|
266
|
+
return name != null && selectionSet.has(name)
|
|
267
|
+
? base
|
|
268
|
+
: echarts.color.modifyAlpha(base, 0.15)
|
|
269
|
+
},
|
|
270
|
+
}
|
|
258
271
|
}
|
|
259
272
|
|
|
260
273
|
return {
|
|
261
274
|
...option,
|
|
262
275
|
dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
|
|
263
|
-
series: seriesArr.map((
|
|
276
|
+
series: seriesArr.map((s, i) => {
|
|
264
277
|
const template =
|
|
265
278
|
(seriesTemplates[i] as object | undefined) ??
|
|
266
279
|
(broadcastTemplate as object)
|
|
@@ -277,7 +290,7 @@ export function createBarOptionFactory(
|
|
|
277
290
|
encode: { x: 'name', y: 'value' },
|
|
278
291
|
barMaxWidth: 100,
|
|
279
292
|
emphasis: { focus: 'series' },
|
|
280
|
-
itemStyle:
|
|
293
|
+
itemStyle: makeDimItemStyle(s, template),
|
|
281
294
|
...(overrideColor ? { color: overrideColor } : {}),
|
|
282
295
|
}
|
|
283
296
|
}),
|
|
@@ -289,12 +302,14 @@ export function createBarOptionFactory(
|
|
|
289
302
|
...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
|
|
290
303
|
yAxis: {
|
|
291
304
|
...baseYAxis,
|
|
305
|
+
// A config transform (RelativeData's `'total'` scale) may pin the
|
|
306
|
+
// ends; each applies only on the side the data reaches.
|
|
292
307
|
min: (extent: { min: number }) => {
|
|
293
|
-
niceMin = extent.min < 0 ? niceNum(extent.min) : 0
|
|
308
|
+
niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
|
|
294
309
|
return niceMin
|
|
295
310
|
},
|
|
296
311
|
max: (extent: { min: number; max: number }) => {
|
|
297
|
-
niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
|
|
312
|
+
niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
|
|
298
313
|
return niceMax
|
|
299
314
|
},
|
|
300
315
|
axisLabel: {
|
|
@@ -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}`. */
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { fireEvent, render, screen } from '@testing-library/react'
|
|
3
3
|
import { Provider } from '../provider/widget-provider'
|
|
4
|
-
import {
|
|
5
|
-
clearAllWidgetStores,
|
|
6
|
-
useTransformEnabled,
|
|
7
|
-
useWidgetId,
|
|
8
|
-
} from '../stores'
|
|
4
|
+
import { clearAllWidgetStores, getWidgetStore } from '../stores'
|
|
9
5
|
import { Category } from './category'
|
|
10
6
|
import { RelativeData } from '../actions/relative-data/relative-data'
|
|
11
7
|
import type { CategoryWidgetData } from './types'
|
|
@@ -13,21 +9,7 @@ import type { CategoryWidgetData } from './types'
|
|
|
13
9
|
beforeEach(() => clearAllWidgetStores())
|
|
14
10
|
afterEach(() => clearAllWidgetStores())
|
|
15
11
|
|
|
16
|
-
//
|
|
17
|
-
// (apps/storybook/stories/modules/widgets/category/category.tsx). The
|
|
18
|
-
// composer-level wiring isn't testable in `apps/storybook` (no vitest
|
|
19
|
-
// infra), so we re-implement just the relative-data branch here and
|
|
20
|
-
// assert the bar-width math end-to-end through Provider → action →
|
|
21
|
-
// bridge → UI.
|
|
22
|
-
function Wired({ maxOverride }: { maxOverride?: number }) {
|
|
23
|
-
const id = useWidgetId()
|
|
24
|
-
const relativeDataActive = useTransformEnabled(id, 'relative-data')
|
|
25
|
-
const effectiveMaxOverride = relativeDataActive ? 100 : maxOverride
|
|
26
|
-
return <Category maxOverride={effectiveMaxOverride} />
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Single-series dataset where the max value is 1000 so percent values
|
|
30
|
-
// map to round numbers we can match exactly against bar widths.
|
|
12
|
+
// Single series: shares of the 1750 total are ≈57.14 / 28.57 / 14.29.
|
|
31
13
|
const DATA: CategoryWidgetData = [
|
|
32
14
|
[
|
|
33
15
|
{ name: 'A', value: 1000 },
|
|
@@ -36,72 +18,78 @@ const DATA: CategoryWidgetData = [
|
|
|
36
18
|
],
|
|
37
19
|
]
|
|
38
20
|
|
|
39
|
-
function
|
|
21
|
+
function widths(): number[] {
|
|
40
22
|
return Array.from(
|
|
41
23
|
document.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'),
|
|
42
|
-
)
|
|
24
|
+
).map((el) => parseFloat(el.style.width))
|
|
43
25
|
}
|
|
44
26
|
|
|
45
|
-
|
|
46
|
-
|
|
27
|
+
const toggle = () =>
|
|
28
|
+
fireEvent.click(screen.getByRole('button', { name: /show relative values/i }))
|
|
29
|
+
|
|
30
|
+
describe('Category + RelativeData', () => {
|
|
31
|
+
it('off → bars scale to the data max', () => {
|
|
47
32
|
render(
|
|
48
33
|
<Provider id='cat-rd-off' data={DATA}>
|
|
49
34
|
<RelativeData />
|
|
50
|
-
<
|
|
35
|
+
<Category />
|
|
51
36
|
</Provider>,
|
|
52
37
|
)
|
|
53
|
-
|
|
54
|
-
expect(fills).toHaveLength(3)
|
|
55
|
-
// 1000/1000, 500/1000, 250/1000
|
|
56
|
-
expect(fills[0]!.style.width).toBe('100%')
|
|
57
|
-
expect(fills[1]!.style.width).toBe('50%')
|
|
58
|
-
expect(fills[2]!.style.width).toBe('25%')
|
|
38
|
+
expect(widths()).toEqual([100, 50, 25])
|
|
59
39
|
})
|
|
60
40
|
|
|
61
|
-
it('
|
|
41
|
+
it("scale 'max' (default): values are shares of the total, the top bar fills", () => {
|
|
62
42
|
render(
|
|
63
|
-
<Provider id='cat-rd-
|
|
43
|
+
<Provider id='cat-rd-max' data={DATA}>
|
|
64
44
|
<RelativeData />
|
|
65
|
-
<
|
|
45
|
+
<Category />
|
|
46
|
+
</Provider>,
|
|
47
|
+
)
|
|
48
|
+
toggle()
|
|
49
|
+
const values = (
|
|
50
|
+
getWidgetStore('cat-rd-max').getState().data as CategoryWidgetData
|
|
51
|
+
)[0]!.map((d) => d.value)
|
|
52
|
+
expect(values[0]).toBeCloseTo(57.14, 2)
|
|
53
|
+
const [a, b, c] = widths()
|
|
54
|
+
expect(a).toBeCloseTo(100)
|
|
55
|
+
expect(b).toBeCloseTo(50)
|
|
56
|
+
expect(c).toBeCloseTo(25)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("scale 'total': bars end at 100%", () => {
|
|
60
|
+
render(
|
|
61
|
+
<Provider id='cat-rd-total' data={DATA}>
|
|
62
|
+
<RelativeData scale='total' />
|
|
63
|
+
<Category />
|
|
66
64
|
</Provider>,
|
|
67
65
|
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(
|
|
83
|
-
expect(b).toBeLessThan(30)
|
|
84
|
-
expect(c).toBeGreaterThan(13)
|
|
85
|
-
expect(c).toBeLessThan(16)
|
|
86
|
-
// The largest bar must be NOT 100% — that's the whole point of the
|
|
87
|
-
// fix. Without the wiring, A would have been 100% (largest of the
|
|
88
|
-
// percent series).
|
|
89
|
-
expect(a).toBeLessThan(100)
|
|
66
|
+
toggle()
|
|
67
|
+
const [a, b, c] = widths()
|
|
68
|
+
expect(a).toBeCloseTo(57.14, 1)
|
|
69
|
+
expect(b).toBeCloseTo(28.57, 1)
|
|
70
|
+
expect(c).toBeCloseTo(14.29, 1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('ignores maxOverride while relative is on (legacy ±100 wiring)', () => {
|
|
74
|
+
render(
|
|
75
|
+
<Provider id='cat-rd-legacy' data={DATA}>
|
|
76
|
+
<RelativeData initialEnabled />
|
|
77
|
+
<Category maxOverride={100} minOverride={-100} />
|
|
78
|
+
</Provider>,
|
|
79
|
+
)
|
|
80
|
+
expect(widths()[0]).toBeCloseTo(100)
|
|
90
81
|
})
|
|
91
82
|
|
|
92
83
|
it('toggling off again restores data-max scaling', () => {
|
|
93
84
|
render(
|
|
94
85
|
<Provider id='cat-rd-restore' data={DATA}>
|
|
95
|
-
<RelativeData />
|
|
96
|
-
<
|
|
86
|
+
<RelativeData scale='total' />
|
|
87
|
+
<Category />
|
|
97
88
|
</Provider>,
|
|
98
89
|
)
|
|
99
|
-
const button = screen.getByRole('button', { name: /relative
|
|
90
|
+
const button = screen.getByRole('button', { name: /show relative values/i })
|
|
100
91
|
fireEvent.click(button) // on
|
|
101
92
|
fireEvent.click(button) // off
|
|
102
|
-
|
|
103
|
-
expect(fills[0]!.style.width).toBe('100%')
|
|
104
|
-
expect(fills[1]!.style.width).toBe('50%')
|
|
105
|
-
expect(fills[2]!.style.width).toBe('25%')
|
|
93
|
+
expect(widths()).toEqual([100, 50, 25])
|
|
106
94
|
})
|
|
107
95
|
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useWidgetShallow,
|
|
7
7
|
type WidgetState,
|
|
8
8
|
} from '../stores'
|
|
9
|
+
import { selectRelativeScale } from '../actions/relative-data/scale'
|
|
9
10
|
import { CategoryUI } from './category-ui'
|
|
10
11
|
import type {
|
|
11
12
|
CategoryKey,
|
|
@@ -87,7 +88,8 @@ export interface CategoryProps {
|
|
|
87
88
|
* stay coherent across data transforms (e.g., the Searcher filtering
|
|
88
89
|
* rows in and out — bars don't rescale just because the larger rows
|
|
89
90
|
* got hidden). Pass an explicit number to fix the denominator
|
|
90
|
-
* regardless of the data.
|
|
91
|
+
* regardless of the data. Ignored while `RelativeData` is on, whose
|
|
92
|
+
* `scale` decides the denominator instead.
|
|
91
93
|
*/
|
|
92
94
|
maxOverride?: number
|
|
93
95
|
/**
|
|
@@ -134,18 +136,30 @@ export function Category({
|
|
|
134
136
|
// Auto-fill both ends of the scale from `rawData` so bar widths and the
|
|
135
137
|
// zero stay coherent across data transforms (e.g., the Searcher filtering
|
|
136
138
|
// rows in/out). Consumer's explicit value wins, but each end only applies
|
|
137
|
-
// when
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
139
|
+
// when the data reaches past zero on that side, so positive-only data
|
|
140
|
+
// keeps its zero at the left edge, negative-only at the right. With no
|
|
141
|
+
// data on a side we pass nothing and CategoryUI falls back to the live
|
|
142
|
+
// `data`.
|
|
143
|
+
// With RelativeData on, the values are percentages: `'total'` ends the
|
|
144
|
+
// scale at ±100, `'max'` at the largest share so the top row fills.
|
|
145
|
+
const relativeScale = useWidget(id, selectRelativeScale)
|
|
146
|
+
const [naturalMin, naturalMax] = useMemo(() => {
|
|
147
|
+
const [min, max] = extentFromCategoryData(slice.rawData)
|
|
148
|
+
if (relativeScale === 'total')
|
|
149
|
+
return [min < 0 ? -100 : 0, max > 0 ? 100 : 0]
|
|
150
|
+
if (relativeScale === 'max') return extentFromCategoryData(slice.data)
|
|
151
|
+
return [min, max]
|
|
152
|
+
}, [slice.rawData, slice.data, relativeScale])
|
|
153
|
+
// The overrides are in absolute units, so they sit out while relative
|
|
154
|
+
// values are on.
|
|
145
155
|
const effectiveMaxOverride =
|
|
146
|
-
naturalMax > 0
|
|
156
|
+
naturalMax > 0
|
|
157
|
+
? ((relativeScale ? undefined : maxOverride) ?? naturalMax)
|
|
158
|
+
: undefined
|
|
147
159
|
const effectiveMinOverride =
|
|
148
|
-
naturalMin < 0
|
|
160
|
+
naturalMin < 0
|
|
161
|
+
? ((relativeScale ? undefined : minOverride) ?? naturalMin)
|
|
162
|
+
: undefined
|
|
149
163
|
|
|
150
164
|
const seriesKey = JSON.stringify(series?.map((s) => s.name) ?? [])
|
|
151
165
|
const stored = useWidget<
|
|
@@ -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 DownloadContext,
|
|
5
5
|
type DownloadItem,
|
|
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
|
|
@@ -14,7 +15,7 @@ import type { HistogramWidgetData } from './types'
|
|
|
14
15
|
*/
|
|
15
16
|
export function createHistogramDownloadConfig(args: {
|
|
16
17
|
filename: string
|
|
17
|
-
getData: (ctx: DownloadContext) => HistogramWidgetData
|
|
18
|
+
getData: (ctx: DownloadContext) => HistogramWidgetData<HistogramBin>
|
|
18
19
|
getTicks: () => readonly number[]
|
|
19
20
|
seriesNames?: readonly string[]
|
|
20
21
|
getCaptureEl?: (ctx: DownloadContext) => HTMLElement | null
|
|
@@ -46,7 +47,10 @@ export function createHistogramDownloadConfig(args: {
|
|
|
46
47
|
const rows: unknown[][] = [header]
|
|
47
48
|
for (let bin = 0; bin < Math.max(0, ticks.length - 1); bin++) {
|
|
48
49
|
const row: unknown[] = [ticks[bin], ticks[bin + 1]]
|
|
49
|
-
for (let s = 0; s < seriesCount; s++)
|
|
50
|
+
for (let s = 0; s < seriesCount; s++) {
|
|
51
|
+
const value = data[s]?.[bin]
|
|
52
|
+
row.push(value == null ? 0 : getHistogramBinValue(value))
|
|
53
|
+
}
|
|
50
54
|
rows.push(row)
|
|
51
55
|
}
|
|
52
56
|
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,13 +12,17 @@ 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'
|
|
25
|
+
import { fixedAxisBounds } from '../actions/relative-data/scale'
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* Builds the **structural** ECharts option for a histogram widget — axes,
|
|
@@ -147,7 +151,7 @@ export function histogramOptions({
|
|
|
147
151
|
* the consumer-supplied `optionsOverride`. Called once by Provider to
|
|
148
152
|
* seed `rawOptions` in the store.
|
|
149
153
|
* - **Merge phase** (`option != null`) — fuses post-pipeline `state.data`
|
|
150
|
-
* (`HistogramWidgetData` — `
|
|
154
|
+
* (`HistogramWidgetData` — `HistogramBin[][]`) into the option via the
|
|
151
155
|
* dataset API. Each series gets its own dataset of `[binLabel, count]`
|
|
152
156
|
* pairs derived from `ticks`; series reference their dataset by
|
|
153
157
|
* `datasetIndex` and are encoded by column position. Reactive
|
|
@@ -199,8 +203,8 @@ export function createHistogramOptionFactory(
|
|
|
199
203
|
: structural
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
const seriesArr: HistogramWidgetData = Array.isArray(data)
|
|
203
|
-
? (data as HistogramWidgetData)
|
|
206
|
+
const seriesArr: HistogramWidgetData<HistogramBin> = Array.isArray(data)
|
|
207
|
+
? (data as HistogramWidgetData<HistogramBin>)
|
|
204
208
|
: []
|
|
205
209
|
if (seriesArr.length === 0 || binLabels.length === 0) {
|
|
206
210
|
return { ...option, dataset: [], series: [] }
|
|
@@ -213,6 +217,7 @@ export function createHistogramOptionFactory(
|
|
|
213
217
|
typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
|
|
214
218
|
? option.yAxis
|
|
215
219
|
: {}
|
|
220
|
+
const fixedY = fixedAxisBounds(baseYAxis)
|
|
216
221
|
const baseGrid =
|
|
217
222
|
typeof option.grid === 'object' && !Array.isArray(option.grid)
|
|
218
223
|
? option.grid
|
|
@@ -261,14 +266,28 @@ export function createHistogramOptionFactory(
|
|
|
261
266
|
// and bins would stay dimmed forever after an external clear. Always
|
|
262
267
|
// emitting lets normal merge swap the callback in place, no
|
|
263
268
|
// `replaceMerge` and no entry-animation flash on selection on/off.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
//
|
|
270
|
+
// The base colour is the bin's own `color` (object bins), else the
|
|
271
|
+
// template's `itemStyle.color`, else the series colour / palette.
|
|
272
|
+
const makeDimItemStyle = (
|
|
273
|
+
datums: readonly unknown[],
|
|
274
|
+
template: unknown,
|
|
275
|
+
) => {
|
|
276
|
+
const baseColor = createItemColorResolver({
|
|
277
|
+
theme,
|
|
278
|
+
datums,
|
|
279
|
+
template,
|
|
280
|
+
fallback: (params) => params.color as string,
|
|
281
|
+
})
|
|
282
|
+
return {
|
|
283
|
+
color: (params: CallbackDataParams) => {
|
|
284
|
+
const base = baseColor(params)
|
|
285
|
+
if (!selectionSet) return base
|
|
286
|
+
return selectionSet.has(params.dataIndex)
|
|
287
|
+
? base
|
|
288
|
+
: echarts.color.modifyAlpha(base, 0.15)
|
|
289
|
+
},
|
|
290
|
+
}
|
|
272
291
|
}
|
|
273
292
|
|
|
274
293
|
// A consumer's `optionsOverride.xAxis` may legitimately be an array
|
|
@@ -306,12 +325,16 @@ export function createHistogramOptionFactory(
|
|
|
306
325
|
return {
|
|
307
326
|
...option,
|
|
308
327
|
xAxis: nextXAxis,
|
|
309
|
-
dataset: seriesArr.map((
|
|
310
|
-
source: binLabels.map(
|
|
311
|
-
|
|
312
|
-
|
|
328
|
+
dataset: seriesArr.map((bins) => ({
|
|
329
|
+
source: binLabels.map((label, i) => {
|
|
330
|
+
const bin = bins[i]
|
|
331
|
+
return [label, bin == null ? 0 : getHistogramBinValue(bin)] as [
|
|
332
|
+
string,
|
|
333
|
+
number,
|
|
334
|
+
]
|
|
335
|
+
}),
|
|
313
336
|
})),
|
|
314
|
-
series: seriesArr.map((
|
|
337
|
+
series: seriesArr.map((bins, i) => {
|
|
315
338
|
const template =
|
|
316
339
|
(seriesTemplates[i] as object | undefined) ??
|
|
317
340
|
(broadcastTemplate as object)
|
|
@@ -324,7 +347,7 @@ export function createHistogramOptionFactory(
|
|
|
324
347
|
encode: { x: 0, y: 1 },
|
|
325
348
|
barCategoryGap: '0%',
|
|
326
349
|
emphasis: { focus: 'series' },
|
|
327
|
-
itemStyle:
|
|
350
|
+
itemStyle: makeDimItemStyle(bins, template),
|
|
328
351
|
...(overrideColor ? { color: overrideColor } : {}),
|
|
329
352
|
}
|
|
330
353
|
}),
|
|
@@ -333,12 +356,14 @@ export function createHistogramOptionFactory(
|
|
|
333
356
|
...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
|
|
334
357
|
yAxis: {
|
|
335
358
|
...baseYAxis,
|
|
359
|
+
// A config transform (RelativeData's `'total'` scale) may pin the
|
|
360
|
+
// ends; each applies only on the side the data reaches.
|
|
336
361
|
min: (extent: { min: number }) => {
|
|
337
|
-
niceMin = extent.min < 0 ? niceNum(extent.min) : 0
|
|
362
|
+
niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
|
|
338
363
|
return niceMin
|
|
339
364
|
},
|
|
340
365
|
max: (extent: { min: number; max: number }) => {
|
|
341
|
-
niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
|
|
366
|
+
niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
|
|
342
367
|
return niceMax
|
|
343
368
|
},
|
|
344
369
|
axisLabel: {
|