@carto/ps-react-ui 4.11.1 → 4.11.2
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/echart-BMPpj7n_.js +250 -0
- package/dist/echart-BMPpj7n_.js.map +1 -0
- package/dist/types/widgets-v2/echart/edge-label-clamp.d.ts +57 -0
- package/dist/types/widgets-v2/echart/edge-label-clamp.test.d.ts +1 -0
- package/dist/widgets-v2/bar.js +87 -99
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/echart.js +1 -1
- package/dist/widgets-v2/histogram.js +96 -107
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +104 -116
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +2 -2
- package/package.json +3 -3
- package/src/widgets-v2/bar/options.test.ts +31 -4
- package/src/widgets-v2/bar/options.ts +18 -22
- package/src/widgets-v2/echart/echart-ui.test.tsx +70 -0
- package/src/widgets-v2/echart/echart-ui.tsx +28 -0
- package/src/widgets-v2/echart/edge-label-clamp.test.ts +198 -0
- package/src/widgets-v2/echart/edge-label-clamp.ts +216 -0
- package/src/widgets-v2/histogram/options.test.ts +11 -4
- package/src/widgets-v2/histogram/options.ts +17 -21
- package/src/widgets-v2/timeseries/options.test.ts +9 -4
- package/src/widgets-v2/timeseries/options.ts +17 -22
- package/dist/echart-CU0KmClP.js +0 -176
- package/dist/echart-CU0KmClP.js.map +0 -1
|
@@ -208,7 +208,15 @@ export function createBarOptionFactory(
|
|
|
208
208
|
const liveFormatter = ctx?.formatter
|
|
209
209
|
const liveLabelFormatter = ctx?.labelFormatter
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
// Closure shared between the yAxis min/max callbacks and the label
|
|
212
|
+
// formatter, so only the rounded extents are labelled (matches v1).
|
|
213
|
+
// Delegating the extent to ECharts (rather than precomputing scalars
|
|
214
|
+
// from the raw data) is what keeps stacked bars inside the plot: when
|
|
215
|
+
// StackToggle marks the series, ECharts feeds the *post-stack* extent
|
|
216
|
+
// to these callbacks, so `niceNum` rounds the stacked total instead of
|
|
217
|
+
// a single series.
|
|
218
|
+
let niceMin = 0
|
|
219
|
+
let niceMax = 1
|
|
212
220
|
|
|
213
221
|
// Zoom slider layout: when ZoomToggle has installed `dataZoom`, push the
|
|
214
222
|
// slider above the legend (if any) and reserve room in the grid below.
|
|
@@ -276,12 +284,18 @@ export function createBarOptionFactory(
|
|
|
276
284
|
...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
|
|
277
285
|
yAxis: {
|
|
278
286
|
...baseYAxis,
|
|
279
|
-
min:
|
|
280
|
-
|
|
287
|
+
min: (extent: { min: number }) => {
|
|
288
|
+
niceMin = extent.min < 0 ? niceNum(extent.min) : 0
|
|
289
|
+
return niceMin
|
|
290
|
+
},
|
|
291
|
+
max: (extent: { min: number; max: number }) => {
|
|
292
|
+
niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
|
|
293
|
+
return niceMax
|
|
294
|
+
},
|
|
281
295
|
axisLabel: {
|
|
282
296
|
...((baseYAxis as { axisLabel?: object }).axisLabel ?? {}),
|
|
283
297
|
formatter: (value: number) => {
|
|
284
|
-
if (value !==
|
|
298
|
+
if (value !== niceMax && value !== niceMin) return ''
|
|
285
299
|
if (value === 0) return ''
|
|
286
300
|
return liveFormatter ? liveFormatter(value) : String(value)
|
|
287
301
|
},
|
|
@@ -312,21 +326,3 @@ function buildBarTooltipFormatter(
|
|
|
312
326
|
return { name: String(name), seriesName, marker, value: formattedValue }
|
|
313
327
|
})
|
|
314
328
|
}
|
|
315
|
-
|
|
316
|
-
function computeNiceBounds(seriesArr: BarWidgetData): {
|
|
317
|
-
niceMinVal: number
|
|
318
|
-
niceMaxVal: number
|
|
319
|
-
} {
|
|
320
|
-
let min = 0
|
|
321
|
-
let max = -Infinity
|
|
322
|
-
for (const series of seriesArr) {
|
|
323
|
-
for (const d of series) {
|
|
324
|
-
if (typeof d?.value !== 'number' || !Number.isFinite(d.value)) continue
|
|
325
|
-
if (d.value < min) min = d.value
|
|
326
|
-
if (d.value > max) max = d.value
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
const niceMinVal = min < 0 ? niceNum(min) : 0
|
|
330
|
-
const niceMaxVal = max <= 0 ? 1 : niceNum(max)
|
|
331
|
-
return { niceMinVal, niceMaxVal }
|
|
332
|
-
}
|
|
@@ -11,6 +11,11 @@ const mockChart = {
|
|
|
11
11
|
resize: vi.fn(),
|
|
12
12
|
on: vi.fn(),
|
|
13
13
|
off: vi.fn(),
|
|
14
|
+
// Stubbed for the edge-label clamp; tests override per-case.
|
|
15
|
+
convertToPixel: vi.fn((_finder: unknown, index: number) =>
|
|
16
|
+
index === 0 ? 4 : 296,
|
|
17
|
+
),
|
|
18
|
+
getWidth: vi.fn(() => 300),
|
|
14
19
|
}
|
|
15
20
|
const initSpy = vi.fn((...args: unknown[]): typeof mockChart => {
|
|
16
21
|
void args
|
|
@@ -20,8 +25,20 @@ const initSpy = vi.fn((...args: unknown[]): typeof mockChart => {
|
|
|
20
25
|
vi.mock('echarts', () => ({
|
|
21
26
|
__esModule: true,
|
|
22
27
|
init: (...args: unknown[]) => initSpy(...args),
|
|
28
|
+
// Fake text measurement: width ∝ character count, enough to make a long
|
|
29
|
+
// edge label overflow a near-edge tick in the clamp tests.
|
|
30
|
+
format: {
|
|
31
|
+
getTextRect: (text: unknown) => ({ width: String(text).length * 10 }),
|
|
32
|
+
},
|
|
23
33
|
}))
|
|
24
34
|
|
|
35
|
+
/** Invoke the `finished` listener the bridge bound on the mock chart. */
|
|
36
|
+
function fireFinished(): void {
|
|
37
|
+
for (const [event, handler] of mockChart.on.mock.calls) {
|
|
38
|
+
if (event === 'finished') (handler as () => void)()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
// Resize observer mock — capture the callback so tests can trigger it.
|
|
26
43
|
let resizeCb: (() => void) | null = null
|
|
27
44
|
const observeResizeSpy = vi.fn((_node: HTMLElement, cb: () => void) => {
|
|
@@ -43,11 +60,27 @@ beforeEach(() => {
|
|
|
43
60
|
mockChart.resize.mockReset()
|
|
44
61
|
mockChart.on.mockReset()
|
|
45
62
|
mockChart.off.mockReset()
|
|
63
|
+
mockChart.convertToPixel.mockClear()
|
|
64
|
+
mockChart.getWidth.mockClear()
|
|
46
65
|
initSpy.mockClear()
|
|
47
66
|
observeResizeSpy.mockClear()
|
|
48
67
|
resizeCb = null
|
|
49
68
|
})
|
|
50
69
|
|
|
70
|
+
const categoryOption = {
|
|
71
|
+
xAxis: { type: 'category', axisLabel: {} },
|
|
72
|
+
series: [{ type: 'bar', encode: { x: 'name', y: 'value' }, datasetIndex: 0 }],
|
|
73
|
+
dataset: [
|
|
74
|
+
{
|
|
75
|
+
source: [
|
|
76
|
+
{ name: 'California', value: 3 },
|
|
77
|
+
{ name: 'Texas', value: 2 },
|
|
78
|
+
{ name: 'Wyoming', value: 1 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
} as unknown as echarts.EChartsOption
|
|
83
|
+
|
|
51
84
|
describe('<EchartUI>', () => {
|
|
52
85
|
it('initialises echarts on mount with merged init opts and notifies onInstance', () => {
|
|
53
86
|
const onInstance = vi.fn()
|
|
@@ -186,6 +219,43 @@ describe('<EchartUI>', () => {
|
|
|
186
219
|
expect(mockChart.dispose).toHaveBeenCalledTimes(1)
|
|
187
220
|
})
|
|
188
221
|
|
|
222
|
+
it('clamps overflowing edge labels on `finished` (category x-axis)', () => {
|
|
223
|
+
render(<EchartUI option={categoryOption} />)
|
|
224
|
+
mockChart.setOption.mockClear()
|
|
225
|
+
// First tick at x=4, 'California' ~100px wide (half 50) → overflows left.
|
|
226
|
+
act(() => fireFinished())
|
|
227
|
+
expect(mockChart.setOption).toHaveBeenCalledTimes(1)
|
|
228
|
+
const applied = mockChart.setOption.mock.calls[0]![0] as {
|
|
229
|
+
xAxis: { axisLabel: { alignMinLabel: string | null } }
|
|
230
|
+
}
|
|
231
|
+
expect(applied.xAxis.axisLabel.alignMinLabel).toBe('left')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('does not clamp again on a second `finished` with the same verdict', () => {
|
|
235
|
+
render(<EchartUI option={categoryOption} />)
|
|
236
|
+
act(() => fireFinished())
|
|
237
|
+
mockChart.setOption.mockClear()
|
|
238
|
+
act(() => fireFinished())
|
|
239
|
+
expect(mockChart.setOption).not.toHaveBeenCalled()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('does not clamp a non-category (time) x-axis', () => {
|
|
243
|
+
render(
|
|
244
|
+
<EchartUI
|
|
245
|
+
option={
|
|
246
|
+
{
|
|
247
|
+
xAxis: { type: 'time', axisLabel: {} },
|
|
248
|
+
series: [{ type: 'line', encode: { x: 'name', y: 'value' } }],
|
|
249
|
+
dataset: [{ source: [{ name: 1, value: 2 }] }],
|
|
250
|
+
} as unknown as echarts.EChartsOption
|
|
251
|
+
}
|
|
252
|
+
/>,
|
|
253
|
+
)
|
|
254
|
+
mockChart.setOption.mockClear()
|
|
255
|
+
act(() => fireFinished())
|
|
256
|
+
expect(mockChart.setOption).not.toHaveBeenCalled()
|
|
257
|
+
})
|
|
258
|
+
|
|
189
259
|
it('treats a non-array series as a single-fingerprint signature', () => {
|
|
190
260
|
// The fingerprint helper handles both array AND object/single-series
|
|
191
261
|
// forms. Re-render with a *fresh* option object that has the same
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useEffect, useEffectEvent, useRef } from 'react'
|
|
2
2
|
import { Box, type SxProps, type Theme } from '@mui/material'
|
|
3
3
|
import * as echarts from 'echarts'
|
|
4
|
+
import {
|
|
5
|
+
clampEdgeLabels,
|
|
6
|
+
CENTERED,
|
|
7
|
+
type EdgeAlignment,
|
|
8
|
+
} from './edge-label-clamp'
|
|
4
9
|
import { observeResize } from './shared-resize-observer'
|
|
5
10
|
import { styles } from './style'
|
|
6
11
|
|
|
@@ -70,6 +75,12 @@ export function EchartUI({
|
|
|
70
75
|
const seriesFingerprintRef = useRef<string | null>(null)
|
|
71
76
|
const datasetFingerprintRef = useRef<string | null>(null)
|
|
72
77
|
|
|
78
|
+
// Latest applied option (read by the `finished` clamp listener, which is
|
|
79
|
+
// bound once and must see the current option) + the edge-label alignment
|
|
80
|
+
// currently applied to the chart (so the clamp skips redundant setOptions).
|
|
81
|
+
const optionRef = useRef<echarts.EChartsOption>(option)
|
|
82
|
+
const edgeAlignRef = useRef<EdgeAlignment>(CENTERED)
|
|
83
|
+
|
|
73
84
|
// Stable notify wrapper — always reads the latest `onInstance` prop
|
|
74
85
|
// without forcing the init effect below to re-run when the parent
|
|
75
86
|
// passes a fresh function reference. The init effect now only fires
|
|
@@ -89,7 +100,22 @@ export function EchartUI({
|
|
|
89
100
|
})
|
|
90
101
|
chartRef.current = chart
|
|
91
102
|
notifyInstance(chart)
|
|
103
|
+
// Edge-label clamp: after each layout settles (option change OR resize both
|
|
104
|
+
// end in a render → `finished`), measure the category x-axis edge labels
|
|
105
|
+
// and anchor them inward only if they'd clip. `finished` (not rAF) so
|
|
106
|
+
// `convertToPixel` sees the flushed `lazyUpdate` layout. Loop-safe: the
|
|
107
|
+
// verdict is anchor-independent, so our own clamp setOption recomputes the
|
|
108
|
+
// same result and the ref guard short-circuits.
|
|
109
|
+
const onFinished = (): void => {
|
|
110
|
+
edgeAlignRef.current = clampEdgeLabels(
|
|
111
|
+
chart,
|
|
112
|
+
optionRef.current,
|
|
113
|
+
edgeAlignRef.current,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
chart.on('finished', onFinished)
|
|
92
117
|
return () => {
|
|
118
|
+
chart.off('finished', onFinished)
|
|
93
119
|
// Notify observers *before* disposal so any queued imperative
|
|
94
120
|
// dispatches (e.g. ZoomToggle's `setOption` cleanup) see the
|
|
95
121
|
// instance disappear before its DOM is torn down.
|
|
@@ -108,6 +134,8 @@ export function EchartUI({
|
|
|
108
134
|
if (datasetFp !== datasetFingerprintRef.current) augmented.add('dataset')
|
|
109
135
|
seriesFingerprintRef.current = seriesFp
|
|
110
136
|
datasetFingerprintRef.current = datasetFp
|
|
137
|
+
// Expose the latest option to the `finished` clamp listener (bound once).
|
|
138
|
+
optionRef.current = option
|
|
111
139
|
chartRef.current?.setOption(option, {
|
|
112
140
|
notMerge: false,
|
|
113
141
|
lazyUpdate: true,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import type { ECharts, EChartsOption } from 'echarts'
|
|
3
|
+
import {
|
|
4
|
+
CENTERED,
|
|
5
|
+
clampEdgeLabels,
|
|
6
|
+
decideEdgeAlignment,
|
|
7
|
+
resolveEdgeLabels,
|
|
8
|
+
} from './edge-label-clamp'
|
|
9
|
+
|
|
10
|
+
const FONT = '12px sans-serif'
|
|
11
|
+
|
|
12
|
+
describe('decideEdgeAlignment', () => {
|
|
13
|
+
// 'California' at 12px is ~50px wide → half ~25px. Tick offsets are chosen
|
|
14
|
+
// well below/above that so the verdict is robust to exact font metrics.
|
|
15
|
+
it("anchors 'left' when the first label overflows the left edge", () => {
|
|
16
|
+
const out = decideEdgeAlignment({
|
|
17
|
+
firstLabel: 'California',
|
|
18
|
+
lastLabel: 'X',
|
|
19
|
+
font: FONT,
|
|
20
|
+
firstTickX: 2,
|
|
21
|
+
lastTickX: 690,
|
|
22
|
+
width: 700,
|
|
23
|
+
})
|
|
24
|
+
expect(out.alignMinLabel).toBe('left')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("anchors 'right' when the last label overflows the right edge", () => {
|
|
28
|
+
const out = decideEdgeAlignment({
|
|
29
|
+
firstLabel: 'X',
|
|
30
|
+
lastLabel: 'Wyoming',
|
|
31
|
+
font: FONT,
|
|
32
|
+
firstTickX: 350,
|
|
33
|
+
lastTickX: 698,
|
|
34
|
+
width: 700,
|
|
35
|
+
})
|
|
36
|
+
expect(out.alignMaxLabel).toBe('right')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('leaves both centered (null) when the edge labels fit', () => {
|
|
40
|
+
const out = decideEdgeAlignment({
|
|
41
|
+
firstLabel: 'California',
|
|
42
|
+
lastLabel: 'Wyoming',
|
|
43
|
+
font: FONT,
|
|
44
|
+
firstTickX: 120,
|
|
45
|
+
lastTickX: 580,
|
|
46
|
+
width: 700,
|
|
47
|
+
})
|
|
48
|
+
expect(out).toEqual(CENTERED)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('resolveEdgeLabels', () => {
|
|
53
|
+
const barOption = (): EChartsOption =>
|
|
54
|
+
({
|
|
55
|
+
xAxis: { type: 'category', axisLabel: {} },
|
|
56
|
+
series: [
|
|
57
|
+
{ type: 'bar', encode: { x: 'name', y: 'value' }, datasetIndex: 0 },
|
|
58
|
+
],
|
|
59
|
+
dataset: [
|
|
60
|
+
{
|
|
61
|
+
source: [
|
|
62
|
+
{ name: 'California', value: 3 },
|
|
63
|
+
{ name: 'Texas', value: 2 },
|
|
64
|
+
{ name: 'Wyoming', value: 1 },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}) as unknown as EChartsOption
|
|
69
|
+
|
|
70
|
+
it('reads first/last category from the bar dataset via encode.x = name', () => {
|
|
71
|
+
const out = resolveEdgeLabels(barOption())
|
|
72
|
+
expect(out?.firstLabel).toBe('California')
|
|
73
|
+
expect(out?.lastLabel).toBe('Wyoming')
|
|
74
|
+
expect(out?.count).toBe(3)
|
|
75
|
+
expect(out?.font).toBe('12px sans-serif')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('reads positional encode.x = 0 for the histogram tuple dataset', () => {
|
|
79
|
+
const out = resolveEdgeLabels({
|
|
80
|
+
xAxis: { type: 'category', axisLabel: {} },
|
|
81
|
+
series: [{ type: 'bar', encode: { x: 0, y: 1 }, datasetIndex: 0 }],
|
|
82
|
+
dataset: [
|
|
83
|
+
{
|
|
84
|
+
source: [
|
|
85
|
+
['0–10', 5],
|
|
86
|
+
['90–100', 2],
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
} as unknown as EChartsOption)
|
|
91
|
+
expect(out?.firstLabel).toBe('0–10')
|
|
92
|
+
expect(out?.lastLabel).toBe('90–100')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('applies the axisLabel formatter to the category value', () => {
|
|
96
|
+
const opt = barOption() as unknown as {
|
|
97
|
+
xAxis: { axisLabel: { formatter: (v: string | number) => string } }
|
|
98
|
+
}
|
|
99
|
+
opt.xAxis.axisLabel.formatter = (v) => `[${v}]`
|
|
100
|
+
const out = resolveEdgeLabels(opt as unknown as EChartsOption)
|
|
101
|
+
expect(out?.firstLabel).toBe('[California]')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('builds the font from axisLabel fontSize/fontFamily', () => {
|
|
105
|
+
const out = resolveEdgeLabels({
|
|
106
|
+
xAxis: {
|
|
107
|
+
type: 'category',
|
|
108
|
+
axisLabel: { fontSize: 10, fontFamily: 'Inter' },
|
|
109
|
+
},
|
|
110
|
+
series: [{ encode: { x: 'name' }, datasetIndex: 0 }],
|
|
111
|
+
dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }],
|
|
112
|
+
} as unknown as EChartsOption)
|
|
113
|
+
expect(out?.font).toBe('10px Inter')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns null for a non-category x-axis', () => {
|
|
117
|
+
expect(
|
|
118
|
+
resolveEdgeLabels({
|
|
119
|
+
xAxis: { type: 'value' },
|
|
120
|
+
series: [{ encode: { x: 'name' }, datasetIndex: 0 }],
|
|
121
|
+
dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }],
|
|
122
|
+
} as unknown as EChartsOption),
|
|
123
|
+
).toBeNull()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns null with fewer than two categories', () => {
|
|
127
|
+
expect(resolveEdgeLabels(barOption2(['Solo']))).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function barOption2(names: string[]): EChartsOption {
|
|
132
|
+
return {
|
|
133
|
+
xAxis: { type: 'category', axisLabel: {} },
|
|
134
|
+
series: [{ encode: { x: 'name' }, datasetIndex: 0 }],
|
|
135
|
+
dataset: [{ source: names.map((name) => ({ name })) }],
|
|
136
|
+
} as unknown as EChartsOption
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('clampEdgeLabels', () => {
|
|
140
|
+
const fakeChart = (firstTickX: number, lastTickX: number, width: number) => {
|
|
141
|
+
const setOption = vi.fn()
|
|
142
|
+
const chart = {
|
|
143
|
+
convertToPixel: (_finder: unknown, index: number) =>
|
|
144
|
+
index === 0 ? firstTickX : lastTickX,
|
|
145
|
+
getWidth: () => width,
|
|
146
|
+
setOption,
|
|
147
|
+
} as unknown as ECharts
|
|
148
|
+
return { chart, setOption }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const overflowOption = barOption2(['California', 'Texas', 'Wyoming'])
|
|
152
|
+
|
|
153
|
+
it('applies alignment via setOption when an edge overflows', () => {
|
|
154
|
+
const { chart, setOption } = fakeChart(2, 698, 700)
|
|
155
|
+
const result = clampEdgeLabels(chart, overflowOption, CENTERED)
|
|
156
|
+
expect(result.alignMinLabel).toBe('left')
|
|
157
|
+
expect(setOption).toHaveBeenCalledTimes(1)
|
|
158
|
+
const applied = setOption.mock.calls[0]![0] as {
|
|
159
|
+
xAxis: { axisLabel: { alignMinLabel: string | null } }
|
|
160
|
+
}
|
|
161
|
+
expect(applied.xAxis.axisLabel.alignMinLabel).toBe('left')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not call setOption when the verdict matches the previous state', () => {
|
|
165
|
+
const { chart, setOption } = fakeChart(2, 698, 700)
|
|
166
|
+
const prev = {
|
|
167
|
+
alignMinLabel: 'left' as const,
|
|
168
|
+
alignMaxLabel: 'right' as const,
|
|
169
|
+
}
|
|
170
|
+
clampEdgeLabels(chart, overflowOption, prev)
|
|
171
|
+
expect(setOption).not.toHaveBeenCalled()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('resets to centered under dataZoom (zoom guard) without measuring', () => {
|
|
175
|
+
const { chart, setOption } = fakeChart(2, 698, 700)
|
|
176
|
+
const zoomed = {
|
|
177
|
+
...(overflowOption as object),
|
|
178
|
+
dataZoom: [{ type: 'slider' }],
|
|
179
|
+
} as unknown as EChartsOption
|
|
180
|
+
const prev = { alignMinLabel: 'left' as const, alignMaxLabel: null }
|
|
181
|
+
const result = clampEdgeLabels(chart, zoomed, prev)
|
|
182
|
+
expect(result).toEqual(CENTERED)
|
|
183
|
+
// Cleared the stale 'left' from before zoom.
|
|
184
|
+
expect(setOption).toHaveBeenCalledTimes(1)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('leaves a non-category axis centered and untouched', () => {
|
|
188
|
+
const { chart, setOption } = fakeChart(2, 698, 700)
|
|
189
|
+
const valueAxis = {
|
|
190
|
+
xAxis: { type: 'value' },
|
|
191
|
+
series: [{ encode: { x: 'name' }, datasetIndex: 0 }],
|
|
192
|
+
dataset: [{ source: [{ name: 'A' }, { name: 'B' }] }],
|
|
193
|
+
} as unknown as EChartsOption
|
|
194
|
+
const result = clampEdgeLabels(chart, valueAxis, CENTERED)
|
|
195
|
+
expect(result).toEqual(CENTERED)
|
|
196
|
+
expect(setOption).not.toHaveBeenCalled()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import * as echarts from 'echarts'
|
|
2
|
+
import type { ECharts, EChartsOption } from 'echarts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Render-time clamp that stops the first/last **category x-axis** labels from
|
|
6
|
+
* clipping at the chart edges — *only when they actually overflow*.
|
|
7
|
+
*
|
|
8
|
+
* ECharts centers each category label on its tick; the first/last ticks sit at
|
|
9
|
+
* the plot boundary, so a wide edge label spills past the chart and is cut off.
|
|
10
|
+
* `grid.containLabel` does not contain this horizontal overflow and the v6
|
|
11
|
+
* `outerBounds` shrink is unreliable, so we measure at render time and anchor
|
|
12
|
+
* the overflowing edge label inward (`alignMinLabel: 'left'` /
|
|
13
|
+
* `alignMaxLabel: 'right'`). Labels that fit stay centered.
|
|
14
|
+
*
|
|
15
|
+
* Lives in the generic bridge because the verdict needs the laid-out chart
|
|
16
|
+
* (`convertToPixel`, `getWidth`); everything else is read from the `option`.
|
|
17
|
+
* Auto-targets bar + histogram (category x-axis); pie's horizontal-bar fallback
|
|
18
|
+
* (category *y*-axis), scatterplot (value) and timeseries (time) are skipped.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface EdgeAlignment {
|
|
22
|
+
alignMinLabel: 'left' | null
|
|
23
|
+
alignMaxLabel: 'right' | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const CENTERED: EdgeAlignment = {
|
|
27
|
+
alignMinLabel: null,
|
|
28
|
+
alignMaxLabel: null,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Bias toward anchoring when borderline: clipping is worse than a hair of
|
|
32
|
+
// off-centering, and the reconstructed font may differ slightly from what
|
|
33
|
+
// ECharts actually renders.
|
|
34
|
+
const SAFETY_MARGIN_PX = 2
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pure overflow decision. Inputs are anchor-independent (tick centers and text
|
|
38
|
+
* width don't depend on the label's current `text-anchor`), so feeding the
|
|
39
|
+
* result back via `setOption` doesn't change them — the next pass yields the
|
|
40
|
+
* same verdict, keeping the clamp loop-stable.
|
|
41
|
+
*/
|
|
42
|
+
export function decideEdgeAlignment(args: {
|
|
43
|
+
firstLabel: string
|
|
44
|
+
lastLabel: string
|
|
45
|
+
font: string
|
|
46
|
+
firstTickX: number
|
|
47
|
+
lastTickX: number
|
|
48
|
+
width: number
|
|
49
|
+
}): EdgeAlignment {
|
|
50
|
+
const halfFirst =
|
|
51
|
+
echarts.format.getTextRect(args.firstLabel, args.font).width / 2
|
|
52
|
+
const halfLast =
|
|
53
|
+
echarts.format.getTextRect(args.lastLabel, args.font).width / 2
|
|
54
|
+
return {
|
|
55
|
+
alignMinLabel:
|
|
56
|
+
halfFirst + SAFETY_MARGIN_PX > args.firstTickX ? 'left' : null,
|
|
57
|
+
alignMaxLabel:
|
|
58
|
+
halfLast + SAFETY_MARGIN_PX > args.width - args.lastTickX
|
|
59
|
+
? 'right'
|
|
60
|
+
: null,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface AxisLabel {
|
|
65
|
+
formatter?: (value: string | number) => string | number
|
|
66
|
+
fontSize?: number | string
|
|
67
|
+
fontFamily?: string
|
|
68
|
+
fontWeight?: number | string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface CategoryXAxis {
|
|
72
|
+
type?: string
|
|
73
|
+
axisLabel?: AxisLabel
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface SeriesLike {
|
|
77
|
+
encode?: { x?: string | number }
|
|
78
|
+
datasetIndex?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Cell = string | number | null | undefined
|
|
82
|
+
|
|
83
|
+
function firstOf<T>(value: T | T[] | undefined): T | undefined {
|
|
84
|
+
if (Array.isArray(value)) return value[0]
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function firstXAxis(option: EChartsOption): CategoryXAxis | undefined {
|
|
89
|
+
const axis = firstOf(
|
|
90
|
+
option.xAxis as CategoryXAxis | CategoryXAxis[] | undefined,
|
|
91
|
+
)
|
|
92
|
+
return axis && typeof axis === 'object' ? axis : undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasDataZoom(option: EChartsOption): boolean {
|
|
96
|
+
const dz = option.dataZoom
|
|
97
|
+
return Array.isArray(dz) ? dz.length > 0 : dz != null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function cellToText(value: Cell, fmt: AxisLabel['formatter']): string {
|
|
101
|
+
if (typeof fmt === 'function') return String(fmt(value ?? ''))
|
|
102
|
+
return value == null ? '' : String(value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Extracts the displayed first/last category strings and the label font from
|
|
107
|
+
* the option, reading categories generically via `series[0].encode.x` into the
|
|
108
|
+
* referenced dataset (`'name'` for bar, `0` for histogram). Returns `null` when
|
|
109
|
+
* the option isn't a measurable category x-axis (fewer than 2 categories, no
|
|
110
|
+
* dataset, etc.).
|
|
111
|
+
*/
|
|
112
|
+
export function resolveEdgeLabels(option: EChartsOption): {
|
|
113
|
+
firstLabel: string
|
|
114
|
+
lastLabel: string
|
|
115
|
+
font: string
|
|
116
|
+
count: number
|
|
117
|
+
} | null {
|
|
118
|
+
const xAxis = firstXAxis(option)
|
|
119
|
+
if (!xAxis) return null
|
|
120
|
+
if (xAxis.type !== 'category') return null
|
|
121
|
+
|
|
122
|
+
const series0 = firstOf(
|
|
123
|
+
option.series as SeriesLike | SeriesLike[] | undefined,
|
|
124
|
+
)
|
|
125
|
+
const encodeX = series0?.encode?.x
|
|
126
|
+
if (encodeX == null) return null
|
|
127
|
+
|
|
128
|
+
const datasetIndex = series0?.datasetIndex ?? 0
|
|
129
|
+
const allDatasets = option.dataset as
|
|
130
|
+
| { source?: unknown }
|
|
131
|
+
| { source?: unknown }[]
|
|
132
|
+
| undefined
|
|
133
|
+
const dataset = Array.isArray(allDatasets)
|
|
134
|
+
? allDatasets[datasetIndex]
|
|
135
|
+
: allDatasets
|
|
136
|
+
const source = dataset?.source
|
|
137
|
+
if (!Array.isArray(source) || source.length < 2) return null
|
|
138
|
+
|
|
139
|
+
const rows = source as Record<string | number, Cell>[]
|
|
140
|
+
const fmt = xAxis.axisLabel?.formatter
|
|
141
|
+
const axisLabel = xAxis.axisLabel ?? {}
|
|
142
|
+
const size =
|
|
143
|
+
typeof axisLabel.fontSize === 'number'
|
|
144
|
+
? `${axisLabel.fontSize}px`
|
|
145
|
+
: (axisLabel.fontSize ?? '12px')
|
|
146
|
+
const family = axisLabel.fontFamily ?? 'sans-serif'
|
|
147
|
+
const weight = axisLabel.fontWeight != null ? `${axisLabel.fontWeight} ` : ''
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
firstLabel: cellToText(rows[0]?.[encodeX], fmt),
|
|
151
|
+
lastLabel: cellToText(rows[rows.length - 1]?.[encodeX], fmt),
|
|
152
|
+
font: `${weight}${size} ${family}`,
|
|
153
|
+
count: source.length,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Measures the rendered chart and, when the edge labels would clip, applies
|
|
159
|
+
* `alignMinLabel`/`alignMaxLabel` (or clears them) via an imperative merge
|
|
160
|
+
* `setOption`. Returns the alignment now in effect so the caller can keep a
|
|
161
|
+
* `prev` ref and skip redundant `setOption`s. Should be invoked from the
|
|
162
|
+
* chart's `finished` event (layout is settled, so `convertToPixel` is valid).
|
|
163
|
+
*/
|
|
164
|
+
export function clampEdgeLabels(
|
|
165
|
+
chart: ECharts,
|
|
166
|
+
option: EChartsOption,
|
|
167
|
+
prev: EdgeAlignment,
|
|
168
|
+
): EdgeAlignment {
|
|
169
|
+
const next = computeAlignment(chart, option)
|
|
170
|
+
if (
|
|
171
|
+
next.alignMinLabel === prev.alignMinLabel &&
|
|
172
|
+
next.alignMaxLabel === prev.alignMaxLabel
|
|
173
|
+
) {
|
|
174
|
+
return prev
|
|
175
|
+
}
|
|
176
|
+
chart.setOption({ xAxis: { axisLabel: { ...next } } } as EChartsOption, {
|
|
177
|
+
lazyUpdate: true,
|
|
178
|
+
})
|
|
179
|
+
return next
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function computeAlignment(
|
|
183
|
+
chart: ECharts,
|
|
184
|
+
option: EChartsOption,
|
|
185
|
+
): EdgeAlignment {
|
|
186
|
+
// Under dataZoom, convertToPixel on the absolute first/last index returns
|
|
187
|
+
// off-plot pixels, so the overflow math is invalid — reset to centered.
|
|
188
|
+
if (hasDataZoom(option)) return CENTERED
|
|
189
|
+
|
|
190
|
+
const labels = resolveEdgeLabels(option)
|
|
191
|
+
if (!labels) return CENTERED
|
|
192
|
+
|
|
193
|
+
const firstTickX = chart.convertToPixel({ xAxisIndex: 0 }, 0)
|
|
194
|
+
const lastTickX = chart.convertToPixel({ xAxisIndex: 0 }, labels.count - 1)
|
|
195
|
+
const width = chart.getWidth()
|
|
196
|
+
// Layout not measurable yet (defensive — `finished` normally fires after
|
|
197
|
+
// layout). Fall back to centered; a later `finished` re-measures.
|
|
198
|
+
if (
|
|
199
|
+
typeof firstTickX !== 'number' ||
|
|
200
|
+
typeof lastTickX !== 'number' ||
|
|
201
|
+
!Number.isFinite(firstTickX) ||
|
|
202
|
+
!Number.isFinite(lastTickX) ||
|
|
203
|
+
!width
|
|
204
|
+
) {
|
|
205
|
+
return CENTERED
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return decideEdgeAlignment({
|
|
209
|
+
firstLabel: labels.firstLabel,
|
|
210
|
+
lastLabel: labels.lastLabel,
|
|
211
|
+
font: labels.font,
|
|
212
|
+
firstTickX,
|
|
213
|
+
lastTickX,
|
|
214
|
+
width,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
@@ -203,14 +203,21 @@ describe('createHistogramOptionFactory (data → dataset merger)', () => {
|
|
|
203
203
|
expect(typeof out.tooltip?.formatter).toBe('function')
|
|
204
204
|
})
|
|
205
205
|
|
|
206
|
-
it('resolves y-axis min/max
|
|
206
|
+
it('resolves y-axis min/max via niceNum callbacks after fusion', () => {
|
|
207
207
|
const merge = createHistogramOptionFactory({ theme, ticks: [0, 10, 20] })
|
|
208
208
|
const out = merge({}, [[3, 47]]) as {
|
|
209
|
-
yAxis: {
|
|
209
|
+
yAxis: {
|
|
210
|
+
min: (extent: { min: number }) => number
|
|
211
|
+
max: (extent: { min: number; max: number }) => number
|
|
212
|
+
}
|
|
210
213
|
}
|
|
211
|
-
|
|
214
|
+
// Bounds are callbacks: ECharts supplies the (post-stack) extent and we
|
|
215
|
+
// round it with niceNum, so stacked bins can't overflow the axis.
|
|
216
|
+
expect(typeof out.yAxis.min).toBe('function')
|
|
217
|
+
expect(typeof out.yAxis.max).toBe('function')
|
|
218
|
+
expect(out.yAxis.min({ min: 0 })).toBe(0)
|
|
212
219
|
// 47 → niceNum → 50 (Math.ceil(47 / 10) * 10)
|
|
213
|
-
expect(out.yAxis.max).toBe(50)
|
|
220
|
+
expect(out.yAxis.max({ min: 0, max: 47 })).toBe(50)
|
|
214
221
|
})
|
|
215
222
|
|
|
216
223
|
it('uses series[i].name for series[i].name when provided', () => {
|