diff --git a/common/changes/@visactor/vchart/feat-axis-cross-value_2026-09-14.json b/common/changes/@visactor/vchart/feat-axis-cross-value_2026-09-14.json new file mode 100644 index 0000000000..c4a2d09a6a --- /dev/null +++ b/common/changes/@visactor/vchart/feat-axis-cross-value_2026-09-14.json @@ -0,0 +1,21 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "feat: add `crossValue` to pin a cartesian axis to a tick of the perpendicular axis", + "type": "none" + }, + { + "packageName": "@visactor/vchart", + "comment": "fix: `domainLine.onZeroAxisId` never matched because it compared the internal auto id instead of the spec id", + "type": "none" + }, + { + "packageName": "@visactor/vchart", + "comment": "fix: `domainLine.onZero` could bind to an axis of another region, or to a z axis, in multi-axis charts", + "type": "none" + } + ], + "packageName": "@visactor/vchart", + "email": "chendaxin.tk@bytedance.com" +} diff --git a/docs/assets/option/en/component/axis-common/cartesian-axis.md b/docs/assets/option/en/component/axis-common/cartesian-axis.md index 0488070275..88084b3a18 100644 --- a/docs/assets/option/en/component/axis-common/cartesian-axis.md +++ b/docs/assets/option/en/component/axis-common/cartesian-axis.md @@ -42,6 +42,29 @@ When there are multiple axes, you can use this property to manually specify on w When there are multiple axes, you can use this property to manually specify on which axis the 0 scale is. +#${prefix} crossValue(number) + +The tick value on the perpendicular axis where this axis is pinned. When omitted, the axis stays on the edge given by `orient` (default behavior). Only supported when `orient` is `left`/`right`/`top`/`bottom`. + +When set, the axis line, ticks, labels and unit move together to that tick; the **axis title stays on the original edge**, and grid lines stay put as well. The axis is raised above the series. Without a title it stops reserving space on the edge of the plot area (the plot area gets that band back); with a title it keeps reserving, because the title still lives there. An axis with an explicit `width` / `height` also keeps reserving that space, since an explicit size takes priority. + +- When the perpendicular axis is continuous, the value is a tick value on that axis; values outside its domain are clamped to the nearest end. +- When the perpendicular axis is discrete (band), the value is a **zero-based index** into its domain and the axis is placed at the start edge of that band; an index greater than or equal to the band count is clamped to the end of the axis. +- The perpendicular axis is picked in this order: `crossAxisId` > `crossAxisIndex` > the first axis that is perpendicular to this one, shares the same region, and whose domain covers the value. +- `domainLine.onZero` is the line-only narrow version of `crossValue: 0`; when both are set, `crossValue` wins. + +#${prefix} crossAxisId(string|number) + +Specifies which axis `crossValue` refers to when the chart has multiple axes, matched against the `id` on that axis spec. + +- Tips: once specified, `crossValue` no longer has to fall inside that axis domain; out-of-range values are clamped to the nearest end. + +#${prefix} crossAxisIndex(number) + +Specifies which axis `crossValue` refers to when the chart has multiple axes. + +- Tips: **the index refers to the index in the `axes` array**. + #${prefix} label(Object) Axis Label Configuration. diff --git a/docs/assets/option/zh/component/axis-common/cartesian-axis.md b/docs/assets/option/zh/component/axis-common/cartesian-axis.md index 038168fb9c..ad6735a02c 100644 --- a/docs/assets/option/zh/component/axis-common/cartesian-axis.md +++ b/docs/assets/option/zh/component/axis-common/cartesian-axis.md @@ -42,6 +42,29 @@ X 轴或者 Y 轴的轴线是否在另一个轴的 0 刻度上,只有在另一 当有多轴时,可以用这个属性手动指定,在哪个轴的 0 刻度上。 +#${prefix} crossValue(number) + +本轴钉在垂直方向那根轴的哪个刻度值上。不配置时轴贴 `orient` 指定的那条边(默认行为)。仅 `orient` 为 `left`/`right`/`top`/`bottom` 的轴支持。 + +配置后轴线、刻度、标签、轴单位整体移动到该刻度处;**轴标题保持在原来那条边上**,网格线也保持不动。轴会被抬到系列之上;没有轴标题时它不再在绘图区边上占位(把这条带子让给绘图区),有轴标题时仍然占位 —— 标题还留在那里。显式配了 `width` / `height` 的轴同样保留占位:显式尺寸优先级更高。 + +- 参照轴是连续轴时,取值就是该轴上的刻度值,超出 domain 会被夹到端点。 +- 参照轴是离散(band)轴时,取值是 domain 的**下标(0 基)**,轴落在该类目的起始边;下标大于等于类目数则夹到轴末端。 +- 参照轴的选取:`crossAxisId` > `crossAxisIndex` > 与本轴垂直、共用同一绘图区、且 domain 罩得住该值的第一根轴。 +- `domainLine.onZero` 是本配置 `crossValue: 0` 的「仅轴线」窄版,两者同时配置时以 `crossValue` 为准。 + +#${prefix} crossAxisId(string|number) + +当有多轴时,手动指定 `crossValue` 参照哪根轴的刻度,取值为该轴 spec 上的 `id`。 + +- Tips: 显式指定后不再要求 `crossValue` 落在该轴 domain 内,越界会被夹到端点。 + +#${prefix} crossAxisIndex(number) + +当有多轴时,手动指定 `crossValue` 参照哪根轴的刻度。 + +- Tips: **该索引对应 `axes` 数组的下标**。 + #${prefix} label(Object) 轴标签配置。 diff --git a/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts b/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts index cae05123be..1b4032f368 100644 --- a/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts +++ b/packages/vchart-types/types/component/axis/cartesian/interface/spec.d.ts @@ -3,14 +3,19 @@ import type { IBandAxisSpec, ILinearAxisSpec, IGrid, ICommonAxisSpec } from '../ import type { ICartesianDomainLine, ICartesianLabel, ITimeLayerType, ICartesianTitle, ICartesianAxisUnit } from './common'; import type { AxisItemStateStyle } from '@visactor/vrender-components'; export type ICartesianAxisSpec = ICartesianLinearAxisSpec | ICartesianBandAxisSpec | ICartesianTimeAxisSpec | ICartesianLogAxisSpec | ICartesianSymlogAxisSpec; -export type ICartesianVertical = { +export type ICartesianAxisCrossing = { + crossValue?: number; + crossAxisId?: StringOrNumber; + crossAxisIndex?: number; +}; +export type ICartesianVertical = ICartesianAxisCrossing & { orient: 'left' | 'right'; innerOffset?: { top?: ILayoutNumber; bottom?: ILayoutNumber; }; }; -export type ICartesianHorizontal = { +export type ICartesianHorizontal = ICartesianAxisCrossing & { orient: 'top' | 'bottom'; innerOffset?: { left?: ILayoutNumber; diff --git a/packages/vchart/__tests__/unit/component/cartesian/axis/cross-value.test.ts b/packages/vchart/__tests__/unit/component/cartesian/axis/cross-value.test.ts new file mode 100644 index 0000000000..caef88565c --- /dev/null +++ b/packages/vchart/__tests__/unit/component/cartesian/axis/cross-value.test.ts @@ -0,0 +1,432 @@ +import { default as VChart } from '../../../../../src'; +import type { ICartesianZ } from '../../../../../src/component/axis/cartesian/interface/spec'; +import { LayoutZIndex } from '../../../../../src/constant/layout'; +import { createCanvas, removeDom } from '../../../../util/dom'; + +const CATEGORIES = ['A', 'B', 'C', 'D', 'E']; +const VALUES = [-60, 40, -20, 80, 30]; +const DATA = CATEGORIES.map((x, i) => ({ x, y: VALUES[i] })); + +describe('cartesian axis crossValue', () => { + let canvasDom: HTMLCanvasElement; + let vchart: any; + + beforeEach(() => { + canvasDom = createCanvas(); + canvasDom.style.position = 'relative'; + canvasDom.style.width = '500px'; + canvasDom.style.height = '400px'; + canvasDom.width = 500; + canvasDom.height = 400; + }); + + afterEach(() => { + removeDom(canvasDom); + vchart?.release(); + vchart = null; + }); + + const render = (spec: any) => { + vchart = new VChart(spec, { renderCanvas: canvasDom, animation: false }); + vchart.renderSync(); + return vchart; + }; + + const axisOf = (id: string) => vchart.getComponents().find((com: any) => com.userId === id) as any; + const styleOf = (axis: any) => axis._axisMark.getSimpleStyle() ?? {}; + /** 真正决定分层的是图元上的 zIndex,不是 markConfig —— 后者写了不代表图元跟着变 */ + const zOf = (axis: any) => axis._axisMark.getProduct()?.attribute?.zIndex; + /** 同理,偏移要看落到图元上的那个值:setSimpleStyle 丢掉字段不等于图元上被清掉 */ + const graphicDy = (axis: any) => axis._axisMark._component?.attribute?.dy; + const isHorizontalAxis = (axis: any) => axis.getOrient() === 'top' || axis.getOrient() === 'bottom'; + /** 轴线最终落在图表坐标系的哪个位置 */ + const axisLinePos = (axis: any) => { + const key = isHorizontalAxis(axis) ? 'y' : 'x'; + return axis.getLayoutStartPoint()[key] + (styleOf(axis)[`d${key}`] ?? 0); + }; + /** 参照轴上某个刻度值在图表坐标系的位置 —— crossValue 的定义就是「轴线落在这里」 */ + const tickPos = (axis: any, value: any) => { + const key = isHorizontalAxis(axis) ? 'x' : 'y'; + return axis.getLayoutStartPoint()[key] + axis.valueToPosition(value); + }; + + const barSpec = (valueAxis: any = {}, catAxis: any = {}, rest: any = {}) => ({ + type: 'bar', + width: 500, + height: 400, + animation: false, + data: [{ id: 'd', values: DATA }], + xField: 'x', + yField: 'y', + axes: [ + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100, ...valueAxis }, + { id: 'cat', orient: 'bottom', type: 'band', ...catAxis } + ], + ...rest + }); + + describe('geometry', () => { + it('pins a bottom axis onto the tick of the left axis', () => { + render(barSpec({}, { crossValue: 0 })); + const cat = axisOf('cat'); + const value = axisOf('value'); + + expect(value.getScale().domain()).toEqual([-100, 100]); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(value, 0), 5); + // 不经过 valueToPosition 的独立校验:对称 domain 的 0 刻度必须正落在值轴中点 + const range = value.getScale().range() as number[]; + expect(axisLinePos(cat) - (value.getLayoutStartPoint().y + range[1])).toBeCloseTo( + value.getLayoutStartPoint().y + range[0] - axisLinePos(cat), + 5 + ); + }); + + it('pins a top axis onto the tick of the left axis', () => { + render(barSpec({}, { orient: 'top', crossValue: 25 })); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value'), 25), 5); + }); + + it('pins a left axis onto the band start of the bottom axis', () => { + render(barSpec({ crossValue: 2 }, {})); + const value = axisOf('value'); + const cat = axisOf('cat'); + expect(axisLinePos(value)).toBeCloseTo(tickPos(cat, CATEGORIES[2]), 5); + }); + + it('pins a right axis onto the band start of the bottom axis', () => { + render(barSpec({ orient: 'right', crossValue: 3 }, {})); + const value = axisOf('value'); + const cat = axisOf('cat'); + expect(axisLinePos(value)).toBeCloseTo(tickPos(cat, CATEGORIES[3]), 5); + }); + + it('follows an inverted perpendicular axis', () => { + render(barSpec({ inverse: true }, { crossValue: 60 })); + const cat = axisOf('cat'); + const value = axisOf('value'); + expect(value.getInverse()).toBe(true); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(value, 60), 5); + // inverse 之后 60 落在下半区,比中点更靠下 + expect(axisLinePos(cat)).toBeGreaterThan(tickPos(value, 0)); + }); + + it('stays on the tick when the perpendicular axis has innerOffset', () => { + render(barSpec({ innerOffset: { top: 20, bottom: 30 } }, { crossValue: 0 })); + const cat = axisOf('cat'); + const value = axisOf('value'); + const range = value.getScale().range() as number[]; + // innerOffset 让 range 不再贴着布局矩形的两端,用旧的 range[0] 相对量会整体偏 30px + expect(range[0]).toBeCloseTo(value.getLayoutRect().height - 30, 5); + expect(range[1]).toBeCloseTo(20, 5); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(value, 0), 5); + }); + + it('clamps an out-of-range value to the end of a continuous axis', () => { + render(barSpec({}, { crossValue: 1000 })); + const cat = axisOf('cat'); + const value = axisOf('value'); + // 越界不该让整根轴退回边上:夹到 domain 末端 + expect(axisLinePos(cat)).toBeCloseTo(tickPos(value, 100), 5); + }); + + it('clamps an out-of-range index to the end of a band axis', () => { + render(barSpec({ crossValue: 99 }, {})); + const value = axisOf('value'); + const cat = axisOf('cat'); + const range = cat.getScale().range() as number[]; + expect(axisLinePos(value)).toBeCloseTo(cat.getLayoutStartPoint().x + range[range.length - 1], 5); + }); + }); + + describe('layout', () => { + it('gives the reserved band back to the plot area when the axis moves inside', () => { + render(barSpec({}, {})); + const baselineHeight = axisOf('cat').getLayoutRect().height; + expect(baselineHeight).toBeGreaterThan(0); + vchart.release(); + + render(barSpec({}, { crossValue: 0 })); + expect(axisOf('cat').getLayoutRect().height).toBe(0); + }); + + it('keeps reserving the band when the axis has a title', () => { + render(barSpec({}, { crossValue: 0, title: { visible: true, text: 'category' } })); + const cat = axisOf('cat'); + expect(cat.getLayoutRect().height).toBeGreaterThan(0); + // 标题反向补偿,留在原来那条边上 + expect(styleOf(cat).title.dy).toBeCloseTo(-styleOf(cat).dy, 5); + }); + + it('keeps reserving the band for a field-alias title with no explicit text', () => { + render(barSpec({}, { crossValue: 0, title: { visible: true } })); + expect(axisOf('cat').getLayoutRect().height).toBeGreaterThan(0); + }); + + it('still reserves an explicitly sized band (documented limitation)', () => { + render(barSpec({}, { crossValue: 0, height: 40 })); + // 用户显式给的尺寸是 USER 级,布局不接受测量结果把它改掉 —— 轴照样移过去,但带子留着 + const cat = axisOf('cat'); + expect(cat.getLayoutRect().height).toBe(40); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value'), 0), 5); + }); + + it('keeps reserving when the value is clamped to an edge', () => { + render(barSpec({}, { crossValue: -1000 })); + // 夹到端点 = 还贴着边,不该把带子收掉 + expect(axisOf('cat').getLayoutRect().height).toBeGreaterThan(0); + }); + + it('raises the axis above the region', () => { + render(barSpec({}, { crossValue: 0 })); + const cat = axisOf('cat'); + const plain = axisOf('value'); + expect(zOf(cat)).toBeGreaterThan(zOf(plain)); + expect(zOf(cat)).toBeGreaterThan(LayoutZIndex.Region); + }); + }); + + describe('target axis resolution', () => { + it('honours crossAxisId matched against the spec id', () => { + render( + barSpec( + {}, + { crossValue: 0, crossAxisId: 'value2' }, + { + axes: [ + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100 }, + { id: 'value2', orient: 'right', type: 'linear', min: -100, max: 300 }, + { id: 'cat', orient: 'bottom', type: 'band', crossValue: 0, crossAxisId: 'value2' } + ] + } + ) + ); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value2'), 0), 5); + // 两根值轴的 domain 不同,0 的位置也不同 —— 绑错了这条会挂 + expect(axisLinePos(cat)).not.toBeCloseTo(tickPos(axisOf('value'), 0), 1); + }); + + it('does not match an internal component id', () => { + render(barSpec({}, { crossValue: 0 })); + const internalId = axisOf('value').id; + vchart.release(); + + // 内部自增 id 不是公开契约,拿它当 crossAxisId 不该命中任何轴 + render(barSpec({}, { crossValue: 0, crossAxisId: internalId })); + expect(styleOf(axisOf('cat')).dy ?? 0).toBe(0); + }); + + it('honours crossAxisIndex as the index in the axes array', () => { + render( + barSpec( + {}, + {}, + { + axes: [ + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100 }, + { id: 'value2', orient: 'right', type: 'linear', min: -100, max: 300 }, + { id: 'cat', orient: 'bottom', type: 'band', crossValue: 0, crossAxisIndex: 1 } + ] + } + ) + ); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value2'), 0), 5); + }); + + it('counts crossAxisIndex against the spec, not the compacted component array', () => { + render( + barSpec( + {}, + {}, + { + axes: [ + // 非法 orient,不会建出组件 —— 组件数组从此跟 spec 下标错开 + { id: 'bogus', orient: 'nowhere' }, + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100 }, + { id: 'value2', orient: 'right', type: 'linear', min: -100, max: 300 }, + { id: 'cat', orient: 'bottom', type: 'band', crossValue: 0, crossAxisIndex: 2 } + ] + } + ) + ); + expect(axisOf('bogus')).toBeUndefined(); + const cat = axisOf('cat'); + // spec 下标 2 是 value2;按组件数组算会落到 cat 自己身上,什么都不会动 + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value2'), 0), 5); + }); + + it('keeps an explicit target even when the value is out of its domain', () => { + render(barSpec({}, { crossValue: 1000, crossAxisId: 'value' })); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value'), 100), 5); + }); + + it('prefers the axis whose domain covers the value', () => { + render( + barSpec( + {}, + {}, + { + axes: [ + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100 }, + { id: 'value2', orient: 'right', type: 'linear', min: 150, max: 300 }, + { id: 'cat', orient: 'bottom', type: 'band', crossValue: 200 } + ] + } + ) + ); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value2'), 200), 5); + }); + + it('binds to the perpendicular axis of its own region', () => { + render({ + type: 'common', + width: 500, + height: 400, + animation: false, + data: [ + { id: 'd0', values: DATA }, + { id: 'd1', values: DATA } + ], + region: [{ id: 'r0' }, { id: 'r1' }], + series: [ + { type: 'bar', regionIndex: 0, dataId: 'd0', xField: 'x', yField: 'y' }, + { type: 'bar', regionIndex: 1, dataId: 'd1', xField: 'x', yField: 'y' } + ], + axes: [ + { id: 'value0', orient: 'left', regionIndex: [0], type: 'linear', min: -100, max: 100 }, + { id: 'cat0', orient: 'bottom', regionIndex: [0], type: 'band', crossValue: 0 }, + { id: 'value1', orient: 'left', regionIndex: [1], type: 'linear', min: -100, max: 300 }, + { id: 'cat1', orient: 'bottom', regionIndex: [1], type: 'band', crossValue: 0 } + ] + }); + const cat1 = axisOf('cat1'); + expect(cat1.getRegions()[0]).not.toBe(axisOf('cat0').getRegions()[0]); + expect(axisLinePos(cat1)).toBeCloseTo(tickPos(axisOf('value1'), 0), 5); + // 不能绑到数组里排在前面、但属于另一个 region 的那根(两根 domain 不同,位置可区分) + expect(axisLinePos(cat1)).not.toBeCloseTo(tickPos(axisOf('value0'), 0), 1); + }); + }); + + describe('lifecycle', () => { + it('clears the offset when crossValue is removed', () => { + render(barSpec({}, { crossValue: 0 })); + expect(styleOf(axisOf('cat')).dy).not.toBe(0); + + vchart.updateSpec(barSpec({}, {})); + const cat = axisOf('cat'); + expect(graphicDy(cat)).toBe(0); + expect(cat.getLayoutRect().height).toBeGreaterThan(0); + expect(zOf(cat)).toBe(zOf(axisOf('value'))); + }); + + it('applies the offset when crossValue is added', () => { + render(barSpec({}, {})); + expect(styleOf(axisOf('cat')).dy ?? 0).toBe(0); + + vchart.updateSpec(barSpec({}, { crossValue: 0 })); + const cat = axisOf('cat'); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value'), 0), 5); + expect(zOf(cat)).toBeGreaterThan(zOf(axisOf('value'))); + }); + + it('follows a domain change', () => { + render(barSpec({}, { crossValue: 0 })); + vchart.updateSpec(barSpec({ min: -400, max: 100 }, { crossValue: 0 })); + const cat = axisOf('cat'); + const value = axisOf('value'); + expect(value.getScale().domain()).toEqual([-400, 100]); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(value, 0), 5); + }); + + it('clears the offset when the target no longer resolves', () => { + render(barSpec({}, { crossValue: 0 })); + expect(styleOf(axisOf('cat')).dy).not.toBe(0); + + vchart.updateSpec(barSpec({}, { crossValue: 0, crossAxisId: 'nope' })); + const cat = axisOf('cat'); + expect(graphicDy(cat)).toBe(0); + // 轴线真的回到了底边,而不是只在 _simpleStyle 里没了那个字段 + expect(cat._axisMark.getProduct().AABBBounds.y1).toBeGreaterThan(cat.getRegions()[0].getLayoutRect().height / 2); + }); + }); + + describe('other marks', () => { + it('keeps grid lines spanning the region', () => { + render(barSpec({}, { crossValue: 0, grid: { visible: true } })); + const cat = axisOf('cat'); + const gridStyle = cat._gridMark.getSimpleStyle(); + expect(gridStyle.dx ?? 0).toBe(0); + expect(gridStyle.dy ?? 0).toBe(0); + }); + + it('moves the axis unit together with the axis', () => { + render(barSpec({}, { crossValue: 0, unit: { visible: true, text: 'pcs' } })); + const cat = axisOf('cat'); + expect(cat._unitText.attribute.dy).toBeCloseTo(styleOf(cat).dy, 5); + }); + + it('keeps the unit offset the user configured', () => { + render(barSpec({}, { unit: { visible: true, text: 'pcs', style: { dy: 7 } } })); + expect(axisOf('cat')._unitText.attribute.dy).toBe(7); + }); + + it('adds the cross offset on top of the configured unit offset', () => { + render(barSpec({}, { crossValue: 0, unit: { visible: true, text: 'pcs', style: { dy: 7 } } })); + const cat = axisOf('cat'); + expect(cat._unitText.attribute.dy).toBeCloseTo(styleOf(cat).dy + 7, 5); + }); + }); + + describe('z axis', () => { + it('does not carry crossValue on the z branch of the spec type', () => { + const zAxis: ICartesianZ = { + orient: 'z', + // @ts-expect-error z 轴没有「对面那根轴」,crossValue 一族不挂在它上面 + crossValue: 0 + }; + expect(zAxis.orient).toBe('z'); + }); + + it('ignores a crossValue smuggled onto a z axis at runtime', () => { + render( + barSpec( + {}, + {}, + { + axes: [ + { id: 'value', orient: 'left', type: 'linear', min: -100, max: 100 }, + { id: 'cat', orient: 'bottom', type: 'band' }, + { id: 'z', orient: 'z', crossValue: 0 } + ] + } + ) + ); + const z = axisOf('z'); + expect(z).toBeTruthy(); + expect(styleOf(z).dx ?? 0).toBe(0); + expect(styleOf(z).dy ?? 0).toBe(0); + }); + }); + + describe('domainLine.onZero', () => { + it('resolves onZeroAxisId against the spec id', () => { + render(barSpec({}, { domainLine: { visible: true, onZero: true, onZeroAxisId: 'value' } })); + const cat = axisOf('cat'); + const value = axisOf('value'); + const lineDy = styleOf(cat).line?.dy ?? 0; + expect(lineDy).not.toBe(0); + expect(cat.getLayoutStartPoint().y + lineDy).toBeCloseTo(tickPos(value, 0), 5); + }); + + it('lets crossValue win over onZero', () => { + render(barSpec({}, { crossValue: 50, domainLine: { visible: true, onZero: true } })); + const cat = axisOf('cat'); + expect(styleOf(cat).line?.dy ?? 0).toBe(0); + expect(axisLinePos(cat)).toBeCloseTo(tickPos(axisOf('value'), 50), 5); + }); + }); +}); diff --git a/packages/vchart/src/component/axis/cartesian/axis.ts b/packages/vchart/src/component/axis/cartesian/axis.ts index f67b4b92f1..fafdbb6b0e 100644 --- a/packages/vchart/src/component/axis/cartesian/axis.ts +++ b/packages/vchart/src/component/axis/cartesian/axis.ts @@ -4,7 +4,7 @@ import { Bounds, last, type IBounds, type IBoundsLike, type Maybe } from '@visac import type { IEffect, IModelInitOption, IModelSpecInfo } from '../../../model/interface'; import type { ICartesianSeries } from '../../../series/interface'; import type { IRegion } from '../../../region/interface'; -import type { ICartesianAxisCommonSpec, IAxisHelper, ICartesianVertical } from './interface'; +import type { ICartesianAxisCommonSpec, IAxisHelper, ICartesianVertical, ICartesianAxisCrossing } from './interface'; import { mergeSpec } from '@visactor/vutils-extension'; import { isArray, @@ -131,6 +131,11 @@ export abstract class CartesianAxis { + // `crossValue` 是它的全量版(挪整根轴),两者同时配会让轴线被平移两次 —— 前者优先 + if (isValidNumber(this._crossSpec.crossValue)) { + return; + } // 在布局结束之后调整坐标轴零基线 const { onZero, visible } = this._spec.domainLine; if (this.visible && onZero && visible !== false) { const { onZeroAxisId, onZeroAxisIndex } = this._spec.domainLine; - const axesComponents = this._option.getComponentsByKey('axes') as IAxis[]; - const isX = isXAxis(this.getOrient()); + const isX = isXAxis(this._orient); - // 判断坐标轴是否可用 - const isValidAxis = (item: any) => { - return ( - (isX ? !isXAxis(item.getOrient()) : isXAxis(item.getOrient())) && - isContinuous(item.getScale().type) && - (item.getTickData() - ? item - .getTickData() - .getLatestData() - ?.find((d: any) => d.value === 0) - : item.getScale().domain()[0] <= 0 && last(item.getScale().domain()) >= 0) - ); + // 0 刻度得真的落在这根轴上 + const hasZeroTick = (item: IAxis) => { + const scale = item.getScale(); + if (!isContinuous(scale.type)) { + return false; + } + const tickData = (item as unknown as CartesianAxis).getTickData?.(); + return tickData + ? !!tickData.getLatestData()?.find((d: Datum) => d.value === 0) + : scale.domain()[0] <= 0 && last(scale.domain() as number[]) >= 0; }; - const relativeAxes = axesComponents.filter(item => isValidAxis(item)); - if (relativeAxes.length) { - let bindAxis; - if (isValid(onZeroAxisId)) { - bindAxis = relativeAxes.find(axis => axis.id === onZeroAxisId); - } else if (isValid(onZeroAxisIndex)) { - const indexAxis = axesComponents[onZeroAxisIndex]; - if (isValidAxis(indexAxis)) { - bindAxis = indexAxis; - } + const bindAxis = this._getPerpendicularAxis(onZeroAxisId, onZeroAxisIndex, hasZeroTick); + if (bindAxis) { + const axisMark = this._axisMark; + // 找到了绑定的 axis,获取基线的位置 + const position = bindAxis.valueToPosition(0); + // 获取偏移量 + if (isX) { + axisMark.setSimpleStyle({ + ...axisMark.getSimpleStyle(), + line: { + ...this._axisStyle.line, + dy: + this._orient === 'bottom' + ? -( + (bindAxis.getInverse() ? bindAxis.getScale().range()[1] : bindAxis.getScale().range()[0]) - + position + ) + : position + } + }); } else { - // 默认绑定第一条的相对坐标轴 - bindAxis = relativeAxes[0]; - } - if (bindAxis) { - const axisMark = this._axisMark; - // 找到了绑定的 axis,获取基线的位置 - const position = bindAxis.valueToPosition(0); - // 获取偏移量 - if (isX) { - axisMark.setSimpleStyle({ - ...axisMark.getSimpleStyle(), - line: { - ...this._axisStyle.line, - dy: - this._orient === 'bottom' - ? -( - (bindAxis.getInverse() ? bindAxis.getScale().range()[1] : bindAxis.getScale().range()[0]) - - position - ) - : position - } - }); - } else { - axisMark.setSimpleStyle({ - ...axisMark.getSimpleStyle(), - line: { - ...this._axisStyle.line, - dx: - this._orient === 'left' - ? position - : -( - (bindAxis.getInverse() ? bindAxis.getScale().range()[0] : bindAxis.getScale().range()[1]) - - position - ) - } - }); - } + axisMark.setSimpleStyle({ + ...axisMark.getSimpleStyle(), + line: { + ...this._axisStyle.line, + dx: + this._orient === 'left' + ? position + : -( + (bindAxis.getInverse() ? bindAxis.getScale().range()[0] : bindAxis.getScale().range()[1]) - + position + ) + } + }); } } } }; + /** 能给本轴提供刻度的那些轴:严格垂直(z 轴与极坐标轴都不提供)、且跟自己同区(否则刻度落在别的绘图区里) */ + private _getPerpendicularAxes(): IAxis[] { + const selfRegions = this.getRegions(); + const isX = isXAxis(this._orient); + return (this._option.getComponentsByKey('axes') as IAxis[]).filter(item => { + if (!item || item === this || item.getCoordinateType() !== 'cartesian') { + return false; + } + const orient = item.getOrient() as IOrientType; + return ( + (isX ? isYAxis(orient) : isXAxis(orient)) && + !!item.getScale() && + item.getRegions().some(region => selfRegions.includes(region)) + ); + }); + } + + private _findPerpendicularAxis( + axes: IAxis[], + axisId: StringOrNumber | undefined, + axisIndex: number | undefined + ): IAxis | undefined { + if (isValid(axisId)) { + return axes.find(item => item.userId === axisId); + } + if (isValid(axisIndex)) { + // 认 spec 上的下标,不是组件数组的位置:非法轴不会建组件,两者不一定对齐 + const target = + axes.find(item => item.getSpecIndex() === axisIndex) ?? + (this._option.getComponentsByKey('axes') as IAxis[])[axisIndex]; + return target && axes.includes(target) ? target : undefined; + } + return undefined; + } + + /** + * `crossValue` 与 `domainLine.onZero` 共用的选轴:显式 id(只认 spec 上写的 `id`)> 显式 spec 下标 > 第一根满足 `accept` 的。 + */ + private _getPerpendicularAxis( + axisId: StringOrNumber | undefined, + axisIndex: number | undefined, + accept: (axis: IAxis) => boolean + ): IAxis | undefined { + const axes = this._getPerpendicularAxes(); + if (isValid(axisId) || isValid(axisIndex)) { + const target = this._findPerpendicularAxis(axes, axisId, axisIndex); + return target && accept(target) ? target : undefined; + } + return axes.find(accept); + } + + /** + * 选出给 `crossValue` 提供刻度的轴,同时算出它在该轴局部坐标里的像素位置。 + * 没显式指定时优先挑 domain 罩得住这个值的那根;band 轴上 `crossValue` 是 domain 的 0 基下标。 + */ + private _resolveCrossTarget(): { axis: IAxis; position: number; interior: boolean } | undefined { + const { crossAxisId, crossAxisIndex, crossValue } = this._crossSpec; + if (!isValidNumber(crossValue)) { + return undefined; + } + + const axes = this._getPerpendicularAxes(); + let axis: IAxis | undefined; + if (isValid(crossAxisId) || isValid(crossAxisIndex)) { + axis = this._findPerpendicularAxis(axes, crossAxisId, crossAxisIndex); + } else { + axis = + axes.find(item => { + const scale = item.getScale(); + if (!isContinuous(scale.type)) { + return true; + } + const domain = scale.domain() as number[]; + return minInArr(domain) <= crossValue && crossValue <= maxInArr(domain); + }) ?? axes[0]; + } + if (!axis) { + return undefined; + } + + const scale = axis.getScale(); + const domain = scale.domain(); + if (isContinuous(scale.type)) { + const min = minInArr(domain as number[]); + const max = maxInArr(domain as number[]); + return { + axis, + position: axis.valueToPosition(clamp(crossValue, min, max)), + interior: crossValue > min && crossValue < max + }; + } + + // valueToPosition 给的是该类目的起始边;下标越界没有对应类目,只能直接取轴末端 + const index = clamp(Math.round(crossValue), 0, domain.length); + return { + axis, + position: index < domain.length ? axis.valueToPosition(domain[index]) : last(scale.range() as number[]), + interior: crossValue > 0 && crossValue < domain.length + }; + } + + /** 判据与渲染端一致:`base-axis` 只在 `title.visible` 为真时才建标题 */ + private _hasAxisTitle(): boolean { + return this._spec.title?.visible === true; + } + + /** 被夹到端点时仍按贴边处理,不算落在内部 */ + private _isCrossInterior(): boolean { + return this._resolveCrossTarget()?.interior ?? false; + } + + /** + * 两端都换到图表坐标系再相减。不读 range:轴组的原点就画在轴线上(`start` 恒为 `{x:0,y:0}`), + * 所以 inverse、innerOffset、多 region 都不用单独分支。 + */ + private _getCrossOffset(): number | null { + if (!this.visible) { + return null; + } + const target = this._resolveCrossTarget(); + if (!target) { + return null; + } + const key = isXAxis(this._orient) ? 'y' : 'x'; + return target.axis.getLayoutStartPoint()[key] + target.position - this.getLayoutStartPoint()[key]; + } + protected _layoutCacheProcessing(rect: ILayoutRect) { ['width', 'height'].forEach(key => { if (rect[key as 'width' | 'height'] < this._layoutCache[key as 'width' | 'height']) { @@ -1072,6 +1219,19 @@ export abstract class CartesianAxis