@carto/ps-react-ui 4.11.1 → 4.11.3

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.
Files changed (30) hide show
  1. package/dist/echart-BMPpj7n_.js +250 -0
  2. package/dist/echart-BMPpj7n_.js.map +1 -0
  3. package/dist/types/widgets-v2/echart/edge-label-clamp.d.ts +57 -0
  4. package/dist/types/widgets-v2/echart/edge-label-clamp.test.d.ts +1 -0
  5. package/dist/types/widgets-v2/wrapper/style.d.ts +5 -12
  6. package/dist/widgets-v2/bar.js +87 -99
  7. package/dist/widgets-v2/bar.js.map +1 -1
  8. package/dist/widgets-v2/echart.js +1 -1
  9. package/dist/widgets-v2/histogram.js +96 -107
  10. package/dist/widgets-v2/histogram.js.map +1 -1
  11. package/dist/widgets-v2/timeseries.js +104 -116
  12. package/dist/widgets-v2/timeseries.js.map +1 -1
  13. package/dist/widgets-v2.js +248 -244
  14. package/dist/widgets-v2.js.map +1 -1
  15. package/package.json +1 -1
  16. package/src/widgets-v2/bar/options.test.ts +31 -4
  17. package/src/widgets-v2/bar/options.ts +18 -22
  18. package/src/widgets-v2/echart/echart-ui.test.tsx +70 -0
  19. package/src/widgets-v2/echart/echart-ui.tsx +28 -0
  20. package/src/widgets-v2/echart/edge-label-clamp.test.ts +198 -0
  21. package/src/widgets-v2/echart/edge-label-clamp.ts +216 -0
  22. package/src/widgets-v2/histogram/options.test.ts +11 -4
  23. package/src/widgets-v2/histogram/options.ts +17 -21
  24. package/src/widgets-v2/timeseries/options.test.ts +9 -4
  25. package/src/widgets-v2/timeseries/options.ts +17 -22
  26. package/src/widgets-v2/wrapper/style.ts +13 -18
  27. package/src/widgets-v2/wrapper/widget-wrapper.test.tsx +66 -0
  28. package/src/widgets-v2/wrapper/widget-wrapper.tsx +7 -4
  29. package/dist/echart-CU0KmClP.js +0 -176
  30. package/dist/echart-CU0KmClP.js.map +0 -1
@@ -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 to concrete niceNum bounds after fusion', () => {
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: { min?: number; max?: number }
209
+ yAxis: {
210
+ min: (extent: { min: number }) => number
211
+ max: (extent: { min: number; max: number }) => number
212
+ }
210
213
  }
211
- expect(out.yAxis.min).toBe(0)
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', () => {
@@ -209,7 +209,14 @@ export function createHistogramOptionFactory(
209
209
  // pick that up.
210
210
  const liveFormatter = ctx?.formatter
211
211
 
212
- const { niceMinVal, niceMaxVal } = computeHistogramNiceBounds(seriesArr)
212
+ // Closure shared between the yAxis min/max callbacks and the label
213
+ // formatter, so only the rounded extents are labelled (matches v1 +
214
+ // bar). Delegating the extent to ECharts (rather than precomputing
215
+ // scalars from the raw counts) keeps stacked bins inside the plot:
216
+ // when StackToggle marks the series, ECharts feeds the *post-stack*
217
+ // extent to these callbacks, so `niceNum` rounds the stacked total.
218
+ let niceMin = 0
219
+ let niceMax = 1
213
220
 
214
221
  // Zoom slider layout: when ZoomToggle has installed `dataZoom`, push
215
222
  // the slider above the legend (if any) and reserve room in the grid.
@@ -270,12 +277,18 @@ export function createHistogramOptionFactory(
270
277
  ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
271
278
  yAxis: {
272
279
  ...baseYAxis,
273
- min: niceMinVal,
274
- max: niceMaxVal,
280
+ min: (extent: { min: number }) => {
281
+ niceMin = extent.min < 0 ? niceNum(extent.min) : 0
282
+ return niceMin
283
+ },
284
+ max: (extent: { min: number; max: number }) => {
285
+ niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
286
+ return niceMax
287
+ },
275
288
  axisLabel: {
276
289
  ...((baseYAxis as { axisLabel?: object }).axisLabel ?? {}),
277
290
  formatter: (value: number) => {
278
- if (value !== niceMaxVal && value !== niceMinVal) return ''
291
+ if (value !== niceMax && value !== niceMin) return ''
279
292
  if (value === 0) return ''
280
293
  return liveFormatter ? liveFormatter(value) : String(value)
281
294
  },
@@ -315,23 +328,6 @@ function buildHistogramTooltipFormatter(
315
328
  })
316
329
  }
317
330
 
318
- function computeHistogramNiceBounds(seriesArr: HistogramWidgetData): {
319
- niceMinVal: number
320
- niceMaxVal: number
321
- } {
322
- let max = -Infinity
323
- for (const counts of seriesArr) {
324
- for (const v of counts) {
325
- if (typeof v !== 'number' || !Number.isFinite(v)) continue
326
- if (v > max) max = v
327
- }
328
- }
329
- return {
330
- niceMinVal: 0,
331
- niceMaxVal: max <= 0 ? 1 : niceNum(max),
332
- }
333
- }
334
-
335
331
  function formatNumber(n: number): string {
336
332
  if (Number.isInteger(n)) return String(n)
337
333
  return Number(n.toFixed(2)).toString()
@@ -260,13 +260,18 @@ describe('createTimeseriesOptionFactory', () => {
260
260
  { formatter: fmt },
261
261
  ) as {
262
262
  yAxis: {
263
- min: number
264
- max: number
263
+ min: (extent: { min: number }) => number
264
+ max: (extent: { min: number; max: number }) => number
265
265
  axisLabel: { formatter: (v: number) => string }
266
266
  }
267
267
  }
268
- expect(out.yAxis.min).toBe(0)
269
- expect(out.yAxis.max).toBe(100)
268
+ // Bounds are callbacks now: ECharts supplies the (post-stack) extent and
269
+ // we round it with niceNum. Invoke them as ECharts would before reading
270
+ // the label formatter (which keys off the closure-cached extents).
271
+ expect(typeof out.yAxis.min).toBe('function')
272
+ expect(typeof out.yAxis.max).toBe('function')
273
+ expect(out.yAxis.min({ min: 0 })).toBe(0)
274
+ expect(out.yAxis.max({ min: 0, max: 100 })).toBe(100)
270
275
  expect(out.yAxis.axisLabel.formatter(100)).toBe('100%')
271
276
  expect(out.yAxis.axisLabel.formatter(50)).toBe('')
272
277
  expect(out.yAxis.axisLabel.formatter(0)).toBe('')