From 7452d4b9dfa679af0fb27ab789624c033b54d0af Mon Sep 17 00:00:00 2001 From: "ouxiaofeng.utlf" Date: Fri, 29 May 2026 13:28:30 +0800 Subject: [PATCH] fix: Fix the issue where map labels do not follow the map --- .../runtime/browser/test-page/map.ts | 198 ++++++++++++++++ .../vchart/__tests__/unit/series/map.test.ts | 222 ++++++++++++++++++ packages/vchart/src/series/map/map.ts | 36 ++- 3 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 packages/vchart/__tests__/runtime/browser/test-page/map.ts create mode 100644 packages/vchart/__tests__/unit/series/map.test.ts diff --git a/packages/vchart/__tests__/runtime/browser/test-page/map.ts b/packages/vchart/__tests__/runtime/browser/test-page/map.ts new file mode 100644 index 0000000000..5e643f1167 --- /dev/null +++ b/packages/vchart/__tests__/runtime/browser/test-page/map.ts @@ -0,0 +1,198 @@ +/** + * 复现 Issue #4585:开启 roam 后,缩放/平移地图时 label 不跟随。 + * 验证修复后:缩放和平移地图时 label 应该跟着 path 一起变换。 + * + * 验证方式: + * 1. 等图表渲染完成; + * 2. 通过 dispatchZoom 程序触发缩放,观察 path 与 label 是否同步缩放; + * 3. 通过 region 上的 pan 接口程序触发平移; + * 4. 也可以直接在页面上滚轮 / 拖拽,肉眼对比 label 是否始终位于对应国家的中心。 + */ +import { default as VChart } from '../../../../src/index'; + +const CONTAINER_ID = 'chart'; + +const run = async () => { + const response = await fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/geojson/usa.json'); + const geojson = await response.json(); + VChart.registerMap('usa', geojson); + + const spec: any = { + type: 'map', + title: { + text: 'Issue #4585 - map label should follow zoom/pan', + subtext: 'wheel to zoom, drag to pan, then check whether the label sticks to its country' + }, + color: { + type: 'linear', + range: ['rgb(252,250,97)', 'rgb(252,150,134)', 'rgb(87,33,15)'] + }, + area: { + style: { + fill: { + field: 'value', + scale: 'color', + changeDomain: 'replace' + } + } + }, + data: [ + { + values: [ + { name: 'Alabama', value: 4822023 }, + { name: 'Alaska', value: 731449 }, + { name: 'Arizona', value: 6553255 }, + { name: 'Arkansas', value: 2949131 }, + { name: 'California', value: 38041430 }, + { name: 'Colorado', value: 5187582 }, + { name: 'Connecticut', value: 3590347 }, + { name: 'Delaware', value: 917092 }, + { name: 'District of Columbia', value: 632323 }, + { name: 'Florida', value: 19317568 }, + { name: 'Georgia', value: 9919945 }, + { name: 'Hawaii', value: 1392313 }, + { name: 'Idaho', value: 1595728 }, + { name: 'Illinois', value: 12875255 }, + { name: 'Indiana', value: 6537334 }, + { name: 'Iowa', value: 3074186 }, + { name: 'Kansas', value: 2885905 }, + { name: 'Kentucky', value: 4380415 }, + { name: 'Louisiana', value: 4601893 }, + { name: 'Maine', value: 1329192 }, + { name: 'Maryland', value: 5884563 }, + { name: 'Massachusetts', value: 6646144 }, + { name: 'Michigan', value: 9883360 }, + { name: 'Minnesota', value: 5379139 }, + { name: 'Mississippi', value: 2984926 }, + { name: 'Missouri', value: 6021988 }, + { name: 'Montana', value: 1005141 }, + { name: 'Nebraska', value: 1855525 }, + { name: 'Nevada', value: 2758931 }, + { name: 'New Hampshire', value: 1320718 }, + { name: 'New Jersey', value: 8864590 }, + { name: 'New Mexico', value: 2085538 }, + { name: 'New York', value: 19570261 }, + { name: 'North Carolina', value: 9752073 }, + { name: 'North Dakota', value: 699628 }, + { name: 'Ohio', value: 11544225 }, + { name: 'Oklahoma', value: 3814820 }, + { name: 'Oregon', value: 3899353 }, + { name: 'Pennsylvania', value: 12763536 }, + { name: 'Rhode Island', value: 1050292 }, + { name: 'South Carolina', value: 4723723 }, + { name: 'South Dakota', value: 833354 }, + { name: 'Tennessee', value: 6456243 }, + { name: 'Texas', value: 26059203 }, + { name: 'Utah', value: 2855287 }, + { name: 'Vermont', value: 626011 }, + { name: 'Virginia', value: 8185867 }, + { name: 'Washington', value: 6897012 }, + { name: 'West Virginia', value: 1855413 }, + { name: 'Wisconsin', value: 5726398 }, + { name: 'Wyoming', value: 576412 } + ] + } + ], + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + map: 'usa', + label: { + visible: true, + style: { + fontSize: 10, + stroke: '#fff', + lineWidth: 2 + } + }, + region: [ + { + roam: true, + projection: { + type: 'albersUsa' + } + } + ], + legends: [ + { + visible: true, + type: 'color', + field: 'value', + orient: 'bottom', + position: 'start', + title: { visible: true, text: 'Population' } + } + ] + }; + + const vchart = new VChart(spec, { + dom: document.getElementById(CONTAINER_ID) as HTMLElement + }); + await vchart.renderAsync(); + (window as any).vchart = vchart; + + // 提供一些便捷的快捷键 / 全局函数辅助验证 + const getLabelGraphic = () => { + const series = (vchart as any).getChart().getAllSeries()[0]; + return series?._labelMark?.getComponent()?.getComponent(); + }; + const getPathGroup = () => { + const series = (vchart as any).getChart().getAllSeries()[0]; + return series?.getRootMark().getProduct(); + }; + + const printState = (tag: string) => { + const labelG = getLabelGraphic(); + const pathG = getPathGroup(); + console.log(`[${tag}] pathGroup.postMatrix=`, pathG?.attribute.postMatrix); + console.log(`[${tag}] labelGraphic.postMatrix=`, labelG?.attribute.postMatrix); + }; + + printState('initial'); + + // 程序触发缩放 1.5x,便于自动化校验 + (window as any).runZoomTest = (scale = 1.5) => { + const region = (vchart as any).getChart().getAllRegions()[0]; + const { x, y, width, height } = region.getLayoutRect + ? { ...region.getLayoutStartPoint(), ...region.getLayoutRect() } + : { x: 200, y: 200, width: 400, height: 400 }; + const center = { x: x + width / 2, y: y + height / 2 }; + const geoCoordinate = (vchart as any) + .getChart() + .getComponentsByKey('geoCoordinate')?.[0]; + if (geoCoordinate?.dispatchZoom) { + geoCoordinate.dispatchZoom(scale, center); + printState(`after zoom x${scale}`); + const labelG = getLabelGraphic(); + const pathG = getPathGroup(); + const pathM = pathG?.attribute.postMatrix; + const labelM = labelG?.attribute.postMatrix; + const passed = + pathM && + labelM && + Math.abs(pathM.a - labelM.a) < 1e-6 && + Math.abs(pathM.d - labelM.d) < 1e-6 && + Math.abs(pathM.e - labelM.e) < 1e-6 && + Math.abs(pathM.f - labelM.f) < 1e-6; + console.log( + `%c[Issue#4585] ${passed ? 'PASS' : 'FAIL'}: label postMatrix ${ + passed ? 'matches' : 'does NOT match' + } path postMatrix`, + `color: ${passed ? 'green' : 'red'}; font-weight: bold;` + ); + return passed; + } + console.warn('geoCoordinate not found'); + return false; + }; + + // 默认在加载后跑一次自动校验 + setTimeout(() => { + (window as any).runZoomTest(1.5); + console.log( + '可以再手动执行 window.runZoomTest(0.7) / window.runZoomTest(2),或在画布上滚轮缩放 / 拖拽平移做肉眼验证。' + ); + }, 500); +}; + +run(); diff --git a/packages/vchart/__tests__/unit/series/map.test.ts b/packages/vchart/__tests__/unit/series/map.test.ts new file mode 100644 index 0000000000..e9ad4a9f53 --- /dev/null +++ b/packages/vchart/__tests__/unit/series/map.test.ts @@ -0,0 +1,222 @@ +import { Matrix } from '@visactor/vutils'; +import { VChart } from '../../../src/vchart-all'; +import type { IMapChartSpec } from '../../../src'; +import type { MapSeries } from '../../../src/series/map/map'; +import { createCanvas, removeDom } from '../../util/dom'; + +/** + * Issue #4585: 地图开启 roam 后,缩放/平移时 label 应当跟随地图同步缩放与平移。 + * + * 单元测试目标: + * 1. handleZoom 后 pathGroup 与 labelGraphic 的 postMatrix 一致; + * 2. handlePan 后 pathGroup 与 labelGraphic 的 postMatrix 一致; + * 3. onLayoutEnd 时 labelGraphic 的 postMatrix 被重置(与 path 行为对齐)。 + */ + +// 一个最小可用的 geojson 用例:两个矩形 polygon,分别命名 A / B +const MINI_GEOJSON = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { name: 'A' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0] + ] + ] + } + }, + { + type: 'Feature', + properties: { name: 'B' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [20, 0], + [30, 0], + [30, 10], + [20, 10], + [20, 0] + ] + ] + } + } + ] +}; + +describe('map series roam label sync (issue #4585)', () => { + let canvasDom: HTMLCanvasElement; + let vchart: VChart; + + beforeAll(() => { + VChart.registerMap('issue-4585-map', MINI_GEOJSON as any); + }); + + beforeEach(() => { + canvasDom = createCanvas(); + canvasDom.style.position = 'relative'; + canvasDom.style.width = '500px'; + canvasDom.style.height = '500px'; + canvasDom.width = 500; + canvasDom.height = 500; + }); + + afterEach(() => { + if (vchart) { + vchart.release(); + } + removeDom(canvasDom); + }); + + const createMapVChart = async () => { + const spec: IMapChartSpec = { + type: 'map', + map: 'issue-4585-map', + nameField: 'name', + valueField: 'value', + nameProperty: 'name', + data: [ + { + values: [ + { name: 'A', value: 1 }, + { name: 'B', value: 2 } + ] + } + ], + label: { visible: true }, + region: [{ roam: true }], + animation: false + } as IMapChartSpec; + + vchart = new VChart(spec, { + renderCanvas: canvasDom, + animation: false, + autoFit: true + }); + await vchart.renderAsync(); + return (vchart.getChart() as any).getAllSeries()[0] as MapSeries; + }; + + it('handleZoom 后 pathGroup 与 labelGraphic 的 postMatrix 同步', async () => { + const series = await createMapVChart(); + const pathGroup = series.getRootMark().getProduct() as any; + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + expect(labelGraphic).toBeTruthy(); + expect(pathGroup.attribute.postMatrix).toBeFalsy(); + expect(labelGraphic.attribute.postMatrix).toBeFalsy(); + + // 模拟 GeoCoordinate 派发的 zoom 事件 + series.handleZoom({ + scale: 1.5, + scaleCenter: { x: 250, y: 250 } + } as any); + + const pathM = pathGroup.attribute.postMatrix; + const labelM = labelGraphic.attribute.postMatrix; + + expect(pathM).toBeTruthy(); + expect(labelM).toBeTruthy(); + expect(labelM.a).toBeCloseTo(pathM.a); + expect(labelM.d).toBeCloseTo(pathM.d); + expect(labelM.e).toBeCloseTo(pathM.e); + expect(labelM.f).toBeCloseTo(pathM.f); + + // 1.5 倍缩放分量 + expect(labelM.a).toBeCloseTo(1.5); + expect(labelM.d).toBeCloseTo(1.5); + }); + + it('handleZoom scale=1 时 不应创建 postMatrix', async () => { + const series = await createMapVChart(); + const pathGroup = series.getRootMark().getProduct() as any; + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + series.handleZoom({ + scale: 1, + scaleCenter: { x: 250, y: 250 } + } as any); + + expect(pathGroup.attribute.postMatrix).toBeFalsy(); + expect(labelGraphic.attribute.postMatrix).toBeFalsy(); + }); + + it('handlePan 后 pathGroup 与 labelGraphic 的 postMatrix 同步平移', async () => { + const series = await createMapVChart(); + const pathGroup = series.getRootMark().getProduct() as any; + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + series.handlePan({ delta: [20, -10] } as any); + + const pathM = pathGroup.attribute.postMatrix; + const labelM = labelGraphic.attribute.postMatrix; + + expect(pathM).toBeTruthy(); + expect(labelM).toBeTruthy(); + expect(labelM.e).toBeCloseTo(pathM.e); + expect(labelM.f).toBeCloseTo(pathM.f); + expect(labelM.e).toBeCloseTo(20); + expect(labelM.f).toBeCloseTo(-10); + }); + + it('handlePan delta=[0,0] 时 不应创建 postMatrix', async () => { + const series = await createMapVChart(); + const pathGroup = series.getRootMark().getProduct() as any; + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + series.handlePan({ delta: [0, 0] } as any); + + expect(pathGroup.attribute.postMatrix).toBeFalsy(); + expect(labelGraphic.attribute.postMatrix).toBeFalsy(); + }); + + it('连续多次缩放 后 path 与 label 的 postMatrix 始终保持一致', async () => { + const series = await createMapVChart(); + const pathGroup = series.getRootMark().getProduct() as any; + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + series.handleZoom({ scale: 1.5, scaleCenter: { x: 250, y: 250 } } as any); + series.handleZoom({ scale: 0.8, scaleCenter: { x: 200, y: 200 } } as any); + series.handlePan({ delta: [5, 5] } as any); + + const pathM = pathGroup.attribute.postMatrix; + const labelM = labelGraphic.attribute.postMatrix; + + expect(labelM.a).toBeCloseTo(pathM.a); + expect(labelM.b).toBeCloseTo(pathM.b); + expect(labelM.c).toBeCloseTo(pathM.c); + expect(labelM.d).toBeCloseTo(pathM.d); + expect(labelM.e).toBeCloseTo(pathM.e); + expect(labelM.f).toBeCloseTo(pathM.f); + }); + + it('onLayoutEnd 触发后 labelGraphic 的 postMatrix 应当被重置', async () => { + const series = await createMapVChart(); + const labelGraphic = (series as any)._labelMark?.getComponent()?.getComponent(); + + // 先模拟一次缩放,制造 postMatrix + series.handleZoom({ scale: 1.5, scaleCenter: { x: 250, y: 250 } } as any); + expect(labelGraphic.attribute.postMatrix).toBeTruthy(); + expect(labelGraphic.attribute.postMatrix.a).toBeCloseTo(1.5); + + // 触发 series.onLayoutEnd,预期 postMatrix 被重置为单位矩阵 + series.onLayoutEnd(); + + const labelM: Matrix = labelGraphic.attribute.postMatrix; + expect(labelM).toBeTruthy(); + expect(labelM.a).toBeCloseTo(1); + expect(labelM.d).toBeCloseTo(1); + expect(labelM.b).toBeCloseTo(0); + expect(labelM.c).toBeCloseTo(0); + expect(labelM.e).toBeCloseTo(0); + expect(labelM.f).toBeCloseTo(0); + }); +}); diff --git a/packages/vchart/src/series/map/map.ts b/packages/vchart/src/series/map/map.ts index 6fbc11206f..01dcb9d35c 100644 --- a/packages/vchart/src/series/map/map.ts +++ b/packages/vchart/src/series/map/map.ts @@ -237,6 +237,18 @@ export class MapSeries extends GeoSer this._mapViewData = null as any; } + private _ensureLabelGraphicPostMatrix() { + const labelGraphic = this._labelMark?.getComponent()?.getComponent(); + + if (labelGraphic && !labelGraphic.attribute.postMatrix) { + labelGraphic.setAttributes({ + postMatrix: new Matrix() + }); + } + + return labelGraphic; + } + handleZoom(e: ZoomEventParam) { const { scale, scaleCenter } = e; if (scale === 1) { @@ -252,10 +264,10 @@ export class MapSeries extends GeoSer } pathGroup.scale(scale, scale, scaleCenter); } - const vgrammarLabel = this._labelMark?.getComponent(); - if (vgrammarLabel) { - vgrammarLabel.renderInner(); + const labelGraphic = this._ensureLabelGraphicPostMatrix(); + if (labelGraphic) { + labelGraphic.scale(scale, scale, scaleCenter); } } @@ -273,8 +285,24 @@ export class MapSeries extends GeoSer } pathGroup.translate(delta[0], delta[1]); } - const vgrammarLabel = this._labelMark?.getComponent(); + const labelGraphic = this._ensureLabelGraphicPostMatrix(); + if (labelGraphic) { + labelGraphic.translate(delta[0], delta[1]); + } + } + + onLayoutEnd(): void { + super.onLayoutEnd(); + + const labelGraphic = this._labelMark?.getComponent()?.getComponent(); + if (labelGraphic?.attribute.postMatrix) { + labelGraphic.setAttributes({ + postMatrix: new Matrix() + }); + } + + const vgrammarLabel = this._labelMark?.getComponent(); if (vgrammarLabel) { vgrammarLabel.renderInner(); }