Skip to content

feat(vchart): add crossValue to pin a cartesian axis to a tick of the perpendicular axis - #4681

Open
g1f9 wants to merge 1 commit into
VisActor:developfrom
g1f9:feat/axis-cross-value
Open

g1f9 wants to merge 1 commit into
VisActor:developfrom
g1f9:feat/axis-cross-value

Conversation

@g1f9

@g1f9 g1f9 commented Sep 14, 2026

Copy link
Copy Markdown

🤔 Motivation

orient can only place a cartesian axis on one of the four edges, so an axis that should be drawn inside the plot area has no spec-level expression today.

domainLine.onZero covers one narrow slice of this: it only supports the value 0, and it only moves the axis line — ticks, labels and unit stay behind on the edge.

Spreadsheet-style charts need the general form: pin an axis to an arbitrary tick of the perpendicular axis, and move the whole axis there. That is what crossValue adds.

💡 Modification

crossValue / crossAxisId / crossAxisIndex on the cartesian axis spec:

  • continuous perpendicular axis — the value is a tick value, clamped to the domain
  • band perpendicular axis — the value is a zero-based index into the domain; the axis is placed at the start edge of that band, and an index >= domain.length clamps to the end of the axis
  • the axis line, ticks, labels and unit move together. The axis title stays on the original edge — it is compensated back, so it keeps living outside the plot area
  • grid lines are untouched (they are a separate mark) and keep spanning the region
  • the offset is computed in chart coordinates (bindAxis.layoutStart + localPos - self.layoutStart), so inverse, innerOffset and multi-region charts need no special branches
  • the axis is raised above the region, otherwise its labels would be hidden behind marks. The z-index is resolved in setAttrFromSpec, and adding/removing crossValue forces a remake, because a mark's z-index is only written when the mark is built
  • the axis stops reserving space on the edge, so the plot area gets that band back. Two documented exceptions: an axis with a title keeps reserving (the title still lives there), and an axis with an explicit width / height keeps reserving (an explicit size outranks the measured one)
  • crossValue takes precedence over domainLine.onZero; configuring both would translate the axis line twice

crossValue is declared on ICartesianVertical / ICartesianHorizontal only — a z axis has no opposite axis to take a tick from, and it is ignored there at runtime as well.

Bug fixes in the bind-axis resolution, which domainLine.onZero now shares:

bug effect
onZeroAxisId compared axis.id (the createID() value) while the spec id is kept on userId the option never resolved an axis
the candidate was not required to be a cartesian axis, strictly perpendicular, or in the same region onZero could bind to a z axis or to an axis of another region
onZeroAxisIndex used the component-array index the component array skips invalid axes, so it drifts out of sync with the axes spec index the docs promise

Both options now match userId only — matching the internal auto id would collide with a numeric user id.

No behavior change when crossValue is not configured — every new branch is gated on it. The onZero fixes above do change behavior, but only in cases where the option was previously broken or bound to the wrong axis.

🔍 Verification

packages/vchart/__tests__/unit/component/cartesian/axis/cross-value.test.ts31 new unit tests:

group covers
geometry all four orients, inverse, continuous + band perpendicular axis, out-of-range clamping on both, innerOffset (the case the old relative-to-range offset got wrong)
layout band reclaimed / kept with a title / kept with a field-alias title / kept with an explicit size / kept when the value clamps to an edge, z-index raised
target resolution crossAxisId by spec id, internal auto id rejected, crossAxisIndex as the spec index (with a compacted component array), explicit target with an out-of-domain value, domain-covering preference, own-region binding
lifecycle updateSpec adding / removing crossValue, domain change, target that stops resolving
other marks grid lines stay put, axis unit moves with the axis
z axis not on the z branch of the spec type, ignored at runtime
domainLine.onZero onZeroAxisId resolves against the spec id, crossValue wins over onZero

Local runs on this branch:

command result
npm run compile clean
jest __tests__/unit 473 passed / 473, 82 suites
jest .../cross-value.test.ts 31 passed / 31
eslint on the changed files 0 errors

One suite, __tests__/unit/mark/text.test.ts (rich-text lineDash), fails on this branch — it fails identically on the base commit 6feedc4, so it is unrelated to this change.

Not covered: log / time perpendicular axes (they go through the same valueToPosition path).

Known limitation, documented in the option docs: getLayoutStartPoint() / model bounds still describe the (now zero-thickness) band on the edge rather than the moved axis. No consumer in the repo depends on it today.

📝 Checklist

  • The pull request is created against the develop branch
  • Option docs updated (zh + en)
  • Published type declarations updated (vchart-types)
  • Change file added
  • Unit tests added

@g1f9
g1f9 marked this pull request as draft September 14, 2026 13:15

@xuefei1313 xuefei1313 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🦞 Aime Bot Review

改动摘要

  • 新增 crossValue / crossAxisId / crossAxisIndex 三个笛卡尔轴配置,可把整根轴(轴线、刻度、标签、标题)钉到垂直轴的任意刻度处:连续参照轴按值处理并 clamp 到 domain 端点;band 参照轴按 0 基下标定位到类目起始边,越界夹到轴末端。
  • 配套布局处理完整:无标题时收回原边缘占位、轴整体抬升到 region 之上避免被图元遮挡、标题做反向补偿留在绘图区外、网格线保持不动。
  • 复用现有 _fixAxisOnZerolayoutEnd 机制与选轴优先级(显式 id > 显式 index > 首根可用轴),并约定 crossValue 优先于 domainLine.onZero,避免轴线被平移两次。
  • 顺带修复 domainLine.onZeroAxisId 历史 bug:原先拿 axis.idcreateID() 的内部自增值)比对 spec 里写的 id(实际落在 userId 上),多轴时静默失效;crossAxisId 同时兼容 userId / id
  • 变更规模 5 files、+215/-1,含中英文档与 beachball change 文件。

代码观察 / 建议

  1. change 文件的 type 请确认feat-axis-cross-value_2026-09-14.json 里 feat 和 fix 两条的 type 都是 "none"。beachball 中 none 不会进入 changelog、也不会触发版本 bump,用户可见的新特性与 bugfix 一般应为 minor / patch,请确认是否有意为之。
  2. 版本号一致性请确认:接口注释与文档标注 @since 2.1.7,而 develop 分支当前 packages/vchart/package.json2.1.6。请确认实际发版版本号(按 semver feat 也可能对应 2.2.0),保证文档、change 与发布版本对齐。
  3. 测试覆盖:本 PR 未包含测试/快照文件。核心位移在 _fixAxisCross 中按 orient(bottom/left vs right/top)× inverse(取 range[0]/range[1])× 端点组合了较多分支,建议补充单测或渲染快照(至少覆盖四方位、连续/band 参照轴、inverse、越界 clamp 四类),降低后续布局回归风险。
  4. axis.tsconst nextStyle: any = { ...simpleStyle, [key]: offset } 建议收敛为更精确的类型(axis mark 的 simple style 类型),避免 any 逃逸;另外标题反补偿依赖 simpleStyle.title 存在,请确认标题走自定义 style 而无顶层 title 配置时补偿是否仍然生效。
  5. band 参照轴用 clamp(Math.round(crossValue), 0, domain.length) 取下标,文档表述为「0 基下标」。非整数(如 1.5)会被四舍五入到 2,若希望行为更可预期,可考虑 Math.floor 并在文档补一句非整数取值的处理规则。
  6. 小的性能/健壮性建议(非阻塞):_isCrossInterior()_fixAxisCross() 在一次布局中各做了一次 _getCrossBindAxis()(遍历 axes、读 scale/domain)。当前调用量很小,可暂不处理;如后续布局更频繁,可缓存一次 bind 结果。scale 判空已有覆盖,这里没问题。

合并建议

整体质量很高:动机与对齐 Excel 交互的设计说明清晰,注释充分,中英文档同步补充,还顺带修复了 onZeroAxisId 的历史匹配问题且保持向后兼容。建议在确认 change type@since 版本号、并补一个布局测试/快照后合并 ✅。辛苦贡献者!

@xuefei1313

xuefei1313 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

🦞 Aime Bot Review(补充)

接着上面的 Review 补充几点——我把 PR 分支拉到本地用仓库内的 TypeScript 4.9.5 实际跑了一次 tsc --noEmit,发现两个编译级类型错误,是上一轮纯静态阅读没有覆盖到的:

阻塞合并(tsc 实测复现)

1. packages/vchart/src/component/axis/cartesian/axis.ts:1123 — TS2339

Property 'dataToPosition' does not exist on type 'IAxis'.

bindAxis 的类型是 IAxiscomponent/axis/interface/common.ts),该接口只声明了 valueToPosition,没有 dataToPosition;带 cfg.bandPosition 参数的实现在 BandAxisMixin.dataToPosition(values, cfg) 上。运行时 band 轴确实走得到该实现,所以渲染看起来正常,但静态类型过不了(仓库的构建/类型检查会报错)。建议在 IAxis 上补齐声明(IAxisLocationCfg 同文件已定义):

dataToPosition: (values: any[], cfg?: IAxisLocationCfg) => number;

基类 Axis.dataToPosition(values) 只有一个参数,实现签名参数更少在结构类型下是兼容的,不影响现有代码。

2. 同文件 :1072 — 两个 TS2345

Argument of type '"left" | "top" | "right" | "bottom" | "z" | IPolarOrientType'
  is not assignable to parameter of type 'IOrientType'.

IAxis.getOrient() 的返回类型包含 IPolarOrientType,直接传给 isXAxis 不收。老的 _fixAxisOnZero 里参数标注成了 any 所以一直没暴露,新代码用了 IAxis 类型才触发。建议显式收窄(如 isXAxis(item.getOrient() as IOrientType)),或直接按 orient 字符串判断,尽量不要退回 any。

补充建议(非阻塞)

3. band 下标的「取整」与「是否内部」判定不一致

_crossPosition 里用 Math.round(crossValue) 取下标(:1118),但 _isCrossInterior 用的是原始值(:1140crossValue > 0 && crossValue < domain.length)。于是 crossValue: 0.4 实际会落在下标 0 的起始边——也就是绘图区边缘,布局却按「在内部」处理而把边上占位收掉,贴边的刻度标签可能被裁切。建议两处共用同一个 clamp 后的整数下标(先取整再判 index > 0 && index < length)。这与上一轮提到的取整规则是同一个问题的两面,合并处理即可。

4. 参照轴解析失败时建议给开发态告警

显式配置了 crossAxisId / crossAxisIndex 但解析不到(或值不在连续轴 domain 内)时,目前是静默留在边上,使用者排查成本较高,建议在 dev 模式输出一条 warning。

5. 请确认运行时 spec 更新的场景

layoutZIndex 只在构造函数里根据初始 spec 抬升(:140)。如果图表创建后通过 spec 更新才加入 crossValue,请确认组件会重建;不重建的话 zIndex 抬升不生效,标签仍可能被图元遮住。

小结

上一条 Review 提到的 change type、测试覆盖等意见保持不变;在此基础上,建议先修掉第 1、2 条 tsc 错误后再合并。另外同步一个好消息:我核对了 base-model.ts:120this.userId = spec.id),onZeroAxisId 的根因分析和兼容修复都是正确的 👍。辛苦!

… perpendicular axis

`orient` can only place a cartesian axis on one of the four edges, so an axis
that should be drawn inside the plot area — e.g. a category axis crossing the
value axis at 40 — has no spec-level expression today. `domainLine.onZero`
covers one narrow case of this: it only supports 0, and it only moves the axis
line, leaving ticks, labels and unit behind on the edge.

`crossValue` generalizes it along both dimensions: any tick value, and the whole
axis moves. The axis title stays on the original edge, and grid lines are a
separate mark that keeps spanning the region — which is how spreadsheet
applications render a crossing axis.

- continuous perpendicular axis: the value is a tick value, clamped to the domain
- band perpendicular axis: the value is a zero-based domain index and the axis is
  placed at the start edge of that band, clamped to the end of the axis
- the offset is computed in chart coordinates (`bindAxis.layoutStart + localPos -
  self.layoutStart`), so inverse, `innerOffset` and multi-region need no branches
- the axis is raised above the region so its labels are not hidden by marks. The
  z-index is resolved in `setAttrFromSpec`, and adding or removing `crossValue`
  forces a remake, because a mark's z-index is only written when the mark is built
- the axis stops reserving space on the edge, so the plot area gets that band
  back. Axes with a title keep reserving — the title is compensated back to the
  edge and would otherwise be clipped. An axis with an explicit `width` / `height`
  also keeps reserving, because an explicit size outranks the measured one
- `crossValue` takes precedence over `domainLine.onZero`, otherwise the axis line
  would be translated twice

`crossValue` is declared on `ICartesianVertical` / `ICartesianHorizontal` only: a
z axis has no opposite axis to take a tick from, and it is ignored there at
runtime as well.

Also fixes the bind-axis resolution, which `domainLine.onZero` now shares:

- `onZeroAxisId` compared `axis.id`, the value returned by `createID()`, while the
  id written in the spec is kept on `userId`, so the option never resolved an
  axis. Both options now match `userId` only — matching the internal auto id
  would collide with a numeric user id.
- the candidate was not required to be a cartesian axis, to be strictly
  perpendicular, or to share a region, so `onZero` could bind to a z axis or to an
  axis belonging to another region.
- `onZeroAxisIndex` / `crossAxisIndex` now match the index in the `axes` spec
  array, falling back to the component index. That is what the docs promise; the
  component array skips invalid axes and drifts out of sync with the spec.

Adds 33 unit tests covering the four orients, inverse, continuous and band
perpendicular axes, out-of-range clamping, `innerOffset`, multi-region, z axis
exclusion, target resolution by user id and spec index, `updateSpec` add/remove,
domain updates, explicit sizes, titles, the axis unit offset and grid lines.
@g1f9
g1f9 force-pushed the feat/axis-cross-value branch from cac9a00 to d4f918d Compare September 15, 2026 11:42
@g1f9
g1f9 marked this pull request as ready for review September 16, 2026 08:23

@xile611 xile611 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复核最新 head d4f918dedc08aaa7bcf0c50d31718b22b8aa0c52,仍有 3 处有效配置下的布局/渲染问题,详见行内意见。

验证:使用 VRender 1.1.8,现有笛卡尔轴测试 6 组、54 项全部通过;补充用例复现了边缘标签裁切、最小尺寸约束失效和轴单位被 region 遮挡。四个方向的标题位置、单位自定义偏移验证通过。

Comment on lines +684 to +689
if (!this._hasAxisTitle() && this._isCrossInterior()) {
const orient = this._layout.layoutOrient;
if (orient === 'left' || orient === 'right') {
result.width = 0;
} else {
result.height = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 按平移后的实际包围盒保留边缘空间

这里仅根据交叉值是否严格位于 domain 内就把占位清零,没有考虑标签仍可能越过画布边界。例如 500×400 柱图,值轴 min:-100, max:100,底轴 crossValue:-99, label:{style:{fontSize:24}},最终轴图元的 globalAABBBounds.y2 为 408.26,标签下部被裁切;同一配置不设置 crossValue 时位于画布内。应依据平移后标签/刻度的实际包围盒保留必要的边缘空间,只有能完整容纳时才全部回收占位。

if (this._unitText) {
// 轴单位是容器上的独立图元,同样是合并语义,得把用户配的偏移一起算进去
const { dx = 0, dy = 0 } = this._spec.unit?.style ?? {};
this._unitText.setAttributes(isX ? { dy: dy + crossOffset } : { dx: dx + crossOffset });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 同步抬高独立轴单位的层级

_unitText 直接挂在 root 上,与 region 是兄弟图元;这里仅更新 dx/dy,抬高 _axisMark 不会改变单位的层级,单位默认 zIndex 仍为 0,低于 region 的 450。底轴设置 crossValue:0unit:{visible:true,text:'pcs',style:{dx:-60}},并给 region 配白色背景时,移入绘图区的单位会被完全遮住。已做像素验证:单位颜色像素为 0,单独将单位 zIndex 设为 460 后恢复显示。应让单位默认跟随交叉轴的层级,同时保留用户显式配置的 zIndex。

} else {
result.height = 0;
}
return result;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 回收轴占位后仍需应用最小尺寸约束

这个提前 return 绕过了函数末尾的 this._layout.setRectInSpec(...),覆盖了布局系统已经应用的最小尺寸。复现:底轴配置 minHeight:50 时实际高度为 50,增加 crossValue:0 后实际高度变为 0。纵轴的 minWidth 也走同一条清零路径。显式最小尺寸属于已有的布局契约,回收占位后仍应通过标准 setRectInSpec 处理,不能只保留 width/height 的优先级。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants