Skip to content

Commit 99da42a

Browse files
committed
fix: preserve component animation static truth
Component animations moved persistent graphics to old or start-frame attrs before transitions. That wrote transient values into baseAttributes under the D3 static truth model. This commits final targets first, then applies visual start frames through the transient path. It covers label, axis, group transition, marker, timeline, and legend component animations. Constraint: D3 static truth is baseAttributes plus resolvedStatePatch. Constraint: Animation frames must remain transient unless explicitly committed at end. Rejected: Disable restoreStaticAttribute | hides stale static truth instead of fixing ownership. Confidence: high Scope-risk: moderate Directive: Do not use setAttributes for animation start or old-frame setup. Tested: packages/vrender-components rushx test --runInBand Tested: rush compile -t @visactor/vrender-components Tested: targeted eslint on changed component files, 0 errors with existing warnings Tested: git diff --check
1 parent 710c6a8 commit 99da42a

12 files changed

Lines changed: 513 additions & 42 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import { application, createGroup, createRect, createText } from '@visactor/vrender-core';
2+
import { DefaultTimeline, ManualTicker, registerAnimate } from '@visactor/vrender-animate';
3+
import { registerAxisAnimate } from '../../src/animation/axis-animate';
4+
import { GroupTransition } from '../../src/axis/animate/group-transition';
5+
import { graphicFadeIn } from '../../src/marker/animate/common';
6+
7+
let runtimeRegistered = false;
8+
9+
function ensureRuntime() {
10+
if (runtimeRegistered) {
11+
return;
12+
}
13+
registerAnimate();
14+
registerAxisAnimate();
15+
runtimeRegistered = true;
16+
}
17+
18+
function createGraphicServiceStub() {
19+
return {
20+
onAttributeUpdate: jest.fn(),
21+
onSetStage: jest.fn(),
22+
onRemove: jest.fn(),
23+
onAddIncremental: jest.fn(),
24+
onClearIncremental: jest.fn(),
25+
beforeUpdateAABBBounds: jest.fn(),
26+
afterUpdateAABBBounds: jest.fn(),
27+
clearAABBBounds: jest.fn(),
28+
validCheck: jest.fn(() => true)
29+
};
30+
}
31+
32+
function createStageHarness(label: string) {
33+
ensureRuntime();
34+
35+
const timeline = new DefaultTimeline();
36+
const ticker = new ManualTicker();
37+
(ticker as any).setupTickHandler();
38+
ticker.autoStop = false;
39+
ticker.addTimeline(timeline);
40+
41+
const graphicService = createGraphicServiceStub();
42+
const stage: any = {
43+
stage: null,
44+
name: label,
45+
params: { optimize: { tickRenderMode: 'effect' } },
46+
ticker,
47+
graphicService,
48+
renderNextFrame: jest.fn(),
49+
getTimeline: () => timeline
50+
};
51+
stage.stage = stage;
52+
application.graphicService = graphicService as any;
53+
54+
return {
55+
stage,
56+
ticker
57+
};
58+
}
59+
60+
function tick(ticker: ManualTicker, delta: number) {
61+
ticker.tick(delta);
62+
}
63+
64+
describe('component update animation static truth', () => {
65+
let originalGraphicService: typeof application.graphicService;
66+
67+
beforeEach(() => {
68+
originalGraphicService = application.graphicService;
69+
});
70+
71+
afterEach(() => {
72+
application.graphicService = originalGraphicService;
73+
jest.restoreAllMocks();
74+
});
75+
76+
test('keeps axis update target after animating a reused tick from old attrs', () => {
77+
const { stage, ticker } = createStageHarness('axis-update-static-truth');
78+
const oldAttrs = {
79+
x: 20,
80+
y: 8,
81+
text: 'A',
82+
fontSize: 12,
83+
opacity: 1,
84+
visible: true
85+
};
86+
const newAttrs = {
87+
...oldAttrs,
88+
x: 120,
89+
y: 40,
90+
opacity: 0.75
91+
};
92+
const tickText = createText(newAttrs);
93+
94+
tickText.setStage(stage, null as any);
95+
tickText.setFinalAttributes({ ...newAttrs });
96+
tickText.setAttributes({ ...oldAttrs });
97+
98+
tickText.applyAnimationState(
99+
['update'],
100+
[
101+
{
102+
name: 'update',
103+
animation: {
104+
selfOnly: true,
105+
type: 'axisUpdate',
106+
duration: 300,
107+
easing: 'linear',
108+
customParameters: {
109+
config: { duration: 300, easing: 'linear' },
110+
diffAttrs: {
111+
x: newAttrs.x,
112+
y: newAttrs.y,
113+
opacity: newAttrs.opacity
114+
}
115+
}
116+
}
117+
}
118+
]
119+
);
120+
121+
tick(ticker, 150);
122+
expect(tickText.attribute.x).toBeGreaterThan(oldAttrs.x);
123+
expect(tickText.attribute.x).toBeLessThan(newAttrs.x);
124+
expect(tickText.attribute.y).toBeGreaterThan(oldAttrs.y);
125+
expect(tickText.attribute.y).toBeLessThan(newAttrs.y);
126+
127+
tick(ticker, 150);
128+
expect(tickText.attribute.x).toBeCloseTo(newAttrs.x, 5);
129+
expect(tickText.attribute.y).toBeCloseTo(newAttrs.y, 5);
130+
expect(tickText.attribute.opacity).toBeCloseTo(newAttrs.opacity, 5);
131+
expect((tickText as any).baseAttributes.x).toBeCloseTo(newAttrs.x, 5);
132+
expect((tickText as any).baseAttributes.y).toBeCloseTo(newAttrs.y, 5);
133+
expect((tickText as any).baseAttributes.opacity).toBeCloseTo(newAttrs.opacity, 5);
134+
expect((tickText as any).getFinalAttribute().x).toBeCloseTo(newAttrs.x, 5);
135+
expect((tickText as any).getFinalAttribute().y).toBeCloseTo(newAttrs.y, 5);
136+
expect((tickText as any).getFinalAttribute().opacity).toBeCloseTo(newAttrs.opacity, 5);
137+
});
138+
139+
test('keeps axis enter target when starting from the previous scale point', () => {
140+
const { stage, ticker } = createStageHarness('axis-enter-static-truth');
141+
const startPoint = { x: 15, y: 12 };
142+
const newAttrs = {
143+
x: 115,
144+
y: 52,
145+
text: 'A',
146+
fontSize: 12,
147+
opacity: 1,
148+
visible: true
149+
};
150+
const tickText = createText(newAttrs);
151+
152+
tickText.data = { rawValue: 'A' };
153+
tickText.setStage(stage, null as any);
154+
tickText.applyAnimationState(
155+
['enter'],
156+
[
157+
{
158+
name: 'enter',
159+
animation: {
160+
selfOnly: true,
161+
type: 'axisEnter',
162+
duration: 300,
163+
easing: 'linear',
164+
customParameters: {
165+
config: { type: 'to', to: {} },
166+
lastScale: { scale: () => 0 },
167+
getTickCoord: () => startPoint
168+
}
169+
}
170+
}
171+
]
172+
);
173+
174+
tick(ticker, 150);
175+
expect(tickText.attribute.x).toBeGreaterThan(startPoint.x);
176+
expect(tickText.attribute.x).toBeLessThan(newAttrs.x);
177+
expect(tickText.attribute.y).toBeGreaterThan(startPoint.y);
178+
expect(tickText.attribute.y).toBeLessThan(newAttrs.y);
179+
180+
tick(ticker, 150);
181+
expect(tickText.attribute.x).toBeCloseTo(newAttrs.x, 5);
182+
expect(tickText.attribute.y).toBeCloseTo(newAttrs.y, 5);
183+
expect((tickText as any).baseAttributes.x).toBeCloseTo(newAttrs.x, 5);
184+
expect((tickText as any).baseAttributes.y).toBeCloseTo(newAttrs.y, 5);
185+
expect((tickText as any).getFinalAttribute().x).toBeCloseTo(newAttrs.x, 5);
186+
expect((tickText as any).getFinalAttribute().y).toBeCloseTo(newAttrs.y, 5);
187+
});
188+
189+
test('keeps group transition target after animating a reused child from old attrs', () => {
190+
const { stage, ticker } = createStageHarness('group-transition-static-truth');
191+
const oldAttrs = {
192+
x: 10,
193+
y: 15,
194+
text: 'A',
195+
fontSize: 12,
196+
opacity: 1,
197+
visible: true
198+
};
199+
const newAttrs = {
200+
...oldAttrs,
201+
x: 90,
202+
y: 55,
203+
opacity: 0.6
204+
};
205+
const group = createGroup({});
206+
const currentText = createText(newAttrs);
207+
const oldText = createText(oldAttrs);
208+
209+
currentText.id = 'tick-1';
210+
oldText.id = 'tick-1';
211+
group.setStage(stage, null as any);
212+
group.appendChild(currentText);
213+
(group as any).getInnerView = () => group;
214+
(group as any).getPrevInnerView = () => ({
215+
'tick-1': oldText
216+
});
217+
218+
group.animate().play(new GroupTransition(null, null, 300, 'linear'));
219+
220+
tick(ticker, 1);
221+
tick(ticker, 150);
222+
expect(currentText.attribute.x).toBeGreaterThan(oldAttrs.x);
223+
expect(currentText.attribute.x).toBeLessThan(newAttrs.x);
224+
expect(currentText.attribute.y).toBeGreaterThan(oldAttrs.y);
225+
expect(currentText.attribute.y).toBeLessThan(newAttrs.y);
226+
227+
tick(ticker, 150);
228+
expect(currentText.attribute.x).toBeCloseTo(newAttrs.x, 5);
229+
expect(currentText.attribute.y).toBeCloseTo(newAttrs.y, 5);
230+
expect(currentText.attribute.opacity).toBeCloseTo(newAttrs.opacity, 5);
231+
expect((currentText as any).baseAttributes.x).toBeCloseTo(newAttrs.x, 5);
232+
expect((currentText as any).baseAttributes.y).toBeCloseTo(newAttrs.y, 5);
233+
expect((currentText as any).baseAttributes.opacity).toBeCloseTo(newAttrs.opacity, 5);
234+
expect((currentText as any).getFinalAttribute().x).toBeCloseTo(newAttrs.x, 5);
235+
expect((currentText as any).getFinalAttribute().y).toBeCloseTo(newAttrs.y, 5);
236+
expect((currentText as any).getFinalAttribute().opacity).toBeCloseTo(newAttrs.opacity, 5);
237+
});
238+
239+
test('keeps marker fade-in opacity target after appear animation ends', () => {
240+
const { stage, ticker } = createStageHarness('marker-fade-in-static-truth');
241+
const finalAttrs = {
242+
x: 0,
243+
y: 0,
244+
width: 20,
245+
height: 20,
246+
fillOpacity: 0.4,
247+
strokeOpacity: 0.7,
248+
visible: true
249+
};
250+
const rect = createRect(finalAttrs);
251+
252+
rect.setStage(stage, null as any);
253+
graphicFadeIn(rect, 0, 300, 'linear');
254+
255+
tick(ticker, 150);
256+
expect(rect.attribute.fillOpacity).toBeGreaterThan(0);
257+
expect(rect.attribute.fillOpacity).toBeLessThan(finalAttrs.fillOpacity);
258+
expect(rect.attribute.strokeOpacity).toBeGreaterThan(0);
259+
expect(rect.attribute.strokeOpacity).toBeLessThan(finalAttrs.strokeOpacity);
260+
261+
tick(ticker, 150);
262+
expect(rect.attribute.fillOpacity).toBeCloseTo(finalAttrs.fillOpacity, 5);
263+
expect(rect.attribute.strokeOpacity).toBeCloseTo(finalAttrs.strokeOpacity, 5);
264+
expect((rect as any).baseAttributes.fillOpacity).toBeCloseTo(finalAttrs.fillOpacity, 5);
265+
expect((rect as any).baseAttributes.strokeOpacity).toBeCloseTo(finalAttrs.strokeOpacity, 5);
266+
expect((rect as any).getFinalAttribute().fillOpacity).toBeCloseTo(finalAttrs.fillOpacity, 5);
267+
expect((rect as any).getFinalAttribute().strokeOpacity).toBeCloseTo(finalAttrs.strokeOpacity, 5);
268+
});
269+
});

0 commit comments

Comments
 (0)