From a741d11415914911417985ae8adc40ddb545ce50 Mon Sep 17 00:00:00 2001 From: xile611 Date: Fri, 16 Oct 2026 12:41:19 +0800 Subject: [PATCH] fix(vchart): preserve manual resize with autoFit enabled --- .../fix-resize-auto-fit_2026-10-16.json | 10 ++ .../__tests__/unit/core/resize-remake.test.ts | 99 +++++++++++++++++++ .../visual/cases/api/resize-auto-fit.mjs | 89 +++++++++++++++++ .../vchart/__tests__/visual/cases/index.mjs | 5 + packages/vchart/src/core/vchart.ts | 12 ++- 5 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 common/changes/@visactor/vchart/fix-resize-auto-fit_2026-10-16.json create mode 100644 packages/vchart/__tests__/visual/cases/api/resize-auto-fit.mjs diff --git a/common/changes/@visactor/vchart/fix-resize-auto-fit_2026-10-16.json b/common/changes/@visactor/vchart/fix-resize-auto-fit_2026-10-16.json new file mode 100644 index 0000000000..91cde0a98d --- /dev/null +++ b/common/changes/@visactor/vchart/fix-resize-auto-fit_2026-10-16.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vchart", + "comment": "修复默认 autoFit 的首次或重复尺寸通知覆盖手动 resize 的问题,同时保持 Spec 重建后的画布尺寸及真实容器变化的自动适配。", + "type": "patch" + } + ], + "packageName": "@visactor/vchart" +} diff --git a/packages/vchart/__tests__/unit/core/resize-remake.test.ts b/packages/vchart/__tests__/unit/core/resize-remake.test.ts index 7af70a6809..9c1f9cd5ac 100644 --- a/packages/vchart/__tests__/unit/core/resize-remake.test.ts +++ b/packages/vchart/__tests__/unit/core/resize-remake.test.ts @@ -48,3 +48,102 @@ test('manual resize survives spec remakes and keeps the chart and stage at the s removeDom(dom); } }); + +describe('manual resize with autoFit enabled', () => { + const resizeObserverDescriptor = Object.getOwnPropertyDescriptor(window, 'ResizeObserver'); + let dom: HTMLElement; + let chart: VChart; + let notifyResize: () => void; + + beforeEach(() => { + dom = createDiv(); + dom.style.width = '600px'; + dom.style.height = '600px'; + // 控制首次和后续容器通知,避免浏览器调度顺序影响尺寸契约测试。 + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + writable: true, + value: jest.fn().mockImplementation(callback => { + const observer = { observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn() }; + notifyResize = () => callback([], observer); + return observer; + }) + }); + }); + + afterEach(() => { + chart.release(); + removeDom(dom); + if (resizeObserverDescriptor) { + Object.defineProperty(window, 'ResizeObserver', resizeObserverDescriptor); + } else { + delete (window as any).ResizeObserver; + } + }); + + const createChart = (spec: IBarChartSpec) => { + chart = new VChart(spec, { dom, animation: false, resizeDelay: 0, dpr: 1 }); + chart.renderSync(); + }; + + const expectSize = (width: number, height: number) => { + expect(chart.getChart()!.getCanvasRect()).toEqual({ width, height }); + expect([chart.getStage()!.width, chart.getStage()!.height]).toEqual([width, height]); + expect([chart.getCanvas()!.width, chart.getCanvas()!.height]).toEqual([width, height]); + }; + + test('initial observer delivery and unchanged window resize preserve the manual size', async () => { + createChart({ ...makeSpec(), width: 600, height: 600 }); + await chart.resize(300, 300); + notifyResize(); + expectSize(300, 300); + window.dispatchEvent(new Event('resize')); + expectSize(300, 300); + }); + + test('real container changes still resize the chart after a manual resize', () => { + createChart(makeSpec()); + chart.resizeSync(300, 300); + notifyResize(); + expectSize(300, 300); + dom.style.width = '480px'; + dom.style.height = '360px'; + notifyResize(); + expectSize(480, 360); + chart.resizeSync(250, 200); + window.dispatchEvent(new Event('resize')); + expectSize(250, 200); + }); + + test('an automatic size equal to the current canvas still advances the observed size', () => { + createChart(makeSpec()); + chart.resizeSync(300, 300); + dom.style.width = '300px'; + dom.style.height = '300px'; + notifyResize(); + expectSize(300, 300); + chart.resizeSync(250, 200); + notifyResize(); + expectSize(250, 200); + dom.style.width = '600px'; + dom.style.height = '600px'; + notifyResize(); + expectSize(600, 600); + }); + + test('remakes preserve the manual size and new explicit spec dimensions reset automatic sizing', () => { + createChart({ ...makeSpec(), width: 600, height: 600 }); + chart.resizeSync(300, 300); + const original = chart.getChart(); + chart.updateSpecSync({ ...makeSpec(true), width: 600, height: 600 }); + expect(chart.getChart()).not.toBe(original); + notifyResize(); + expectSize(300, 300); + chart.updateSpecSync({ ...makeSpec(), width: 640, height: 480 }); + expectSize(640, 480); + notifyResize(); + chart.resizeSync(320, 240); + window.dispatchEvent(new Event('resize')); + expectSize(320, 240); + }); +}); diff --git a/packages/vchart/__tests__/visual/cases/api/resize-auto-fit.mjs b/packages/vchart/__tests__/visual/cases/api/resize-auto-fit.mjs new file mode 100644 index 0000000000..a63a5df54a --- /dev/null +++ b/packages/vchart/__tests__/visual/cases/api/resize-auto-fit.mjs @@ -0,0 +1,89 @@ +/** + * 验证目的:默认 autoFit 的首次容器通知不能撤销手动 resize。 + * 图表类型:bar。 + * 关键配置:显式 600×600、默认 autoFit、resize(300, 300)、customMark 触发重建。 + * 场景条件:同一浏览器任务内 renderAsync 后立即 resize,再等待真实 ResizeObserver 通知。 + * 最终检查:resize、首次通知、无尺寸变化的窗口事件及重建后,布局、stage 和 Canvas 均为 300×300。 + * 覆盖边界:容器真实变化和新显式 Spec 尺寸覆盖由核心单元测试验证。 + */ +export default { + createSpec() { + return { + type: 'bar', + width: 600, + height: 600, + xField: 'date', + yField: 'value', + data: { + id: 'data', + values: [ + { date: '2019-08-29', value: 39359021311 }, + { date: '2019-08-30', value: 41363548585 } + ] + }, + tooltip: { transitionDuration: 0 } + }; + }, + async exercise(page) { + // 重新创建实例,让首次原生 observer 通知发生在手动 resize 之后。 + await page.evaluate(async () => { + const spec = window.__visualChart.getSpec(); + window.__visualChart.release(); + const chart = (window.__visualChart = new window.VChart.default(spec, { + dom: 'chart', + animation: false, + resizeDelay: 0, + theme: { fontFamily: 'Arial', tooltip: { transitionDuration: 0 } }, + onError: error => window.__visualErrors.push(String(error)) + })); + const sizes = (window.__resizeAutoFitSizes = []); + const capture = () => { + const rect = chart.getChart().getCanvasRect(); + const stage = chart.getStage(); + const canvas = chart.getCanvas(); + sizes.push([rect.width, rect.height, stage.width, stage.height, canvas.width, canvas.height]); + }; + // 测试自己的原生 observer 提供交付屏障,不以固定延时猜测首次通知完成时间。 + const delivered = new Promise(resolve => { + const observer = new ResizeObserver(() => { + observer.disconnect(); + requestAnimationFrame(resolve); + }); + observer.observe(document.getElementById('chart')); + }); + await chart.renderAsync(); + await chart.resize(300, 300); + capture(); + await delivered; + capture(); + window.dispatchEvent(new Event('resize')); + capture(); + const original = chart.getChart(); + await chart.updateSpec({ + ...chart.getSpec(), + customMark: [{ type: 'text', style: { text: 'resize 300×300', x: 20, y: 20 } }] + }); + window.__resizeAutoFitRemade = chart.getChart() !== original; + window.dispatchEvent(new Event('resize')); + capture(); + }); + }, + async verify(page) { + // 每个阶段检查真实尺寸,并确认重建确实发生,防止无效动作产生假通过。 + await page.evaluate(() => { + const sizes = window.__resizeAutoFitSizes; + const chart = window.__visualChart; + const rect = chart.getChart().getCanvasRect(); + const stage = chart.getStage(); + const canvas = chart.getCanvas(); + if ( + !window.__resizeAutoFitRemade || + sizes?.length !== 4 || + sizes.some(size => size.some(value => value !== 300)) || + [rect.width, rect.height, stage.width, stage.height, canvas.width, canvas.height].some(value => value !== 300) + ) { + throw new Error('自动尺寸监听或 Spec 重建覆盖了手动 resize 的 300×300 尺寸:' + JSON.stringify(sizes)); + } + }); + } +}; diff --git a/packages/vchart/__tests__/visual/cases/index.mjs b/packages/vchart/__tests__/visual/cases/index.mjs index 5acdf7b01a..4a83196c98 100644 --- a/packages/vchart/__tests__/visual/cases/index.mjs +++ b/packages/vchart/__tests__/visual/cases/index.mjs @@ -68,6 +68,11 @@ export const cases = [ file: './api/update-resize.mjs', sourceExample: 'packages/vchart/__tests__/runtime/browser/test-page/event-update-spec.ts' }, + { + id: 'resize-auto-fit', + purpose: '默认自动尺寸监听不覆盖手动 resize,Spec 重建后保持画布尺寸', + file: './api/resize-auto-fit.mjs' + }, { id: 'bar-area-smooth', purpose: '同一区域的柱图与平滑轮廓面积图叠加', diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index a1552249dd..eed867b69c 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -353,7 +353,9 @@ export class VChart implements IVChart { } }; - private _currentSize: { width: number; height: number }; + private _currentSize: Readonly<{ width: number; height: number }>; + // 自动尺寸来源的快照;手动 resize 只改变当前画布,不能使未变化的来源被误判为变化。 + private _lastAutoSize: Readonly<{ width: number; height: number }>; private _observer: ResizeObserver = null; private _currentThemeName: string; @@ -419,6 +421,7 @@ export class VChart implements IVChart { this._setNewSpec(spec); this._updateCurrentTheme(); this._currentSize = this.getCurrentSize(); + this._lastAutoSize = this._currentSize; const pluginList: string[] = []; const runtimePluginInstallers: ((app?: IApp) => void)[] = []; @@ -630,8 +633,9 @@ export class VChart implements IVChart { private _doResize = () => { const { width, height } = this.getCurrentSize(); - if (this._currentSize.width !== width || this._currentSize.height !== height) { + if (this._lastAutoSize.width !== width || this._lastAutoSize.height !== height) { this._currentSize = { width, height }; + this._lastAutoSize = this._currentSize; this.resizeSync(width, height); } }; @@ -740,6 +744,7 @@ export class VChart implements IVChart { const { width, height } = this.getCurrentSize(); this._currentSize = { width, height }; + this._lastAutoSize = this._currentSize; this._chart?.onResize(width, height, false); this._compiler?.resize(width, height, false); } @@ -1331,8 +1336,7 @@ export class VChart implements IVChart { this._option.performanceHook?.beforeResizeWithUpdate?.(this); // updateSpec 重建 chart 时沿用当前尺寸,不能恢复到手动 resize 之前的画布大小。 - this._currentSize.width = width; - this._currentSize.height = height; + this._currentSize = { width, height }; this._chart.onResize(width, height, false); this._option.performanceHook?.afterResizeWithUpdate?.(this);