diff --git a/common/changes/@visactor/vchart/fix-horizontal-waterfall-leader-line_2026-09-20.json b/common/changes/@visactor/vchart/fix-horizontal-waterfall-leader-line_2026-09-20.json new file mode 100644 index 0000000000..af241423a4 --- /dev/null +++ b/common/changes/@visactor/vchart/fix-horizontal-waterfall-leader-line_2026-09-20.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: 修复横向瀑布图连接线端点方向错误,避免连接线覆盖相邻柱体边缘,并保持反向轴和反向累计模式的正确连接。", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/docs/superpowers/plans/2026-09-20-waterfall-leader-line.md b/docs/superpowers/plans/2026-09-20-waterfall-leader-line.md new file mode 100644 index 0000000000..121cd931a7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-20-waterfall-leader-line.md @@ -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) diff --git a/docs/superpowers/specs/2026-09-20-waterfall-leader-line-design.md b/docs/superpowers/specs/2026-09-20-waterfall-leader-line-design.md new file mode 100644 index 0000000000..418d6d3bd6 --- /dev/null +++ b/docs/superpowers/specs/2026-09-20-waterfall-leader-line-design.md @@ -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。 diff --git a/docs/superpowers/validation/2026-09-20-waterfall-leader-line.png b/docs/superpowers/validation/2026-09-20-waterfall-leader-line.png new file mode 100644 index 0000000000..a5a5e9015a Binary files /dev/null and b/docs/superpowers/validation/2026-09-20-waterfall-leader-line.png differ diff --git a/packages/vchart/__tests__/unit/series/waterfall.test.ts b/packages/vchart/__tests__/unit/series/waterfall.test.ts index 45090e3aeb..83c256f31b 100644 --- a/packages/vchart/__tests__/unit/series/waterfall.test.ts +++ b/packages/vchart/__tests__/unit/series/waterfall.test.ts @@ -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({}, 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(); + 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(['horizontal', 'vertical'])('%s', direction => { + describe.each(['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({}, 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'); + } }); }); diff --git a/packages/vchart/src/series/waterfall/waterfall.ts b/packages/vchart/src/series/waterfall/waterfall.ts index 09b6c51864..85bb6bb882 100644 --- a/packages/vchart/src/series/waterfall/waterfall.ts +++ b/packages/vchart/src/series/waterfall/waterfall.ts @@ -58,7 +58,7 @@ export class WaterfallSeries