Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vchart",
"comment": "fix: 修复横向瀑布图连接线端点方向错误,避免连接线覆盖相邻柱体边缘,并保持反向轴和反向累计模式的正确连接。",
"type": "patch"
}
],
"packageName": "@visactor/vchart"
}
45 changes: 45 additions & 0 deletions docs/superpowers/plans/2026-09-20-waterfall-leader-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 瀑布图连接线方向修复计划

**目标:** 修复横向瀑布图连接线延伸到柱外侧的回归,保留显式反向轴的正确行为。

**方案:** 在 WaterfallSeries 现有方向判断中归一化 Y 轴 inverse;使用真实 VChart 渲染验证连接线端点与柱边界,不再用 mock 的布尔返回值固化错误行为。

**技术栈:** TypeScript、现有 Jest/Electron 测试环境。

**约束:** 不改变公共 API、依赖、图像基准或柱样式;不新增每图元分配。单任务在当前会话内执行。

## 实施步骤

- [x] 替换 `packages/vchart/__tests__/unit/series/waterfall.test.ts` 中直接断言私有方向方法的测试:渲染正负变化与总计柱,读取 bar 图元和 leaderLine 图元,检查连接线在累计顺序中的上一柱与下一柱相邻边缘之间。覆盖两种方向、三种 inverse 配置、两种 calculationMode,以及 inverse 更新。
- [x] 在未修复源码上运行测试,确认横向配置在端点几何断言处失败。
- [x] 修改 `packages/vchart/src/series/waterfall/waterfall.ts`:横向方向判断返回 `!this._yAxisHelper?.isInverse?.()`,纵向保持现有逻辑,并解释 Y 轴屏幕方向。
- [x] 运行瀑布图、柱图和瀑布图数据转换测试;运行 TypeScript、定向 ESLint、Prettier、`git diff --check`。
- [x] 使用原始 case 在浏览器核对修复后的连接线,并记录验证结果。
- [ ] 添加中文 patch 变更说明,提交并推送 `codex/fix-horizontal-waterfall-leader-line`,创建目标为 develop 的 PR。

## 验证命令

在 `packages/vchart` 执行:

```sh
./node_modules/.bin/jest --runInBand --runTestsByPath __tests__/unit/series/waterfall.test.ts
./node_modules/.bin/jest --runInBand --runTestsByPath __tests__/unit/series/waterfall.test.ts __tests__/unit/chart/bar.test.ts __tests__/unit/data/waterfall-transform-options.test.ts
./node_modules/.bin/tsc --noEmit --project tsconfig.json
./node_modules/.bin/eslint src/series/waterfall/waterfall.ts __tests__/unit/series/waterfall.test.ts --quiet
./node_modules/.bin/prettier --check src/series/waterfall/waterfall.ts __tests__/unit/series/waterfall.test.ts
```

## 完成标准

新增回归先失败、修复后通过;所有方向组合连接相邻柱边界,显式 inverse 更新有效;PR 说明包含原因、修复前后行为、Bugserver case 和实际验证结果。

## 验证结果

- 修复前,6 组横向配置和 1 项横向轴更新用例均在连接线端点几何断言处失败;6 组纵向配置未出现该问题。
- 修复后,瀑布图 13 项、柱图 8 项、瀑布图数据转换 2 项,共 3 个套件、23 项测试通过。
- `tsc --noEmit`、定向 ESLint 和 Prettier 均通过。
- 本地源码通过 esbuild 生成浏览器验证构建,用原始 Bugserver spec 复现:14 条可见连接线均从 40px 恢复为 10px,所有柱的几何属性前后一致。
- 仅将旧方向判断代入同一本地构建作为修复前对照;右侧使用本次实际源码。对照图见下。
- 原私有方法重命名为 `_isCategoryAxisReversed`,明确表达归一化后的屏幕分类方向,避免与 axis helper 原始 inverse 混淆。

![原始用例连接线修复前后](../validation/2026-09-20-waterfall-leader-line.png)
25 changes: 25 additions & 0 deletions docs/superpowers/specs/2026-09-20-waterfall-leader-line-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 瀑布图连接线方向修复

## 问题与目标

Bugserver 用例 `649d7f2452a1e9eec95f9f1c` 是横向瀑布图。提交 `14c5e3e6f` 根据轴的 `inverse` 翻转连接线端点,但横向 Y 轴默认 `inverse=true`,屏幕上的分类仍然从上往下排列。因此连接线从柱间空隙延伸到两根柱的外侧;复现中线长由 10px 变为 40px。

用户已确认修复端点方向判断,并要求从当前 develop 创建分支、提交 PR。

## 方案

保留既有的累计模式与端点选择算法,在方向判断处将 Y 轴的 inverse 转换成屏幕分类方向:横向使用 `!yAxisHelper.isInverse()`,纵向保留 `xAxisHelper.isInverse()`。这样默认横向分类不再额外翻转,显式反向横轴/纵轴仍连接相邻柱边界。

不整体回退原提交,因为纵向反向轴需要原来的修复。也不逐图元读取 scale.range() 或比较柱位置,因为既有 axis helper 已提供所需方向信息,无须增加数组分配和几何计算。

## 验收

- 真实图表的连接线只跨越相邻柱之间的空隙,连接对应累计值。
- 覆盖 horizontal/vertical、未指定 inverse/false/true、increase/decrease。
- 配置更新切换 inverse 后,连接线仍在相邻柱边界。
- 不改变柱的几何、虚线样式、公共 API、依赖和图像基准。
- 运行时只修改方向判断,不新增缓存、兜底或每图元分配。

## 自查

需求、实现范围与验证入口一致;无待定配置或额外兼容承诺。沿用本任务已批准的修复目标,直接执行并提交 PR。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
139 changes: 101 additions & 38 deletions packages/vchart/__tests__/unit/series/waterfall.test.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,111 @@
import { DataSet } from '@visactor/vdataset';
import type { ISeriesOption } from '../../../src/series/interface';
import { WaterfallSeries } from '../../../src/series/waterfall/waterfall';
import { initChartDataSet, seriesOption } from '../../util/context';
import { Direction } from '../../../src/typings/space';
import type { ILineGraphicAttribute, IRectGraphicAttribute } from '@visactor/vrender-core';
import VChart from '../../../src';
import type { IWaterfallChartSpec } from '../../../src/chart/waterfall/interface';
import type { WaterfallSeries } from '../../../src/series/waterfall/waterfall';
import { createDiv, removeDom } from '../../util/dom';

describe('[Domain-Series-Waterfall] Waterfall Series', () => {
let ctx: ISeriesOption;
type Direction = 'horizontal' | 'vertical';
type CalculationMode = 'increase' | 'decrease';

const createSpec = (direction: Direction, calculationMode: CalculationMode, inverse?: boolean): IWaterfallChartSpec => {
const horizontal = direction === 'horizontal';
return {
type: 'waterfall',
direction,
calculationMode,
width: 500,
height: 500,
animation: false,
data: {
values: [
{ category: 'Feb.4', total: true, value: 45 },
{ category: 'Feb.11', value: -5 },
{ category: 'Feb.20', value: 2 },
{ category: 'Feb.25', value: -2 },
{ category: 'total', total: true, value: 40 }
]
},
xField: horizontal ? 'value' : 'category',
yField: horizontal ? 'category' : 'value',
total: { type: 'field', tagField: 'total', valueField: 'value' },
axes: horizontal
? [
{ orient: 'bottom', type: 'linear' },
{ orient: 'left', type: 'band', paddingInner: 0.4, inverse }
]
: [
{ orient: 'left', type: 'linear' },
{ orient: 'bottom', type: 'band', paddingInner: 0.4, inverse }
]
};
};

describe('Waterfall leader line geometry', () => {
let chart: VChart;
let dom: HTMLElement;

beforeEach(() => {
const dataSet = new DataSet();
initChartDataSet(dataSet);
ctx = seriesOption({ dataSet });
dom = createDiv();
});

afterEach(() => {
chart?.release();
removeDom(dom);
});

test('leader line category positions should respect inverse axis in vertical mode', () => {
const series = new WaterfallSeries<any>({}, ctx);
(series as any)._direction = Direction.vertical;
(series as any)._xAxisHelper = {
isInverse: () => false
};

expect((series as any)._getLeaderLineCategoryPos(true, false)).toBe(1);
expect((series as any)._getLeaderLineCategoryPos(false, false)).toBe(0);
expect((series as any)._getLeaderLineCategoryPos(true, true)).toBe(0);
expect((series as any)._getLeaderLineCategoryPos(false, true)).toBe(1);

(series as any)._xAxisHelper = {
isInverse: () => true
};

expect((series as any)._getLeaderLineCategoryPos(true, false)).toBe(0);
expect((series as any)._getLeaderLineCategoryPos(false, false)).toBe(1);
expect((series as any)._getLeaderLineCategoryPos(true, true)).toBe(1);
expect((series as any)._getLeaderLineCategoryPos(false, true)).toBe(0);
const expectLinesBetweenBars = (direction: Direction) => {
const series = chart.getChart().getAllSeries()[0] as WaterfallSeries;
const bars = new Map<string, IRectGraphicAttribute>();
series
.getMarkInName('bar')
.getGraphics()
.forEach(graphic => {
bars.set(graphic.context.data[0].category, graphic.attribute as IRectGraphicAttribute);
});
const lines = series
.getMarkInName('leaderLine')
.getGraphics()
.filter(graphic => graphic.attribute.visible !== false);
expect(lines).toHaveLength(bars.size - 1);

const position = direction === 'horizontal' ? 'y' : 'x';
const size = direction === 'horizontal' ? 'height' : 'width';
lines.forEach(graphic => {
const { lastIndex, index } = graphic.context.data[0];
const previous = bars.get(lastIndex);
const next = bars.get(index);
const points = (graphic.attribute as ILineGraphicAttribute).points;
// Derive facing edges from rendered bars, independently of axis inverse and calculation mode.
const forward = previous[position] < next[position];
const start = previous[position] + (forward ? previous[size] : 0);
const end = next[position] + (forward ? 0 : next[size]);

expect(points).toHaveLength(2);
expect(points[0][position]).toBeCloseTo(start);
expect(points[1][position]).toBeCloseTo(end);
expect(Math.abs(points[1][position] - points[0][position])).toBeCloseTo(Math.abs(end - start));
});
};

describe.each<Direction>(['horizontal', 'vertical'])('%s', direction => {
describe.each<CalculationMode>(['increase', 'decrease'])('%s', calculationMode => {
test.each([undefined, false, true])('connects facing bar edges with inverse=%s', inverse => {
chart = new VChart(createSpec(direction, calculationMode, inverse), { dom, animation: false });
chart.renderSync();

expectLinesBetweenBars(direction);
});
});
});

test('leader line category positions should respect inverse axis in horizontal mode', () => {
const series = new WaterfallSeries<any>({}, ctx);
(series as any)._direction = Direction.horizontal;
(series as any)._yAxisHelper = {
isInverse: () => true
};
test('updates facing edges when the horizontal category axis is reversed', () => {
chart = new VChart(createSpec('horizontal', 'increase'), { dom, animation: false });
chart.renderSync();
expectLinesBetweenBars('horizontal');

expect((series as any)._getLeaderLineCategoryPos(true, false)).toBe(0);
expect((series as any)._getLeaderLineCategoryPos(false, false)).toBe(1);
for (const inverse of [true, false]) {
chart.updateSpecSync(createSpec('horizontal', 'increase', inverse));
expectLinesBetweenBars('horizontal');
}
});
});
11 changes: 6 additions & 5 deletions packages/vchart/src/series/waterfall/waterfall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class WaterfallSeries<T extends IWaterfallSeriesSpec = IWaterfallSeriesSp
return this._totalData?.getLatestData();
}

declare protected _spec: T;
protected declare _spec: T;

protected _leaderLineMark: IRuleMark = null;
protected _stackLabelMark: ITextMark = null;
Expand Down Expand Up @@ -328,16 +328,17 @@ export class WaterfallSeries<T extends IWaterfallSeriesSpec = IWaterfallSeriesSp
);
}

protected _isCategoryAxisInverse() {
protected _isCategoryAxisReversed() {
// Y 轴默认从下向上,横向图的 inverse=true 才对应屏幕上从上向下的分类正序。
return this._direction === Direction.horizontal
? this._yAxisHelper?.isInverse?.()
? !this._yAxisHelper?.isInverse?.()
Comment thread
xile611 marked this conversation as resolved.
: this._xAxisHelper?.isInverse?.();
}

protected _getLeaderLineCategoryPos(isStart: boolean, isDecrease: boolean) {
const inverse = !!this._isCategoryAxisInverse();
const reversed = !!this._isCategoryAxisReversed();
const normalPos = isStart ? (isDecrease ? 0 : 1) : isDecrease ? 1 : 0;
return inverse ? 1 - normalPos : normalPos;
return reversed ? 1 - normalPos : normalPos;
}

initMarkStyle(): void {
Expand Down
Loading