From b3ba64f3d121edeacb5fc861b0823eca6e8505c4 Mon Sep 17 00:00:00 2001 From: xile611 Date: Wed, 16 Sep 2026 10:59:24 +0800 Subject: [PATCH] fix: restore point visibility after overlap updates --- ...-overlap-update-spec_2026-09-16-02-59.json | 11 ++ .../unit/mark/symbol-overlap.test.ts | 34 ++++ .../vchart/__tests__/unit/mark/symbol.test.ts | 19 +++ .../unit/series/line-overlap-update.test.ts | 149 ++++++++++++++++++ packages/vchart/src/mark/base/base-mark.ts | 1 + .../src/mark/transform/symbol-overlap.ts | 37 +---- 6 files changed, 218 insertions(+), 33 deletions(-) create mode 100644 common/changes/@visactor/vchart/codex-fix-symbol-overlap-update-spec_2026-09-16-02-59.json create mode 100644 packages/vchart/__tests__/unit/mark/symbol-overlap.test.ts create mode 100644 packages/vchart/__tests__/unit/series/line-overlap-update.test.ts diff --git a/common/changes/@visactor/vchart/codex-fix-symbol-overlap-update-spec_2026-09-16-02-59.json b/common/changes/@visactor/vchart/codex-fix-symbol-overlap-update-spec_2026-09-16-02-59.json new file mode 100644 index 0000000000..58a17c2c2f --- /dev/null +++ b/common/changes/@visactor/vchart/codex-fix-symbol-overlap-update-spec_2026-09-16-02-59.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: restore point visibility after overlap updates", + "type": "none", + "packageName": "@visactor/vchart" + } + ], + "packageName": "@visactor/vchart", + "email": "dingling112@gmail.com" +} \ No newline at end of file diff --git a/packages/vchart/__tests__/unit/mark/symbol-overlap.test.ts b/packages/vchart/__tests__/unit/mark/symbol-overlap.test.ts new file mode 100644 index 0000000000..d4ab66cc28 --- /dev/null +++ b/packages/vchart/__tests__/unit/mark/symbol-overlap.test.ts @@ -0,0 +1,34 @@ +import { transform } from '../../../src/mark/transform/symbol-overlap'; +import type { IMarkGraphic } from '../../../src/mark/interface'; + +const point = (position: number, group = 'a', forceShow = false) => + ({ + context: { + data: [{ group }], + finalAttrs: { x: position, y: position, size: 10, visible: true, forceShow } + } + } as unknown as IMarkGraphic); + +test.each([1, 2])('overlap respects the current encoded visibility in direction %s', direction => { + const graphics = [point(0), point(1)]; + transform({ direction }, graphics); + expect(graphics[1].context.finalAttrs.visible).toBe(false); + + graphics[0].context.finalAttrs = { x: 0, y: 0, size: 10, visible: true }; + graphics[1].context.finalAttrs = { x: 100, y: 100, size: 10, visible: false }; + transform({ direction }, graphics); + expect(graphics[1].context.finalAttrs.visible).toBe(false); + + graphics[1].context.finalAttrs = { x: 100, y: 100, size: 10, visible: true }; + transform({ direction }, graphics); + expect(graphics[1].context.finalAttrs.visible).toBe(true); +}); + +test('overlap preserves grouping, sorting and forceShow', () => { + const graphics = [point(50), point(0), point(1), point(2, 'a', true), point(0, 'b')]; + const order = graphics.slice(); + + expect(transform({ direction: 1, sort: true, groupBy: 'group' }, graphics)).toBe(graphics); + expect(graphics).toEqual(order); + expect(graphics.map(g => g.context.finalAttrs.visible)).toEqual([true, true, false, true, true]); +}); diff --git a/packages/vchart/__tests__/unit/mark/symbol.test.ts b/packages/vchart/__tests__/unit/mark/symbol.test.ts index e9a7dece4e..0ca5c79bbc 100644 --- a/packages/vchart/__tests__/unit/mark/symbol.test.ts +++ b/packages/vchart/__tests__/unit/mark/symbol.test.ts @@ -60,6 +60,25 @@ test('symbol setAttribute support constant value.', () => { expect(y_hover).toEqual(100); }); +test('symbol reinit restores defaults before applying the next spec', () => { + const symbolMark = new SymbolMark('symbol0', ctx); + symbolMark.created(); + symbolMark.initStyleWithSpec({ style: { visible: false, size: 12, lineWidth: 2 } }); + + symbolMark.clearBeforeReInit(); + + expect(symbolMark.stateStyle.normal).toBeDefined(); + expect(symbolMark.getAttribute('visible', {})).toBe(true); + expect(symbolMark.getAttribute('size', {})).toBe(1); + expect(symbolMark.getAttribute('symbolType', {})).toBe('circle'); + expect(symbolMark.getAttribute('lineWidth', {})).toBe(0); + + symbolMark.initStyleWithSpec({ style: { visible: false, size: 8 } }); + + expect(symbolMark.getAttribute('visible', {})).toBe(false); + expect(symbolMark.getAttribute('size', {})).toBe(8); +}); + test('symbol setAttribute support function.', () => { const symbolMark = new SymbolMark('symbol0', ctx); symbolMark.created(); diff --git a/packages/vchart/__tests__/unit/series/line-overlap-update.test.ts b/packages/vchart/__tests__/unit/series/line-overlap-update.test.ts new file mode 100644 index 0000000000..9dccab078e --- /dev/null +++ b/packages/vchart/__tests__/unit/series/line-overlap-update.test.ts @@ -0,0 +1,149 @@ +import VChart, { ManualTicker, type ILineChartSpec } from '../../../src'; +import type { ISymbolGraphicAttribute } from '@visactor/vrender-core'; +import { setGraphicStates } from '../../../src/util/graphic-state'; + +const makeValues = (count: number) => + ['green', 'orange'].flatMap(group => Array.from({ length: count }, (_, i) => ({ x: `item-${i}`, y: i + 1, group }))); + +const makeSpec = (count: number, horizontal = false): ILineChartSpec => ({ + type: 'line', + width: horizontal ? 160 : 240, + height: horizontal ? 240 : 160, + padding: 0, + animation: false, + direction: horizontal ? 'horizontal' : 'vertical', + data: { id: 'data', values: makeValues(count) }, + xField: horizontal ? 'y' : 'x', + yField: horizontal ? 'x' : 'y', + seriesField: 'group', + markOverlap: true, + point: { style: { size: 14 } }, + axes: [ + { orient: 'bottom', visible: false }, + { orient: 'left', visible: false } + ] +}); + +const points = (chart: VChart) => chart.getChart().getAllSeries()[0].getMarkInName('point').getGraphics(); +const visibleCount = (chart: VChart, group: string) => + points(chart).filter(g => g.context.data[0].group === group && g.attribute.visible !== false).length; +const snapshot = (chart: VChart) => + points(chart).map(g => ({ + x: g.attribute.x, + y: g.attribute.y, + visible: g.attribute.visible, + size: (g.attribute as ISymbolGraphicAttribute).size, + fill: g.attribute.fill + })); + +describe('line overlap updates', () => { + const charts: VChart[] = []; + const containers: HTMLElement[] = []; + const createChart = (spec: ILineChartSpec, ticker?: ManualTicker) => { + const dom = document.createElement('div'); + document.body.appendChild(dom); + containers.push(dom); + const chart = new VChart(spec, { dom, ticker, animation: spec.animation !== false }); + charts.push(chart); + chart.renderSync(); + return chart; + }; + + afterEach(() => { + charts.splice(0).forEach(chart => chart.release()); + containers.splice(0).forEach(dom => dom.remove()); + }); + + test.each([false, true])('dense to sparse update restores reused points, horizontal=%s', horizontal => { + const chart = createChart(makeSpec(12, horizontal)); + const original = points(chart).slice(); + expect(visibleCount(chart, 'green')).toBeLessThan(12); + + chart.updateSpecSync(makeSpec(3, horizontal)); + + const green = points(chart).filter(g => g.context.data[0].group === 'green'); + expect(green.every(g => original.includes(g))).toBe(true); + expect(visibleCount(chart, 'green')).toBe(3); + expect(visibleCount(chart, 'orange')).toBe(3); + expect(snapshot(chart)).toEqual(snapshot(createChart(makeSpec(3, horizontal)))); + + chart.updateSpecSync(makeSpec(12, horizontal)); + expect(visibleCount(chart, 'green')).toBeLessThan(12); + chart.updateSpecSync(makeSpec(3, horizontal)); + expect(visibleCount(chart, 'green')).toBe(3); + expect(visibleCount(chart, 'orange')).toBe(3); + }); + + test('a previous overlap decision does not override the next explicit visibility', () => { + const chart = createChart(makeSpec(12)); + // 在真实图元上再次运行防重叠,覆盖旧实现的隐藏标记已存在的路径。 + chart.updateSpecSync(makeSpec(12)); + const spec = makeSpec(3); + spec.point.style.visible = datum => datum.x !== 'item-1'; + + chart.updateSpecSync(spec); + + expect(visibleCount(chart, 'green')).toBe(2); + expect(visibleCount(chart, 'orange')).toBe(2); + expect(snapshot(chart)).toEqual(snapshot(createChart(spec))); + }); + + test('updateData restores points that no longer overlap', () => { + const chart = createChart(makeSpec(12)); + const original = points(chart)[1]; + expect(original.attribute.visible).toBe(false); + + chart.updateDataSync('data', makeValues(3)); + + expect(points(chart)[1]).toBe(original); + expect(snapshot(chart)).toEqual(snapshot(createChart(makeSpec(3)))); + }); + + test('resize recomputes visibility in both directions', () => { + const chart = createChart(makeSpec(12)); + const narrow = snapshot(chart); + + chart.resize(720, 160); + expect(visibleCount(chart, 'green')).toBe(12); + expect(visibleCount(chart, 'orange')).toBe(12); + + chart.resize(240, 160); + expect(snapshot(chart)).toEqual(narrow); + }); + + test('hover after an update restores the new normal visibility', () => { + const chart = createChart(makeSpec(12)); + chart.updateSpecSync(makeSpec(3)); + const point = points(chart)[1]; + + setGraphicStates(point, ['dimension_hover'], false); + setGraphicStates(point, [], false); + + expect(point.attribute.visible).toBe(true); + expect(snapshot(chart)).toEqual(snapshot(createChart(makeSpec(3)))); + }); + + test('animated update finishes with the same visible points as a fresh render', () => { + const ticker = new ManualTicker(); + ticker.autoStop = false; + const animatedSpec = (count: number): ILineChartSpec => ({ + ...makeSpec(count), + animation: true, + animationAppear: { duration: 300, easing: 'linear' }, + animationUpdate: { duration: 300, easing: 'linear' } + }); + try { + const chart = createChart(animatedSpec(12), ticker); + ticker.tickAt(400); + chart.updateSpecSync(animatedSpec(3)); + ticker.tickAt(800); + + expect(visibleCount(chart, 'green')).toBe(3); + expect(visibleCount(chart, 'orange')).toBe(3); + expect(snapshot(chart)).toEqual(snapshot(createChart(makeSpec(3)))); + } finally { + charts.splice(0).forEach(chart => chart.release()); + ticker.release(); + } + }); +}); diff --git a/packages/vchart/src/mark/base/base-mark.ts b/packages/vchart/src/mark/base/base-mark.ts index 4bfe9c1d54..c18af7e291 100644 --- a/packages/vchart/src/mark/base/base-mark.ts +++ b/packages/vchart/src/mark/base/base-mark.ts @@ -2296,6 +2296,7 @@ export class BaseMark extends GrammarItem implements IMar this.state.clearAllStateInfo(); this.uncommit(); this.stateStyle = {}; + this._initStyle(); this.getGraphics().forEach(g => { if (g.currentStates?.length) { (g as ReinitStateGraphic)[statesClearedBeforeReInitKey] = g.currentStates.slice(); diff --git a/packages/vchart/src/mark/transform/symbol-overlap.ts b/packages/vchart/src/mark/transform/symbol-overlap.ts index 03cb9b131c..9057066780 100644 --- a/packages/vchart/src/mark/transform/symbol-overlap.ts +++ b/packages/vchart/src/mark/transform/symbol-overlap.ts @@ -1,30 +1,8 @@ import type { ISymbolGraphicAttribute } from '@visactor/vrender-core'; -import { PREFIX } from '../../constant/base'; import { isNil } from '@visactor/vutils'; import { Factory } from '../../core/factory'; import type { IMarkGraphic } from '../interface'; -export const OVERLAP_HIDE_KEY = `${PREFIX}_hide_`; - -function setVisible(g: IMarkGraphic, visible: boolean) { - if (g.context.finalAttrs) { - g.context.finalAttrs.visible = visible; - } -} - -function reset(graphics: IMarkGraphic[]) { - graphics.forEach(g => { - const hide = (g as any)[OVERLAP_HIDE_KEY]; - - if (hide) { - setVisible(g, true); - - (g as any)[OVERLAP_HIDE_KEY] = false; - } - }); - return graphics; -} - function overlapX(graphics: IMarkGraphic[], delta: number, deltaMul: number) { let lastX = -Infinity; let lastR = 0; @@ -44,9 +22,7 @@ function overlapX(graphics: IMarkGraphic[], delta: number, deltaMul: number) { } if (Math.abs(currentX - lastX) < itemDelta + lastR + r) { if (!(g.context.finalAttrs as any).forceShow) { - (g as any)[OVERLAP_HIDE_KEY] = true; - - setVisible(g, false); + g.context.finalAttrs.visible = false; } } else { lastX = currentX; @@ -75,9 +51,7 @@ function overlapY(graphics: IMarkGraphic[], delta: number, deltaMul: number) { } if (Math.abs(currentY - lastY) < itemDelta + lastR + r) { if (!(g.context.finalAttrs as any).forceShow) { - (g as any)[OVERLAP_HIDE_KEY] = true; - - setVisible(g, false); + g.context.finalAttrs.visible = false; } } else { lastY = currentY; @@ -110,9 +84,7 @@ function overlapXY(graphics: IMarkGraphic[], delta: number, deltaMul: number) { dis = (lastX - currentX) ** 2 + (lastY - currentY) ** 2; if (dis < (itemDelta + lastR + r) ** 2) { if (!(g.context.finalAttrs as any).forceShow) { - (g as any)[OVERLAP_HIDE_KEY] = true; - - setVisible(g, false); + g.context.finalAttrs.visible = false; } } else { lastY = currentY; @@ -148,8 +120,7 @@ export const transform = ( const { direction, delta, deltaMul = 1, groupBy } = options; const handleOverlap = (graphics: IMarkGraphic[]) => { - reset(graphics); - + // afterEncode 消费本轮完整编码结果,旧防重叠结果不能覆盖当前配置的可见性。 const sortedgraphics = options.sort ? graphics.slice().sort((a, b) => { return a.context.finalAttrs.x - b.context.finalAttrs.x;