import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' /** * Render-time clamp that stops the first/last **category x-axis** labels from * clipping at the chart edges — *only when they actually overflow*. * * ECharts centers each category label on its tick; the first/last ticks sit at * the plot boundary, so a wide edge label spills past the chart and is cut off. * `grid.containLabel` does not contain this horizontal overflow and the v6 * `outerBounds` shrink is unreliable, so we measure at render time and anchor * the overflowing edge label inward (`alignMinLabel: 'left'` / * `alignMaxLabel: 'right'`). Labels that fit stay centered. * * Lives in the generic bridge because the verdict needs the laid-out chart * (`convertToPixel`, `getWidth`); everything else is read from the `option`. * Auto-targets bar + histogram (category x-axis); pie's horizontal-bar fallback * (category *y*-axis), scatterplot (value) and timeseries (time) are skipped. */ export interface EdgeAlignment { alignMinLabel: 'left' | null alignMaxLabel: 'right' | null } export const CENTERED: EdgeAlignment = { alignMinLabel: null, alignMaxLabel: null, } // Small cushion biasing toward anchoring when borderline: clipping is worse // than a hair of off-centering, and the measured width can differ slightly // from the final render (mainly when the web font isn't loaded yet at measure // time). Kept to 1px — a wider margin anchored labels that only just clear the // edge, and an anchored edge label can then be dropped by the axis // `hideOverlap`, leaving a gap. 1px guards measurement error without that. const SAFETY_MARGIN_PX = 1 /** * Pure overflow decision. Inputs are anchor-independent (tick centers and text * width don't depend on the label's current `text-anchor`), so feeding the * result back via `setOption` doesn't change them — the next pass yields the * same verdict, keeping the clamp loop-stable. */ export function decideEdgeAlignment(args: { firstLabel: string lastLabel: string font: string firstTickX: number lastTickX: number width: number }): EdgeAlignment { const halfFirst = echarts.format.getTextRect(args.firstLabel, args.font).width / 2 const halfLast = echarts.format.getTextRect(args.lastLabel, args.font).width / 2 return { alignMinLabel: halfFirst + SAFETY_MARGIN_PX > args.firstTickX ? 'left' : null, alignMaxLabel: halfLast + SAFETY_MARGIN_PX > args.width - args.lastTickX ? 'right' : null, } } interface AxisLabel { formatter?: (value: string | number) => string | number fontSize?: number | string fontFamily?: string fontWeight?: number | string } interface CategoryXAxis { type?: string axisLabel?: AxisLabel } interface SeriesLike { encode?: { x?: string | number } datasetIndex?: number } type Cell = string | number | null | undefined function firstOf(value: T | T[] | undefined): T | undefined { if (Array.isArray(value)) return value[0] return value } function firstXAxis(option: EChartsOption): CategoryXAxis | undefined { const axis = firstOf( option.xAxis as CategoryXAxis | CategoryXAxis[] | undefined, ) return axis && typeof axis === 'object' ? axis : undefined } function hasDataZoom(option: EChartsOption): boolean { const dz = option.dataZoom return Array.isArray(dz) ? dz.length > 0 : dz != null } function cellToText(value: Cell, fmt: AxisLabel['formatter']): string { if (typeof fmt === 'function') return String(fmt(value ?? '')) return value == null ? '' : String(value) } /** * Extracts the displayed first/last category strings and the label font from * the option, reading categories generically via `series[0].encode.x` into the * referenced dataset (`'name'` for bar, `0` for histogram). Returns `null` when * the option isn't a measurable category x-axis (fewer than 2 categories, no * dataset, etc.). */ export function resolveEdgeLabels(option: EChartsOption): { firstLabel: string lastLabel: string font: string count: number } | null { const xAxis = firstXAxis(option) if (!xAxis) return null if (xAxis.type !== 'category') return null const series0 = firstOf( option.series as SeriesLike | SeriesLike[] | undefined, ) const encodeX = series0?.encode?.x if (encodeX == null) return null const datasetIndex = series0?.datasetIndex ?? 0 const allDatasets = option.dataset as | { source?: unknown } | { source?: unknown }[] | undefined const dataset = Array.isArray(allDatasets) ? allDatasets[datasetIndex] : allDatasets const source = dataset?.source if (!Array.isArray(source) || source.length < 2) return null const rows = source as Record[] const fmt = xAxis.axisLabel?.formatter const axisLabel = xAxis.axisLabel ?? {} const size = typeof axisLabel.fontSize === 'number' ? `${axisLabel.fontSize}px` : (axisLabel.fontSize ?? '12px') const family = axisLabel.fontFamily ?? 'sans-serif' const weight = axisLabel.fontWeight != null ? `${axisLabel.fontWeight} ` : '' return { firstLabel: cellToText(rows[0]?.[encodeX], fmt), lastLabel: cellToText(rows[rows.length - 1]?.[encodeX], fmt), font: `${weight}${size} ${family}`, count: source.length, } } /** * Measures the rendered chart and, when the edge labels would clip, applies * `alignMinLabel`/`alignMaxLabel` (or clears them) via an imperative merge * `setOption`. Returns the alignment now in effect so the caller can keep a * `prev` ref and skip redundant `setOption`s. Should be invoked from the * chart's `finished` event (layout is settled, so `convertToPixel` is valid). */ export function clampEdgeLabels( chart: ECharts, option: EChartsOption, prev: EdgeAlignment, ): EdgeAlignment { const next = computeAlignment(chart, option) if ( next.alignMinLabel === prev.alignMinLabel && next.alignMaxLabel === prev.alignMaxLabel ) { return prev } chart.setOption({ xAxis: { axisLabel: { ...next } } } as EChartsOption, { lazyUpdate: true, }) return next } function computeAlignment( chart: ECharts, option: EChartsOption, ): EdgeAlignment { // Under dataZoom, convertToPixel on the absolute first/last index returns // off-plot pixels, so the overflow math is invalid — reset to centered. if (hasDataZoom(option)) return CENTERED const labels = resolveEdgeLabels(option) if (!labels) return CENTERED const firstTickX = chart.convertToPixel({ xAxisIndex: 0 }, 0) const lastTickX = chart.convertToPixel({ xAxisIndex: 0 }, labels.count - 1) const width = chart.getWidth() // Layout not measurable yet (defensive — `finished` normally fires after // layout). Fall back to centered; a later `finished` re-measures. if ( typeof firstTickX !== 'number' || typeof lastTickX !== 'number' || !Number.isFinite(firstTickX) || !Number.isFinite(lastTickX) || !width ) { return CENTERED } return decideEdgeAlignment({ firstLabel: labels.firstLabel, lastLabel: labels.lastLabel, font: labels.font, firstTickX, lastTickX, width, }) }