From 47cd904fed04dc602c1671afcd7962d5e76e66df Mon Sep 17 00:00:00 2001 From: xile611 Date: Tue, 15 Sep 2026 15:11:20 +0800 Subject: [PATCH] fix(datazoom): skip previews with degenerate scales --- ...atazoom-degenerate-preview_2026-09-15.json | 10 ++ .../2026-09-15-datazoom-preview-regression.md | 75 +++++++++++ .../unit/animation/manual-ticker.test.ts | 127 ++++++++++++++++++ .../component/data-zoom/data-zoom.test.ts | 84 ++++++++++++ .../data-zoom/data-zoom/data-zoom.ts | 15 ++- 5 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 common/changes/@visactor/vchart/fix-datazoom-degenerate-preview_2026-09-15.json create mode 100644 docs/superpowers/plans/2026-09-15-datazoom-preview-regression.md create mode 100644 packages/vchart/__tests__/unit/component/data-zoom/data-zoom.test.ts diff --git a/common/changes/@visactor/vchart/fix-datazoom-degenerate-preview_2026-09-15.json b/common/changes/@visactor/vchart/fix-datazoom-degenerate-preview_2026-09-15.json new file mode 100644 index 0000000000..6538f4da9e --- /dev/null +++ b/common/changes/@visactor/vchart/fix-datazoom-degenerate-preview_2026-09-15.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "fix: 修复预览比例尺退化时 DataZoom 显示多余预览线的问题", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/docs/superpowers/plans/2026-09-15-datazoom-preview-regression.md b/docs/superpowers/plans/2026-09-15-datazoom-preview-regression.md new file mode 100644 index 0000000000..bc1df71be6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-datazoom-preview-regression.md @@ -0,0 +1,75 @@ +# DataZoom 预览回归最小修复计划与验证记录 + +## 目标与范围 + +消除分类 X 轴、数值 Y 轴图表中竖向 DataZoom 的多余预览线,同时保留 #4186 的数据更新修复。 + +- 运行时代码只恢复 `_createOrUpdateComponent` 的比例尺有效性检查,复用 `_isScaleValid`。 +- 保留 `_handleDataCollectionChange` 中的 `_initAfterLayout()`,使更新后使用新的 domain/range。 +- 使用纯 VChart 代码验证 #4186;保留原 spec、数据、更新顺序和包装层转发的动画参数,无需 React 环境。 +- 不修改依赖、公共 API、数据统计或 VRender 绘制逻辑。 + +## 根因 + +[原 case](https://bugserver.cn.goofy.app/case?product=chartspace4&fileid=6503dac997ee1ad37a86235a) 的竖向预览 value domain 为 `[0, 0]`,所有点落在滑轨中间。 + +[提交 2533b828e](https://github.com/VisActor/VChart/commit/2533b828e8b086db006b3c1ebbb6a88b4dcc9903) 为解决 [#4186](https://github.com/VisActor/VChart/issues/4186) 增加了 domain 刷新,但同时删除 `_isScaleValid(xScale) && _isScaleValid(yScale)`,使退化预览也被绘制。[#4527](https://github.com/VisActor/VChart/pull/4527) 未新增对应测试。 + +## 执行清单 + +- [x] 在修改运行时代码前,建立 #4186 历史故障构建失败、修复构建通过的对照。 +- [x] 添加组件测试,确认恢复判断前的三个用例均在“预览应隐藏”的断言失败。 +- [x] 恢复原有预览条件,保留 domain 刷新。 +- [x] 添加 #4186 的动画中断回归测试,检查实际场景树。 +- [x] 完成定向 Jest、ESLint、TypeScript 和两类浏览器验收。 +- [x] 添加 patch 变更记录并检查最终差异。 + +## #4186 历史对照 + +固定初始单条 `华北区 / 其他自然线索量 / 333`,更新为原 issue 的六个地区、六组系列及零值记录。测试 fixture 保存在 `packages/vchart/__tests__/unit/animation/manual-ticker.test.ts`。 + +1. 渲染初始图表,用 ManualTicker 推进至 2000ms。 +2. 调用 `updateSpecSync(spec, undefined, { morph: false, enableExitAnimation: false })`。 +3. 每隔 20ms 依次设置范围 `[0, 0.7]`、`[0.2, 0.8]`、`[0.3, 0.9]`、`[0.1, 0.7]`。 +4. 每次通过 DataZoom 的 `setStartAndEnd` 更新后调用 `chart.renderSync()`,确保同步测试实际执行过滤后的渲染。 +5. 动画结束后,遍历整个场景树,将柱图元的数据、数量和几何位置与静态参考图比较。 +6. 恢复 `[0, 1]`,再次比较。部分范围应有 30 个柱图元,完整范围应有 36 个。 + +| VChart 构建 | VRender core/components/animate | 部分范围 | 恢复完整范围 | +| --- | --- | --- | --- | +| `784a405c34265a77f49dd987bc73cc3cdfa0aec1`,即 `2533b828e^` | `1.0.41` | 失败:6 / 预期 30 | 失败:6 / 预期 36 | +| `f49734a6e`,#4527 合入后 | `1.0.44` | 通过:30,数据和几何一致 | 通过:36,数据和几何一致 | +| `6feedc40a` 加本次修复 | `1.1.8` | 通过:30,数据和几何一致 | 通过:36,数据和几何一致 | + +两个历史构建各自使用对应提交锁文件中的生产依赖闭包和原始 integrity,通过 `pnpm install --frozen-lockfile --ignore-scripts` 安装;未使用当前依赖替代历史依赖。 + +历史失败时 DataZoom 的 state domain 仍只有 `华北区`,缩放后错误地只保留这个地区;合入 #4527 后及本次修复后均包含六个地区。截图也确认旧构建只剩单个地区,修复后的图表恢复正常。 + +早期候选测试仅调用缩放、未执行同步渲染,不能有效验证最终图元;已修正。最终测试锁定错误过滤和最终场景树差异,不将未观察到的其他动画问题归因于本次修复。 + +## 测试覆盖与检查结果 + +新增组件测试覆盖: + +- 竖向退化预览隐藏,横向正常预览保持可见。 +- `updateSpecSync` 和 `updateDataSync` 从单条数据更新为多条后,预览恢复且坐标有限。 +- 更新后可缩放过滤,并可恢复完整数据范围。 + +在 `packages/vchart` 执行,均通过: + +```sh +./node_modules/.bin/jest __tests__/unit/component/data-zoom __tests__/unit/core/update-effects.test.ts __tests__/unit/animation/manual-ticker.test.ts --runInBand +./node_modules/.bin/eslint src/component/data-zoom/data-zoom/data-zoom.ts __tests__/unit/component/data-zoom/data-zoom.test.ts __tests__/unit/animation/manual-ticker.test.ts --quiet +./node_modules/.bin/tsc --noEmit --project tsconfig.json +``` + +Jest 共 **4 个测试套件、145 个测试通过**。`git diff --check` 通过。提交钩子的 ESLint、Prettier 和 commitlint 均通过。Prettier 同时统一了源文件原有的三处格式差异(嵌套三元表达式缩进及括号),不改变行为。原 bugserver case 的浏览器对照确认:Y 轴 DataZoom 的额外预览线消失,底部预览保持一致。#4186 的同一历史验证页面加载本次修复后,部分范围和完整范围均通过。 + +## 交付文件 + +- `packages/vchart/src/component/data-zoom/data-zoom/data-zoom.ts`:恢复预览条件。 +- `packages/vchart/__tests__/unit/component/data-zoom/data-zoom.test.ts`:静态和更新回归。 +- `packages/vchart/__tests__/unit/animation/manual-ticker.test.ts`:#4186 场景树回归。 +- `common/changes/@visactor/vchart/fix-datazoom-degenerate-preview_2026-09-15.json`:中文 patch 说明。 + +提交信息:`fix(datazoom): skip previews with degenerate scales`。 diff --git a/packages/vchart/__tests__/unit/animation/manual-ticker.test.ts b/packages/vchart/__tests__/unit/animation/manual-ticker.test.ts index 9e6cc6a316..24a0618c47 100644 --- a/packages/vchart/__tests__/unit/animation/manual-ticker.test.ts +++ b/packages/vchart/__tests__/unit/animation/manual-ticker.test.ts @@ -1398,7 +1398,134 @@ const hasRenderableBarGeometry = (graphic: AnimatedGraphic) => { ); }; +const issue4186Regions = ['华西区', '华东区', '华中区', '华北区', '华南区', '未知']; +const issue4186Series = [ + '直播自然线索量', + '短视频自然线索量', + '直播广告线索量', + '短视频广告线索量', + '其他广告线索量', + '其他自然线索量' +]; +const issue4186Values = [ + [4, 4, 4, 4, 4, 3], + [2, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1], + [1, 1, 4, 2, 3, 4], + [55, 0, 0, 0, 0, 0], + [5, 4, 3, 3, 1, 2] +]; +const issue4186Handler = () => ({ + style: { + size: 10, + symbolType: 'M0-4.5c2.5,0,4.5,2,4.5,4.5S2.5,4.5,0,4.5s-4.5-2-4.5-4.5S-2.5-4.5,0-4.5z M-1.5-2v4 M1.5-2v4' + } +}); +const createIssue4186Spec = (updated: boolean): IBarChartSpec => ({ + type: 'bar', + width: 500, + height: 500, + xField: ['data_name'], + yField: 'value', + seriesField: 'type', + axes: [], + data: [ + { + id: 'dataBar', + values: updated + ? issue4186Series.flatMap((type, i) => + issue4186Regions.map((data_name, j) => ({ data_name, type, value: issue4186Values[i][j] })) + ) + : [{ data_name: '华北区', type: '其他自然线索量', value: 333 }] + }, + { id: 'dataLine', values: [] } + ], + dataZoom: [ + { + orient: 'bottom', + height: 18, + start: 0, + end: 1, + brushSelect: false, + showDetail: true, + startHandler: issue4186Handler(), + endHandler: issue4186Handler() + } + ] +}); + +const snapshotIssue4186Bars = (chart: VChart) => + collectGraphics(chart.getStage() as unknown as TraversableGraphic) + .filter(g => g.type === 'rect' && g.context?.data?.[0]?.data_name !== undefined) + .map(g => { + const d = g.context.data[0]; + const a = g.attribute; + const geometry = [a.x, a.y, a.width ?? a.x1 - a.x, a.height ?? a.y1 - a.y]; + expect(geometry.every(Number.isFinite)).toBe(true); + return { + key: `${d.data_name}/${d.type}`, + value: d.value, + geometry: geometry.map(n => Number(n.toFixed(4))) + }; + }) + .sort((a, b) => a.key.localeCompare(b.key)); + describe('manual ticker animation regressions', () => { + it('renders the correct bars after dataZoom interrupts a data update (#4186)', () => { + const { container, dom } = createChartContainer(); + const reference = createChartContainer(); + const ticker = createManualTicker(); + const chart = new VChart(createIssue4186Spec(false), { dom, ticker, animation: true }); + const expected = new VChart(createIssue4186Spec(true), { dom: reference.dom, animation: false }); + try { + chart.renderSync(); + ticker.tickAt(2000); + chart.updateSpecSync(createIssue4186Spec(true), undefined, { morph: false, enableExitAnimation: false }); + const zoom = chart + .getChart() + .getAllComponents() + .find(c => c.type === 'dataZoom') as import('../../../src/component/data-zoom/data-zoom/data-zoom').DataZoom; + const start = ticker.getTime(); + const ranges: [number, number][] = [ + [0, 0.7], + [0.2, 0.8], + [0.3, 0.9], + [0.1, 0.7] + ]; + ranges.forEach(([a, b], i) => { + ticker.tickAt(start + (i + 1) * 20); + zoom.setStartAndEnd(a, b); + chart.renderSync(); + }); + ticker.tickAt(start + 3000); + expected.renderSync(); + const expectedZoom = expected + .getChart() + .getAllComponents() + .find(c => c.type === 'dataZoom') as import('../../../src/component/data-zoom/data-zoom/data-zoom').DataZoom; + expectedZoom.setStartAndEnd(0.1, 0.7); + expected.renderSync(); + const bars = snapshotIssue4186Bars(chart); + const expectedBars = snapshotIssue4186Bars(expected); + expect(expectedBars).toHaveLength(30); + expect(bars).toEqual(expectedBars); + + zoom.setStartAndEnd(0, 1); + chart.renderSync(); + ticker.tickAt(start + 6000); + expectedZoom.setStartAndEnd(0, 1); + expected.renderSync(); + expect(snapshotIssue4186Bars(expected)).toHaveLength(36); + expect(snapshotIssue4186Bars(chart)).toEqual(snapshotIssue4186Bars(expected)); + } finally { + chart.release(); + expected.release(); + ticker.release(); + removeDom(container); + removeDom(reference.container); + } + }); + it('keeps custom group final attributes after a prevented update animation', () => { const { container, dom } = createChartContainer(); const ticker = createManualTicker(); diff --git a/packages/vchart/__tests__/unit/component/data-zoom/data-zoom.test.ts b/packages/vchart/__tests__/unit/component/data-zoom/data-zoom.test.ts new file mode 100644 index 0000000000..f38451f659 --- /dev/null +++ b/packages/vchart/__tests__/unit/component/data-zoom/data-zoom.test.ts @@ -0,0 +1,84 @@ +import VChart, { type IBarChartSpec } from '../../../../src'; +import type { DataZoom } from '../../../../src/component/data-zoom/data-zoom/data-zoom'; +import type { IGroup, ILine } from '@visactor/vrender-core'; +import { createDiv, removeDom } from '../../../util/dom'; + +type Row = { category: string; series: string; value: number }; +const rows: Row[] = [ + { category: 'A', series: 'S', value: 10 }, + { category: 'B', series: 'S', value: 30 }, + { category: 'C', series: 'S', value: 20 } +]; +const makeSpec = (values: Row[]): IBarChartSpec => ({ + type: 'bar', + width: 500, + height: 500, + animation: false, + xField: ['category'], + yField: 'value', + seriesField: 'series', + data: [{ id: 'data', values }], + dataZoom: [{ orient: 'bottom' }] +}); + +describe('DataZoom preview validity', () => { + let dom: HTMLElement; + let chart: VChart; + beforeEach(() => { + dom = createDiv(); + }); + afterEach(() => { + chart?.release(); + removeDom(dom); + }); + + const zoom = (orient: 'bottom' | 'left') => + chart + .getChart() + .getAllComponents() + .find(c => c.type === 'dataZoom' && c.getSpec().orient === orient) as DataZoom; + const graphic = (orient: 'bottom' | 'left', name: string) => + (zoom(orient).getVRenderComponents()[0] as IGroup).find(n => n.name === name, true); + const expectHidden = (orient: 'bottom' | 'left') => { + for (const name of ['previewLine', 'previewArea']) { + const node = graphic(orient, name); + expect(!node || node.attribute.visible === false).toBe(true); + } + }; + const expectPreview = () => { + const line = graphic('bottom', 'previewLine') as ILine; + expect(line).toBeTruthy(); + expect(line.attribute.visible).not.toBe(false); + expect(line.attribute.points.length).toBeGreaterThan(2); + expect(line.attribute.points.every(p => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); + }; + + it('hides the degenerate vertical preview and keeps the horizontal preview', () => { + const spec = makeSpec(rows); + spec.dataZoom = [{ orient: 'bottom' }, { orient: 'left' }]; + chart = new VChart(spec, { dom, animation: false }); + chart.renderSync(); + expectHidden('left'); + expectPreview(); + }); + + it.each(['spec', 'data'] as const)('restores preview after a single-row %s update', mode => { + chart = new VChart(makeSpec([{ category: 'A', series: 'S', value: 333 }]), { dom, animation: false }); + chart.renderSync(); + expectHidden('bottom'); + if (mode === 'spec') { + chart.updateSpecSync(makeSpec(rows)); + } else { + chart.updateDataSync('data', rows); + } + expectPreview(); + const model = zoom('bottom'); + const viewData = () => chart.getChart().getAllSeries()[0].getViewData().latestData; + model.setStartAndEnd(0, 0.4); + expect(viewData().length).toBeLessThan(rows.length); + model.setStartAndEnd(0.4, 1); + model.setStartAndEnd(0, 1); + expect(viewData().map((d: Row) => d.category)).toEqual(['A', 'B', 'C']); + expectPreview(); + }); +}); diff --git a/packages/vchart/src/component/data-zoom/data-zoom/data-zoom.ts b/packages/vchart/src/component/data-zoom/data-zoom/data-zoom.ts index 6c122a45b0..5c017d1401 100644 --- a/packages/vchart/src/component/data-zoom/data-zoom/data-zoom.ts +++ b/packages/vchart/src/component/data-zoom/data-zoom/data-zoom.ts @@ -194,13 +194,13 @@ export class DataZoom extends DataFilte const extendWidth = !this._visible ? 0 : this._isHorizontal - ? (startHandlerScaleXSize - this._startHandlerSize) / 2 + (endHandlerScaleXSize - this._endHandlerSize) / 2 - : (Math.max(startHandlerScaleXSize, endHandlerScaleXSize) - this._width) / 2; + ? (startHandlerScaleXSize - this._startHandlerSize) / 2 + (endHandlerScaleXSize - this._endHandlerSize) / 2 + : (Math.max(startHandlerScaleXSize, endHandlerScaleXSize) - this._width) / 2; const extendHeight = !this._visible ? 0 : this._isHorizontal - ? (Math.max(startHandlerScaleYSize, endHandlerScaleYSize) - this._height) / 2 - : (startHandlerScaleYSize - this._startHandlerSize) / 2 + (endHandlerScaleYSize - this._endHandlerSize) / 2; + ? (Math.max(startHandlerScaleYSize, endHandlerScaleYSize) - this._height) / 2 + : (startHandlerScaleYSize - this._startHandlerSize) / 2 + (endHandlerScaleYSize - this._endHandlerSize) / 2; if (this._isHorizontal) { result.y2 = result.y1 + this._height + extendHeight; result.x2 = result.x1 + rect.width + extendWidth; @@ -451,7 +451,7 @@ export class DataZoom extends DataFilte minSpan: this._minSpan, maxSpan: this._maxSpan, delayType: spec.delayType, - delayTime: isValid(spec.delayType) ? (spec.delayTime ?? 30) : 0, + delayTime: isValid(spec.delayType) ? spec.delayTime ?? 30 : 0, realTime: spec.realTime ?? true, previewData: isNeedPreview && this._data.getLatestData(), previewPointsX: isNeedPreview && this._dataToPositionX, @@ -477,7 +477,10 @@ export class DataZoom extends DataFilte protected _createOrUpdateComponent(changeData?: boolean) { if (this._visible) { - const isNeedPreview = this._spec.showBackgroundChart !== false; + const xScale = this._isHorizontal ? this._stateScale : this._valueScale; + const yScale = this._isHorizontal ? this._valueScale : this._stateScale; + const isNeedPreview = + this._isScaleValid(xScale) && this._isScaleValid(yScale) && this._spec.showBackgroundChart !== false; const attrs = this._getAttrs(isNeedPreview); const axis = this._relatedAxisComponent as CartesianAxis;