fix(图表): 统一坐标轴标签的旋转锚点与轴线间距

This commit is contained in:
jianneng-fit2cloud
2026-07-24 10:57:30 +08:00
parent cc5d4c4af7
commit 86560d384e
18 changed files with 156 additions and 67 deletions

View File

@@ -792,7 +792,7 @@ export class Bar extends G2ChartView<ViewSpec, G2Column> {
...label,
labelAutoHide: true,
labelAutoRotate: false,
labelTransform: `rotate(${axis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(axis)
}
return x
}

View File

@@ -487,7 +487,7 @@ export class BidirectionalHorizontalBar extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle({ ...xAxis, position }),
transform: [
{
type: 'hide',
@@ -583,6 +583,11 @@ export class BidirectionalHorizontalBar extends G2ChartView {
}
merge(yAxisOption, { position: POSITION_MAP[yAxis.position] })
merge(yAxisExtOption, { position: POSITION_MAP[yAxisExt.position] })
merge(yAxisOption, this.getAxisLabelStyle({ ...yAxis, position: yAxisOption.position }))
merge(
yAxisExtOption,
this.getAxisLabelStyle({ ...yAxisExt, position: yAxisExtOption.position })
)
// 横向布局下数值轴在上下方,G2 默认轴高度偏保守,会挤出较大的底部/顶部空白
const axisTickLength = 4
const axisLabelSpacing = 4

View File

@@ -320,14 +320,16 @@ export class BulletGraph extends G2ChartView<RuntimeOptions, G2Bullet> {
}
protected configXAxis(chart: Chart): any {
return this.getAxisConfig(chart, 'xAxis')
const horizontal = parseJson(chart.customAttr).basicStyle.layout === 'horizontal'
return this.getAxisConfig(chart, 'xAxis', horizontal ? 'left' : 'bottom')
}
protected configYAxis(chart: Chart): any {
return this.getAxisConfig(chart, 'yAxis')
const horizontal = parseJson(chart.customAttr).basicStyle.layout === 'horizontal'
return this.getAxisConfig(chart, 'yAxis', horizontal ? 'bottom' : 'left')
}
protected getAxisConfig(chart: Chart, axisType: string) {
protected getAxisConfig(chart: Chart, axisType: string, position: string) {
const customStyle = parseJson(chart.customStyle)
const axis = JSON.parse(JSON.stringify(customStyle[axisType]))
if (customStyle[axisType] && axis.show) {
@@ -383,7 +385,7 @@ export class BulletGraph extends G2ChartView<RuntimeOptions, G2Bullet> {
...grid,
// 刻度值
...label,
labelTransform: `rotate(${axis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle({ ...axis, position })
}
return x
}

View File

@@ -272,7 +272,7 @@ export class HorizontalBar extends Bar {
...label,
labelAutoHide: true,
labelAutoRotate: false,
labelTransform: `rotate(${Math.abs(axis.axisLabel.rotate || 0)})`
...this.getAxisLabelStyle(axis)
}
return x
}

View File

@@ -156,39 +156,6 @@ export class RangeBar extends HorizontalBar {
return newChart
}
protected configXAxis(chart: PanelChart, options: ViewSpec): ViewSpec {
const tmpOptions = super.configXAxis(chart, options)
const { yAxis } = parseJson(chart.customStyle)
if (tmpOptions.children[0].axis.x) {
const rotate = Math.abs(yAxis.axisLabel.rotate || 0)
const rotateRatio = Math.sin((rotate * Math.PI) / 180)
// 按旋转后的横向投影补偿半个字号,保持标签与轴线的视觉间距
tmpOptions.children[0].axis.x.labelSpacing = 4 + (yAxis.axisLabel.fontSize / 2) * rotateRatio
if (rotate) {
const { data, encode } = tmpOptions.children[0]
const edgeData = yAxis.position === 'right' ? data[data.length - 1] : data[0]
const label = `${edgeData[encode.x] ?? ''}`
const lengthLimit = yAxis.axisLabel.lengthLimit
const displayLabel =
lengthLimit && label.length > lengthLimit
? `${label.substring(0, lengthLimit)}...`
: label
const labelWidth = Array.from(displayLabel).reduce(
(width, char) => width + yAxis.axisLabel.fontSize * (/[^\x00-\xff]/.test(char) ? 1 : 0.6),
0
)
// 为边缘标签旋转后的纵向投影预留空间,避免超出画布被裁剪
const edgeInset = Math.ceil((labelWidth * rotateRatio) / 2)
if (yAxis.position === 'right') {
tmpOptions.insetBottom = Math.max(tmpOptions.insetBottom || 0, edgeInset)
} else {
tmpOptions.insetTop = Math.max(tmpOptions.insetTop || 0, edgeInset)
}
}
}
return tmpOptions
}
protected configYAxis(chart: PanelChart, options: ViewSpec): ViewSpec {
const tmpOptions = super.configYAxis(chart, options)
if (tmpOptions.children[0].axis?.y === false) {
@@ -200,14 +167,22 @@ export class RangeBar extends HorizontalBar {
const rangeChart = asRangeDateChart(chart)
const isDate = !!rangeChart.data.isDate
const dateFormat = options.children[0].scale.y.mask
const formatAxisValue = val => {
if (isDate) {
return formatRangeDate(new Date(val), dateFormat)
}
return valueFormatter(val, axis.axisLabelFormatter)
}
const formatAxisLabel = (val, index, ticks) => {
if (ticks?.length > 1 && index === ticks.length - 1) {
return ''
}
return formatAxisValue(val)
}
tmpOptions.children[0].axis.y = {
...tmpOptions.children[0].axis.y,
labelFormatter: val => {
if (isDate) {
return formatRangeDate(new Date(val), dateFormat)
}
return valueFormatter(val, axis.axisLabelFormatter)
}
labelAutoHide: { keepHeader: true },
labelFormatter: formatAxisLabel
}
return tmpOptions
}

View File

@@ -584,7 +584,7 @@ export class StockLine extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(xAxis),
transform: [
{
type: 'hide',
@@ -646,7 +646,7 @@ export class StockLine extends G2ChartView {
gridLineDash,
// 隐藏最低分割线,避免与底部 X 轴线重叠
gridFilter: (_, index) => index !== 0,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
return valueFormatter(d, yAxis.axisLabelFormatter)
}

View File

@@ -479,7 +479,7 @@ export class Area extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}
@@ -533,7 +533,7 @@ export class Area extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: yAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
return valueFormatter(d, yAxis.axisLabelFormatter)
}

View File

@@ -444,7 +444,7 @@ export class Line extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}
@@ -497,7 +497,7 @@ export class Line extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: yAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
return valueFormatter(d, yAxis.axisLabelFormatter)
}

View File

@@ -711,7 +711,7 @@ export class GroupLineMix extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}

View File

@@ -652,7 +652,7 @@ export class GroupLineMix extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}

View File

@@ -646,7 +646,7 @@ export class StackLineMix extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}

View File

@@ -560,7 +560,7 @@ export class ColumnLineMix extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}

View File

@@ -352,7 +352,7 @@ export class Quadrant extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(xAxis),
// 应用横轴标签格式配置
labelFormatter: d => {
return valueFormatter(d, xAxis.axisLabelFormatter)
@@ -437,7 +437,7 @@ export class Quadrant extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: yAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
return valueFormatter(d, yAxis.axisLabelFormatter)
}

View File

@@ -758,7 +758,7 @@ export class MultiScatter extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: axisStyle.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${axisStyle.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(axisStyle)
}
}

View File

@@ -413,7 +413,7 @@ export class Scatter extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: xAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}
@@ -465,7 +465,7 @@ export class Scatter extends G2ChartView {
gridStrokeOpacity: 1,
gridLineWidth: yAxis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
return valueFormatter(d, yAxis.axisLabelFormatter)
}

View File

@@ -509,7 +509,7 @@ export class TableG2Chart extends G2ChartView {
label: xAxis.axisLabel.show,
labelFill: xAxis.axisLabel.color,
labelFontSize: xAxis.axisLabel.fontSize,
labelTransform: `rotate(${xAxis.axisLabel.rotate || 0})`
...this.getAxisLabelStyle(xAxis)
}
}
}
@@ -547,7 +547,7 @@ export class TableG2Chart extends G2ChartView {
label: yAxis.axisLabel.show,
labelFill: yAxis.axisLabel.color,
labelFontSize: yAxis.axisLabel.fontSize,
labelTransform: `rotate(${yAxis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(yAxis),
labelFormatter: d => {
const str = toString(d)
if (!str) {

View File

@@ -18,6 +18,24 @@ export const getLegendNavButtonPath = (size: number) => [
['Z']
]
let axisLabelMeasureContext: CanvasRenderingContext2D | null | undefined
const measureAxisLabelWidth = (value: unknown, fontSize: number) => {
const label =
typeof value === 'object' && value !== null
? `${value['label'] ?? value['value'] ?? ''}`
: `${value ?? ''}`
axisLabelMeasureContext ??= document.createElement('canvas').getContext('2d')
if (axisLabelMeasureContext) {
axisLabelMeasureContext.font = `${fontSize}px sans-serif`
return axisLabelMeasureContext.measureText(label).width
}
return Array.from(label).reduce(
(width, char) => width + fontSize * (/[^\x00-\xff]/.test(char) ? 1 : 0.6),
0
)
}
export interface G2DrawOptions<O> extends AntVDrawOptions<O> {
/**
* 缩放比例
@@ -41,10 +59,98 @@ export abstract class G2ChartView<
*
* 某些图表必须读取首次渲染生成的布局信息,修正配置后再执行一次 render
* 公共渲染组件会等待该钩子结束,再恢复联动等依赖最终图形元素的事件状态
* 未实现该钩子的图表不会增加额外处理或渲染,原有渲染流程保持不变
* 未实现该钩子的图表不会增加专属处理,公共轴标签边界校正独立执行
*/
public afterRender?(chart: P): void | Promise<void>
/**
* 轴标签超出画布时改用显式内边距,优先保留标签并压缩绘图区
*/
public async adjustAxisLabelOverflow(chart: P): Promise<void> {
const context = chart?.getContext()
const canvas = context?.canvas
const axisViews = (context?.views ?? []).filter(view =>
view.components?.some(
component =>
`${component.type}`.startsWith('axis') &&
['top', 'right', 'bottom', 'left'].includes(`${component.position}`)
)
)
if (!axisViews.length) {
return
}
const labels = canvas?.getRoot().querySelectorAll('.g2-axis-label-item') ?? []
if (!labels.length) {
return
}
const { width = chart.getContainer().clientWidth, height = chart.getContainer().clientHeight } =
canvas.getConfig()
const visibleBounds = labels
.filter(label => label.style.visibility !== 'hidden')
.map(label => label.getBBox())
.filter(({ width, height }) => width > 0 && height > 0)
if (!visibleBounds.length) {
return
}
const safeSpacing = 4
const minX = Math.min(...visibleBounds.map(({ x }) => x))
const minY = Math.min(...visibleBounds.map(({ y }) => y))
const maxX = Math.max(...visibleBounds.map(({ x, width }) => x + width))
const maxY = Math.max(...visibleBounds.map(({ y, height }) => y + height))
const overflow = {
paddingLeft: Math.max(0, safeSpacing - minX),
paddingRight: Math.max(0, maxX - width + safeSpacing),
paddingTop: Math.max(0, safeSpacing - minY),
paddingBottom: Math.max(0, maxY - height + safeSpacing)
}
if (Object.values(overflow).every(value => value < 1)) {
return
}
const options = chart.options()
Object.entries(overflow).forEach(([key, value]) => {
if (value < 1) {
return
}
const layoutValue = Math.max(0, ...axisViews.map(view => Number(view.layout?.[key]) || 0))
const optionValue = typeof options[key] === 'number' ? options[key] : 0
options[key] = Math.ceil(Math.max(layoutValue, optionValue) + value)
})
chart.options(options)
await chart.render()
}
/**
* 统一坐标轴标签的旋转锚点与轴线间距
*/
protected getAxisLabelStyle(axis: DeepPartial<ChartAxisStyle>): Partial<AxisComponent> {
const position = axis.position
const rotate = Number(axis.axisLabel.rotate) || 0
const rotateRadian = (rotate * Math.PI) / 180
const rotateRatio = Math.sin(Math.abs(rotateRadian))
const fontSize = axis.axisLabel.fontSize || 12
if (position === 'top' || position === 'bottom') {
const direction = position === 'top' ? -1 : 1
return {
labelSpacing: 4,
labelTextAlign: 'center',
labelTextBaseline: position === 'top' ? 'bottom' : 'top',
labelTransform: value => {
const offset = (measureAxisLabelWidth(value, fontSize) * rotateRatio) / 2
return `translate(0, ${(direction * offset).toFixed(2)}px) rotate(${rotate})`
}
}
}
if (position === 'left' || position === 'right') {
return {
labelSpacing: 4 + (fontSize * rotateRatio) / 2,
labelTextAlign: position === 'left' ? 'right' : 'left',
labelTextBaseline: 'middle',
labelTransform: `rotate(${rotate})`
}
}
return { labelTransform: `rotate(${rotate})` }
}
protected getLegend = (chart: Chart) => {
let legend = {}
let customStyle: CustomStyle
@@ -145,7 +251,7 @@ export abstract class G2ChartView<
gridStrokeOpacity: 1,
gridLineWidth: axis.splitLine.lineStyle.width,
gridLineDash,
labelTransform: `rotate(${axis.axisLabel.rotate || 0})`,
...this.getAxisLabelStyle(axis),
transform: [
{
type: 'hide',

View File

@@ -602,9 +602,10 @@ const renderG2 = async (chart, chartView: G2ChartView<any, any>) => {
})
// 固定本轮创建的实例,避免等待异步渲染期间新一轮 renderG2 替换全局 myChart 后误操作新实例
const chartInstance = myChart
// 先等待 G2 首次渲染,图表视图的 afterRender 才能安全读取 view.layout 等首次布局结果
// 先等待 G2 首次渲染,后续布局校正才能安全读取实际标签边界与 view.layout
await chartInstance?.render()
// 仅实现钩子的特殊图表会执行后处理;普通 G2 图表没有额外逻辑,也不会产生额外 render
await chartView.adjustAxisLabelOverflow(chartInstance)
// 仅实现钩子的特殊图表会继续执行专属后处理
await chartView.afterRender?.(chartInstance)
// 异步等待期间若图表已被新实例替换,本轮旧实例不再回放联动状态,避免污染当前画布
if (chartInstance && chartInstance === myChart) {