@carto/ps-react-ui 5.4.0 → 5.6.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-5-dVhroJ.js +1289 -0
- package/dist/actions-5-dVhroJ.js.map +1 -0
- package/dist/{category-C6myUFUJ.js → category-DBUD2ZBo.js} +267 -250
- package/dist/category-DBUD2ZBo.js.map +1 -0
- package/dist/item-color-ZYxH3NM5.js +20 -0
- package/dist/item-color-ZYxH3NM5.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 +37 -16
- 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 +0 -1167
- package/dist/actions-Dhlz_adK.js.map +0 -1
- package/dist/category-C6myUFUJ.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,9 +1,15 @@
|
|
|
1
1
|
export interface RelativeDataLabels {
|
|
2
2
|
on: string
|
|
3
3
|
off: string
|
|
4
|
+
scaleMenu: string
|
|
5
|
+
scaleMax: string
|
|
6
|
+
scaleTotal: string
|
|
4
7
|
}
|
|
5
8
|
|
|
6
9
|
export const DEFAULT_RELATIVE_DATA_LABELS: RelativeDataLabels = {
|
|
7
10
|
on: 'Show absolute values',
|
|
8
11
|
off: 'Show relative values',
|
|
12
|
+
scaleMenu: 'Relative scale',
|
|
13
|
+
scaleMax: 'Relative to max',
|
|
14
|
+
scaleTotal: 'Relative to total',
|
|
9
15
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { render } from '@testing-library/react'
|
|
2
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
3
3
|
import { Provider } from '../../provider/widget-provider'
|
|
4
4
|
import { clearAllWidgetStores, getWidgetStore } from '../../stores'
|
|
5
5
|
import { RelativeData } from './relative-data'
|
|
@@ -69,3 +69,80 @@ describe('<RelativeData> — rawFormatter-driven restoration', () => {
|
|
|
69
69
|
expect(s.formatter).toBe(fmtA)
|
|
70
70
|
})
|
|
71
71
|
})
|
|
72
|
+
|
|
73
|
+
describe('<RelativeData> — scale', () => {
|
|
74
|
+
const DATA = [
|
|
75
|
+
[
|
|
76
|
+
{ name: 'a', value: 50 },
|
|
77
|
+
{ name: 'b', value: 150 },
|
|
78
|
+
],
|
|
79
|
+
]
|
|
80
|
+
const values = (id: string) =>
|
|
81
|
+
(getWidgetStore(id).getState().data as { value: number }[][])[0]!.map(
|
|
82
|
+
(d) => d.value,
|
|
83
|
+
)
|
|
84
|
+
// The value axis a structural option ends up with after the action's
|
|
85
|
+
// config transform.
|
|
86
|
+
const yAxis = (id: string) => {
|
|
87
|
+
const xf = getWidgetStore(id)
|
|
88
|
+
.getState()
|
|
89
|
+
.configTransforms.find((t) => t.id === 'relative-data-axis')!
|
|
90
|
+
return (xf.fn({ yAxis: { type: 'value' } }) as { yAxis: object }).yAxis
|
|
91
|
+
}
|
|
92
|
+
const slot = (id: string) =>
|
|
93
|
+
getWidgetStore(id).getState().transformStates['relative-data']
|
|
94
|
+
|
|
95
|
+
it("defaults to 'max': values are shares of the total, axis left to the data", () => {
|
|
96
|
+
render(
|
|
97
|
+
<Provider id='sc1' data={DATA}>
|
|
98
|
+
<RelativeData initialEnabled />
|
|
99
|
+
</Provider>,
|
|
100
|
+
)
|
|
101
|
+
expect(values('sc1')).toEqual([25, 75])
|
|
102
|
+
expect(yAxis('sc1')).toEqual({ type: 'value' })
|
|
103
|
+
expect(slot('sc1')).toMatchObject({ enabled: true, scale: 'max' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("scale='total' keeps the same values and pins the axis to ±100", () => {
|
|
107
|
+
render(
|
|
108
|
+
<Provider id='sc2' data={DATA}>
|
|
109
|
+
<RelativeData initialEnabled scale='total' />
|
|
110
|
+
</Provider>,
|
|
111
|
+
)
|
|
112
|
+
expect(values('sc2')).toEqual([25, 75])
|
|
113
|
+
expect(yAxis('sc2')).toEqual({ type: 'value', min: -100, max: 100 })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('picking a scale in the menu switches it and turns relative on', () => {
|
|
117
|
+
render(
|
|
118
|
+
<Provider id='sc3' data={DATA}>
|
|
119
|
+
<RelativeData />
|
|
120
|
+
</Provider>,
|
|
121
|
+
)
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Relative scale' }))
|
|
123
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Relative to total' }))
|
|
124
|
+
expect(slot('sc3')).toMatchObject({ enabled: true, scale: 'total' })
|
|
125
|
+
expect(values('sc3')).toEqual([25, 75])
|
|
126
|
+
expect(yAxis('sc3')).toMatchObject({ max: 100 })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('follows a scale written to the store from outside', () => {
|
|
130
|
+
render(
|
|
131
|
+
<Provider id='sc4' data={DATA}>
|
|
132
|
+
<RelativeData initialEnabled />
|
|
133
|
+
</Provider>,
|
|
134
|
+
)
|
|
135
|
+
act(() => {
|
|
136
|
+
getWidgetStore('sc4').setState((s) => ({
|
|
137
|
+
transformStates: {
|
|
138
|
+
...s.transformStates,
|
|
139
|
+
'relative-data': {
|
|
140
|
+
...s.transformStates['relative-data']!,
|
|
141
|
+
scale: 'total',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}))
|
|
145
|
+
})
|
|
146
|
+
expect(yAxis('sc4')).toMatchObject({ max: 100 })
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -1,25 +1,56 @@
|
|
|
1
|
-
import { useEffect, useMemo, type ComponentType } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import { useEffect, useMemo, useState, type ComponentType } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
IconButton,
|
|
5
|
+
ListItemText,
|
|
6
|
+
Menu,
|
|
7
|
+
MenuItem,
|
|
8
|
+
type SvgIconProps,
|
|
9
|
+
} from '@mui/material'
|
|
10
|
+
import {
|
|
11
|
+
ArrowDropDown as ArrowDropDownIcon,
|
|
12
|
+
Percent as PercentIcon,
|
|
13
|
+
} from '@mui/icons-material'
|
|
4
14
|
import { Tooltip } from '../../../components'
|
|
5
15
|
import {
|
|
6
16
|
getWidgetStore,
|
|
7
|
-
|
|
17
|
+
useTransform,
|
|
8
18
|
useWidget,
|
|
9
19
|
useWidgetId,
|
|
20
|
+
type TransformPair,
|
|
10
21
|
} from '../../stores'
|
|
11
22
|
import { createPercentFormatter, toRelativeData } from './transforms'
|
|
23
|
+
import {
|
|
24
|
+
RELATIVE_DATA_ID,
|
|
25
|
+
toRelativeTotalAxis,
|
|
26
|
+
type RelativeScale,
|
|
27
|
+
} from './scale'
|
|
12
28
|
import { DEFAULT_RELATIVE_DATA_LABELS, type RelativeDataLabels } from './labels'
|
|
13
29
|
import { styles } from './style'
|
|
14
30
|
|
|
15
31
|
const DATA_DESCRIPTOR = {
|
|
16
|
-
id:
|
|
32
|
+
id: RELATIVE_DATA_ID,
|
|
17
33
|
type: 'data' as const,
|
|
18
34
|
order: 15,
|
|
19
35
|
}
|
|
20
36
|
|
|
37
|
+
const AXIS_DESCRIPTOR = {
|
|
38
|
+
id: 'relative-data-axis',
|
|
39
|
+
type: 'config' as const,
|
|
40
|
+
order: 15,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const identity = (option: unknown) => option
|
|
44
|
+
|
|
21
45
|
export interface RelativeDataProps {
|
|
22
46
|
initialEnabled?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Initial bar scale: `'max'` (the largest item fills its bar) or
|
|
49
|
+
* `'total'` (bars end at 100%). Values are the share of the total
|
|
50
|
+
* either way. Only seeds the store — the menu, or a write to
|
|
51
|
+
* `transformStates['relative-data'].scale`, changes it afterwards.
|
|
52
|
+
*/
|
|
53
|
+
scale?: RelativeScale
|
|
23
54
|
/**
|
|
24
55
|
* BCP-47 locale tag forwarded to `Intl.NumberFormat`. Defaults to the
|
|
25
56
|
* runtime locale.
|
|
@@ -43,7 +74,8 @@ export interface RelativeDataProps {
|
|
|
43
74
|
}
|
|
44
75
|
|
|
45
76
|
/**
|
|
46
|
-
* Toggle between absolute and relative (percentage) values
|
|
77
|
+
* Toggle between absolute and relative (percentage) values, as a split
|
|
78
|
+
* button: the toggle plus an arrow that opens the scale menu.
|
|
47
79
|
*
|
|
48
80
|
* - **Data side**: a single data transform ({@link toRelativeData}) registered
|
|
49
81
|
* through `useSingleTransform`. When disabled, the pipeline skips it and the
|
|
@@ -53,9 +85,15 @@ export interface RelativeDataProps {
|
|
|
53
85
|
* reads `state.rawFormatter` (the consumer-supplied original, always live)
|
|
54
86
|
* and writes it back to `formatter`. No "original" snapshot is captured —
|
|
55
87
|
* `rawFormatter` is the original.
|
|
88
|
+
* - **Scale**: lives in `transformStates['relative-data'].scale` next to
|
|
89
|
+
* `enabled`, so duplicate mounts (Toolbox inline + overflow) and
|
|
90
|
+
* `keepAlive` remounts agree on it. It never changes the values; under
|
|
91
|
+
* `'total'` a config transform pins the ECharts value axis to 100%, and
|
|
92
|
+
* Category reads it through `selectRelativeScale`.
|
|
56
93
|
*/
|
|
57
94
|
export function RelativeData({
|
|
58
95
|
initialEnabled = false,
|
|
96
|
+
scale: initialScale = 'max',
|
|
59
97
|
locale,
|
|
60
98
|
labels,
|
|
61
99
|
icon: Icon = PercentIcon,
|
|
@@ -65,12 +103,39 @@ export function RelativeData({
|
|
|
65
103
|
const id = useWidgetId()
|
|
66
104
|
const _labels = { ...DEFAULT_RELATIVE_DATA_LABELS, ...labels }
|
|
67
105
|
|
|
68
|
-
const
|
|
106
|
+
const persistedScale = useWidget(
|
|
69
107
|
id,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
{ initialEnabled },
|
|
108
|
+
(s) =>
|
|
109
|
+
s.transformStates[DATA_DESCRIPTOR.id]?.scale as RelativeScale | undefined,
|
|
73
110
|
)
|
|
111
|
+
const scale = persistedScale ?? initialScale
|
|
112
|
+
|
|
113
|
+
const pairs = useMemo<readonly TransformPair[]>(
|
|
114
|
+
() => [
|
|
115
|
+
{ descriptor: DATA_DESCRIPTOR, fn: transform ?? toRelativeData },
|
|
116
|
+
{
|
|
117
|
+
descriptor: AXIS_DESCRIPTOR,
|
|
118
|
+
fn: scale === 'total' ? toRelativeTotalAxis : identity,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
[transform, scale],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const { enabled, setEnabled, toggle } = useTransform(id, pairs, {
|
|
125
|
+
initialEnabled,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (persistedScale === undefined)
|
|
130
|
+
writeScale(id, initialScale, initialEnabled)
|
|
131
|
+
}, [id, persistedScale, initialScale, initialEnabled])
|
|
132
|
+
|
|
133
|
+
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
|
134
|
+
const selectScale = (next: RelativeScale) => {
|
|
135
|
+
writeScale(id, next, initialEnabled)
|
|
136
|
+
setEnabled(true)
|
|
137
|
+
setAnchorEl(null)
|
|
138
|
+
}
|
|
74
139
|
|
|
75
140
|
const percentFormatter = useMemo(
|
|
76
141
|
() => createPercentFormatter(locale),
|
|
@@ -92,16 +157,68 @@ export function RelativeData({
|
|
|
92
157
|
const tooltip = enabled ? _labels.on : _labels.off
|
|
93
158
|
|
|
94
159
|
return (
|
|
95
|
-
<
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
160
|
+
<Box sx={styles.group}>
|
|
161
|
+
<Tooltip title={tooltip}>
|
|
162
|
+
<IconButton
|
|
163
|
+
size='small'
|
|
164
|
+
aria-label={tooltip}
|
|
165
|
+
aria-pressed={enabled}
|
|
166
|
+
onClick={toggle}
|
|
167
|
+
sx={{ ...styles.toggle, ...(enabled && styles.toggleActive) }}
|
|
168
|
+
>
|
|
169
|
+
<Icon fontSize='small' {...iconProps} />
|
|
170
|
+
</IconButton>
|
|
171
|
+
</Tooltip>
|
|
172
|
+
<Tooltip title={_labels.scaleMenu}>
|
|
173
|
+
<IconButton
|
|
174
|
+
size='small'
|
|
175
|
+
aria-label={_labels.scaleMenu}
|
|
176
|
+
aria-haspopup='true'
|
|
177
|
+
aria-expanded={anchorEl ? 'true' : undefined}
|
|
178
|
+
onClick={(e) => setAnchorEl(e.currentTarget)}
|
|
179
|
+
sx={styles.arrow}
|
|
180
|
+
>
|
|
181
|
+
<ArrowDropDownIcon />
|
|
182
|
+
</IconButton>
|
|
183
|
+
</Tooltip>
|
|
184
|
+
<Menu
|
|
185
|
+
anchorEl={anchorEl}
|
|
186
|
+
open={Boolean(anchorEl)}
|
|
187
|
+
onClose={() => setAnchorEl(null)}
|
|
188
|
+
variant='menu'
|
|
189
|
+
elevation={8}
|
|
190
|
+
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
|
191
|
+
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
102
192
|
>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
193
|
+
{SCALES.map((option) => (
|
|
194
|
+
<MenuItem
|
|
195
|
+
key={option}
|
|
196
|
+
selected={option === scale}
|
|
197
|
+
onClick={() => selectScale(option)}
|
|
198
|
+
>
|
|
199
|
+
<ListItemText>
|
|
200
|
+
{option === 'max' ? _labels.scaleMax : _labels.scaleTotal}
|
|
201
|
+
</ListItemText>
|
|
202
|
+
</MenuItem>
|
|
203
|
+
))}
|
|
204
|
+
</Menu>
|
|
205
|
+
</Box>
|
|
106
206
|
)
|
|
107
207
|
}
|
|
208
|
+
|
|
209
|
+
const SCALES: readonly RelativeScale[] = ['max', 'total']
|
|
210
|
+
|
|
211
|
+
function writeScale(id: string, scale: RelativeScale, initialEnabled: boolean) {
|
|
212
|
+
getWidgetStore(id).setState((s) => ({
|
|
213
|
+
transformStates: {
|
|
214
|
+
...s.transformStates,
|
|
215
|
+
[DATA_DESCRIPTOR.id]: {
|
|
216
|
+
// `useSingleTransform` seeds `enabled` itself; this fallback only
|
|
217
|
+
// keeps the slot well-typed if the scale lands first.
|
|
218
|
+
enabled: initialEnabled,
|
|
219
|
+
...s.transformStates[DATA_DESCRIPTOR.id],
|
|
220
|
+
scale,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}))
|
|
224
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { WidgetState } from '../../stores/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How far a bar reaches while `RelativeData` is on. The value is always
|
|
5
|
+
* the share of the series total; the scale only moves the end of the
|
|
6
|
+
* value axis: `'max'` ends it at the largest share so the top item fills
|
|
7
|
+
* the bar, `'total'` ends it at 100%.
|
|
8
|
+
*/
|
|
9
|
+
export type RelativeScale = 'max' | 'total'
|
|
10
|
+
|
|
11
|
+
export const RELATIVE_DATA_ID = 'relative-data'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The scale the value axis should use right now, or `null` when relative
|
|
15
|
+
* values are off. For widgets that size bars themselves (Category); the
|
|
16
|
+
* ECharts widgets get it through the config transform below.
|
|
17
|
+
*/
|
|
18
|
+
export function selectRelativeScale(s: WidgetState): RelativeScale | null {
|
|
19
|
+
const slot = s.transformStates[RELATIVE_DATA_ID]
|
|
20
|
+
if (slot?.enabled !== true) return null
|
|
21
|
+
return (slot.scale as RelativeScale | undefined) ?? 'max'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Config transform for `'total'`: pins the value axis to ±100. The widget
|
|
26
|
+
* mergers read numeric `yAxis.min` / `yAxis.max` back through
|
|
27
|
+
* {@link fixedAxisBounds} and apply each only on the side the data reaches.
|
|
28
|
+
*/
|
|
29
|
+
export const toRelativeTotalAxis = (option: unknown): unknown => {
|
|
30
|
+
if (option == null || typeof option !== 'object') return option
|
|
31
|
+
const cfg = option as Record<string, unknown>
|
|
32
|
+
const yAxis = cfg.yAxis
|
|
33
|
+
if (yAxis == null || typeof yAxis !== 'object' || Array.isArray(yAxis))
|
|
34
|
+
return option
|
|
35
|
+
return { ...cfg, yAxis: { ...yAxis, min: -100, max: 100 } }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Numeric bounds a config transform set on the structural `yAxis`. */
|
|
39
|
+
export function fixedAxisBounds(yAxis: object): {
|
|
40
|
+
min?: number
|
|
41
|
+
max?: number
|
|
42
|
+
} {
|
|
43
|
+
const { min, max } = yAxis as { min?: unknown; max?: unknown }
|
|
44
|
+
return {
|
|
45
|
+
min: typeof min === 'number' ? min : undefined,
|
|
46
|
+
max: typeof max === 'number' ? max : undefined,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { SxProps, Theme } from '@mui/material'
|
|
2
2
|
|
|
3
3
|
export const styles = {
|
|
4
|
+
group: {
|
|
5
|
+
display: 'inline-flex',
|
|
6
|
+
alignItems: 'center',
|
|
7
|
+
},
|
|
4
8
|
toggle: {
|
|
5
9
|
p: 0.5,
|
|
6
10
|
'& .MuiSvgIcon-root': { fontSize: 20 },
|
|
@@ -8,4 +12,10 @@ export const styles = {
|
|
|
8
12
|
toggleActive: {
|
|
9
13
|
background: (theme: Theme) => theme.palette.primary.relatedLight,
|
|
10
14
|
},
|
|
15
|
+
arrow: {
|
|
16
|
+
p: 0,
|
|
17
|
+
width: 16,
|
|
18
|
+
borderRadius: 0.5,
|
|
19
|
+
'& .MuiSvgIcon-root': { fontSize: 18 },
|
|
20
|
+
},
|
|
11
21
|
} satisfies Record<string, SxProps<Theme>>
|