Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vrender-core",
"comment": "统一 Glyph 状态生命周期,保留旧状态覆盖顺序,补齐派生子图形同步与属性撤销,并修复内部中断状态动画污染基础属性的问题。",
"type": "patch"
}
],
"packageName": "@visactor/vrender-core"
}
25 changes: 25 additions & 0 deletions docs/refactor/state-engine/GLYPH_STATE_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Glyph 状态与派生属性契约

Glyph 与普通 Graphic 共用 `baseAttributes + resolvedStatePatch -> attribute`、同状态刷新、状态动画及清空路径。

## 定义来源

- 配置 `glyphStateProxy` 时,由 proxy 决定完整状态贡献;返回空值不回退 `glyphStates` 或 Group。
- 无 proxy、配置非空 `glyphStates` 时,读取其 `.attributes`;`subAttributes` 不自动传播。
- 旧输入按目标状态列表顺序合并,配置 `stateSort` 时先排序,后面的状态覆盖前面的状态;不修改调用方数组。
- 没有旧输入时,完全使用标准状态定义与 Group-first、priority/rank 规则。旧输入与 Group 不隐式逐状态混合。
- 动态值变化但状态名不变时,用 `setStates(names, { animate: false })` 刷新;需要动画时同时设置 `animate` 和 `animateSameStatePatchChange`。

## 派生图形

子图形和编码上下文准备好后调用 `setSubGraphicEncoder(encoder)`。注册时立即同步一次,后续回调读取已提交的 `glyph.attribute`,包括基础更新、状态恢复及动画中间帧。编码器仅修改子图形,不修改宿主属性或宿主状态。

`commitSubGraphicAttributes(child, patch, removedKeys, context)` 在一次提交中更新值并删除已经撤销的 own keys,同时维护子图形基础属性、状态、更新标记及继承关系。上层负责输出键归属;删除后可重新读取当前宿主继承值。不要直接删除 `child.attribute` 的键,也不要用写入 `undefined` 代替属性删除。

更新顺序为:宿主提交、继承绑定、派生同步、外部通知。`skipUpdateCallback` 跳过观察回调和服务通知,但不跳过派生同步;编码器应将 context 传给子图形提交。`onUpdate` 属于观察回调。

clone 保留已编码外观,不复制宿主编码器;独立使用的 clone 应自行注册。release 解除编码器、子图形继承关系并释放子图形。

## 动画中断

内部切换/取消状态停止旧动画后,由状态系统恢复静态真值,不将旧动画终值提交为基础属性。公开 `animate.stop('start' | 'end' | attrs)` 仍是显式静态提交 API。
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
application,
AttributeUpdateType,
createGroup,
createGlyph,
createLine,
createRect,
createSymbol,
Expand Down Expand Up @@ -124,6 +125,42 @@ describe('D3 pre-handoff animation runtime', () => {
jest.restoreAllMocks();
});

test('Glyph children follow actual animation frames and interrupted state restoration', () => {
const { group, ticker, graphicService } = createStageHarness('glyph-state-runtime');
const glyph = createGlyph({ width: 20, fill: 'blue' });
const child = createRect({ height: 10 });
bindGraphicService(glyph, graphicService);
bindGraphicService(child, graphicService);
glyph.setSubGraphic([child]);
glyph.setSubGraphicEncoder((g, context) =>
g.commitSubGraphicAttributes(child, { width: g.attribute.width }, undefined, context)
);
group.appendChild(glyph);
glyph.states = { selected: { width: 60 } };
glyph.stateAnimateConfig = { duration: 100, easing: 'linear' };
glyph.useStates(['selected'], true);
expect(child.attribute.width).toBe(20);
tick(ticker, 50);
expect(child.attribute.width).toBeCloseTo(40);
expect(glyph.baseAttributes.width).toBe(20);
tick(ticker, 50);
expect(child.attribute.width).toBeCloseTo(60);
glyph.clearStates(true);
tick(ticker, 50);
expect(child.attribute.width).toBeCloseTo(40);
tick(ticker, 50);
expect(child.attribute.width).toBe(20);
glyph.useStates(['selected'], true);
tick(ticker, 25);
expect(child.attribute.width).toBeCloseTo(30);
glyph.clearStates(false);
expect({ host: glyph.attribute.width, base: glyph.baseAttributes.width }).toEqual({ host: 20, base: 20 });
expect(child.attribute.width).toBe(20);
tick(ticker, 100);
expect(child.attribute.width).toBe(20);
expect(glyph.baseAttributes.width).toBe(20);
});

test('state animation updates graphic.attribute over time without polluting baseAttributes', () => {
const { group, ticker, graphicService } = createStageHarness('state-runtime');
const rect = createAnimatedRect(graphicService);
Expand Down
94 changes: 93 additions & 1 deletion packages/vrender-core/__tests__/unit/graphic/glyph-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { StateDefinitionCompiler } from '../../../src/graphic/state/state-definition-compiler';
import { createGlyph } from '../../../src/graphic/glyph';
import { createRect } from '../../../src/graphic/rect';
import { createGroup } from '../../../src/graphic/group';

describe('Glyph state', () => {
const createTestGlyph = () => {
Expand Down Expand Up @@ -115,7 +117,7 @@ describe('Glyph state', () => {
expect(glyph.normalAttrs).toEqual((glyph as any).baseAttributes);
});

test('should differ from normal graphic states by reading glyphStates instead of states', () => {
test('explicit glyphStates take precedence over standard local definitions', () => {
const { glyph } = createTestGlyph();
(glyph as any).states = {
hover: {
Expand All @@ -135,4 +137,94 @@ describe('Glyph state', () => {

expect(glyph.attribute.stroke).toBe('glyph-state');
});

test('removes state-only keys and restores the latest base attributes', () => {
const { glyph } = createTestGlyph();
glyph.glyphStates = {
selected: { attributes: { fillOpacity: 0.25, stroke: 'red' }, subAttributes: [] }
};
glyph.setStates(['selected'], false);
expect(glyph.attribute.fillOpacity).toBe(0.25);
expect(glyph.baseAttributes.fillOpacity).toBeUndefined();
glyph.setAttribute('stroke', 'orange');
expect(glyph.attribute.stroke).toBe('red');
glyph.setStates([], false);
expect(glyph.attribute.stroke).toBe('orange');
expect(glyph.attribute.fillOpacity).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(glyph.attribute, 'fillOpacity')).toBe(false);
});

test('refreshes a proxy-only state without clearing it first', () => {
const { glyph } = createTestGlyph();
let opacity = 0.2;
glyph.glyphStateProxy = () => ({ attributes: { fillOpacity: opacity }, subAttributes: [] });
glyph.setStates(['selected'], { animate: false });
opacity = 0.8;
glyph.setStates(['selected'], { animate: false });
expect(glyph.currentStates).toEqual(['selected']);
expect(glyph.effectiveStates).toEqual(['selected']);
expect(glyph.resolvedStatePatch.fillOpacity).toBe(0.8);
expect(glyph.attribute.fillOpacity).toBe(0.8);
expect(glyph.baseAttributes.fillOpacity).toBeUndefined();
});

test('preserves legacy input order and stateSort without mutating the input', () => {
const { glyph } = createTestGlyph();
glyph.glyphStates = {
a: { attributes: { stroke: 'red' }, subAttributes: [] },
z: { attributes: { stroke: 'blue' }, subAttributes: [] }
};
glyph.useStates(['z', 'a'], false);
expect(glyph.attribute.stroke).toBe('red');
glyph.useStates(['a', 'z'], false);
expect(glyph.attribute.stroke).toBe('blue');
(glyph as any).stateSort = (a: string, b: string) => b.localeCompare(a);
const states = ['a', 'z'];
const proxy = jest.fn((name: string) => glyph.glyphStates[name]);
glyph.glyphStateProxy = proxy;
glyph.setStates(states, { animate: false });
expect(glyph.attribute.stroke).toBe('red');
expect(proxy).toHaveBeenCalledWith('a', ['z', 'a']);
expect(states).toEqual(['a', 'z']);
});

test('uses Group definitions unless explicit legacy inputs own the glyph', () => {
const { glyph } = createTestGlyph();
const group = createGroup({});
group.sharedStateDefinitions = {
hover: { stroke: 'shared' },
selected: { fillOpacity: 0.4 }
};
group.add(glyph);
glyph.states = { hover: { stroke: 'local' } };
glyph.setStates(['hover'], false);
expect(glyph.attribute.stroke).toBe('shared');
glyph.glyphStates = { hover: { attributes: { stroke: 'legacy' }, subAttributes: [] } };
glyph.setStates(['hover', 'selected'], { animate: false });
expect(glyph.attribute.stroke).toBe('legacy');
expect(glyph.attribute.fillOpacity).toBeUndefined();
glyph.glyphStateProxy = () => undefined;
glyph.setStates(['hover'], { animate: false });
expect(glyph.attribute.stroke).toBe('black');
glyph.glyphStateProxy = undefined;
glyph.glyphStates = undefined;
glyph.setStates(['hover', 'selected'], { animate: false });
expect(glyph.attribute.stroke).toBe('shared');
expect(glyph.attribute.fillOpacity).toBe(0.4);
glyph.clearStates(false);
expect(glyph.registeredActiveScopes).toBeUndefined();
});
test('repeated legacy state switches reuse compiled definitions', () => {
const { glyph } = createTestGlyph();
glyph.glyphStateProxy = name => ({ attributes: { fill: name === 'hover' ? 'red' : 'blue' }, subAttributes: [] });
glyph.useStates(['hover', 'selected'], false);
const compile = jest.spyOn(StateDefinitionCompiler.prototype, 'compile');
for (let i = 0; i < 20; i++) {
glyph.useStates(['selected', 'hover'], false);
glyph.useStates(['hover', 'selected'], false);
glyph.clearStates(false);
}
expect(compile).not.toHaveBeenCalled();
compile.mockRestore();
});
});
Loading
Loading