diff --git a/VRender-Animate-Guide.md b/VRender-Animate-Guide.md new file mode 100644 index 000000000..5007635d0 --- /dev/null +++ b/VRender-Animate-Guide.md @@ -0,0 +1,531 @@ +# VRender-Animate 使用指南 + +VRender-Animate 是 VRender 图形引擎的动画系统,提供强大的动画能力,支持属性动画、自定义动画、状态管理等功能。 + +## 升级变更 + +### animate 注册机制 + +0.x 的 VRender 默认自带 animate,现在需要手动注册。 + +```typescript +import { registerAnimate, registerCustomAnimate } from '@visactor/vrender-animate'; + +// 注册基本的动画接口 +registerAnimate(); +// 注册自定义动画 +registerCustomAnimate(); +``` + +### Ticker 接口变更 + +0.x 的 VRender 中 Ticker 和 Stage 不对应,所以无需传入 Stage,现在需要传入 Stage。并且 Stage 和 Ticker 一一对应 + +```typescript +import { DefaultTicker } from '@visactor/vrender-animate'; +const ticker = new DefaultTicker(stage); +``` + +## 1. Timeline、Ticker、Animate 核心架构 + +### 1.1 Timeline(时间线管理器) + +Timeline 是动画系统的时间管理核心,负责统一管理所有动画的时间推进。 + +**核心特性:** + +- 基于链表的动画节点管理,支持高效的增删操作 +- 支持播放速度控制 +- 支持暂停/恢复功能 +- 自动计算总时长 +- 事件系统支持 + +**使用示例:** + +```typescript +import { DefaultTimeline } from '@visactor/vrender-animate'; + +// 创建时间线 +const timeline = new DefaultTimeline(); + +// 设置播放速度 +timeline.setPlaySpeed(2.0); // 2倍速播放 + +// 暂停和恢复 +timeline.pause(); +timeline.resume(); + +// 监听动画结束事件 +timeline.on('animationEnd', () => { + console.log('所有动画已完成'); +}); + +// 获取时间线状态 +console.log('动画数量:', timeline.animateCount); +console.log('播放状态:', timeline.getPlayState()); // 'playing' | 'paused' | 'stopped' +console.log('总时长:', timeline.getTotalDuration()); +``` + +**Timeline 主要方法:** + +- `addAnimate(animate)` - 添加动画到时间线 +- `removeAnimate(animate, release)` - 从时间线移除动画 +- `tick(delta)` - 推进时间线(通常由 Ticker 调用) +- `clear()` - 清空所有动画 +- `forEachAccessAnimate(callback)` - 遍历所有可执行动画 + +### 1.2 Ticker(时钟驱动器) + +Ticker 负责驱动时间的推进,有两种实现: + +#### DefaultTicker(默认时钟) + +基于 requestAnimationFrame 的实时时钟,适用于正常的动画播放场景。 + +```typescript +import { DefaultTicker } from '@visactor/vrender-animate'; + +const ticker = new DefaultTicker(stage); + +// 添加时间线 +ticker.addTimeline(timeline); + +// 设置帧率 +ticker.setFPS(60); +ticker.setInterval(16); // 或直接设置间隔(毫秒) + +// 启动时钟 +ticker.start(); + +// 暂停和恢复 +ticker.pause(); +ticker.resume(); + +// 停止时钟 +ticker.stop(); +``` + +**DefaultTicker 特性:** + +- 自动启停:当没有动画时自动停止 +- 帧率控制:支持设置 FPS 或间隔时间 +- 随机扰动:避免所有 tick 发生在同一帧 + +#### ManualTicker(手动时钟) + +手动控制的时钟,适用于测试、录制或精确控制的场景。 + +```typescript +import { ManualTicker } from '@visactor/vrender-animate'; + +const manualTicker = new ManualTicker(stage); +manualTicker.addTimeline(timeline); + +// 手动推进时间 +manualTicker.tick(16.67); // 推进一帧的时间 + +// 跳转到指定时间点 +manualTicker.tickTo(1000); // 跳转到1秒时间点 +manualTicker.tickAt(5000); // 跳转到5s时间点 + +// 获取当前时间 +console.log('当前时间:', manualTicker.getTime()); +``` + +### 1.3 Animate(动画实例) + +Animate 是单个动画的执行实体,支持链式调用的动画定义。 + +**基本使用:** + +```typescript +import { Animate } from '@visactor/vrender-animate'; + +// 创建动画 +const animate = new Animate('moveAnimation', timeline); + +// 绑定目标图形(真正使用时请用myRect.animate()) +animate.bind(myRect); + +if (!myRect.animates) { + myRect.animates = new Map(); +} +myRect.animates.set(animate.id, animate); + +// 定义动画序列 +animate + .to({ x: 100, y: 50 }, 1000, 'easeInOut') // 移动动画 + .wait(500) // 等待500ms + .to({ fill: 'red' }, 800, 'linear') // 颜色变化 + .from({ opacity: 0.5 }, 300, 'easeOut'); // 从指定状态开始 + +// 设置回调 +animate.onStart(() => console.log('动画开始')); +animate.onFrame((step, ratio) => console.log('动画进度:', ratio)); +animate.onEnd(() => console.log('动画结束')); +animate.onRemove(() => console.log('动画被移除')); +``` + +**动画控制方法:** + +```typescript +// 播放控制 +animate.play(); // 开始播放 +animate.pause(); // 暂停 +animate.resume(); // 恢复 +animate.stop('end'); // 停止并跳到结束状态 +animate.stop('start'); // 停止并回到开始状态 +animate.stop({ x: 50, y: 25 }); // 停止并设置为指定状态 + +// 时间控制 +animate.startAt(1000); // 延迟1秒开始 +animate.getDuration(); // 获取总时长 +animate.getStartTime(); // 获取开始时间 + +// 属性控制 +animate.preventAttr('x'); // 阻止x属性被后续动画修改 +animate.preventAttrs(['x', 'y']); // 阻止多个属性 +animate.validAttr('x'); // 检查属性是否可以被修改 +``` + +**高级功能:** + +```typescript +// 循环动画 +animate.loop(3); // 循环3次 +animate.loop(Infinity); // 无限循环 + +// 回弹动画 +animate.bounce(true); // 启用回弹效果 + +// 动画链式依赖 +const anim1 = animate1.to({ x: 100 }, 1000); +const anim2 = animate2.after(anim1).to({ y: 100 }, 1000); // anim2在anim1后执行 +const anim3 = animate3.afterAll([anim1, anim2]).to({ opacity: 0 }, 500); // anim3在所有动画后执行 + +// 并行动画 +const anim4 = animate4.parallel(anim1).to({ opacity: 0.5 }, 1000); // anim4与anim1并行 +``` + +**动画步骤(Step)详解:** + +```typescript +// Step是Animate内部的执行单元 +// 每个to()、wait()、from()调用都会创建一个Step + +// Step的主要属性 +interface IStep { + type: 'to' | 'wait' | 'from'; + duration: number; + props: Record; + easing: EasingTypeFunc; + + // 链表结构 + prev?: IStep; + next?: IStep; + + // 执行方法 + onStart(): void; + update(end: boolean, ratio: number, out: Record): void; + onEnd(): void; +} +``` + +--- + +## 2. AnimationState 使用及 AnimationTransition 管理 + +### 2.1 动画状态概念 + +AnimationState 为图形元素提供状态驱动的动画系统,支持预定义状态之间的平滑过渡。 + +**状态配置接口:** + +```typescript +interface IAnimationState { + name: string; // 状态名称 + animation: IAnimationConfig; // 动画配置 +} + +interface IAnimationConfig { + from?: Record; // 起始属性 + to?: Record; // 目标属性 + duration?: number; // 持续时间 + delay?: number; // 延迟时间 + easing?: EasingType; // 缓动函数 + loop?: boolean | number; // 循环设置 + // ... 更多配置选项 +} +``` + +### 2.2 注册和使用动画状态 + +```typescript +import { AnimationStates } from '@visactor/vrender-animate'; + +// 1. 注册基础动画状态 +myRect.registerAnimationState({ + name: AnimationStates.APPEAR, + animation: { + from: { opacity: 0, scaleX: 0, scaleY: 0 }, + to: { opacity: 1, scaleX: 1, scaleY: 1 }, + duration: 800, + easing: 'bounceOut' + } +}); + +myRect.registerAnimationState({ + name: AnimationStates.HIGHLIGHT, + animation: { + to: { fill: 'yellow', lineWidth: 3 }, + duration: 300, + easing: 'easeInOut' + } +}); + +// 2. 注册自定义状态 +myRect.registerAnimationState({ + name: 'customGlow', + animation: { + to: { + fill: 'gold', + stroke: 'orange', + shadowBlur: 10, + shadowColor: 'yellow' + }, + duration: 500, + easing: 'easeOutQuad' + } +}); + +// 3. 应用状态 +el.applyAnimationState( + ['update'], + [ + { + name: 'update', + animation: { + selfOnly: true, + ...animationConfig.update, + type: 'axisUpdate', + customParameters: { + config: animationConfig.update, + diffAttrs, + lastScale + } + } + } + ] +); +``` + +### 2.3 AnimationTransition 转换规则 + +AnimationTransitionRegistry 管理状态之间的转换规则,决定哪些状态可以互相切换。 + +**内置转换规则:** + +```typescript +// appear动画可以被任何动画覆盖(除了disappear、exit会停止appear) +'appear' -> '*' // 允许转换,不停止原动画 +'appear' -> 'appear' // 不允许转换(避免重复) +'appear' -> 'disappear' // 允许转换,停止原动画 +'appear' -> 'exit' // 允许转换,停止原动画 + +// 循环动画(normal)的转换规则 +'normal' -> '*' // 允许转换,不停止原动画 +'normal' -> 'normal' // 不允许转换 +'normal' -> 'disappear' // 允许转换,停止原动画 +'normal' -> 'exit' // 允许转换,停止原动画 + +// 退出动画不能被覆盖(除了disappear) +'exit' -> '*' // 不允许转换 +'exit' -> 'disappear' // 允许转换,停止原动画 +'exit' -> 'enter' // 允许转换,停止原动画 +'exit' -> 'exit' // 不允许转换 + +// update和state动画的转换规则 +'update' -> '*' // 允许转换,不停止原动画 +'update' -> 'disappear' // 允许转换,停止原动画 +'update' -> 'exit' // 允许转换,停止原动画 + +'state' -> '*' // 允许转换,不停止原动画 +'state' -> 'disappear' // 允许转换,停止原动画 +'state' -> 'exit' // 允许转换,停止原动画 +``` + +**自定义转换规则:** + +```typescript +import { AnimationTransitionRegistry } from '@visactor/vrender-animate'; + +const registry = AnimationTransitionRegistry.getInstance(); + +// 注册自定义转换规则 +registry.registerTransition('customState1', 'customState2', (graphic, fromState) => { + return { + allowTransition: true, // 是否允许转换 + stopOriginalTransition: true // 是否停止原动画 + }; +}); + +// 通配符规则 +registry.registerTransition('*', 'emergency', (graphic, fromState) => { + // 紧急状态可以打断任何动画 + return { + allowTransition: true, + stopOriginalTransition: true + }; +}); +``` + +**转换函数的参数:** + +```typescript +type TransitionFunction = ( + graphic: IGraphic, // 目标图形对象 + fromState: string // 源状态名称 +) => { + allowTransition: boolean; // 是否允许进行状态转换 + stopOriginalTransition: boolean; // 是否停止当前正在执行的动画 +}; +``` + +--- + +## 3. 动画注册机制(registerAnimate) + +### 3.1 注册过程 + +```typescript +import { registerAnimate } from '@visactor/vrender-animate'; + +// 全局注册动画功能到Graphic原型 +registerAnimate(); +``` + +**注册效果:** + +- 所有继承自 Graphic 的图形对象都将具备动画能力 + +### 3.2 扩展后的图形功能 + +注册后,所有图形对象将具备以下动画能力: + +#### 状态管理方法(来自 GraphicStateExtension) + +```typescript +// 状态注册 +myRect.registerAnimationState(state: IAnimationState): this; + +// 状态应用 +myRect.applyAnimationState( + state: string[], + animationConfig: (IAnimationState | IAnimationState[])[], + callback?: (empty?: boolean) => void +): this; + +// 预定义状态方法 +myRect.applyAppearState(config: IAnimationConfig, callback?: () => void): this; +myRect.applyDisappearState(config: IAnimationConfig, callback?: () => void): this; +myRect.applyUpdateState(config: IAnimationConfig, callback?: () => void): this; +myRect.applyHighlightState(config: IAnimationConfig, callback?: () => void): this; +myRect.applyUnhighlightState(config: IAnimationConfig, callback?: () => void): this; + +// 状态控制 +myRect.stopAnimationState(state: string, type?: 'start' | 'end' | Record): this; +myRect.clearAnimationStates(): this; +``` + +#### 动画执行方法(来自 AnimateExtension) + +```typescript +// 基础动画创建 +myRect.animate(params?: IGraphicAnimateParams): IAnimate; + +// 动画执行器 +myRect.executeAnimation(config: IAnimationConfig): this; +myRect.executeAnimations(configs: IAnimationConfig[]): this; + +// 时间线和时钟创建 +myRect.createTimeline(): DefaultTimeline; +myRect.createTicker(stage: any): DefaultTicker; + +// 属性管理 +myRect.getAttributes(final?: boolean): Record; +myRect.setFinalAttributes(finalAttribute: Record): void; +myRect.initFinalAttributes(finalAttribute: Record): void; +myRect.getGraphicAttribute(key: string, prev?: boolean): any; +``` + +### 3.3 完整使用示例 + +```typescript +import { registerAnimate, AnimationStates, DefaultTimeline, DefaultTicker } from '@visactor/vrender-animate'; +import { Rect, Circle, Stage } from '@visactor/vrender-core'; + +// 1. 注册动画系统 +registerAnimate(); + +// 2. 创建舞台和图形 +const stage = new Stage({ + container: 'main', + width: 800, + height: 600 +}); + +const rect = new Rect({ + x: 100, + y: 100, + width: 50, + height: 50, + fill: 'blue' +}); + +stage.defaultLayer.add(rect); + +// 3. 使用链式动画API +rect + .animate() + .to({ x: 200, fill: 'red' }, 1000, 'linear') + .wait(500) + .to({ y: 200, opacity: 0.5 }, 800, 'linear') + .onEnd(() => console.log('矩形动画完成')); + +// 4. 事件驱动的动画 +rect.addEventListener('pointerenter', () => { + rect.applyAnimationState( + ['hover'], + [ + { + name: 'hover', + animation: { + to: { fill: 'yellow', scaleX: 1.2, scaleY: 1.2 }, + duration: 200, + easing: 'easeOutQuad' + } + } + ] + ); +}); + +rect.addEventListener('pointertap', () => { + rect.applyAnimationState( + ['selected'], + [ + { + name: 'selected', + animation: { + to: { + stroke: 'orange', + lineWidth: 3, + shadowBlur: 5, + shadowColor: 'orange' + }, + duration: 300 + } + } + ] + ); +}); +``` diff --git a/abc.html b/abc.html index f4a45e536..21fe282ad 100644 --- a/abc.html +++ b/abc.html @@ -6,8 +6,66 @@ Document - + + diff --git a/common/changes/@visactor/vrender-components/chore-delete-default-value_2025-05-21-09-51.json b/common/changes/@visactor/vrender-components/chore-delete-default-value_2025-05-21-09-51.json new file mode 100644 index 000000000..0decaf859 --- /dev/null +++ b/common/changes/@visactor/vrender-components/chore-delete-default-value_2025-05-21-09-51.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-components", + "comment": "fix: delete-default-value", + "type": "none" + } + ], + "packageName": "@visactor/vrender-components" +} \ No newline at end of file diff --git a/common/changes/@visactor/vrender-components/feat-remove-marker-type_2025-05-16-08-24.json b/common/changes/@visactor/vrender-components/feat-remove-marker-type_2025-05-16-08-24.json new file mode 100644 index 000000000..c0dea2719 --- /dev/null +++ b/common/changes/@visactor/vrender-components/feat-remove-marker-type_2025-05-16-08-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-components", + "comment": "feat: remove marker type. close @VisActor/VChart#3782", + "type": "none" + } + ], + "packageName": "@visactor/vrender-components" +} \ No newline at end of file diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a00bd3b3f..5de8acfe8 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: workspace:0.22.11 version: link:../packages/vrender '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 axios: specifier: ^1.4.0 version: 1.8.4 @@ -98,8 +98,8 @@ importers: specifier: workspace:0.22.11 version: link:../vrender '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 react-reconciler: specifier: ^0.29.0 version: 0.29.2(react@18.3.1) @@ -159,8 +159,8 @@ importers: specifier: workspace:0.22.11 version: link:../vrender '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 react-reconciler: specifier: ^0.29.0 version: 0.29.2(react@18.3.1) @@ -210,6 +210,9 @@ importers: ../../packages/vrender: dependencies: + '@visactor/vrender-animate': + specifier: workspace:0.22.11 + version: link:../vrender-animate '@visactor/vrender-core': specifier: workspace:0.22.11 version: link:../vrender-core @@ -239,8 +242,8 @@ importers: specifier: ^18.0.0 version: 18.3.5(@types/react@18.3.20) '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 '@vitejs/plugin-react': specifier: 3.1.0 version: 3.1.0(vite@3.2.6(@types/node@22.13.17)(less@4.1.3)(terser@5.17.1)) @@ -258,7 +261,7 @@ importers: version: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) jest-electron: specifier: ^0.1.12 - version: 0.1.12(jest@26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))) + version: 0.1.12(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))) jest-extended: specifier: ^1.2.1 version: 1.2.1 @@ -270,7 +273,65 @@ importers: version: 18.3.1(react@18.3.1) ts-jest: specifier: ^26.0.0 - version: 26.5.6(jest@26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)))(typescript@4.9.5) + version: 26.5.6(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)))(typescript@4.9.5) + typescript: + specifier: 4.9.5 + version: 4.9.5 + vite: + specifier: 3.2.6 + version: 3.2.6(@types/node@22.13.17)(less@4.1.3)(terser@5.17.1) + + ../../packages/vrender-animate: + dependencies: + '@visactor/vrender-core': + specifier: workspace:0.22.11 + version: link:../vrender-core + '@visactor/vutils': + specifier: 1.0.4 + version: 1.0.4 + devDependencies: + '@internal/bundler': + specifier: workspace:* + version: link:../../tools/bundler + '@internal/eslint-config': + specifier: workspace:* + version: link:../../share/eslint-config + '@internal/ts-config': + specifier: workspace:* + version: link:../../share/ts-config + '@rushstack/eslint-patch': + specifier: ~1.1.4 + version: 1.1.4 + '@types/node-fetch': + specifier: 2.6.4 + version: 2.6.4 + '@types/react': + specifier: ^18.0.0 + version: 18.3.20 + '@types/react-dom': + specifier: ^18.0.0 + version: 18.3.5(@types/react@18.3.20) + '@vitejs/plugin-react': + specifier: 3.1.0 + version: 3.1.0(vite@3.2.6(@types/node@22.13.17)(less@4.1.3)(terser@5.17.1)) + canvas: + specifier: 2.11.2 + version: 2.11.2 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + eslint: + specifier: ~8.18.0 + version: 8.18.0 + node-fetch: + specifier: 2.6.6 + version: 2.6.6 + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) typescript: specifier: 4.9.5 version: 4.9.5 @@ -280,6 +341,9 @@ importers: ../../packages/vrender-components: dependencies: + '@visactor/vrender-animate': + specifier: workspace:0.22.11 + version: link:../vrender-animate '@visactor/vrender-core': specifier: workspace:0.22.11 version: link:../vrender-core @@ -287,11 +351,11 @@ importers: specifier: workspace:0.22.11 version: link:../vrender-kits '@visactor/vscale': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 devDependencies: '@internal/bundler': specifier: workspace:* @@ -316,7 +380,7 @@ importers: version: 8.18.0 jest: specifier: ^26.0.0 - version: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + version: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) jest-electron: specifier: ^0.1.12 version: 0.1.12(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))) @@ -336,8 +400,8 @@ importers: ../../packages/vrender-core: dependencies: '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 color-convert: specifier: 2.0.1 version: 2.0.1 @@ -374,7 +438,7 @@ importers: version: 8.18.0 jest: specifier: ^26.0.0 - version: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + version: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) jest-electron: specifier: ^0.1.12 version: 0.1.12(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))) @@ -406,8 +470,8 @@ importers: specifier: workspace:0.22.11 version: link:../vrender-core '@visactor/vutils': - specifier: ~0.19.5 - version: 0.19.5 + specifier: 1.0.4 + version: 1.0.4 gifuct-js: specifier: 2.1.2 version: 2.1.2 @@ -509,7 +573,7 @@ importers: version: 26.0.24 jest: specifier: ^26.0.0 - version: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + version: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) typescript: specifier: 4.9.5 version: 4.9.5 @@ -2128,8 +2192,8 @@ packages: '@visactor/vscale@0.15.14': resolution: {integrity: sha512-ttGdvS49APcO23WFfsbq9zyQ1y767LjqchPPL78KOrd4UjhYQXRCdeqz7K4A57e333R37oLnPfSuIVFz9qJGYw==} - '@visactor/vscale@0.19.5': - resolution: {integrity: sha512-KiXrn184Fh5aJBl/IcOK5irkJr0jwrpNjLPJ/0wfepYSycyEF5z7lDdfnvoJFEcMoljYjDQVg6Fxg9Adozc6vg==} + '@visactor/vscale@1.0.4': + resolution: {integrity: sha512-mXuX0gbQ5dmsU+dOfrDfFT45ijTZrFh1wYeIY44cdMhFo4v+tVdeihN0F+3CEI7oSZiZENbpJ7dXvxnu04xG/g==} '@visactor/vutils@0.13.3': resolution: {integrity: sha512-lCFiuUHwqz/0RCvIYa79ycduCLAILWaXddPOjxEd3VRX9CCoWMUmRtM3gF5JxtK2pK6Mu7hW7LaMSuWFw+0Kkw==} @@ -2137,8 +2201,8 @@ packages: '@visactor/vutils@0.15.14': resolution: {integrity: sha512-mZuJhXdDZqq5arqc/LfEmWOY6l7ErK1MurO8bR3vESxeCaQ18pN36iit15K2IMQVJuKZPnZ2ksw8+a1irXi/8A==} - '@visactor/vutils@0.19.5': - resolution: {integrity: sha512-sSU9Gnmnej7LgkENKkdmVqx1I3ZYVugDbGP0KEzgo8j+txAwrthEQTSeFwZcVS0iYrAvSzpmAVuN0/NRo6+vpg==} + '@visactor/vutils@1.0.4': + resolution: {integrity: sha512-GE149SM5WAc9DMNV7bGtPD4xHP68vbHMRuxGPJ3ndzAGLC/KuXpClteMw6bTY1fRX1vDLY/tQ/GVthgeOx4kDw==} '@vitejs/plugin-react@3.1.0': resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==} @@ -7871,43 +7935,6 @@ snapshots: - ts-node - utf-8-validate - '@jest/core@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))': - dependencies: - '@jest/console': 26.6.2 - '@jest/reporters': 26.6.2 - '@jest/test-result': 26.6.2 - '@jest/transform': 26.6.2 - '@jest/types': 26.6.2 - '@types/node': 22.13.17 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 26.6.2 - jest-config: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-haste-map: 26.6.2 - jest-message-util: 26.6.2 - jest-regex-util: 26.0.0 - jest-resolve: 26.6.2 - jest-resolve-dependencies: 26.6.3 - jest-runner: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-runtime: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-snapshot: 26.6.2 - jest-util: 26.6.2 - jest-validate: 26.6.2 - jest-watcher: 26.6.2 - micromatch: 4.0.8 - p-each-series: 2.2.0 - rimraf: 3.0.2 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - '@jest/environment@24.9.0': dependencies: '@jest/fake-timers': 24.9.0 @@ -8024,20 +8051,6 @@ snapshots: - ts-node - utf-8-validate - '@jest/test-sequencer@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))': - dependencies: - '@jest/test-result': 26.6.2 - graceful-fs: 4.2.11 - jest-haste-map: 26.6.2 - jest-runner: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-runtime: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - '@jest/transform@24.9.0': dependencies: '@babel/core': 7.20.12 @@ -8775,9 +8788,9 @@ snapshots: dependencies: '@visactor/vutils': 0.15.14 - '@visactor/vscale@0.19.5': + '@visactor/vscale@1.0.4': dependencies: - '@visactor/vutils': 0.19.5 + '@visactor/vutils': 1.0.4 '@visactor/vutils@0.13.3': dependencies: @@ -8791,7 +8804,7 @@ snapshots: '@turf/invariant': 6.5.0 eventemitter3: 4.0.7 - '@visactor/vutils@0.19.5': + '@visactor/vutils@1.0.4': dependencies: '@turf/helpers': 6.5.0 '@turf/invariant': 6.5.0 @@ -11386,28 +11399,6 @@ snapshots: - ts-node - utf-8-validate - jest-cli@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@jest/core': 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - '@jest/test-result': 26.6.2 - '@jest/types': 26.6.2 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - import-local: 3.2.0 - is-ci: 2.0.0 - jest-config: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-util: 26.6.2 - jest-validate: 26.6.2 - prompts: 2.4.2 - yargs: 15.4.1 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - jest-config@24.9.0: dependencies: '@babel/core': 7.20.12 @@ -11443,35 +11434,7 @@ snapshots: jest-environment-jsdom: 26.6.2(canvas@2.11.2) jest-environment-node: 26.6.2 jest-get-type: 26.3.0 - jest-jasmine2: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-regex-util: 26.0.0 - jest-resolve: 26.6.2 - jest-util: 26.6.2 - jest-validate: 26.6.2 - micromatch: 4.0.8 - pretty-format: 26.6.2 - optionalDependencies: - ts-node: 10.9.0(@types/node@22.13.17)(typescript@4.9.5) - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - utf-8-validate - - jest-config@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@babel/core': 7.20.12 - '@jest/test-sequencer': 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - '@jest/types': 26.6.2 - babel-jest: 26.6.3(@babel/core@7.20.12) - chalk: 4.1.2 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-environment-jsdom: 26.6.2(canvas@2.11.2) - jest-environment-node: 26.6.2 - jest-get-type: 26.3.0 - jest-jasmine2: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + jest-jasmine2: 26.6.3 jest-regex-util: 26.0.0 jest-resolve: 26.6.2 jest-util: 26.6.2 @@ -11531,26 +11494,10 @@ snapshots: jest-util: 26.6.2 pretty-format: 26.6.2 - jest-electron@0.1.12(jest@26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))): - dependencies: - electron: 11.5.0 - jest: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-haste-map: 24.9.0 - jest-message-util: 24.9.0 - jest-mock: 24.9.0 - jest-resolve: 24.9.0 - jest-runner: 24.9.0 - jest-runtime: 24.9.0 - jest-util: 24.9.0 - throat: 5.0.0 - tslib: 1.14.1 - transitivePeerDependencies: - - supports-color - jest-electron@0.1.12(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5))): dependencies: electron: 11.5.0 - jest: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + jest: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) jest-haste-map: 24.9.0 jest-message-util: 24.9.0 jest-mock: 24.9.0 @@ -11676,7 +11623,7 @@ snapshots: transitivePeerDependencies: - supports-color - jest-jasmine2@26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): + jest-jasmine2@26.6.3: dependencies: '@babel/traverse': 7.27.0 '@jest/environment': 26.6.2 @@ -11697,38 +11644,7 @@ snapshots: pretty-format: 26.6.2 throat: 5.0.0 transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - - jest-jasmine2@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@babel/traverse': 7.27.0 - '@jest/environment': 26.6.2 - '@jest/source-map': 26.6.2 - '@jest/test-result': 26.6.2 - '@jest/types': 26.6.2 - '@types/node': 22.13.17 - chalk: 4.1.2 - co: 4.6.0 - expect: 26.6.2 - is-generator-fn: 2.1.0 - jest-each: 26.6.2 - jest-matcher-utils: 26.6.2 - jest-message-util: 26.6.2 - jest-runtime: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-snapshot: 26.6.2 - jest-util: 26.6.2 - pretty-format: 26.6.2 - throat: 5.0.0 - transitivePeerDependencies: - - bufferutil - - canvas - supports-color - - ts-node - - utf-8-validate jest-leak-detector@24.9.0: dependencies: @@ -11883,35 +11799,6 @@ snapshots: - ts-node - utf-8-validate - jest-runner@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@jest/console': 26.6.2 - '@jest/environment': 26.6.2 - '@jest/test-result': 26.6.2 - '@jest/types': 26.6.2 - '@types/node': 22.13.17 - chalk: 4.1.2 - emittery: 0.7.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-docblock: 26.0.0 - jest-haste-map: 26.6.2 - jest-leak-detector: 26.6.2 - jest-message-util: 26.6.2 - jest-resolve: 26.6.2 - jest-runtime: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-util: 26.6.2 - jest-worker: 26.6.2 - source-map-support: 0.5.21 - throat: 5.0.0 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - jest-runtime@24.9.0: dependencies: '@jest/console': 24.9.0 @@ -11976,42 +11863,6 @@ snapshots: - ts-node - utf-8-validate - jest-runtime@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@jest/console': 26.6.2 - '@jest/environment': 26.6.2 - '@jest/fake-timers': 26.6.2 - '@jest/globals': 26.6.2 - '@jest/source-map': 26.6.2 - '@jest/test-result': 26.6.2 - '@jest/transform': 26.6.2 - '@jest/types': 26.6.2 - '@types/yargs': 15.0.19 - chalk: 4.1.2 - cjs-module-lexer: 0.6.0 - collect-v8-coverage: 1.0.2 - exit: 0.1.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-config: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-haste-map: 26.6.2 - jest-message-util: 26.6.2 - jest-mock: 26.6.2 - jest-regex-util: 26.0.0 - jest-resolve: 26.6.2 - jest-snapshot: 26.6.2 - jest-util: 26.6.2 - jest-validate: 26.6.2 - slash: 3.0.0 - strip-bom: 4.0.0 - yargs: 15.4.1 - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - jest-serializer@24.9.0: {} jest-serializer@26.6.2: @@ -12129,18 +11980,6 @@ snapshots: - ts-node - utf-8-validate - jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)): - dependencies: - '@jest/core': 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - import-local: 3.2.0 - jest-cli: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - transitivePeerDependencies: - - bufferutil - - canvas - - supports-color - - ts-node - - utf-8-validate - js-binary-schema-parser@2.0.3: {} js-string-escape@1.0.1: {} @@ -14081,27 +13920,12 @@ snapshots: dependencies: punycode: 2.3.1 - ts-jest@26.5.6(jest@26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)))(typescript@4.9.5): - dependencies: - bs-logger: 0.2.6 - buffer-from: 1.1.2 - fast-json-stable-stringify: 2.1.0 - jest: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) - jest-util: 26.6.2 - json5: 2.2.3 - lodash: 4.17.21 - make-error: 1.3.6 - mkdirp: 1.0.4 - semver: 7.3.4 - typescript: 4.9.5 - yargs-parser: 20.2.9 - ts-jest@26.5.6(jest@26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)))(typescript@4.9.5): dependencies: bs-logger: 0.2.6 buffer-from: 1.1.2 fast-json-stable-stringify: 2.1.0 - jest: 26.6.3(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) + jest: 26.6.3(canvas@2.11.2)(ts-node@10.9.0(@types/node@22.13.17)(typescript@4.9.5)) jest-util: 26.6.2 json5: 2.2.3 lodash: 4.17.21 diff --git a/docs/demos/package.json b/docs/demos/package.json index 491b840fd..923ba0c7f 100644 --- a/docs/demos/package.json +++ b/docs/demos/package.json @@ -12,7 +12,7 @@ "@internal/eslint-config": "workspace:*", "@internal/ts-config": "workspace:*", "@visactor/vrender-kits": "workspace:0.14.8", - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "d3-scale-chromatic": "^3.0.0", "lodash": "4.17.21", "dat.gui": "^0.7.9", diff --git a/docs/package.json b/docs/package.json index ce86be1c3..b677e3ac7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,7 @@ "dependencies": { "@arco-design/web-react": "2.46.1", "@visactor/vchart": "1.3.0", - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "@visactor/vgrammar": "~0.5.7", "@visactor/vrender": "workspace:0.22.11", "markdown-it": "^13.0.0", diff --git a/packages/react-vrender-utils/package.json b/packages/react-vrender-utils/package.json index d9bb4a947..86a45509f 100644 --- a/packages/react-vrender-utils/package.json +++ b/packages/react-vrender-utils/package.json @@ -26,7 +26,7 @@ "dependencies": { "@visactor/vrender": "workspace:0.22.11", "@visactor/react-vrender": "workspace:0.22.11", - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" }, diff --git a/packages/react-vrender/package.json b/packages/react-vrender/package.json index 592c34c2c..c128184e1 100644 --- a/packages/react-vrender/package.json +++ b/packages/react-vrender/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@visactor/vrender": "workspace:0.22.11", - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" }, diff --git a/packages/vrender-animate/.eslintrc.js b/packages/vrender-animate/.eslintrc.js new file mode 100644 index 000000000..c8e50d056 --- /dev/null +++ b/packages/vrender-animate/.eslintrc.js @@ -0,0 +1,7 @@ +require('@rushstack/eslint-patch/modern-module-resolution'); + +module.exports = { + extends: ['@internal/eslint-config/profile/lib'], + parserOptions: { tsconfigRootDir: __dirname, project: './tsconfig.eslint.json' } + // ignorePatterns: [], +}; diff --git a/packages/vrender-animate/CHANGELOG.json b/packages/vrender-animate/CHANGELOG.json new file mode 100644 index 000000000..78d2e60be --- /dev/null +++ b/packages/vrender-animate/CHANGELOG.json @@ -0,0 +1,1458 @@ +{ + "name": "@visactor/vrender-kits", + "entries": [ + { + "version": "0.22.1", + "tag": "@visactor/vrender-kits_v0.22.1", + "date": "Tue, 18 Feb 2025 10:14:45 GMT", + "comments": { + "none": [ + { + "comment": "fix: datazoom error when spec is updating. fix@visactor/vchart#3712" + }, + { + "comment": "feat: support dynamicTexture" + }, + { + "comment": "feat: `removeState` of graphic should support array or string\n\n" + }, + { + "comment": "fix: fix the bug of dpr will not work when createWindowByCanvas in node env\n\n" + } + ] + } + }, + { + "version": "0.22.0", + "tag": "@visactor/vrender-kits_v0.22.0", + "date": "Fri, 07 Feb 2025 14:23:36 GMT", + "comments": {} + }, + { + "version": "0.21.14", + "tag": "@visactor/vrender-kits_v0.21.14", + "date": "Fri, 07 Feb 2025 13:42:01 GMT", + "comments": {} + }, + { + "version": "0.21.13", + "tag": "@visactor/vrender-kits_v0.21.13", + "date": "Thu, 06 Feb 2025 08:25:45 GMT", + "comments": {} + }, + { + "version": "0.21.12", + "tag": "@visactor/vrender-kits_v0.21.12", + "date": "Wed, 05 Feb 2025 07:04:09 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with inversify error when nobind" + } + ] + } + }, + { + "version": "0.21.11", + "tag": "@visactor/vrender-kits_v0.21.11", + "date": "Wed, 15 Jan 2025 12:13:28 GMT", + "comments": {} + }, + { + "version": "0.21.10", + "tag": "@visactor/vrender-kits_v0.21.10", + "date": "Wed, 15 Jan 2025 03:14:32 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with duplicate getContext in wx env" + } + ] + } + }, + { + "version": "0.21.9", + "tag": "@visactor/vrender-kits_v0.21.9", + "date": "Mon, 13 Jan 2025 03:23:50 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix drawShape function in gif-image render " + } + ] + } + }, + { + "version": "0.21.8", + "tag": "@visactor/vrender-kits_v0.21.8", + "date": "Mon, 06 Jan 2025 11:07:36 GMT", + "comments": {} + }, + { + "version": "0.21.7", + "tag": "@visactor/vrender-kits_v0.21.7", + "date": "Wed, 25 Dec 2024 07:53:11 GMT", + "comments": { + "none": [ + { + "comment": "fix: upgrade vutils to 0.19.3\n\n" + } + ] + } + }, + { + "version": "0.21.6", + "tag": "@visactor/vrender-kits_v0.21.6", + "date": "Tue, 24 Dec 2024 12:46:37 GMT", + "comments": {} + }, + { + "version": "0.21.5", + "tag": "@visactor/vrender-kits_v0.21.5", + "date": "Tue, 24 Dec 2024 07:53:11 GMT", + "comments": {} + }, + { + "version": "0.21.4", + "tag": "@visactor/vrender-kits_v0.21.4", + "date": "Mon, 23 Dec 2024 10:16:00 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with gesture emitEvent when gesture is released" + } + ] + } + }, + { + "version": "0.21.3", + "tag": "@visactor/vrender-kits_v0.21.3", + "date": "Mon, 23 Dec 2024 08:28:14 GMT", + "comments": { + "none": [ + { + "comment": "feat: support loadFont" + } + ] + } + }, + { + "version": "0.21.2", + "tag": "@visactor/vrender-kits_v0.21.2", + "date": "Thu, 12 Dec 2024 10:23:51 GMT", + "comments": {} + }, + { + "version": "0.21.1", + "tag": "@visactor/vrender-kits_v0.21.1", + "date": "Thu, 05 Dec 2024 07:50:47 GMT", + "comments": {} + }, + { + "version": "0.21.0", + "tag": "@visactor/vrender-kits_v0.21.0", + "date": "Thu, 28 Nov 2024 03:30:36 GMT", + "comments": {} + }, + { + "version": "0.20.16", + "tag": "@visactor/vrender-kits_v0.20.16", + "date": "Thu, 21 Nov 2024 06:58:23 GMT", + "comments": {} + }, + { + "version": "0.20.15", + "tag": "@visactor/vrender-kits_v0.20.15", + "date": "Fri, 15 Nov 2024 08:34:34 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix press in andiord" + } + ] + } + }, + { + "version": "0.20.14", + "tag": "@visactor/vrender-kits_v0.20.14", + "date": "Wed, 13 Nov 2024 07:47:16 GMT", + "comments": {} + }, + { + "version": "0.20.13", + "tag": "@visactor/vrender-kits_v0.20.13", + "date": "Wed, 13 Nov 2024 06:35:02 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix trigger of press in mobile\n\n" + }, + { + "comment": "fix: fix smartInvert of gradient bar\n\n" + } + ] + } + }, + { + "version": "0.20.12", + "tag": "@visactor/vrender-kits_v0.20.12", + "date": "Thu, 31 Oct 2024 02:49:49 GMT", + "comments": {} + }, + { + "version": "0.20.11", + "tag": "@visactor/vrender-kits_v0.20.11", + "date": "Wed, 30 Oct 2024 13:10:03 GMT", + "comments": {} + }, + { + "version": "0.20.10", + "tag": "@visactor/vrender-kits_v0.20.10", + "date": "Wed, 23 Oct 2024 08:37:33 GMT", + "comments": {} + }, + { + "version": "0.20.9", + "tag": "@visactor/vrender-kits_v0.20.9", + "date": "Tue, 15 Oct 2024 03:50:15 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix max width of arc label in left\n\n" + } + ] + } + }, + { + "version": "0.20.8", + "tag": "@visactor/vrender-kits_v0.20.8", + "date": "Sun, 29 Sep 2024 09:44:02 GMT", + "comments": {} + }, + { + "version": "0.20.7", + "tag": "@visactor/vrender-kits_v0.20.7", + "date": "Fri, 27 Sep 2024 03:22:31 GMT", + "comments": {} + }, + { + "version": "0.20.6", + "tag": "@visactor/vrender-kits_v0.20.6", + "date": "Thu, 26 Sep 2024 09:28:36 GMT", + "comments": {} + }, + { + "version": "0.20.5", + "tag": "@visactor/vrender-kits_v0.20.5", + "date": "Fri, 20 Sep 2024 06:37:57 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix path string of arc, fix #1434\n\n" + } + ] + } + }, + { + "version": "0.20.4", + "tag": "@visactor/vrender-kits_v0.20.4", + "date": "Thu, 12 Sep 2024 07:33:20 GMT", + "comments": {} + }, + { + "version": "0.20.3", + "tag": "@visactor/vrender-kits_v0.20.3", + "date": "Sat, 07 Sep 2024 09:16:33 GMT", + "comments": {} + }, + { + "version": "0.20.2", + "tag": "@visactor/vrender-kits_v0.20.2", + "date": "Wed, 04 Sep 2024 12:52:31 GMT", + "comments": {} + }, + { + "version": "0.20.1", + "tag": "@visactor/vrender-kits_v0.20.1", + "date": "Fri, 30 Aug 2024 09:55:08 GMT", + "comments": {} + }, + { + "version": "0.20.0", + "tag": "@visactor/vrender-kits_v0.20.0", + "date": "Thu, 15 Aug 2024 07:26:54 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix bug of auto-render when remove some graphics\n\n" + }, + { + "comment": "fix: optimize triangle symbols\n\n" + }, + { + "comment": "refactor: optimize cornerRadius parse of arc\n\n" + } + ] + } + }, + { + "version": "0.19.24", + "tag": "@visactor/vrender-kits_v0.19.24", + "date": "Tue, 13 Aug 2024 07:47:29 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix wrong stroke style is applied to area\n\n" + } + ] + } + }, + { + "version": "0.19.23", + "tag": "@visactor/vrender-kits_v0.19.23", + "date": "Tue, 06 Aug 2024 05:17:39 GMT", + "comments": {} + }, + { + "version": "0.19.22", + "tag": "@visactor/vrender-kits_v0.19.22", + "date": "Mon, 05 Aug 2024 09:08:30 GMT", + "comments": {} + }, + { + "version": "0.19.21", + "tag": "@visactor/vrender-kits_v0.19.21", + "date": "Mon, 05 Aug 2024 01:39:45 GMT", + "comments": {} + }, + { + "version": "0.19.20", + "tag": "@visactor/vrender-kits_v0.19.20", + "date": "Wed, 31 Jul 2024 09:48:37 GMT", + "comments": {} + }, + { + "version": "0.19.19", + "tag": "@visactor/vrender-kits_v0.19.19", + "date": "Tue, 23 Jul 2024 11:56:39 GMT", + "comments": {} + }, + { + "version": "0.19.18", + "tag": "@visactor/vrender-kits_v0.19.18", + "date": "Fri, 12 Jul 2024 07:18:10 GMT", + "comments": {} + }, + { + "version": "0.19.17", + "tag": "@visactor/vrender-kits_v0.19.17", + "date": "Fri, 05 Jul 2024 17:26:17 GMT", + "comments": {} + }, + { + "version": "0.19.16", + "tag": "@visactor/vrender-kits_v0.19.16", + "date": "Fri, 05 Jul 2024 14:29:15 GMT", + "comments": {} + }, + { + "version": "0.19.15", + "tag": "@visactor/vrender-kits_v0.19.15", + "date": "Fri, 28 Jun 2024 10:32:37 GMT", + "comments": {} + }, + { + "version": "0.19.14", + "tag": "@visactor/vrender-kits_v0.19.14", + "date": "Wed, 26 Jun 2024 09:16:23 GMT", + "comments": { + "none": [ + { + "comment": "feat: upgrade @visactor/vutils" + } + ] + } + }, + { + "version": "0.19.13", + "tag": "@visactor/vrender-kits_v0.19.13", + "date": "Tue, 25 Jun 2024 11:17:14 GMT", + "comments": {} + }, + { + "version": "0.19.12", + "tag": "@visactor/vrender-kits_v0.19.12", + "date": "Fri, 21 Jun 2024 06:52:50 GMT", + "comments": {} + }, + { + "version": "0.19.11", + "tag": "@visactor/vrender-kits_v0.19.11", + "date": "Fri, 14 Jun 2024 09:50:59 GMT", + "comments": {} + }, + { + "version": "0.19.10", + "tag": "@visactor/vrender-kits_v0.19.10", + "date": "Thu, 13 Jun 2024 09:52:46 GMT", + "comments": {} + }, + { + "version": "0.19.9", + "tag": "@visactor/vrender-kits_v0.19.9", + "date": "Wed, 05 Jun 2024 12:25:00 GMT", + "comments": {} + }, + { + "version": "0.19.8", + "tag": "@visactor/vrender-kits_v0.19.8", + "date": "Wed, 05 Jun 2024 08:24:28 GMT", + "comments": {} + }, + { + "version": "0.19.7", + "tag": "@visactor/vrender-kits_v0.19.7", + "date": "Tue, 04 Jun 2024 11:10:08 GMT", + "comments": {} + }, + { + "version": "0.19.6", + "tag": "@visactor/vrender-kits_v0.19.6", + "date": "Wed, 29 May 2024 06:57:11 GMT", + "comments": {} + }, + { + "version": "0.19.5", + "tag": "@visactor/vrender-kits_v0.19.5", + "date": "Fri, 24 May 2024 09:21:23 GMT", + "comments": {} + }, + { + "version": "0.19.4", + "tag": "@visactor/vrender-kits_v0.19.4", + "date": "Fri, 17 May 2024 06:46:41 GMT", + "comments": { + "none": [ + { + "comment": "feat: support harmony env" + } + ] + } + }, + { + "version": "0.19.3", + "tag": "@visactor/vrender-kits_v0.19.3", + "date": "Fri, 10 May 2024 09:24:39 GMT", + "comments": { + "none": [ + { + "comment": "feat: support baseOpacity for group" + } + ] + } + }, + { + "version": "0.19.2", + "tag": "@visactor/vrender-kits_v0.19.2", + "date": "Thu, 09 May 2024 12:26:00 GMT", + "comments": { + "none": [ + { + "comment": "feat: support tt env, closed #1129" + } + ] + } + }, + { + "version": "0.19.1", + "tag": "@visactor/vrender-kits_v0.19.1", + "date": "Wed, 08 May 2024 08:47:35 GMT", + "comments": {} + }, + { + "version": "0.19.0", + "tag": "@visactor/vrender-kits_v0.19.0", + "date": "Tue, 30 Apr 2024 08:40:53 GMT", + "comments": { + "none": [ + { + "comment": "feat: support style callback in html and react, fix 1102\n\n" + } + ] + } + }, + { + "version": "0.18.17", + "tag": "@visactor/vrender-kits_v0.18.17", + "date": "Tue, 30 Apr 2024 07:48:41 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with setLineDash crash, closed #1047" + } + ] + } + }, + { + "version": "0.18.16", + "tag": "@visactor/vrender-kits_v0.18.16", + "date": "Mon, 29 Apr 2024 07:40:31 GMT", + "comments": {} + }, + { + "version": "0.18.15", + "tag": "@visactor/vrender-kits_v0.18.15", + "date": "Fri, 26 Apr 2024 10:37:19 GMT", + "comments": {} + }, + { + "version": "0.18.14", + "tag": "@visactor/vrender-kits_v0.18.14", + "date": "Wed, 24 Apr 2024 08:07:48 GMT", + "comments": {} + }, + { + "version": "0.18.13", + "tag": "@visactor/vrender-kits_v0.18.13", + "date": "Fri, 19 Apr 2024 08:46:08 GMT", + "comments": {} + }, + { + "version": "0.18.12", + "tag": "@visactor/vrender-kits_v0.18.12", + "date": "Fri, 19 Apr 2024 07:48:17 GMT", + "comments": {} + }, + { + "version": "0.18.11", + "tag": "@visactor/vrender-kits_v0.18.11", + "date": "Wed, 17 Apr 2024 03:02:22 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix for dragenter triggering error in drag event" + } + ] + } + }, + { + "version": "0.18.10", + "tag": "@visactor/vrender-kits_v0.18.10", + "date": "Fri, 29 Mar 2024 08:02:16 GMT", + "comments": {} + }, + { + "version": "0.18.9", + "tag": "@visactor/vrender-kits_v0.18.9", + "date": "Thu, 28 Mar 2024 10:13:24 GMT", + "comments": {} + }, + { + "version": "0.18.8", + "tag": "@visactor/vrender-kits_v0.18.8", + "date": "Wed, 27 Mar 2024 11:33:58 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with pointer tap event point map" + } + ] + } + }, + { + "version": "0.18.7", + "tag": "@visactor/vrender-kits_v0.18.7", + "date": "Fri, 22 Mar 2024 08:46:19 GMT", + "comments": { + "none": [ + { + "comment": "fix: set vtag params to optional" + } + ] + } + }, + { + "version": "0.18.6", + "tag": "@visactor/vrender-kits_v0.18.6", + "date": "Tue, 19 Mar 2024 10:10:17 GMT", + "comments": {} + }, + { + "version": "0.18.5", + "tag": "@visactor/vrender-kits_v0.18.5", + "date": "Tue, 12 Mar 2024 15:16:46 GMT", + "comments": {} + }, + { + "version": "0.18.4", + "tag": "@visactor/vrender-kits_v0.18.4", + "date": "Tue, 12 Mar 2024 09:40:06 GMT", + "comments": {} + }, + { + "version": "0.18.3", + "tag": "@visactor/vrender-kits_v0.18.3", + "date": "Mon, 11 Mar 2024 08:24:00 GMT", + "comments": {} + }, + { + "version": "0.18.2", + "tag": "@visactor/vrender-kits_v0.18.2", + "date": "Fri, 08 Mar 2024 03:19:08 GMT", + "comments": {} + }, + { + "version": "0.18.1", + "tag": "@visactor/vrender-kits_v0.18.1", + "date": "Mon, 04 Mar 2024 08:29:15 GMT", + "comments": {} + }, + { + "version": "0.18.0", + "tag": "@visactor/vrender-kits_v0.18.0", + "date": "Wed, 28 Feb 2024 10:09:04 GMT", + "comments": {} + }, + { + "version": "0.17.26", + "tag": "@visactor/vrender-kits_v0.17.26", + "date": "Wed, 28 Feb 2024 08:06:31 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with load svg sync, fix issue with decode react dom" + } + ] + } + }, + { + "version": "0.17.25", + "tag": "@visactor/vrender-kits_v0.17.25", + "date": "Fri, 23 Feb 2024 04:29:58 GMT", + "comments": { + "none": [ + { + "comment": "feat: support offscreenCanvas in lynx env, closed #994" + } + ] + } + }, + { + "version": "0.17.24", + "tag": "@visactor/vrender-kits_v0.17.24", + "date": "Tue, 06 Feb 2024 09:48:26 GMT", + "comments": {} + }, + { + "version": "0.17.23", + "tag": "@visactor/vrender-kits_v0.17.23", + "date": "Sun, 04 Feb 2024 12:41:45 GMT", + "comments": {} + }, + { + "version": "0.17.22", + "tag": "@visactor/vrender-kits_v0.17.22", + "date": "Fri, 02 Feb 2024 07:17:07 GMT", + "comments": {} + }, + { + "version": "0.17.21", + "tag": "@visactor/vrender-kits_v0.17.21", + "date": "Thu, 01 Feb 2024 12:22:29 GMT", + "comments": {} + }, + { + "version": "0.17.20", + "tag": "@visactor/vrender-kits_v0.17.20", + "date": "Thu, 01 Feb 2024 09:26:17 GMT", + "comments": {} + }, + { + "version": "0.17.19", + "tag": "@visactor/vrender-kits_v0.17.19", + "date": "Wed, 24 Jan 2024 13:11:27 GMT", + "comments": {} + }, + { + "version": "0.17.18", + "tag": "@visactor/vrender-kits_v0.17.18", + "date": "Wed, 24 Jan 2024 10:10:41 GMT", + "comments": { + "none": [ + { + "comment": "feat: compatible canvas in lynx env" + }, + { + "comment": "fix: fix issue with interface" + } + ] + } + }, + { + "version": "0.17.17", + "tag": "@visactor/vrender-kits_v0.17.17", + "date": "Mon, 22 Jan 2024 08:19:38 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with loaded tree-shaking" + } + ] + } + }, + { + "version": "0.17.16", + "tag": "@visactor/vrender-kits_v0.17.16", + "date": "Wed, 17 Jan 2024 09:02:13 GMT", + "comments": {} + }, + { + "version": "0.17.15", + "tag": "@visactor/vrender-kits_v0.17.15", + "date": "Wed, 17 Jan 2024 06:43:01 GMT", + "comments": {} + }, + { + "version": "0.17.14", + "tag": "@visactor/vrender-kits_v0.17.14", + "date": "Fri, 12 Jan 2024 10:33:32 GMT", + "comments": {} + }, + { + "version": "0.17.13", + "tag": "@visactor/vrender-kits_v0.17.13", + "date": "Wed, 10 Jan 2024 14:18:21 GMT", + "comments": {} + }, + { + "version": "0.17.12", + "tag": "@visactor/vrender-kits_v0.17.12", + "date": "Wed, 10 Jan 2024 03:56:46 GMT", + "comments": {} + }, + { + "version": "0.17.11", + "tag": "@visactor/vrender-kits_v0.17.11", + "date": "Fri, 05 Jan 2024 11:54:56 GMT", + "comments": {} + }, + { + "version": "0.17.10", + "tag": "@visactor/vrender-kits_v0.17.10", + "date": "Wed, 03 Jan 2024 13:19:34 GMT", + "comments": { + "none": [ + { + "comment": "feat: support fillPickable and strokePickable for area, closed #792" + } + ] + } + }, + { + "version": "0.17.9", + "tag": "@visactor/vrender-kits_v0.17.9", + "date": "Fri, 29 Dec 2023 09:59:13 GMT", + "comments": {} + }, + { + "version": "0.17.8", + "tag": "@visactor/vrender-kits_v0.17.8", + "date": "Fri, 29 Dec 2023 07:20:26 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with mapToCanvasPoint in miniapp, closed #828" + } + ] + } + }, + { + "version": "0.17.7", + "tag": "@visactor/vrender-kits_v0.17.7", + "date": "Wed, 20 Dec 2023 10:05:55 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with create layer in miniapp env" + } + ] + } + }, + { + "version": "0.17.6", + "tag": "@visactor/vrender-kits_v0.17.6", + "date": "Wed, 20 Dec 2023 07:39:54 GMT", + "comments": {} + }, + { + "version": "0.17.5", + "tag": "@visactor/vrender-kits_v0.17.5", + "date": "Tue, 19 Dec 2023 09:13:27 GMT", + "comments": {} + }, + { + "version": "0.17.4", + "tag": "@visactor/vrender-kits_v0.17.4", + "date": "Thu, 14 Dec 2023 11:00:38 GMT", + "comments": {} + }, + { + "version": "0.17.3", + "tag": "@visactor/vrender-kits_v0.17.3", + "date": "Wed, 13 Dec 2023 12:04:17 GMT", + "comments": {} + }, + { + "version": "0.17.2", + "tag": "@visactor/vrender-kits_v0.17.2", + "date": "Tue, 12 Dec 2023 13:05:58 GMT", + "comments": { + "none": [ + { + "comment": "feat(dataZoom): add mask to modify hot zone. feat @visactor/vchart#1415'" + } + ] + } + }, + { + "version": "0.17.1", + "tag": "@visactor/vrender-kits_v0.17.1", + "date": "Wed, 06 Dec 2023 11:19:22 GMT", + "comments": { + "none": [ + { + "comment": "feat: support pickStrokeBuffer, closed #758" + }, + { + "comment": "fix: fix issue with rebind pick-contribution" + } + ] + } + }, + { + "version": "0.17.0", + "tag": "@visactor/vrender-kits_v0.17.0", + "date": "Thu, 30 Nov 2023 12:58:15 GMT", + "comments": { + "none": [ + { + "comment": "feat: rect support x1 and y1" + }, + { + "comment": "refactor: refact inversify completely, closed #657" + } + ], + "minor": [ + { + "comment": "feat: optmize bounds performance" + } + ] + } + }, + { + "version": "0.16.18", + "tag": "@visactor/vrender-kits_v0.16.18", + "date": "Thu, 30 Nov 2023 09:40:58 GMT", + "comments": { + "none": [ + { + "comment": "fix: doubletap should not be triggered when the target is different twice before and after" + } + ] + } + }, + { + "version": "0.16.17", + "tag": "@visactor/vrender-kits_v0.16.17", + "date": "Thu, 23 Nov 2023 13:32:49 GMT", + "comments": { + "none": [ + { + "comment": "feat: support 'tap' gesture for Gesture plugin" + }, + { + "comment": "fix: \\`pickMode: 'imprecise'\\` not work in polygon" + } + ] + } + }, + { + "version": "0.16.16", + "tag": "@visactor/vrender-kits_v0.16.16", + "date": "Fri, 17 Nov 2023 02:33:59 GMT", + "comments": {} + }, + { + "version": "0.16.15", + "tag": "@visactor/vrender-kits_v0.16.15", + "date": "Thu, 16 Nov 2023 02:46:27 GMT", + "comments": {} + }, + { + "version": "0.16.14", + "tag": "@visactor/vrender-kits_v0.16.14", + "date": "Wed, 15 Nov 2023 09:56:28 GMT", + "comments": {} + }, + { + "version": "0.16.13", + "tag": "@visactor/vrender-kits_v0.16.13", + "date": "Thu, 09 Nov 2023 11:49:33 GMT", + "comments": { + "none": [ + { + "comment": "fix: temp fix issue with lynx measuretext" + } + ] + } + }, + { + "version": "0.16.12", + "tag": "@visactor/vrender-kits_v0.16.12", + "date": "Tue, 07 Nov 2023 10:52:54 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix node-canvas max count issue" + } + ] + } + }, + { + "version": "0.16.11", + "tag": "@visactor/vrender-kits_v0.16.11", + "date": "Thu, 02 Nov 2023 13:43:18 GMT", + "comments": {} + }, + { + "version": "0.16.10", + "tag": "@visactor/vrender-kits_v0.16.10", + "date": "Thu, 02 Nov 2023 11:17:24 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix issue with xul and html attribute, closed #634" + } + ] + } + }, + { + "version": "0.16.9", + "tag": "@visactor/vrender-kits_v0.16.9", + "date": "Fri, 27 Oct 2023 02:21:19 GMT", + "comments": {} + }, + { + "version": "0.16.8", + "tag": "@visactor/vrender-kits_v0.16.8", + "date": "Mon, 23 Oct 2023 11:38:47 GMT", + "comments": {} + }, + { + "version": "0.16.7", + "tag": "@visactor/vrender-kits_v0.16.7", + "date": "Mon, 23 Oct 2023 08:53:33 GMT", + "comments": {} + }, + { + "version": "0.16.6", + "tag": "@visactor/vrender-kits_v0.16.6", + "date": "Mon, 23 Oct 2023 06:30:33 GMT", + "comments": {} + }, + { + "version": "0.16.5", + "tag": "@visactor/vrender-kits_v0.16.5", + "date": "Fri, 20 Oct 2023 04:22:42 GMT", + "comments": {} + }, + { + "version": "0.16.4", + "tag": "@visactor/vrender-kits_v0.16.4", + "date": "Thu, 19 Oct 2023 10:30:12 GMT", + "comments": {} + }, + { + "version": "0.16.3", + "tag": "@visactor/vrender-kits_v0.16.3", + "date": "Wed, 18 Oct 2023 07:43:09 GMT", + "comments": { + "none": [ + { + "comment": "fix: add canvas resize support in lynx env, closed #581" + } + ] + } + }, + { + "version": "0.16.2", + "tag": "@visactor/vrender-kits_v0.16.2", + "date": "Tue, 10 Oct 2023 11:48:48 GMT", + "comments": {} + }, + { + "version": "0.16.1", + "tag": "@visactor/vrender-kits_v0.16.1", + "date": "Mon, 09 Oct 2023 09:51:01 GMT", + "comments": { + "none": [ + { + "comment": "fix: fix flex issue, closed, fix node dpr issue, closed #554, closed #555" + }, + { + "comment": "fix: fix reinit env issue" + } + ] + } + }, + { + "version": "0.16.0", + "tag": "@visactor/vrender-kits_v0.16.0", + "date": "Thu, 28 Sep 2023 07:23:52 GMT", + "comments": {} + }, + { + "version": "0.15.5", + "tag": "@visactor/vrender-kits_v0.15.5", + "date": "Wed, 27 Sep 2023 09:33:50 GMT", + "comments": {} + }, + { + "version": "0.15.4", + "tag": "@visactor/vrender-kits_v0.15.4", + "date": "Mon, 25 Sep 2023 03:05:18 GMT", + "comments": {} + }, + { + "version": "0.15.3", + "tag": "@visactor/vrender-kits_v0.15.3", + "date": "Wed, 20 Sep 2023 13:12:13 GMT", + "comments": {} + }, + { + "version": "0.15.2", + "tag": "@visactor/vrender-kits_v0.15.2", + "date": "Thu, 14 Sep 2023 09:55:56 GMT", + "comments": {} + }, + { + "version": "0.15.1", + "tag": "@visactor/vrender-kits_v0.15.1", + "date": "Wed, 13 Sep 2023 02:21:58 GMT", + "comments": {} + }, + { + "version": "0.15.0", + "tag": "@visactor/vrender-kits_v0.15.0", + "date": "Tue, 12 Sep 2023 12:20:48 GMT", + "comments": {} + }, + { + "version": "0.14.8", + "tag": "@visactor/vrender-kits_v0.14.8", + "date": "Thu, 07 Sep 2023 11:56:00 GMT", + "comments": {} + }, + { + "version": "0.12.21", + "tag": "@visactor/vrender-kits_v0.12.21", + "date": "Thu, 31 Aug 2023 10:03:38 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.6` to `0.14.7`" + } + ] + } + }, + { + "version": "0.12.20", + "tag": "@visactor/vrender-kits_v0.12.20", + "date": "Tue, 29 Aug 2023 11:30:33 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.5` to `0.14.6`" + } + ] + } + }, + { + "version": "0.12.19", + "tag": "@visactor/vrender-kits_v0.12.19", + "date": "Wed, 23 Aug 2023 11:53:28 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.4` to `0.14.5`" + } + ] + } + }, + { + "version": "0.12.18", + "tag": "@visactor/vrender-kits_v0.12.18", + "date": "Fri, 18 Aug 2023 10:16:08 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.3` to `0.14.4`" + } + ] + } + }, + { + "version": "0.12.17", + "tag": "@visactor/vrender-kits_v0.12.17", + "date": "Wed, 16 Aug 2023 06:46:13 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.2` to `0.14.3`" + } + ] + } + }, + { + "version": "0.12.16", + "tag": "@visactor/vrender-kits_v0.12.16", + "date": "Fri, 11 Aug 2023 10:05:27 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.1` to `0.14.2`" + } + ] + } + }, + { + "version": "0.12.15", + "tag": "@visactor/vrender-kits_v0.12.15", + "date": "Thu, 10 Aug 2023 12:14:14 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.14.0` to `0.14.1`" + } + ] + } + }, + { + "version": "0.12.14", + "tag": "@visactor/vrender-kits_v0.12.14", + "date": "Thu, 10 Aug 2023 07:22:55 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.9` to `0.14.0`" + } + ] + } + }, + { + "version": "0.12.13", + "tag": "@visactor/vrender-kits_v0.12.13", + "date": "Wed, 09 Aug 2023 07:34:23 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.8` to `0.13.9`" + } + ] + } + }, + { + "version": "0.12.12", + "tag": "@visactor/vrender-kits_v0.12.12", + "date": "Tue, 08 Aug 2023 09:27:52 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.7` to `0.13.8`" + } + ] + } + }, + { + "version": "0.12.11", + "tag": "@visactor/vrender-kits_v0.12.11", + "date": "Thu, 03 Aug 2023 10:04:34 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.6` to `0.13.7`" + } + ] + } + }, + { + "version": "0.12.10", + "tag": "@visactor/vrender-kits_v0.12.10", + "date": "Wed, 02 Aug 2023 03:13:00 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.5` to `0.13.6`" + } + ] + } + }, + { + "version": "0.12.9", + "tag": "@visactor/vrender-kits_v0.12.9", + "date": "Thu, 27 Jul 2023 12:27:43 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.4` to `0.13.5`" + } + ] + } + }, + { + "version": "0.12.8", + "tag": "@visactor/vrender-kits_v0.12.8", + "date": "Tue, 25 Jul 2023 13:33:47 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.3` to `0.13.4`" + } + ] + } + }, + { + "version": "0.12.7", + "tag": "@visactor/vrender-kits_v0.12.7", + "date": "Tue, 25 Jul 2023 07:34:59 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.2` to `0.13.3`" + } + ] + } + }, + { + "version": "0.12.6", + "tag": "@visactor/vrender-kits_v0.12.6", + "date": "Fri, 21 Jul 2023 10:50:41 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.1` to `0.13.2`" + } + ] + } + }, + { + "version": "0.12.5", + "tag": "@visactor/vrender-kits_v0.12.5", + "date": "Thu, 20 Jul 2023 10:41:23 GMT", + "comments": { + "patch": [ + { + "comment": "fix: fix the error caused by reflect-metadata in react env" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.13.0` to `0.13.1`" + } + ] + } + }, + { + "version": "0.12.4", + "tag": "@visactor/vrender-kits_v0.12.4", + "date": "Wed, 19 Jul 2023 08:29:52 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.12.3` to `0.13.0`" + } + ] + } + }, + { + "version": "0.12.3", + "tag": "@visactor/vrender-kits_v0.12.3", + "date": "Wed, 12 Jul 2023 12:30:46 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.12.2` to `0.12.3`" + } + ] + } + }, + { + "version": "0.12.2", + "tag": "@visactor/vrender-kits_v0.12.2", + "date": "Tue, 11 Jul 2023 13:17:12 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.12.1` to `0.12.2`" + } + ] + } + }, + { + "version": "0.12.1", + "tag": "@visactor/vrender-kits_v0.12.1", + "date": "Fri, 07 Jul 2023 09:04:45 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.12.0` to `0.12.1`" + } + ] + } + }, + { + "version": "0.12.0", + "tag": "@visactor/vrender-kits_v0.12.0", + "date": "Thu, 06 Jul 2023 09:09:12 GMT", + "comments": { + "minor": [ + { + "comment": "refactor: refactor interfaces and types of typescript" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.11.1` to `0.12.0`" + } + ] + } + }, + { + "version": "0.11.1", + "tag": "@visactor/vrender-kits_v0.11.1", + "date": "Tue, 27 Jun 2023 13:38:36 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.11.0` to `0.11.1`" + } + ] + } + }, + { + "version": "0.11.0", + "tag": "@visactor/vrender-kits_v0.11.0", + "date": "Tue, 27 Jun 2023 03:18:18 GMT", + "comments": { + "minor": [ + { + "comment": "update vUtils version" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.11.0-alpha.2` to `0.11.0`" + } + ] + } + }, + { + "version": "0.10.3", + "tag": "@visactor/vrender-kits_v0.10.3", + "date": "Tue, 20 Jun 2023 06:23:42 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.10.2` to `0.10.3`" + } + ] + } + }, + { + "version": "0.10.2", + "tag": "@visactor/vrender-kits_v0.10.2", + "date": "Tue, 20 Jun 2023 03:25:23 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.10.1` to `0.10.2`" + } + ] + } + }, + { + "version": "0.10.1", + "tag": "@visactor/vrender-kits_v0.10.1", + "date": "Mon, 19 Jun 2023 09:49:38 GMT", + "comments": { + "patch": [ + { + "comment": "fix the bug of node-canvas wh" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.10.0` to `0.10.1`" + } + ] + } + }, + { + "version": "0.10.0", + "tag": "@visactor/vrender-kits_v0.10.0", + "date": "Fri, 16 Jun 2023 03:13:09 GMT", + "comments": { + "minor": [ + { + "comment": "code style" + } + ], + "patch": [ + { + "comment": "upgrade vrender" + }, + { + "comment": "fix enableView3dTranform" + }, + { + "comment": "upgrade vrender" + } + ], + "none": [ + { + "comment": "release" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.9.2-alpha.0` to `0.10.0`" + } + ] + } + }, + { + "version": "0.9.1", + "tag": "@visactor/vrender-kits_v0.9.1", + "date": "Thu, 08 Jun 2023 11:34:32 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.9.0` to `0.9.1`" + } + ] + } + }, + { + "version": "0.9.0", + "tag": "@visactor/vrender-kits_v0.9.0", + "date": "Wed, 07 Jun 2023 12:20:05 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@visactor/vrender\" from `0.9.0-alpha.1` to `0.9.0`" + } + ] + } + } + ] +} diff --git a/packages/vrender-animate/CHANGELOG.md b/packages/vrender-animate/CHANGELOG.md new file mode 100644 index 000000000..bf342f138 --- /dev/null +++ b/packages/vrender-animate/CHANGELOG.md @@ -0,0 +1,941 @@ +# Change Log - @visactor/vrender-kits + +This log was last generated on Tue, 18 Feb 2025 10:14:45 GMT and should not be manually modified. + +## 0.22.1 +Tue, 18 Feb 2025 10:14:45 GMT + +### Updates + +- fix: datazoom error when spec is updating. fix@visactor/vchart#3712 +- feat: support dynamicTexture +- feat: `removeState` of graphic should support array or string + + +- fix: fix the bug of dpr will not work when createWindowByCanvas in node env + + + +## 0.22.0 +Fri, 07 Feb 2025 14:23:36 GMT + +_Version update only_ + +## 0.21.14 +Fri, 07 Feb 2025 13:42:01 GMT + +_Version update only_ + +## 0.21.13 +Thu, 06 Feb 2025 08:25:45 GMT + +_Version update only_ + +## 0.21.12 +Wed, 05 Feb 2025 07:04:09 GMT + +### Updates + +- fix: fix issue with inversify error when nobind + +## 0.21.11 +Wed, 15 Jan 2025 12:13:28 GMT + +_Version update only_ + +## 0.21.10 +Wed, 15 Jan 2025 03:14:32 GMT + +### Updates + +- fix: fix issue with duplicate getContext in wx env + +## 0.21.9 +Mon, 13 Jan 2025 03:23:50 GMT + +### Updates + +- fix: fix drawShape function in gif-image render + +## 0.21.8 +Mon, 06 Jan 2025 11:07:36 GMT + +_Version update only_ + +## 0.21.7 +Wed, 25 Dec 2024 07:53:11 GMT + +### Updates + +- fix: upgrade vutils to 0.19.3 + + + +## 0.21.6 +Tue, 24 Dec 2024 12:46:37 GMT + +_Version update only_ + +## 0.21.5 +Tue, 24 Dec 2024 07:53:11 GMT + +_Version update only_ + +## 0.21.4 +Mon, 23 Dec 2024 10:16:00 GMT + +### Updates + +- fix: fix issue with gesture emitEvent when gesture is released + +## 0.21.3 +Mon, 23 Dec 2024 08:28:14 GMT + +### Updates + +- feat: support loadFont + +## 0.21.2 +Thu, 12 Dec 2024 10:23:51 GMT + +_Version update only_ + +## 0.21.1 +Thu, 05 Dec 2024 07:50:47 GMT + +_Version update only_ + +## 0.21.0 +Thu, 28 Nov 2024 03:30:36 GMT + +_Version update only_ + +## 0.20.16 +Thu, 21 Nov 2024 06:58:23 GMT + +_Version update only_ + +## 0.20.15 +Fri, 15 Nov 2024 08:34:34 GMT + +### Updates + +- fix: fix press in andiord + +## 0.20.14 +Wed, 13 Nov 2024 07:47:16 GMT + +_Version update only_ + +## 0.20.13 +Wed, 13 Nov 2024 06:35:02 GMT + +### Updates + +- fix: fix trigger of press in mobile + + +- fix: fix smartInvert of gradient bar + + + +## 0.20.12 +Thu, 31 Oct 2024 02:49:49 GMT + +_Version update only_ + +## 0.20.11 +Wed, 30 Oct 2024 13:10:03 GMT + +_Version update only_ + +## 0.20.10 +Wed, 23 Oct 2024 08:37:33 GMT + +_Version update only_ + +## 0.20.9 +Tue, 15 Oct 2024 03:50:15 GMT + +### Updates + +- fix: fix max width of arc label in left + + + +## 0.20.8 +Sun, 29 Sep 2024 09:44:02 GMT + +_Version update only_ + +## 0.20.7 +Fri, 27 Sep 2024 03:22:31 GMT + +_Version update only_ + +## 0.20.6 +Thu, 26 Sep 2024 09:28:36 GMT + +_Version update only_ + +## 0.20.5 +Fri, 20 Sep 2024 06:37:57 GMT + +### Updates + +- fix: fix path string of arc, fix #1434 + + + +## 0.20.4 +Thu, 12 Sep 2024 07:33:20 GMT + +_Version update only_ + +## 0.20.3 +Sat, 07 Sep 2024 09:16:33 GMT + +_Version update only_ + +## 0.20.2 +Wed, 04 Sep 2024 12:52:31 GMT + +_Version update only_ + +## 0.20.1 +Fri, 30 Aug 2024 09:55:08 GMT + +_Version update only_ + +## 0.20.0 +Thu, 15 Aug 2024 07:26:54 GMT + +### Updates + +- fix: fix bug of auto-render when remove some graphics + + +- fix: optimize triangle symbols + + +- refactor: optimize cornerRadius parse of arc + + + +## 0.19.24 +Tue, 13 Aug 2024 07:47:29 GMT + +### Updates + +- fix: fix wrong stroke style is applied to area + + + +## 0.19.23 +Tue, 06 Aug 2024 05:17:39 GMT + +_Version update only_ + +## 0.19.22 +Mon, 05 Aug 2024 09:08:30 GMT + +_Version update only_ + +## 0.19.21 +Mon, 05 Aug 2024 01:39:45 GMT + +_Version update only_ + +## 0.19.20 +Wed, 31 Jul 2024 09:48:37 GMT + +_Version update only_ + +## 0.19.19 +Tue, 23 Jul 2024 11:56:39 GMT + +_Version update only_ + +## 0.19.18 +Fri, 12 Jul 2024 07:18:10 GMT + +_Version update only_ + +## 0.19.17 +Fri, 05 Jul 2024 17:26:17 GMT + +_Version update only_ + +## 0.19.16 +Fri, 05 Jul 2024 14:29:15 GMT + +_Version update only_ + +## 0.19.15 +Fri, 28 Jun 2024 10:32:37 GMT + +_Version update only_ + +## 0.19.14 +Wed, 26 Jun 2024 09:16:23 GMT + +### Updates + +- feat: upgrade @visactor/vutils + +## 0.19.13 +Tue, 25 Jun 2024 11:17:14 GMT + +_Version update only_ + +## 0.19.12 +Fri, 21 Jun 2024 06:52:50 GMT + +_Version update only_ + +## 0.19.11 +Fri, 14 Jun 2024 09:50:59 GMT + +_Version update only_ + +## 0.19.10 +Thu, 13 Jun 2024 09:52:46 GMT + +_Version update only_ + +## 0.19.9 +Wed, 05 Jun 2024 12:25:00 GMT + +_Version update only_ + +## 0.19.8 +Wed, 05 Jun 2024 08:24:28 GMT + +_Version update only_ + +## 0.19.7 +Tue, 04 Jun 2024 11:10:08 GMT + +_Version update only_ + +## 0.19.6 +Wed, 29 May 2024 06:57:11 GMT + +_Version update only_ + +## 0.19.5 +Fri, 24 May 2024 09:21:23 GMT + +_Version update only_ + +## 0.19.4 +Fri, 17 May 2024 06:46:41 GMT + +### Updates + +- feat: support harmony env + +## 0.19.3 +Fri, 10 May 2024 09:24:39 GMT + +### Updates + +- feat: support baseOpacity for group + +## 0.19.2 +Thu, 09 May 2024 12:26:00 GMT + +### Updates + +- feat: support tt env, closed #1129 + +## 0.19.1 +Wed, 08 May 2024 08:47:35 GMT + +_Version update only_ + +## 0.19.0 +Tue, 30 Apr 2024 08:40:53 GMT + +### Updates + +- feat: support style callback in html and react, fix 1102 + + + +## 0.18.17 +Tue, 30 Apr 2024 07:48:41 GMT + +### Updates + +- fix: fix issue with setLineDash crash, closed #1047 + +## 0.18.16 +Mon, 29 Apr 2024 07:40:31 GMT + +_Version update only_ + +## 0.18.15 +Fri, 26 Apr 2024 10:37:19 GMT + +_Version update only_ + +## 0.18.14 +Wed, 24 Apr 2024 08:07:48 GMT + +_Version update only_ + +## 0.18.13 +Fri, 19 Apr 2024 08:46:08 GMT + +_Version update only_ + +## 0.18.12 +Fri, 19 Apr 2024 07:48:17 GMT + +_Version update only_ + +## 0.18.11 +Wed, 17 Apr 2024 03:02:22 GMT + +### Updates + +- fix: fix for dragenter triggering error in drag event + +## 0.18.10 +Fri, 29 Mar 2024 08:02:16 GMT + +_Version update only_ + +## 0.18.9 +Thu, 28 Mar 2024 10:13:24 GMT + +_Version update only_ + +## 0.18.8 +Wed, 27 Mar 2024 11:33:58 GMT + +### Updates + +- fix: fix issue with pointer tap event point map + +## 0.18.7 +Fri, 22 Mar 2024 08:46:19 GMT + +### Updates + +- fix: set vtag params to optional + +## 0.18.6 +Tue, 19 Mar 2024 10:10:17 GMT + +_Version update only_ + +## 0.18.5 +Tue, 12 Mar 2024 15:16:46 GMT + +_Version update only_ + +## 0.18.4 +Tue, 12 Mar 2024 09:40:06 GMT + +_Version update only_ + +## 0.18.3 +Mon, 11 Mar 2024 08:24:00 GMT + +_Version update only_ + +## 0.18.2 +Fri, 08 Mar 2024 03:19:08 GMT + +_Version update only_ + +## 0.18.1 +Mon, 04 Mar 2024 08:29:15 GMT + +_Version update only_ + +## 0.18.0 +Wed, 28 Feb 2024 10:09:04 GMT + +_Version update only_ + +## 0.17.26 +Wed, 28 Feb 2024 08:06:31 GMT + +### Updates + +- fix: fix issue with load svg sync, fix issue with decode react dom + +## 0.17.25 +Fri, 23 Feb 2024 04:29:58 GMT + +### Updates + +- feat: support offscreenCanvas in lynx env, closed #994 + +## 0.17.24 +Tue, 06 Feb 2024 09:48:26 GMT + +_Version update only_ + +## 0.17.23 +Sun, 04 Feb 2024 12:41:45 GMT + +_Version update only_ + +## 0.17.22 +Fri, 02 Feb 2024 07:17:07 GMT + +_Version update only_ + +## 0.17.21 +Thu, 01 Feb 2024 12:22:29 GMT + +_Version update only_ + +## 0.17.20 +Thu, 01 Feb 2024 09:26:17 GMT + +_Version update only_ + +## 0.17.19 +Wed, 24 Jan 2024 13:11:27 GMT + +_Version update only_ + +## 0.17.18 +Wed, 24 Jan 2024 10:10:41 GMT + +### Updates + +- feat: compatible canvas in lynx env +- fix: fix issue with interface + +## 0.17.17 +Mon, 22 Jan 2024 08:19:38 GMT + +### Updates + +- fix: fix issue with loaded tree-shaking + +## 0.17.16 +Wed, 17 Jan 2024 09:02:13 GMT + +_Version update only_ + +## 0.17.15 +Wed, 17 Jan 2024 06:43:01 GMT + +_Version update only_ + +## 0.17.14 +Fri, 12 Jan 2024 10:33:32 GMT + +_Version update only_ + +## 0.17.13 +Wed, 10 Jan 2024 14:18:21 GMT + +_Version update only_ + +## 0.17.12 +Wed, 10 Jan 2024 03:56:46 GMT + +_Version update only_ + +## 0.17.11 +Fri, 05 Jan 2024 11:54:56 GMT + +_Version update only_ + +## 0.17.10 +Wed, 03 Jan 2024 13:19:34 GMT + +### Updates + +- feat: support fillPickable and strokePickable for area, closed #792 + +## 0.17.9 +Fri, 29 Dec 2023 09:59:13 GMT + +_Version update only_ + +## 0.17.8 +Fri, 29 Dec 2023 07:20:26 GMT + +### Updates + +- fix: fix issue with mapToCanvasPoint in miniapp, closed #828 + +## 0.17.7 +Wed, 20 Dec 2023 10:05:55 GMT + +### Updates + +- fix: fix issue with create layer in miniapp env + +## 0.17.6 +Wed, 20 Dec 2023 07:39:54 GMT + +_Version update only_ + +## 0.17.5 +Tue, 19 Dec 2023 09:13:27 GMT + +_Version update only_ + +## 0.17.4 +Thu, 14 Dec 2023 11:00:38 GMT + +_Version update only_ + +## 0.17.3 +Wed, 13 Dec 2023 12:04:17 GMT + +_Version update only_ + +## 0.17.2 +Tue, 12 Dec 2023 13:05:58 GMT + +### Updates + +- feat(dataZoom): add mask to modify hot zone. feat @visactor/vchart#1415' + +## 0.17.1 +Wed, 06 Dec 2023 11:19:22 GMT + +### Updates + +- feat: support pickStrokeBuffer, closed #758 +- fix: fix issue with rebind pick-contribution + +## 0.17.0 +Thu, 30 Nov 2023 12:58:15 GMT + +### Minor changes + +- feat: optmize bounds performance + +### Updates + +- feat: rect support x1 and y1 +- refactor: refact inversify completely, closed #657 + +## 0.16.18 +Thu, 30 Nov 2023 09:40:58 GMT + +### Updates + +- fix: doubletap should not be triggered when the target is different twice before and after + +## 0.16.17 +Thu, 23 Nov 2023 13:32:49 GMT + +### Updates + +- feat: support 'tap' gesture for Gesture plugin +- fix: \`pickMode: 'imprecise'\` not work in polygon + +## 0.16.16 +Fri, 17 Nov 2023 02:33:59 GMT + +_Version update only_ + +## 0.16.15 +Thu, 16 Nov 2023 02:46:27 GMT + +_Version update only_ + +## 0.16.14 +Wed, 15 Nov 2023 09:56:28 GMT + +_Version update only_ + +## 0.16.13 +Thu, 09 Nov 2023 11:49:33 GMT + +### Updates + +- fix: temp fix issue with lynx measuretext + +## 0.16.12 +Tue, 07 Nov 2023 10:52:54 GMT + +### Updates + +- fix: fix node-canvas max count issue + +## 0.16.11 +Thu, 02 Nov 2023 13:43:18 GMT + +_Version update only_ + +## 0.16.10 +Thu, 02 Nov 2023 11:17:24 GMT + +### Updates + +- fix: fix issue with xul and html attribute, closed #634 + +## 0.16.9 +Fri, 27 Oct 2023 02:21:19 GMT + +_Version update only_ + +## 0.16.8 +Mon, 23 Oct 2023 11:38:47 GMT + +_Version update only_ + +## 0.16.7 +Mon, 23 Oct 2023 08:53:33 GMT + +_Version update only_ + +## 0.16.6 +Mon, 23 Oct 2023 06:30:33 GMT + +_Version update only_ + +## 0.16.5 +Fri, 20 Oct 2023 04:22:42 GMT + +_Version update only_ + +## 0.16.4 +Thu, 19 Oct 2023 10:30:12 GMT + +_Version update only_ + +## 0.16.3 +Wed, 18 Oct 2023 07:43:09 GMT + +### Updates + +- fix: add canvas resize support in lynx env, closed #581 + +## 0.16.2 +Tue, 10 Oct 2023 11:48:48 GMT + +_Version update only_ + +## 0.16.1 +Mon, 09 Oct 2023 09:51:01 GMT + +### Updates + +- fix: fix flex issue, closed, fix node dpr issue, closed #554, closed #555 +- fix: fix reinit env issue + +## 0.16.0 +Thu, 28 Sep 2023 07:23:52 GMT + +_Version update only_ + +## 0.15.5 +Wed, 27 Sep 2023 09:33:50 GMT + +_Version update only_ + +## 0.15.4 +Mon, 25 Sep 2023 03:05:18 GMT + +_Version update only_ + +## 0.15.3 +Wed, 20 Sep 2023 13:12:13 GMT + +_Version update only_ + +## 0.15.2 +Thu, 14 Sep 2023 09:55:56 GMT + +_Version update only_ + +## 0.15.1 +Wed, 13 Sep 2023 02:21:58 GMT + +_Version update only_ + +## 0.15.0 +Tue, 12 Sep 2023 12:20:48 GMT + +_Version update only_ + +## 0.14.8 +Thu, 07 Sep 2023 11:56:00 GMT + +_Version update only_ + +## 0.12.21 +Thu, 31 Aug 2023 10:03:38 GMT + +_Version update only_ + +## 0.12.20 +Tue, 29 Aug 2023 11:30:33 GMT + +_Version update only_ + +## 0.12.19 +Wed, 23 Aug 2023 11:53:28 GMT + +_Version update only_ + +## 0.12.18 +Fri, 18 Aug 2023 10:16:08 GMT + +_Version update only_ + +## 0.12.17 +Wed, 16 Aug 2023 06:46:13 GMT + +_Version update only_ + +## 0.12.16 +Fri, 11 Aug 2023 10:05:27 GMT + +_Version update only_ + +## 0.12.15 +Thu, 10 Aug 2023 12:14:14 GMT + +_Version update only_ + +## 0.12.14 +Thu, 10 Aug 2023 07:22:55 GMT + +_Version update only_ + +## 0.12.13 +Wed, 09 Aug 2023 07:34:23 GMT + +_Version update only_ + +## 0.12.12 +Tue, 08 Aug 2023 09:27:52 GMT + +_Version update only_ + +## 0.12.11 +Thu, 03 Aug 2023 10:04:34 GMT + +_Version update only_ + +## 0.12.10 +Wed, 02 Aug 2023 03:13:00 GMT + +_Version update only_ + +## 0.12.9 +Thu, 27 Jul 2023 12:27:43 GMT + +_Version update only_ + +## 0.12.8 +Tue, 25 Jul 2023 13:33:47 GMT + +_Version update only_ + +## 0.12.7 +Tue, 25 Jul 2023 07:34:59 GMT + +_Version update only_ + +## 0.12.6 +Fri, 21 Jul 2023 10:50:41 GMT + +_Version update only_ + +## 0.12.5 +Thu, 20 Jul 2023 10:41:23 GMT + +### Patches + +- fix: fix the error caused by reflect-metadata in react env + +## 0.12.4 +Wed, 19 Jul 2023 08:29:52 GMT + +_Version update only_ + +## 0.12.3 +Wed, 12 Jul 2023 12:30:46 GMT + +_Version update only_ + +## 0.12.2 +Tue, 11 Jul 2023 13:17:12 GMT + +_Version update only_ + +## 0.12.1 +Fri, 07 Jul 2023 09:04:45 GMT + +_Version update only_ + +## 0.12.0 +Thu, 06 Jul 2023 09:09:12 GMT + +### Minor changes + +- refactor: refactor interfaces and types of typescript + +## 0.11.1 +Tue, 27 Jun 2023 13:38:36 GMT + +_Version update only_ + +## 0.11.0 +Tue, 27 Jun 2023 03:18:18 GMT + +### Minor changes + +- update vUtils version + +## 0.10.3 +Tue, 20 Jun 2023 06:23:42 GMT + +_Version update only_ + +## 0.10.2 +Tue, 20 Jun 2023 03:25:23 GMT + +_Version update only_ + +## 0.10.1 +Mon, 19 Jun 2023 09:49:38 GMT + +### Patches + +- fix the bug of node-canvas wh + +## 0.10.0 +Fri, 16 Jun 2023 03:13:09 GMT + +### Minor changes + +- code style + +### Patches + +- upgrade vrender +- fix enableView3dTranform +- upgrade vrender + +### Updates + +- release + +## 0.9.1 +Thu, 08 Jun 2023 11:34:32 GMT + +_Version update only_ + +## 0.9.0 +Wed, 07 Jun 2023 12:20:05 GMT + +_Initial release_ + diff --git a/packages/vrender-animate/README.md b/packages/vrender-animate/README.md new file mode 100644 index 000000000..0aa75cebf --- /dev/null +++ b/packages/vrender-animate/README.md @@ -0,0 +1,177 @@ +# VRender Animation Module + +This module provides a graph-based animation system for VRender. + +## Features + +- Graph-based animation system +- Support for simple property animations +- Support for sequence and parallel animations +- Support for composite animations +- Support for custom animations +- Support for path animations +- Compatible with the legacy animation system +- Advanced composition capabilities for nested animations +- Proper propagation of animation events in complex choreography + +## Recent Updates + +- **Improved Animation Node Composition**: Fixed issues with propagation in `GraphAdapterNode` to ensure proper animation sequencing +- **Enhanced Duration Calculation**: Better handling of duration for sequence and parallel animations +- **Better Event Handling**: Improved monitoring of animation completion and successor activation + +## Usage + +### Basic Property Animation + +```typescript +import { createAnimationNode, createGraphManager } from '@visactor/vrender-animate'; + +// Create a simple animation node +const moveNode = createAnimationNode({ + id: 'move', + target: myRect, + propertyName: 'x', + toValue: 200, + duration: 1000 +}); + +// Create a graph manager +const graphManager = createGraphManager(moveNode, stage); + +// Start the animation +graphManager.start(); +``` + +### Sequence Animation + +```typescript +import { createAnimationNode, createSequence, createGraphManager } from '@visactor/vrender-animate'; + +// Create animation nodes +const moveNode = createAnimationNode({ + id: 'move', + target: myRect, + propertyName: 'x', + toValue: 200, + duration: 1000 +}); + +const colorNode = createAnimationNode({ + id: 'color', + target: myRect, + propertyName: 'fill', + toValue: 'blue', + duration: 1000 +}); + +// Create a sequence +const sequence = createSequence([moveNode, colorNode]); + +// Create a graph manager +const graphManager = createGraphManager(sequence, stage); + +// Start the animation +graphManager.start(); +``` + +### Nested Animations (Advanced Composition) + +The animation system supports nesting of animations, allowing complex choreography: + +```typescript +import { createAnimationNode, createSequence, createParallel, createGraphManager } from '@visactor/vrender-animate'; + +// Create simple animations for rectangle 1 +const moveRect1 = createAnimationNode({ + target: rect1, + propertyName: 'x', + toValue: 500, + duration: 2000 +}); + +const colorRect1 = createAnimationNode({ + target: rect1, + propertyName: 'fill', + toValue: 'red', + duration: 1000 +}); + +// Create simple animations for rectangle 2 +const moveRect2 = createAnimationNode({ + target: rect2, + propertyName: 'y', + toValue: 300, + duration: 1500 +}); + +const colorRect2 = createAnimationNode({ + target: rect2, + propertyName: 'fill', + toValue: 'blue', + duration: 800 +}); + +// Create sequences for each rectangle +const sequence1 = createSequence([moveRect1, colorRect1]); +const sequence2 = createSequence([moveRect2, colorRect2]); + +// Run both sequences in parallel +const parallelAnimation = createParallel([sequence1, sequence2]); + +// Create additional animations +const finalAnimation = createAnimationNode({ + target: rect3, + propertyName: 'opacity', + toValue: 0, + duration: 1000 +}); + +// Create a final sequence that runs the parallel animations and then the final animation +const fullAnimation = createSequence([parallelAnimation, finalAnimation]); + +// Create graph manager and start the animation +const graphManager = createGraphManager(fullAnimation, stage); +graphManager.start(); +``` + +### Cleanup Completed Animations + +The animation system includes a mechanism to clean up completed animations to free memory and resources: + +```typescript +import { createAnimationNode, createSequence, createGraphManager } from '@visactor/vrender-animate'; + +// Create animation nodes (as shown in previous examples) +// ... + +// Create a graph manager +const graphManager = createGraphManager(myAnimationGraph, stage); + +// Start the animation +graphManager.start(); + +// Later, when animations have completed, you can clean up: +graphManager.onAllComplete = () => { + console.log('All animations completed'); + + // Remove completed animation nodes + const removed = graphManager.cleanupCompletedNodes(); + console.log(`Cleaned up ${removed} completed nodes`); +}; + +// You can also manually trigger cleanup at any time: +function handleCleanupButtonClick() { + const removed = graphManager.cleanupCompletedNodes(); + console.log(`Cleaned up ${removed} completed nodes`); +} + +// By default, the cleanup is performed in "safe mode", which only removes nodes +// that have no successors or whose successors have also completed. +// To forcibly remove all completed nodes: +graphManager.cleanupCompletedNodes(false); +``` + +## API + +See the API documentation for more details. diff --git a/packages/vrender-animate/bundler.config.js b/packages/vrender-animate/bundler.config.js new file mode 100644 index 000000000..d0fe57b42 --- /dev/null +++ b/packages/vrender-animate/bundler.config.js @@ -0,0 +1,13 @@ +/** + * @type {Partial} + */ +module.exports = { + formats: ['cjs', 'es'], + name: 'VRender.Kits', + umdOutputFilename: 'index', + globals: { + '@visactor/vrender-core': 'VRenderCore', + '@visactor/vutils': 'VUtils' + }, + external: ['@visactor/vrender-core', '@visactor/vutils'] +}; diff --git a/packages/vrender-animate/cross-env DEBUG='Bundler*' bundle b/packages/vrender-animate/cross-env DEBUG='Bundler*' bundle new file mode 100644 index 000000000..1fb468141 --- /dev/null +++ b/packages/vrender-animate/cross-env DEBUG='Bundler*' bundle @@ -0,0 +1,73 @@ + + Bundler:config CLI args { _: [] } +0ms + Bundler:config PROJECT_ROOT /Users/bytedance/dev/github/vr/packages/vrender-animate +1ms + Bundler:config User config file path /Users/bytedance/dev/github/vr/packages/vrender-animate/bundl + Bundler:config User config file path /Users/bytedance/dev/github/vr/packages/vrender-animate/bundler.config.js +1ms + Bundler:config Final config { + root: '/Users/bytedance/dev/github/vr/packages/vrender-animate', + sourceDir: 'src', + tsconfig: 'tsconfig.json', + input: { es: 'index.ts', cjs: 'index.ts', umd: 'index.ts' }, + formats: [ 'cjs', 'es' ], + outputDir: { es: 'es', cjs: 'cjs', umd: 'dist' }, + name: 'VRender.Kits', + umdOutputFilename: 'index', + copy: [], + sourcemap: true, + watch: false, + less: false, + envs: { __VERSION__: '"0.22.4"' }, + clean: false, + minify: true, + noEmitOnError: true, + targets: 'defaults and not IE 11', + external: [ '@visactor/vrender-core', '@visactor/vutils' ], + alias: [], + rollupOptions: {}, + preTasks: {}, + postTasks: {}, + globals: { + '@visactor/vrender-core': 'VRenderCore', + '@visactor/vutils': 'VUtils' + }, + _: [] +} +0ms + Bundler:config tsCompilerOptions { + moduleResolution: 'node', + target: 'ES2016', + noEmit: false, + emitDeclarationOnly: false, + declaration: true, + isolatedModules: false, + allowSyntheticDefaultImports: true, + module: 'commonjs', + skipLibCheck: true, + noEmitOnError: true, + baseUrl: '.', + rootDir: './src', + composite: true +} +8ms + Bundler:config tsCompilerOptions { + moduleResolution: 'node', + target: 'ES2016', + noEmit: false, + emitDeclarationOnly: false, + declaration: true, + isolatedModules: false, + allowSyntheticDefaultImports: true, + module: 'es2015', + skipLibCheck: true, + noEmitOnError: true, + baseUrl: '.', + rootDir: './src', + composite: true +} +12ms + Bundler:config RollupOptions {"input":"/Users/bytedance/dev/github/vr/packages/vrender-animate/src + Bundler:config RollupOptions {"input":"/Users/bytedance/dev/github/vr/packages/vrender-animate/src/index.ts","external":["@visactor/vrender-core","@visactor/vutils"],"plugins":[{"name":"node-resolve + Bundler:config RollupOptions {"input":"/Users/bytedance/dev/github/vr/packages/vrender-animate/src/index.ts","external":["@visactor/vrender-core","@visactor/vutils"],"plugins":[{"name":"node-resolve","version":"15.0.2","resolveId":{"order":"post"}},{"name":"commonjs","version":"24.1.0"},{"name":"b + Bundler:config RollupOptions {"input":"/Users/bytedance/dev/github/vr/packages/vrender-animate/src/index.ts","external":["@visactor/vrender-core","@visactor/vutils"],"plugins":[{"name":"node-resolve","version":"15.0.2","resolveId":{"order":"post"}},{"name":"commonjs","version":"24.1.0"},{"name":"babel"},{"name":"replace"},{"name":"typescript"},{"name":"url"},{"name":"alias"}]} +24ms +@rollup/plugin-typescript TS6304: Composite projects may not disable declaration emit. +Circular dependency: src/index.ts -> src/compat/legacy-adapter.ts -> src/index.ts +@rollup/plugin-typescript: outputToFilesystem option is defaulting to true. +✓ Finished [Build cjs] +3s +✓ Finished [Build es] +3s diff --git a/packages/vrender-animate/package.json b/packages/vrender-animate/package.json new file mode 100644 index 000000000..b9adb2718 --- /dev/null +++ b/packages/vrender-animate/package.json @@ -0,0 +1,72 @@ +{ + "name": "@visactor/vrender-animate", + "version": "0.22.11", + "description": "", + "sideEffects": false, + "main": "cjs/index.js", + "module": "es/index.js", + "types": "es/index.d.ts", + "files": [ + "cjs", + "es", + "dist" + ], + "scripts": { + "compile": "tsc --noEmit", + "eslint": "eslint --debug --fix src/", + "build": "cross-env DEBUG='Bundler*' bundle", + "dev": "cross-env DEBUG='Bundler*' bundle --clean -f es -w", + "start": "vite ./vite", + "test": "" + }, + "dependencies": { + "@visactor/vutils": "1.0.4", + "@visactor/vrender-core": "workspace:0.22.11" + }, + "devDependencies": { + "@internal/bundler": "workspace:*", + "@internal/eslint-config": "workspace:*", + "@internal/ts-config": "workspace:*", + "@rushstack/eslint-patch": "~1.1.4", + "canvas": "2.11.2", + "node-fetch": "2.6.6", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@types/node-fetch": "2.6.4", + "@vitejs/plugin-react": "3.1.0", + "eslint": "~8.18.0", + "vite": "3.2.6", + "typescript": "4.9.5", + "cross-env": "^7.0.3" + }, + "keywords": [ + "VisActor", + "graphics", + "renderer", + "vrender", + "vrender-kits" + ], + "homepage": "", + "bugs": "https://github.com/VisActor/VRender/issues", + "repository": { + "type": "git", + "url": "https://github.com/VisActor/VRender.git", + "directory": "packages/vrender-kits" + }, + "author": { + "name": "VisActor", + "url": "https://VisActor.io/" + }, + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "import": "./es/index.js", + "require": "./cjs/index.js" + } + } +} diff --git a/packages/vrender-animate/src/animate-extension.ts b/packages/vrender-animate/src/animate-extension.ts new file mode 100644 index 000000000..36c94ca2b --- /dev/null +++ b/packages/vrender-animate/src/animate-extension.ts @@ -0,0 +1,118 @@ +// 1. 支持animate函数 +// 2. 支持animates map +// 2. 支持animatedAttribute 为所有动画的最终结果(loop为INFINITY的动画不算) +// 3. 支持finalAttribute 为所有动画的最终结果(animatedAttribute 合并了 graphic.attribute之后的最终结果) +// 3. 重载Graphic的getAttributes方法,根据参数getAttributes(final = true)返回finalAttribute = {}; merge(finalAttribute, graphic.attribute, animatedAttribute), +// animatedAttribute为所有动画的最终结果(loop为INFINITY的动画不算) + +import type { IGraphicAnimateParams, IAnimate } from '@visactor/vrender-core'; +import { Animate } from './animate'; +import { DefaultTimeline, defaultTimeline } from './timeline'; +import { DefaultTicker } from './ticker/default-ticker'; +import type { IAnimationConfig } from './executor/executor'; +import { AnimateExecutor } from './executor/animate-executor'; + +// 基于性能考虑,每次调用animate函数,都会设置animatedAttribute为null,每次getAttributes(true)会根据animatedAttribute属性判断是否需要重新计算animatedAttribute。 +export class AnimateExtension { + declare finalAttribute: Record; + _animateExecutor: AnimateExecutor | null; + + declare animates: Map; + + getAttributes(final: boolean = false) { + if (final && this.finalAttribute) { + return this.finalAttribute; + } + return (this as any).attribute; + } + + animate(params?: IGraphicAnimateParams) { + if (!this.animates) { + this.animates = new Map(); + } + const animate = new Animate( + params?.id, + params?.timeline ?? ((this as any).stage && (this as any).stage.getTimeline()) ?? defaultTimeline, + params?.slience + ); + + animate.bind(this as any); + if (params) { + const { onStart, onEnd, onRemove } = params; + onStart != null && animate.onStart(onStart); + onEnd != null && animate.onEnd(onEnd); + onRemove != null && animate.onRemove(onRemove); + } + this.animates.set(animate.id, animate); + animate.onRemove(() => { + animate.stop(); + this.animates.delete(animate.id); + }); + + // TODO 考虑性能问题 + (this as any).stage?.ticker.start(); + + return animate; + } + + createTimeline() { + return new DefaultTimeline(); + } + + createTicker(stage: any) { + return new DefaultTicker(stage); + } + + setFinalAttributes(finalAttribute: Record) { + if (!this.finalAttribute) { + this.finalAttribute = {}; + } + Object.assign(this.finalAttribute, finalAttribute); + } + + initFinalAttributes(finalAttribute: Record) { + this.finalAttribute = finalAttribute; + } + + initAnimateExecutor(): void { + if (!this._animateExecutor) { + this._animateExecutor = new AnimateExecutor(this as any); + } + } + + /** + * Apply animation configuration to the component + * @param config Animation configuration + * @returns This component instance + */ + executeAnimation(config: IAnimationConfig): this { + this.initAnimateExecutor(); + this._animateExecutor.execute(config); + return this; + } + + /** + * Apply animations to multiple components + * @param configs Animation configurations + * @returns This component instance + */ + executeAnimations(configs: IAnimationConfig[]): this { + this.initAnimateExecutor(); + configs.forEach(config => { + this._animateExecutor.execute(config); + }); + return this; + } + + protected getFinalAttribute() { + return this.finalAttribute; + } + + // TODO prev是兼容原本VGrammar函数的一个参数,用于动画中获取上一次属性,目前的逻辑中应该不需要,直接去当前帧的属性即可 + getGraphicAttribute(key: string, prev: boolean = false) { + if (!prev && this.finalAttribute) { + return this.finalAttribute[key]; + } + return (this as any).attribute[key]; + } +} diff --git a/packages/vrender-animate/src/animate.ts b/packages/vrender-animate/src/animate.ts new file mode 100644 index 000000000..d6af3d033 --- /dev/null +++ b/packages/vrender-animate/src/animate.ts @@ -0,0 +1,710 @@ +import { Step, WaitStep } from './step'; +import { + Generator, + AnimateStatus, + AnimateStepType, + type IGraphic, + type IAnimate, + type IStep, + type ICustomAnimate, + type EasingType, + type ITimeline +} from '@visactor/vrender-core'; +import { defaultTimeline } from './timeline'; + +export class Animate implements IAnimate { + readonly id: string | number; + status: AnimateStatus; + target: IGraphic; + + // 回调函数列表 + _onStart?: (() => void)[]; + _onFrame?: ((step: IStep, ratio: number) => void)[]; + _onEnd?: (() => void)[]; + _onRemove?: (() => void)[]; + + // 时间控制 + private _timeline: ITimeline; + private _startTime: number; + private _duration: number; + private _totalDuration: number; + + // 动画控制 + // private _reversed: boolean; + private _loopCount: number; + private _currentLoop: number; + private _bounce: boolean; + + // 链表头节点和尾节点 + private _firstStep: IStep | null; + private _lastStep: IStep | null; + + // 初始属性和屏蔽的属性 + private _startProps: Record; + private _endProps: Record; + private _preventAttrs: Set; + // 优先级,用于判定是否能被后续的动画preventAttr + declare priority: number; + + protected currentTime: number; + slience?: boolean; + + // 临时变量 + lastRunStep?: IStep; + + interpolateUpdateFunction: + | ((from: Record, to: Record, ratio: number, step: IStep, target: IGraphic) => void) + | null; + + constructor( + id: string | number = Generator.GenAutoIncrementId(), + timeline: ITimeline = defaultTimeline, + slience?: boolean + ) { + this.id = id; + this.status = AnimateStatus.INITIAL; + this._timeline = timeline; + timeline.addAnimate(this); + this.slience = slience; + this._startTime = 0; + this._duration = 0; + this._totalDuration = 0; + // this._reversed = false; + this._loopCount = 0; + this._currentLoop = 0; + this._bounce = false; + this._firstStep = null; + this._lastStep = null; + this._startProps = {}; + this._endProps = {}; + this._preventAttrs = new Set(); + this.currentTime = 0; + this.interpolateUpdateFunction = null; + this.priority = 0; + } + + /** + * 获取开始属性 + */ + getStartProps(): Record { + return this._startProps; + } + + /** + * 获取结束属性 + */ + getEndProps(): Record { + return this._endProps; + } + + /** + * 设置时间线 + */ + setTimeline(timeline: ITimeline): void { + this._timeline = timeline; + } + + /** + * 获取时间线 + */ + getTimeline(): ITimeline { + return this._timeline; + } + + /** + * 时间线属性访问器 + */ + get timeline(): ITimeline { + return this._timeline; + } + + /** + * 绑定目标图形 + */ + bind(target: IGraphic): this { + this.target = target; + + if (this.target.onAnimateBind && !this.slience) { + this.target.onAnimateBind(this as any); + } + // 添加一个animationAttribute属性,用于存储动画过程中的属性 + if (!this.target.animationAttribute) { + this.target.animationAttribute = {}; + } + return this; + } + + /** + * 动画步骤:to + * 添加一个to步骤,这会在当前状态到指定状态间进行插值 + */ + to(props: Record, duration: number = 300, easing: EasingType = 'linear'): this { + // 创建新的step + const step = new Step(AnimateStepType.to, props, duration, easing); + + step.bind(this.target, this); + + this.updateStepAfterAppend(step); + + return this; + } + + /** + * 等待延迟 + */ + wait(delay: number): this { + // 创建新的wait step + const step = new WaitStep(AnimateStepType.wait, {}, delay, 'linear'); + + step.bind(this.target, this); + + this.updateStepAfterAppend(step); + + return this; + } + + protected updateStepAfterAppend(step: IStep): void { + // 如果是第一个step + if (!this._firstStep) { + this._firstStep = step; + this._lastStep = step; + } else { + // 添加到链表末尾 + this._lastStep.append(step); + this._lastStep = step; + } + + this.parseStepProps(step); + + this.updateDuration(); + } + + /** + * 解析step的props + * 1. 预先获取step的propKeys并保存 + * 2. 将截止目前的最新props设置到step.props中,这样该props上的属性就是最终的属性了,跳帧时直接设置即可 + * 3. 同步到_endProps中,保存这个Animate实例的最终props + * 4. 给step的props的原型链上绑定Animate的_startProps,这样在下一个step查找fromProps的时候,一定能拿得到值 + */ + parseStepProps(step: IStep) { + if (!this._lastStep) { + return; + } + + /* 预设置step的属性,基于性能考虑,实现比较复杂 */ + // step.propKeys为真实的props属性的key + step.propKeys = step.propKeys || Object.keys(step.props); + // step.props为包含前序step的props的最终props,用于跳帧等场景,可以直接设置 + Object.keys(this._endProps).forEach(key => { + step.props[key] = step.props[key] ?? this._endProps[key]; + }); + // 将最终的props设置到step.props中 + step.propKeys.forEach(key => { + this._endProps[key] = step.props[key]; + }); + // 给step的props的原型链上绑定Animate的_startProps + // 下一个step在查找上一个step.props(也就是找到它的fromProps)的时候,就能拿到初始的props了 + // 比如: + // rect.animate().to({ x: 100 }, 1000, 'linear').to({ y: 100 }, 1000, 'linear'); + // 在第二个step查找fromProps的时候,就能拿到第一个step的endProps中的y值(在原型链上) + // TODO 由于会有其他animate的干扰,所以不能直接设置原型链 + // Object.setPrototypeOf(step.props, this._startProps); + } + + /** + * 重新同步和计算props,用于内部某些step发生了变更后,重新计算自身 + * 性能较差,不要频繁调用 + * @returns + */ + reSyncProps() { + if (!this._lastStep) { + return; + } + this._endProps = {}; + let currentStep: IStep = this._firstStep; + // 从前向后寻找当前时间所在的step + while (currentStep) { + // step.props为包含前序step的props的最终props,用于跳帧等场景,可以直接设置 + // eslint-disable-next-line no-loop-func + Object.keys(this._endProps).forEach(key => { + currentStep.props[key] = currentStep.props[key] ?? this._endProps[key]; + }); + // 将最终的props设置到step.props中 + // eslint-disable-next-line no-loop-func + currentStep.propKeys.forEach(key => { + this._endProps[key] = currentStep.props[key]; + }); + // 给step的props的原型链上绑定Animate的_startProps + // 下一个step在查找上一个step.props(也就是找到它的fromProps)的时候,就能拿到初始的props了 + // 比如: + // rect.animate().to({ x: 100 }, 1000, 'linear').to({ y: 100 }, 1000, 'linear'); + // 在第二个step查找fromProps的时候,就能拿到第一个step的endProps中的y值(在原型链上) + // TODO 由于会有其他animate的干扰,所以不能直接设置原型链 + // Object.setPrototypeOf(currentStep.props, this._startProps); + currentStep = currentStep.next; + } + } + + /** + * 动画步骤:from + * 添加一个from步骤,这会将目标属性先设置为指定值,然后过渡到当前状态 + * 【注意】这可能会导致动画跳变,请谨慎使用 + */ + from(props: Record, duration: number = 300, easing: EasingType = 'linear'): this { + // 创建新的step + const step = new Step(AnimateStepType.from, props, duration, easing); + + // 如果是第一个step + if (!this._firstStep) { + this._firstStep = step; + this._lastStep = step; + } else { + // 添加到链表末尾 + this._lastStep.append(step); + this._lastStep = step; + } + + this.updateDuration(); + + return this; + } + + /** + * 自定义动画 + */ + play(customAnimate: ICustomAnimate): this { + customAnimate.bind(this.target, this); + this.updateStepAfterAppend(customAnimate); + + return this; + } + + /** + * 暂停动画 + */ + pause(): void { + if (this.status === AnimateStatus.RUNNING) { + this.status = AnimateStatus.PAUSED; + } + } + + /** + * 恢复动画 + */ + resume(): void { + if (this.status === AnimateStatus.PAUSED) { + this.status = AnimateStatus.RUNNING; + } + } + + /** + * 注册开始回调 + */ + onStart(cb?: () => void): void { + if (cb) { + if (!this._onStart) { + this._onStart = []; + } + this._onStart.push(cb); + } else { + this._onStart?.forEach(cb => cb()); + // 设置开始属性,Animate不会重复执行start所以不需要判断firstStart + Object.keys(this._endProps).forEach(key => { + this._startProps[key] = this.target.getComputedAttribute(key); + }); + } + } + + /** + * 注册结束回调 + */ + onEnd(cb?: () => void): void { + if (cb) { + if (!this._onEnd) { + this._onEnd = []; + } + this._onEnd.push(cb); + } else { + this._onEnd?.forEach(cb => cb()); + } + } + + /** + * 注册帧回调 + */ + onFrame(cb?: (step: IStep, ratio: number) => void): void { + if (cb) { + if (!this._onFrame) { + this._onFrame = []; + } + this._onFrame.push(cb); + } + } + + /** + * 注册移除回调 + */ + onRemove(cb?: () => void): void { + if (cb) { + if (!this._onRemove) { + this._onRemove = []; + } + this._onRemove.push(cb); + } else { + this._onRemove?.forEach(cb => cb()); + } + } + + /** + * 屏蔽单个属性 + */ + preventAttr(key: string): void { + this._preventAttrs.add(key); + // 从所有step中移除该属性,并从自身的_startProps和_endProps中移除该属性 + delete this._startProps[key]; + delete this._endProps[key]; + let step = this._firstStep; + while (step) { + step.deleteSelfAttr(key); + step = step.next; + } + } + + /** + * 屏蔽多个属性 + */ + preventAttrs(keys: string[]): void { + keys.forEach(key => this._preventAttrs.add(key)); + } + + /** + * 检查属性是否合法(未被屏蔽) + */ + validAttr(key: string): boolean { + return !this._preventAttrs.has(key); + } + + /** + * 运行自定义回调 + */ + runCb(cb: (a: IAnimate, step: IStep) => void): IAnimate { + this._lastStep?.onEnd(cb); + return this; + } + + /** + * 设置动画开始时间 + */ + startAt(t: number): this { + this._startTime = t; + + return this; + } + + /** + * 自定义插值函数,返回false表示没有匹配上 + */ + customInterpolate( + key: string, + ratio: number, + from: any, + to: any, + target: IGraphic, + ret: Record + ): boolean { + // 默认无自定义插值,可由子类重写 + return false; + } + + /** + * 获取起始值,该起始值为animate的起始值,并不一定为step的起始值 + */ + getFromValue(): Record { + return this._startProps; + } + + /** + * 获取结束值 + */ + getToValue(): Record { + return this._endProps; + } + + /** + * 停止动画 + */ + stop(type?: 'start' | 'end' | Record): void { + // TODO 有些动画可能一添加就被删除 + // if (this.status === AnimateStatus.END) { + // return; + // } + // 遍历step,调用其stop + let step = this._firstStep; + while (step) { + step.stop(); + step = step.next; + } + + if (this.status !== AnimateStatus.END) { + this.onEnd(); + } + + this.status = AnimateStatus.END; + + if (!this.target) { + return; + } + + if (type === 'start') { + // 设置为开始状态 + this.target.setAttributes(this._startProps); + } else if (type === 'end') { + // 设置为结束状态 + this.target.setAttributes(this._endProps); + } else if (type) { + // 设置为自定义状态 + this.target.setAttributes(type); + } + } + + /** + * 释放动画资源 + */ + release(): void { + this.status = AnimateStatus.END; + + // 触发移除回调 + if (this._onRemove) { + this._onRemove.forEach(cb => cb()); + } + + // 清空回调 + this._onStart = []; + this._onFrame = []; + this._onEnd = []; + this._onRemove = []; + } + + /** + * 获取动画持续时间 + */ + getDuration(): number { + return this._duration; + } + + /** + * 获取动画开始时间 + */ + getStartTime(): number { + return this._startTime; + } + + /** + * 在所有动画完成后执行 + */ + afterAll(list: IAnimate[]): this { + if (!list || list.length === 0) { + return this; + } + + // 计算所有动画结束的最大时间点 + let maxEndTime = 0; + list.forEach(animate => { + const endTime = animate.getStartTime() + animate.getTotalDuration(); + maxEndTime = Math.max(maxEndTime, endTime); + }); + + // 设置当前动画的开始时间为最大结束时间 + return this.startAt(maxEndTime); + } + + /** + * 在指定动画完成后执行 + */ + after(animate: IAnimate): this { + if (!animate) { + return this; + } + + // 计算指定动画结束的时间点 + const endTime = animate.getStartTime() + animate.getTotalDuration(); + + // 设置当前动画的开始时间为结束时间 + return this.startAt(endTime); + } + + /** + * 并行执行动画 + */ + parallel(animate: IAnimate): this { + if (!animate) { + return this; + } + + // 设置指定动画的开始时间为当前动画的开始时间 + this.startAt(animate.getStartTime()); + + return this; + } + + // /** + // * 设置动画是否反转 + // */ + // reversed(r: boolean): this { + // this._reversed = r; + // return this; + // } + + /** + * 设置动画循环次数,如果传入true,则无限循环,如果传入false,则不循环 + */ + loop(n: number | boolean): this { + if (n === true) { + n = Infinity; + } else if (n === false) { + n = 0; + } + this._loopCount = n; + this.updateDuration(); + return this; + } + + /** + * 设置动画是否反弹 + */ + bounce(b: boolean): this { + this._bounce = b; + return this; + } + + /** + * 推进动画 + */ + advance(delta: number): void { + if (this.status === AnimateStatus.END) { + console.warn('aaa 动画已经结束,不能推进'); + return; + } + const nextTime = this.currentTime + delta; + // 如果还没开始,直接return + if (nextTime < this._startTime) { + this.currentTime = nextTime; + return; + } + // 如果已经结束,设置状态后return + if (nextTime >= this._startTime + this._totalDuration) { + this._lastStep?.onUpdate(true, 1, {}); + this._lastStep?.onEnd(); + this.onEnd(); + this.status = AnimateStatus.END; + return; + } + + this.status = AnimateStatus.RUNNING; + + // 如果是第一次运行,触发开始回调 + if (this.currentTime <= this._startTime) { + this.onStart(); + } + this.currentTime = nextTime; + + let cycleTime = nextTime - this._startTime; + let newLoop = false; + let bounceTime = false; + if (this._loopCount > 0) { + cycleTime = (nextTime - this._startTime) % this._duration; + const currentLoop = Math.floor((nextTime - this._startTime) / this._duration); + newLoop = currentLoop > this._currentLoop; + this._currentLoop = currentLoop; + + bounceTime = this._bounce && currentLoop % 2 === 1; + if (bounceTime) { + cycleTime = this._duration - cycleTime; + } + } + + // 如果是新的循环,重置为初始状态 + if (newLoop && !bounceTime) { + this.target.setAttributes(this._startProps); + } + + // 选择起始步骤和遍历方向 + let targetStep: IStep | null = null; + + if (this._lastStep === this._firstStep) { + targetStep = this._firstStep; + } else { + let currentStep: IStep = this._firstStep; + // 从前向后寻找当前时间所在的step + while (currentStep) { + const stepStartTime = currentStep.getStartTime(); + const stepDuration = currentStep.getDuration(); + const stepEndTime = stepStartTime + stepDuration; + + // 找到当前周期时间所在的step + if (cycleTime >= stepStartTime && cycleTime <= stepEndTime) { + targetStep = currentStep; + break; + } + + currentStep = currentStep.next; + } + } + + // 如果没找到目标step(可能是所有step都执行完了,但整体动画还没结束,这正常是不存在的) + if (!targetStep) { + // this.currentTime = nextTime; + // console.warn('动画出现问题'); + return; + } + + // 如果当前step和上一次执行的step不一样,则调用上一次step的onEnd,确保所有完成的step都调用了结束 + // 如果上一次的step已经调用了onEnd,在下面的onEnd那里会将lastRunStep设置为null + if (targetStep !== this.lastRunStep) { + this.lastRunStep?.onEnd(); + } + + this.lastRunStep = targetStep; + + // 计算当前step的进度比例(基于当前step内的相对时间) + const stepStartTime = targetStep.getStartTime(); + const stepDuration = targetStep.getDuration(); + + const ratio = (cycleTime - stepStartTime) / stepDuration; + // // 限制ratio在0-1之间 + // ratio = Math.max(0, Math.min(1, ratio)); + + const isEnd = ratio >= 1; + targetStep.update(isEnd, ratio, {}); + + // 如果step执行完毕 + if (isEnd) { + targetStep.onEnd(); + this.lastRunStep = null; + // 不立即调用onFinish,让动画系统来决定何时结束 + } + + // 触发帧回调 + // if (this._onFrame) { + // this._onFrame.forEach(cb => cb(targetStep, ratio)); + // } + } + + updateDuration(): void { + if (!this._lastStep) { + this._duration = 0; + return; + } + + this._duration = this._lastStep.getStartTime() + this._lastStep.getDuration(); + this._totalDuration = this._duration * (this._loopCount + 1); + } + + getTotalDuration(): number { + return this._totalDuration; + } + + getLoop(): number { + return this._loopCount; + } +} diff --git a/packages/vrender-animate/src/component/component-animate-extension.ts b/packages/vrender-animate/src/component/component-animate-extension.ts new file mode 100644 index 000000000..abb7b55f9 --- /dev/null +++ b/packages/vrender-animate/src/component/component-animate-extension.ts @@ -0,0 +1,122 @@ +// import type { IGraphic } from '@visactor/vrender-core'; +// import type { IAnimationConfig } from '../executor/executor'; +// import { ComponentAnimator } from './component-animator'; + +// /** +// * Component animation extension that can be mixed in to component classes +// */ +// export class ComponentAnimateExtension { +// private _componentAnimator: ComponentAnimator; + +// /** +// * Get the component animator for this component +// * @returns The ComponentAnimator instance +// */ +// getComponentAnimator(): ComponentAnimator { +// if (!this._componentAnimator) { +// this._componentAnimator = new ComponentAnimator(this as unknown as IGraphic); +// } +// return this._componentAnimator; +// } + +// /** +// * Create a new animation sequence for this component +// * @returns A new ComponentAnimator instance +// */ +// createAnimationSequence(): ComponentAnimator { +// return new ComponentAnimator(this as unknown as IGraphic); +// } + +// /** +// * Create an animation for the component with the given preset +// * @param preset Animation preset name ('appear', 'disappear', etc.) +// * @param options Animation options +// * @returns The ComponentAnimator instance +// */ +// animate(preset: string, options?: Record): ComponentAnimator { +// const animator = this.getComponentAnimator(); + +// // Call the appropriate animation setup method based on preset +// switch (preset) { +// case 'appear': +// this.setupAppearAnimation(animator, options); +// break; +// case 'disappear': +// this.setupDisappearAnimation(animator, options); +// break; +// default: +// throw new Error(`Unknown animation preset: ${preset}`); +// } + +// // Start the animation immediately +// animator.start(); + +// return animator; +// } + +// /** +// * Create an appear animation +// * @param options Animation options +// * @returns The ComponentAnimator instance (not started) +// */ +// createAppearAnimation(options?: Record): ComponentAnimator { +// const animator = this.createAnimationSequence(); +// this.setupAppearAnimation(animator, options); +// return animator; +// } + +// /** +// * Create a disappear animation +// * @param options Animation options +// * @returns The ComponentAnimator instance (not started) +// */ +// createDisappearAnimation(options?: Record): ComponentAnimator { +// const animator = this.createAnimationSequence(); +// this.setupDisappearAnimation(animator, options); +// return animator; +// } + +// /** +// * Execute an animation with the given config directly on the component +// * @param config Animation configuration +// * @returns This component +// */ +// executeAnimation(config: IAnimationConfig): this { +// this.getComponentAnimator() +// .animate(this as unknown as IGraphic, config) +// .start(); +// return this; +// } + +// /** +// * Set up appear animation for this component +// * This is a placeholder method that component classes should override +// * @param animator The ComponentAnimator to set up +// * @param options Animation options +// */ +// protected setupAppearAnimation(animator: ComponentAnimator, options?: Record): void { +// // To be overridden by concrete component classes +// console.warn('setupAppearAnimation not implemented for this component'); +// } + +// /** +// * Set up disappear animation for this component +// * This is a placeholder method that component classes should override +// * @param animator The ComponentAnimator to set up +// * @param options Animation options +// */ +// protected setupDisappearAnimation(animator: ComponentAnimator, options?: Record): void { +// // To be overridden by concrete component classes +// console.warn('setupDisappearAnimation not implemented for this component'); +// } +// } + +// /** +// * Type for components that can be animated +// */ +// export interface IAnimatableComponent { +// animate: (preset: string, options?: Record) => ComponentAnimator; +// createAppearAnimation: (options?: Record) => ComponentAnimator; +// createDisappearAnimation: (options?: Record) => ComponentAnimator; +// executeAnimation: (config: IAnimationConfig) => IAnimatableComponent; +// } diff --git a/packages/vrender-animate/src/component/component-animator.ts b/packages/vrender-animate/src/component/component-animator.ts new file mode 100644 index 000000000..0434feb2c --- /dev/null +++ b/packages/vrender-animate/src/component/component-animator.ts @@ -0,0 +1,178 @@ +import type { IGraphic, IAnimate } from '@visactor/vrender-core'; +import { AnimateExecutor } from '../executor/animate-executor'; +import type { IAnimationConfig } from '../executor/executor'; + +/** + * Animation task that contains information about a scheduled animation + */ +interface IAnimationTask { + graphic: IGraphic; + config: IAnimationConfig; + animate?: IAnimate[]; +} + +/** + * ComponentAnimator provides a way to orchestrate animations across child elements + * with centralized lifecycle management + */ +export class ComponentAnimator { + private component: IGraphic; + private tasks: IAnimationTask[] = []; + private started: boolean = false; + private completed: number = 0; + private totalDuration: number = 0; + private onStartCallbacks: (() => void)[] = []; + private onEndCallbacks: (() => void)[] = []; + private onUpdateCallbacks: ((progress: number) => void)[] = []; + + /** + * Creates a new ComponentAnimator + * @param component The component or group containing elements to animate + */ + constructor(component: IGraphic) { + this.component = component; + } + + /** + * Add animation for a specific graphic element + * @param graphic The graphic element to animate + * @param config Animation configuration + * @param delay Optional delay before starting this animation (in ms) + * @returns This ComponentAnimator for chaining + */ + animate(graphic: IGraphic, config: IAnimationConfig): ComponentAnimator { + if (this.started) { + console.warn('Cannot add animations after animation has started'); + return this; + } + + this.tasks.push({ + graphic, + config + }); + + return this; + } + + /** + * Add a callback to be called when animation starts + * @param callback Function to call when animation starts + * @returns This ComponentAnimator for chaining + */ + onStart(callback: () => void): ComponentAnimator { + this.onStartCallbacks.push(callback); + return this; + } + + /** + * Add a callback to be called when animation ends + * @param callback Function to call when animation ends + * @returns This ComponentAnimator for chaining + */ + onEnd(callback: () => void): ComponentAnimator { + this.onEndCallbacks.push(callback); + return this; + } + + /** + * Add a callback to be called when animation updates + * @param callback Function to call when animation updates (receives progress from 0 to 1) + * @returns This ComponentAnimator for chaining + */ + onUpdate(callback: (progress: number) => void): ComponentAnimator { + this.onUpdateCallbacks.push(callback); + return this; + } + + /** + * Start all animations in this component animation + * @returns This ComponentAnimator + */ + start(): ComponentAnimator { + if (this.started) { + console.warn('Animation has already started'); + return this; + } + + this.started = true; + this.completed = 0; + + // Call onStart callbacks + this.onStartCallbacks.forEach(callback => callback()); + + // Empty animation case + if (this.tasks.length === 0) { + setTimeout(() => { + this.onEndCallbacks.forEach(callback => callback()); + }, 0); + return this; + } + + // Start all animations with their specified delays + this.tasks.forEach(task => { + const executor = new AnimateExecutor(task.graphic); + + // Set up callbacks to track completion + executor.onEnd(() => { + this.completed++; + if (this.completed === this.tasks.length) { + this.onEndCallbacks.forEach(callback => callback()); + } + }); + + const animate = executor.executeItem(task.config, task.graphic); + task.animate = animate; + animate.forEach(animate => { + this.totalDuration = Math.max(this.totalDuration, animate.getStartTime() + animate.getDuration()); + }); + }); + + return this; + } + + deleteSelfAttr(key: string): void { + this.tasks.forEach(task => { + if (task.animate) { + task.animate.forEach(animate => animate.preventAttr(key)); + } + }); + } + + /** + * Stop all animations in this component animation + * @param type Whether to jump to the end state or start state + * @returns This ComponentAnimator + */ + stop(type?: 'start' | 'end'): ComponentAnimator { + this.tasks.forEach(task => { + if (task.animate) { + task.animate.forEach(animate => animate.stop(type)); + } + }); + + // If not already completed, call end callbacks + if (this.started && this.completed !== this.tasks.length) { + this.onEndCallbacks.forEach(callback => callback()); + this.completed = this.tasks.length; + } + + return this; + } + + /** + * Get total duration of all animations including delays + * @returns Total duration in milliseconds + */ + getDuration(): number { + return this.totalDuration; + } +} + +/** + * Factory function to create a ComponentAnimator for a component + * @param component The component or group to animate + * @returns A new ComponentAnimator instance + */ +export function createComponentAnimator(component: IGraphic): ComponentAnimator { + return new ComponentAnimator(component); +} diff --git a/packages/vrender-animate/src/component/index.ts b/packages/vrender-animate/src/component/index.ts new file mode 100644 index 000000000..c67017eb1 --- /dev/null +++ b/packages/vrender-animate/src/component/index.ts @@ -0,0 +1,2 @@ +export * from './component-animator'; +// export * from './component-animate-extension'; diff --git a/packages/vrender-animate/src/config/morphing.ts b/packages/vrender-animate/src/config/morphing.ts new file mode 100644 index 000000000..8dc724e1d --- /dev/null +++ b/packages/vrender-animate/src/config/morphing.ts @@ -0,0 +1,6 @@ +import type { IAnimateConfig } from '@visactor/vrender-core'; + +export const DefaultMorphingAnimateConfig: IAnimateConfig = { + duration: 1000, + easing: 'quadInOut' +}; diff --git a/packages/vrender-animate/src/custom/clip-graphic.ts b/packages/vrender-animate/src/custom/clip-graphic.ts new file mode 100644 index 000000000..ffd05dee9 --- /dev/null +++ b/packages/vrender-animate/src/custom/clip-graphic.ts @@ -0,0 +1,233 @@ +import type { IArcGraphicAttribute, IGraphic, IGroup, IRectGraphicAttribute } from '@visactor/vrender-core'; +import { application, AttributeUpdateType, type EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export class ClipGraphicAnimate extends ACustomAnimate { + private _group?: IGroup; + private _clipGraphic?: IGraphic; + protected clipFromAttribute?: any; + protected clipToAttribute?: any; + + private _lastClip?: boolean; + private _lastPath?: IGraphic[]; + + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params: { group: IGroup; clipGraphic: IGraphic } + ) { + super(null, {}, duration, easing, params); + this.clipFromAttribute = from; + this.clipToAttribute = to; + this._group = params?.group; + this._clipGraphic = params?.clipGraphic; + } + + onBind() { + super.onBind(); + if (this._group && this._clipGraphic) { + this._lastClip = this._group.attribute.clip; + this._lastPath = this._group.attribute.path; + this._group.setAttributes( + { + clip: true, + path: [this._clipGraphic] + }, + false, + { type: AttributeUpdateType.ANIMATE_BIND } + ); + } + } + + onEnd() { + if (this._group) { + this._group.setAttributes( + { + clip: this._lastClip, + path: this._lastPath + }, + false, + { type: AttributeUpdateType.ANIMATE_END } + ); + } + return; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this._clipGraphic) { + return; + } + const res: any = {}; + Object.keys(this.clipFromAttribute).forEach(k => { + res[k] = this.clipFromAttribute[k] + (this.clipToAttribute[k] - this.clipFromAttribute[k]) * ratio; + }); + this._clipGraphic.setAttributes(res, false, { + type: AttributeUpdateType.ANIMATE_UPDATE, + animationState: { ratio, end } + }); + } +} + +export class ClipAngleAnimate extends ClipGraphicAnimate { + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params: { + group: IGroup; + center?: { x: number; y: number }; + startAngle?: number; + radius?: number; + orient?: 'clockwise' | 'anticlockwise'; + animationType?: 'in' | 'out'; + } + ) { + const groupAttribute = params?.group?.attribute ?? {}; + const width = groupAttribute.width ?? 0; + const height = groupAttribute.height ?? 0; + + const animationType = params?.animationType ?? 'in'; + const startAngle = params?.startAngle ?? 0; + const orient = params?.orient ?? 'clockwise'; + + let arcStartAngle = 0; + let arcEndAngle = 0; + if (orient === 'anticlockwise') { + arcEndAngle = animationType === 'in' ? startAngle + Math.PI * 2 : startAngle; + arcEndAngle = startAngle + Math.PI * 2; + } else { + arcStartAngle = startAngle; + arcEndAngle = animationType === 'out' ? startAngle + Math.PI * 2 : startAngle; + } + const arc = application.graphicService.creator.arc({ + x: params?.center?.x ?? width / 2, + y: params?.center?.y ?? height / 2, + outerRadius: params?.radius ?? (width + height) / 2, + innerRadius: 0, + startAngle: arcStartAngle, + endAngle: arcEndAngle, + fill: true + }); + let fromAttributes: Partial; + let toAttributes: Partial; + if (orient === 'anticlockwise') { + fromAttributes = { startAngle: startAngle + Math.PI * 2 }; + toAttributes = { startAngle: startAngle }; + } else { + fromAttributes = { endAngle: startAngle }; + toAttributes = { endAngle: startAngle + Math.PI * 2 }; + } + super( + animationType === 'in' ? fromAttributes : toAttributes, + animationType === 'in' ? toAttributes : fromAttributes, + duration, + easing, + { group: params?.group, clipGraphic: arc } + ); + } +} + +export class ClipRadiusAnimate extends ClipGraphicAnimate { + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params: { + group: IGroup; + center?: { x: number; y: number }; + startRadius?: number; + endRadius?: number; + animationType?: 'in' | 'out'; + } + ) { + const groupAttribute = params?.group?.attribute ?? {}; + const width = groupAttribute.width ?? 0; + const height = groupAttribute.height ?? 0; + + const animationType = params?.animationType ?? 'in'; + const startRadius = params?.startRadius ?? 0; + const endRadius = params?.endRadius ?? Math.sqrt((width / 2) ** 2 + (height / 2) ** 2); + + const arc = application.graphicService.creator.arc({ + x: params?.center?.x ?? width / 2, + y: params?.center?.y ?? height / 2, + outerRadius: animationType === 'out' ? endRadius : startRadius, + innerRadius: 0, + startAngle: 0, + endAngle: Math.PI * 2, + fill: true + }); + const fromAttributes: Partial = { outerRadius: startRadius }; + const toAttributes: Partial = { outerRadius: endRadius }; + super( + animationType === 'in' ? fromAttributes : toAttributes, + animationType === 'in' ? toAttributes : fromAttributes, + duration, + easing, + { group: params?.group, clipGraphic: arc } + ); + } +} + +export class ClipDirectionAnimate extends ClipGraphicAnimate { + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params: { + group: IGroup; + direction?: 'x' | 'y'; + orient?: 'positive' | 'negative'; + width?: number; + height?: number; + animationType?: 'in' | 'out'; + } + ) { + const groupAttribute = params?.group?.attribute ?? {}; + const width = params?.width ?? groupAttribute.width ?? 0; + const height = params?.height ?? groupAttribute.height ?? 0; + + const animationType = params?.animationType ?? 'in'; + const direction = params?.direction ?? 'x'; + const orient = params?.orient ?? 'positive'; + + const rect = application.graphicService.creator.rect({ + x: 0, + y: 0, + width: animationType === 'in' && direction === 'x' ? 0 : width, + height: animationType === 'in' && direction === 'y' ? 0 : height, + fill: true + }); + let fromAttributes: Partial = {}; + let toAttributes: Partial = {}; + if (direction === 'y') { + if (orient === 'negative') { + fromAttributes = { y: height, height: 0 }; + toAttributes = { y: 0, height: height }; + } else { + fromAttributes = { height: 0 }; + toAttributes = { height: height }; + } + } else { + if (orient === 'negative') { + fromAttributes = { x: width, width: 0 }; + toAttributes = { x: 0, width: width }; + } else { + fromAttributes = { width: 0 }; + toAttributes = { width: width }; + } + } + super( + animationType === 'in' ? fromAttributes : toAttributes, + animationType === 'in' ? toAttributes : fromAttributes, + duration, + easing, + { group: params?.group, clipGraphic: rect } + ); + } +} diff --git a/packages/vrender-animate/src/custom/clip.ts b/packages/vrender-animate/src/custom/clip.ts new file mode 100644 index 000000000..41c5861e8 --- /dev/null +++ b/packages/vrender-animate/src/custom/clip.ts @@ -0,0 +1,33 @@ +import type { EasingType } from '@visactor/vrender-core'; +import { CommonIn, CommonOut } from './common'; + +export interface IScaleAnimationOptions { + direction?: 'x' | 'y' | 'xy'; +} + +export class ClipIn extends CommonIn { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + this.keys = ['clipRange']; + this.from = { clipRange: 0 }; + } + onFirstRun(): void { + super.onFirstRun(); + const { clipDimension } = this.params?.options || {}; + // 需要设置clipRangeByDimension + if (clipDimension) { + (this.target.attribute as any).clipRangeByDimension = clipDimension; + } + } +} + +export class ClipOut extends CommonOut { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + this.keys = ['clipRange']; + } +} diff --git a/packages/vrender-animate/src/custom/common.ts b/packages/vrender-animate/src/custom/common.ts new file mode 100644 index 000000000..6c510b152 --- /dev/null +++ b/packages/vrender-animate/src/custom/common.ts @@ -0,0 +1,99 @@ +import type { EasingType, IAnimate, IStep } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export interface IScaleAnimationOptions { + direction?: 'x' | 'y' | 'xy'; +} + +export class CommonIn extends ACustomAnimate> { + declare valid: boolean; + + keys: string[]; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const attrs = this.target.getFinalAttribute(); + const fromAttrs: Record = this.target.attribute ?? {}; + + const to: Record = {}; + const from: Record = this.from ?? {}; + this.keys.forEach(key => { + to[key] = attrs?.[key] ?? 1; + from[key] = from[key] ?? fromAttrs[key] ?? 0; + }); + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + this.props = to; + this.propKeys = this.keys; + this.from = from; + this.to = to; + + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +export class CommonOut extends ACustomAnimate> { + declare valid: boolean; + + keys: string[]; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const attrs: Record = this.target.attribute; + + const to: Record = {}; + const from: Record = {}; + this.keys.forEach(key => { + to[key] = 0; + from[key] = attrs[key] ?? 1; + }); + + this.props = to; + this.propKeys = this.keys; + this.from = from; + this.to = to; + + Object.assign(this.target.attribute, from); + this.target.addUpdatePositionTag(); + this.target.addUpdateBoundTag(); + // this.target.setAttributes(from as any); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} diff --git a/packages/vrender-animate/src/custom/custom-animate.ts b/packages/vrender-animate/src/custom/custom-animate.ts new file mode 100644 index 000000000..747af59a2 --- /dev/null +++ b/packages/vrender-animate/src/custom/custom-animate.ts @@ -0,0 +1,52 @@ +import type { ComponentAnimator } from '../component'; +import type { EasingType, IAnimateStepType, ICustomAnimate } from '@visactor/vrender-core'; +import { Step } from '../step'; + +export abstract class ACustomAnimate extends Step implements ICustomAnimate { + type: IAnimateStepType = 'customAnimate'; + declare customFrom: T; + declare params?: any; + declare props?: T; + declare from?: T; + declare to?: T; + + // 为了兼容旧的api,from和to是可选的,且尽量不需要From,因为为了避免突变,From都应该从当前位置开始 + // 所以From并不会真正设置到fromProps中,而是作为customFrom参数 + constructor(customFrom: T, customTo: T, duration: number, easing: EasingType, params?: any) { + super('customAnimate', customTo, duration, easing); + this.customFrom = customFrom; + this.params = params; + } + + update(end: boolean, ratio: number, out: Record): void { + // TODO 需要修复,只有在开始的时候才调用 + this.onStart(); + if (!this.props || !this.propKeys) { + return; + } + // 应用缓动函数 + const easedRatio = this.easing(ratio); + this.onUpdate(end, easedRatio, out); + this.syncAttributeUpdate(); + } + + protected setProps(props: T) { + this.props = props; + this.propKeys = Object.keys(props); + this.animate.reSyncProps(); + } +} + +export abstract class AComponentAnimate extends ACustomAnimate { + protected _animator: ComponentAnimator; + + completeBind(animator: ComponentAnimator): void { + this.setStartTime(0); + this._animator && this._animator.start(); + this.setDuration(animator.getDuration()); + } + + stop(): void { + this._animator && this._animator.stop(); + } +} diff --git a/packages/vrender-animate/src/custom/fade.ts b/packages/vrender-animate/src/custom/fade.ts new file mode 100644 index 000000000..20cc9cdfc --- /dev/null +++ b/packages/vrender-animate/src/custom/fade.ts @@ -0,0 +1,25 @@ +import type { EasingType } from '@visactor/vrender-core'; +import { CommonIn, CommonOut } from './common'; + +export interface IScaleAnimationOptions { + direction?: 'x' | 'y' | 'xy'; +} + +export class FadeIn extends CommonIn { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + this.keys = ['opacity', 'fillOpacity', 'strokeOpacity']; + this.from = { opacity: 0, fillOpacity: 0, strokeOpacity: 0 }; + } +} + +export class FadeOut extends CommonOut { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + this.keys = ['opacity', 'fillOpacity', 'strokeOpacity']; + } +} diff --git a/packages/vrender-animate/src/custom/fromTo.ts b/packages/vrender-animate/src/custom/fromTo.ts new file mode 100644 index 000000000..4f325e432 --- /dev/null +++ b/packages/vrender-animate/src/custom/fromTo.ts @@ -0,0 +1,81 @@ +import type { EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export class FromTo extends ACustomAnimate> { + declare valid: boolean; + + keys: string[]; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + this.from = from ?? {}; + } + + onBind(): void { + super.onBind(); + + const finalAttribute = this.target.getFinalAttribute(); + // 如果存在from,不存在to,那么需要设置给props + Object.keys(this.from).forEach(key => { + if (this.props[key] == null) { + this.props[key] = finalAttribute[key]; + } + }); + + // 如果入场动画,那么需要设置属性 + if (this.target.context?.animationState === 'appear') { + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + } + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(this.from); + } + } + + onFirstRun(): void { + // 获取上一步的属性值作为起始值 + this.from = { ...this.getLastProps(), ...this.from }; + const startProps = this.animate.getStartProps(); + this.propKeys && + this.propKeys.forEach(key => { + this.from[key] = this.from[key] ?? startProps[key]; + }); + // TODO:比较hack + // 如果是入场动画,那么还需要设置属性 + // if (this.target.context?.animationState === 'appear') { + // // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + // const finalAttribute = this.target.getFinalAttribute(); + // this.target.setAttributes(finalAttribute); + // } + this.target.setAttributes(this.from); + } + + /** + * 更新执行的时候调用 + * 如果跳帧了就不一定会执行 + */ + update(end: boolean, ratio: number, out: Record): void { + // TODO 需要修复,只有在开始的时候才调用 + this.onStart(); + if (!this.props || !this.propKeys) { + return; + } + // 应用缓动函数 + const easedRatio = this.easing(ratio); + this.animate.interpolateUpdateFunction + ? this.animate.interpolateUpdateFunction(this.from, this.props, easedRatio, this, this.target) + : this.interpolateUpdateFunctions.forEach((func, index) => { + // 如果这个属性被屏蔽了,直接跳过 + if (!this.animate.validAttr(this.propKeys[index])) { + return; + } + const key = this.propKeys[index]; + const fromValue = this.from[key]; + const toValue = this.props[key]; + func(key, fromValue, toValue, easedRatio, this, this.target); + }); + this.onUpdate(end, easedRatio, out); + this.syncAttributeUpdate(); + } +} diff --git a/packages/vrender-animate/src/custom/groupFade.ts b/packages/vrender-animate/src/custom/groupFade.ts new file mode 100644 index 000000000..5af5f96c9 --- /dev/null +++ b/packages/vrender-animate/src/custom/groupFade.ts @@ -0,0 +1,22 @@ +import type { EasingType, IGroup } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; +import { CommonIn, CommonOut } from './common'; + +export class GroupFadeIn extends CommonIn { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + this.keys = ['baseOpacity']; + this.from = { baseOpacity: 0 }; + } +} + +export class GroupFadeOut extends CommonOut { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + this.keys = ['baseOpacity']; + } +} diff --git a/packages/vrender-animate/src/custom/growAngle.ts b/packages/vrender-animate/src/custom/growAngle.ts new file mode 100644 index 000000000..f2952f9a0 --- /dev/null +++ b/packages/vrender-animate/src/custom/growAngle.ts @@ -0,0 +1,252 @@ +import { type IGraphic, type IGroup, type EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; +import { isNumber } from '@visactor/vutils'; + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +export interface IGrowAngleAnimationOptions { + orient?: 'clockwise' | 'anticlockwise'; + overall?: boolean | number; +} + +const growAngleInIndividual = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + if (options && options.orient === 'anticlockwise') { + return { + from: { startAngle: attrs?.endAngle }, + to: { startAngle: attrs?.startAngle } + }; + } + return { + from: { endAngle: attrs?.startAngle }, + to: { endAngle: attrs?.endAngle } + }; +}; + +const growAngleInOverall = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + + if (options && options.orient === 'anticlockwise') { + const overallValue = isNumber(options.overall) ? options.overall : Math.PI * 2; + return { + from: { + startAngle: overallValue, + endAngle: overallValue + }, + to: { + startAngle: attrs?.startAngle, + endAngle: attrs?.endAngle + } + }; + } + const overallValue = isNumber(options?.overall) ? options.overall : 0; + return { + from: { + startAngle: overallValue, + endAngle: overallValue + }, + to: { + startAngle: attrs?.startAngle, + endAngle: attrs?.endAngle + } + }; +}; + +export const growAngleIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growAngleInOverall(graphic, options, animationParameters) + : growAngleInIndividual(graphic, options, animationParameters); +}; + +const growAngleOutIndividual = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.attribute as any; + + if (options && options.orient === 'anticlockwise') { + return { + from: { startAngle: attrs.startAngle }, + to: { startAngle: attrs?.endAngle } + }; + } + return { + from: { endAngle: attrs.endAngle }, + to: { endAngle: attrs?.startAngle } + }; +}; + +const growAngleOutOverall = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.attribute as any; + if (options && options.orient === 'anticlockwise') { + const overallValue = isNumber(options.overall) ? options.overall : Math.PI * 2; + return { + from: { + startAngle: attrs.startAngle, + endAngle: attrs.endAngle + }, + to: { + startAngle: overallValue, + endAngle: overallValue + } + }; + } + const overallValue = isNumber(options?.overall) ? options.overall : 0; + return { + from: { + startAngle: attrs.startAngle, + endAngle: attrs.endAngle + }, + to: { + startAngle: overallValue, + endAngle: overallValue + } + }; +}; + +export const growAngleOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowAngleAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growAngleOutOverall(graphic, options, animationParameters) + : growAngleOutIndividual(graphic, options, animationParameters); +}; + +export class GrowAngleBase extends ACustomAnimate> { + declare valid: boolean; + + declare _updateFunction: (ratio: number) => void; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + determineUpdateFunction(): void { + if (!this.propKeys) { + this.valid = false; + } else if (this.propKeys && this.propKeys.length > 1) { + this._updateFunction = this.updateAngle; + } else if (this.propKeys[0] === 'startAngle') { + this._updateFunction = this.updateStartAngle; + } else if (this.propKeys[0] === 'endAngle') { + this._updateFunction = this.updateEndAngle; + } else { + this.valid = false; + } + } + + /** + * 删除自身属性,会直接从props等内容里删除掉 + */ + deleteSelfAttr(key: string): void { + delete this.props[key]; + // fromProps在动画开始时才会计算,这时可能不在 + this.fromProps && delete this.fromProps[key]; + const index = this.propKeys.indexOf(key); + if (index !== -1) { + this.propKeys.splice(index, 1); + } + + if (this.propKeys && this.propKeys.length > 1) { + this._updateFunction = this.updateAngle; + } else if (this.propKeys[0] === 'startAngle') { + this._updateFunction = this.updateStartAngle; + } else if (this.propKeys[0] === 'endAngle') { + this._updateFunction = this.updateEndAngle; + } else { + this._updateFunction = null; + } + } + + updateStartAngle(ratio: number): void { + (this.target.attribute as any).startAngle = + this.from.startAngle + (this.to.startAngle - this.from.startAngle) * ratio; + } + + updateEndAngle(ratio: number): void { + (this.target.attribute as any).endAngle = this.from.endAngle + (this.to.endAngle - this.from.endAngle) * ratio; + } + + updateAngle(ratio: number): void { + this.updateStartAngle(ratio); + this.updateEndAngle(ratio); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (this._updateFunction) { + this._updateFunction(ratio); + this.target.addUpdateShapeAndBoundsTag(); + } + } +} + +/** + * 增长渐入 + */ +export class GrowAngleIn extends GrowAngleBase { + onBind(): void { + super.onBind(); + const { from, to } = growAngleIn(this.target, this.params.options, this.params); + const fromAttrs = this.target.context?.lastAttrs ?? from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + this.target.setAttributes(fromAttrs); + this.determineUpdateFunction(); + } +} + +export class GrowAngleOut extends GrowAngleBase { + onBind(): void { + super.onBind(); + const { from, to } = growAngleOut(this.target, this.params.options, this.params); + const fromAttrs = from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs ?? (this.target.attribute as any); + this.to = to; + + // this.target.setAttributes(fromAttrs); + this.determineUpdateFunction(); + } +} diff --git a/packages/vrender-animate/src/custom/growCenter.ts b/packages/vrender-animate/src/custom/growCenter.ts new file mode 100644 index 000000000..3ecb6cc30 --- /dev/null +++ b/packages/vrender-animate/src/custom/growCenter.ts @@ -0,0 +1,265 @@ +import type { IGraphic, IGroup, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { isValid } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +interface IGrowCartesianAnimationOptions { + orient?: 'positive' | 'negative'; + overall?: boolean | number; + direction?: 'x' | 'y' | 'xy'; +} + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +const growCenterIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + switch (options?.direction) { + case 'x': { + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + + return { + from: isValid(width) + ? { + x: x + width / 2, + x1: undefined, + width: 0 + } + : { + x: (x + x1) / 2, + x1: (x + x1) / 2, + width: undefined + }, + to: { x, x1, width } + }; + } + case 'y': { + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + + return { + from: isValid(height) + ? { + y: y + height / 2, + y1: undefined, + height: 0 + } + : { + y: (y + y1) / 2, + y1: (y + y1) / 2, + height: undefined + }, + to: { y, y1, height } + }; + } + case 'xy': + default: { + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + const from: any = {}; + + if (isValid(width)) { + from.x = x + width / 2; + from.width = 0; + from.x1 = undefined; + } else { + from.x = (x + x1) / 2; + from.x1 = (x + x1) / 2; + from.width = undefined; + } + + if (isValid(height)) { + from.y = y + height / 2; + from.height = 0; + from.y1 = undefined; + } else { + from.y = (y + y1) / 2; + from.y1 = (y + y1) / 2; + from.height = undefined; + } + + return { + from, + to: { x, y, x1, y1, width, height } + }; + } + } +}; + +const growCenterOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.attribute as any; + switch (options?.direction) { + case 'x': { + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + + return { + to: isValid(width) + ? { + x: x + width / 2, + x1: undefined, + width: 0 + } + : { + x: (x + x1) / 2, + x1: (x + x1) / 2, + width: undefined + } + }; + } + case 'y': { + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + + return { + to: isValid(height) + ? { + y: y + height / 2, + y1: undefined, + height: 0 + } + : { + y: (y + y1) / 2, + y1: (y + y1) / 2, + height: undefined + } + }; + } + case 'xy': + default: { + const x = attrs.x; + const y = attrs.y; + const x1 = attrs.x1; + const y1 = attrs.y1; + const width = attrs.width; + const height = attrs.height; + const to: any = {}; + + if (isValid(width)) { + to.x = x + width / 2; + to.width = 0; + to.x1 = undefined; + } else { + to.x = (x + x1) / 2; + to.x1 = (x + x1) / 2; + to.width = undefined; + } + + if (isValid(height)) { + to.y = y + height / 2; + to.height = 0; + to.y1 = undefined; + } else { + to.y = (y + y1) / 2; + to.y1 = (y + y1) / 2; + to.height = undefined; + } + + return { + to + }; + } + } +}; + +/** + * 增长渐入 + */ +export class GrowCenterIn extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growCenterIn(this.target, this.params.options, this.params); + const fromAttrs = this.target.context?.lastAttrs ?? from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + this.target.setAttributes(fromAttrs); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +export class GrowCenterOut extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growCenterOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + + this.from = from ?? (this.target.attribute as any); + this.to = to; + // this.target.setAttributes(from); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} diff --git a/packages/vrender-animate/src/custom/growHeight.ts b/packages/vrender-animate/src/custom/growHeight.ts new file mode 100644 index 000000000..c2d9a6fe9 --- /dev/null +++ b/packages/vrender-animate/src/custom/growHeight.ts @@ -0,0 +1,234 @@ +import type { IGraphic, IGroup, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { isNil, isNumber, isValid } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +interface IGrowCartesianAnimationOptions { + orient?: 'positive' | 'negative'; + overall?: boolean | number; + direction?: 'x' | 'y' | 'xy'; + layoutRect?: { width: number; height: number }; +} + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +function growHeightInIndividual( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + + if (options && options.orient === 'negative') { + const computedY1 = isValid(height) ? Math.max(y, y + height) : Math.max(y, y1); + return { + from: { y: computedY1, y1: isNil(y1) ? undefined : computedY1, height: isNil(height) ? undefined : 0 }, + to: { y: y, y1: y1, height: height } + }; + } + + const computedY = isValid(height) ? Math.min(y, y + height) : Math.min(y, y1); + return { + from: { y: computedY, y1: isNil(y1) ? undefined : computedY, height: isNil(height) ? undefined : 0 }, + to: { y: y, y1: y1, height: height } + }; +} + +function growHeightInOverall( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + + let overallValue: number; + if (options && options.orient === 'negative') { + if (isNumber(options.overall)) { + overallValue = options.overall; + } else if (animationParameters.group) { + overallValue = + (animationParameters as any).groupHeight ?? + options.layoutRect?.height ?? + animationParameters.group.getBounds().height(); + + (animationParameters as any).groupHeight = overallValue; + } else { + overallValue = animationParameters.height; + } + } else { + overallValue = isNumber(options?.overall) ? options.overall : 0; + } + return { + from: { y: overallValue, y1: isNil(y1) ? undefined : overallValue, height: isNil(height) ? undefined : 0 }, + to: { y: y, y1: y1, height: height } + }; +} + +const growHeightIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growHeightInOverall(graphic, options, animationParameters) + : growHeightInIndividual(graphic, options, animationParameters); +}; + +/** + * 增长渐入 + */ +export class GrowHeightIn extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growHeightIn(this.target, this.params.options, this.params); + const fromAttrs = this.target.context?.lastAttrs ?? from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + this.target.setAttributes(fromAttrs); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +function growHeightOutIndividual( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const y = attrs.y; + const y1 = attrs.y1; + const height = attrs.height; + + if (options && options.orient === 'negative') { + const computedY1 = isValid(height) ? Math.max(y, y + height) : Math.max(y, y1); + + return { + to: { y: computedY1, y1: isNil(y1) ? undefined : computedY1, height: isNil(height) ? undefined : 0 } + }; + } + + const computedY = isValid(height) ? Math.min(y, y + height) : Math.min(y, y1); + return { + to: { y: computedY, y1: isNil(y1) ? undefined : computedY, height: isNil(height) ? undefined : 0 } + }; +} + +function growHeightOutOverall( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const y1 = attrs.y1; + const height = attrs.height; + + let overallValue: number; + if (options && options.orient === 'negative') { + if (isNumber(options.overall)) { + overallValue = options.overall; + } else if (animationParameters.group) { + overallValue = + (animationParameters as any).groupHeight ?? + options.layoutRect?.height ?? + animationParameters.group.getBounds().height(); + + (animationParameters as any).groupHeight = overallValue; + } else { + overallValue = animationParameters.height; + } + } else { + overallValue = isNumber(options?.overall) ? options.overall : 0; + } + return { + to: { y: overallValue, y1: isNil(y1) ? undefined : overallValue, height: isNil(height) ? undefined : 0 } + }; +} + +/** + * 增长渐出 + */ +export const growHeightOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growHeightOutOverall(graphic, options, animationParameters) + : growHeightOutIndividual(graphic, options, animationParameters); +}; + +export class GrowHeightOut extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growHeightOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from ?? (this.target.attribute as any); + this.to = to; + // this.target.setAttributes(from); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} diff --git a/packages/vrender-animate/src/custom/growPoints.ts b/packages/vrender-animate/src/custom/growPoints.ts new file mode 100644 index 000000000..2dfa37554 --- /dev/null +++ b/packages/vrender-animate/src/custom/growPoints.ts @@ -0,0 +1,346 @@ +import { pointInterpolation, type IGraphic, type IGroup, type EasingType } from '@visactor/vrender-core'; +import type { IPointLike } from '@visactor/vutils'; +import { isValidNumber } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +export interface IGrowPointsAnimationOptions { + orient?: 'positive' | 'negative'; +} + +export interface IGrowPointsOverallAnimationOptions extends IGrowPointsAnimationOptions { + center?: IPointLike; +} + +const getCenterPoints = ( + graphic: IGraphic, + options: IGrowPointsOverallAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + const points: IPointLike[] = attrs.points; + const center: IPointLike = { x: 0, y: 0 }; + points.forEach(point => { + center.x += point.x; + center.y += point.y; + }); + center.x /= points.length; + center.y /= points.length; + + if (options && options.center) { + if (isValidNumber(options.center.x)) { + center.x = options.center.x; + } + if (isValidNumber(options.center.y)) { + center.y = options.center.y; + } + } + + if (graphic.type === 'area') { + center.x1 = center.x; + center.y1 = center.y; + } + + return points.map(point => Object.assign({}, point, center)); +}; + +export const growPointsIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsOverallAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: getCenterPoints(graphic, options, animationParameters) }, + to: { points: attrs.points } + }; +}; + +export const growPointsOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsOverallAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: attrs.points }, + to: { points: getCenterPoints(graphic, options, animationParameters) } + }; +}; + +export class GworPointsBase extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const fromPoints = this.from?.points as unknown as IPointLike[]; + const toPoints = this.to?.points as unknown as IPointLike[]; + if (!fromPoints || !toPoints) { + return; + } + + (this.target.attribute as any).points = fromPoints.map((point, index) => { + const newPoint = pointInterpolation(fromPoints[index], toPoints[index], ratio); + return newPoint; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 增长渐入 + */ +export class GrowPointsIn extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line', 'polygon'].includes(this.target.type)) { + const { from, to } = growPointsIn(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } else { + this.valid = false; + } + } +} + +export class GrowPointsOut extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line'].includes(this.target.type)) { + const attrs = this.target.getFinalAttribute(); + const { from, to } = growPointsOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from || attrs; + this.to = to; + } else { + this.valid = false; + } + } +} + +const changePointsX = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + const points = attrs.points; + return points.map((point: IPointLike) => { + if (options && options.orient === 'negative') { + let groupRight = graphic.stage.viewWidth; + + if (graphic.parent.parent.parent) { + groupRight = graphic.parent.parent.parent.AABBBounds.width(); + } + + return { + ...point, + x: groupRight, + y: point.y, + x1: groupRight, + y1: point.y1, + defined: point.defined !== false + } as IPointLike; + } + return { + ...point, + x: 0, + y: point.y, + x1: 0, + y1: point.y1, + defined: point.defined !== false + } as IPointLike; + }); +}; + +const growPointsXIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: changePointsX(graphic, options, animationParameters) }, + to: { points: attrs.points } + }; +}; + +const growPointsXOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: attrs.points }, + to: { points: changePointsX(graphic, options, animationParameters) } + }; +}; + +export class GrowPointsXIn extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line', 'polygon'].includes(this.target.type)) { + const { from, to } = growPointsXIn(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } else { + this.valid = false; + } + } +} + +export class GrowPointsXOut extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line'].includes(this.target.type)) { + const attrs = this.target.getFinalAttribute(); + const { from, to } = growPointsXOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from || attrs; + this.to = to; + } else { + this.valid = false; + } + } +} + +const changePointsY = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + const points = attrs.points; + return points.map((point: IPointLike) => { + if (options && options.orient === 'negative') { + let groupBottom = graphic.stage.viewHeight; + if (graphic.parent.parent.parent) { + groupBottom = graphic.parent.parent.parent.AABBBounds.height(); + } + + return { + ...point, + x: point.x, + y: groupBottom, + x1: point.x1, + y1: groupBottom, + defined: point.defined !== false + } as IPointLike; + } + return { + ...point, + x: point.x, + y: 0, + x1: point.x1, + y1: 0, + defined: point.defined !== false + } as IPointLike; + }); +}; + +const growPointsYIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: changePointsY(graphic, options, animationParameters) }, + to: { points: attrs.points } + }; +}; + +const growPointsYOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowPointsAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + return { + from: { points: attrs.points }, + to: { points: changePointsY(graphic, options, animationParameters) } + }; +}; + +export class GrowPointsYIn extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line', 'polygon'].includes(this.target.type)) { + const { from, to } = growPointsYIn(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } else { + this.valid = false; + } + } +} + +export class GrowPointsYOut extends GworPointsBase { + onBind(): void { + super.onBind(); + if (['area', 'line', 'polygon'].includes(this.target.type)) { + const { from, to } = growPointsYOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from ?? (this.target.attribute as any); + this.to = to; + } else { + this.valid = false; + } + } +} diff --git a/packages/vrender-animate/src/custom/growRadius.ts b/packages/vrender-animate/src/custom/growRadius.ts new file mode 100644 index 000000000..6755fedd0 --- /dev/null +++ b/packages/vrender-animate/src/custom/growRadius.ts @@ -0,0 +1,175 @@ +import { type IGraphic, type IGroup, type EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; +import { isNumber } from '@visactor/vutils'; + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +export interface IGrowAngleAnimationOptions { + orient?: 'clockwise' | 'anticlockwise'; + overall?: boolean | number; +} + +export interface IGrowRadiusAnimationOptions { + orient?: 'inside' | 'outside'; + overall?: boolean | number; +} + +const growRadiusInIndividual = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + + if (options && options.orient === 'inside') { + return { + from: { innerRadius: attrs?.outerRadius }, + to: { innerRadius: attrs?.innerRadius } + }; + } + return { + from: { outerRadius: attrs?.innerRadius }, + to: { outerRadius: attrs?.outerRadius } + }; +}; + +const growRadiusInOverall = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + const overallValue = isNumber(options?.overall) ? options.overall : 0; + return { + from: { + innerRadius: overallValue, + outerRadius: overallValue + }, + to: { + innerRadius: attrs?.innerRadius, + outerRadius: attrs?.outerRadius + } + }; +}; + +export const growRadiusIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growRadiusInOverall(graphic, options, animationParameters) + : growRadiusInIndividual(graphic, options, animationParameters); +}; + +const growRadiusOutIndividual = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + if (options && options.orient === 'inside') { + return { + from: { innerRadius: attrs?.innerRadius }, + to: { innerRadius: attrs?.outerRadius } + }; + } + return { + from: { outerRadius: attrs?.outerRadius }, + to: { outerRadius: attrs?.innerRadius } + }; +}; + +const growRadiusOutOverall = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + const attrs = graphic.getFinalAttribute(); + const overallValue = isNumber(options?.overall) ? options.overall : 0; + return { + from: { + innerRadius: attrs?.innerRadius, + outerRadius: attrs?.outerRadius + }, + to: { + innerRadius: overallValue, + outerRadius: overallValue + } + }; +}; + +export const growRadiusOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowRadiusAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growRadiusOutOverall(graphic, options, animationParameters) + : growRadiusOutIndividual(graphic, options, animationParameters); +}; + +export class GrowPointsBase extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 增长渐入 + */ +export class GrowRadiusIn extends GrowPointsBase { + onBind(): void { + super.onBind(); + const { from, to } = growRadiusIn(this.target, this.params.options, this.params); + const fromAttrs = this.target.context?.lastAttrs ?? from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + this.target.setAttributes(fromAttrs); + } +} + +export class GrowRadiusOut extends GrowPointsBase { + onBind(): void { + super.onBind(); + const { to } = growRadiusOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + + this.from = this.target.attribute as any; + this.to = to; + // this.target.setAttributes(fromAttrs); + } +} diff --git a/packages/vrender-animate/src/custom/growWidth.ts b/packages/vrender-animate/src/custom/growWidth.ts new file mode 100644 index 000000000..4c7061684 --- /dev/null +++ b/packages/vrender-animate/src/custom/growWidth.ts @@ -0,0 +1,223 @@ +import type { IGraphic, IGroup, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { isNil, isNumber, isValid } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +interface IGrowCartesianAnimationOptions { + orient?: 'positive' | 'negative'; + overall?: boolean | number; + direction?: 'x' | 'y' | 'xy'; +} + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +type TypeAnimation = ( + graphic: T, + options: any, + animationParameters: IAnimationParameters +) => { from?: { [channel: string]: any }; to?: { [channel: string]: any } }; + +function growWidthInIndividual( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + + if (options && options.orient === 'negative') { + const computedX1 = isValid(width) ? Math.max(x, x + width) : Math.max(x, x1); + + return { + from: { x: computedX1, x1: isNil(x1) ? undefined : computedX1, width: isNil(width) ? undefined : 0 }, + to: { x: x, x1: x1, width: width } + }; + } + + const computedX = isValid(width) ? Math.min(x, x + width) : Math.min(x, x1); + return { + from: { x: computedX, x1: isNil(x1) ? undefined : computedX, width: isNil(width) ? undefined : 0 }, + to: { x: x, x1: x1, width: width } + }; +} + +function growWidthInOverall( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + // no need to handle the situation where x > x1 + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + let overallValue: number; + if (options && options.orient === 'negative') { + if (isNumber(options.overall)) { + overallValue = options.overall; + } else if (animationParameters.group) { + overallValue = (animationParameters as any).groupWidth ?? animationParameters.group.getBounds().width(); + + (animationParameters as any).groupWidth = overallValue; + } else { + overallValue = animationParameters.width; + } + } else { + overallValue = isNumber(options?.overall) ? options?.overall : 0; + } + return { + from: { x: overallValue, x1: isNil(x1) ? undefined : overallValue, width: isNil(width) ? undefined : 0 }, + to: { x: x, x1: x1, width: width } + }; +} + +const growWidthIn: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growWidthInOverall(graphic, options, animationParameters) + : growWidthInIndividual(graphic, options, animationParameters); +}; + +function growWidthOutIndividual( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const x = attrs.x; + const x1 = attrs.x1; + const width = attrs.width; + + if (options && options.orient === 'negative') { + const computedX1 = isValid(width) ? Math.max(x, x + width) : Math.max(x, x1); + + return { + to: { x: computedX1, x1: isNil(x1) ? undefined : computedX1, width: isNil(width) ? undefined : 0 } + }; + } + + const computedX = isValid(width) ? Math.min(x, x + width) : Math.min(x, x1); + return { + to: { x: computedX, x1: isNil(x1) ? undefined : computedX, width: isNil(width) ? undefined : 0 } + }; +} + +function growWidthOutOverall( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) { + const attrs = graphic.getFinalAttribute(); + const x1 = attrs.x1; + const width = attrs.width; + + let overallValue: number; + if (options && options.orient === 'negative') { + if (isNumber(options.overall)) { + overallValue = options.overall; + } else if (animationParameters.group) { + overallValue = (animationParameters as any).groupWidth ?? animationParameters.group.getBounds().width(); + + (animationParameters as any).groupWidth = overallValue; + } else { + overallValue = animationParameters.width; + } + } else { + overallValue = isNumber(options?.overall) ? options.overall : 0; + } + return { + to: { x: overallValue, x1: isNil(x1) ? undefined : overallValue, width: isNil(width) ? undefined : 0 } + }; +} + +export const growWidthOut: TypeAnimation = ( + graphic: IGraphic, + options: IGrowCartesianAnimationOptions, + animationParameters: IAnimationParameters +) => { + return (options?.overall ?? false) !== false + ? growWidthOutOverall(graphic, options, animationParameters) + : growWidthOutIndividual(graphic, options, animationParameters); +}; + +/** + * 增长渐入 + */ +export class GrowWidthIn extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growWidthIn(this.target, this.params.options, this.params); + const fromAttrs = this.target.context?.lastAttrs ?? from; + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = fromAttrs; + this.to = to; + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + this.target.setAttributes(fromAttrs); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +export class GrowWidthOut extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + const { from, to } = growWidthOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => to[key] != null); + this.from = from ?? (this.target.attribute as any); + this.to = to; + // this.target.setAttributes(from); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} diff --git a/packages/vrender-animate/src/custom/input-text.ts b/packages/vrender-animate/src/custom/input-text.ts new file mode 100644 index 000000000..1e4ad70ff --- /dev/null +++ b/packages/vrender-animate/src/custom/input-text.ts @@ -0,0 +1,146 @@ +import type { EasingType, IAnimate, IStep } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +/** + * 文本输入动画,实现类似打字机的字符逐个显示效果 + * 支持通过beforeText和afterText参数添加前缀和后缀 + * 支持通过showCursor参数显示光标,cursorChar自定义光标字符 + */ +export class InputText extends ACustomAnimate<{ text: string }> { + declare valid: boolean; + + private fromText: string = ''; + private toText: string = ''; + private showCursor: boolean = false; + private cursorChar: string = '|'; + private blinkCursor: boolean = true; + private beforeText: string = ''; + private afterText: string = ''; + + constructor( + from: { text: string }, + to: { text: string }, + duration: number, + easing: EasingType, + params?: { + showCursor?: boolean; + cursorChar?: string; + blinkCursor?: boolean; + beforeText?: string; + afterText?: string; + } + ) { + super(from, to, duration, easing, params); + + // 配置光标相关选项 + if (params?.showCursor !== undefined) { + this.showCursor = params.showCursor; + } + if (params?.cursorChar !== undefined) { + this.cursorChar = params.cursorChar; + } + if (params?.blinkCursor !== undefined) { + this.blinkCursor = params.blinkCursor; + } + + // 配置前缀和后缀文本 + if (params?.beforeText !== undefined) { + this.beforeText = params.beforeText; + } + if (params?.afterText !== undefined) { + this.afterText = params.afterText; + } + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + const fromText = fromProps.text ?? ''; + const toText = toProps.text ?? ''; + + // 初始化解析结果 + this.valid = true; + + // 存储文本用于动画 + this.fromText = fromText.toString(); + this.toText = toText.toString(); + + // 确保to不为空 + if (!this.toText && this.toText !== '') { + this.valid = false; + return; + } + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + // 动画结束时,显示完整文本(不带闪烁光标) + if (this.showCursor && !this.blinkCursor) { + // 如果有光标但不闪烁,保留光标 + this.target.setAttribute('text', this.beforeText + this.toText + this.cursorChar + this.afterText); + } else { + // 不显示光标 + this.target.setAttribute('text', this.beforeText + this.toText + this.afterText); + } + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + + // 计算当前应该显示的字符数量 + const totalChars = this.toText.length; + const fromChars = this.fromText.length; + + // 如果fromText比toText长,则是删除动画 + // 否则是添加动画 + let currentLength: number; + let currentText: string; + + if (fromChars > totalChars) { + // 删除文本动画(从多到少) + currentLength = Math.round(fromChars - (fromChars - totalChars) * ratio); + currentText = this.fromText.substring(0, currentLength); + } else { + // 添加文本动画(从少到多) + currentLength = Math.round(fromChars + (totalChars - fromChars) * ratio); + + // 如果fromText是toText的前缀,则直接使用toText的子串 + if (this.toText.startsWith(this.fromText)) { + currentText = this.toText.substring(0, currentLength); + } else { + // 否则需要在fromText和toText之间进行过渡 + if (currentLength <= fromChars) { + currentText = this.fromText.substring(0, currentLength); + } else { + currentText = this.toText.substring(0, currentLength - fromChars + Math.min(fromChars, currentLength)); + } + } + } + + // 构建最终显示的文本 + let displayText = this.beforeText + currentText + this.afterText; + + // 添加光标效果 + if (this.showCursor) { + if (this.blinkCursor) { + // 闪烁效果:在动画期间,光标每半个周期闪烁一次 + const blinkRate = 0.1; // 光标闪烁频率(每10%动画进度闪烁一次) + const showCursorNow = Math.floor(ratio / blinkRate) % 2 === 0; + + if (showCursorNow) { + displayText = this.beforeText + currentText + this.cursorChar + this.afterText; + } + } else { + // 固定光标(不闪烁) + displayText = this.beforeText + currentText + this.cursorChar + this.afterText; + } + } + + // 更新图形的text属性 + this.target.setAttribute('text', displayText); + } +} diff --git a/packages/vrender-animate/src/custom/label-item-animate.ts b/packages/vrender-animate/src/custom/label-item-animate.ts new file mode 100644 index 000000000..639be6492 --- /dev/null +++ b/packages/vrender-animate/src/custom/label-item-animate.ts @@ -0,0 +1,233 @@ +import { AComponentAnimate } from './custom-animate'; +import { createComponentAnimator } from '../component'; +import { InputText } from './input-text'; + +/** + * LabelItemAppear class handles the appear animation for StoryLabelItem components + */ +export class LabelItemAppear extends AComponentAnimate { + onBind(): void { + super.onBind(); + const animator = createComponentAnimator(this.target); + this._animator = animator; + const duration = this.duration; + const easing = this.easing; + const target = this.target as any; + + const { symbolStartOuterType = 'scale', titleType = 'typewriter', titlePanelType = 'scale' } = this.params; + + const symbolTime = duration / 10; + target._symbolStart.setAttributes({ scaleX: 0, scaleY: 0 }); + + animator.animate(target._symbolStart, { + type: 'to', + to: { scaleX: 1, scaleY: 1 }, + duration: symbolTime * 5, + easing + }); + + let symbolStartOuterFrom: any; + let symbolStartOuterTo: any; + if (symbolStartOuterType === 'scale') { + symbolStartOuterFrom = { scaleX: 0, scaleY: 0 }; + symbolStartOuterTo = { scaleX: 1, scaleY: 1 }; + } else { + symbolStartOuterFrom = { clipRange: 0 }; + symbolStartOuterTo = { clipRange: 1 }; + } + target._symbolStartOuter.setAttributes(symbolStartOuterFrom); + + animator.animate(target._symbolStartOuter, { + type: 'to', + to: symbolStartOuterTo, + duration: symbolTime * 5, + easing + }); + + target._symbolEnd.setAttributes({ scaleX: 0, scaleY: 0 }); + + animator.animate(target._symbolEnd, { + type: 'to', + to: { scaleX: 1, scaleY: 1 }, + duration: symbolTime * 2, + delay: symbolTime * 8, + easing + }); + + target._line.setAttributes({ clipRange: 0 }); + + animator.animate(target._line, { + type: 'to', + to: { clipRange: 1 }, + duration: symbolTime * 9, + easing + }); + + if (titleType === 'typewriter') { + const titleTopText = target._titleTop.attribute.text as string; + target._titleTop.setAttributes({ text: '' }); + + animator.animate(target._titleTop, { + type: 'custom', + delay: symbolTime * 5, + duration: symbolTime * 4, + easing: 'linear', + to: { text: titleTopText }, + custom: InputText + }); + + const titleBottomText = target._titleBottom.attribute.text as string; + target._titleBottom.setAttributes({ text: '' }); + + animator.animate(target._titleBottom, { + type: 'custom', + delay: symbolTime * 5, + duration: symbolTime * 4, + easing: 'linear', + to: { text: titleBottomText }, + custom: InputText + }); + } else { + target._titleTop.setAttributes({ dy: target._titleTop.AABBBounds.height() + 10 }); + + animator.animate(target._titleTop, { + type: 'to', + to: { + dy: 0 + }, + delay: symbolTime * 5, + duration: symbolTime * 4, + easing: 'linear' + }); + + target._titleBottom.setAttributes({ dy: -(10 + target._titleBottom.AABBBounds.height()) }); + + animator.animate(target._titleBottom, { + type: 'to', + to: { + dy: 0 + }, + delay: symbolTime * 5, + duration: symbolTime * 4, + easing: 'linear' + }); + } + + if (titlePanelType === 'scale') { + [target._titleTopPanel, target._titleBottomPanel].forEach(panel => { + const scaleX = panel.attribute.scaleX ?? 1; + panel.setAttributes({ scaleX: 0 }); + animator.animate(panel, { + type: 'to', + to: { + scaleX + }, + duration, + easing + }); + }); + } else if (titlePanelType === 'stroke') { + [target._titleTopPanel, target._titleBottomPanel].forEach(panel => { + const b = panel.AABBBounds; + const totalLen = (b.width() + b.height()) * 2; + panel.setAttributes({ lineDash: [0, totalLen * 10] }); + animator.animate(panel, { + type: 'to', + to: { + lineDash: [totalLen, totalLen * 10] + }, + duration, + easing + }); + }); + } + + this.completeBind(animator); + } +} + +/** + * LabelItemDisappear class handles the disappear animation for StoryLabelItem components + */ +export class LabelItemDisappear extends AComponentAnimate { + onBind(): void { + super.onBind(); + const animator = createComponentAnimator(this.target); + this._animator = animator; + + const duration = this.duration; + const easing = this.easing; + const target = this.target as any; + + const { mode } = this.params; + + if (mode === 'scale') { + animator.animate(target._symbolStart, { + type: 'to', + to: { scaleX: 0, scaleY: 0 }, + duration, + easing + }); + } else { + animator.animate(target._line, { + type: 'to', + to: { clipRange: 0 }, + duration, + easing + }); + + animator.animate(target._symbolStart, { + type: 'to', + to: { scaleX: 0, scaleY: 0 }, + duration: duration / 2, + delay: duration / 2, + easing + }); + + animator.animate(target._symbolEnd, { + type: 'to', + to: { scaleX: 0, scaleY: 0 }, + duration, + easing + }); + + animator.animate(target._titleTop, { + type: 'to', + to: { dy: target._titleTop.AABBBounds.height() + 10 }, + duration: duration / 2, + easing + }); + + animator.animate(target._titleBottom, { + type: 'to', + to: { dy: -(10 + target._titleBottom.AABBBounds.height()) }, + duration: duration / 2, + easing + }); + + animator.animate(target._symbolStartOuter, { + type: 'to', + to: { clipRange: 0 }, + duration: duration / 2, + delay: duration / 2, + easing + }); + + animator.animate(target._titleTopPanel, { + type: 'to', + to: { scaleX: 0 }, + duration, + easing + }); + + animator.animate(target._titleBottomPanel, { + type: 'to', + to: { scaleX: 0 }, + duration, + easing + }); + } + + this.completeBind(animator); + } +} diff --git a/packages/vrender-core/src/animate/morphing.ts b/packages/vrender-animate/src/custom/morphing.ts similarity index 79% rename from packages/vrender-core/src/animate/morphing.ts rename to packages/vrender-animate/src/custom/morphing.ts index 312aaaf8a..e5b65ba64 100644 --- a/packages/vrender-core/src/animate/morphing.ts +++ b/packages/vrender-animate/src/custom/morphing.ts @@ -5,39 +5,35 @@ import { splitRect, splitPolygon, splitArea, - splitPath -} from './../common/split-path'; -import type { - ICustomPath2D, - IGraphic, - MorphingAnimateConfig, - IRect, - EasingType, - MultiMorphingAnimateConfig, - IArc, - ICircle, - IGraphicAttribute, - ILine, - IPolygon, - IArea, - IPath -} from './../interface'; -import { CustomPath2D } from '../common/custom-path2d'; -import { ACustomAnimate } from './animate'; -import { + splitPath, + CustomPath2D, + application, + interpolateColor, + ColorStore, + ColorType, alignBezierCurves, applyTransformOnBezierCurves, findBestMorphingRotation, - pathToBezierCurves -} from '../common/morphing-utils'; -import { application } from '../application'; -import type { IMatrix } from '@visactor/vutils'; -import { isNil } from '@visactor/vutils'; -import { interpolateColor } from '../color-string/interpolate'; -import { ColorStore, ColorType } from '../color-string'; -import { DefaultMorphingAnimateConfig } from './config'; -import { isTransformKey } from '../common/utils'; -import { AttributeUpdateType } from '../common/enums'; + pathToBezierCurves, + AttributeUpdateType, + type MorphingAnimateConfig, + type MultiMorphingAnimateConfig, + type ICustomPath2D, + type IGraphic, + type IRect, + type EasingType, + type IArc, + type ICircle, + type IGraphicAttribute, + type ILine, + type IPolygon, + type IArea, + type IPath +} from '@visactor/vrender-core'; +import { isArray, isNil, type IMatrix } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; +import { DefaultMorphingAnimateConfig } from '../config/morphing'; +import { isTransformKey } from '../utils/transform'; declare const __DEV__: boolean; @@ -55,6 +51,12 @@ interface OtherAttrItem { key: string; } +/** + * 插值计算非路径属性(如颜色、透明度等) + * @param attrs 要插值的属性数组 + * @param out 输出对象 + * @param ratio 插值比例 + */ const interpolateOtherAttrs = (attrs: OtherAttrItem[], out: any, ratio: number) => { attrs.forEach(entry => { if (Number.isFinite(entry.to)) { @@ -77,6 +79,12 @@ const interpolateOtherAttrs = (attrs: OtherAttrItem[], out: any, ratio: number) * License: https://github.com/ecomfe/zrender/blob/master/LICENSE * @license */ +/** + * 根据给定比例插值计算形变数据并应用到路径 + * @param morphingData 形变数据 + * @param path 目标路径对象 + * @param ratio 插值比例 + */ const interpolateMorphingData = (morphingData: MorphingDataItem[], path: ICustomPath2D, ratio: number) => { const tmpArr: number[] = []; const newCp: number[] = []; @@ -133,6 +141,13 @@ const interpolateMorphingData = (morphingData: MorphingDataItem[], path: ICustom } }; +/** + * 解析形变数据,将源路径和目标路径转换为贝塞尔曲线并找到最佳旋转角度 + * @param fromPath 源路径 + * @param toPath 目标路径 + * @param config 变换配置 + * @returns 形变数据数组 + */ const parseMorphingData = ( fromPath: ICustomPath2D | null, toPath: ICustomPath2D, @@ -141,6 +156,7 @@ const parseMorphingData = ( toTransfrom: IMatrix; } ) => { + // [fromPath, toPath] = [toPath, fromPath]; const fromBezier = fromPath ? pathToBezierCurves(fromPath) : []; const toBezier = pathToBezierCurves(toPath); @@ -178,6 +194,12 @@ const validateOtherAttrs = [ // 'lineWidth' ]; +/** + * 解析可动画属性,提取源属性和目标属性的差异 + * @param fromAttrs 源属性 + * @param toAttrs 目标属性 + * @returns 可动画属性数组 + */ const parseOtherAnimateAttrs = ( fromAttrs: Partial | null, toAttrs: Partial | null @@ -193,19 +215,21 @@ const parseOtherAnimateAttrs = ( return; } - const toValue = toAttrs[fromKey]; - if (!isNil(toValue) && !isNil(fromAttrs[fromKey]) && toValue !== fromAttrs[fromKey]) { + const toValue = (toAttrs as any)[fromKey]; + if (!isNil(toValue) && !isNil((fromAttrs as any)[fromKey]) && toValue !== (fromAttrs as any)[fromKey]) { if (fromKey === 'fill' || fromKey === 'stroke') { + const parseColor = (color: string) => { + return typeof color === 'string' ? ColorStore.Get(color, ColorType.Color255) : color; + }; res.push({ - from: - typeof fromAttrs[fromKey] === 'string' - ? ColorStore.Get(fromAttrs[fromKey] as unknown as string, ColorType.Color255) - : fromAttrs[fromKey], - to: typeof toValue === 'string' ? ColorStore.Get(toValue as string, ColorType.Color255) : toValue, + from: isArray(fromAttrs[fromKey]) + ? (fromAttrs[fromKey] as any).map(parseColor) + : parseColor((fromAttrs as any)[fromKey]), + to: isArray(toValue) ? toValue.map(parseColor) : parseColor(toValue), key: fromKey }); } else { - res.push({ from: fromAttrs[fromKey], to: toValue, key: fromKey }); + res.push({ from: (fromAttrs as any)[fromKey], to: toValue, key: fromKey }); } hasAttr = true; @@ -215,7 +239,10 @@ const parseOtherAnimateAttrs = ( return hasAttr ? res : null; }; -export class MorphingPath extends ACustomAnimate { +/** + * 形变路径动画类,用于处理路径和其他属性的形变 + */ +export class MorphingPath extends ACustomAnimate> { declare path: CustomPath2D; saveOnEnd?: boolean; @@ -226,7 +253,7 @@ export class MorphingPath extends ACustomAnimate { duration: number, easing: EasingType ) { - super(0, 1, duration, easing); + super({}, {}, duration, easing); this.morphingData = config.morphingData; this.otherAttrs = config.otherAttrs; this.saveOnEnd = config.saveOnEnd; @@ -247,6 +274,12 @@ export class MorphingPath extends ACustomAnimate { return; } + /** + * 更新动画状态 + * @param end 是否结束 + * @param ratio 动画进度比例 + * @param out 输出属性对象 + */ onUpdate(end: boolean, ratio: number, out: Record): void { const target = this.target as IGraphic; const pathProxy = typeof target.pathProxy === 'function' ? target.pathProxy(target.attribute) : target.pathProxy; @@ -254,6 +287,7 @@ export class MorphingPath extends ACustomAnimate { if (this.otherAttrs && this.otherAttrs.length) { interpolateOtherAttrs(this.otherAttrs, out, ratio); } + this.target.setAttributes(out); // 计算位置 if (end && !this.saveOnEnd) { (this.target as IGraphic).pathProxy = null; @@ -261,6 +295,14 @@ export class MorphingPath extends ACustomAnimate { } } +/** + * 创建从一个图形到另一个图形的形变动画 + * @param fromGraphic 源图形 + * @param toGraphic 目标图形 + * @param animationConfig 动画配置 + * @param fromGraphicTransform 源图形变换矩阵 + * @returns 动画实例 + */ export const morphPath = ( fromGraphic: IGraphic | null, toGraphic: IGraphic, @@ -300,17 +342,22 @@ export const morphPath = ( animate.wait(animationConfig.delay); } - animate.play( - new MorphingPath( - { morphingData, otherAttrs: attrs }, - animationConfig?.duration ?? DefaultMorphingAnimateConfig.duration, - animationConfig?.easing ?? DefaultMorphingAnimateConfig.easing - ) + const morphingPath = new MorphingPath( + { morphingData, otherAttrs: attrs }, + animationConfig?.duration ?? DefaultMorphingAnimateConfig.duration, + animationConfig?.easing ?? DefaultMorphingAnimateConfig.easing ); + animate.play(morphingPath); return animate; }; +/** + * 创建从一个图形到多个图形的形变动画 + * @param fromGraphic 源图形 + * @param toGraphics 目标图形数组 + * @param animationConfig 动画配置 + */ export const oneToMultiMorph = ( fromGraphic: IGraphic, toGraphics: IGraphic[], @@ -358,7 +405,10 @@ export const oneToMultiMorph = ( }); }; -export class MultiToOneMorphingPath extends ACustomAnimate { +/** + * 多对一形变路径动画类,用于处理多个路径形变为一个目标路径 + */ +export class MultiToOneMorphingPath extends ACustomAnimate> { declare path: CustomPath2D; otherAttrs?: OtherAttrItem[][]; @@ -368,7 +418,7 @@ export class MultiToOneMorphingPath extends ACustomAnimate { duration: number, easing: EasingType ) { - super(0, 1, duration, easing); + super({}, {}, duration, easing); this.morphingData = config.morphingData; this.otherAttrs = config.otherAttrs; } @@ -383,6 +433,9 @@ export class MultiToOneMorphingPath extends ACustomAnimate { this.addPathProxy(); } + /** + * 为每个子图形添加路径代理 + */ private addPathProxy() { const shadowRoot = (this.target as IGraphic).shadowRoot; @@ -393,6 +446,9 @@ export class MultiToOneMorphingPath extends ACustomAnimate { this.onUpdate(false, 0, (this.target as IGraphic).attribute); } + /** + * 清除所有子图形的路径代理 + */ private clearPathProxy() { const shadowRoot = (this.target as IGraphic).shadowRoot; @@ -405,6 +461,12 @@ export class MultiToOneMorphingPath extends ACustomAnimate { return; } + /** + * 更新动画状态 + * @param end 是否结束 + * @param ratio 动画进度比例 + * @param out 输出属性对象 + */ onUpdate(end: boolean, ratio: number, out: Record): void { const shadowRoot = (this.target as IGraphic).shadowRoot; @@ -428,12 +490,17 @@ export class MultiToOneMorphingPath extends ACustomAnimate { } } +/** + * 解析图形的阴影子元素属性(排除变换相关属性) + * @param graphicAttrs 图形属性 + * @returns 阴影子元素属性 + */ const parseShadowChildAttrs = (graphicAttrs: Partial) => { const attrs: Partial = {}; Object.keys(graphicAttrs).forEach(key => { if (!isTransformKey(key)) { - attrs[key] = graphicAttrs[key]; + (attrs as any)[key] = (graphicAttrs as any)[key]; } }); @@ -447,6 +514,12 @@ const parseShadowChildAttrs = (graphicAttrs: Partial) => { return attrs; }; +/** + * 将阴影子元素添加到图形中 + * @param graphic 目标图形 + * @param children 子元素数组 + * @param count 子元素数量 + */ const appendShadowChildrenToGraphic = (graphic: IGraphic, children: IGraphic[], count: number) => { const childAttrs = parseShadowChildAttrs(graphic.attribute); const shadowRoot = graphic.attachShadow(); @@ -481,6 +554,13 @@ const appendShadowChildrenToGraphic = (graphic: IGraphic, children: IGraphic[], } }; +/** + * 克隆图形为多个相同的图形 + * @param graphic 源图形 + * @param count 克隆数量 + * @param needAppend 是否需要添加到源图形中 + * @returns 克隆的图形数组 + */ export const cloneGraphic = (graphic: IGraphic, count: number, needAppend?: boolean) => { const children: IGraphic[] = []; const childAttrs = needAppend ? null : parseShadowChildAttrs(graphic.attribute); @@ -503,6 +583,13 @@ export const cloneGraphic = (graphic: IGraphic, count: number, needAppend?: bool return children; }; +/** + * 将图形分割为多个子图形 + * @param graphic 源图形 + * @param count 分割数量 + * @param needAppend 是否需要添加到源图形中 + * @returns 分割后的图形数组 + */ export const splitGraphic = (graphic: IGraphic, count: number, needAppend?: boolean) => { const children: IGraphic[] = []; const childAttrs = needAppend ? null : parseShadowChildAttrs(graphic.attribute); @@ -576,10 +663,10 @@ export const splitGraphic = (graphic: IGraphic, count: number, needAppend?: bool }; /** - * 多对一动画 - * @param fromGraphics - * @param toGraphic - * @param animationConfig + * 创建从多个图形到一个图形的形变动画 + * @param fromGraphics 源图形数组 + * @param toGraphic 目标图形 + * @param animationConfig 动画配置 */ export const multiToOneMorph = ( fromGraphics: IGraphic[], diff --git a/packages/vrender-animate/src/custom/motionPath.ts b/packages/vrender-animate/src/custom/motionPath.ts new file mode 100644 index 000000000..d0a851adc --- /dev/null +++ b/packages/vrender-animate/src/custom/motionPath.ts @@ -0,0 +1,57 @@ +import type { CustomPath2D, IGraphic, EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export class MotionPath extends ACustomAnimate { + declare valid: boolean; + declare pathLength: number; + declare path: CustomPath2D; + declare distance: number; + declare totalLength: number; + declare initAngle: number; + declare changeAngle: boolean; + declare cb?: (from: any, to: any, ratio: number, target: IGraphic) => void; + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params?: { + path: CustomPath2D; + distance: number; + cb?: (from: any, to: any, ratio: number, target: IGraphic) => void; + initAngle?: number; + changeAngle?: boolean; + } + ) { + super(from, to, duration, easing, params); + if (params) { + this.pathLength = params.path.getLength(); + this.path = params.path; + this.distance = params.distance; + this.totalLength = this.distance * this.pathLength; + this.initAngle = params.initAngle ?? 0; + this.changeAngle = !!params.changeAngle; + this.cb = params.cb; + } + } + + onBind(): void { + this.from = { x: 0, y: 0 }; + this.to = this.path.getAttrAt(this.totalLength).pos; + this.props = this.to; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attrs = {} as any; + // 计算位置 + const at = this.totalLength * ratio; + const { pos, angle } = this.path.getAttrAt(at); + attrs.x = pos.x; + attrs.y = pos.y; + if (this.changeAngle) { + attrs.angle = angle + this.initAngle; + } + this.cb && this.cb(this.from, this.to, ratio, this.target as IGraphic); + this.target.setAttributes(attrs); + } +} diff --git a/packages/vrender-animate/src/custom/move.ts b/packages/vrender-animate/src/custom/move.ts new file mode 100644 index 000000000..c3e640ada --- /dev/null +++ b/packages/vrender-animate/src/custom/move.ts @@ -0,0 +1,168 @@ +import type { EasingType, IGraphic, IGroup } from '@visactor/vrender-core'; +import { isFunction, isValidNumber } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +export type FunctionCallback = (...args: any[]) => T; + +export interface IMoveAnimationOptions { + direction?: 'x' | 'y' | 'xy'; + orient?: 'positive' | 'negative'; + offset?: number; + point?: { x?: number; y?: number } | FunctionCallback<{ x?: number; y?: number }>; + excludeChannels?: string[]; + layoutRect?: { width: number; height: number }; +} + +interface IAnimationParameters { + width: number; + height: number; + group: IGroup; + elementIndex: number; + elementCount: number; + view: any; +} + +// When user did not provide proper x/y value, move animation will never work properly, +// due to that, default x/y value won't be set. + +export const moveIn = ( + graphic: IGraphic, + options: IMoveAnimationOptions, + animationParameters: IAnimationParameters +) => { + const { offset = 0, orient, direction, point: pointOpt, excludeChannels = [], layoutRect = {} } = options ?? {}; + let changedX = 0; + let changedY = 0; + + if (orient === 'negative') { + changedX = (layoutRect as any).width ?? graphic.stage.viewWidth; + changedY = (layoutRect as any).height ?? graphic.stage.viewHeight; + } + + changedX += offset; + changedY += offset; + const point = isFunction(pointOpt) + ? pointOpt.call(null, graphic.context?.data?.[0], graphic, animationParameters) + : pointOpt; + const fromX = point && isValidNumber(point.x) ? point.x : changedX; + const fromY = point && isValidNumber(point.y) ? point.y : changedY; + const finalAttrs = graphic.getFinalAttribute(); + const finalAttrsX = excludeChannels.includes('x') ? graphic.attribute.x : finalAttrs.x; + const finalAttrsY = excludeChannels.includes('y') ? graphic.attribute.y : finalAttrs.y; + + switch (direction) { + case 'x': + return { + from: { x: fromX }, + to: { x: finalAttrsX } + }; + case 'y': + return { + from: { y: fromY }, + to: { y: finalAttrsY } + }; + case 'xy': + default: + return { + from: { x: fromX, y: fromY }, + to: { + x: finalAttrsX, + y: finalAttrsY + } + }; + } +}; + +export const moveOut = ( + graphic: IGraphic, + options: IMoveAnimationOptions, + animationParameters: IAnimationParameters +) => { + const { offset = 0, orient, direction, point: pointOpt } = options ?? {}; + + // consider the offset of group + // const groupBounds = graphic.parent ? graphic.parent.getBounds() : null; + const groupWidth = options.layoutRect?.width ?? graphic.stage.viewWidth; + const groupHeight = options.layoutRect?.height ?? graphic.stage.viewHeight; + const changedX = (orient === 'negative' ? groupWidth : 0) + offset; + const changedY = (orient === 'negative' ? groupHeight : 0) + offset; + const point = isFunction(pointOpt) + ? pointOpt.call(null, graphic.context?.data?.[0], graphic, animationParameters) + : pointOpt; + const fromX = point && isValidNumber(point.x) ? point.x : changedX; + const fromY = point && isValidNumber(point.y) ? point.y : changedY; + + switch (direction) { + case 'x': + return { + from: { x: graphic.attribute.x }, + to: { x: fromX } + }; + case 'y': + return { + from: { y: graphic.attribute.y }, + to: { y: fromY } + }; + case 'xy': + default: + return { + from: { + x: graphic.attribute.x, + y: graphic.attribute.y + }, + to: { x: fromX, y: fromY } + }; + } +}; + +export class MoveBase extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 增长渐入 + */ +export class MoveIn extends MoveBase { + onBind(): void { + super.onBind(); + const { from, to } = moveIn(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => (to as any)[key] != null); + this.from = from; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } +} + +export class MoveOut extends MoveBase { + onBind(): void { + super.onBind(); + const { from, to } = moveOut(this.target, this.params.options, this.params); + this.props = to; + this.propKeys = Object.keys(to).filter(key => (to as any)[key] != null); + this.from = from; + this.to = to; + } +} diff --git a/packages/vrender-animate/src/custom/number.ts b/packages/vrender-animate/src/custom/number.ts new file mode 100644 index 000000000..f68efd596 --- /dev/null +++ b/packages/vrender-animate/src/custom/number.ts @@ -0,0 +1,240 @@ +import type { EasingType, IAnimate, IStep } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +/** + * 数字增加动画,支持string; number; xx%; xx,xxx; xxx.xx% + * 也支持通过formatTemplate参数指定格式化模板,如 "{{var}}m"、"${{var}}" + * format和formatTemplate可以同时生效,先应用format再应用模板 + */ +export class IncreaseCount extends ACustomAnimate<{ text: string | number }> { + declare valid: boolean; + + private fromNumber: number; + private toNumber: number; + private decimalLength: number; + private format: string; + private formatTemplate: string | null = null; + + constructor( + from: { text: string | number }, + to: { text: string | number }, + duration: number, + easing: EasingType, + // 支持外部控制小数位数以及格式化 + // format控制数字本身的格式化方式 + // formatTemplate可以定义模板字符串如 "{{var}}m"、"${{var}}",两者可以同时使用 + params?: { + decimalLength?: number; + format?: 'percent' | 'thousandth' | 'none'; + formatTemplate?: string; + } + ) { + super(from, to, duration, easing, params); + this.decimalLength = params?.decimalLength; + + // 检查是否提供了格式化模板 + if (params?.formatTemplate && params.formatTemplate.includes('{{var}}')) { + this.formatTemplate = params.formatTemplate; + } + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + const fromText = fromProps.text ?? 0; + const toText = toProps.text ?? 0; + + // 初始化解析结果 + this.valid = true; + let fromNum = 0; + let toNum = 0; + let fromFormat = ''; + let toFormat = ''; + let maxDecimalLength = 0; + + // 解析fromText + if (typeof fromText === 'number') { + fromNum = fromText; + const str = fromText.toString(); + const decimalPart = str.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length); + } else if (typeof fromText === 'string') { + // 检查是否是百分比 + if (fromText.endsWith('%')) { + fromFormat = '%'; + const numStr = fromText.substring(0, fromText.length - 1); + // 去除可能的千分位逗号 + const cleanNumStr = numStr.replace(/,/g, ''); + fromNum = parseFloat(cleanNumStr) / 100; + if (isNaN(fromNum)) { + this.valid = false; + return; + } + const decimalPart = cleanNumStr.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length + 2); // 百分比需要加2 + } else { + // 处理普通数字或带千分位逗号的数字 + const cleanNumStr = fromText.replace(/,/g, ''); + fromNum = parseFloat(cleanNumStr); + if (isNaN(fromNum)) { + this.valid = false; + return; + } + // 检查是否有千分位 + if (fromText.includes(',')) { + fromFormat = ','; + } + const decimalPart = cleanNumStr.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length); + } + } else { + this.valid = false; + return; + } + + // 解析toText + if (typeof toText === 'number') { + toNum = toText; + const str = toText.toString(); + const decimalPart = str.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length); + } else if (typeof toText === 'string') { + // 检查是否是百分比 + if (toText.endsWith('%')) { + toFormat = '%'; + const numStr = toText.substring(0, toText.length - 1); + // 去除可能的千分位逗号 + const cleanNumStr = numStr.replace(/,/g, ''); + toNum = parseFloat(cleanNumStr) / 100; + if (isNaN(toNum)) { + this.valid = false; + return; + } + const decimalPart = cleanNumStr.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length + 2); // 百分比需要加2 + } else { + // 处理普通数字或带千分位逗号的数字 + const cleanNumStr = toText.replace(/,/g, ''); + toNum = parseFloat(cleanNumStr); + if (isNaN(toNum)) { + this.valid = false; + return; + } + // 检查是否有千分位 + if (toText.includes(',')) { + toFormat = ','; + } + const decimalPart = cleanNumStr.split('.')[1] || ''; + maxDecimalLength = Math.max(maxDecimalLength, decimalPart.length); + } + } else { + this.valid = false; + return; + } + + // 设置最终格式 + // 检查是否有外部传入的格式 + if (this.params?.format) { + // 使用外部传入的格式,将外部格式映射到内部格式 + switch (this.params.format) { + case 'percent': + this.format = '%'; + break; + case 'thousandth': + this.format = ','; + break; + case 'none': + this.format = ''; + break; + default: + // 如果传入了未知格式,则使用自动检测的格式 + this.format = toFormat || fromFormat; + } + + // 如果外部指定了百分比格式,但输入不是百分比,需要适配 + if (this.format === '%' && toFormat !== '%' && fromFormat !== '%') { + // 不需要除以100,因为输入不是百分比 + if (this.decimalLength === undefined) { + // 默认百分比显示2位小数 + this.decimalLength = 2; + } + } + + // 如果外部指定了不用百分比格式,但输入是百分比,需要适配 + if (this.format !== '%' && (toFormat === '%' || fromFormat === '%')) { + // 需要乘以100,因为输入是百分比但不显示为百分比 + fromNum = fromNum * 100; + toNum = toNum * 100; + } + } else { + // 自动检测格式,优先使用toFormat,如果to没有特殊格式则使用fromFormat + this.format = toFormat || fromFormat; + } + + // 设置fromNumber和toNumber + this.fromNumber = fromNum; + this.toNumber = toNum; + + // 如果没有传入decimalLength,则根据输入格式设置 + if (this.decimalLength === undefined) { + this.decimalLength = maxDecimalLength; + } + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + this.props && this.target.setAttributes(this.props as any); + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + // 插值计算当前数值 + const currentNumber = this.fromNumber + (this.toNumber - this.fromNumber) * ratio; + + // 根据格式和小数位格式化数字 + let formattedText: string | number = ''; + const format = this.format; + + // 首先格式化数字值(保留小数位) + // 对于百分比,乘以100 + const adjustedNumber = format === '%' ? currentNumber * 100 : currentNumber; + // 保留指定小数位 + const numberWithDecimals = adjustedNumber.toFixed(this.decimalLength); + // 如果小数位全是0,转为整数 + let formattedNumber: string | number = numberWithDecimals; + if (parseFloat(numberWithDecimals) === Math.floor(parseFloat(numberWithDecimals))) { + formattedNumber = Math.floor(parseFloat(numberWithDecimals)); + } + + // 应用基本格式(百分比、千分位) + let formattedWithBasicFormat: string | number; + if (format === '%') { + // 百分比格式 + formattedWithBasicFormat = `${formattedNumber}%`; + } else if (format === ',') { + // 千分位格式 + const parts = formattedNumber.toString().split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); + formattedWithBasicFormat = parts.join('.'); + } else { + // 普通数字格式 + formattedWithBasicFormat = formattedNumber; + } + + // 应用模板(如果存在) + if (this.formatTemplate) { + // 使用模板格式化 + formattedText = this.formatTemplate.replace('{{var}}', formattedWithBasicFormat.toString()); + } else { + // 不使用模板,直接使用基本格式的结果 + formattedText = formattedWithBasicFormat; + } + + // 更新图形的text属性 + this.target.setAttribute('text', formattedText); + } +} diff --git a/packages/vrender-animate/src/custom/poptip-animate.ts b/packages/vrender-animate/src/custom/poptip-animate.ts new file mode 100644 index 000000000..366fd66ed --- /dev/null +++ b/packages/vrender-animate/src/custom/poptip-animate.ts @@ -0,0 +1,113 @@ +import { AComponentAnimate } from './custom-animate'; +import { createComponentAnimator } from '../component'; +import { InputText } from './input-text'; + +/** + * PoptipAppear class handles the appear animation for Poptip components + */ +export class PoptipAppear extends AComponentAnimate { + onBind(): void { + super.onBind(); + const animator = createComponentAnimator(this.target); + this._animator = animator; + + const duration = this.duration; + const easing = this.easing; + const target = this.target as any; + + const { wave } = this.params; + + target.setAttributes({ scaleX: 0, scaleY: 0 }); + + animator.animate(target, { + type: 'to', + to: { scaleX: 1, scaleY: 1 }, + duration: (duration / 3) * 2, + easing + }); + + target.titleShape && + animator.animate(target.titleShape, { + type: 'custom', + to: { text: target.titleShape.attribute.text as string }, + duration, + easing, + custom: InputText + }); + + target.contentShape && + animator.animate(target.contentShape, { + type: 'custom', + to: { text: target.contentShape.attribute.text as string }, + duration, + easing, + custom: InputText + }); + + if (wave) { + const dur = duration / 6; + animator.animate(target.group, { + timeSlices: [ + { + duration: dur, + effects: { + type: 'to', + to: { angle: wave }, + easing + } + }, + { + duration: dur * 2, + effects: { + type: 'to', + to: { angle: -wave }, + easing + } + }, + { + duration: dur * 2, + effects: { + type: 'to', + to: { angle: wave }, + easing + } + }, + { + duration: dur, + effects: { + type: 'to', + to: { angle: 0 }, + easing + } + } + ] + }); + } + + this.completeBind(animator); + } +} + +/** + * PoptipDisappear class handles the disappear animation for Poptip components + */ +export class PoptipDisappear extends AComponentAnimate { + onBind(): void { + super.onBind(); + const animator = createComponentAnimator(this.target); + this._animator = animator; + + const duration = this.duration; + const easing = this.easing; + const target = this.target as any; + + animator.animate(target, { + type: 'to', + to: { scaleX: 0, scaleY: 0 }, + duration, + easing + }); + + this.completeBind(animator); + } +} diff --git a/packages/vrender-animate/src/custom/readme.md b/packages/vrender-animate/src/custom/readme.md new file mode 100644 index 000000000..e5f2efbb7 --- /dev/null +++ b/packages/vrender-animate/src/custom/readme.md @@ -0,0 +1,10 @@ +## 自定义动画背景提示 + +- VChart 如果存在入场动画,将不会设置属性到 attribute,需要在动画中进行设置(从 finalAttribute 中获取,finalAttribute 中保存着一份最新的属性副本)。 +- VChart 如果存在出场动画,通常不需要直接设置属性,否则可能会导致跳帧。 + +## 容易误解的定义 + +1. step 是一个动画的基本单元,自定义动画继承自 step,step 通过 props 属性作为动画的目标属性。 +2. 而自定义动画中通常使用 from 和 to 保存动画的开始和结束状态,作为一一对应,自定义动画中因为插值是自己实现的,所以你定义 fromaaa 和 toccc 都可以,自己在 update 里拿到你保存的开始和结束变量,然后自己计算。 +3. 但是自定义动画中,你自己保存了自定义的 from 和 to 之后,还需要设置一下 props,这样上层的 Animate 才知道这个动画的最终属性是什么(因为 from 和 to 是你自定义的,而 props 属性才是动画系统认可的最终状态)。 diff --git a/packages/vrender-animate/src/custom/register.ts b/packages/vrender-animate/src/custom/register.ts new file mode 100644 index 000000000..9fbbda35f --- /dev/null +++ b/packages/vrender-animate/src/custom/register.ts @@ -0,0 +1,180 @@ +import { AnimateExecutor } from '../executor/animate-executor'; +import { ClipIn, ClipOut } from './clip'; +import { FadeIn, FadeOut } from './fade'; +import { GrowAngleIn, GrowAngleOut } from './growAngle'; +import { GrowCenterIn, GrowCenterOut } from './growCenter'; +import { GrowHeightIn, GrowHeightOut } from './growHeight'; +import { + GrowPointsIn, + GrowPointsOut, + GrowPointsXIn, + GrowPointsXOut, + GrowPointsYIn, + GrowPointsYOut +} from './growPoints'; +import { GrowRadiusIn, GrowRadiusOut } from './growRadius'; +import { GrowWidthIn, GrowWidthOut } from './growWidth'; +import { InputText } from './input-text'; +import { LabelItemAppear, LabelItemDisappear } from './label-item-animate'; +import { IncreaseCount } from './number'; +import { PoptipAppear, PoptipDisappear } from './poptip-animate'; +import { InputRichText } from './richtext/input-richtext'; +import { OutputRichText } from './richtext/output-richtext'; +import { SlideRichText } from './richtext/slide-richtext'; +import { SlideOutRichText } from './richtext/slide-out-richtext'; +import { ScaleIn, ScaleOut } from './scale'; +import { State } from './state'; +import { + GrowIn, + GrowOut, + MoveRotateIn, + MoveRotateOut, + MoveScaleIn, + MoveScaleOut, + PulseAnimate, + SlideIn, + SlideOut, + SpinIn, + SpinOut, + StrokeIn, + StrokeOut +} from './story'; +import { Update } from './update'; +import { MoveIn, MoveOut } from './move'; +import { RotateIn, RotateOut } from './rotate'; +import { MotionPath } from './motionPath'; +import { FromTo } from './fromTo'; +import { GroupFadeIn, GroupFadeOut } from './groupFade'; +import { StreamLight } from './streamLight'; + +export const registerCustomAnimate = () => { + // 基础动画 + AnimateExecutor.registerBuiltInAnimate('increaseCount', IncreaseCount); + + AnimateExecutor.registerBuiltInAnimate('fromTo', FromTo); + AnimateExecutor.registerBuiltInAnimate('scaleIn', ScaleIn); + AnimateExecutor.registerBuiltInAnimate('scaleOut', ScaleOut); + AnimateExecutor.registerBuiltInAnimate('growHeightIn', GrowHeightIn); + AnimateExecutor.registerBuiltInAnimate('growHeightOut', GrowHeightOut); + AnimateExecutor.registerBuiltInAnimate('growWidthIn', GrowWidthIn); + AnimateExecutor.registerBuiltInAnimate('growWidthOut', GrowWidthOut); + AnimateExecutor.registerBuiltInAnimate('growCenterIn', GrowCenterIn); + AnimateExecutor.registerBuiltInAnimate('growCenterOut', GrowCenterOut); + AnimateExecutor.registerBuiltInAnimate('clipIn', ClipIn); + AnimateExecutor.registerBuiltInAnimate('clipOut', ClipOut); + AnimateExecutor.registerBuiltInAnimate('fadeIn', FadeIn); + AnimateExecutor.registerBuiltInAnimate('fadeOut', FadeOut); + AnimateExecutor.registerBuiltInAnimate('growPointsIn', GrowPointsIn); + AnimateExecutor.registerBuiltInAnimate('growPointsOut', GrowPointsOut); + AnimateExecutor.registerBuiltInAnimate('growPointsXIn', GrowPointsXIn); + AnimateExecutor.registerBuiltInAnimate('growPointsXOut', GrowPointsXOut); + AnimateExecutor.registerBuiltInAnimate('growPointsYIn', GrowPointsYIn); + AnimateExecutor.registerBuiltInAnimate('growPointsYOut', GrowPointsYOut); + AnimateExecutor.registerBuiltInAnimate('growAngleIn', GrowAngleIn); + AnimateExecutor.registerBuiltInAnimate('growAngleOut', GrowAngleOut); + AnimateExecutor.registerBuiltInAnimate('growRadiusIn', GrowRadiusIn); + AnimateExecutor.registerBuiltInAnimate('growRadiusOut', GrowRadiusOut); + AnimateExecutor.registerBuiltInAnimate('moveIn', MoveIn); + AnimateExecutor.registerBuiltInAnimate('moveOut', MoveOut); + AnimateExecutor.registerBuiltInAnimate('rotateIn', RotateIn); + AnimateExecutor.registerBuiltInAnimate('rotateOut', RotateOut); + // state和update共用一个自定义动画类 + AnimateExecutor.registerBuiltInAnimate('update', Update); + AnimateExecutor.registerBuiltInAnimate('state', State); + // Label item animations + AnimateExecutor.registerBuiltInAnimate('labelItemAppear', LabelItemAppear); + AnimateExecutor.registerBuiltInAnimate('labelItemDisappear', LabelItemDisappear); + // Poptip animations + AnimateExecutor.registerBuiltInAnimate('poptipAppear', PoptipAppear); + AnimateExecutor.registerBuiltInAnimate('poptipDisappear', PoptipDisappear); + + // Text input animations + AnimateExecutor.registerBuiltInAnimate('inputText', InputText); + AnimateExecutor.registerBuiltInAnimate('inputRichText', InputRichText); + AnimateExecutor.registerBuiltInAnimate('outputRichText', OutputRichText); + AnimateExecutor.registerBuiltInAnimate('slideRichText', SlideRichText); + AnimateExecutor.registerBuiltInAnimate('slideOutRichText', SlideOutRichText); + + // 故事化动画 - 入场 + AnimateExecutor.registerBuiltInAnimate('slideIn', SlideIn); + AnimateExecutor.registerBuiltInAnimate('growIn', GrowIn); + AnimateExecutor.registerBuiltInAnimate('spinIn', SpinIn); + AnimateExecutor.registerBuiltInAnimate('moveScaleIn', MoveScaleIn); + AnimateExecutor.registerBuiltInAnimate('moveRotateIn', MoveRotateIn); + AnimateExecutor.registerBuiltInAnimate('strokeIn', StrokeIn); + + // 故事化动画 - 出场 + AnimateExecutor.registerBuiltInAnimate('slideOut', SlideOut); + AnimateExecutor.registerBuiltInAnimate('growOut', GrowOut); + AnimateExecutor.registerBuiltInAnimate('spinOut', SpinOut); + AnimateExecutor.registerBuiltInAnimate('moveScaleOut', MoveScaleOut); + AnimateExecutor.registerBuiltInAnimate('moveRotateOut', MoveRotateOut); + AnimateExecutor.registerBuiltInAnimate('strokeOut', StrokeOut); + + // 特效动画 + AnimateExecutor.registerBuiltInAnimate('pulse', PulseAnimate); + + // 路径动画 + AnimateExecutor.registerBuiltInAnimate('MotionPath', MotionPath); + // 流光动画 + AnimateExecutor.registerBuiltInAnimate('streamLight', StreamLight); +}; + +export { + ClipIn, + ClipOut, + FadeIn, + FadeOut, + GrowAngleIn, + GrowAngleOut, + GrowCenterIn, + GrowCenterOut, + GrowHeightIn, + GrowHeightOut, + GrowPointsIn, + GrowPointsOut, + GrowPointsXIn, + GrowPointsXOut, + GrowPointsYIn, + GrowPointsYOut, + GrowRadiusIn, + GrowRadiusOut, + GrowWidthIn, + GrowWidthOut, + IncreaseCount, + PoptipAppear, + PoptipDisappear, + ScaleIn, + ScaleOut, + MoveIn, + MoveOut, + RotateIn, + RotateOut, + State, + Update, + MotionPath, + LabelItemAppear, + LabelItemDisappear, + InputText, + InputRichText, + OutputRichText, + SlideRichText, + SlideOutRichText, + SlideIn, + GrowIn, + SpinIn, + MoveScaleIn, + MoveRotateIn, + SlideOut, + GrowOut, + SpinOut, + MoveScaleOut, + MoveRotateOut, + StrokeIn, + StrokeOut, + PulseAnimate, + GroupFadeIn, + GroupFadeOut, + FromTo, + StreamLight +}; diff --git a/packages/vrender-animate/src/custom/richtext/input-richtext.ts b/packages/vrender-animate/src/custom/richtext/input-richtext.ts new file mode 100644 index 000000000..aa357d65e --- /dev/null +++ b/packages/vrender-animate/src/custom/richtext/input-richtext.ts @@ -0,0 +1,233 @@ +import { ACustomAnimate } from '../custom-animate'; +import type { + IRichTextCharacter, + IRichTextParagraphCharacter, + IAnimate, + IStep, + EasingType +} from '@visactor/vrender-core'; +import { RichText } from '@visactor/vrender-core'; + +/** + * 富文本输入动画,实现类似打字机的字符逐个显示效果 + * 支持通过beforeText和afterText参数添加前缀和后缀 + * 支持通过showCursor参数显示光标,cursorChar自定义光标字符 + * 支持通过fadeInChars参数开启字符透明度渐变效果 + * 支持通过strokeFirst参数开启描边先于填充显示效果,使用文字自身颜色作为描边色 + */ +export class InputRichText extends ACustomAnimate<{ textConfig: IRichTextCharacter[] }> { + declare valid: boolean; + + private fromTextConfig: IRichTextCharacter[] = []; + private toTextConfig: IRichTextCharacter[] = []; + private originalTextConfig: IRichTextCharacter[] = []; + private showCursor: boolean = false; + private cursorChar: string = '|'; + private blinkCursor: boolean = true; + private fadeInChars: boolean = false; + private fadeInDuration: number = 0.3; // 透明度渐变持续时间,以动画总时长的比例表示 + private strokeFirst: boolean = false; // 是否开启描边先于填充显示效果 + private strokeToFillRatio: number = 0.3; // 描边到填充的过渡比例,占总动画时长的比例 + + constructor( + from: { textConfig: IRichTextCharacter[] }, + to: { textConfig: IRichTextCharacter[] }, + duration: number, + easing: EasingType, + params?: { + showCursor?: boolean; + cursorChar?: string; + blinkCursor?: boolean; + beforeText?: string; + afterText?: string; + fadeInChars?: boolean; + fadeInDuration?: number; + strokeFirst?: boolean; + strokeToFillRatio?: number; + } + ) { + super(from, to, duration, easing, params); + + // 配置光标相关选项 + if (params?.showCursor !== undefined) { + this.showCursor = params.showCursor; + } + if (params?.cursorChar !== undefined) { + this.cursorChar = params.cursorChar; + } + if (params?.blinkCursor !== undefined) { + this.blinkCursor = params.blinkCursor; + } + + // 配置字符透明度渐变效果 + if (params?.fadeInChars !== undefined) { + this.fadeInChars = params.fadeInChars; + } + if (params?.fadeInDuration !== undefined) { + this.fadeInDuration = params.fadeInDuration; + } + + // 配置描边先于填充显示效果 + if (params?.strokeFirst !== undefined) { + this.strokeFirst = params.strokeFirst; + } + if (params?.strokeToFillRatio !== undefined) { + this.strokeToFillRatio = params.strokeToFillRatio; + } + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + + // 存储原始配置 + this.originalTextConfig = toProps.textConfig ? [...toProps.textConfig] : []; + + // 初始化解析结果 + this.valid = true; + + // 确保to不为空 + if (!this.originalTextConfig || this.originalTextConfig.length === 0) { + this.valid = false; + return; + } + + // 将文本拆分为单个字符,使用RichText的静态方法 + this.fromTextConfig = + fromProps.textConfig && fromProps.textConfig.length > 0 + ? RichText.TransformTextConfig2SingleCharacter(fromProps.textConfig) + : []; + + this.toTextConfig = RichText.TransformTextConfig2SingleCharacter(this.originalTextConfig); + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + // 动画结束时,恢复原始textConfig + this.target.setAttribute('textConfig', this.originalTextConfig); + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + + // 计算当前应该显示的字符数量 + const totalItems = this.toTextConfig.length; + const fromItems = this.fromTextConfig.length; + + // 计算文本显示比例上限 - 如果有渐变效果,需要为尾部字符的渐变留出时间 + // 例如,如果fadeInDuration为0.3,则文本显示部分最多占用动画时间的70% + const maxTextShowRatio = this.fadeInChars ? 1 - this.fadeInDuration : 1; + + // 确定当前应该显示多少个项目 + let currentLength: number; + + // 如果fromItems比totalItems长,则是删除动画,否则是添加动画 + if (fromItems > totalItems) { + // 删除文本动画(从多到少) + currentLength = Math.round(fromItems - (fromItems - totalItems) * ratio); + } else { + // 添加文本动画(从少到多)- 需要更快显示字符以便留出时间让最后的字符完成渐变 + if (this.fadeInChars) { + // 当ratio达到maxTextShowRatio时,应该已经显示全部文本 + const adjustedRatio = Math.min(1, ratio / maxTextShowRatio); + currentLength = Math.round(fromItems + (totalItems - fromItems) * adjustedRatio); + } else { + // 无渐变效果时,正常显示 + currentLength = Math.round(fromItems + (totalItems - fromItems) * ratio); + } + } + + // 构建当前要显示的textConfig + let currentTextConfig: IRichTextCharacter[]; + if (fromItems > totalItems) { + // 删除动画:显示from的前currentLength项 + currentTextConfig = this.fromTextConfig.slice(0, currentLength); + } else { + // 添加文本动画:显示to的前currentLength项,可能需要应用透明度和描边效果 + currentTextConfig = this.toTextConfig.slice(0, currentLength).map((item, index) => { + // 如果是文本项并且需要应用效果 + if ('text' in item) { + const newItem = { ...item }; + + // 如果启用了描边优先效果 + if (this.strokeFirst) { + // 计算描边到填充的过渡进度 + // 字符在特定时间点出现:出现时刻 = (index / totalItems) * maxTextShowRatio + const appearTime = (index / totalItems) * maxTextShowRatio; + const itemLifetime = Math.max(0, ratio - appearTime); // 当前字符已经存在的时间 + const maxLifetime = 1 - appearTime; // 当前字符从出现到动画结束的最大时间 + const fillProgress = Math.min(1, itemLifetime / (this.strokeToFillRatio * maxLifetime)); + + // 使用文本自身的填充颜色作为描边颜色 + if ('fill' in newItem && newItem.fill) { + newItem.stroke = newItem.fill; + // 计算描边宽度,基于字体大小 + // const fontSize = newItem.fontSize || 16; + // newItem.lineWidth = Math.max(1, fontSize * 0.05); // 线宽大约为字体大小的5% + + // 如果还没到填充阶段,则将填充色透明度设为0 + if (fillProgress < 1) { + newItem.fillOpacity = fillProgress; + } + } + + // 如果也启用了透明度渐变 + if (this.fadeInChars) { + const fadeProgress = Math.min(1, itemLifetime / (this.fadeInDuration * maxLifetime)); + newItem.opacity = Math.max(0, Math.min(1, fadeProgress)); + } + } + // 只启用了透明度渐变效果,没有启用描边优先 + else if (this.fadeInChars) { + const appearTime = (index / totalItems) * maxTextShowRatio; + const fadeProgress = (ratio - appearTime) / this.fadeInDuration; + newItem.opacity = Math.max(0, Math.min(1, fadeProgress)); + } + + return newItem; + } + return item; + }); + } + + // 如果启用了光标 + if (this.showCursor && currentLength < totalItems) { + // 判断是否应该显示光标 + let shouldShowCursor = true; + + if (this.blinkCursor) { + // 闪烁效果:在动画期间,光标每半个周期闪烁一次 + const blinkRate = 0.1; // 光标闪烁频率(每10%动画进度闪烁一次) + shouldShowCursor = Math.floor(ratio / blinkRate) % 2 === 0; + } + + if (shouldShowCursor && currentTextConfig.length > 0) { + // 找到最后一个文本项,在其后添加光标 + const lastIndex = currentTextConfig.length - 1; + const lastItem = currentTextConfig[lastIndex]; + + if ('text' in lastItem) { + // 如果最后一项是文本,将光标添加到文本后面 + currentTextConfig[lastIndex] = { + ...lastItem, + text: String(lastItem.text) + this.cursorChar + }; + } else { + // 如果最后一项是非文本(如图片),添加一个只包含光标的新文本项 + const cursorItem: IRichTextParagraphCharacter = { + text: this.cursorChar, + fontSize: 16 // 使用默认字体大小,或者从context获取 + }; + currentTextConfig.push(cursorItem); + } + } + } + + // 更新富文本的textConfig属性 + this.target.setAttribute('textConfig', currentTextConfig); + } +} diff --git a/packages/vrender-animate/src/custom/richtext/output-richtext.ts b/packages/vrender-animate/src/custom/richtext/output-richtext.ts new file mode 100644 index 000000000..4a2aad98d --- /dev/null +++ b/packages/vrender-animate/src/custom/richtext/output-richtext.ts @@ -0,0 +1,274 @@ +import { ACustomAnimate } from '../custom-animate'; +import type { IRichTextCharacter, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { RichText } from '@visactor/vrender-core'; + +/** + * 富文本退出动画,实现类似打字机的字符逐个消失效果 + * 支持通过beforeText和afterText参数添加前缀和后缀 + * 支持通过showCursor参数显示光标,cursorChar自定义光标字符 + * 支持通过fadeOutChars参数开启字符透明度渐变效果 + * 支持通过direction参数控制消失方向(从头到尾或从尾到头) + */ +export class OutputRichText extends ACustomAnimate<{ textConfig: IRichTextCharacter[] }> { + declare valid: boolean; + + private fromTextConfig: IRichTextCharacter[] = []; + private toTextConfig: IRichTextCharacter[] = []; + private originalTextConfig: IRichTextCharacter[] = []; + private showCursor: boolean = false; + private cursorChar: string = '|'; + private blinkCursor: boolean = true; + private beforeText: string = ''; + private afterText: string = ''; + private fadeOutChars: boolean = false; + private fadeOutDuration: number = 0.3; // 透明度渐变持续时间,以动画总时长的比例表示 + private direction: 'forward' | 'backward' = 'backward'; // 字符消失方向,默认从尾到头(backward) + + constructor( + from: { textConfig: IRichTextCharacter[] }, + to: { textConfig: IRichTextCharacter[] }, + duration: number, + easing: EasingType, + params?: { + showCursor?: boolean; + cursorChar?: string; + blinkCursor?: boolean; + beforeText?: string; + afterText?: string; + fadeOutChars?: boolean; + fadeOutDuration?: number; + direction?: 'forward' | 'backward'; + } + ) { + super(from, to, duration, easing, params); + + // 配置光标相关选项 + if (params?.showCursor !== undefined) { + this.showCursor = params.showCursor; + } + if (params?.cursorChar !== undefined) { + this.cursorChar = params.cursorChar; + } + if (params?.blinkCursor !== undefined) { + this.blinkCursor = params.blinkCursor; + } + + // 配置前缀和后缀文本 + if (params?.beforeText !== undefined) { + this.beforeText = params.beforeText; + } + if (params?.afterText !== undefined) { + this.afterText = params.afterText; + } + + // 配置字符透明度渐变效果 + if (params?.fadeOutChars !== undefined) { + this.fadeOutChars = params.fadeOutChars; + } + if (params?.fadeOutDuration !== undefined) { + this.fadeOutDuration = params.fadeOutDuration; + } + + // 配置方向 + if (params?.direction !== undefined) { + this.direction = params.direction; + } + + this.propKeys = ['textConfig']; + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + + // 存储原始配置(这里是起始状态,显示所有文本) + this.originalTextConfig = fromProps.textConfig ? [...fromProps.textConfig] : []; + + // 初始化解析结果 + this.valid = true; + + // 确保from不为空 + if (!this.originalTextConfig || this.originalTextConfig.length === 0) { + this.valid = false; + return; + } + + // 将文本拆分为单个字符,使用RichText的静态方法 + this.fromTextConfig = RichText.TransformTextConfig2SingleCharacter(this.originalTextConfig); + + // 目标状态是空文本(或指定的目标) + this.toTextConfig = + toProps.textConfig && toProps.textConfig.length > 0 + ? RichText.TransformTextConfig2SingleCharacter(toProps.textConfig) + : []; + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + // 动画结束时,应用最终textConfig(通常是空的或特定的toTextConfig) + if (this.toTextConfig.length > 0) { + this.target.setAttribute('textConfig', this.toTextConfig); + } else { + this.target.setAttribute('textConfig', []); + } + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + + // 获取当前应该显示的字符 + const fromItems = this.fromTextConfig.length; + + // 计算文本显示比例上限 - 如果有渐变效果,需要为尾部字符的渐变留出时间 + const maxTextHideRatio = this.fadeOutChars ? 1 - this.fadeOutDuration : 1; + + // 根据方向确定字符消失的顺序 + let displayedLength: number; + + if (this.direction === 'forward') { + // 从前往后消失(类似于正向打字效果) + if (this.fadeOutChars) { + // 当ratio达到maxTextHideRatio时,应该已经隐藏全部文本 + const adjustedRatio = Math.min(1, ratio / maxTextHideRatio); + displayedLength = Math.round(fromItems * (1 - adjustedRatio)); + } else { + // 无渐变效果时,正常隐藏 + displayedLength = Math.round(fromItems * (1 - ratio)); + } + + // 构建从头开始删除的文本配置 + let currentTextConfig = + this.direction === 'forward' + ? this.fromTextConfig.slice(fromItems - displayedLength) // 从头开始隐藏,保留尾部 + : this.fromTextConfig.slice(0, displayedLength); // 从尾开始隐藏,保留头部 + + // 应用透明度渐变效果 + if (this.fadeOutChars) { + currentTextConfig = this.applyFadeEffect(currentTextConfig, ratio, fromItems, displayedLength); + } + + // 如果启用了光标 + if (this.showCursor && displayedLength > 0) { + currentTextConfig = this.addCursor(currentTextConfig, ratio); + } + + // 更新富文本的textConfig属性 + this.target.setAttribute('textConfig', currentTextConfig); + } else { + // 从后往前消失(类似于退格删除效果) + if (this.fadeOutChars) { + // 当ratio达到maxTextHideRatio时,应该已经隐藏全部文本 + const adjustedRatio = Math.min(1, ratio / maxTextHideRatio); + displayedLength = Math.round(fromItems * (1 - adjustedRatio)); + } else { + // 无渐变效果时,正常隐藏 + displayedLength = Math.round(fromItems * (1 - ratio)); + } + + // 构建从尾开始删除的文本配置 + let currentTextConfig = this.fromTextConfig.slice(0, displayedLength); + + // 应用透明度渐变效果 + if (this.fadeOutChars) { + currentTextConfig = this.applyFadeEffect(currentTextConfig, ratio, fromItems, displayedLength); + } + + // 如果启用了光标 + if (this.showCursor && displayedLength > 0) { + currentTextConfig = this.addCursor(currentTextConfig, ratio); + } + + // 更新富文本的textConfig属性 + this.target.setAttribute('textConfig', currentTextConfig); + } + } + + // 应用透明度渐变效果 + private applyFadeEffect( + textConfig: IRichTextCharacter[], + ratio: number, + totalItems: number, + displayedLength: number + ): IRichTextCharacter[] { + // 计算边界字符的索引,这是正在淡出的字符 + let fadeIndex: number; + + if (this.direction === 'forward') { + // 从前往后消失,当前正在淡出的是第displayedLength个字符 + fadeIndex = totalItems - displayedLength; + } else { + // 从后往前消失,当前正在淡出的是第displayedLength个字符 + fadeIndex = displayedLength; + } + + // 计算边界字符的透明度 + const fadeProgress = (ratio - (1 - this.fadeOutDuration)) / this.fadeOutDuration; + const fadeOpacity = Math.max(0, 1 - Math.min(1, fadeProgress)); + + return textConfig.map((item, index) => { + if (this.direction === 'forward') { + // 从前往后消失,第一个字符最先淡出 + if (index === 0 && 'text' in item) { + return { + ...item, + opacity: fadeOpacity + }; + } + } else { + // 从后往前消失,最后一个字符最先淡出 + if (index === textConfig.length - 1 && 'text' in item) { + return { + ...item, + opacity: fadeOpacity + }; + } + } + return item; + }); + } + + // 添加光标 + private addCursor(textConfig: IRichTextCharacter[], ratio: number): IRichTextCharacter[] { + // 判断是否应该显示光标 + let shouldShowCursor = true; + + if (this.blinkCursor) { + // 闪烁效果:在动画期间,光标每半个周期闪烁一次 + const blinkRate = 0.1; // 光标闪烁频率(每10%动画进度闪烁一次) + shouldShowCursor = Math.floor(ratio / blinkRate) % 2 === 0; + } + + if (shouldShowCursor && textConfig.length > 0) { + // 确定光标位置(根据direction) + const cursorIndex = this.direction === 'forward' ? 0 : textConfig.length - 1; + const cursorItem = textConfig[cursorIndex]; + + if ('text' in cursorItem) { + // 复制数组 + const result = [...textConfig]; + + if (this.direction === 'forward') { + // 光标在前面 + result[cursorIndex] = { + ...cursorItem, + text: this.cursorChar + String(cursorItem.text) + }; + } else { + // 光标在后面 + result[cursorIndex] = { + ...cursorItem, + text: String(cursorItem.text) + this.cursorChar + }; + } + + return result; + } + } + + return textConfig; + } +} diff --git a/packages/vrender-animate/src/custom/richtext/slide-out-richtext.ts b/packages/vrender-animate/src/custom/richtext/slide-out-richtext.ts new file mode 100644 index 000000000..56ff838be --- /dev/null +++ b/packages/vrender-animate/src/custom/richtext/slide-out-richtext.ts @@ -0,0 +1,369 @@ +import { ACustomAnimate } from '../custom-animate'; +import type { IRichTextCharacter, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { RichText } from '@visactor/vrender-core'; + +/** + * 滑动富文本退出动画,文字会向指定方向滑出,同时逐字消失 + * 支持上、下、左、右四个方向 + * 支持按单词或字符退场 + */ +export class SlideOutRichText extends ACustomAnimate<{ textConfig: IRichTextCharacter[] }> { + declare valid: boolean; + + private fromTextConfig: IRichTextCharacter[] = []; + private toTextConfig: IRichTextCharacter[] = []; + private originalTextConfig: IRichTextCharacter[] = []; + private singleCharConfig: IRichTextCharacter[] = []; + private fadeOutDuration: number = 0.3; // 透明度渐变持续时间,以动画总时长的比例表示 + private slideDirection: 'up' | 'down' | 'left' | 'right' = 'right'; // 滑动方向 + private slideDistance: number = 30; // 滑动距离(像素) + private wordByWord: boolean = false; // 是否按单词为单位进行动画 + // 默认正则表达式: 匹配英文单词(含中间连字符),连续中文字符,数字,以及独立的符号和空格 + private wordRegex: RegExp = /[a-zA-Z]+(-[a-zA-Z]+)*|[\u4e00-\u9fa5]+|[0-9]+|[^\s\w\u4e00-\u9fa5]/g; + private wordGroups: number[][] = []; // 存储单词分组信息,每个数组包含属于同一单词的字符索引 + private reverseOrder: boolean = false; // 是否反转字符/单词的消失顺序 + + constructor( + from: { textConfig: IRichTextCharacter[] }, + to: { textConfig: IRichTextCharacter[] }, + duration: number, + easing: EasingType, + params?: { + fadeOutDuration?: number; + slideDirection?: 'up' | 'down' | 'left' | 'right'; + slideDistance?: number; + wordByWord?: boolean; + wordRegex?: RegExp; + reverseOrder?: boolean; + } + ) { + super(from, to, duration, easing, params); + + // 配置透明度渐变效果 + if (params?.fadeOutDuration !== undefined) { + this.fadeOutDuration = params.fadeOutDuration; + } + + // 配置滑动方向和距离 + if (params?.slideDirection !== undefined) { + this.slideDirection = params.slideDirection; + } + if (params?.slideDistance !== undefined) { + this.slideDistance = params.slideDistance; + } + + // 配置按单词动画 + if (params?.wordByWord !== undefined) { + this.wordByWord = params.wordByWord; + } + if (params?.wordRegex !== undefined) { + this.wordRegex = params.wordRegex; + } + + // 配置顺序 + if (params?.reverseOrder !== undefined) { + this.reverseOrder = params.reverseOrder; + } + + this.propKeys = ['textConfig']; + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + + // 存储原始配置 + this.originalTextConfig = fromProps.textConfig ? [...fromProps.textConfig] : []; + + // 初始化解析结果 + this.valid = true; + + // 确保from不为空 + if (!this.originalTextConfig || this.originalTextConfig.length === 0) { + this.valid = false; + return; + } + + // 将文本拆分为单个字符,使用RichText的静态方法 + this.fromTextConfig = RichText.TransformTextConfig2SingleCharacter(this.originalTextConfig); + + // 目标状态是空文本(或指定的目标) + this.toTextConfig = + toProps.textConfig && toProps.textConfig.length > 0 + ? RichText.TransformTextConfig2SingleCharacter(toProps.textConfig) + : []; + + // 创建单字符数组,用于动画初始状态 + this.singleCharConfig = this.fromTextConfig.map(item => { + if ('text' in item) { + // 文本字符初始设置为完全可见且无偏移 + return { + ...item, + opacity: 1, + dx: 0, + dy: 0 + }; + } + return { ...item, opacity: 1 }; + }); + + // 如果启用按单词动画,则计算单词分组 + if (this.wordByWord) { + this.calculateWordGroups(); + } + } + + // 计算单词分组 + private calculateWordGroups(): void { + // 重置单词分组 + this.wordGroups = []; + + // 构建完整文本用于正则匹配 + let fullText = ''; + const charMap: Record = {}; // 映射全文索引到字符配置索引 + let fullTextIndex = 0; + + // 构建全文和映射 + this.fromTextConfig.forEach((item, configIndex) => { + if ('text' in item) { + const text = String(item.text); + fullText += text; + // 为每个字符创建映射 + charMap[fullTextIndex] = configIndex; + fullTextIndex++; + } + }); + + // 使用正则表达式查找单词 + let match; + + // 重置正则表达式状态 + this.wordRegex.lastIndex = 0; + + while ((match = this.wordRegex.exec(fullText)) !== null) { + const wordStart = match.index; + const wordEnd = match.index + match[0].length; + + // 找出属于这个单词的所有字符索引 + const wordIndices = []; + + for (let i = wordStart; i < wordEnd; i++) { + if (charMap[i] !== undefined) { + wordIndices.push(charMap[i]); + } + } + + // 添加到单词分组 + if (wordIndices.length > 0) { + this.wordGroups.push(wordIndices); + } + } + + // 处理没有分配到任何单词的字符 + const allocatedIndices = new Set(); + this.wordGroups.forEach(group => { + group.forEach(index => allocatedIndices.add(index)); + }); + + for (let i = 0; i < this.fromTextConfig.length; i++) { + if ('text' in this.fromTextConfig[i] && !allocatedIndices.has(i)) { + // 单独为每个未分配的字符创建一个"单词" + this.wordGroups.push([i]); + } + } + } + + // 根据滑动方向计算目标x偏移(最终位置) + private getTargetDx(): number { + switch (this.slideDirection) { + case 'left': + return -this.slideDistance; + case 'right': + return this.slideDistance; + default: + return 0; + } + } + + // 根据滑动方向计算目标y偏移(最终位置) + private getTargetDy(): number { + switch (this.slideDirection) { + case 'up': + return -this.slideDistance; + case 'down': + return this.slideDistance; + default: + return 0; + } + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + // 动画结束时,应用最终textConfig(通常是空的或特定的toTextConfig) + if (this.toTextConfig.length > 0) { + this.target.setAttribute('textConfig', this.toTextConfig); + } else { + this.target.setAttribute('textConfig', []); + } + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + + // 计算文本显示时间比例上限 - 为尾部字符的渐变和滑动效果留出时间 + const maxTextShowRatio = 1 - this.fadeOutDuration; + + let updatedTextConfig: IRichTextCharacter[]; + + if (this.wordByWord && this.wordGroups.length > 0) { + // 按单词动画 + updatedTextConfig = this.updateByWord(ratio, maxTextShowRatio); + } else { + // 按字符动画 + updatedTextConfig = this.updateByCharacter(ratio, maxTextShowRatio); + } + + // 更新富文本的textConfig属性 + this.target.setAttribute('textConfig', updatedTextConfig); + } + + // 按单词更新文本配置 + private updateByWord(ratio: number, maxTextShowRatio: number): IRichTextCharacter[] { + const totalGroups = this.wordGroups.length; + const updatedTextConfig = [...this.singleCharConfig]; + + // 处理单词分组 + for (let groupIndex = 0; groupIndex < this.wordGroups.length; groupIndex++) { + // 计算这个单词组的消失时间点 + let disappearTime; + + if (this.reverseOrder) { + // 反转顺序 (与入场顺序相反) + if (this.slideDirection === 'left') { + // 从左到右的顺序 (第一个单词先消失) + disappearTime = (groupIndex / totalGroups) * maxTextShowRatio; + } else { + // 从右到左的顺序 (最后的单词先消失) + disappearTime = ((totalGroups - 1 - groupIndex) / totalGroups) * maxTextShowRatio; + } + } else { + // 标准顺序 (与入场顺序相同) + if (this.slideDirection === 'left') { + // 从右到左的顺序 (最后的单词先消失) + disappearTime = ((totalGroups - 1 - groupIndex) / totalGroups) * maxTextShowRatio; + } else { + // 从左到右的顺序 (第一个单词先消失) + disappearTime = (groupIndex / totalGroups) * maxTextShowRatio; + } + } + + // 如果当前时间还没到显示这个单词的消失时间点,保持可见状态 + if (ratio < disappearTime) { + for (const charIndex of this.wordGroups[groupIndex]) { + const item = updatedTextConfig[charIndex]; + if ('text' in item) { + updatedTextConfig[charIndex] = { + ...item, + opacity: 1, + dx: 0, + dy: 0 + }; + } + } + continue; + } + + // 计算动画进度(0-1之间) + const animProgress = (ratio - disappearTime) / this.fadeOutDuration; + const progress = Math.max(0, Math.min(1, animProgress)); + + // 计算当前偏移和透明度 + const dx = this.getTargetDx() * progress; + const dy = this.getTargetDy() * progress; + const opacity = 1 - progress; + + // 更新这个单词的所有字符 + for (const charIndex of this.wordGroups[groupIndex]) { + const item = updatedTextConfig[charIndex]; + if ('text' in item) { + updatedTextConfig[charIndex] = { + ...item, + opacity, + dx, + dy + }; + } + } + } + + return updatedTextConfig; + } + + // 按字符更新文本配置 + private updateByCharacter(ratio: number, maxTextShowRatio: number): IRichTextCharacter[] { + const totalItems = this.fromTextConfig.length; + const updatedTextConfig = [...this.singleCharConfig]; + + // 更新每个字符的状态 + for (let index = 0; index < updatedTextConfig.length; index++) { + const item = updatedTextConfig[index]; + if ('text' in item) { + // 计算每个字符的消失时间点 + let disappearTime; + + if (this.reverseOrder) { + // 反转入场顺序 + if (this.slideDirection === 'left') { + // 从左到右的顺序 (第一个字符先消失) + disappearTime = (index / totalItems) * maxTextShowRatio; + } else { + // 从右到左的顺序 (最后的字符先消失) + disappearTime = ((totalItems - 1 - index) / totalItems) * maxTextShowRatio; + } + } else { + // 与入场顺序相同 + if (this.slideDirection === 'left') { + // 从右到左的顺序 (最后的字符先消失) + disappearTime = ((totalItems - 1 - index) / totalItems) * maxTextShowRatio; + } else { + // 标准顺序 (第一个字符先消失) + disappearTime = (index / totalItems) * maxTextShowRatio; + } + } + + // 如果当前时间还没到这个字符的消失时间点,保持可见状态 + if (ratio < disappearTime) { + updatedTextConfig[index] = { + ...item, + opacity: 1, + dx: 0, + dy: 0 + }; + continue; + } + + // 计算动画进度(0-1之间) + const animProgress = (ratio - disappearTime) / this.fadeOutDuration; + const progress = Math.max(0, Math.min(1, animProgress)); + + // 计算当前偏移和透明度 + const dx = this.getTargetDx() * progress; + const dy = this.getTargetDy() * progress; + const opacity = 1 - progress; + + updatedTextConfig[index] = { + ...item, + opacity, + dx, + dy + }; + } + } + + return updatedTextConfig; + } +} diff --git a/packages/vrender-animate/src/custom/richtext/slide-richtext.ts b/packages/vrender-animate/src/custom/richtext/slide-richtext.ts new file mode 100644 index 000000000..4652f310b --- /dev/null +++ b/packages/vrender-animate/src/custom/richtext/slide-richtext.ts @@ -0,0 +1,329 @@ +import { ACustomAnimate } from '../custom-animate'; +import type { IRichTextCharacter, IAnimate, IStep, EasingType } from '@visactor/vrender-core'; +import { RichText } from '@visactor/vrender-core'; + +/** + * 滑动富文本动画,结合打字效果和方向滑动效果 + * 文字会从指定方向滑入,同时逐字显示和渐入 + * 支持上、下、左、右四个方向 + * 支持按单词或字符入场 + */ +export class SlideRichText extends ACustomAnimate<{ textConfig: IRichTextCharacter[] }> { + declare valid: boolean; + + private fromTextConfig: IRichTextCharacter[] = []; + private toTextConfig: IRichTextCharacter[] = []; + private originalTextConfig: IRichTextCharacter[] = []; + private singleCharConfig: IRichTextCharacter[] = []; + private fadeInDuration: number = 0.3; // 透明度渐变持续时间,以动画总时长的比例表示 + private slideDirection: 'up' | 'down' | 'left' | 'right' = 'right'; // 滑动方向 + private slideDistance: number = 30; // 滑动距离(像素) + private wordByWord: boolean = false; // 是否按单词为单位进行动画 + // 默认正则表达式: 匹配英文单词(含中间连字符),连续中文字符,数字,以及独立的符号和空格 + private wordRegex: RegExp = /[a-zA-Z]+(-[a-zA-Z]+)*|[\u4e00-\u9fa5]+|[0-9]+|[^\s\w\u4e00-\u9fa5]/g; + private wordGroups: number[][] = []; // 存储单词分组信息,每个数组包含属于同一单词的字符索引 + + constructor( + from: { textConfig: IRichTextCharacter[] }, + to: { textConfig: IRichTextCharacter[] }, + duration: number, + easing: EasingType, + params?: { + fadeInDuration?: number; + slideDirection?: 'up' | 'down' | 'left' | 'right'; + slideDistance?: number; + wordByWord?: boolean; + wordRegex?: RegExp; + } + ) { + super(from, to, duration, easing, params); + + // 配置透明度渐变效果 + if (params?.fadeInDuration !== undefined) { + this.fadeInDuration = params.fadeInDuration; + } + + // 配置滑动方向和距离 + if (params?.slideDirection !== undefined) { + this.slideDirection = params.slideDirection; + } + if (params?.slideDistance !== undefined) { + this.slideDistance = params.slideDistance; + } + + // 配置按单词动画 + if (params?.wordByWord !== undefined) { + this.wordByWord = params.wordByWord; + } + if (params?.wordRegex !== undefined) { + this.wordRegex = params.wordRegex; + } + } + + onFirstRun(): void { + const fromProps = this.getLastProps(); + const toProps = this.getEndProps(); + + // 存储原始配置 + this.originalTextConfig = toProps.textConfig ? [...toProps.textConfig] : []; + + // 初始化解析结果 + this.valid = true; + + // 确保to不为空 + if (!this.originalTextConfig || this.originalTextConfig.length === 0) { + this.valid = false; + return; + } + + // 将文本拆分为单个字符,使用RichText的静态方法 + this.fromTextConfig = + fromProps.textConfig && fromProps.textConfig.length > 0 + ? RichText.TransformTextConfig2SingleCharacter(fromProps.textConfig) + : []; + + this.toTextConfig = RichText.TransformTextConfig2SingleCharacter(this.originalTextConfig); + + // 创建单字符数组,用于动画 + this.singleCharConfig = this.toTextConfig.map(item => { + if ('text' in item) { + // 文本字符初始设置为透明 + return { + ...item, + opacity: 0, + dx: this.getInitialDx(), + dy: this.getInitialDy() + }; + } + return { ...item, opacity: 0 }; + }); + + // 如果启用按单词动画,则计算单词分组 + if (this.wordByWord) { + this.calculateWordGroups(); + } + } + + // 计算单词分组 + private calculateWordGroups(): void { + // 重置单词分组 + this.wordGroups = []; + + // 构建完整文本用于正则匹配 + let fullText = ''; + const charMap: Record = {}; // 映射全文索引到字符配置索引 + let fullTextIndex = 0; + + // 构建全文和映射 + this.toTextConfig.forEach((item, configIndex) => { + if ('text' in item) { + const text = String(item.text); + fullText += text; + // 为每个字符创建映射 + charMap[fullTextIndex] = configIndex; + fullTextIndex++; + } + }); + + // 使用正则表达式查找单词 + let match; + + // 重置正则表达式状态 + this.wordRegex.lastIndex = 0; + + while ((match = this.wordRegex.exec(fullText)) !== null) { + const wordStart = match.index; + const wordEnd = match.index + match[0].length; + + // 找出属于这个单词的所有字符索引 + const wordIndices = []; + + for (let i = wordStart; i < wordEnd; i++) { + if (charMap[i] !== undefined) { + wordIndices.push(charMap[i]); + } + } + + // 添加到单词分组 + if (wordIndices.length > 0) { + this.wordGroups.push(wordIndices); + } + } + + // 处理没有分配到任何单词的字符 + const allocatedIndices = new Set(); + this.wordGroups.forEach(group => { + group.forEach(index => allocatedIndices.add(index)); + }); + + for (let i = 0; i < this.toTextConfig.length; i++) { + if ('text' in this.toTextConfig[i] && !allocatedIndices.has(i)) { + // 单独为每个未分配的字符创建一个"单词" + this.wordGroups.push([i]); + } + } + } + + // 根据滑动方向计算初始x偏移 + private getInitialDx(): number { + switch (this.slideDirection) { + case 'left': + return -this.slideDistance; + case 'right': + return this.slideDistance; + default: + return 0; + } + } + + // 根据滑动方向计算初始y偏移 + private getInitialDy(): number { + switch (this.slideDirection) { + case 'up': + return -this.slideDistance; + case 'down': + return this.slideDistance; + default: + return 0; + } + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + if (!cb) { + // 动画结束时,恢复原始textConfig + this.target.setAttribute('textConfig', this.originalTextConfig); + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (!this.valid) { + return; + } + + // 计算文本显示比例上限 - 为尾部字符的渐变和滑动效果留出时间 + const maxTextShowRatio = 1 - this.fadeInDuration; + + let updatedTextConfig: IRichTextCharacter[]; + + if (this.wordByWord && this.wordGroups.length > 0) { + // 按单词动画 + updatedTextConfig = this.updateByWord(ratio, maxTextShowRatio); + } else { + // 按字符动画 + updatedTextConfig = this.updateByCharacter(ratio, maxTextShowRatio); + } + + // 更新富文本的textConfig属性 + this.target.setAttribute('textConfig', updatedTextConfig); + } + + // 按单词更新文本配置 + private updateByWord(ratio: number, maxTextShowRatio: number): IRichTextCharacter[] { + const totalGroups = this.wordGroups.length; + const updatedTextConfig = [...this.singleCharConfig]; + + // 处理单词分组 + for (let groupIndex = 0; groupIndex < this.wordGroups.length; groupIndex++) { + // 计算这个单词组的显示时间点 + let appearTime; + if (this.slideDirection === 'left') { + // 从右到左顺序(最后的单词先出现) + appearTime = ((totalGroups - 1 - groupIndex) / totalGroups) * maxTextShowRatio; + } else { + // 标准顺序(第一个单词先出现) + appearTime = (groupIndex / totalGroups) * maxTextShowRatio; + } + + // 如果当前时间还没到显示这个单词的时间点,保持隐藏状态 + if (ratio < appearTime) { + for (const charIndex of this.wordGroups[groupIndex]) { + const item = updatedTextConfig[charIndex]; + if ('text' in item) { + updatedTextConfig[charIndex] = { + ...item, + opacity: 0, + dx: this.getInitialDx(), + dy: this.getInitialDy() + }; + } + } + continue; + } + + // 计算动画进度(0-1之间) + const animProgress = (ratio - appearTime) / this.fadeInDuration; + const progress = Math.max(0, Math.min(1, animProgress)); + + // 计算当前偏移和透明度 + const dx = this.getInitialDx() * (1 - progress); + const dy = this.getInitialDy() * (1 - progress); + + // 更新这个单词的所有字符 + for (const charIndex of this.wordGroups[groupIndex]) { + const item = updatedTextConfig[charIndex]; + if ('text' in item) { + updatedTextConfig[charIndex] = { + ...item, + opacity: progress, + dx, + dy + }; + } + } + } + + return updatedTextConfig; + } + + // 按字符更新文本配置 + private updateByCharacter(ratio: number, maxTextShowRatio: number): IRichTextCharacter[] { + const totalItems = this.toTextConfig.length; + const updatedTextConfig = [...this.singleCharConfig]; + + // 更新每个字符的状态 + for (let index = 0; index < updatedTextConfig.length; index++) { + const item = updatedTextConfig[index]; + if ('text' in item) { + // 计算每个字符的显示时间点 + // 对于left方向,反转显示顺序(从右到左) + let appearTime; + if (this.slideDirection === 'left') { + // 从右到左的顺序 (最后的字符先出现) + appearTime = ((totalItems - 1 - index) / totalItems) * maxTextShowRatio; + } else { + // 标准顺序 (第一个字符先出现) + appearTime = (index / totalItems) * maxTextShowRatio; + } + + // 如果当前时间还没到显示这个字符的时间点,保持隐藏状态 + if (ratio < appearTime) { + updatedTextConfig[index] = { + ...item, + opacity: 0, + dx: this.getInitialDx(), + dy: this.getInitialDy() + }; + continue; + } + + // 计算动画进度(0-1之间) + const animProgress = (ratio - appearTime) / this.fadeInDuration; + const progress = Math.max(0, Math.min(1, animProgress)); + + // 计算当前偏移和透明度 + const dx = this.getInitialDx() * (1 - progress); + const dy = this.getInitialDy() * (1 - progress); + + updatedTextConfig[index] = { + ...item, + opacity: progress, + dx, + dy + }; + } + } + + return updatedTextConfig; + } +} diff --git a/packages/vrender-animate/src/custom/rotate.ts b/packages/vrender-animate/src/custom/rotate.ts new file mode 100644 index 000000000..e11fc723c --- /dev/null +++ b/packages/vrender-animate/src/custom/rotate.ts @@ -0,0 +1,102 @@ +import type { EasingType, IGraphic } from '@visactor/vrender-core'; +import { isNumberClose, isValidNumber } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +export interface IRotateAnimationOptions { + orient?: 'clockwise' | 'anticlockwise'; + angle?: number; +} + +export const rotateIn = (graphic: IGraphic, options: IRotateAnimationOptions) => { + const finalAttrs = graphic.getFinalAttribute(); + const attributeAngle = finalAttrs.angle ?? 0; + + let angle = 0; + if (isNumberClose(attributeAngle / (Math.PI * 2), 0)) { + angle = Math.round(attributeAngle / (Math.PI * 2)) * Math.PI * 2; + } else if (isValidNumber(options?.angle)) { + angle = options.angle; + } else if (options?.orient === 'anticlockwise') { + angle = Math.ceil(attributeAngle / (Math.PI * 2)) * Math.PI * 2; + } else { + angle = Math.floor(attributeAngle / (Math.PI * 2)) * Math.PI * 2; + } + return { + from: { angle }, + to: { angle: attributeAngle } + }; +}; + +export const rotateOut = (graphic: IGraphic, options: IRotateAnimationOptions) => { + const finalAttrs = graphic.getFinalAttribute(); + const finalAngle = finalAttrs.angle ?? 0; + let angle = 0; + if (isNumberClose(finalAngle / (Math.PI * 2), 0)) { + angle = Math.round(finalAngle / (Math.PI * 2)) * Math.PI * 2; + } else if (isValidNumber(options?.angle)) { + angle = options.angle; + } else if (options?.orient === 'anticlockwise') { + angle = Math.ceil(finalAngle / (Math.PI * 2)) * Math.PI * 2; + } else { + angle = Math.floor(finalAngle / (Math.PI * 2)) * Math.PI * 2; + } + return { + from: { angle: finalAngle }, + to: { angle } + }; +}; + +export class RotateBase extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: any) { + super(from, to, duration, easing, params); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 增长渐入 + */ +export class RotateIn extends RotateBase { + onBind(): void { + super.onBind(); + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const { from, to } = rotateIn(this.target, this.params.options); + + this.props = to; + this.propKeys = ['angle']; + this.from = from; + this.to = to; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } +} + +export class RotateOut extends RotateBase { + onBind(): void { + super.onBind(); + const { from, to } = rotateOut(this.target, this.params.options); + this.props = to; + this.propKeys = ['angle']; + + this.from = from; + this.to = to; + } +} diff --git a/packages/vrender-animate/src/custom/scale.ts b/packages/vrender-animate/src/custom/scale.ts new file mode 100644 index 000000000..923f0739c --- /dev/null +++ b/packages/vrender-animate/src/custom/scale.ts @@ -0,0 +1,155 @@ +import type { EasingType, IAnimate, IStep } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export interface IScaleAnimationOptions { + direction?: 'x' | 'y' | 'xy'; +} + +export class ScaleIn extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + } + + declare _updateFunction: (ratio: number) => void; + + onBind(): void { + super.onBind(); + let from: Record; + let to: Record; + const attrs = this.target.getFinalAttribute(); + const fromAttrs = this.target.attribute ?? {}; + + switch (this.params?.direction) { + case 'x': + from = { scaleX: fromAttrs.scaleX ?? 0 }; + to = { scaleX: attrs?.scaleX ?? 1 }; + this._updateFunction = this.updateX; + break; + case 'y': + from = { scaleY: fromAttrs.scaleY ?? 0 }; + to = { scaleY: attrs?.scaleY ?? 1 }; + this._updateFunction = this.updateY; + break; + case 'xy': + default: + from = { scaleX: fromAttrs.scaleX ?? 0, scaleY: fromAttrs.scaleY ?? 0 }; + to = { + scaleX: attrs?.scaleX ?? 1, + scaleY: attrs?.scaleY ?? 1 + }; + this._updateFunction = this.updateXY; + } + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + + this.props = to; + this.from = from; + this.to = to; + if (this.params.controlOptions?.immediatelyApply !== false) { + this.target.setAttributes(from); + } + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + updateX(ratio: number): void { + this.target.attribute.scaleX = this.from.scaleX + (this.to.scaleX - this.from.scaleX) * ratio; + } + + updateY(ratio: number): void { + this.target.attribute.scaleY = this.from.scaleY + (this.to.scaleY - this.from.scaleY) * ratio; + } + + updateXY(ratio: number): void { + this.updateX(ratio); + this.updateY(ratio); + } + + /** + * 删除自身属性,会直接从props等内容里删除掉 + */ + deleteSelfAttr(key: string): void { + delete this.props[key]; + // fromProps在动画开始时才会计算,这时可能不在 + this.fromProps && delete this.fromProps[key]; + const index = this.propKeys.indexOf(key); + if (index !== -1) { + this.propKeys.splice(index, 1); + } + + if (this.propKeys && this.propKeys.length > 1) { + this._updateFunction = this.updateXY; + } else if (this.propKeys[0] === 'scaleX') { + this._updateFunction = this.updateX; + } else if (this.propKeys[0] === 'scaleY') { + this._updateFunction = this.updateY; + } else { + this._updateFunction = null; + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (this._updateFunction) { + this._updateFunction(ratio); + this.target.addUpdatePositionTag(); + this.target.addUpdateBoundTag(); + } + } +} + +export class ScaleOut extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IScaleAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + let from: Record; + let to: Record; + // 获取当前的数据 + const attrs = this.target.attribute; + switch (this.params?.direction) { + case 'x': + from = { scaleX: attrs?.scaleX ?? 1 }; + to = { scaleX: 0 }; + break; + case 'y': + from = { scaleY: attrs?.scaleY ?? 1 }; + to = { scaleY: 0 }; + break; + case 'xy': + default: + from = { scaleX: attrs?.scaleX ?? 1, scaleY: attrs?.scaleY ?? 1 }; + to = { + scaleX: 0, + scaleY: 0 + }; + } + this.props = to; + this.from = from; + this.to = to; + } + + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + super.onEnd(cb); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateBoundTag(); + } +} diff --git a/packages/vrender-animate/src/custom/sphere.ts b/packages/vrender-animate/src/custom/sphere.ts new file mode 100644 index 000000000..a3c1118f8 --- /dev/null +++ b/packages/vrender-animate/src/custom/sphere.ts @@ -0,0 +1,83 @@ +import { pi, pi2 } from '@visactor/vutils'; +import { ACustomAnimate } from './custom-animate'; + +type RotateSphereParams = + | { + center: { x: number; y: number; z: number }; + r: number; + cb?: (out: any) => void; + } + | (() => any); + +export class RotateBySphereAnimate extends ACustomAnimate { + declare params: RotateSphereParams; + declare theta: number; + declare phi: number; + + onBind(): void { + super.onBind(); + + // const to: Record = {}; + // const from: Record = this.from ?? {}; + + // 用于入场的时候设置属性(因为有动画的时候VChart不会再设置属性了) + + // this.props = to; + this.propKeys = ['x', 'y', 'z', 'alpha', 'zIndex']; + // this.from = from; + // this.to = to; + } + + onFirstRun(): void { + super.onFirstRun(); + const finalAttribute = this.target.getFinalAttribute(); + if (finalAttribute) { + this.target.setAttributes(finalAttribute); + } + } + + onStart(): void { + super.onStart(); + const { center, r } = typeof this.params === 'function' ? this.params() : this.params; + const startX = this.target.finalAttribute.x; + const startY = this.target.finalAttribute.y; + const startZ = this.target.finalAttribute.z; + const phi = Math.acos((startY - center.y) / r); + let theta = Math.acos((startX - center.x) / r / Math.sin(phi)); + if (startZ - center.z < 0) { + theta = pi2 - theta; + } + this.theta = theta; + this.phi = phi; + } + + onEnd() { + return; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (this.phi == null || this.theta == null) { + return; + } + const target = this.target; + const { center, r, cb } = typeof this.params === 'function' ? this.params() : this.params; + const deltaAngle = Math.PI * 2 * ratio; + const theta = this.theta + deltaAngle; + const phi = this.phi; + const x = r * Math.sin(phi) * Math.cos(theta) + center.x; + const y = r * Math.cos(phi) + center.y; + const z = r * Math.sin(phi) * Math.sin(theta) + center.z; + target.attribute.x = x; + target.attribute.y = y; + target.attribute.z = z; + // out.beta = phi; + target.attribute.alpha = theta + pi / 2; + while (target.attribute.alpha > pi2) { + target.attribute.alpha -= pi2; + } + target.attribute.alpha = pi2 - target.attribute.alpha; + + target.attribute.zIndex = target.attribute.z * -10000; + cb && cb(out); + } +} diff --git a/packages/vrender-animate/src/custom/state.ts b/packages/vrender-animate/src/custom/state.ts new file mode 100644 index 000000000..9c6e03e20 --- /dev/null +++ b/packages/vrender-animate/src/custom/state.ts @@ -0,0 +1,45 @@ +import type { EasingType, IAnimate, IStep } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export interface IUpdateAnimationOptions { + diffAttrs: Record; + animationState: string; + diffState: string; + data: Record[]; +} + +/** + * 文本输入动画,实现类似打字机的字符逐个显示效果 + * 支持通过beforeText和afterText参数添加前缀和后缀 + * 支持通过showCursor参数显示光标,cursorChar自定义光标字符 + */ +export class State extends ACustomAnimate> { + declare valid: boolean; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IUpdateAnimationOptions) { + super(from, to, duration, easing, params); + } + + update(end: boolean, ratio: number, out: Record): void { + this.onStart(); + if (!this.props || !this.propKeys) { + return; + } + // 应用缓动函数 + const easedRatio = this.easing(ratio); + this.animate.interpolateUpdateFunction + ? this.animate.interpolateUpdateFunction(this.fromProps, this.props, easedRatio, this, this.target) + : this.interpolateUpdateFunctions.forEach((func, index) => { + // 如果这个属性被屏蔽了,直接跳过 + if (!this.animate.validAttr(this.propKeys[index])) { + return; + } + const key = this.propKeys[index]; + const fromValue = this.fromProps[key]; + const toValue = this.props[key]; + func(key, fromValue, toValue, easedRatio, this, this.target); + }); + this.onUpdate(end, easedRatio, out); + this.syncAttributeUpdate(); + } +} diff --git a/packages/vrender-animate/src/custom/story.ts b/packages/vrender-animate/src/custom/story.ts new file mode 100644 index 000000000..75b22200d --- /dev/null +++ b/packages/vrender-animate/src/custom/story.ts @@ -0,0 +1,1012 @@ +import { FadeIn } from './fade'; +import type { EasingType, IGraphicAttribute } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; +import { AnimateExecutor } from '../executor/animate-executor'; +import { ColorStore, ColorType, interpolateColor } from '@visactor/vrender-core'; + +export class StoryFadeIn extends FadeIn {} + +// 滑动动画的参数接口 +export interface ISlideAnimationOptions { + direction?: 'top' | 'bottom' | 'left' | 'right'; + distance?: number; + fromOpacity?: number; // 透明度初始值,默认0 +} + +// 缩放动画的参数接口 +export interface IGrowAnimationOptions { + fromScale?: number; + direction?: 'x' | 'y' | 'xy'; + fromOpacity?: number; // 透明度初始值,默认0 +} + +// 旋转动画的参数接口 +export interface ISpinAnimationOptions { + fromAngle?: number; + fromScale?: number; + fromOpacity?: number; // 透明度初始值,默认0 +} + +// 描边动画的参数接口 +export interface IStrokeAnimationOptions { + lineWidth?: number; // 描边宽度,默认2 + strokeColor?: string; // 描边颜色,默认黑色 + fromOpacity?: number; // 透明度初始值,默认1 + dashLength?: number; // 虚线长度,默认为元素周长 + showFill?: boolean; // 是否显示填充,默认false + fillOpacity?: number; // 填充透明度,仅当showFill为true时有效 +} + +// 脉冲/强调动画的参数接口 +export interface IPulseAnimationOptions { + pulseCount?: number; // 脉冲次数 + pulseOpacity?: number; // 脉冲透明度,默认0.3 + pulseScale?: number; // 缩放比例,默认1.05 + pulseColor?: string; // 脉冲颜色,默认为元素自身颜色 + pulseColorIntensity?: number; // 脉冲颜色强度,0-1,默认0.2 + strokeOnly?: boolean; // 是否只应用于描边,默认false + fillOnly?: boolean; // 是否只应用于填充,默认false + useScale?: boolean; // 是否使用缩放效果,默认true + useOpacity?: boolean; // 是否使用透明度效果,默认true + useColor?: boolean; // 是否使用颜色效果,默认false + useStroke?: boolean; // 是否使用描边效果,默认true + useFill?: boolean; // 是否使用填充效果,默认true +} + +/** + * 滑动入场动画,包括从上到下,从下到上,从左到右,从右到左的位置,以及透明度属性插值 + */ +export class SlideIn extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: ISlideAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + // 用于入场的时候设置属性 + const attrs = this.target.getFinalAttribute(); + + const direction = (this.params?.direction as 'top' | 'bottom' | 'left' | 'right') || 'right'; + const distance = this.params?.distance || 50; + const fromOpacity = this.params?.fromOpacity ?? 0; // 使用透明度初始值参数 + + // 初始化from和to对象 + const from: Record = { opacity: fromOpacity, baseOpacity: fromOpacity }; + const to: Record = { opacity: 1, baseOpacity: 1 }; + + // 根据方向设置对应的属性 + if (direction === 'top') { + from.y = (attrs.y ?? 0) - distance; + to.y = attrs.y ?? 0; + this.propKeys = ['opacity', 'baseOpacity', 'y']; + } else if (direction === 'bottom') { + from.y = (attrs.y ?? 0) + distance; + to.y = attrs.y ?? 0; + this.propKeys = ['opacity', 'baseOpacity', 'y']; + } else if (direction === 'left') { + from.x = (attrs.x ?? 0) - distance; + to.x = attrs.x ?? 0; + this.propKeys = ['opacity', 'baseOpacity', 'x']; + } else { + // right + from.x = (attrs.x ?? 0) + distance; + to.x = attrs.x ?? 0; + this.propKeys = ['opacity', 'baseOpacity', 'x']; + } + + this.from = from; + this.to = to; + this.props = to; + + // 将初始属性应用到目标对象 + this.target.setAttributes(from as any); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 缩放入场动画,包括scaleX、scaleY属性从某个比例缩放到1,该比例可以小于1也可以大于1,以及透明度属性插值 + */ +export class GrowIn extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IGrowAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + // 用于入场的时候设置属性 + const attrs = this.target.getFinalAttribute(); + + const fromScale = this.params?.fromScale ?? 0; + const direction = this.params?.direction || 'xy'; + const fromOpacity = this.params?.fromOpacity ?? 0; // 使用透明度初始值参数 + + // 初始化from和to对象 + const from: Record = { opacity: fromOpacity, baseOpacity: fromOpacity }; + const to: Record = { opacity: 1, baseOpacity: 1 }; + this.propKeys = ['opacity', 'baseOpacity']; + + // 根据方向设置对应的缩放属性 + if (direction === 'x' || direction === 'xy') { + from.scaleX = fromScale; + to.scaleX = attrs.scaleX ?? 1; + this.propKeys.push('scaleX'); + } + + if (direction === 'y' || direction === 'xy') { + from.scaleY = fromScale; + to.scaleY = attrs.scaleY ?? 1; + this.propKeys.push('scaleY'); + } + + this.from = from; + this.to = to; + this.props = to; + + // 将初始属性应用到目标对象 + this.target.setAttributes(from as any); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 旋转入场动画,包括rotate属性从某个角度度旋转到0,以及缩放属性从某个比例缩放到1,该比例可以小于1也可以大于1 + */ +export class SpinIn extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: ISpinAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + // 用于入场的时候设置属性 + const attrs = this.target.getFinalAttribute(); + + const fromAngle = this.params?.fromAngle ?? Math.PI * 2; // 默认旋转一圈 + const fromScale = this.params?.fromScale ?? 0; + const fromOpacity = this.params?.fromOpacity ?? 0; // 使用透明度初始值参数 + + // 初始化from和to对象 + const from: Record = { + opacity: fromOpacity, + baseOpacity: fromOpacity, + angle: fromAngle, + scaleX: fromScale, + scaleY: fromScale + }; + + const to: Record = { + opacity: 1, + baseOpacity: 1, + angle: attrs.angle ?? 0, + scaleX: attrs.scaleX ?? 1, + scaleY: attrs.scaleY ?? 1 + }; + + this.propKeys = ['opacity', 'baseOpacity', 'angle', 'scaleX', 'scaleY']; + this.from = from; + this.to = to; + this.props = to; + + // 将初始属性应用到目标对象 + this.target.setAttributes(from as any); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 描边入场动画,使用lineDashOffset实现描边效果 + * 通过调整虚线偏移量,创建线条逐渐显示的动画效果 + */ +export class StrokeIn extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + private perimeter: number = 0; + private originalAttributes: Record = {}; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IStrokeAnimationOptions) { + super(from, to, duration, easing, params); + } + + onBind(): void { + super.onBind(); + // 保存原始属性 + this.originalAttributes = { ...this.target.getAttributes() }; + + // 获取图形周长 + if (this.target.type === 'rect') { + const attr = this.target.attribute as any; + const width = attr.width ?? 100; + const height = attr.height ?? 100; + this.perimeter = 2 * (width + height); + } else if (this.target.type === 'circle') { + const attr = this.target.attribute as any; + const radius = attr.radius ?? 50; + this.perimeter = 2 * Math.PI * radius; + } else if (this.target.type === 'ellipse') { + const attr = this.target.attribute as any; + const radiusX = attr.radiusX ?? 50; + const radiusY = attr.radiusY ?? 50; + // 椭圆周长近似计算 + this.perimeter = 2 * Math.PI * Math.sqrt((radiusX * radiusX + radiusY * radiusY) / 2); + } else { + // 对于其他形状,使用默认值 + this.perimeter = 1000; + } + + const lineWidth = this.params?.lineWidth ?? 2; + const strokeColor = this.params?.strokeColor ?? 'black'; + const fromOpacity = this.params?.fromOpacity ?? 1; + const dashLength = this.params?.dashLength ?? this.perimeter; + const showFill = this.params?.showFill ?? false; + const fillOpacity = this.params?.fillOpacity ?? 0; + + // 设置初始状态 + this.from = { + lineDash: [dashLength, dashLength], + lineDashOffset: dashLength, + lineWidth, + stroke: strokeColor, + strokeOpacity: fromOpacity + }; + + // 设置目标状态 + this.to = { + lineDash: [dashLength, dashLength], + lineDashOffset: 0, + lineWidth, + stroke: strokeColor, + strokeOpacity: fromOpacity + }; + + // 如果需要显示填充,添加填充相关属性 + if (showFill) { + this.from.fillOpacity = fillOpacity; + this.to.fillOpacity = this.originalAttributes.fillOpacity ?? 1; + } else { + this.from.fillOpacity = 0; + this.to.fillOpacity = 0; + } + + this.propKeys = ['lineDash', 'lineDashOffset', 'lineWidth', 'stroke', 'strokeOpacity', 'fillOpacity']; + this.props = this.to; + + // 应用初始属性 + this.target.setAttributes(this.from); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + + // 更新lineDashOffset + attribute.lineDashOffset = this.from.lineDashOffset + (this.to.lineDashOffset - this.from.lineDashOffset) * ratio; + + // 更新fillOpacity (如果需要显示填充) + if (this.params?.showFill) { + attribute.fillOpacity = this.from.fillOpacity + (this.to.fillOpacity - this.from.fillOpacity) * ratio; + } + } + + onEnd(): void { + super.onEnd(); + // 动画结束后,是否要恢复原始属性 + if (!this.params?.showFill) { + // 如果不显示填充,恢复原始的stroke属性但保持fillOpacity为0 + const originalAttrs = { ...this.originalAttributes }; + originalAttrs.fillOpacity = 0; + this.target.setAttributes(originalAttrs); + } + } +} + +/** + * 描边出场动画,使用lineDashOffset实现描边消失效果 + */ +export class StrokeOut extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + private perimeter: number = 0; + private originalAttributes: Record = {}; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IStrokeAnimationOptions) { + super(from, to, duration, easing, params); + } + + onFirstRun(): void { + // 保存原始属性 + this.originalAttributes = { ...this.target.getAttributes() }; + + // 获取图形周长 + if (this.target.type === 'rect') { + const attr = this.target.attribute as any; + const width = attr.width ?? 100; + const height = attr.height ?? 100; + this.perimeter = 2 * (width + height); + } else if (this.target.type === 'circle') { + const attr = this.target.attribute as any; + const radius = attr.radius ?? 50; + this.perimeter = 2 * Math.PI * radius; + } else if (this.target.type === 'ellipse') { + const attr = this.target.attribute as any; + const radiusX = attr.radiusX ?? 50; + const radiusY = attr.radiusY ?? 50; + // 椭圆周长近似计算 + this.perimeter = 2 * Math.PI * Math.sqrt((radiusX * radiusX + radiusY * radiusY) / 2); + } else { + // 对于其他形状,使用默认值 + this.perimeter = 1000; + } + + const lineWidth = this.params?.lineWidth ?? 2; + const strokeColor = this.params?.strokeColor ?? 'black'; + const fromOpacity = this.params?.fromOpacity ?? 1; + const dashLength = this.params?.dashLength ?? this.perimeter; + const showFill = this.params?.showFill ?? false; + + // 设置初始状态 - 完全显示的描边 + this.from = { + lineDash: [dashLength, dashLength], + lineDashOffset: 0, + lineWidth, + stroke: strokeColor, + strokeOpacity: fromOpacity + }; + + // 设置目标状态 - 完全消失的描边 + this.to = { + lineDash: [dashLength, dashLength], + lineDashOffset: -dashLength, + lineWidth, + stroke: strokeColor, + strokeOpacity: fromOpacity + }; + + // 处理填充 + if (showFill) { + this.from.fillOpacity = this.originalAttributes.fillOpacity ?? 1; + this.to.fillOpacity = 0; + } else { + this.from.fillOpacity = 0; + this.to.fillOpacity = 0; + } + + this.propKeys = ['lineDash', 'lineDashOffset', 'lineWidth', 'stroke', 'strokeOpacity', 'fillOpacity']; + this.props = this.to; + + // 应用初始属性 + this.target.setAttributes(this.from); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + + // 更新lineDashOffset + attribute.lineDashOffset = this.from.lineDashOffset + (this.to.lineDashOffset - this.from.lineDashOffset) * ratio; + + // 更新fillOpacity (如果有) + if (this.params?.showFill) { + attribute.fillOpacity = this.from.fillOpacity + (this.to.fillOpacity - this.from.fillOpacity) * ratio; + } + } +} + +// 复合动画的参数接口 +export interface IMoveScaleAnimationOptions { + slideDirection?: 'top' | 'bottom' | 'left' | 'right'; + slideDistance?: number; + fromScale?: number; + scaleDirection?: 'x' | 'y' | 'xy'; + slideRatio?: number; // 滑动动画占总时长的比例,默认0.5 + fromOpacity?: number; // 透明度初始值,默认0 +} + +/** + * 移动+缩放入场动画 + * 先走SlideIn,然后走GrowIn + */ +export class MoveScaleIn extends ACustomAnimate { + declare valid: boolean; + private readonly slideInDuration: number; + private readonly growInDuration: number; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IMoveScaleAnimationOptions) { + super(from, to, duration, easing, params); + const slideRatio = params?.slideRatio ?? 0.5; + this.slideInDuration = duration * slideRatio; + this.growInDuration = duration * (1 - slideRatio); + } + + onBind(): void { + super.onBind(); + // 创建AnimateExecutor来运行序列动画 + const executor = new AnimateExecutor(this.target); + + // 第一步:滑动入场(包含透明度变化) + executor.execute({ + type: 'custom', + custom: SlideIn, + customParameters: { + direction: this.params?.slideDirection || 'right', + distance: this.params?.slideDistance || 50, + fromOpacity: this.params?.fromOpacity ?? 0 + }, + duration: this.slideInDuration, + easing: this.easing + }); + + // 第二步:缩放入场(不包含透明度变化) + executor.execute({ + type: 'custom', + custom: GrowIn, + customParameters: { + fromScale: this.params?.fromScale || 0.5, + direction: this.params?.scaleDirection || 'xy', + fromOpacity: 1 // 设置初始透明度为1,使第二阶段不进行透明度插值 + }, + duration: this.growInDuration, + easing: this.easing, + delay: this.slideInDuration // 等第一步完成后再开始 + }); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // 动画逻辑由子动画处理 + } +} + +// 移动旋转动画的参数接口 +export interface IMoveRotateAnimationOptions { + slideDirection?: 'top' | 'bottom' | 'left' | 'right'; + slideDistance?: number; + fromAngle?: number; + fromScale?: number; + slideRatio?: number; // 滑动动画占总时长的比例,默认0.5 + fromOpacity?: number; // 透明度初始值,默认0 +} + +/** + * 移动+旋转入场动画 + * 先走SlideIn,然后走SpinIn + */ +export class MoveRotateIn extends ACustomAnimate { + declare valid: boolean; + private readonly slideInDuration: number; + private readonly spinInDuration: number; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IMoveRotateAnimationOptions) { + super(from, to, duration, easing, params); + const slideRatio = params?.slideRatio ?? 0.5; + this.slideInDuration = duration * slideRatio; + this.spinInDuration = duration * (1 - slideRatio); + } + + onBind(): void { + super.onBind(); + // 创建AnimateExecutor来运行序列动画 + const executor = new AnimateExecutor(this.target); + + // 第一步:滑动入场(包含透明度变化) + executor.execute({ + type: 'custom', + custom: SlideIn, + customParameters: { + direction: this.params?.slideDirection || 'right', + distance: this.params?.slideDistance || 50, + fromOpacity: this.params?.fromOpacity ?? 0 + }, + duration: this.slideInDuration, + easing: this.easing + }); + + // 第二步:旋转入场(不包含透明度变化) + executor.execute({ + type: 'custom', + custom: SpinIn, + customParameters: { + fromAngle: this.params?.fromAngle || Math.PI, + fromScale: this.params?.fromScale || 0.5, + fromOpacity: 1 // 设置初始透明度为1,使第二阶段不进行透明度插值 + }, + duration: this.spinInDuration, + easing: this.easing, + delay: this.slideInDuration // 等第一步完成后再开始 + }); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // 动画逻辑由子动画处理 + } +} + +/** + * 滑动出场动画,包括从当前位置滑动到指定方向,以及透明度属性插值 + */ +export class SlideOut extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor( + from: null, + to: null, + duration: number, + easing: EasingType, + params?: ISlideAnimationOptions & { toOpacity?: number } + ) { + super(from, to, duration, easing, params); + } + + onFirstRun(): void { + // 用于出场的时候设置属性 + const attrs = this.target.getAttributes(); + + const direction = (this.params?.direction as 'top' | 'bottom' | 'left' | 'right') || 'right'; + const distance = this.params?.distance || 50; + const fromOpacity = this.params?.fromOpacity ?? 1; // 使用透明度初始值参数 + const toOpacity = this.params?.toOpacity ?? 0; // 使用目标透明度参数 + + // 初始化from和to对象 + const from: Record = { opacity: fromOpacity, baseOpacity: fromOpacity }; + const to: Record = { opacity: toOpacity, baseOpacity: toOpacity }; + + // 根据方向设置对应的属性 + if (direction === 'top') { + from.y = attrs.y ?? 0; + to.y = (attrs.y ?? 0) - distance; + this.propKeys = ['opacity', 'baseOpacity', 'y']; + } else if (direction === 'bottom') { + from.y = attrs.y ?? 0; + to.y = (attrs.y ?? 0) + distance; + this.propKeys = ['opacity', 'baseOpacity', 'y']; + } else if (direction === 'left') { + from.x = attrs.x ?? 0; + to.x = (attrs.x ?? 0) - distance; + this.propKeys = ['opacity', 'baseOpacity', 'x']; + } else { + // right + from.x = attrs.x ?? 0; + to.x = (attrs.x ?? 0) + distance; + this.propKeys = ['opacity', 'baseOpacity', 'x']; + } + + this.from = from; + this.to = to; + this.props = to; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 缩放出场动画,包括scaleX、scaleY属性从当前比例缩放到指定比例,以及透明度属性插值 + */ +export class GrowOut extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor( + from: null, + to: null, + duration: number, + easing: EasingType, + params?: IGrowAnimationOptions & { toOpacity?: number } + ) { + super(from, to, duration, easing, params); + } + + onFirstRun(): void { + // 用于出场的时候设置属性 + const attrs = this.target.getAttributes(); + + const toScale = this.params?.fromScale ?? 0; // 使用fromScale作为目标比例 + const direction = this.params?.direction || 'xy'; + const fromOpacity = this.params?.fromOpacity ?? 1; // 使用透明度初始值参数 + const toOpacity = this.params?.toOpacity ?? 0; // 使用目标透明度参数 + + // 初始化from和to对象 + const from: Record = { opacity: fromOpacity, baseOpacity: fromOpacity }; + const to: Record = { opacity: toOpacity, baseOpacity: toOpacity }; + this.propKeys = ['opacity', 'baseOpacity']; + + // 根据方向设置对应的缩放属性 + if (direction === 'x' || direction === 'xy') { + from.scaleX = attrs.scaleX ?? 1; + to.scaleX = toScale; + this.propKeys.push('scaleX'); + } + + if (direction === 'y' || direction === 'xy') { + from.scaleY = attrs.scaleY ?? 1; + to.scaleY = toScale; + this.propKeys.push('scaleY'); + } + + this.from = from; + this.to = to; + this.props = to; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 旋转出场动画,包括rotate属性从当前角度旋转到指定角度,以及缩放属性从当前比例缩放到指定比例 + */ +export class SpinOut extends ACustomAnimate> { + declare valid: boolean; + declare propKeys: string[]; + declare from: Record; + declare to: Record; + + constructor( + from: null, + to: null, + duration: number, + easing: EasingType, + params?: ISpinAnimationOptions & { toOpacity?: number } + ) { + super(from, to, duration, easing, params); + } + + onFirstRun(): void { + // 用于出场的时候设置属性 + const attrs = this.target.getAttributes(); + + const toAngle = this.params?.fromAngle ?? Math.PI * 2; // 默认旋转一圈 + const toScale = this.params?.fromScale ?? 0; + const fromOpacity = this.params?.fromOpacity ?? 1; // 使用透明度初始值参数 + const toOpacity = this.params?.toOpacity ?? 0; // 使用目标透明度参数 + + // 初始化from和to对象 + const from: Record = { + opacity: fromOpacity, + baseOpacity: fromOpacity, + angle: attrs.angle ?? 0, + scaleX: attrs.scaleX ?? 1, + scaleY: attrs.scaleY ?? 1 + }; + + const to: Record = { + opacity: toOpacity, + baseOpacity: toOpacity, + angle: toAngle, + scaleX: toScale, + scaleY: toScale + }; + + this.propKeys = ['opacity', 'baseOpacity', 'angle', 'scaleX', 'scaleY']; + this.from = from; + this.to = to; + this.props = to; + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + const attribute: Record = this.target.attribute; + this.propKeys.forEach(key => { + attribute[key] = this.from[key] + (this.to[key] - this.from[key]) * ratio; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} + +/** + * 移动+缩放出场动画 + * 先走GrowOut,然后走SlideOut + */ +export class MoveScaleOut extends ACustomAnimate { + declare valid: boolean; + private readonly growOutDuration: number; + private readonly slideOutDuration: number; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IMoveScaleAnimationOptions) { + super(from, to, duration, easing, params); + const slideRatio = params?.slideRatio ?? 0.5; + this.growOutDuration = duration * (1 - slideRatio); + this.slideOutDuration = duration * slideRatio; + } + + onFirstRun(): void { + // 创建AnimateExecutor来运行序列动画 + const executor = new AnimateExecutor(this.target); + + // 第一步:缩放出场(不包含透明度变化) + executor.execute({ + type: 'custom', + custom: GrowOut, + customParameters: { + fromScale: this.params?.fromScale || 0.5, + direction: this.params?.scaleDirection || 'xy', + fromOpacity: 1, // 保持透明度为1,不做变化 + toOpacity: 1 // 确保第一阶段不改变透明度 + }, + duration: this.growOutDuration, + easing: this.easing + }); + + // 第二步:滑动出场(包含透明度变化) + executor.execute({ + type: 'custom', + custom: SlideOut, + customParameters: { + direction: this.params?.slideDirection || 'right', + distance: this.params?.slideDistance || 50, + fromOpacity: 1 // 起始透明度为1 + }, + duration: this.slideOutDuration, + easing: this.easing, + delay: this.growOutDuration // 等第一步完成后再开始 + }); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // 动画逻辑由子动画处理 + } +} + +/** + * 移动+旋转出场动画 + * 先走SpinOut,然后走SlideOut + */ +export class MoveRotateOut extends ACustomAnimate { + declare valid: boolean; + private readonly spinOutDuration: number; + private readonly slideOutDuration: number; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IMoveRotateAnimationOptions) { + super(from, to, duration, easing, params); + const slideRatio = params?.slideRatio ?? 0.5; + this.spinOutDuration = duration * (1 - slideRatio); + this.slideOutDuration = duration * slideRatio; + } + + onFirstRun(): void { + // 创建AnimateExecutor来运行序列动画 + const executor = new AnimateExecutor(this.target); + + // 第一步:旋转出场(不包含透明度变化) + executor.execute({ + type: 'custom', + custom: SpinOut, + customParameters: { + fromAngle: this.params?.fromAngle || Math.PI, + fromScale: this.params?.fromScale || 0.5, + fromOpacity: 1, // 保持透明度为1,不做变化 + toOpacity: 1 // 确保第一阶段不改变透明度 + }, + duration: this.spinOutDuration, + easing: this.easing + }); + + // 第二步:滑动出场(包含透明度变化) + executor.execute({ + type: 'custom', + custom: SlideOut, + customParameters: { + direction: this.params?.slideDirection || 'right', + distance: this.params?.slideDistance || 50, + fromOpacity: 1 // 起始透明度为1 + }, + duration: this.slideOutDuration, + easing: this.easing, + delay: this.spinOutDuration // 等第一步完成后再开始 + }); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // 动画逻辑由子动画处理 + } +} + +/** + * 脉冲/强调动画,通过循环变化透明度、颜色和缩放来吸引注意力 + */ +export class PulseAnimate extends ACustomAnimate> { + declare valid: boolean; + private originalAttributes: Record = {}; + private pulseCount: number = 3; // 默认3次脉冲 + private pulseOpacity: number = 0.3; + private pulseScale: number = 1.05; + private pulseColor: string | null = null; + private pulseColorIntensity: number = 0.2; + private strokeOnly: boolean = false; + private fillOnly: boolean = false; + private useScale: boolean = true; + private useOpacity: boolean = true; + private useStroke: boolean = true; + private useFill: boolean = true; + private useColor: boolean = false; + private originalFill: string | null = null; + private originalStroke: string | null = null; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IPulseAnimationOptions) { + super(from, to, duration, easing, params); + + // 配置脉冲参数 + if (params?.pulseCount !== undefined) { + this.pulseCount = params.pulseCount; + } + if (params?.pulseScale !== undefined) { + this.pulseScale = params.pulseScale; + } + if (params?.pulseColor !== undefined) { + this.pulseColor = params.pulseColor; + } + if (params?.pulseColorIntensity !== undefined) { + this.pulseColorIntensity = params.pulseColorIntensity; + } + if (params?.strokeOnly !== undefined) { + this.strokeOnly = params.strokeOnly; + } + if (params?.fillOnly !== undefined) { + this.fillOnly = params.fillOnly; + } + if (params?.useScale !== undefined) { + this.useScale = params.useScale; + } + if (params?.useOpacity !== undefined) { + this.useOpacity = params.useOpacity; + } + if (params?.useStroke !== undefined) { + this.useStroke = params.useStroke; + } + if (params?.useFill !== undefined) { + this.useFill = params.useFill; + } + if (params?.useColor !== undefined) { + this.useColor = params.useColor; + } + } + + onBind(): void { + super.onBind(); + // 保存原始属性 + this.originalAttributes = { ...this.target.getAttributes() }; + + // 保存颜色相关的属性 + if (this.useColor) { + this.originalFill = this.originalAttributes.fill || null; + this.originalStroke = this.originalAttributes.stroke || null; + + // 如果没有指定脉冲颜色,使用元素自身的颜色 + if (!this.pulseColor) { + if (this.fillOnly && this.originalFill) { + this.pulseColor = this.originalFill; + } else if (this.strokeOnly && this.originalStroke) { + this.pulseColor = this.originalStroke; + } else if (this.originalFill) { + this.pulseColor = this.originalFill; + } else if (this.originalStroke) { + this.pulseColor = this.originalStroke; + } else { + this.pulseColor = '#FFFFFF'; // 默认白色 + } + } + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // 使用ratio计算脉冲效果 + // ratio从0到1表示整个动画的进度 + + // 计算脉冲值:通过将单个进度映射到多个脉冲周期 + // 将0-1的ratio映射到0到pulseCount*2*PI的角度,用于sin函数 + const angle = ratio * Math.PI * this.pulseCount; + // 将sin值(-1到1)映射到0到1的范围 + const pulseValue = Math.abs(Math.sin(angle)); + + // 应用属性 + const attribute: Record = this.target.attribute; + + // 应用透明度 pulse + if (this.useOpacity) { + // 确保即使是最小值也是基于原始透明度的百分比 + const opacity = 1 + (this.pulseOpacity - 1) * pulseValue; + if (this.useStroke) { + attribute.strokeOpacity = (this.originalAttributes.strokeOpacity || 1) * opacity; + } + if (this.useFill) { + attribute.fillOpacity = (this.originalAttributes.fillOpacity || 1) * opacity; + } + } + + // 应用缩放脉冲 + if (this.useScale) { + // 计算缩放比例: 从1到pulseScale之间变化 + const scale = 1 + (this.pulseScale - 1) * pulseValue; + attribute.scaleX = (this.originalAttributes.scaleX || 1) * scale; + attribute.scaleY = (this.originalAttributes.scaleY || 1) * scale; + } + + // 应用颜色脉冲 + if (this.useColor && this.pulseColor) { + this.applyColorPulse(attribute, pulseValue); + } + + // 确保更新渲染 + this.target.addUpdateShapeAndBoundsTag(); + this.target.addUpdatePositionTag(); + } + + // 应用颜色脉冲 + private applyColorPulse(attribute: Record, pulseValue: number): void { + // 根据pulseColorIntensity调整颜色变化强度 + const colorRatio = this.pulseColorIntensity * pulseValue; + + // 应用填充颜色脉冲 + if (this.useFill && this.originalFill && this.pulseColor) { + attribute.fill = interpolateColor(this.originalFill, this.pulseColor, colorRatio, true); + } + + // 应用描边颜色脉冲 + if (this.useStroke && this.originalStroke && this.pulseColor) { + attribute.stroke = interpolateColor(this.originalStroke, this.pulseColor, colorRatio, true); + } + } + + onEnd(): void { + super.onEnd(); + // 恢复原始属性 + this.target.setAttributes(this.originalAttributes); + } +} diff --git a/packages/vrender-animate/src/custom/streamLight.ts b/packages/vrender-animate/src/custom/streamLight.ts new file mode 100644 index 000000000..02865749b --- /dev/null +++ b/packages/vrender-animate/src/custom/streamLight.ts @@ -0,0 +1,313 @@ +import type { + EasingType, + IArea, + IAreaCacheItem, + ICubicBezierCurve, + ICurve, + ICustomPath2D, + IGraphic, + ILine, + ILineAttribute, + IRect, + IRectAttribute +} from '@visactor/vrender-core'; +import { application, AttributeUpdateType, CustomPath2D, divideCubic } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; +import type { IPoint } from '@visactor/vutils'; +import { PointService } from '@visactor/vutils'; + +export class StreamLight extends ACustomAnimate { + declare valid: boolean; + declare target: IGraphic; + + declare rect: IRect; + declare line: ILine; + declare area: IArea; + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params?: { attribute?: Partial; streamLength?: number; isHorizontal?: boolean } + ) { + super(from, to, duration, easing, params); + } + + getEndProps(): Record { + return {}; + } + + onStart(): void { + if (!this.target) { + return; + } + if (this.target.type === 'rect') { + this.onStartRect(); + } else if (this.target.type === 'line') { + this.onStartLineOrArea('line'); + } else if (this.target.type === 'area') { + this.onStartLineOrArea('area'); + } + } + + onStartLineOrArea(type: 'line' | 'area') { + const root = this.target.attachShadow(); + const line = application.graphicService.creator[type]({ + ...this.params?.attribute + }); + this[type] = line; + line.pathProxy = new CustomPath2D(); + root.add(line); + } + + onStartRect(): void { + const root = this.target.attachShadow(); + + const isHorizontal = this.params?.isHorizontal ?? true; + const sizeAttr = isHorizontal ? 'height' : 'width'; + const otherSizeAttr = isHorizontal ? 'width' : 'height'; + const size = this.target.AABBBounds[sizeAttr](); + const y = isHorizontal ? 0 : this.target.AABBBounds.y1; + + const rect = application.graphicService.creator.rect({ + [sizeAttr]: size, + fill: '#bcdeff', + shadowBlur: 30, + shadowColor: '#bcdeff', + ...this.params?.attribute, + x: 0, + y, + [otherSizeAttr]: 0 + }); + this.rect = rect; + root.add(rect); + } + + onBind(): void { + return; + } + + onEnd(): void { + this.target.detachShadow(); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (this.rect) { + return this.onUpdateRect(end, ratio, out); + } else if (this.line || this.area) { + return this.onUpdateLineOrArea(end, ratio, out); + } + } + + protected onUpdateRect(end: boolean, ratio: number, out: Record): void { + const isHorizontal = this.params?.isHorizontal ?? true; + const parentAttr = (this.target as any).attribute; + if (isHorizontal) { + const parentWidth = parentAttr.width ?? Math.abs(parentAttr.x1 - parentAttr.x) ?? 250; + const streamLength = this.params?.streamLength ?? parentWidth; + const maxLength = this.params?.attribute?.width ?? 60; + // 起点,rect x右端点 对齐 parent左端点 + // 如果parent.x1 < parent.x, 需要把rect属性移到parent x1的位置上, 因为初始 rect.x = parent.x + const startX = -maxLength; + // 插值 + const currentX = startX + (streamLength - startX) * ratio; + // 位置限定 > 0 + const x = Math.max(currentX, 0); + // 宽度计算 + const w = Math.min(Math.min(currentX + maxLength, maxLength), streamLength - currentX); + // 如果 rect右端点 超出 parent右端点, 宽度动态调整 + const width = w + x > parentWidth ? Math.max(parentWidth - x, 0) : w; + this.rect.setAttributes( + { + x, + width, + dx: Math.min(parentAttr.x1 - parentAttr.x, 0) + } as any, + false, + { + type: AttributeUpdateType.ANIMATE_PLAY, + animationState: { + ratio, + end + } + } + ); + } else { + const parentHeight = parentAttr.height ?? Math.abs(parentAttr.y1 - parentAttr.y) ?? 250; + const streamLength = this.params?.streamLength ?? parentHeight; + const maxLength = this.params?.attribute?.height ?? 60; + // 起点,y上端点 对齐 parent下端点 + const startY = parentHeight; + // 插值 + const currentY = startY - (streamLength + maxLength) * ratio; + // 位置限定 < parentHeight + let y = Math.min(currentY, parentHeight); + // 高度最小值 + const h = Math.min(parentHeight - currentY, maxLength); + // 如果 rect上端点=y 超出 parent上端点 = 0, 则高度不断变小 + let height; + if (y <= 0) { + // 必须先得到高度再将y置为0, 顺序很重要 + height = Math.max(y + h, 0); + y = 0; + } else { + height = h; + } + this.rect.setAttributes( + { + y, + height, + dy: Math.min(parentAttr.y1 - parentAttr.y, 0) + } as any, + false, + { + type: AttributeUpdateType.ANIMATE_PLAY, + animationState: { + ratio, + end + } + } + ); + } + } + + protected onUpdateLineOrArea(end: boolean, ratio: number, out: Record) { + const target = this.line || this.area; + if (!target) { + return; + } + const customPath = target.pathProxy as ICustomPath2D; + const targetLine = this.target as ILine | IArea; + if (targetLine.cache || targetLine.cacheArea) { + this._onUpdateLineOrAreaWithCache(customPath, targetLine, end, ratio, out); + } else { + this._onUpdateLineWithoutCache(customPath, targetLine, end, ratio, out); + } + const targetAttrs = targetLine.attribute; + target.setAttributes({ + stroke: targetAttrs.stroke, + ...target.attribute + }); + target.addUpdateBoundTag(); + } + + // 针对有cache的linear + protected _onUpdateLineOrAreaWithCache( + customPath: ICustomPath2D, + g: ILine | IArea, + end: boolean, + ratio: number, + out: Record + ) { + customPath.clear(); + if (g.type === 'line') { + let cache = g.cache; + if (!Array.isArray(cache)) { + cache = [cache]; + } + const totalLen = cache.reduce((l: any, c: any) => l + c.getLength(), 0); + const curves: ICurve[] = []; + cache.forEach((c: any) => { + c.curves.forEach((ci: any) => curves.push(ci)); + }); + return this._updateCurves(customPath, curves, totalLen, ratio); + } else if (g.type === 'area' && g.cacheArea?.top?.curves) { + const cache = g.cacheArea as IAreaCacheItem; + const totalLen = cache.top.curves.reduce((a, b) => a + b.getLength(), 0); + return this._updateCurves(customPath, cache.top.curves, totalLen, ratio); + } + } + + protected _updateCurves(customPath: ICustomPath2D, curves: ICurve[], totalLen: number, ratio: number) { + const startLen = totalLen * ratio; + const endLen = Math.min(startLen + (this.params?.streamLength ?? 10), totalLen); + let lastLen = 0; + let start = false; + for (let i = 0; i < curves.length; i++) { + if (curves[i].defined !== false) { + const curveItem = curves[i]; + const len = curveItem.getLength(); + const startPercent = 1 - (lastLen + len - startLen) / len; + let endPercent = 1 - (lastLen + len - endLen) / len; + let curveForStart: ICubicBezierCurve; + if (lastLen < startLen && lastLen + len > startLen) { + start = true; + if (curveItem.p2 && curveItem.p3) { + const [_, curve2] = divideCubic(curveItem as ICubicBezierCurve, startPercent); + customPath.moveTo(curve2.p0.x, curve2.p0.y); + curveForStart = curve2; + // console.log(curve2.p0.x, curve2.p0.y); + } else { + const p = curveItem.getPointAt(startPercent); + customPath.moveTo(p.x, p.y); + } + } + if (lastLen < endLen && lastLen + len > endLen) { + if (curveItem.p2 && curveItem.p3) { + if (curveForStart) { + endPercent = (endLen - startLen) / curveForStart.getLength(); + } + const [curve1] = divideCubic(curveForStart || (curveItem as ICubicBezierCurve), endPercent); + customPath.bezierCurveTo(curve1.p1.x, curve1.p1.y, curve1.p2.x, curve1.p2.y, curve1.p3.x, curve1.p3.y); + } else { + const p = curveItem.getPointAt(endPercent); + customPath.lineTo(p.x, p.y); + } + break; + } else if (start) { + if (curveItem.p2 && curveItem.p3) { + const curve = curveForStart || curveItem; + customPath.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); + } else { + customPath.lineTo(curveItem.p1.x, curveItem.p1.y); + } + } + lastLen += len; + } + } + } + + // 只针对最简单的linear + protected _onUpdateLineWithoutCache( + customPath: ICustomPath2D, + line: ILine, + end: boolean, + ratio: number, + out: Record + ) { + const { points, curveType } = line.attribute; + if (!points || points.length < 2 || curveType !== 'linear') { + return; + } + let totalLen = 0; + for (let i = 1; i < points.length; i++) { + totalLen += PointService.distancePP(points[i], points[i - 1]); + } + const startLen = totalLen * ratio; + const endLen = Math.min(startLen + (this.params?.streamLength ?? 10), totalLen); + const nextPoints = []; + let lastLen = 0; + for (let i = 1; i < points.length; i++) { + const len = PointService.distancePP(points[i], points[i - 1]); + if (lastLen < startLen && lastLen + len > startLen) { + nextPoints.push(PointService.pointAtPP(points[i - 1], points[i], 1 - (lastLen + len - startLen) / len)); + } + if (lastLen < endLen && lastLen + len > endLen) { + nextPoints.push(PointService.pointAtPP(points[i - 1], points[i], 1 - (lastLen + len - endLen) / len)); + break; + } else if (nextPoints.length) { + nextPoints.push(points[i]); + } + lastLen += len; + } + + if (!nextPoints.length || nextPoints.length < 2) { + return; + } + customPath.clear(); + customPath.moveTo(nextPoints[0].x, nextPoints[0].y); + for (let i = 1; i < nextPoints.length; i++) { + customPath.lineTo(nextPoints[i].x, nextPoints[i].y); + } + } +} diff --git a/packages/vrender-animate/src/custom/tag-points.ts b/packages/vrender-animate/src/custom/tag-points.ts new file mode 100644 index 000000000..d9f9075ad --- /dev/null +++ b/packages/vrender-animate/src/custom/tag-points.ts @@ -0,0 +1,197 @@ +import { clamp, isValidNumber, Point, type IPointLike } from '@visactor/vutils'; +import type { ISegment, ILineAttribute, EasingType } from '@visactor/vrender-core'; +import { pointInterpolation } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export class TagPointsUpdate extends ACustomAnimate<{ points?: IPointLike[]; segments?: ISegment[] }> { + protected fromPoints: IPointLike[]; + protected toPoints: IPointLike[]; + protected points: IPointLike[]; + protected interpolatePoints: [IPointLike, IPointLike][]; + protected newPointAnimateType: 'grow' | 'appear' | 'clip'; + protected clipRange: number; + protected shrinkClipRange: number; + protected clipRangeByDimension: 'x' | 'y'; + protected segmentsCache: number[]; + + constructor( + from: any, + to: any, + duration: number, + easing: EasingType, + params?: { newPointAnimateType?: 'grow' | 'appear' | 'clip'; clipRangeByDimension?: 'x' | 'y' } + ) { + super(from, to, duration, easing, params); + this.newPointAnimateType = params?.newPointAnimateType ?? 'grow'; + this.clipRangeByDimension = params?.clipRangeByDimension ?? 'x'; + } + + private getPoints(attribute: typeof this.from, cache = false): IPointLike[] { + if (attribute.points) { + return attribute.points; + } + + if (attribute.segments) { + const points = [] as IPointLike[]; + if (!this.segmentsCache) { + this.segmentsCache = []; + } + attribute.segments.map((segment: any) => { + if (segment.points) { + points.push(...segment.points); + } + if (cache) { + this.segmentsCache.push(segment.points?.length ?? 0); + } + }); + return points; + } + return []; + } + + onBind(): void { + super.onBind(); + const { points, segments } = this.target.attribute as any; + const { points: pointsTo, segments: segmentsTo } = this.target.getFinalAttribute() as any; + + this.from = { points, segments }; + this.to = { points: pointsTo, segments: segmentsTo }; + this.props = this.to; + + const originFromPoints = this.getPoints(this.from); + const originToPoints = this.getPoints(this.to, true); + this.fromPoints = !originFromPoints ? [] : !Array.isArray(originFromPoints) ? [originFromPoints] : originFromPoints; + this.toPoints = !originToPoints ? [] : !Array.isArray(originToPoints) ? [originToPoints] : originToPoints; + + const tagMap = new Map(); + this.fromPoints.forEach(point => { + if (point.context) { + tagMap.set(point.context, point); + } + }); + let firstMatchedIndex = Infinity; + let lastMatchedIndex = -Infinity; + let firstMatchedPoint: IPointLike; + let lastMatchedPoint: IPointLike; + for (let i = 0; i < this.toPoints.length; i += 1) { + if (tagMap.has(this.toPoints[i].context)) { + firstMatchedIndex = i; + firstMatchedPoint = tagMap.get(this.toPoints[i].context); + break; + } + } + for (let i = this.toPoints.length - 1; i >= 0; i -= 1) { + if (tagMap.has(this.toPoints[i].context)) { + lastMatchedIndex = i; + lastMatchedPoint = tagMap.get(this.toPoints[i].context); + break; + } + } + + if (this.newPointAnimateType === 'clip') { + if (this.toPoints.length !== 0) { + if (Number.isFinite(lastMatchedIndex)) { + this.clipRange = + this.toPoints[lastMatchedIndex][this.clipRangeByDimension] / + this.toPoints[this.toPoints.length - 1][this.clipRangeByDimension]; + if (this.clipRange === 1) { + this.shrinkClipRange = + this.toPoints[lastMatchedIndex][this.clipRangeByDimension] / + this.fromPoints[this.fromPoints.length - 1][this.clipRangeByDimension]; + } + if (!isValidNumber(this.clipRange)) { + this.clipRange = 0; + } else { + this.clipRange = clamp(this.clipRange, 0, 1); + } + } else { + this.clipRange = 0; + } + } + } + // TODO: shrink removed points + // if no point is matched, animation should start from toPoint[0] + let prevMatchedPoint = this.toPoints[0]; + this.interpolatePoints = this.toPoints.map((point, index) => { + const matchedPoint = tagMap.get(point.context); + if (matchedPoint) { + prevMatchedPoint = matchedPoint; + return [matchedPoint, point]; + } + // appear new point + if (this.newPointAnimateType === 'appear' || this.newPointAnimateType === 'clip') { + return [point, point]; + } + // grow new point + if (index < firstMatchedIndex && firstMatchedPoint) { + return [firstMatchedPoint, point]; + } else if (index > lastMatchedIndex && lastMatchedPoint) { + return [lastMatchedPoint, point]; + } + return [prevMatchedPoint, point]; + }); + this.points = this.interpolatePoints.map(interpolate => { + const fromPoint = interpolate[0]; + const toPoint = interpolate[1]; + const newPoint = new Point(fromPoint.x, fromPoint.y, fromPoint.x1, fromPoint.y1); + newPoint.defined = toPoint.defined; + newPoint.context = toPoint.context; + return newPoint; + }); + } + + onFirstRun(): void { + const lastClipRange = (this.target.attribute as any).clipRange; + if (isValidNumber(lastClipRange * this.clipRange)) { + this.clipRange *= lastClipRange; + } + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + if (end) { + Object.keys(this.to).forEach(k => { + (this.target.attribute as any)[k] = (this.to as any)[k]; + }); + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + return; + } + // if not create new points, multi points animation might not work well. + this.points = this.points.map((point, index) => { + const newPoint = pointInterpolation(this.interpolatePoints[index][0], this.interpolatePoints[index][1], ratio); + newPoint.context = point.context; + return newPoint; + }); + if (this.clipRange) { + if (this.shrinkClipRange) { + // 折线变短 + if (!end) { + out.points = this.fromPoints; + out.clipRange = this.clipRange - (this.clipRange - this.shrinkClipRange) * ratio; + } else { + out.points = this.toPoints; + out.clipRange = 1; + } + return; + } + this.target.setAttributes({ clipRange: this.clipRange + (1 - this.clipRange) * ratio } as any); + } + if (this.segmentsCache && this.to.segments) { + let start = 0; + const segments = this.to.segments.map((segment: any, index: any) => { + const end = start + this.segmentsCache[index]; + const points = this.points.slice(start, end); + start = end; + return { + ...segment, + points + }; + }); + (this.target.attribute as ILineAttribute).segments = segments; + } else { + (this.target.attribute as ILineAttribute).points = this.points; + } + this.target.addUpdatePositionTag(); + this.target.addUpdateShapeAndBoundsTag(); + } +} diff --git a/packages/vrender-animate/src/custom/update.ts b/packages/vrender-animate/src/custom/update.ts new file mode 100644 index 000000000..2e38de631 --- /dev/null +++ b/packages/vrender-animate/src/custom/update.ts @@ -0,0 +1,61 @@ +import type { EasingType } from '@visactor/vrender-core'; +import { ACustomAnimate } from './custom-animate'; + +export interface IUpdateAnimationOptions { + diffAttrs: Record; + animationState: string; + diffState: string; + data: Record[]; +} + +/** + * 文本输入动画,实现类似打字机的字符逐个显示效果 + * 支持通过beforeText和afterText参数添加前缀和后缀 + * 支持通过showCursor参数显示光标,cursorChar自定义光标字符 + */ +export class Update extends ACustomAnimate> { + declare valid: boolean; + // params: IUpdateAnimationOptions; + + constructor(from: null, to: null, duration: number, easing: EasingType, params?: IUpdateAnimationOptions) { + super(from, to, duration, easing, params); + // this.params = params; + } + + onBind() { + super.onBind(); + let { diffAttrs = {} } = this.target.context ?? ({} as any); + const { options } = this.params as any; + + diffAttrs = { ...diffAttrs }; + if (options?.excludeChannels?.length) { + options.excludeChannels.forEach((channel: string) => { + delete diffAttrs[channel]; + }); + } + + this.props = diffAttrs; + } + + update(end: boolean, ratio: number, out: Record): void { + this.onStart(); + if (!this.props || !this.propKeys) { + return; + } + // 应用缓动函数 + const easedRatio = this.easing(ratio); + this.animate.interpolateUpdateFunction + ? this.animate.interpolateUpdateFunction(this.fromProps, this.props, easedRatio, this, this.target) + : this.interpolateUpdateFunctions.forEach((func, index) => { + // 如果这个属性被屏蔽了,直接跳过 + if (!this.animate.validAttr(this.propKeys[index])) { + return; + } + const key = this.propKeys[index]; + const fromValue = this.fromProps[key]; + const toValue = this.props[key]; + func(key, fromValue, toValue, easedRatio, this, this.target); + }); + this.onUpdate(end, easedRatio, out); + } +} diff --git a/packages/vrender-animate/src/executor/animate-executor.ts b/packages/vrender-animate/src/executor/animate-executor.ts new file mode 100644 index 000000000..b288e0e0a --- /dev/null +++ b/packages/vrender-animate/src/executor/animate-executor.ts @@ -0,0 +1,757 @@ +import type { IGraphic, EasingType, IAnimate } from '@visactor/vrender-core'; +import type { + IAnimationConfig, + IAnimationTimeline, + IAnimationTypeConfig, + MarkFunctionCallback, + MarkFunctionValueType, + IAnimationTimeSlice, + IAnimationChannelAttrs, + IAnimationChannelAttributes, + IAnimationCustomConstructor, + IAnimationChannelInterpolator +} from './executor'; +import { cloneDeep, isArray, isFunction } from '@visactor/vutils'; + +interface IAnimateExecutor { + execute: (params: IAnimationConfig) => void; + executeItem: (params: IAnimationConfig, graphic: IGraphic, index?: number) => IAnimate[]; + onStart: (cb?: () => void) => void; + onEnd: (cb?: () => void) => void; +} + +export class AnimateExecutor implements IAnimateExecutor { + static builtInAnimateMap: Record = {}; + + static registerBuiltInAnimate(name: string, animate: IAnimationCustomConstructor) { + AnimateExecutor.builtInAnimateMap[name] = animate; + } + + declare _target: IGraphic; + + // 所有动画实例 + private _animates: IAnimate[] = []; + + // 动画开始回调 + private _startCallbacks: (() => void)[] = []; + // 动画结束回调 + private _endCallbacks: (() => void)[] = []; + + // 是否已经开始动画 + private _started: boolean = false; + + // 当前正在运行的动画数量 + private _activeCount: number = 0; + + constructor(target: IGraphic) { + this._target = target; + } + + /** + * 注册一个回调,当动画开始时调用 + */ + onStart(cb?: () => void): void { + if (cb) { + this._startCallbacks.push(cb); + + // 如果动画已经开始,立即调用回调 + if (this._started && this._activeCount > 0) { + cb(); + } + } else { + this._startCallbacks.forEach(cb => { + cb(); + }); + } + } + + /** + * 注册一个回调,当所有动画结束时调用 + */ + onEnd(cb?: () => void): void { + if (cb) { + this._endCallbacks.push(cb); + } else { + this._endCallbacks.forEach(cb => { + cb(); + }); + } + } + + /** + * 跟踪动画并附加生命周期钩子 + */ + private _trackAnimation(animate: IAnimate): void { + this._animates.push(animate); + this._activeCount++; + + // 如果这是第一个正在运行的动画,触发onStart回调 + if (this._activeCount === 1 && !this._started) { + this._started = true; + this.onStart(); + } + + // 处理动画完成 + animate.onEnd(() => { + this._activeCount--; + + // 从跟踪的动画中移除 + const index = this._animates.indexOf(animate); + if (index >= 0) { + this._animates.splice(index, 1); + } + + // 如果所有动画都已完成,触发onEnd回调 + if (this._activeCount === 0 && this._started) { + this._started = false; + this.onEnd(); + } + }); + } + + parseParams(params: IAnimationConfig, isTimeline: boolean, child?: IGraphic): IAnimationConfig { + const totalTime = this.resolveValue(params.totalTime, undefined, undefined); + const startTime = this.resolveValue(params.startTime, undefined, 0); + + // execute只在mark层面调用,所以性能影响可以忽略 + // TODO 存在性能问题,如果后续调用频繁,需要重新修改 + const parsedParams: Record = { ...params }; + parsedParams.oneByOneDelay = 0; + parsedParams.startTime = startTime; + parsedParams.totalTime = totalTime; + + const oneByOne = this.resolveValue(params.oneByOne, child, false); + + if (isTimeline) { + const timeSlices = (parsedParams as IAnimationTimeline).timeSlices; + if (!isArray(timeSlices)) { + (parsedParams as IAnimationTimeline).timeSlices = [timeSlices]; + } + let sliceTime = 0; + ((parsedParams as IAnimationTimeline).timeSlices as IAnimationTimeSlice[]) = ( + (parsedParams as IAnimationTimeline).timeSlices as IAnimationTimeSlice[] + ).map(slice => { + const delay = this.resolveValue(slice.delay, child, 0); + const delayAfter = this.resolveValue(slice.delayAfter, child, 0); + const duration = this.resolveValue(slice.duration, child, 300); + sliceTime += delay + duration + delayAfter; + return { + ...slice, + delay, + delayAfter, + duration + }; + }); + let oneByOneDelay = 0; + if (oneByOne) { + oneByOneDelay = typeof oneByOne === 'number' ? (oneByOne as number) : oneByOne ? sliceTime : 0; + } + parsedParams.oneByOneDelay = oneByOneDelay; + + let scale = 1; + if (totalTime) { + const _totalTime = sliceTime + oneByOneDelay * (this._target.count - 2); + scale = totalTime ? totalTime / _totalTime : 1; + } + ((parsedParams as IAnimationTimeline).timeSlices as IAnimationTimeSlice[]) = ( + (parsedParams as IAnimationTimeline).timeSlices as IAnimationTimeSlice[] + ).map(slice => { + let effects = slice.effects; + if (!Array.isArray(effects)) { + effects = [effects]; + } + return { + ...slice, + delay: (slice.delay as number) * scale, + delayAfter: (slice.delayAfter as number) * scale, + duration: (slice.duration as number) * scale, + effects: effects.map(effect => { + const custom = effect.custom ?? AnimateExecutor.builtInAnimateMap[(effect.type as any) ?? 'fromTo']; + const customType = + custom && isFunction(custom) ? (/^class\s/.test(Function.prototype.toString.call(custom)) ? 1 : 2) : 0; + return { + ...effect, + custom, + customType + }; + }) + }; + }); + parsedParams.oneByOneDelay = oneByOneDelay * scale; + (parsedParams as IAnimationTimeline).startTime = startTime * scale; + } else { + const delay = this.resolveValue((params as IAnimationTypeConfig).delay, child, 0); + const delayAfter = this.resolveValue((params as IAnimationTypeConfig).delayAfter, child, 0); + const duration = this.resolveValue((params as IAnimationTypeConfig).duration, child, 300); + const loopTime = delay + delayAfter + duration; + + let oneByOneDelay = 0; + if (oneByOne) { + oneByOneDelay = typeof oneByOne === 'number' ? (oneByOne as number) : oneByOne ? loopTime : 0; + } + parsedParams.oneByOneDelay = oneByOneDelay; + parsedParams.custom = + (params as IAnimationTypeConfig).custom ?? + AnimateExecutor.builtInAnimateMap[(params as IAnimationTypeConfig).type ?? 'fromTo']; + + const customType = + parsedParams.custom && isFunction(parsedParams.custom) + ? /^class\s/.test(Function.prototype.toString.call(parsedParams.custom)) + ? 1 + : 2 + : 0; + parsedParams.customType = customType; + + if (totalTime) { + const _totalTime = delay + delayAfter + duration + oneByOneDelay * (this._target.count - 2); + const scale = totalTime ? totalTime / _totalTime : 1; + parsedParams.delay = delay * scale; + parsedParams.delayAfter = delayAfter * scale; + parsedParams.duration = duration * scale; + parsedParams.oneByOneDelay = oneByOneDelay * scale; + (parsedParams as IAnimationTypeConfig).startTime = startTime; + } + } + + return parsedParams; + } + + execute(params: IAnimationConfig | IAnimationConfig[]) { + if (Array.isArray(params)) { + params.forEach(param => this._execute(param)); + } else { + this._execute(params); + } + } + + /** + * 执行动画,针对一组元素 + */ + _execute(params: IAnimationConfig) { + if (params.selfOnly) { + return this._executeItem(params, this._target, 0, 1); + } + + // 判断是否为timeline配置 + const isTimeline = 'timeSlices' in params; + + // 筛选符合条件的子图元 + let filteredChildren: IGraphic[]; + + // 如果设置了partitioner,则进行筛选 + if (isTimeline && params.partitioner) { + filteredChildren = (filteredChildren ?? (this._target.getChildren() as IGraphic[])).filter(child => { + return (params as IAnimationTimeline).partitioner((child.context as any)?.data?.[0], child, {}); + }); + } + + // 如果需要排序,则进行排序 + if (isTimeline && (params as IAnimationTimeline).sort) { + filteredChildren = filteredChildren ?? (this._target.getChildren() as IGraphic[]); + filteredChildren.sort((a, b) => { + return (params as IAnimationTimeline).sort( + (a.context as any)?.data?.[0], + (b.context as any)?.data?.[0], + a, + b, + {} + ); + }); + } + + // + + const cb = isTimeline + ? (child: IGraphic, index: number, count: number) => { + const parsedParams = this.parseParams(params, isTimeline, child); + // 执行单个图元的timeline动画 + const animate = this.executeTimelineItem(parsedParams as IAnimationTimeline, child, index, count); + if (animate) { + this._trackAnimation(animate); + } + } + : (child: IGraphic, index: number, count: number) => { + const parsedParams = this.parseParams(params, isTimeline, child); + // 执行单个图元的config动画 + const animate = this.executeTypeConfigItem(parsedParams as IAnimationTypeConfig, child, index, count); + if (animate) { + this._trackAnimation(animate); + } + }; + + // 执行每个图元的动画 + if (filteredChildren) { + filteredChildren.forEach((child, index) => cb(child, index, filteredChildren.length)); + } else if (this._target.count <= 1) { + cb(this._target, 0, 1); + } else { + this._target.forEachChildren((child, index) => cb(child as IGraphic, index, this._target.count - 1)); + } + + return; + } + + /** + * 执行 TypeConfig 类型的动画 + */ + private executeTypeConfigItem( + params: IAnimationTypeConfig, + graphic: IGraphic, + index: number, + count: number + ): IAnimate { + const { + type = 'fromTo', + channel, + customParameters, + easing = 'linear', + delay = 0, + delayAfter = 0, + duration = 300, + startTime = 0, + oneByOneDelay = 0, + loop, + bounce, + priority = 0, + options, + custom, + customType, // 0: undefined, 1: class, 2: function + controlOptions + } = params as any; + + // 创建动画实例 + const animate = graphic.animate() as unknown as IAnimate; + animate.priority = priority; + + const delayValue = isFunction(delay) ? delay(graphic.context?.data?.[0], graphic, {}) : delay; + + // 如果设置了indexKey,则使用indexKey作为index + const datum = graphic.context?.data?.[0]; + const indexKey = graphic.context?.indexKey; + if (datum && indexKey) { + index = datum[indexKey] ?? index; + } + + // 设置开始时间 + animate.startAt(startTime as number); + const wait = index * oneByOneDelay + delayValue; + wait > 0 && animate.wait(wait); + + // 放到startAt中,否则label无法确定主图元何时开始 + // // 添加延迟 + // if (delayValue > 0) { + // animate.wait(delayValue); + // } + + // 根据 channel 配置创建属性对象 + // 根据 channel 配置创建属性对象 + let parsedFromProps = null; + let props = params.to; + let from = params.from; + if (!props) { + if (!parsedFromProps) { + parsedFromProps = this.createPropsFromChannel(channel, graphic); + } + props = parsedFromProps.props; + } + if (!from) { + if (!parsedFromProps) { + parsedFromProps = this.createPropsFromChannel(channel, graphic); + } + from = parsedFromProps.from; + } + + this._handleRunAnimate( + animate, + custom, + customType, + from, + props, + duration as number, + easing, + customParameters, + controlOptions, + options, + type, + graphic + ); + + let totalDelay = 0; + if (oneByOneDelay) { + totalDelay = oneByOneDelay * (count - index - 1); + } + + // 添加后延迟 + const delayAfterValue = isFunction(delayAfter) ? delayAfter(graphic.context?.data?.[0], graphic, {}) : delayAfter; + if (delayAfterValue > 0) { + totalDelay += delayAfterValue as number; + } + + if (totalDelay > 0) { + animate.wait(totalDelay); + } + + // 设置循环 + if (loop && (loop as number) > 0) { + animate.loop(loop as number); + } + + // 设置反弹 + if (bounce) { + animate.bounce(true); + } + + return animate; + } + + private _handleRunAnimate( + animate: IAnimate, + custom: IAnimationCustomConstructor | IAnimationChannelInterpolator, + customType: number, // 0: undefined, 1: class, 2: function + from: Record | null, + props: Record, + duration: number, + easing: EasingType, + customParameters: any, + controlOptions: any, + options: any, + type: string, + graphic: IGraphic + ) { + // 处理自定义动画 + if (custom && customType) { + const customParams = this.resolveValue(customParameters, graphic, {}); + const objOptions = isFunction(options) + ? options.call( + null, + (customParams && customParams.data?.[0]) ?? graphic.context?.data?.[0], + graphic, + customParams + ) + : options; + customParams.options = objOptions; + customParams.controlOptions = controlOptions; + if (customType === 1) { + // 自定义动画构造器 - 创建自定义动画类 + this.createCustomAnimation( + animate, + custom as IAnimationCustomConstructor, + from, + props, + duration as number, + easing, + customParams + ); + } else if (customType === 2) { + // 自定义插值器 - 创建自定义插值动画 + this.createCustomInterpolatorAnimation( + animate, + custom as IAnimationChannelInterpolator, + props, + duration as number, + easing, + customParams + ); + } + } else if (type === 'to') { + animate.to(props, duration as number, easing); + } else if (type === 'from') { + animate.from(props, duration as number, easing); + } + } + + /** + * 执行 Timeline 类型的动画 + */ + private executeTimelineItem(params: IAnimationTimeline, graphic: IGraphic, index: number, count: number): IAnimate { + const { timeSlices, startTime = 0, loop, bounce, oneByOneDelay, priority, controlOptions } = params as any; + + // 如果设置了indexKey,则使用indexKey作为index + const datum = graphic.context?.data?.[0]; + const indexKey = graphic.context?.indexKey; + if (datum && indexKey) { + index = datum[indexKey] ?? index; + } + + // 创建动画实例 + const animate = graphic.animate() as unknown as IAnimate; + animate.priority = priority; + + // 设置开始时间 + animate.startAt(startTime as number); + animate.wait(index * oneByOneDelay); + + // 设置循环 + if (loop && (loop as number) > 0) { + animate.loop(loop as number); + } + + // 设置反弹 + if (bounce) { + animate.bounce(true); + } + + // 处理时间切片 + const slices = Array.isArray(timeSlices) ? timeSlices : [timeSlices]; + + slices.forEach(slice => { + this.applyTimeSliceToAnimate(slice, animate, graphic, controlOptions); + }); + + // 后等待 + if (oneByOneDelay) { + animate.wait(oneByOneDelay * (count - index - 1)); + } + + return animate; + } + + /** + * 将时间切片应用到动画实例 + */ + private applyTimeSliceToAnimate( + slice: IAnimationTimeSlice, + animate: IAnimate, + graphic: IGraphic, + controlOptions: any + ) { + const { effects, duration = 300, delay = 0, delayAfter = 0 } = slice; + + // 解析时间参数 + // const durationValue = duration as number; + const delayValue = isFunction(delay) ? delay(graphic.context?.data?.[0], graphic, {}) : delay; + const delayAfterValue = isFunction(delayAfter) ? delayAfter(graphic.context?.data?.[0], graphic, {}) : delayAfter; + + // 添加延迟 + if (delayValue > 0) { + animate.wait(delayValue); + } + + // 处理动画效果 + const effectsArray = Array.isArray(effects) ? effects : [effects]; + + effectsArray.forEach(effect => { + const { type = 'fromTo', channel, customParameters, easing = 'linear', options } = effect; + + // 根据 channel 配置创建属性对象 + let parsedFromProps = null; + let props = effect.to; + let from = effect.from; + if (!props) { + if (!parsedFromProps) { + parsedFromProps = this.createPropsFromChannel(channel, graphic); + } + props = parsedFromProps.props; + } + if (!from) { + if (!parsedFromProps) { + parsedFromProps = this.createPropsFromChannel(channel, graphic); + } + from = parsedFromProps.from; + } + const custom = effect.custom ?? AnimateExecutor.builtInAnimateMap[type]; + const customType = (effect as any).customType; + this._handleRunAnimate( + animate, + custom, + customType, + from, + props, + duration as number, + easing, + customParameters, + controlOptions, + options, + type, + graphic + ); + }); + + // 添加后延迟 + if (delayAfterValue > 0) { + animate.wait(delayAfterValue); + } + } + + /** + * 创建自定义插值器动画 + */ + private createCustomInterpolatorAnimation( + animate: IAnimate, + interpolator: IAnimationChannelInterpolator, + props: Record, + duration: number, + easing: EasingType, + customParams: any + ) { + // 获取动画目标的当前属性作为起始值 + const from: Record = {}; + const to = props; + + // 为每个属性填充起始值 + Object.keys(to).forEach(key => { + from[key] = animate.target.getComputedAttribute(key); + }); + + animate.interpolateUpdateFunction = (from, to, ratio, step, target) => { + interpolator(ratio, from, to, step, target, animate.target, customParams); + }; + + animate.to(props, duration, easing); + } + + /** + * 创建自定义动画类 + */ + private createCustomAnimation( + animate: IAnimate, + CustomAnimateConstructor: IAnimationCustomConstructor, + from: Record | null, + props: Record, + duration: number, + easing: EasingType, + customParams: any + ) { + // 获取动画目标的当前属性作为起始值 + // const from: Record = {}; + const to = props; + + // // 为每个属性填充起始值 + // Object.keys(to).forEach(key => { + // from[key] = animate.target.getComputedAttribute(key); + // }); + + // 实例化自定义动画类 + // 自定义动画自己去计算from + const customAnimate = new CustomAnimateConstructor(from, to, duration, easing, customParams); + + // 播放自定义动画 + animate.play(customAnimate); + } + + /** + * 从 channel 配置创建属性对象 + */ + private createPropsFromChannel( + channel: IAnimationChannelAttrs | IAnimationChannelAttributes | undefined, + graphic: IGraphic + ): { from: Record | null; props: Record } { + const props: Record = {}; + let from: Record | null = null; + + if (!channel) { + return { + from, + props + }; + } + + if (!Array.isArray(channel)) { + // 如果是对象,解析 from/to 配置 + Object.keys(channel).forEach(key => { + const config = channel[key]; + if (config.to !== undefined) { + if (typeof config.to === 'function') { + props[key] = config.to((graphic.context as any)?.data?.[0], graphic, {}); + } else { + props[key] = config.to; + } + } + if (config.from !== undefined) { + if (!from) { + from = {}; + } + if (typeof config.from === 'function') { + from[key] = config.from((graphic.context as any)?.data?.[0], graphic, {}); + } else { + from[key] = config.from; + } + } + }); + } else { + channel.forEach(key => { + const value = graphic.context?.diffAttrs?.[key]; + if (value !== undefined) { + props[key] = value; + } + }); + } + + return { + from, + props + }; + } + + /** + * 解析函数或值类型的配置项 + */ + private resolveValue(value: MarkFunctionValueType | undefined, graphic?: IGraphic, defaultValue?: T): T { + if (value === undefined) { + return defaultValue as T; + } + + if (typeof value === 'function' && graphic) { + return (value as MarkFunctionCallback)((graphic.context as any)?.data?.[0], graphic, {}); + } + + return value as T; + } + + executeItem(params: IAnimationConfig | IAnimationConfig[], graphic: IGraphic, index: number = 0, count: number = 1) { + if (Array.isArray(params)) { + return params.map(param => this._executeItem(param, graphic, index, count)).filter(Boolean); + } + return [this._executeItem(params, graphic, index, count)].filter(Boolean); + } + + /** + * 执行动画(具体执行到内部的单个图元) + */ + _executeItem(params: IAnimationConfig, graphic: IGraphic, index: number = 0, count: number = 1): IAnimate | null { + if (!graphic) { + return null; + } + + const isTimeline = 'timeSlices' in params; + let animate: IAnimate | null = null; + + const parsedParams = this.parseParams(params, isTimeline); + + if (isTimeline) { + // 处理 Timeline 类型的动画配置 + animate = this.executeTimelineItem(parsedParams as IAnimationTimeline, graphic, index, count); + } else { + // 处理 TypeConfig 类型的动画配置 + animate = this.executeTypeConfigItem(parsedParams as IAnimationTypeConfig, graphic, index, count); + } + + // 跟踪动画以进行生命周期管理 + if (animate) { + this._trackAnimation(animate); + } + + return animate; + } + + /** + * 停止所有由该执行器管理的动画 + */ + stop(type?: 'start' | 'end' | Record): void { + // animate.stop会从数组里删除,所以需要while循环,不能forEach + while (this._animates.length > 0) { + const animate = this._animates.pop(); + animate?.stop(type); + } + + // 清空动画实例数组 + this._animates = []; + this._activeCount = 0; + + // 如果动画正在运行,触发结束回调 + if (this._started) { + this._started = false; + this.onEnd(); + } + } +} diff --git a/packages/vrender-animate/src/executor/executor.ts b/packages/vrender-animate/src/executor/executor.ts new file mode 100644 index 000000000..e9a968325 --- /dev/null +++ b/packages/vrender-animate/src/executor/executor.ts @@ -0,0 +1,167 @@ +import type { IGraphic, EasingType } from '@visactor/vrender-core'; +import type { ACustomAnimate } from '../custom/custom-animate'; + +export type MarkFunctionCallback = (datum: any, graphic: IGraphic, parameters: any) => T; +export type MarkFunctionValueType = MarkFunctionCallback | T; + +interface IAnimationParameters { + [key: string]: any; +} + +/** + * 动画 channel 配置 + */ +export type IAnimationChannelFunction = (datum: any, element: IGraphic, parameters: IAnimationParameters) => any; + +/** + * 动画 channel 属性配置 + */ +export type IAnimationChannelAttrs = Record< + string, + { + from?: any | IAnimationChannelFunction; + to?: any | IAnimationChannelFunction; + } +>; +export type IAnimationChannelAttributes = string[]; + +/** + * 动画 channel 插值器 + */ +export type IAnimationChannelInterpolator = ( + ratio: number, + from: any, + to: any, + nextAttributes: any, + datum: any, + element: IGraphic, + parameters: IAnimationParameters +) => boolean | void; + +/** + * 动画 custom 构造器 + */ +export interface IAnimationCustomConstructor { + new (from: any, to: any, duration: number, ease: EasingType, parameters?: any): ACustomAnimate; +} + +export interface IAnimationEffect { + /** 动画类型 */ + type?: string; + /** 动画 channel 配置 */ + channel?: IAnimationChannelAttrs | IAnimationChannelAttributes; + /** 动画 to 配置(和channel互斥,如果同时设置,以to为准) */ + to?: Record; + /** 动画 from 配置 */ + from?: Record; + /** 动画 自定义插值 配置 */ + custom?: IAnimationChannelInterpolator | IAnimationCustomConstructor; + /** 动画 custom 参数配置 */ + customParameters?: MarkFunctionValueType; + /** 动画 easing 配置 */ + easing?: EasingType; + /** options暂时没有处理 */ + options?: + | MarkFunctionValueType + | { + // 忽略的属性 + excludeChannels?: string[]; + }; +} + +export interface IAnimationTimeSlice { + /** 动画效果 */ + effects: IAnimationEffect | IAnimationEffect[]; + /** 动画时长 */ + duration?: MarkFunctionValueType; + /** 延迟delay后执行动画 */ + delay?: MarkFunctionValueType; + /** effect动画后再延迟delayAfter结束这个周期 */ + delayAfter?: MarkFunctionValueType; +} + +export interface IAnimationControlOptions { + /** 当动画状态变更时清空动画 */ + stopWhenStateChange?: boolean; + /** 是否立即应用动画初始状态 */ + immediatelyApply?: boolean; + /** encode 计算图元最终状态时是否忽略循环动画 */ + ignoreLoopFinalAttributes?: boolean; +} + +/** + * 动画 config 简化配置 + */ +export interface IAnimationTypeConfig { + /** 动画类型 */ + type?: string; + /** 动画 channel 配置 */ + channel?: IAnimationChannelAttrs | IAnimationChannelAttributes; + /** 动画 to 配置(和channel互斥,如果同时设置,以to为准) */ + to?: Record; + /** 动画 from 配置 */ + from?: Record; + /** 动画 自定义插值 配置 */ + custom?: IAnimationChannelInterpolator | IAnimationCustomConstructor; + /** 动画 custom 参数配置 */ + customParameters?: MarkFunctionValueType; + /** 动画 easing 配置 */ + easing?: EasingType; + /** 动画 delay 配置 */ + delay?: MarkFunctionValueType; + /** 动画 delayAfter 配置 */ + delayAfter?: MarkFunctionValueType; + /** 动画 duration 配置 */ + duration?: MarkFunctionValueType; + /** 动画 oneByOne 配置(是否依次执行) */ + oneByOne?: MarkFunctionValueType; + /** 动画 startTime 配置 */ + startTime?: MarkFunctionValueType; + /** 动画 totalTime 配置(如果有循环,只算一个周期) */ + totalTime?: MarkFunctionValueType; + /** loop: true 无限循环; loop: 正整数,表示循环的次数 */ + loop?: boolean | number; + /** 动画 effect 配置项 */ + options?: MarkFunctionValueType; + /** 动画执行相关控制配置项 */ + controlOptions?: IAnimationControlOptions; + /** 动画优先级 */ + priority?: number; + /** 该动画是否需要忽略子图元 */ + selfOnly?: boolean; +} + +/** + * 动画 timeline 完整配置,一条时间线内的动画单元只能串行 + * 多个timeline是可以并行的 + * 考虑到同一图元不能在多个timeline上,所以timeline不应该提供数组配置的能力 + */ +export interface IAnimationTimeline { + /** 为了方便动画编排,用户可以设置 id 用于识别时间线 */ + id?: string; + /** 时间切片 */ + timeSlices: IAnimationTimeSlice | IAnimationTimeSlice[]; + /** 动画开始的相对时间,可以为负数 */ + startTime?: MarkFunctionValueType; + /** 动画时长 */ + totalTime?: MarkFunctionValueType; + /** 动画依次执行的延迟 */ + oneByOne?: MarkFunctionValueType; + /** loop: true 无限循环; loop: 正整数,表示循环的次数 */ + loop?: MarkFunctionValueType; + /** 对图元元素进行划分,和过滤类似,但是不同时间线不能同时作用在相同的元素上 */ + partitioner?: MarkFunctionCallback; + /** 对同一时间线上的元素进行排序 */ + sort?: (datumA: any, datumB: any, elementA: IGraphic, elementB: IGraphic, parameters: any) => number; + /** 动画执行相关控制配置项 */ + controlOptions?: IAnimationControlOptions; + /** 动画优先级 */ + priority?: number; + /** 该动画是否需要忽略子图元 */ + selfOnly?: boolean; +} + +/** + * 动画配置 + */ +export type IAnimationConfig = IAnimationTimeline | IAnimationTypeConfig; diff --git a/packages/vrender-animate/src/index.ts b/packages/vrender-animate/src/index.ts new file mode 100644 index 000000000..3df6fb6db --- /dev/null +++ b/packages/vrender-animate/src/index.ts @@ -0,0 +1,31 @@ +// 导出实现 +export { Animate } from './animate'; +export { DefaultTimeline } from './timeline'; +export { ManualTicker } from './ticker/manual-ticker'; +export { DefaultTicker } from './ticker/default-ticker'; +export { Step as AnimateStep } from './step'; + +// 导出工具函数 +export * from './utils/easing-func'; +export { registerAnimate } from './register'; +export { ACustomAnimate, AComponentAnimate } from './custom/custom-animate'; +export { ComponentAnimator } from './component/component-animator'; +export { IncreaseCount } from './custom/number'; +export { MorphingPath, MultiToOneMorphingPath, oneToMultiMorph, multiToOneMorph, morphPath } from './custom/morphing'; +export { InputText } from './custom/input-text'; +export { ClipGraphicAnimate, ClipAngleAnimate, ClipRadiusAnimate, ClipDirectionAnimate } from './custom/clip-graphic'; +export { TagPointsUpdate } from './custom/tag-points'; +export { GroupFadeIn, GroupFadeOut } from './custom/groupFade'; +export { RotateBySphereAnimate } from './custom/sphere'; +export { AnimateExecutor } from './executor/animate-executor'; +export type { IAnimationConfig } from './executor/executor'; +export * from './custom/register'; +// Export animation state modules +export * from './state'; +export { AnimationTransitionRegistry } from './state/animation-states-registry'; +export { transitionRegistry } from './state/animation-states-registry'; +export { AnimationStateManager } from './state/animation-state'; +export { AnimationStateStore } from './state/animation-state'; + +// Export component animation modules +export * from './component'; diff --git a/packages/vrender-animate/src/interpolate/number.ts b/packages/vrender-animate/src/interpolate/number.ts new file mode 100644 index 000000000..6abc9faaf --- /dev/null +++ b/packages/vrender-animate/src/interpolate/number.ts @@ -0,0 +1,3 @@ +export function interpolateNumber(from: number, to: number, ratio: number): number { + return from + (to - from) * ratio; +} diff --git a/packages/vrender-animate/src/interpolate/store.ts b/packages/vrender-animate/src/interpolate/store.ts new file mode 100644 index 000000000..a17451169 --- /dev/null +++ b/packages/vrender-animate/src/interpolate/store.ts @@ -0,0 +1,223 @@ +import type { IGraphic, IStep } from '@visactor/vrender-core'; +import { interpolateColor, interpolatePureColorArrayToStr, pointsInterpolation } from '@visactor/vrender-core'; +import { interpolateNumber } from './number'; +import type { IPointLike } from '@visactor/vutils'; + +// 直接设置,触发 隐藏类(Hidden Class)优化: +/** + * +const a = { type: 1 }; +const ITERATIONS = 1e7; // 测试次数 + +// 动态生成 keys 数组(确保引擎无法静态推断 key) +const keys = []; +for (let i = 0; i < ITERATIONS; i++) { + // 通过条件确保 key 动态变化(但实际始终为 'type',避免属性缺失的开销) + keys.push(Math.random() < 0 ? 'other' : 'type'); +} + +// 测试字面量访问 +function testLiteral() { + let sum = 0; + for (let i = 0; i < ITERATIONS; i++) { + const key = keys[i]; // 读取 key(与动态测试完全一致) + sum += a.type; // 差异仅在此处:使用字面量访问 + } + return sum; +} + +// 测试变量动态访问 +function testDynamic() { + let sum = 0; + for (let i = 0; i < ITERATIONS; i++) { + const key = keys[i]; // 读取 key(与字面量测试完全一致) + sum += a[key]; // 差异仅在此处:使用变量访问 + } + return sum; +} + +// 预热(避免 JIT 编译影响) +testLiteral(); +testDynamic(); + +// 正式测试 +console.time('literal'); +testLiteral(); +console.timeEnd('literal'); + +console.time('dynamic'); +testDynamic(); +console.timeEnd('dynamic'); + + +// out: +// literal: 7.1259765625 ms +// dynamic: 9.322998046875 ms + */ + +export class InterpolateUpdateStore { + opacity = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.opacity = interpolateNumber(from, to, ratio); + }; + baseOpacity = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).baseOpacity = interpolateNumber(from, to, ratio); + }; + fillOpacity = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.fillOpacity = interpolateNumber(from, to, ratio); + }; + strokeOpacity = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.strokeOpacity = interpolateNumber(from, to, ratio); + }; + zIndex = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.zIndex = interpolateNumber(from, to, ratio); + }; + backgroundOpacity = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.backgroundOpacity = interpolateNumber(from, to, ratio); + }; + shadowOffsetX = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.shadowOffsetX = interpolateNumber(from, to, ratio); + }; + shadowOffsetY = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.shadowOffsetY = interpolateNumber(from, to, ratio); + }; + shadowBlur = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.shadowBlur = interpolateNumber(from, to, ratio); + }; + fill = ( + key: string, + from: [number, number, number, number], + to: [number, number, number, number], + ratio: number, + step: IStep, + target: IGraphic + ) => { + target.attribute.fill = interpolateColor(from, to, ratio, false) as any; + }; + fillPure = ( + key: string, + from: [number, number, number, number], + to: [number, number, number, number], + ratio: number, + step: IStep, + target: IGraphic + ) => { + target.attribute.fill = step.fromParsedProps.fill + ? (interpolatePureColorArrayToStr(step.fromParsedProps.fill, step.toParsedProps.fill, ratio) as any) + : step.toParsedProps.fill; + }; + stroke = ( + key: string, + from: [number, number, number, number], + to: [number, number, number, number], + ratio: number, + step: IStep, + target: IGraphic + ) => { + target.attribute.stroke = interpolateColor(from, to, ratio, false); + }; + strokePure = ( + key: string, + from: [number, number, number, number], + to: [number, number, number, number], + ratio: number, + step: IStep, + target: IGraphic + ) => { + target.attribute.stroke = step.fromParsedProps.stroke + ? (interpolatePureColorArrayToStr(step.fromParsedProps.stroke, step.toParsedProps.stroke, ratio) as any) + : step.toParsedProps.stroke; + }; + + // 需要更新Bounds + width = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).width = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + height = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).height = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + x = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.x = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + target.addUpdatePositionTag(); + }; + y = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.y = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + target.addUpdatePositionTag(); + }; + angle = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.angle = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + target.addUpdatePositionTag(); + }; + scaleX = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.scaleX = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + target.addUpdatePositionTag(); + }; + scaleY = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.scaleY = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + target.addUpdatePositionTag(); + }; + lineWidth = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + target.attribute.lineWidth = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + startAngle = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).startAngle = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + endAngle = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).endAngle = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + radius = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).radius = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + outerRadius = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).outerRadius = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + innerRadius = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).innerRadius = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + size = (key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).size = interpolateNumber(from, to, ratio); + target.addUpdateBoundTag(); + }; + points = (key: string, from: IPointLike[], to: IPointLike[], ratio: number, step: IStep, target: IGraphic) => { + (target.attribute as any).points = pointsInterpolation(from, to, ratio); + target.addUpdateBoundTag(); + }; +} + +export const interpolateUpdateStore = new InterpolateUpdateStore(); + +export function commonInterpolateUpdate(key: string, from: any, to: any, ratio: number, step: IStep, target: IGraphic) { + if (Number.isFinite(to) && Number.isFinite(from)) { + (target.attribute as any)[key] = from + (to - from) * ratio; + return true; + } else if (Array.isArray(to) && Array.isArray(from) && to.length === from.length) { + const nextList = []; + let valid = true; + for (let i = 0; i < to.length; i++) { + const v = from[i]; + const val = v + (to[i] - v) * ratio; + if (!Number.isFinite(val)) { + valid = false; + break; + } + nextList.push(val); + } + if (valid) { + (target.attribute as any)[key] = nextList; + } + return true; + } + return false; +} diff --git a/packages/vrender-animate/src/register.ts b/packages/vrender-animate/src/register.ts new file mode 100644 index 000000000..fc7266b38 --- /dev/null +++ b/packages/vrender-animate/src/register.ts @@ -0,0 +1,10 @@ +import { Graphic } from '@visactor/vrender-core'; +import { mixin } from '@visactor/vutils'; +import { GraphicStateExtension } from './state/graphic-extension'; +import { AnimateExtension } from './animate-extension'; + +export function registerAnimate() { + // Mix in animation state methods to Graphic prototype + mixin(Graphic, GraphicStateExtension); + mixin(Graphic, AnimateExtension); +} diff --git a/packages/vrender-animate/src/state/animation-state.ts b/packages/vrender-animate/src/state/animation-state.ts new file mode 100644 index 000000000..2dcd06617 --- /dev/null +++ b/packages/vrender-animate/src/state/animation-state.ts @@ -0,0 +1,237 @@ +import type { IGraphic } from '@visactor/vrender-core'; +import type { IAnimationState } from './types'; +import { AnimationTransitionRegistry } from './animation-states-registry'; +import type { IAnimationConfig } from '../executor/executor'; +import { AnimateExecutor } from '../executor/animate-executor'; +import { isArray } from '@visactor/vutils'; + +// Standard animation state names +export const AnimationStates = { + APPEAR: 'appear', + DISAPPEAR: 'disappear', + UPDATE: 'update', + HIGHLIGHT: 'highlight', + UNHIGHLIGHT: 'unhighlight', + SELECT: 'select', + UNSELECT: 'unselect', + HOVER: 'hover', + UNHOVER: 'unhover', + ACTIVE: 'active', + INACTIVE: 'inactive' +}; + +export class AnimationStateStore { + graphic: IGraphic; + + constructor(graphic: IGraphic) { + this.graphic = graphic; + } + + // 动画状态配置 + // 并不是所有图元都有(只有mark才有),所以在应用状态的时候,需要额外传入 + states?: Map; + + registerState(state: IAnimationState): void { + if (!this.states) { + this.states = new Map(); + } + this.states.set(state.name, state); + } + + clearStates(): void { + this.states?.clear(); + } +} + +// 一个状态对应一个执行器,每个图元都有一一对应 +interface IStateInfo { + state: string; + animationConfig: IAnimationConfig | IAnimationConfig[]; + executor: AnimateExecutor; +} + +export class AnimationStateManager { + protected graphic: IGraphic; + + // 当前状态 + // TODO(注意,这里无法了解动画的顺序,既有串行也有并行,具体在执行的时候确定,执行之后就无法获取串行或并行配置了) + stateList: IStateInfo[] | null = null; + + constructor(graphic: IGraphic) { + this.graphic = graphic; + } + + // TODO 这里因为只有状态变更才会调用,所以代码写的比较宽松,如果有性能问题需要优化 + /** + * 应用状态 + * @param nextState 下一个状态数组,如果传入数组,那么状态是串行的。但是每次applyState都会立即执行动画,也就是applyState和applyState之间是并行 + * @param animationConfig 动画配置 + * @param callback 动画结束后的回调函数,参数empty为true表示没有动画需要执行直接调的回调 + */ + applyState( + nextState: string[], + animationConfig: (IAnimationState | IAnimationState[])[], + callback?: (empty?: boolean) => void + ): void { + const registry = AnimationTransitionRegistry.getInstance(); + + // TODO 这里指判断第一个状态,后续如果需要的话要循环判断 + // 检查是否需要停止当前状态,以及下一个状态是否需要应用 + const shouldStopState: IStateInfo[] = []; + const shouldApplyState: IStateInfo[] = []; + if (!(this.stateList && this.stateList.length)) { + nextState.forEach((state, index) => { + shouldApplyState.push({ + state, + animationConfig: isArray(animationConfig[index]) + ? (animationConfig[index] as IAnimationState[]).map(item => item.animation) + : (animationConfig[index] as IAnimationState).animation, + executor: new AnimateExecutor(this.graphic) + }); + }); + } else { + // const _stateList = this.stateList[0]; + nextState.forEach((state, index) => { + // 遍历this.stateList,获取result,只要有一个是false,那这个result就是false + const result: { allowTransition: boolean; stopOriginalTransition: boolean } = { + allowTransition: true, + stopOriginalTransition: true + }; + this.stateList.forEach(currState => { + const _result = registry.isTransitionAllowed(currState.state, state, this.graphic); + result.allowTransition = result.allowTransition && _result.allowTransition; + }); + // 所有状态都允许过渡,则添加到shouldApplyState + if (result.allowTransition) { + shouldApplyState.push({ + state, + animationConfig: isArray(animationConfig[index]) + ? (animationConfig[index] as IAnimationState[]).map(item => item.animation) + : (animationConfig[index] as IAnimationState).animation, + executor: new AnimateExecutor(this.graphic) + }); + // 允许过渡的话,需要重新遍历this.stateList,获取stopOriginalTransition + this.stateList.forEach(currState => { + const _result = registry.isTransitionAllowed(currState.state, state, this.graphic); + if (_result.stopOriginalTransition) { + shouldStopState.push(currState); + } + }); + } + }); + } + + // 停止动画 + shouldStopState.forEach(state => { + state.executor.stop(); + }); + + // 立即应用动画,串行的应用 + if (shouldApplyState.length) { + shouldApplyState[0].executor.execute(shouldApplyState[0].animationConfig); + // 如果下一个状态存在,那么下一个状态的动画在当前状态动画结束后立即执行 + for (let i = 0; i < shouldApplyState.length; i++) { + const nextState = shouldApplyState[i + 1]; + const currentState = shouldApplyState[i]; + currentState.executor.onEnd(() => { + if (nextState) { + nextState.executor.execute(nextState.animationConfig); + } + // 删除这个状态 + this.stateList = this.stateList.filter(state => state !== currentState); + + // 如果是最后一个状态且有回调,则调用回调 + if (i === shouldApplyState.length - 1 && callback) { + callback(false); + } + }); + } + } else if (callback) { + // 如果没有需要应用的动画状态,直接调用回调 + callback(true); + } + + if (this.stateList) { + this.stateList = this.stateList.filter(state => !shouldStopState.includes(state)); + } else { + this.stateList = []; + } + this.stateList.push(...shouldApplyState); + } + + /** + * Apply a standard appear animation to the graphic + * @param animationConfig Animation configuration + * @param callback Callback to be called when animation ends + */ + applyAppearState(animationConfig: IAnimationConfig, callback?: () => void): void { + this.applyState([AnimationStates.APPEAR], [{ name: AnimationStates.APPEAR, animation: animationConfig }], callback); + } + + /** + * Apply a standard disappear animation to the graphic + * @param animationConfig Animation configuration + * @param callback Callback to be called when animation ends + */ + applyDisappearState(animationConfig: IAnimationConfig, callback?: () => void): void { + this.applyState( + [AnimationStates.DISAPPEAR], + [{ name: AnimationStates.DISAPPEAR, animation: animationConfig }], + callback + ); + } + + /** + * Apply a standard update animation to the graphic + * @param animationConfig Animation configuration + * @param callback Callback to be called when animation ends + */ + applyUpdateState(animationConfig: IAnimationConfig, callback?: () => void): void { + this.applyState([AnimationStates.UPDATE], [{ name: AnimationStates.UPDATE, animation: animationConfig }], callback); + } + + /** + * Apply a standard highlight animation to the graphic + * @param animationConfig Animation configuration + * @param callback Callback to be called when animation ends + */ + applyHighlightState(animationConfig: IAnimationConfig, callback?: () => void): void { + this.applyState( + [AnimationStates.HIGHLIGHT], + [{ name: AnimationStates.HIGHLIGHT, animation: animationConfig }], + callback + ); + } + + /** + * Apply a standard unhighlight animation to the graphic + * @param animationConfig Animation configuration + * @param callback Callback to be called when animation ends + */ + applyUnhighlightState(animationConfig: IAnimationConfig, callback?: () => void): void { + this.applyState( + [AnimationStates.UNHIGHLIGHT], + [{ name: AnimationStates.UNHIGHLIGHT, animation: animationConfig }], + callback + ); + } + + stopState(state: string, type?: 'start' | 'end' | Record): void { + const stateInfo = this.stateList?.find(stateInfo => stateInfo.state === state); + if (stateInfo) { + stateInfo.executor.stop(type); + } + } + + clearState(): void { + // 清空状态 + this.stateList?.forEach(state => { + state.executor.stop(); + }); + this.stateList = null; + } + + // getstateList(): string[] | null { + // return this.stateList; + // } +} diff --git a/packages/vrender-animate/src/state/animation-states-registry.ts b/packages/vrender-animate/src/state/animation-states-registry.ts new file mode 100644 index 000000000..d19600202 --- /dev/null +++ b/packages/vrender-animate/src/state/animation-states-registry.ts @@ -0,0 +1,215 @@ +import type { IGraphic } from '@visactor/vrender-core'; + +interface ITransitionResult { + allowTransition: boolean; + stopOriginalTransition: boolean; +} + +/** + * 注册动画状态切换的转换函数 + */ +export type TransitionFunction = (graphic: IGraphic, fromState: string) => ITransitionResult; + +/** + * 动画状态切换的注册表 + * 管理所有图形的动画状态切换逻辑 + */ +export class AnimationTransitionRegistry { + private static instance: AnimationTransitionRegistry; + + // 源状态到目标状态的映射,每个目标状态都有一个转换函数 + private transitions: Map> = new Map(); + + constructor() { + this.registerDefaultTransitions(); + } + + /** + * 获取注册表的单例实例 + */ + static getInstance(): AnimationTransitionRegistry { + if (!AnimationTransitionRegistry.instance) { + AnimationTransitionRegistry.instance = new AnimationTransitionRegistry(); + } + return AnimationTransitionRegistry.instance; + } + + /** + * 注册默认的转换规则 + */ + private registerDefaultTransitions(): void { + // appear动画,可以被任何动画覆盖,但不会停止(disappear、exit除外) + this.registerTransition('appear', '*', () => ({ + allowTransition: true, + stopOriginalTransition: false + })); + // appear 动画碰到appear动画,什么都不会发生 + this.registerTransition('appear', 'appear', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + this.registerTransition('appear', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('appear', 'exit', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + // 循环动画(normal),可以被任何动画覆盖,但不会停止(disappear、exit除外) + this.registerTransition('normal', '*', () => ({ + allowTransition: true, + stopOriginalTransition: false + })); + // 循环动画碰到循环动画,什么都不会发生 + this.registerTransition('normal', 'normal', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + this.registerTransition('normal', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('normal', 'exit', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + // 退出动画不能被覆盖或停止(disappear除外) + this.registerTransition('exit', '*', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + this.registerTransition('exit', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + // 退出动画碰到enter动画,会立即停止 + this.registerTransition('exit', 'enter', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + // 退出动画碰到退出,什么都不会发生 + this.registerTransition('exit', 'exit', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + + // enter 动画可以被任何动画覆盖,但不会停止(exit、disappear除外) + this.registerTransition('enter', '*', () => ({ + allowTransition: true, + stopOriginalTransition: false + })); + // enter 动画碰到enter动画,什么都不会发生 + this.registerTransition('enter', 'enter', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + this.registerTransition('enter', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('enter', 'exit', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + // disappear 动画碰到任何动画,什么都不会发生(appear除外) + this.registerTransition('disappear', '*', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + + // disappear 动画碰到appear动画,会立即停止 + this.registerTransition('disappear', 'appear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + this.registerTransition('update', '*', () => ({ + allowTransition: true, + stopOriginalTransition: false + })); + // update动画碰到disappear动画,会停止,也会被覆盖 + this.registerTransition('update', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + // update动画碰到exit动画,会停止,也会被覆盖 + this.registerTransition('update', 'exit', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + // state动画,可以被任何动画覆盖,但不会停止(disappear、exit除外) + this.registerTransition('state', '*', () => ({ + allowTransition: true, + stopOriginalTransition: false + })); + // state动画碰到disappear动画,会停止,也会被覆盖 + this.registerTransition('state', 'disappear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + // state动画碰到exit动画,会停止,也会被覆盖 + this.registerTransition('state', 'exit', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + } + + /** + * 检查两个状态之间是否允许转换 + */ + isTransitionAllowed(fromState: string, toState: string, graphic: IGraphic): ITransitionResult { + // 直接转换规则 + let func = this.transitions.get(fromState)?.get(toState); + if (func) { + return func(graphic, fromState); + } + + // 状态到通配符 + func = this.transitions.get(fromState)?.get('*'); + if (func) { + return func(graphic, fromState); + } + + // 通配符到状态 + func = this.transitions.get('*')?.get(toState); + if (func) { + return func(graphic, fromState); + } + + // 通配符到通配符 + func = this.transitions.get('*')?.get('*'); + if (func) { + return func(graphic, fromState); + } + + // 默认允许转换 + return { + allowTransition: true, + stopOriginalTransition: true + }; + } + + /** + * 注册两个状态之间的转换 + */ + registerTransition(fromState: string, toState: string, transition: TransitionFunction): void { + let fromStateMap = this.transitions.get(fromState); + + if (!fromStateMap) { + fromStateMap = new Map(); + this.transitions.set(fromState, fromStateMap); + } + + fromStateMap.set(toState, transition); + } +} + +// 初始化单例转换注册表 +const transitionRegistry = AnimationTransitionRegistry.getInstance(); + +export { transitionRegistry }; diff --git a/packages/vrender-animate/src/state/graphic-extension.ts b/packages/vrender-animate/src/state/graphic-extension.ts new file mode 100644 index 000000000..8f272defa --- /dev/null +++ b/packages/vrender-animate/src/state/graphic-extension.ts @@ -0,0 +1,126 @@ +import type { IGraphic } from '@visactor/vrender-core'; +import type { IAnimationState } from './types'; +import { AnimationStateManager, AnimationStateStore } from './animation-state'; +import type { IAnimationConfig } from '../executor/executor'; + +/** + * 将动画状态方法作为混入扩展 Graphic 的类 + */ +export class GraphicStateExtension { + _getAnimationStateManager(graphic: IGraphic): AnimationStateManager { + if (!(graphic as any)._animationStateManager) { + // Create the appropriate manager type based on whether this is a group + (graphic as any)._animationStateManager = new AnimationStateManager(graphic); + } + return (graphic as any)._animationStateManager; + } + _getAnimationStateStore(graphic: IGraphic): AnimationStateStore { + if (!(graphic as any)._animationStateStore) { + // Create the appropriate manager type based on whether this is a group + (graphic as any)._animationStateStore = new AnimationStateStore(graphic); + } + return (graphic as any)._animationStateStore; + } + + /** + * 注册一个动画状态 + */ + registerAnimationState(state: IAnimationState): this { + this._getAnimationStateStore(this as unknown as IGraphic).registerState(state); + return this; + } + + /** + * 应用一个动画状态到图形 + */ + applyAnimationState( + state: string[], + animationConfig: (IAnimationState | IAnimationState[])[], + callback?: (empty?: boolean) => void + ): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyState(state, animationConfig, callback); + return this; + } + + /** + * 应用出现动画状态 + * @param animationConfig 动画配置 + * @param callback 动画结束回调 + */ + applyAppearState(animationConfig: IAnimationConfig, callback?: () => void): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyAppearState(animationConfig, callback); + return this; + } + + /** + * 应用消失动画状态 + * @param animationConfig 动画配置 + * @param callback 动画结束回调 + */ + applyDisappearState(animationConfig: IAnimationConfig, callback?: () => void): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyDisappearState(animationConfig, callback); + return this; + } + + /** + * 应用更新动画状态 + * @param animationConfig 动画配置 + * @param callback 动画结束回调 + */ + applyUpdateState(animationConfig: IAnimationConfig, callback?: () => void): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyUpdateState(animationConfig, callback); + return this; + } + + /** + * 应用高亮动画状态 + * @param animationConfig 动画配置 + * @param callback 动画结束回调 + */ + applyHighlightState(animationConfig: IAnimationConfig, callback?: () => void): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyHighlightState(animationConfig, callback); + return this; + } + + /** + * 应用取消高亮动画状态 + * @param animationConfig 动画配置 + * @param callback 动画结束回调 + */ + applyUnhighlightState(animationConfig: IAnimationConfig, callback?: () => void): this { + this._getAnimationStateManager(this as unknown as IGraphic).applyUnhighlightState(animationConfig, callback); + return this; + } + + /** + * 停止一个动画状态 + */ + stopAnimationState(state: string, type?: 'start' | 'end' | Record): this { + this._getAnimationStateManager(this as unknown as IGraphic).stopState(state, type); + return this; + } + + /** + * 清除图形上的所有动画状态 + */ + clearAnimationStates(): this { + this._getAnimationStateManager(this as unknown as IGraphic).clearState(); + return this; + } + + // /** + // * 获取图形当前的动画状态 + // */ + // getCurrentAnimationState(): string[] | null { + // return this._getAnimationStateManager(this as unknown as IGraphic).getCurrentState(); + // } + + /** + * 继承 + */ + static extend(graphic: IGraphic): IGraphic { + const extension = new GraphicStateExtension(); + extension._getAnimationStateManager(graphic); + return graphic; + } +} diff --git a/packages/vrender-animate/src/state/index.ts b/packages/vrender-animate/src/state/index.ts new file mode 100644 index 000000000..980a74dc0 --- /dev/null +++ b/packages/vrender-animate/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './animation-state'; +export * from './graphic-extension'; +export * from './animation-states-registry'; diff --git a/packages/vrender-animate/src/state/types.ts b/packages/vrender-animate/src/state/types.ts new file mode 100644 index 000000000..8c474a9eb --- /dev/null +++ b/packages/vrender-animate/src/state/types.ts @@ -0,0 +1,39 @@ +import type { IGraphic } from '@visactor/vrender-core'; +import type { IAnimationConfig } from '../executor/executor'; + +export interface IAnimationState { + /** + * 状态名称 + */ + name: string; + + /** + * 动画配置 + */ + animation: IAnimationConfig; +} + +/** + * Animation state manager for a graphic + */ +export interface IAnimationStateManager { + /** + * Register a state for the graphic + */ + registerState: (state: IAnimationState) => void; + + /** + * Apply a state to the graphic + */ + applyState: (state: string | string[]) => void; + + /** + * Clear all states from the graphic + */ + clearStates: () => void; + + /** + * Get the current state of the graphic + */ + getCurrentState: () => string | string[] | null; +} diff --git a/packages/vrender-animate/src/step.ts b/packages/vrender-animate/src/step.ts new file mode 100644 index 000000000..80d3d9214 --- /dev/null +++ b/packages/vrender-animate/src/step.ts @@ -0,0 +1,343 @@ +import { + ColorStore, + ColorType, + Generator, + type IGraphic, + type IAnimate, + type IStep, + type EasingType, + type EasingTypeFunc, + type IAnimateStepType +} from '@visactor/vrender-core'; +import { Easing } from './utils/easing'; +import { commonInterpolateUpdate, interpolateUpdateStore } from './interpolate/store'; +import { isString } from '@visactor/vutils'; + +function noop() { + //... +} + +export class Step implements IStep { + id: number; + type: IAnimateStepType; + prev?: IStep; + duration: number; + next?: IStep; + props?: Record; + propKeys?: string[]; + interpolateUpdateFunctions?: (( + key: string, + from: number, + to: number, + ratio: number, + step: IStep, + target: IGraphic + ) => void)[]; + easing: EasingTypeFunc; + animate: IAnimate; + target: IGraphic; + fromProps: Record; + fromParsedProps: Record; + toParsedProps: Record; + + // 内部状态 + protected _startTime: number = 0; + _hasFirstRun: boolean = false; + + protected _endCb?: (animate: IAnimate, step: IStep) => void; + + syncAttributeUpdate: () => void; + + constructor(type: IAnimateStepType, props: Record, duration: number, easing: EasingType) { + this.type = type; + this.props = props; + this.duration = duration; + // 设置缓动函数 + if (easing) { + this.easing = typeof easing === 'function' ? easing : Easing[easing] ?? Easing.linear; + } else { + this.easing = Easing.linear; + } + if (type === 'wait') { + this.onUpdate = noop; + } + this.id = Generator.GenAutoIncrementId(); + this.syncAttributeUpdate = noop; + } + + bind(target: IGraphic, animate: IAnimate): void { + this.target = target; + this.animate = animate; + this.onBind(); + this.syncAttributeUpdate(); + } + + append(step: IStep): void { + this.next = step; + step.prev = this; + + // 更新绝对时间 + step.setStartTime(this.getStartTime() + this.duration, false); + } + + // 更新下游节点的开始时间 + private updateDownstreamStartTimes(): void { + let currentStep = this.next; + let currentStartTime = this._startTime + this.duration; + + while (currentStep) { + currentStep.setStartTime(currentStartTime, false); + currentStartTime += currentStep.duration; + currentStep = currentStep.next; + } + this.animate.updateDuration(); + } + + getLastProps(): any { + if (this.prev) { + return this.prev.props || {}; + } + return this.animate.getStartProps(); + } + + setDuration(duration: number, updateDownstream: boolean = true): void { + this.duration = duration; + + // 如果有后续节点,更新所有后续节点的开始时间 + if (updateDownstream) { + this.updateDownstreamStartTimes(); + } + } + + getDuration(): number { + return this.duration; + } + + determineInterpolateUpdateFunction(): void { + // 根据属性类型确定插值更新函数 + // 这里可以进行优化,例如缓存不同类型属性的插值更新函数 + if (!this.props) { + return; + } + + const funcs: ((key: string, from: number, to: number, ratio: number, step: IStep, target: IGraphic) => void)[] = []; + this.propKeys.forEach(key => { + // 普通颜色特殊处理,需要提前解析成number[] + if (key === 'fill' || key === 'stroke') { + const from = this.fromProps[key]; + const to = this.props[key]; + if (isString(from) && isString(to)) { + const fromArray = ColorStore.Get(from, ColorType.Color255); + const toArray = ColorStore.Get(to, ColorType.Color255); + if (!this.fromParsedProps) { + this.fromParsedProps = {}; + } + if (!this.toParsedProps) { + this.toParsedProps = {}; + } + this.fromParsedProps[key] = fromArray; + this.toParsedProps[key] = toArray; + funcs.push((interpolateUpdateStore as any)[key === 'fill' ? 'fillPure' : 'strokePure']); + } else if ((interpolateUpdateStore as any)[key]) { + funcs.push((interpolateUpdateStore as any)[key]); + } else { + funcs.push(commonInterpolateUpdate); + } + } else if ((interpolateUpdateStore as any)[key]) { + funcs.push((interpolateUpdateStore as any)[key]); + } else { + funcs.push(commonInterpolateUpdate); + } + }); + this.interpolateUpdateFunctions = funcs; + } + + setStartTime(time: number, updateDownstream: boolean = true): void { + this._startTime = time; + if (updateDownstream) { + this.updateDownstreamStartTimes(); + } + } + + getStartTime(): number { + return this._startTime; + } + + onBind(): void { + // 在第一次绑定到Animate的时候触发 + if (this.target.type === 'glyph') { + this.syncAttributeUpdate = this._syncAttributeUpdate; + } + } + + _syncAttributeUpdate = (): void => { + this.target.setAttributes(this.target.attribute); + }; + + /** + * 首次运行逻辑 + * 如果跳帧了就不一定会执行 + */ + onFirstRun(): void { + // 首次运行逻辑 + } + + /** + * 开始执行的时候调用 + * 如果跳帧了就不一定会执行 + */ + onStart(): void { + if (!this._hasFirstRun) { + this._hasFirstRun = true; + // 获取上一步的属性值作为起始值 + this.fromProps = this.getLastProps(); + const startProps = this.animate.getStartProps(); + this.propKeys && + this.propKeys.forEach(key => { + this.fromProps[key] = this.fromProps[key] ?? startProps[key]; + }); + this.determineInterpolateUpdateFunction(); + this.tryPreventConflict(); + this.trySyncStartProps(); + this.onFirstRun(); + } + } + + protected tryPreventConflict(): void { + // 屏蔽掉之前动画冲突的属性 + const animate = this.animate; + const target = this.target; + target.animates.forEach((a: any) => { + if (a === animate || a.priority > animate.priority || a.priority === Infinity) { + return; + } + const fromProps = a.getStartProps(); + this.propKeys.forEach(key => { + if (fromProps[key] != null) { + a.preventAttr(key); + } + }); + }); + } + + /** + * 删除自身属性,会直接从props等内容里删除掉 + */ + deleteSelfAttr(key: string): void { + delete this.props[key]; + // fromProps在动画开始时才会计算,这时可能不在 + this.fromProps && delete this.fromProps[key]; + const index = this.propKeys.indexOf(key); + if (index !== -1) { + this.propKeys.splice(index, 1); + this.interpolateUpdateFunctions?.splice(index, 1); + } + } + + /** + * 尝试同步startProps,因为当前animate的startProps仅包含当前animate的信息,不排除过程中有其他animate的干扰 + * 所以为了避免属性突变,需要确保startProps的属性值是最新的 + */ + trySyncStartProps(): void { + this.propKeys.forEach(key => { + this.fromProps[key] = this.animate.target.getComputedAttribute(key); + }); + } + + /** + * 更新执行的时候调用 + * 如果跳帧了就不一定会执行 + */ + update(end: boolean, ratio: number, out: Record): void { + // TODO 需要修复,只有在开始的时候才调用 + this.onStart(); + if (!this.props || !this.propKeys) { + return; + } + // 应用缓动函数 + const easedRatio = this.easing(ratio); + this.animate.interpolateUpdateFunction + ? this.animate.interpolateUpdateFunction(this.fromProps, this.props, easedRatio, this, this.target) + : this.interpolateUpdateFunctions.forEach((func, index) => { + // 如果这个属性被屏蔽了,直接跳过 + if (!this.animate.validAttr(this.propKeys[index])) { + return; + } + const key = this.propKeys[index]; + const fromValue = this.fromProps[key]; + const toValue = this.props[key]; + func(key, fromValue, toValue, easedRatio, this, this.target); + }); + this.onUpdate(end, easedRatio, out); + this.syncAttributeUpdate(); + } + + onUpdate(end: boolean, ratio: number, out: Record): void { + // ... + } + + /** + * 结束执行的时候调用 + * 如果跳帧了就不一定会执行 + */ + onEnd(cb?: (animate: IAnimate, step: IStep) => void): void { + this.target.setAttributes(this.props); + if (cb) { + this._endCb = cb; + } else if (this._endCb) { + this._endCb(this.animate, this); + } + } + + /** + * 获取结束的属性,包含前序的终值,是merge过的 + * @returns + */ + getEndProps(): Record { + return this.props; + } + + /** + * 获取开始的属性,是前序的终值 + * @returns + */ + getFromProps(): Record { + return this.fromProps; + } + + /** + * 获取结束的属性,包含前序的终值,是merge过的,同getEndProps + * @returns + */ + getMergedEndProps(): Record | void { + return this.getEndProps(); + } + + stop(): void { + // ... + } +} + +export class WaitStep extends Step { + constructor(type: IAnimateStepType, props: Record, duration: number, easing: EasingType) { + super(type, props, duration, easing); + } + + onStart(): void { + super.onStart(); + } + onFirstRun(): void { + // 设置上一个阶段的props到attribute + const fromProps = this.getFromProps(); + this.target.setAttributes(fromProps); + } + + update(end: boolean, ratio: number, out: Record): void { + this.onStart(); + // 其他的不执行 + } + + determineInterpolateUpdateFunction(): void { + return; + } +} diff --git a/packages/vrender-animate/src/ticker/default-ticker.ts b/packages/vrender-animate/src/ticker/default-ticker.ts new file mode 100644 index 000000000..fcf7778cd --- /dev/null +++ b/packages/vrender-animate/src/ticker/default-ticker.ts @@ -0,0 +1,280 @@ +import { EventEmitter } from '@visactor/vutils'; +import type { IStage, ITimeline } from '@visactor/vrender-core'; +import { application, PerformanceRAF, type ITickHandler, type ITicker, STATUS } from '@visactor/vrender-core'; + +const performanceRAF = new PerformanceRAF(); + +class RAFTickHandler implements ITickHandler { + protected released: boolean = false; + + tick(interval: number, cb: (handler: ITickHandler) => void | boolean): void { + performanceRAF.addAnimationFrameCb(() => { + if (this.released) { + return; + } + return cb(this); + }); + } + + release(): void { + this.released = true; + } + + getTime(): number { + return Date.now(); + } +} + +/** + * Graph-based Ticker implementation + * This ticker works directly with GraphManager instances without needing timeline adapters + */ +export class DefaultTicker extends EventEmitter implements ITicker { + protected interval: number; + protected tickerHandler: ITickHandler; + protected status: STATUS; + protected lastFrameTime: number; + protected tickCounts: number; + protected stage: IStage; + timelines: ITimeline[] = []; + autoStop: boolean; + // 随机扰动(每次都对interval进行随机的扰动,避免所有tick都发生在同一帧) + protected _jitter: number; + protected timeOffset: number; + declare _lastTickTime: number; + protected frameTimeHistory: number[] = []; + + constructor(stage?: IStage) { + super(); + this.init(); + this.lastFrameTime = -1; + this.tickCounts = 0; + this.stage = stage; + this.autoStop = true; + this.interval = 16; + this.computeTimeOffsetAndJitter(); + } + + bindStage(stage: IStage): void { + this.stage = stage; + } + + /** + * 计算时间偏移和随机扰动 + */ + computeTimeOffsetAndJitter(): void { + this.timeOffset = Math.floor(Math.random() * this.interval); + this._jitter = Math.min(Math.max(this.interval * 0.2, 6), this.interval * 0.7); + } + + init(): void { + this.interval = 16; + this.status = STATUS.INITIAL; + application.global.hooks.onSetEnv.tap('graph-ticker', () => { + this.initHandler(); + }); + if (application.global.env) { + this.initHandler(); + } + } + + addTimeline(timeline: ITimeline): void { + this.timelines.push(timeline); + } + + remTimeline(timeline: ITimeline): void { + this.timelines = this.timelines.filter(t => t !== timeline); + } + + getTimelines(): ITimeline[] { + return this.timelines; + } + + protected initHandler() { + this.setupTickHandler(); + } + + /** + * Set up the tick handler + * @returns true if setup was successful, false otherwise + */ + protected setupTickHandler(): boolean { + const handler: ITickHandler = new RAFTickHandler(); + + // Destroy the previous tick handler + if (this.tickerHandler) { + this.tickerHandler.release(); + } + + this.tickerHandler = handler; + return true; + } + + setInterval(interval: number): void { + this.interval = interval; + this.computeTimeOffsetAndJitter(); + } + + getInterval(): number { + return this.interval; + } + + setFPS(fps: number): void { + this.setInterval(Math.floor(1000 / fps)); + } + + getFPS(): number { + return 1000 / this.interval; + } + + tick(interval: number): void { + this.tickerHandler.tick(interval, (handler: ITickHandler) => { + return this.handleTick(handler, { once: true }); + }); + } + + tickTo(t: number): void { + if (!this.tickerHandler.tickTo) { + return; + } + this.tickerHandler.tickTo(t, (handler: ITickHandler) => { + this.handleTick(handler, { once: true }); + }); + } + + pause(): boolean { + if (this.status === STATUS.INITIAL) { + return false; + } + this.status = STATUS.PAUSE; + return true; + } + + resume(): boolean { + if (this.status === STATUS.INITIAL) { + return false; + } + this.status = STATUS.RUNNING; + return true; + } + + ifCanStop(): boolean { + if (this.autoStop) { + if (!this.timelines.length) { + return true; + } + if (this.timelines.every(timeline => !timeline.isRunning())) { + return true; + } + } + return false; + } + + start(force: boolean = false): boolean { + if (this.status === STATUS.RUNNING) { + return false; + } + if (!this.tickerHandler) { + return false; + } + + // 暂停中、或者应该停止的时候,就不执行 + if (!force) { + if (this.status === STATUS.PAUSE) { + return false; + } + if (this.ifCanStop()) { + return false; + } + } + + this.status = STATUS.RUNNING; + this.tickerHandler.tick(0, this.handleTick); + return true; + } + + stop(): void { + // Reset the tick handler + this.status = STATUS.INITIAL; + this.setupTickHandler(); + this.lastFrameTime = -1; + } + + /** + * 用于自动启动或停止 + * 基于当前的graph managers检查是否需要启动或停止 + */ + trySyncTickStatus(): void { + if (this.status === STATUS.INITIAL && this.timelines.some(timeline => timeline.isRunning())) { + this.start(); + } else if (this.status === STATUS.RUNNING && this.timelines.every(timeline => !timeline.isRunning())) { + this.stop(); + } + } + + release(): void { + this.stop(); + this.timelines = []; + this.tickerHandler?.release(); + this.tickerHandler = null; + this.lastFrameTime = -1; + } + + protected checkSkip(delta: number): boolean { + if (this.stage.params.optimize.tickRenderMode === 'performance') { + return false; + } + // 随机扰动(每次都对interval进行随机的扰动,避免所有tick都发生在同一帧) + const skip = delta < this.interval + (Math.random() - 0.5) * 2 * this._jitter; + return skip; + } + + protected handleTick = (handler: ITickHandler, params?: { once?: boolean }): boolean => { + const { once = false } = params ?? {}; + + // 尝试停止 + if (this.ifCanStop()) { + this.stop(); + return false; + } + + const currentTime = handler.getTime(); + this._lastTickTime = currentTime; + + if (this.lastFrameTime < 0) { + this.lastFrameTime = currentTime - this.interval + this.timeOffset; + this.frameTimeHistory.push(this.lastFrameTime); + } + + const delta = currentTime - this.lastFrameTime; + + const skip = this.checkSkip(delta); + + if (!skip) { + this._handlerTick(delta); + this.lastFrameTime = currentTime; + this.frameTimeHistory.push(this.lastFrameTime); + } + + if (!once) { + handler.tick(this.interval, this.handleTick); + } + + return !skip; + }; + + protected _handlerTick = (delta: number): void => { + if (this.status !== STATUS.RUNNING) { + return; + } + + this.tickCounts++; + + // Update all graph managers + this.timelines.forEach(timeline => { + timeline.tick(delta); + }); + + this.emit('tick', delta); + }; +} diff --git a/packages/vrender-animate/src/ticker/manual-ticker.ts b/packages/vrender-animate/src/ticker/manual-ticker.ts new file mode 100644 index 000000000..56fc823a5 --- /dev/null +++ b/packages/vrender-animate/src/ticker/manual-ticker.ts @@ -0,0 +1,84 @@ +import type { IStage } from '@visactor/vrender-core'; +import { STATUS, type ITickHandler, type ITicker } from '@visactor/vrender-core'; +import { DefaultTicker } from './default-ticker'; + +class ManualTickHandler implements ITickHandler { + protected released: boolean = false; + protected currentTime: number = -1; + + tick(interval: number, cb: (handler: ITickHandler) => void): void { + if (this.currentTime < 0) { + this.currentTime = 0; + } + this.currentTime += interval; + cb(this); + } + + release(): void { + this.released = true; + } + + getTime(): number { + return this.currentTime; + } + + tickTo(time: number, cb: (handler: ITickHandler) => void): void { + if (this.currentTime < 0) { + this.currentTime = 0; + } + const interval = time - this.currentTime; + this.tick(interval, cb); + } +} + +export class ManualTicker extends DefaultTicker implements ITicker { + constructor(stage?: IStage) { + super(stage); + // manualTicker 的 lastFrameTime 默认为 0 + // status 默认为 STATUS.RUNNING(不需要启动) + this.lastFrameTime = 0; + this.status = STATUS.RUNNING; + } + protected setupTickHandler(): boolean { + const handler: ITickHandler = new ManualTickHandler(); + + // Destroy the previous tick handler + if (this.tickerHandler) { + this.tickerHandler.release(); + } + + this.tickerHandler = handler; + return true; + } + + checkSkip(delta: number): boolean { + return false; + } + + getTime(): number { + return this.tickerHandler.getTime(); + } + + tickAt(time: number): void { + this.tickTo(time); + } + + start(force = false) { + if (this.status === STATUS.RUNNING) { + return false; + } + if (!this.tickerHandler) { + return false; + } + if (!force) { + if (this.status === STATUS.PAUSE) { + return false; + } + if (this.ifCanStop()) { + return false; + } + } + this.status = STATUS.RUNNING; + return true; + } +} diff --git a/packages/vrender-animate/src/timeline.ts b/packages/vrender-animate/src/timeline.ts new file mode 100644 index 000000000..c563dde40 --- /dev/null +++ b/packages/vrender-animate/src/timeline.ts @@ -0,0 +1,214 @@ +import { Generator, type IAnimate, type ITimeline, AnimateStatus } from '@visactor/vrender-core'; +import { EventEmitter } from '@visactor/vutils'; + +// 定义链表节点 +interface AnimateNode { + animate: IAnimate; + next: AnimateNode | null; + prev: AnimateNode | null; +} + +export class DefaultTimeline extends EventEmitter implements ITimeline { + declare id: number; + protected head: AnimateNode | null = null; + protected tail: AnimateNode | null = null; + protected animateMap: Map = new Map(); + protected _animateCount: number = 0; + protected declare paused: boolean; + + // 添加必要的属性 + protected _playSpeed: number = 1; + protected _totalDuration: number = 0; + protected _startTime: number = 0; + protected _currentTime: number = 0; + + declare isGlobal?: boolean; + + get animateCount() { + return this._animateCount; + } + + constructor() { + super(); + this.id = Generator.GenAutoIncrementId(); + this.paused = false; + } + + isRunning() { + return !this.paused && this._animateCount > 0; + } + + forEachAccessAnimate(cb: (animate: IAnimate, index: number) => void) { + let current = this.head; + let index = 0; + + while (current) { + // 保存下一个节点的引用,以防在回调中移除当前节点 + const next = current.next; + cb(current.animate, index); + index++; + current = next; + } + } + + addAnimate(animate: IAnimate) { + const newNode: AnimateNode = { + animate, + next: null, + prev: null + }; + + // 添加到链表尾部 + if (!this.head) { + this.head = newNode; + this.tail = newNode; + } else { + if (this.tail) { + this.tail.next = newNode; + newNode.prev = this.tail; + this.tail = newNode; + } + } + + // 在映射中保存节点引用 + this.animateMap.set(animate, newNode); + this._animateCount++; + + // 更新总时长 + this._totalDuration = Math.max(this._totalDuration, animate.getStartTime() + animate.getDuration()); + } + + pause() { + this.paused = true; + } + + resume() { + this.paused = false; + } + + tick(delta: number) { + if (this.paused) { + return; + } + + // 应用播放速度 + const scaledDelta = delta * this._playSpeed; + + // 更新当前时间 + this._currentTime += scaledDelta; + + this.forEachAccessAnimate((animate, i) => { + if (animate.status === AnimateStatus.END) { + this.removeAnimate(animate, true); + } else if (animate.status === AnimateStatus.RUNNING || animate.status === AnimateStatus.INITIAL) { + animate.advance(scaledDelta); + } + }); + + if (this._animateCount === 0) { + this.emit('animationEnd'); + } + } + + clear() { + this.forEachAccessAnimate(animate => { + animate.release(); + }); + + this.head = null; + this.tail = null; + this.animateMap.clear(); + this._animateCount = 0; + this._totalDuration = 0; + } + + removeAnimate(animate: IAnimate, release: boolean = true) { + const node = this.animateMap.get(animate); + + if (!node) { + return; + } + + if (release) { + animate._onRemove && animate._onRemove.forEach(cb => cb()); + animate.release(); + } + + // 从链表中移除节点 + if (node.prev) { + node.prev.next = node.next; + } else { + // 节点是头节点 + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + // 节点是尾节点 + this.tail = node.prev; + } + + // 从映射中移除 + this.animateMap.delete(animate); + this._animateCount--; + + // 如果移除的是最长时间的动画,应该重新计算总时长 + if (animate.getStartTime() + animate.getDuration() >= this._totalDuration) { + this.recalculateTotalDuration(); + } + + return; + } + + // 重新计算总时长 + protected recalculateTotalDuration() { + this._totalDuration = 0; + this.forEachAccessAnimate(animate => { + this._totalDuration = Math.max(this._totalDuration, animate.getStartTime() + animate.getDuration()); + }); + } + + getTotalDuration() { + return this._totalDuration; + } + + getPlaySpeed() { + return this._playSpeed; + } + + setPlaySpeed(speed: number) { + this._playSpeed = speed; + } + + // 实现ITimeline接口所需的其他方法 + getPlayState(): 'playing' | 'paused' | 'stopped' { + if (this.paused) { + return 'paused'; + } + if (this.animateCount === 0) { + return 'stopped'; + } + return 'playing'; + } + + setStartTime(time: number) { + this._startTime = time; + } + + getStartTime() { + return this._startTime; + } + + getCurrentTime() { + return this._currentTime; + } + + setCurrentTime(time: number) { + this._currentTime = time; + } +} + +// 不会使用,存粹做临时存储用,请一定要放置到stage中才行 +export const defaultTimeline = new DefaultTimeline(); +defaultTimeline.isGlobal = true; diff --git a/packages/vrender-core/src/animate/easing-func.ts b/packages/vrender-animate/src/utils/easing-func.ts similarity index 70% rename from packages/vrender-core/src/animate/easing-func.ts rename to packages/vrender-animate/src/utils/easing-func.ts index 42c0dfb8a..078c83b42 100644 --- a/packages/vrender-core/src/animate/easing-func.ts +++ b/packages/vrender-animate/src/utils/easing-func.ts @@ -1,5 +1,4 @@ -import { CustomPath2D } from '../common/custom-path2d'; -import { CurveContext } from '../common/segment'; +import { CurveContext, CustomPath2D } from '@visactor/vrender-core'; export function generatorPathEasingFunc(path: string) { const customPath = new CustomPath2D(); diff --git a/packages/vrender-core/src/animate/easing.ts b/packages/vrender-animate/src/utils/easing.ts similarity index 100% rename from packages/vrender-core/src/animate/easing.ts rename to packages/vrender-animate/src/utils/easing.ts diff --git a/packages/vrender-animate/src/utils/transform.ts b/packages/vrender-animate/src/utils/transform.ts new file mode 100644 index 000000000..304ada2d1 --- /dev/null +++ b/packages/vrender-animate/src/utils/transform.ts @@ -0,0 +1,16 @@ +export const transformKeys = [ + 'x', + 'y', + 'dx', + 'dy', + 'scaleX', + 'scaleY', + 'angle', + 'anchor', + 'postMatrix', + 'scrollX', + 'scrollY' +]; +export const isTransformKey = (key: string) => { + return transformKeys.includes(key); +}; diff --git a/packages/vrender-animate/tsconfig.eslint.json b/packages/vrender-animate/tsconfig.eslint.json new file mode 100644 index 000000000..7f6149b2c --- /dev/null +++ b/packages/vrender-animate/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "@internal/ts-config/tsconfig.base.json", + "compilerOptions": { + "types": ["jest", "node"], + "lib": ["DOM", "ESNext"], + "baseUrl": "./", + "rootDir": "./" + }, + "include": ["src", "__tests__", "examples"], + "exclude": ["bugserver-config"], + "references": [ + { + "path": "../vrender-core" + } + ] +} diff --git a/packages/vrender-animate/tsconfig.json b/packages/vrender-animate/tsconfig.json new file mode 100644 index 000000000..9ad2e6ba9 --- /dev/null +++ b/packages/vrender-animate/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@internal/ts-config/tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./es", + "rootDir": "./src", + "composite": true, + "target": "ES2016" + }, + "include": ["src"], + "references": [ + { + "path": "../vrender-core" + } + ] +} diff --git a/packages/vrender-animate/vite/index.html b/packages/vrender-animate/vite/index.html new file mode 100644 index 000000000..e0d1c8408 --- /dev/null +++ b/packages/vrender-animate/vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/vrender-animate/vite/public/vite.svg b/packages/vrender-animate/vite/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/vrender-animate/vite/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vrender-animate/vite/src/App.css b/packages/vrender-animate/vite/src/App.css new file mode 100644 index 000000000..b9d355df2 --- /dev/null +++ b/packages/vrender-animate/vite/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/vrender-animate/vite/src/App.tsx b/packages/vrender-animate/vite/src/App.tsx new file mode 100644 index 000000000..d6dcc835b --- /dev/null +++ b/packages/vrender-animate/vite/src/App.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import reactLogo from './assets/react.svg'; +import viteLogo from '/vite.svg'; +import './App.css'; + +function App() { + const [count, setCount] = useState(0); + + return ( + <> + +

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

Click on the Vite and React logos to learn more

+ + ); +} + +export default App; diff --git a/packages/vrender-animate/vite/src/assets/react.svg b/packages/vrender-animate/vite/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/vrender-animate/vite/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vrender-animate/vite/src/index.css b/packages/vrender-animate/vite/src/index.css new file mode 100644 index 000000000..2c3fac689 --- /dev/null +++ b/packages/vrender-animate/vite/src/index.css @@ -0,0 +1,69 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/vrender-animate/vite/src/main.tsx b/packages/vrender-animate/vite/src/main.tsx new file mode 100644 index 000000000..b3ad8a593 --- /dev/null +++ b/packages/vrender-animate/vite/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App.tsx'; +import './index.css'; + +ReactDOM.render( + + + , + document.getElementById('root') as HTMLElement +); diff --git a/packages/vrender-animate/vite/src/vite-env.d.ts b/packages/vrender-animate/vite/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/vrender-animate/vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/vrender-animate/vite/tsconfig.json b/packages/vrender-animate/vite/tsconfig.json new file mode 100644 index 000000000..4af7feedf --- /dev/null +++ b/packages/vrender-animate/vite/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/packages/vrender-animate/vite/tsconfig.node.json b/packages/vrender-animate/vite/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/packages/vrender-animate/vite/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/vrender-animate/vite/vite.config.ts b/packages/vrender-animate/vite/vite.config.ts new file mode 100644 index 000000000..c8f4f283c --- /dev/null +++ b/packages/vrender-animate/vite/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react({ jsxRuntime: 'classic' })] +}); diff --git a/packages/vrender-components/__tests__/browser/examples/mark-point.ts b/packages/vrender-components/__tests__/browser/examples/mark-point.ts index 43bcdb68a..73e344773 100644 --- a/packages/vrender-components/__tests__/browser/examples/mark-point.ts +++ b/packages/vrender-components/__tests__/browser/examples/mark-point.ts @@ -62,24 +62,13 @@ export function run() { fill: 'red' } }, - symbol: { + itemContent: { hover: { - stroke: 'red', - fill: 'red' - } - }, - image: { - hover: { - stroke: 'red', - fill: 'red', + stroke: 'blue', + fill: 'blue', width: 200, - height: 200 - } - }, - text: { - hover: { - stroke: 'red', - fill: 'red' + height: 200, + size: 200 } }, textBackground: { @@ -87,18 +76,6 @@ export function run() { stroke: 'red', fill: 'red' } - }, - richText: { - hover: { - stroke: 'red', - fill: 'red' - } - }, - customMark: { - hover: { - stroke: 'red', - fill: 'red' - } } }, @@ -150,85 +127,42 @@ export function run() { confine: true, autoRotate: guiObject.itemAutoRotate, - textStyle: { - // text: 'mark point label text' - type: 'text', + type: 'text', + style: { + type: 'rich', textStyle: { - text: 'Type your annotation text here', + textConfig: [{ text: 'Type your annotation text here' }], // fontWeight: 'bold', fontSize: 12, fill: '#3f51b5', height: 25 // textAlign: 'center' - } - // text: [ - // { - - // }, - // { - // text: '替代方案', - // fontStyle: 'italic', - // textDecoration: 'underline', - // fill: '#3f51b5', - // height: 25 - // } - // ] - }, - richTextStyle: { - textConfig: [ - { - text: 'Mapbox', - fontWeight: 'bold', - fontSize: 10, - fill: '#3f51b5' - }, - { - text: '公司成立于2010年,创立目标是为Google Map提供一个', - - fontSize: 8 - }, + }, + text: [ + {}, { text: '替代方案', fontStyle: 'italic', - + textDecoration: 'underline', fill: '#3f51b5', - fontSize: 8 - }, - { - text: '。在当时,Google Map', - - fontSize: 8 - }, - { - text: '地图', - textDecoration: 'line-through', - - fontSize: 8 - }, - { - text: '[1]', - script: 'super', - - fontSize: 8 - }, - { - text: '几乎垄断了所有线上地图业务,但是在Google Map中,几乎没有定制化的可能,也没有任何工具可以让制图者按照他们的设想来创建地图', - - fontSize: 8 - }, - { - text: '。\n', - - fill: '#30ff05', - fontSize: 8 + height: 25 } ] - }, - imageStyle: { - image: `https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/shape_logo.png` - // width: 400, - // height: 400 } + + // type: 'image', + // style: { + // image: `https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/shape_logo.png`, + // width: 400, + // height: 400 + // }, + + // type: 'symbol', + // style: { + // symbolType: `circle`, + // size: 30, + // fill: 'red' + // } }, targetSymbol: { visible: true, @@ -265,13 +199,12 @@ export function run() { const markPoint2 = new MarkPoint({ position: { - x: 250, + x: 0, y: 250 }, ...(styleAttr as any), itemContent: { - ...styleAttr.itemContent, - type: 'text' + ...styleAttr.itemContent } }); @@ -283,7 +216,7 @@ export function run() { ...(styleAttr as any), itemContent: { ...styleAttr.itemContent, - type: 'richText' + type: 'text' } }); @@ -294,8 +227,7 @@ export function run() { }, ...(styleAttr as any), itemContent: { - ...styleAttr.itemContent, - type: 'image' + ...styleAttr.itemContent } }); diff --git a/packages/vrender-components/__tests__/browser/examples/poptip.ts b/packages/vrender-components/__tests__/browser/examples/poptip.ts index 105888532..604aa865c 100644 --- a/packages/vrender-components/__tests__/browser/examples/poptip.ts +++ b/packages/vrender-components/__tests__/browser/examples/poptip.ts @@ -3,6 +3,11 @@ import '@visactor/vrender'; import { createLine, createText, IText } from '@visactor/vrender'; import render from '../../util/render'; import { PopTip, loadPoptip } from '../../../src'; +import { registerAnimate, registerCustomAnimate } from '@visactor/vrender-animate'; + +registerAnimate(); +registerCustomAnimate(); + // import { initBrowserEnv } from '@visactor/vrender-kits'; // initBrowserEnv(); loadPoptip(); @@ -246,9 +251,22 @@ export function run() { ); stage.render(); poptipList.forEach(poptip => { - poptip.appearAnimate({ duration: 300, easing: 'quadOut', wave: 0.3 }); + poptip.applyAppearState({ + type: 'poptipAppear', + duration: 300, + easing: 'quadOut', + selfOnly: true, + customParameters: { + wave: 0.3 + } + }); + setTimeout(() => { - poptip.disappearAnimate({ duration: 300, easing: 'aIn3' }); + poptip.applyDisappearState({ + type: 'poptipDisappear', + duration: 300, + easing: 'aIn3' + }); }, 2000); }); diff --git a/packages/vrender-components/__tests__/browser/examples/story-label-item.ts b/packages/vrender-components/__tests__/browser/examples/story-label-item.ts index 72fdd58b5..457805436 100644 --- a/packages/vrender-components/__tests__/browser/examples/story-label-item.ts +++ b/packages/vrender-components/__tests__/browser/examples/story-label-item.ts @@ -1,8 +1,10 @@ import '@visactor/vrender'; -import { IPointLike } from '@visactor/vutils'; import render from '../../util/render'; import { StoryLabelItem } from '../../../src'; -import { createLine } from '@visactor/vrender-core'; +import { registerAnimate, registerCustomAnimate } from '@visactor/vrender-animate'; + +registerAnimate(); +registerCustomAnimate(); export function run() { const labels: StoryLabelItem[] = []; @@ -97,22 +99,32 @@ export function run() { }) ); - const stage = render(labels, 'main', 'black'); + const stage = render(labels, 'main', { background: 'black' }); stage.render(); labels.forEach((label, index) => { - label.appearAnimate({ + label.applyAppearState({ + type: 'labelItemAppear', duration: 1000, easing: 'cubicIn', - symbolStartOuterType: 'scale', - titleType: 'move', - titlePanelType: index > 3 ? 'stroke' : 'scale' + selfOnly: true, + customParameters: { + symbolStartOuterType: 'scale', + titleType: 'typewriter', + titlePanelType: index > 3 ? 'stroke' : 'scale' + } }); }); setTimeout(() => { labels.forEach(label => { - label.disappearAnimate({ duration: 1000, easing: 'cubicIn' }); + label.applyDisappearState({ + type: 'labelItemDisappear', + duration: 1000, + easing: 'cubicIn', + selfOnly: true, + customParameters: {} + }); }); }, 3000); } diff --git a/packages/vrender-components/__tests__/browser/vite.config.ts b/packages/vrender-components/__tests__/browser/vite.config.ts index 4f393b723..576b76192 100644 --- a/packages/vrender-components/__tests__/browser/vite.config.ts +++ b/packages/vrender-components/__tests__/browser/vite.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ alias: { '@visactor/vrender-core': path.resolve(__dirname, '../../../vrender-core/src/index.ts'), '@visactor/vrender': path.resolve(__dirname, '../../../vrender/src/index.ts'), - '@visactor/vrender-kits': path.resolve(__dirname, '../../../vrender-kits/src/index.ts') + '@visactor/vrender-kits': path.resolve(__dirname, '../../../vrender-kits/src/index.ts'), + '@visactor/vrender-animate': path.resolve(__dirname, '../../../vrender-animate/src/index.ts') } } }); diff --git a/packages/vrender-components/__tests__/unit/marker/point.test.ts b/packages/vrender-components/__tests__/unit/marker/point.test.ts index 588568805..129f1ae51 100644 --- a/packages/vrender-components/__tests__/unit/marker/point.test.ts +++ b/packages/vrender-components/__tests__/unit/marker/point.test.ts @@ -30,7 +30,7 @@ describe('Marker', () => { refX: 10, refY: 0, refAngle: 0, - textStyle: { + style: { text: 'mark point label text', panel: { visible: true diff --git a/packages/vrender-components/jest.config.js b/packages/vrender-components/jest.config.js index 36f6004e9..316f8523f 100644 --- a/packages/vrender-components/jest.config.js +++ b/packages/vrender-components/jest.config.js @@ -26,6 +26,7 @@ module.exports = { '@visactor/vrender-core': path.resolve(__dirname, '../vrender-core/src/index.ts'), '@visactor/vrender/es/core': path.resolve(__dirname, '../vrender/src/index.ts'), '@visactor/vrender/es/register': path.resolve(__dirname, '../vrender/src/register.ts'), - '@visactor/vrender/es/kits': path.resolve(__dirname, '../vrender/src/kits.ts') + '@visactor/vrender/es/kits': path.resolve(__dirname, '../vrender/src/kits.ts'), + '@visactor/vrender-animate': path.resolve(__dirname, '../vrender-animate/src/index.ts') } }; diff --git a/packages/vrender-components/package.json b/packages/vrender-components/package.json index 455bc75ed..547d6ee06 100644 --- a/packages/vrender-components/package.json +++ b/packages/vrender-components/package.json @@ -25,17 +25,18 @@ "build:spec-types": "rm -rf ./spec-types && tsc -p ./tsconfig.spec.json --declaration --emitDeclarationOnly --outDir ./spec-types" }, "dependencies": { - "@visactor/vutils": "~0.19.5", - "@visactor/vscale": "~0.19.5", + "@visactor/vutils": "1.0.4", + "@visactor/vscale": "1.0.4", "@visactor/vrender-core": "workspace:0.22.11", - "@visactor/vrender-kits": "workspace:0.22.11" + "@visactor/vrender-kits": "workspace:0.22.11", + "@visactor/vrender-animate": "workspace:0.22.11" }, "devDependencies": { "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", "@internal/ts-config": "workspace:*", "@rushstack/eslint-patch": "~1.1.4", - "@visactor/vscale": "~0.19.5", + "@visactor/vscale": "1.0.4", "@types/jest": "^26.0.0", "jest": "^26.0.0", "jest-electron": "^0.1.12", diff --git a/packages/vrender-components/src/animation/animate-component.ts b/packages/vrender-components/src/animation/animate-component.ts new file mode 100644 index 000000000..c79da835f --- /dev/null +++ b/packages/vrender-components/src/animation/animate-component.ts @@ -0,0 +1,60 @@ +import type { EasingType } from '@visactor/vrender-core'; +import { AbstractComponent } from '../core/base'; +import { isArray, isObject, merge } from '@visactor/vutils'; + +interface AnimateComponentAttribute { + animation?: boolean | any; + animationEnter?: boolean | any; + animationExit?: boolean | any; + animationUpdate?: boolean | any; +} + +/** + * 标签的离场动画配置 + */ +export interface ICommonAnimation { + /** + * 动画执行的时长 + */ + duration?: number; + /** + * 动画延迟的时长 + */ + delay?: number; + /** + * 动画的缓动函数 + */ + easing?: EasingType; +} + +export abstract class AnimateComponent extends AbstractComponent { + // parsed animation config + protected _animationConfig?: { + enter: ICommonAnimation | false; + exit: ICommonAnimation | false; + update: ICommonAnimation | false; + }; + + _prepareAnimate(defaultAnimation: ICommonAnimation) { + if (this.attribute.animation !== false) { + const { animation, animationEnter, animationExit, animationUpdate } = this.attribute; + const animationCfg = isObject(animation) ? animation : {}; + this._animationConfig = { + enter: animationEnter !== false ? merge({}, defaultAnimation, animationCfg, animationEnter ?? {}) : false, + exit: animationExit !== false ? merge({}, defaultAnimation, animationCfg, animationExit ?? {}) : false, + update: + animationUpdate !== false + ? isArray(animationUpdate) + ? animationUpdate + : merge({}, defaultAnimation, animationCfg, animationUpdate ?? {}) + : false + }; + } else { + this._animationConfig = { + enter: false, + exit: false, + update: false + }; + } + } +} diff --git a/packages/vrender-components/src/animation/axis-animate.ts b/packages/vrender-components/src/animation/axis-animate.ts new file mode 100644 index 000000000..a9e0ce4be --- /dev/null +++ b/packages/vrender-components/src/animation/axis-animate.ts @@ -0,0 +1,129 @@ +import { AComponentAnimate, AnimateExecutor, createComponentAnimator } from '@visactor/vrender-animate'; + +/** + * AxisEnter class handles the enter animation for Axis components + */ +export class AxisEnter extends AComponentAnimate { + onBind(): void { + const animator = createComponentAnimator(this.target); + this._animator = animator; + const duration = this.duration; + const easing = this.easing; + const { config, lastScale, getTickCoord } = this.params; + + let ratio = 1; + const currData = this.target.data; + if (lastScale && getTickCoord && currData) { + ratio = 0.7; + + const oldValue = lastScale.scale(currData.rawValue); + const point = getTickCoord(oldValue); + const newX = this.target.attribute.x; + const newY = this.target.attribute.y; + + this.target.setAttributes({ x: point.x, y: point.y }); + animator.animate(this.target, { + type: 'to', + to: { x: newX, y: newY }, + duration, + easing + }); + } + + // if (updateEls && updateEls.length > 1) { + // ratio = 0.5; + // const oldData1 = updateEls[0].oldEl.data; + // const { rawValue: oldRawValue1, value: oldValue1 } = oldData1; + // const oldData2 = updateEls[1].oldEl.data; + // const { rawValue: oldRawValue2, value: oldValue2 } = oldData2; + // const data = this.target.data; + // const { rawValue: newRawValue } = data; + // // rawValue 是原始值,value是映射出来的值,假设是线性映射,计算一下newRawValue在old阶段的value是什么值 + // const oldValue = + // oldValue1 + ((oldValue2 - oldValue1) * (newRawValue - oldRawValue1)) / (oldRawValue2 - oldRawValue1); + // // 将 x 和 y 做映射 + // const oldX1 = updateEls[0].oldEl.attribute.x; + // const oldY1 = updateEls[0].oldEl.attribute.y; + // const oldX2 = updateEls[1].oldEl.attribute.x; + // const oldY2 = updateEls[1].oldEl.attribute.y; + // const oldX = oldX1 + ((oldX2 - oldX1) * (oldValue - oldValue1)) / (oldValue2 - oldValue1); + // const oldY = oldY1 + ((oldY2 - oldY1) * (oldValue - oldValue1)) / (oldValue2 - oldValue1); + // const newX = this.target.attribute.x; + // const newY = this.target.attribute.y; + + // this.target.setAttributes({ x: oldX, y: oldY }); + // animator.animate(this.target, { + // type: 'to', + // to: { x: newX, y: newY }, + // duration, + // easing + // }); + // } + + animator.animate(this.target, { + type: config.type ?? 'fadeIn', + to: config.to, + duration: duration * ratio, + easing + }); + this.completeBind(animator); + } +} + +/** + * AxisUpdate class handles the update animation for Axis components + */ +export class AxisUpdate extends AComponentAnimate { + onBind(): void { + const animator = createComponentAnimator(this.target); + this._animator = animator; + const duration = this.duration; + const easing = this.easing; + const { config, diffAttrs } = this.params; + // this.target.applyAnimationState( + // ['update'], + // [ + // { + // name: 'update', + // animation: { + // type: 'to', + // to: { ...this.props }, + // duration, + // easing, + // customParameters: { + // diffAttrs: { ...this.props } + // } + // } + // } + // ] + // ); + // console.log('this.props', this.props, { ...this.target.attribute }); + animator.animate(this.target, { + type: 'to', + to: { ...diffAttrs }, + duration, + easing, + customParameters: { + diffAttrs: { ...diffAttrs } + } + }); + this.completeBind(animator); + } + + // 轴动画本身没有逻辑,具体通过animator中执行,所以当需要屏蔽自身属性时,需要通过animator中执行 + deleteSelfAttr(key: string): void { + super.deleteSelfAttr(key); + this._animator.deleteSelfAttr(key); + } + + // 轴动画本身没有逻辑,具体通过animator中执行,所以本身不需要屏蔽冲突 + protected tryPreventConflict(): void { + return; + } +} + +export function registerAxisAnimate() { + // Label update animation + AnimateExecutor.registerBuiltInAnimate('axisEnter', AxisEnter); + AnimateExecutor.registerBuiltInAnimate('axisUpdate', AxisUpdate); +} diff --git a/packages/vrender-components/src/animation/label-animate.ts b/packages/vrender-components/src/animation/label-animate.ts new file mode 100644 index 000000000..031d16f83 --- /dev/null +++ b/packages/vrender-components/src/animation/label-animate.ts @@ -0,0 +1,115 @@ +import { AComponentAnimate, AnimateExecutor, createComponentAnimator, IncreaseCount } from '@visactor/vrender-animate'; + +/** + * LabelUpdate class handles the update animation for Label components + */ +export class LabelUpdate extends AComponentAnimate { + onBind(): void { + const animator = createComponentAnimator(this.target); + this._animator = animator; + const duration = this.duration; + const easing = this.easing; + + const { prevText, curText, prevLabelLine, curLabelLine } = this.params; + const diff: Record = {}; + + for (const key in curText.attribute) { + if (prevText.attribute[key] !== curText.attribute[key]) { + diff[key] = curText.attribute[key]; + } + } + + const { text, ...rest } = diff; + + animator.animate(prevText, { + type: 'to', + to: rest, + duration, + easing + }); + + animator.animate(prevText, { + type: 'increaseCount', + to: { + text: curText.attribute.text + }, + duration, + easing + }); + + if (prevLabelLine) { + animator.animate(prevLabelLine, { + type: 'to', + to: curLabelLine.attribute, + duration, + easing + }); + } + + this.completeBind(animator); + } + + // 标签动画本身没有逻辑,具体通过animator中执行,所以本身不需要屏蔽冲突 + protected tryPreventConflict(): void { + return; + } +} + +export class LabelEnter extends AComponentAnimate { + onBind(): void { + const animator = createComponentAnimator(this.target); + this._animator = animator; + const duration = this.duration; + const easing = this.easing; + + const { relatedGraphic, relatedGraphics, config } = this.params; + const { mode, type = 'fadeIn' } = config; + + const target = this.target; + + let startTime = 0; + + if (mode === 'after') { + relatedGraphic.animates && + relatedGraphic.animates.forEach((animate: any) => { + startTime = Math.max(startTime, animate.getStartTime() + animate.getTotalDuration()); + }); + } else if (mode === 'after-all') { + relatedGraphics && + relatedGraphics.forEach((graphic: any) => { + graphic.animates && + graphic.animates.forEach((animate: any) => { + startTime = Math.max(startTime, animate.getStartTime() + animate.getTotalDuration()); + }); + }); + } else { + // 'same-time' + relatedGraphic.animates && + relatedGraphic.animates.forEach((animate: any) => { + startTime = Math.max(startTime, animate.getStartTime()); + }); + } + + animator.animate(target, { + ...config, + duration, + easing, + startTime, + type + }); + + this.completeBind(animator); + } + + // 标签动画本身没有逻辑,具体通过animator中执行,所以本身不需要屏蔽冲突 + protected tryPreventConflict(): void { + return; + } +} + +export function registerLabelAnimate() { + AnimateExecutor.registerBuiltInAnimate('increaseCount', IncreaseCount); + // Label update animation + AnimateExecutor.registerBuiltInAnimate('labelUpdate', LabelUpdate); + AnimateExecutor.registerBuiltInAnimate('labelEnter', LabelEnter); +} diff --git a/packages/vrender-components/src/axis/animate/config.ts b/packages/vrender-components/src/axis/animate/config.ts new file mode 100644 index 000000000..bbcb62ddd --- /dev/null +++ b/packages/vrender-components/src/axis/animate/config.ts @@ -0,0 +1,7 @@ +import type { EasingType } from '@visactor/vrender-core'; + +export const DefaultAxisAnimation = { + type: 'default', + duration: 300, + easing: 'linear' as EasingType +}; diff --git a/packages/vrender-components/src/axis/animate/group-transition.ts b/packages/vrender-components/src/axis/animate/group-transition.ts index 7590cd6f6..ade892b17 100644 --- a/packages/vrender-components/src/axis/animate/group-transition.ts +++ b/packages/vrender-components/src/axis/animate/group-transition.ts @@ -1,5 +1,6 @@ +import { AnimateMode } from '@visactor/vrender-core'; import type { EasingType, IGraphic, IGroup } from '@visactor/vrender-core'; -import { ACustomAnimate, AnimateMode } from '@visactor/vrender-core'; +import { ACustomAnimate } from '@visactor/vrender-animate'; import type { Dict } from '@visactor/vutils'; import { cloneDeep, interpolateString, isEqual, isValidNumber } from '@visactor/vutils'; import { traverseGroup } from '../../util'; diff --git a/packages/vrender-components/src/axis/base.ts b/packages/vrender-components/src/axis/base.ts index 963a541ff..aa3460e15 100644 --- a/packages/vrender-components/src/axis/base.ts +++ b/packages/vrender-components/src/axis/base.ts @@ -14,14 +14,14 @@ import type { IText } from '@visactor/vrender-core'; // eslint-disable-next-line no-duplicate-imports -import { graphicCreator } from '@visactor/vrender-core'; -import type { Dict } from '@visactor/vutils'; +import { graphicCreator, diff } from '@visactor/vrender-core'; +import type { Dict, IBounds } from '@visactor/vutils'; // eslint-disable-next-line no-duplicate-imports -import { abs, cloneDeep, get, isEmpty, isFunction, merge, pi } from '@visactor/vutils'; +import { abs, cloneDeep, get, isArray, isEmpty, isEqual, isFunction, merge, pi } from '@visactor/vutils'; import { AbstractComponent } from '../core/base'; import type { Point } from '../core/type'; import type { TagAttributes } from '../tag'; -import { createTextGraphicByType } from '../util'; +import { createTextGraphicByType, traverseGroup } from '../util'; import { DEFAULT_STATES } from '../constant'; import { AXIS_ELEMENT_NAME } from './constant'; import { DEFAULT_AXIS_THEME } from './config'; @@ -38,10 +38,15 @@ import type { import { Tag } from '../tag/tag'; import { getElMap, getVerticalCoord } from './util'; import { dispatchClickState, dispatchHoverState, dispatchUnHoverState } from '../util/interaction'; +import { AnimateComponent } from '../animation/animate-component'; +import { DefaultAxisAnimation } from './animate/config'; +import type { IBaseScale } from '@visactor/vscale'; -export abstract class AxisBase extends AbstractComponent> { +export abstract class AxisBase extends AnimateComponent> { name = 'axis'; + lastScale: IBaseScale; + // TODO: 组件整体统一起来 protected _innerView: IGroup; getInnerView() { @@ -74,6 +79,9 @@ export abstract class AxisBase extends AbstractCom private _lastHover: IGraphic; private _lastSelect: IGraphic; + // 用于动画diff + protected _newElementAttrMap: Dict; + protected abstract renderLine(container: IGroup): void; abstract isInValidValue(value: number): boolean; abstract getTickCoord(value: number): Point; @@ -113,7 +121,9 @@ export abstract class AxisBase extends AbstractCom */ getBoundsWithoutRender(attributes: Partial) { const currentAttribute = cloneDeep(this.attribute); - merge(this.attribute, attributes); + // scale 不能拷贝,它是一个实例,重新设置上去 + currentAttribute.scale = (this.attribute as any).scale; + this.attribute = attributes; const offscreenGroup = graphicCreator.group({ x: this.attribute.x, @@ -129,14 +139,20 @@ export abstract class AxisBase extends AbstractCom } protected render(): void { + this._prepare(); this._prevInnerView = this._innerView && getElMap(this._innerView); this.removeAllChild(true); this._innerView = graphicCreator.group({ x: 0, y: 0, pickable: false }); this.add(this._innerView); - this._renderInner(this._innerView); this._bindEvent(); + // 尝试执行动画 + this.runAnimation(); + } + + protected _prepare() { + this._prepareAnimate(DefaultAxisAnimation); } private _bindEvent() { @@ -553,6 +569,109 @@ export abstract class AxisBase extends AbstractCom return data; } + protected runAnimation() { + const lastScale = this.lastScale; + if ((this.attribute as any).scale) { + const scale = (this.attribute as any).scale; + this.lastScale = scale.clone(); + this.lastScale.range([0, 1]); + } + + if (this.attribute.animation && (this as any).applyAnimationState) { + // @ts-ignore + const currentInnerView = this.getInnerView(); + // @ts-ignore + const prevInnerView = this.getPrevInnerView(); + if (!prevInnerView) { + return; + } + + const animationConfig = this._animationConfig; + + this._newElementAttrMap = {}; + + // 遍历新的场景树,将新节点属性更新为旧节点 + // TODO: 目前只处理更新场景 + traverseGroup(currentInnerView, (el: IGraphic) => { + if ((el as IGraphic).type !== 'group' && el.id) { + const oldEl = prevInnerView[el.id]; + // 删除旧图元的动画 + el.setFinalAttributes(el.attribute); + if (oldEl) { + oldEl.release(); + // oldEl.stopAnimationState('enter'); + // oldEl.stopAnimationState('update'); + const oldAttrs = (oldEl as IGraphic).attribute; + const finalAttrs = el.getFinalAttribute(); + const diffAttrs: Record = diff(oldAttrs, finalAttrs); + + let hasDiff = Object.keys(diffAttrs).length > 0; + // TODO 如果入场会有fadeIn,则需要处理opacity(后续还需要考虑其他动画情况) + if ('opacity' in oldAttrs && finalAttrs.opacity !== oldAttrs.opacity) { + diffAttrs.opacity = finalAttrs.opacity ?? 1; + hasDiff = true; + } + + if (animationConfig.update && hasDiff) { + this._newElementAttrMap[el.id] = { + state: 'update', + node: el, + attrs: el.attribute + }; + const oldAttrs = (oldEl as IGraphic).attribute; + + (el as IGraphic).setAttributes(oldAttrs); + + el.applyAnimationState( + ['update'], + [ + { + name: 'update', + animation: { + selfOnly: true, + ...animationConfig.update, + type: 'axisUpdate', + customParameters: { + config: animationConfig.update, + diffAttrs, + lastScale + } + } + } + ] + ); + } + } else if (animationConfig.enter) { + this._newElementAttrMap[el.id] = { + state: 'enter', + node: el, + attrs: el.attribute + }; + + el.applyAnimationState( + ['enter'], + [ + { + name: 'enter', + animation: { + ...animationConfig.enter, + type: 'axisEnter', + selfOnly: true, + customParameters: { + config: animationConfig.enter, + lastScale, + getTickCoord: this.getTickCoord.bind(this) + } + } + } + ] + ); + } + } + }); + } + } + release(): void { super.release(); this._prevInnerView = null; diff --git a/packages/vrender-components/src/axis/line.ts b/packages/vrender-components/src/axis/line.ts index 7f19bd964..05637ab22 100644 --- a/packages/vrender-components/src/axis/line.ts +++ b/packages/vrender-components/src/axis/line.ts @@ -15,9 +15,9 @@ import { mixin, last as peek } from '@visactor/vutils'; -import { graphicCreator } from '@visactor/vrender-core'; +import { diff, graphicCreator } from '@visactor/vrender-core'; // eslint-disable-next-line no-duplicate-imports -import type { TextAlignType, IGroup, INode, IText, TextBaselineType } from '@visactor/vrender-core'; +import type { TextAlignType, IGroup, INode, IText, TextBaselineType, IGraphic } from '@visactor/vrender-core'; import type { SegmentAttributes } from '../segment'; // eslint-disable-next-line no-duplicate-imports import { Segment } from '../segment'; @@ -27,7 +27,7 @@ import type { LineAttributes, LineAxisAttributes, TitleAttributes, AxisItem, Tra import { AxisBase } from './base'; import { DEFAULT_AXIS_THEME } from './config'; import { AXIS_ELEMENT_NAME, DEFAULT_STATES, TopZIndex } from './constant'; -import { measureTextSize } from '../util'; +import { measureTextSize, traverseGroup } from '../util'; import { autoHide as autoHideFunc } from './overlap/auto-hide'; import { autoRotate as autoRotateFunc, getXAxisLabelAlign, getYAxisLabelAlign } from './overlap/auto-rotate'; import { autoLimit as autoLimitFunc } from './overlap/auto-limit'; diff --git a/packages/vrender-components/src/axis/register.ts b/packages/vrender-components/src/axis/register.ts index f278d7070..d24518374 100644 --- a/packages/vrender-components/src/axis/register.ts +++ b/packages/vrender-components/src/axis/register.ts @@ -7,12 +7,14 @@ import { registerRichtext, registerText } from '@visactor/vrender-kits'; +import { registerAxisAnimate } from '../animation/axis-animate'; function loadBasicAxis() { registerGroup(); registerLine(); registerRichtext(); registerText(); + registerAxisAnimate(); } export function loadLineAxisComponent() { diff --git a/packages/vrender-components/src/axis/type.ts b/packages/vrender-components/src/axis/type.ts index c61919450..356ce5540 100644 --- a/packages/vrender-components/src/axis/type.ts +++ b/packages/vrender-components/src/axis/type.ts @@ -54,6 +54,23 @@ export type AxisItem = { }; export interface AxisBaseAttributes extends IGroupGraphicAttribute { + /** + * 是否开启动画 + * @default false + */ + animation?: boolean; + /** + * 标签入场动画配置 + */ + animationEnter?: boolean; + /** + * 标签更新动画配置 + */ + animationUpdate?: boolean; + /** + * 标签离场动画配置 + */ + animationExit?: boolean; /** * 是否开启选中交互 * @default false diff --git a/packages/vrender-components/src/label-item/label-item.ts b/packages/vrender-components/src/label-item/label-item.ts index 34655db36..7f53b9c59 100644 --- a/packages/vrender-components/src/label-item/label-item.ts +++ b/packages/vrender-components/src/label-item/label-item.ts @@ -7,22 +7,21 @@ import type { ISymbolGraphicAttribute, IText } from '@visactor/vrender-core'; -import { ILineAttribute, InputText, ISymbolAttribute } from '@visactor/vrender-core'; import { AbstractComponent } from '../core/base'; import type { IStoryLabelItemAttrs } from './type'; import type { ComponentOptions } from '../interface'; -import { max, merge } from '@visactor/vutils'; +import { merge } from '@visactor/vutils'; export class StoryLabelItem extends AbstractComponent> { name: 'labelItem'; - private _line?: ILine; - private _symbolStart: ISymbol; - private _symbolEnd: ISymbol; - private _symbolStartOuter: ISymbol; - private _titleTop: IText; - private _titleBottom: IText; - private _titleTopPanel: IRect; - private _titleBottomPanel: IRect; + _line?: ILine; + _symbolStart: ISymbol; + _symbolEnd: ISymbol; + _symbolStartOuter: ISymbol; + _titleTop: IText; + _titleBottom: IText; + _titleTopPanel: IRect; + _titleBottomPanel: IRect; static defaultAttributes: Partial = { // 内容在X上的偏移量 @@ -231,112 +230,4 @@ export class StoryLabelItem extends AbstractComponent { - const scaleX = panel.attribute.scaleX; - panel.setAttributes({ scaleX: 0 }); - panel.animate().to({ scaleX }, duration, 'circInOut'); - }); - } else if (titlePanelType === 'stroke') { - [this._titleTopPanel, this._titleBottomPanel].forEach(panel => { - const b = panel.AABBBounds; - const totalLen = (b.width() + b.height()) * 2; - panel.setAttributes({ lineDash: [0, totalLen * 10] }); - panel.animate().to({ lineDash: [totalLen, totalLen * 10] }, duration, 'quadOut'); - }); - } - } - - disappearAnimate(animateConfig: { duration?: number; easing?: string; mode?: 'scale' | 'default' }) { - if (animateConfig.mode === 'scale') { - const { duration = 1000, easing = 'quadOut' } = animateConfig; - this.animate().to({ scaleX: 0, scaleY: 0 }, duration, easing as any); - } else { - const { duration = 1000, easing = 'quadOut' } = animateConfig; - this._line.animate().to({ clipRange: 0 }, duration, easing as any); - this._symbolStart - .animate() - .wait(duration / 2) - .to({ scaleX: 0, scaleY: 0 }, duration / 2, easing as any); - this._symbolEnd.animate().to({ scaleX: 0, scaleY: 0 }, duration, easing as any); - this._titleTop.animate().to({ dy: this._titleTop.AABBBounds.height() + 10 }, duration / 2, easing as any); - this._titleBottom - .animate() - .to({ dy: -(10 + this._titleBottom.AABBBounds.height()) }, duration / 2, easing as any); - this._symbolStartOuter - .animate() - .wait(duration / 2) - .to({ clipRange: 0 }, duration / 2, easing as any); - this._titleTopPanel.animate().to({ scaleX: 0 }, duration, 'circInOut'); - this._titleBottomPanel.animate().to({ scaleX: 0 }, duration, 'circInOut'); - } - } } diff --git a/packages/vrender-components/src/label/animate/animate.ts b/packages/vrender-components/src/label/animate/animate.ts index 1afc45a2f..f4d2d0418 100644 --- a/packages/vrender-components/src/label/animate/animate.ts +++ b/packages/vrender-components/src/label/animate/animate.ts @@ -1,124 +1,5 @@ -import type { IText, ITextGraphicAttribute, EasingType } from '@visactor/vrender-core'; -import { IncreaseCount } from '@visactor/vrender-core'; -import type { ILabelAnimation, ILabelUpdateAnimation, ILabelUpdateChannelAnimation, LabelContent } from '../type'; -import { array, isArray, isEmpty, isValidNumber } from '@visactor/vutils'; - -const fadeIn = (textAttribute: ITextGraphicAttribute = {}) => { - return { - from: { - opacity: 0, - fillOpacity: 0, - strokeOpacity: 0 - }, - to: { - opacity: textAttribute.opacity ?? 1, - fillOpacity: textAttribute.fillOpacity ?? 1, - strokeOpacity: textAttribute.strokeOpacity ?? 1 - } - }; -}; - -const fadeOut = (textAttribute: ITextGraphicAttribute = {}) => { - return { - from: { - opacity: textAttribute.opacity ?? 1, - fillOpacity: textAttribute.fillOpacity ?? 1, - strokeOpacity: textAttribute.strokeOpacity ?? 1 - }, - to: { - opacity: 0, - fillOpacity: 0, - strokeOpacity: 0 - } - }; -}; - -const animationEffects = { fadeIn, fadeOut }; - -export function getAnimationAttributes( - textAttribute: ITextGraphicAttribute, - type: 'fadeIn' | 'fadeOut' -): { - from: any; - to: any; -} { - return animationEffects[type]?.(textAttribute) ?? { from: {}, to: {} }; -} - -export function updateAnimation( - prev: LabelContent['text'], - next: LabelContent['text'], - animationConfig: ILabelUpdateAnimation | ILabelUpdateChannelAnimation[] -) { - const changeAttributes = (prev: LabelContent['text'], next: LabelContent['text']) => { - const changed = {}; - for (const key in next.attribute) { - if (prev.attribute[key] !== next.attribute[key]) { - changed[key] = next.attribute[key]; - } - } - return changed; - }; - - if (!isArray(animationConfig)) { - const { duration, easing, increaseEffect = true } = animationConfig; - - prev.animate().to(changeAttributes(prev, next), duration, easing); - if (increaseEffect && prev.type === 'text' && next.type === 'text') { - playIncreaseCount(prev as IText, next as IText, duration, easing); - } - return; - } - - animationConfig.forEach(cfg => { - const { duration, easing, increaseEffect = true, channel } = cfg; - const { to } = update(prev, next, channel, cfg.options); - if (!isEmpty(to)) { - prev.animate().to(changeAttributes(prev, next), duration, easing); - } - - if (increaseEffect && prev.type === 'text' && next.type === 'text') { - playIncreaseCount(prev as IText, next as IText, duration, easing); - } - }); -} - -export const update = ( - prev: LabelContent['text'], - next: LabelContent['text'], - channel?: string[], - options?: ILabelUpdateChannelAnimation['options'] -) => { - const from = Object.assign({}, prev.attribute); - const to = Object.assign({}, next.attribute); - array(options?.excludeChannels).forEach(key => { - delete to[key]; - }); - Object.keys(to).forEach(key => { - if (channel && !channel.includes(key)) { - delete to[key]; - } - }); - return { from, to }; -}; - -export function playIncreaseCount(prev: IText, next: IText, duration: number, easing: EasingType) { - if ( - prev.attribute.text !== next.attribute.text && - isValidNumber(Number(prev.attribute.text) * Number(next.attribute.text)) - ) { - prev - .animate() - .play( - new IncreaseCount( - { text: prev.attribute.text as string }, - { text: next.attribute.text as string }, - duration, - easing - ) - ); - } -} +import type { EasingType } from '@visactor/vrender-core'; +import type { ILabelAnimation } from '../type'; export const DefaultLabelAnimation: ILabelAnimation = { mode: 'same-time', diff --git a/packages/vrender-components/src/label/arc.ts b/packages/vrender-components/src/label/arc.ts index 3d0879474..49b14d068 100644 --- a/packages/vrender-components/src/label/arc.ts +++ b/packages/vrender-components/src/label/arc.ts @@ -276,7 +276,7 @@ export class ArcLabel extends LabelBase { data.forEach((d, index) => { const currentMark = this._idToGraphic.get(d.id); - const graphicAttribute = currentMark.attribute as IArcGraphicAttribute; + const graphicAttribute = currentMark.getAttributes(true) as IArcGraphicAttribute; const center = { x: graphicAttribute?.x ?? 0, y: graphicAttribute?.y ?? 0 }; if (!isNil(data[index]) && !isNil(textBoundsArray[index])) { const item = data[index] ? data[index] : null; @@ -502,8 +502,8 @@ export class ArcLabel extends LabelBase { let maxRadius = 0; currentMarks.forEach((currentMark: IGraphic) => { - if ((currentMark.attribute as IArcGraphicAttribute).outerRadius > maxRadius) { - maxRadius = (currentMark.attribute as IArcGraphicAttribute).outerRadius; + if ((currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius > maxRadius) { + maxRadius = (currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius; } }); @@ -767,8 +767,8 @@ export class ArcLabel extends LabelBase { let maxRadius = 0; currentMarks.forEach((currentMark: IGraphic) => { - if ((currentMark.attribute as IArcGraphicAttribute).outerRadius > maxRadius) { - maxRadius = (currentMark.attribute as IArcGraphicAttribute).outerRadius; + if ((currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius > maxRadius) { + maxRadius = (currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius; } }); @@ -821,8 +821,8 @@ export class ArcLabel extends LabelBase { let maxRadius = 0; currentMarks.forEach((currentMark: IGraphic) => { - if ((currentMark.attribute as IArcGraphicAttribute).outerRadius > maxRadius) { - maxRadius = (currentMark.attribute as IArcGraphicAttribute).outerRadius; + if ((currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius > maxRadius) { + maxRadius = (currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius; } }); @@ -902,8 +902,8 @@ export class ArcLabel extends LabelBase { let maxRadius = 0; currentMarks.forEach((currentMark: IGraphic) => { - if ((currentMark.attribute as IArcGraphicAttribute).outerRadius > maxRadius) { - maxRadius = (currentMark.attribute as IArcGraphicAttribute).outerRadius; + if ((currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius > maxRadius) { + maxRadius = (currentMark.getAttributes(true) as IArcGraphicAttribute).outerRadius; } }); @@ -1017,7 +1017,7 @@ export class ArcLabel extends LabelBase { } if (baseMark.type === 'arc3d' && baseMark) { - const { beta, x, y } = baseMark.attribute; + const { beta, x, y } = baseMark.getAttributes(true); lineGraphic.setAttributes({ beta, anchor3d: [x, y] diff --git a/packages/vrender-components/src/label/base.ts b/packages/vrender-components/src/label/base.ts index 3352e17ad..67f23a849 100644 --- a/packages/vrender-components/src/label/base.ts +++ b/packages/vrender-components/src/label/base.ts @@ -31,7 +31,6 @@ import { isObject, pointInRect } from '@visactor/vutils'; -import { AbstractComponent } from '../core/base'; import type { PointLocationCfg } from '../core/type'; import { labelSmartInvert, contrastAccessibilityChecker, smartInvertStrategy } from '../util/label-smartInvert'; import { createTextGraphicByType, getMarksByName, getNoneGroupMarksByName, traverseGroup } from '../util'; @@ -42,7 +41,6 @@ import { bitmapTool, boundToRange, canPlace, clampText, place } from './overlap' import type { BaseLabelAttrs, OverlapAttrs, - ILabelAnimation, LabelItem, SmartInvertAttrs, ILabelEnterAnimation, @@ -52,14 +50,15 @@ import type { ShiftYStrategy, Strategy } from './type'; -import { DefaultLabelAnimation, getAnimationAttributes, updateAnimation } from './animate/animate'; +import { DefaultLabelAnimation } from './animate/animate'; import { connectLineBetweenBounds, getPointsOfLineArea } from './util'; import type { ComponentOptions } from '../interface'; import { loadLabelComponent } from './register'; import { shiftY } from './overlap/shiftY'; +import { AnimateComponent } from '../animation/animate-component'; loadLabelComponent(); -export class LabelBase extends AbstractComponent { +export class LabelBase extends AnimateComponent { name = 'label'; protected _baseMarks?: IGraphic[]; @@ -159,8 +158,8 @@ export class LabelBase extends AbstractComponent { }; } - if (baseMark && baseMark.attribute.fill) { - lineGraphic.setAttribute('stroke', baseMark.attribute.fill); + if (baseMark && baseMark.getAttributes(true).fill) { + lineGraphic.setAttribute('stroke', baseMark.getAttributes(true).fill); } if (this.attribute.line && !isEmpty(this.attribute.line.style)) { @@ -176,6 +175,14 @@ export class LabelBase extends AbstractComponent { if (isNil(this._idToGraphic) || (this._isCollectionBase && isNil(this._idToPoint))) { return; } + // 如果有动画的话,需要先设置入场的最终属性,否则无法计算放重叠、反色之类的逻辑 + const markAttributeList: any[] = []; + if (this._enableAnimation !== false) { + this._baseMarks.forEach(mark => { + markAttributeList.push(mark.attribute); + mark.initAttributes(mark.getAttributes(true)); + }); + } const { overlap, smartInvert, dataFilter, customLayoutFunc, customOverlapFunc } = this.attribute; let data = this.attribute.data; @@ -240,6 +247,12 @@ export class LabelBase extends AbstractComponent { } this._renderLabels(labels); + + if (this._enableAnimation !== false) { + this._baseMarks.forEach((mark, index) => { + mark.initAttributes(markAttributeList[index]); + }); + } } private _bindEvent(target: IGraphic) { @@ -417,26 +430,7 @@ export class LabelBase extends AbstractComponent { } } - if (this.attribute.animation !== false) { - const { animation, animationEnter, animationExit, animationUpdate } = this.attribute; - const animationCfg = isObject(animation) ? animation : {}; - this._animationConfig = { - enter: animationEnter !== false ? merge({}, DefaultLabelAnimation, animationCfg, animationEnter ?? {}) : false, - exit: animationExit !== false ? merge({}, DefaultLabelAnimation, animationCfg, animationExit ?? {}) : false, - update: - animationUpdate !== false - ? isArray(animationUpdate) - ? animationUpdate - : merge({}, DefaultLabelAnimation, animationCfg, animationUpdate ?? {}) - : false - }; - } else { - this._animationConfig = { - enter: false, - exit: false, - update: false - }; - } + this._prepareAnimate(DefaultLabelAnimation); } protected getRelatedGraphic(item: LabelItem) { @@ -453,12 +447,13 @@ export class LabelBase extends AbstractComponent { continue; } + const graphicAttribute = baseMark.getAttributes(true); const labelAttribute = { fill: this._isCollectionBase - ? isArray(baseMark.attribute.stroke) - ? baseMark.attribute.stroke.find(entry => !!entry && entry !== true) - : baseMark.attribute.stroke - : baseMark.attribute.fill, + ? isArray(graphicAttribute.stroke) + ? graphicAttribute.stroke.find(entry => !!entry && entry !== true) + : graphicAttribute.stroke + : graphicAttribute.fill, ...textStyle, ...textData }; @@ -689,7 +684,7 @@ export class LabelBase extends AbstractComponent { hasPlace = place( bmpTool, bitmap, - strategy[j], + (strategy as any)[j], this.attribute, text as Text, this._isCollectionBase @@ -750,6 +745,12 @@ export class LabelBase extends AbstractComponent { ): IBoundsLike { if (graphic) { if (graphic.attribute.visible !== false) { + // TODO 这里有些hack 如果有动画,需要使用finalAttribute + if (graphic.context?.animationState) { + const clonedGraphic = graphic.clone(); + Object.assign(clonedGraphic.attribute, graphic.getAttributes(true)); + return clonedGraphic.AABBBounds; + } return graphic.AABBBounds; } const { x, y } = graphic.attribute; @@ -785,19 +786,20 @@ export class LabelBase extends AbstractComponent { if (showLabelLine) { labelLine = this._createLabelLine(text as IText, relatedGraphic); } + const currentLabel = labelLine ? { text, labelLine } : { text }; if (syncState) { - this.updateStatesOfLabels([labelLine ? { text, labelLine } : { text }], relatedGraphic.currentStates ?? []); + this.updateStatesOfLabels([currentLabel], relatedGraphic.currentStates ?? []); } if (state === 'enter') { texts.push(text); - currentTextMap.set(textKey, labelLine ? { text, labelLine } : { text }); - this._addLabel({ text, labelLine }, texts, labelLines, index); + currentTextMap.set(textKey, currentLabel); + this._addLabel(currentLabel, texts, labelLines, index); } else if (state === 'update') { const prevLabel = prevTextMap.get(textKey); prevTextMap.delete(textKey); currentTextMap.set(textKey, prevLabel); - this._updateLabel(prevLabel, { text, labelLine }); + this._updateLabel(prevLabel, currentLabel); } }); @@ -806,6 +808,70 @@ export class LabelBase extends AbstractComponent { this._graphicToText = currentTextMap; } + protected runEnterAnimation(text: IText | IRichText, labelLine?: ILine) { + if (this._enableAnimation === false || !this._animationConfig.enter) { + return; + } + + const relatedGraphic = this.getRelatedGraphic(text.attribute); + const { enter } = this._animationConfig; + + [text, labelLine].filter(Boolean).forEach(item => + item.applyAnimationState( + ['enter'], + [ + { + name: 'enter', + animation: { + ...enter, + type: 'labelEnter', + selfOnly: true, + customParameters: { + relatedGraphic, + relatedGraphics: this._idToGraphic, + config: { + ...enter, + // 默认fadeIn + type: item === text ? (enter as any).type : 'fadeIn' + } + } + } + } + ] + ) + ); + } + + protected _runUpdateAnimation(prevLabel: LabelContent, currentLabel: LabelContent) { + const { text: prevText, labelLine: prevLabelLine } = prevLabel; + const { text: curText, labelLine: curLabelLine } = currentLabel; + + prevText.applyAnimationState( + ['update'], + [ + { + name: 'update', + animation: { + type: 'labelUpdate', + ...this._animationConfig.update, + customParameters: { + prevText, + curText, + prevLabelLine, + curLabelLine + } + } + } + ] + ); + } + + protected _syncStateWithRelatedGraphic(relatedGraphic: IGraphic) { + if (this.attribute.syncState && relatedGraphic) { + relatedGraphic.on('afterStateUpdate', this._handleRelatedGraphicSetState); + } + } + protected _addLabel( label: LabelContent, texts?: LabelContent['text'][], @@ -817,61 +883,27 @@ export class LabelBase extends AbstractComponent { const relatedGraphic = this.getRelatedGraphic(text.attribute); this._syncStateWithRelatedGraphic(relatedGraphic); - if (this._enableAnimation !== false && this._animationConfig.enter !== false) { - if (relatedGraphic) { - const { from, to } = getAnimationAttributes(text.attribute, 'fadeIn'); - if (text) { - this.add(text); - } - - if (labelLine) { - labelLines.push(labelLine); - this.add(labelLine); - } - - // enter的时长如果不是大于0,那么直接跳过动画 - this._animationConfig.enter.duration > 0 && - relatedGraphic.once('animate-bind', a => { - // text和labelLine共用一个from - text.setAttributes(from); - labelLine && labelLine.setAttributes(from); - const listener = this._afterRelatedGraphicAttributeUpdate( - text, - texts, - labelLine, - labelLines, - index, - relatedGraphic, - to, - this._animationConfig.enter as ILabelEnterAnimation - ); - relatedGraphic.on('afterAttributeUpdate', listener); - }); - } - } else { - if (text) { - this.add(text); - } - if (labelLine) { - this.add(labelLine); - } + if (text) { + this.add(text); } + if (labelLine) { + this.add(labelLine); + } + + this.runEnterAnimation(text, labelLine); } protected _updateLabel(prevLabel: LabelContent, currentLabel: LabelContent) { const { text: prevText, labelLine: prevLabelLine } = prevLabel; const { text: curText, labelLine: curLabelLine } = currentLabel; - if (this._enableAnimation !== false && this._animationConfig.update !== false) { - const { duration, easing } = this._animationConfig.update; - updateAnimation(prevText, curText, this._animationConfig.update); - if (prevLabelLine && curLabelLine) { - prevLabel.labelLine.animate().to(curLabelLine.attribute, duration, easing); - } - } else { + + if (this._enableAnimation === false || this._animationConfig.update === false) { prevLabel.text.setAttributes(curText.attribute as any); if (prevLabelLine && curLabelLine) { prevLabel.labelLine.setAttributes(curLabelLine.attribute); } + } else { + this._runUpdateAnimation(prevLabel, currentLabel); } } @@ -884,14 +916,37 @@ export class LabelBase extends AbstractComponent { }; if (this._enableAnimation !== false && this._animationConfig.exit !== false) { - const { duration, easing } = this._animationConfig.exit; textMap.forEach(label => { - label.text - ?.animate() - .to(getAnimationAttributes(label.text.attribute, 'fadeOut').to, duration, easing) - .onEnd(() => { + label.text.applyAnimationState( + ['exit'], + [ + { + name: 'exit', + animation: { + ...this._animationConfig.exit, + type: 'fadeOut' + } + } + ], + () => { removeLabelAndLine(label); - }); + } + ); + label.labelLine?.applyAnimationState( + ['exit'], + [ + { + name: 'exit', + animation: { + ...this._animationConfig.exit, + type: 'fadeOut' + } + } + ], + () => { + // removeLabelAndLine(label); + } + ); }); } else { textMap.forEach(label => { @@ -926,100 +981,6 @@ export class LabelBase extends AbstractComponent { } }; - protected _syncStateWithRelatedGraphic(relatedGraphic: IGraphic) { - if (this.attribute.syncState && relatedGraphic) { - relatedGraphic.on('afterAttributeUpdate', this._handleRelatedGraphicSetState); - } - } - - // 默认labelLine和text共用相同动画属性 - protected _afterRelatedGraphicAttributeUpdate( - text: IText | IRichText, - texts: (IText | IRichText)[], - labelLine: ILine, - labelLines: ILine[], - index: number, - relatedGraphic: IGraphic, - to: any, - { mode, duration, easing, delay }: ILabelAnimation - ) { - // TODO: 跟随动画 - const listener = (event: any) => { - const { detail } = event; - if (!detail) { - return {}; - } - const step = detail.animationState?.step; - const isValidAnimateState = - detail.type === AttributeUpdateType.ANIMATE_UPDATE && - step && - // 不是第一个wait - !(step.type === 'wait' && step.prev?.type == null); - - if (!isValidAnimateState) { - return {}; - } - // const prevStep = step.prev; - // if (prevStep && prevStep.type === 'wait' && prevStep.prev?.type == null) { - // delay = delay ?? step.position; - // } - if (detail.type === AttributeUpdateType.ANIMATE_END) { - text.setAttributes(to); - labelLine && labelLine.setAttributes(to); - return; - } - - const onStart = () => { - if (relatedGraphic) { - relatedGraphic.onAnimateBind = undefined; - relatedGraphic.removeEventListener('afterAttributeUpdate', listener); - } - }; - - switch (mode) { - case 'after': - // 3. 当前关联图元的动画播放结束后 - if (detail.animationState.end) { - text.animate({ onStart }).wait(delay).to(to, duration, easing); - labelLine && labelLine.animate().wait(delay).to(to, duration, easing); - } - break; - case 'after-all': - // 2. 所有完成后才开始; - if (index === texts.length - 1) { - if (detail.animationState.end) { - texts.forEach(t => { - t.animate({ onStart }).wait(delay).to(to, duration, easing); - }); - labelLines.forEach(t => { - t.animate().wait(delay).to(to, duration, easing); - }); - } - } - break; - case 'same-time': - default: - if (this._isCollectionBase) { - const point = this._idToPoint.get((text.attribute as LabelItem).id); - if ( - point && - (!text.animates || !text.animates.has('label-animate')) && - relatedGraphic.containsPoint(point.x, point.y, IContainPointMode.LOCAL, this.stage?.getPickerService()) - ) { - text.animate({ onStart }).wait(delay).to(to, duration, easing); - labelLine && labelLine.animate().wait(delay).to(to, duration, easing); - } - } else if (detail.animationState.isFirstFrameOfStep) { - text.animate({ onStart }).wait(delay).to(to, duration, easing); - labelLine && labelLine.animate().wait(delay).to(to, duration, easing); - } - - break; - } - }; - return listener; - } - protected _smartInvert(labels: (IText | IRichText)[]) { const option = (isObject(this.attribute.smartInvert) ? this.attribute.smartInvert : {}) as SmartInvertAttrs; const { textType, contrastRatiosThreshold, alternativeColors, mode, interactInvertType } = option; @@ -1048,7 +1009,7 @@ export class LabelBase extends AbstractComponent { * similarBase(智能反色的补色), * null(不执行智能反色,保持fill设置的颜色) * */ - let backgroundColor = baseMark.attribute.fill as IColor; + let backgroundColor = baseMark.getAttributes(true).fill as IColor; let foregroundColor = label.attribute.fill as IColor; if (isObject(backgroundColor) && backgroundColor.gradient) { diff --git a/packages/vrender-components/src/label/register.ts b/packages/vrender-components/src/label/register.ts index dceb201e5..d0630b15c 100644 --- a/packages/vrender-components/src/label/register.ts +++ b/packages/vrender-components/src/label/register.ts @@ -1,8 +1,14 @@ import { registerGroup, registerLine, registerRichtext, registerText } from '@visactor/vrender-kits'; +import { registerLabelAnimate } from '../animation/label-animate'; export function loadLabelComponent() { registerGroup(); registerText(); registerRichtext(); registerLine(); + registerLabelAnimate(); +} + +export function loadLabelAnimate() { + registerLabelAnimate(); } diff --git a/packages/vrender-components/src/marker/config.ts b/packages/vrender-components/src/marker/config.ts index edb5e525c..d7ed59672 100644 --- a/packages/vrender-components/src/marker/config.ts +++ b/packages/vrender-components/src/marker/config.ts @@ -449,25 +449,7 @@ export const DEFAULT_MARK_POINT_THEME = { itemContent: { type: 'text', position: 'middle', - refX: 10, - symbolStyle: { - symbolType: 'star', - fill: 'rgb(48, 115, 242)', - fillOpacity: 0.8, - size: 20 - }, - textStyle: { - dx: 0, - dy: 0 - }, - imageStyle: { - width: 80, - height: 80 - }, - richTextStyle: { - width: 100, - height: 100 - } + refX: 10 } }; diff --git a/packages/vrender-components/src/marker/point.ts b/packages/vrender-components/src/marker/point.ts index 1f77dea05..ae5cd5032 100644 --- a/packages/vrender-components/src/marker/point.ts +++ b/packages/vrender-components/src/marker/point.ts @@ -109,11 +109,9 @@ export class MarkPoint extends Marker { refX = 0, refY = 0, refAngle = 0, - textStyle = {}, - richTextStyle = {}, - imageStyle = {}, + style, position: positionType = IMarkPointItemPosition.middle - } = itemContent; + } = itemContent as any; const { state } = this.attribute as MarkPointAttrs; const lineEndAngle = this._line?.getEndAngle() || 0; const itemRefOffsetX = refX * Math.cos(lineEndAngle) + refY * Math.cos(lineEndAngle - Math.PI / 2); @@ -122,7 +120,7 @@ export class MarkPoint extends Marker { const offsetX = newItemPosition.x - newPosition.x; const offsetY = newItemPosition.y - newPosition.y; item.setAttributes({ - ...(textStyle as TagAttributes), + ...(style as TagAttributes), textStyle: { ...this.getTextAlignAttr( autoRotate, @@ -131,25 +129,25 @@ export class MarkPoint extends Marker { lineEndAngle, itemContent.position ?? ('end' as keyof typeof IMarkPointItemPosition) ), - ...textStyle.textStyle + ...style.textStyle }, state: { panel: merge({}, DEFAULT_STATES, state?.textBackground), - text: merge({}, DEFAULT_STATES, state?.text) + text: merge({}, DEFAULT_STATES, state?.itemContent) } } as any); } else if (itemType === 'richText') { item.setAttributes({ - dx: this.getItemDx(item, positionType, richTextStyle) + (richTextStyle.dx || 0), - dy: this.getItemDy(item, positionType, richTextStyle) + (richTextStyle.dy || 0) + dx: this.getItemDx(item, positionType, style) + (style.dx || 0), + dy: this.getItemDy(item, positionType, style) + (style.dy || 0) }); - item.states = merge({}, DEFAULT_STATES, state?.richText); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } else if (itemType === 'image') { item.setAttributes({ - dx: this.getItemDx(item, positionType, imageStyle) + (imageStyle.dx || 0), - dy: this.getItemDy(item, positionType, imageStyle) + (imageStyle.dy || 0) + dx: this.getItemDx(item, positionType, style) + (style.dx || 0), + dy: this.getItemDy(item, positionType, style) + (style.dy || 0) }); - item.states = merge({}, DEFAULT_STATES, state?.image); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } const itemAngle = isPostiveXAxis(lineEndAngle) ? lineEndAngle : lineEndAngle - Math.PI; @@ -193,37 +191,38 @@ export class MarkPoint extends Marker { protected initItem(itemContent: IItemContent, newPosition: Point, newItemPosition: Point) { const { state } = this.attribute as MarkPointAttrs; - const { type = 'text', symbolStyle, richTextStyle, imageStyle, renderCustomCallback } = itemContent; + const { type = 'text', style, renderCustomCallback } = itemContent as any; let item: ISymbol | Tag | IImage | IRichText | IGroup; if (type === 'symbol') { item = graphicCreator.symbol({ ...newItemPosition, - ...symbolStyle + ...style }); - item.states = merge({}, DEFAULT_STATES, state?.symbol); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } else if (type === 'text') { item = new Tag({ ...newItemPosition, state: { panel: merge({}, DEFAULT_STATES, state?.textBackground), - text: merge({}, DEFAULT_STATES, state?.text) + text: merge({}, DEFAULT_STATES, state?.itemContent) } }); } else if (type === 'richText') { + // 兼容老逻辑 item = graphicCreator.richtext({ ...newItemPosition, - ...richTextStyle + ...style }); - item.states = merge({}, DEFAULT_STATES, state?.richText); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } else if (type === 'image') { item = graphicCreator.image({ ...newItemPosition, - ...imageStyle + ...style }); - item.states = merge({}, DEFAULT_STATES, state?.image); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } else if (type === 'custom' && renderCustomCallback) { item = renderCustomCallback(); - item.states = merge({}, DEFAULT_STATES, state?.customMark); + item.states = merge({}, DEFAULT_STATES, state?.itemContent); } item.name = `mark-point-${type}`; this.setItemAttributes(item, itemContent, newPosition, newItemPosition, type); @@ -461,7 +460,7 @@ export class MarkPoint extends Marker { visible: targetItemvisible = false, size: targetSymbolSize } = targetSymbol; - const targetSize = targetItemvisible ? targetSymbolStyle.size ?? targetSymbolSize ?? 20 : 0; + const targetSize = targetItemvisible ? (targetSymbolStyle.size ?? targetSymbolSize ?? 20) : 0; let targetOffsetAngle; if (itemLine.type === 'type-do') { diff --git a/packages/vrender-components/src/marker/register.ts b/packages/vrender-components/src/marker/register.ts index d27809dee..9c3aaa0c9 100644 --- a/packages/vrender-components/src/marker/register.ts +++ b/packages/vrender-components/src/marker/register.ts @@ -8,29 +8,35 @@ import { } from '@visactor/vrender-kits'; import { loadTagComponent } from '../tag/register'; import { loadArcSegmentComponent, loadSegmentComponent } from '../segment/register'; +import { registerAnimate } from '@visactor/vrender-animate'; function loadBaseMarker() { registerGroup(); loadTagComponent(); + registerAnimate(); } export function loadMarkLineComponent() { loadBaseMarker(); loadSegmentComponent(); + registerAnimate(); } export function loadMarkArcLineComponent() { loadBaseMarker(); loadArcSegmentComponent(); + registerAnimate(); } export function loadMarkAreaComponent() { loadBaseMarker(); registerPolygon(); + registerAnimate(); } export function loadMarkArcAreaComponent() { loadBaseMarker(); registerArc(); + registerAnimate(); } export function loadMarkPointComponent() { @@ -40,4 +46,5 @@ export function loadMarkPointComponent() { registerSymbol(); registerImage(); registerLine(); + registerAnimate(); } diff --git a/packages/vrender-components/src/marker/type.ts b/packages/vrender-components/src/marker/type.ts index 5247d265d..af44ea090 100644 --- a/packages/vrender-components/src/marker/type.ts +++ b/packages/vrender-components/src/marker/type.ts @@ -285,34 +285,24 @@ export type MarkPointState = { * 设置线终点图形在特定状态下的样式 */ lineEndSymbol?: State>; - /** - * 设置标注图形在特定状态下的样式 - */ - symbol?: State>; - /** - * 设置标注图形在特定状态下的样式 - */ - image?: State>; - /** - * 设置标签在特定状态下的样式 - */ - text?: State>; /** * 设置标签背景区块在特定状态下的样式 */ textBackground?: State>; - /** - * 设置富文本在特定状态下的样式 - */ - richText?: State>; - /** - * 设置自定义标注图形在特定状态下的样式 - */ - customMark?: State>; /** * 设置目标元素在特定状态下的样式 */ targetItem?: State>; + /** + * 设置content在特定状态下的样式 + * 等价于原来的 symbol | image | text | richText | customMark + */ + itemContent?: State< + | Partial + | Partial + | Partial + | Partial + >; }; export type MarkerLineLabelAttrs = { @@ -494,9 +484,9 @@ export type MarkArcAreaAttrs = MarkerAttrs & { export type IItemContent = IMarkRef & { /** * 标注类型 - * Tips: 保留'richText'与之前的定义做兼容 + * Tips: 不保留'richText', 在vchart层做兼容 */ - type?: 'symbol' | 'text' | 'image' | 'richText' | 'custom'; + type?: 'symbol' | 'text' | 'image' | 'custom'; /** * 设置标注的位置 */ @@ -510,22 +500,9 @@ export type IItemContent = IMarkRef & { */ offsetY?: number; /** - * type为symbol时, symbol的样式 - */ - symbolStyle?: ISymbolGraphicAttribute; - /** - * type为image时, image的样式 - */ - imageStyle?: IImageGraphicAttribute; - /** - * type为text时, text的配置 - * 'text'类型的ItemContent新增三种子类型:'text','rich','html'。配置在textStyle.type上,继承自TagAttributes。 - */ - textStyle?: IMarkLabel; - /** - * type为rich text时, rich text的样式 + * item样式 */ - richTextStyle?: IRichTextGraphicAttribute; + style?: ISymbolGraphicAttribute | IImageGraphicAttribute | IMarkLabel; /** * type为custom时,允许以callback的方式传入需要render的item */ diff --git a/packages/vrender-components/src/poptip/poptip.ts b/packages/vrender-components/src/poptip/poptip.ts index 3bc2aa8b8..2bc8873bf 100644 --- a/packages/vrender-components/src/poptip/poptip.ts +++ b/packages/vrender-components/src/poptip/poptip.ts @@ -2,7 +2,6 @@ * @description PopTip组件 */ import { - InputText, type IGraphic, type IGroup, type IRect, @@ -560,40 +559,4 @@ export class PopTip extends AbstractComponent> { }; } } - - appearAnimate(animateConfig: { duration?: number; easing?: string; wave?: number }) { - // 基准时间,line[0, 500], point[100, 600] 100 onebyone, pointNormal[600, 1000] 90+90 onebyone, activeLine[500, 700] - // line和activeLine的clipRange - const { duration = 1000, easing = 'quadOut' } = animateConfig; - this.setAttributes({ scaleX: 0, scaleY: 0 }); - this.animate().to({ scaleX: 1, scaleY: 1 }, (duration / 3) * 2, easing as any); - this.titleShape && - this.titleShape - .animate() - .play(new InputText({ text: '' }, { text: this.titleShape.attribute.text as string }, duration, easing as any)); - this.contentShape && - this.contentShape - .animate() - .play( - new InputText({ text: '' }, { text: this.contentShape.attribute.text as string }, duration, easing as any) - ); - - // 摇摆 - if (animateConfig.wave) { - const dur = duration / 6; - this.group - .animate() - .to({ angle: animateConfig.wave }, dur, easing as any) - .to({ angle: -animateConfig.wave }, dur * 2, easing as any) - .to({ angle: animateConfig.wave }, dur * 2, easing as any) - .to({ angle: 0 }, dur, easing as any); - } - } - - disappearAnimate(animateConfig: { duration?: number; easing?: string }) { - // 基准时间,line[0, 500], point[100, 600] 100 onebyone, pointNormal[600, 1000] 90+90 onebyone, activeLine[500, 700] - // line和activeLine的clipRange - const { duration = 1000, easing = 'quadOut' } = animateConfig; - this.animate().to({ scaleX: 0, scaleY: 0 }, duration, easing as any); - } } diff --git a/packages/vrender-components/src/weather/weather-box.ts b/packages/vrender-components/src/weather/weather-box.ts index 722a40c64..ffe7ef2e1 100644 --- a/packages/vrender-components/src/weather/weather-box.ts +++ b/packages/vrender-components/src/weather/weather-box.ts @@ -2,7 +2,8 @@ import { AbstractComponent } from '../core/base'; import type { IWeatherBoxAttrs } from './type'; import type { ComponentOptions } from '../interface'; import { merge } from '@visactor/vutils'; -import { Animate, DefaultTimeline, type IGroup, type ISymbol, type ITimeline } from '@visactor/vrender-core'; +import type { IGroup, ISymbol, ITimeline } from '@visactor/vrender-core'; +import { Animate, DefaultTimeline } from '@visactor/vrender-animate'; // todo 后续可能做成有随机数种子的伪随机,这样可以保证每次都生成一样的随机数 function random() { @@ -65,7 +66,7 @@ export class WeatherBox extends AbstractComponent> { constructor(attributes: IWeatherBoxAttrs, options?: ComponentOptions) { super(options?.skipDefault ? attributes : merge({}, WeatherBox.defaultAttributes, attributes)); - this.timeline = options?.timeline ?? new DefaultTimeline(); + this.timeline = options?.timeline ?? (new DefaultTimeline() as any); } protected render(): void { diff --git a/packages/vrender-components/tsconfig.json b/packages/vrender-components/tsconfig.json index cf04cc05e..2b97d19e4 100644 --- a/packages/vrender-components/tsconfig.json +++ b/packages/vrender-components/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../vrender-kits" + }, + { + "path": "../vrender-animate" } ] } diff --git a/packages/vrender-core/__tests__/browser/vite.config.ts b/packages/vrender-core/__tests__/browser/vite.config.ts index ad1542445..fe23d9382 100644 --- a/packages/vrender-core/__tests__/browser/vite.config.ts +++ b/packages/vrender-core/__tests__/browser/vite.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ '@visactor/vrender-core': path.resolve(__dirname, '../../../vrender-core/src/index.ts'), '@visactor/vrender-kits': path.resolve(__dirname, '../../../vrender-kits/src/index.ts'), '@visactor/vrender-components': path.resolve(__dirname, '../../../vrender-components/src/index.ts'), + '@visactor/vrender-animate': path.resolve(__dirname, '../../../vrender-animate/src/index.ts'), util: 'rollup-plugin-node-polyfills/polyfills/util' } }, diff --git a/packages/vrender-core/package.json b/packages/vrender-core/package.json index 77e2535a8..9f7352128 100644 --- a/packages/vrender-core/package.json +++ b/packages/vrender-core/package.json @@ -30,7 +30,7 @@ }, "dependencies": { "color-convert": "2.0.1", - "@visactor/vutils": "~0.19.5" + "@visactor/vutils": "1.0.4" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-core/src/animate/Ticker/default-ticker.ts b/packages/vrender-core/src/animate/Ticker/default-ticker.ts deleted file mode 100644 index 4bf64ece7..000000000 --- a/packages/vrender-core/src/animate/Ticker/default-ticker.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { EventEmitter, Logger } from '@visactor/vutils'; -import type { ITickHandler, ITickerHandlerStatic, ITimeline, ITicker } from '../../interface'; -import { application } from '../../application'; -import type { TickerMode } from './type'; -import { STATUS } from './type'; -import { RAFTickHandler } from './raf-tick-handler'; -import { TimeOutTickHandler } from './timeout-tick-handler'; - -export class DefaultTicker extends EventEmitter implements ITicker { - protected interval: number; - protected tickerHandler: ITickHandler; - protected _mode: TickerMode; - protected status: STATUS; - protected lastFrameTime: number; - protected tickCounts: number; - protected timelines: ITimeline[]; - autoStop: boolean; - - set mode(m: TickerMode) { - if (this._mode === m) { - return; - } - this._mode = m; - this.setupTickHandler(); - } - get mode(): TickerMode { - return this._mode; - } - - constructor(timelines: ITimeline[] = []) { - super(); - this.init(); - this.lastFrameTime = -1; - this.tickCounts = 0; - this.timelines = timelines; - this.autoStop = true; - } - - init() { - this.interval = NaN; - this.status = STATUS.INITIAL; - application.global.hooks.onSetEnv.tap('default-ticker', () => { - this.initHandler(); - }); - if (application.global.env) { - this.initHandler(); - } - } - - addTimeline(timeline: ITimeline) { - this.timelines.push(timeline); - } - remTimeline(timeline: ITimeline) { - this.timelines = this.timelines.filter(t => t !== timeline); - } - getTimelines(): ITimeline[] { - return this.timelines; - } - - protected initHandler(): ITickHandler | null { - if (this._mode) { - return null; - } - const ticks: { mode: TickerMode; cons: ITickerHandlerStatic }[] = [ - { mode: 'raf', cons: RAFTickHandler }, - { mode: 'timeout', cons: TimeOutTickHandler } - ]; - for (let i = 0; i < ticks.length; i++) { - if (ticks[i].cons.Avaliable()) { - this.mode = ticks[i].mode; - break; - } - } - return null; - } - - /** - * 设置tickHandler - * @returns 返回true表示设置成功,false表示设置失败 - */ - protected setupTickHandler(): boolean { - let handler: ITickHandler; - // 创建下一个tickHandler - switch (this._mode) { - case 'raf': - handler = new RAFTickHandler(); - break; - case 'timeout': - handler = new TimeOutTickHandler(); - break; - // case 'manual': - // handler = new ManualTickHandler(); - // break; - default: - Logger.getInstance().warn('非法的计时器模式'); - handler = new RAFTickHandler(); - break; - } - if (!handler.avaliable()) { - return false; - } - - // 销毁上一个tickerHandler - if (this.tickerHandler) { - this.tickerHandler.release(); - } - this.tickerHandler = handler; - return true; - } - - setInterval(interval: number) { - this.interval = interval; - } - getInterval(): number { - return this.interval; - } - - setFPS(fps: number): void { - this.setInterval(1000 / fps); - } - getFPS(): number { - return 1000 / this.interval; - } - tick(interval: number): void { - this.tickerHandler.tick(interval, (handler: ITickHandler) => { - this.handleTick(handler, { once: true }); - }); - } - tickTo(t: number): void { - if (!this.tickerHandler.tickTo) { - return; - } - this.tickerHandler.tickTo(t, (handler: ITickHandler) => { - this.handleTick(handler, { once: true }); - }); - } - pause(): boolean { - if (this.status === STATUS.INITIAL) { - return false; - } - this.status = STATUS.PAUSE; - return true; - } - resume(): boolean { - if (this.status === STATUS.INITIAL) { - return false; - } - this.status = STATUS.RUNNING; - return true; - } - - ifCanStop(): boolean { - if (this.autoStop) { - if (!this.timelines.length) { - return true; - } - if (this.timelines.reduce((a, b) => a + b.animateCount, 0) === 0) { - return true; - } - } - return false; - } - - start(force: boolean = false): boolean { - if (this.status === STATUS.RUNNING) { - return false; - } - if (!this.tickerHandler) { - return false; - } - // 如果不需要start,那就不start - if (!force) { - // 暂停状态不执行 - if (this.status === STATUS.PAUSE) { - return false; - } - if (!this.timelines.length) { - return false; - } - if (this.timelines.reduce((a, b) => a + b.animateCount, 0) === 0) { - return false; - } - } - this.status = STATUS.RUNNING; - this.tickerHandler.tick(0, this.handleTick); - return true; - } - stop(): void { - // 重新设置tickHandler - this.status = STATUS.INITIAL; - this.setupTickHandler(); - this.lastFrameTime = -1; - } - - protected handleTick = (handler: ITickHandler, params?: { once?: boolean }) => { - const { once = false } = params ?? {}; - // 尝试停止 - if (this.ifCanStop()) { - this.stop(); - return; - } - this._handlerTick(); - if (!once) { - handler.tick(this.interval, this.handleTick); - } - }; - - protected _handlerTick = () => { - // 具体执行函数 - const tickerHandler = this.tickerHandler; - const time = tickerHandler.getTime(); - // 上一帧经过的时间 - let delta = 0; - if (this.lastFrameTime >= 0) { - delta = time - this.lastFrameTime; - } - this.lastFrameTime = time; - - if (this.status !== STATUS.RUNNING) { - return; - } - this.tickCounts++; - - this.timelines.forEach(t => { - t.tick(delta); - }); - this.emit('tick'); - }; - - release(): void { - this.stop(); - this.timelines = []; - this.tickerHandler.release(); - this.emit('afterTick'); - } - - /** - * 同步tick状态,需要手动触发tick执行,保证属性为走完动画的属性 - * 【注】grammar会设置属性到最终值,然后调用render,这时候需要VRender手动触发tick,保证属性为走完动画的属性,而不是Grammar设置上的属性 - */ - trySyncTickStatus() { - if (this.status === STATUS.RUNNING) { - this._handlerTick(); - } - } -} diff --git a/packages/vrender-core/src/animate/Ticker/index.ts b/packages/vrender-core/src/animate/Ticker/index.ts deleted file mode 100644 index d90a0239e..000000000 --- a/packages/vrender-core/src/animate/Ticker/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './default-ticker'; -export * from './manual-ticker'; -export * from './manual-ticker-handler'; -export * from './raf-tick-handler'; -export * from './timeout-tick-handler'; diff --git a/packages/vrender-core/src/animate/Ticker/manual-ticker-handler.ts b/packages/vrender-core/src/animate/Ticker/manual-ticker-handler.ts deleted file mode 100644 index d4b815c7b..000000000 --- a/packages/vrender-core/src/animate/Ticker/manual-ticker-handler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ITickHandler } from '../../interface/animate'; - -export class ManualTickHandler implements ITickHandler { - protected timerId: number; - protected time: number = 0; - - static Avaliable(): boolean { - return true; - } - - avaliable(): boolean { - return ManualTickHandler.Avaliable(); - } - - tick(interval: number, cb: (handler: ITickHandler, params?: { once: boolean }) => void): void { - this.time = Math.max(0, interval + this.time); - cb(this, { once: true }); - } - - tickTo(t: number, cb: (handler: ITickHandler, params?: { once: boolean }) => void): void { - this.time = Math.max(0, t); - cb(this, { once: true }); - } - - release() { - if (this.timerId > 0) { - // clearTimeout(this.timerId); - this.timerId = -1; - } - } - - getTime() { - return this.time; - } -} diff --git a/packages/vrender-core/src/animate/Ticker/manual-ticker.ts b/packages/vrender-core/src/animate/Ticker/manual-ticker.ts deleted file mode 100644 index 1e7945524..000000000 --- a/packages/vrender-core/src/animate/Ticker/manual-ticker.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ITicker, ITickHandler, ITimeline } from '../../interface/animate'; -import { DefaultTicker } from './default-ticker'; -import { ManualTickHandler } from './manual-ticker-handler'; -import type { STATUS, TickerMode } from './type'; - -export class ManualTicker extends DefaultTicker implements ITicker { - protected declare interval: number; - protected declare tickerHandler: ITickHandler; - protected declare _mode: TickerMode; - protected declare status: STATUS; - protected declare lastFrameTime: number; - protected declare tickCounts: number; - protected declare timelines: ITimeline[]; - declare autoStop: boolean; - - set mode(m: TickerMode) { - m = 'manual'; - this.setupTickHandler(); - } - get mode(): TickerMode { - return this._mode; - } - - protected initHandler(): ITickHandler | null { - this.mode = 'manual'; - return null; - } - - /** - * 设置tickHandler - * @returns 返回true表示设置成功,false表示设置失败 - */ - protected setupTickHandler(): boolean { - const handler: ITickHandler = new ManualTickHandler(); - this._mode = 'manual'; - - // 销毁上一个tickerHandler - if (this.tickerHandler) { - this.tickerHandler.release(); - } - this.tickerHandler = handler; - return true; - } - - tickAt(time: number) { - this.tickerHandler.tick(time - Math.max(this.lastFrameTime, 0), (handler: ITickHandler) => { - this.handleTick(handler, { once: true }); - }); - } - - ifCanStop(): boolean { - return false; - } -} diff --git a/packages/vrender-core/src/animate/Ticker/raf-tick-handler.ts b/packages/vrender-core/src/animate/Ticker/raf-tick-handler.ts deleted file mode 100644 index 2f39214b1..000000000 --- a/packages/vrender-core/src/animate/Ticker/raf-tick-handler.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { application } from '../../application'; -import type { ITickHandler } from '../../interface/animate'; - -export class RAFTickHandler implements ITickHandler { - protected released: boolean; - - static Avaliable(): boolean { - return !!application.global.getRequestAnimationFrame(); - } - avaliable(): boolean { - return RAFTickHandler.Avaliable(); - } - - tick(interval: number, cb: (handler: ITickHandler) => void): void { - const raf = application.global.getRequestAnimationFrame(); - raf(() => { - if (this.released) { - return; - } - cb(this); - }); - } - - release() { - this.released = true; - } - getTime() { - return Date.now(); - } -} diff --git a/packages/vrender-core/src/animate/Ticker/timeout-tick-handler.ts b/packages/vrender-core/src/animate/Ticker/timeout-tick-handler.ts deleted file mode 100644 index c6c13c183..000000000 --- a/packages/vrender-core/src/animate/Ticker/timeout-tick-handler.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ITickHandler } from '../../interface/animate'; - -export class TimeOutTickHandler implements ITickHandler { - protected timerId: number; - - static Avaliable(): boolean { - return true; - } - - avaliable(): boolean { - return TimeOutTickHandler.Avaliable(); - } - - tick(interval: number, cb: (handler: ITickHandler) => void): void { - this.timerId = setTimeout(() => { - cb(this); - }, interval) as unknown as number; - } - - release() { - if (this.timerId > 0) { - clearTimeout(this.timerId); - this.timerId = -1; - } - } - getTime() { - return Date.now(); - } -} diff --git a/packages/vrender-core/src/animate/Ticker/type.ts b/packages/vrender-core/src/animate/Ticker/type.ts deleted file mode 100644 index 97afaea54..000000000 --- a/packages/vrender-core/src/animate/Ticker/type.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type TickerMode = 'raf' | 'timeout' | 'manual'; - -export enum STATUS { - INITIAL = 0, // initial表示初始状态 - RUNNING = 1, // running表示正在执行 - PAUSE = 2 // PULSE表示tick还是继续,只是不执行函数了 -} diff --git a/packages/vrender-core/src/animate/animate.ts b/packages/vrender-core/src/animate/animate.ts deleted file mode 100644 index 57087397a..000000000 --- a/packages/vrender-core/src/animate/animate.ts +++ /dev/null @@ -1,1308 +0,0 @@ -import type { - EasingType, - EasingTypeFunc, - IAnimate, - IAnimateStepType, - IAnimateTarget, - ICustomAnimate, - IGraphic, - IStep, - IStepConfig, - ISubAnimate, - ITimeline -} from '../interface'; -import { AnimateMode, AnimateStatus, AnimateStepType, AttributeUpdateType } from '../common/enums'; -import { Easing } from './easing'; -import { Logger, max } from '@visactor/vutils'; -import { defaultTimeline } from './timeline'; -import { Generator } from '../common/generator'; - -// 参考TweenJS -// https://github.com/CreateJS/TweenJS/tree/master/src/tweenjs -/** - * The MIT License (MIT) - - Copyright (c) 2014 gskinner.com, inc. - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - */ -export abstract class ACustomAnimate implements ICustomAnimate { - declare from: T; - declare to: T; - declare duration: number; - declare easing: EasingType; - declare params: any; - declare target: IAnimateTarget; - declare updateCount: number; - declare subAnimate: ISubAnimate; - declare step?: IStep; - declare mode?: AnimateMode; - - // 用于判断是否一致 - declare _endProps?: any; - declare _mergedEndProps?: any; - - constructor(from: T, to: T, duration: number, easing: EasingType, params?: any) { - this.from = from; - this.to = to; - this.duration = duration; - this.easing = easing; - this.params = params; - this.updateCount = 0; - } - - bind(target: IAnimateTarget, subAni: ISubAnimate) { - this.target = target; - this.subAnimate = subAni; - this.onBind(); - } - - // 在第一次调用的时候触发 - onBind() { - return; - } - - // 第一次执行的时候调用 - onFirstRun() { - return; - } - - // 开始执行的时候调用(如果有循环,那每个周期都会调用) - onStart() { - return; - } - - // 结束执行的时候调用(如果有循环,那每个周期都会调用) - onEnd() { - return; - } - - getEndProps(): Record | void { - return this.to; - } - - getFromProps(): Record | void { - return this.from; - } - - getMergedEndProps(): Record | void { - const thisEndProps = this.getEndProps(); - if (thisEndProps) { - if (this._endProps === thisEndProps) { - return this._mergedEndProps; - } - this._endProps = thisEndProps; - this._mergedEndProps = Object.assign({}, this.step.prev.getLastProps() ?? {}, thisEndProps); - return; - } - return this.step.prev ? this.step.prev.getLastProps() : thisEndProps; - } - - // abstract getFromValue(key: string): any; - // abstract getToValue(key: string): any; - - abstract onUpdate(end: boolean, ratio: number, out: Record): void; - - update(end: boolean, ratio: number, out: Record): void { - if (this.updateCount === 0) { - this.onFirstRun(); - // out添加之前的props - const props = this.step.getLastProps(); - Object.keys(props).forEach(k => { - if (this.subAnimate.animate.validAttr(k)) { - out[k] = props[k]; - } - }); - } - this.updateCount += 1; - this.onUpdate(end, ratio, out); - if (end) { - this.onEnd(); - } - } -} - -export class CbAnimate extends ACustomAnimate { - cb: () => void; - - constructor(cb: () => void) { - super(null, null, 0, 'linear'); - this.cb = cb; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - return; - } - - onStart(): void { - this.cb(); - } -} - -type InterpolateFunc = ( - key: string, - ratio: number, - from: any, - to: any, - target: IAnimateTarget, - out: Record -) => boolean; - -export class Animate implements IAnimate { - static mode: AnimateMode = AnimateMode.NORMAL; - declare target: IAnimateTarget; - declare timeline: ITimeline; - declare nextAnimate?: IAnimate; - declare prevAnimate?: IAnimate; - // 当前Animate的状态,正常,暂停,结束 - declare status: AnimateStatus; - declare readonly id: string | number; - // 开始时间 - protected declare _startTime: number; - protected declare _duringTime: number; - declare subAnimates: SubAnimate[]; - declare tailAnimate: SubAnimate; - - // 绝对的位置 - declare rawPosition: number; - // 时间倍速缩放 - declare timeScale: number; - - declare interpolateFunc: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; - - declare _onStart?: (() => void)[]; - declare _onFrame?: ((step: IStep, ratio: number) => void)[]; - declare _onEnd?: (() => void)[]; - declare _onRemove?: (() => void)[]; - declare _preventAttrs?: Set; - static interpolateMap: Map = new Map(); - slience?: boolean; - - constructor( - id: string | number = Generator.GenAutoIncrementId(), - timeline: ITimeline = defaultTimeline, - slience?: boolean - ) { - this.id = id; - this.timeline = timeline || defaultTimeline; - this.status = AnimateStatus.INITIAL; - this.tailAnimate = new SubAnimate(this); - this.subAnimates = [this.tailAnimate]; - this.timeScale = 1; - this.rawPosition = -1; - this._startTime = 0; - this._duringTime = 0; - this.timeline.addAnimate(this); - this.slience = slience; - } - - setTimeline(timeline: ITimeline) { - if (timeline === this.timeline) { - return; - } - this.timeline.removeAnimate(this, false); - timeline.addAnimate(this); - } - - getStartTime(): number { - return this._startTime; - } - - getDuration(): number { - return this.subAnimates.reduce((t, subAnimate) => t + subAnimate.totalDuration, 0); - } - - after(animate: IAnimate) { - const t = animate.getDuration(); - this._startTime = t; - return this; - } - - afterAll(list: IAnimate[]) { - let maxT = -Infinity; - list.forEach(a => { - maxT = max(a.getDuration(), maxT); - }); - this._startTime = maxT; - return this; - } - - parallel(animate: IAnimate) { - this._startTime = animate.getStartTime(); - return this; - } - - static AddInterpolate(name: string, cb: InterpolateFunc) { - Animate.interpolateMap.set(name, cb); - } - - play(customAnimate: ICustomAnimate) { - this.tailAnimate.play(customAnimate); - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - if (this.subAnimates.length === 1 && this.tailAnimate.totalDuration === customAnimate.duration) { - this.trySetAttribute(customAnimate.getFromProps(), customAnimate.mode); - } - return this; - } - - trySetAttribute(attr: Record | void, mode: AnimateMode = Animate.mode) { - if (attr && mode & AnimateMode.SET_ATTR_IMMEDIATELY) { - (this.target as any).setAttributes && - (this.target as any).setAttributes(attr, false, { type: AttributeUpdateType.ANIMATE_PLAY }); - } - } - - runCb(cb: (a: IAnimate, step: IStep) => void) { - // this.tailAnimate.runCb(cb); - const customAnimate = new CbAnimate(() => { - cb(this, customAnimate.step.prev); - }); - this.tailAnimate.play(customAnimate); - return this; - } - - /** - * 自定义插值,返回false表示没有匹配上 - * @param key - * @param from - * @param to - */ - customInterpolate( - key: string, - ratio: number, - from: any, - to: any, - target: IAnimateTarget, - ret: Record - ): boolean { - const func = Animate.interpolateMap.get(key) || Animate.interpolateMap.get(''); - if (!func) { - return false; - } - return func(key, ratio, from, to, target, ret); - } - - pause() { - if (this.status === AnimateStatus.RUNNING) { - this.status = AnimateStatus.PAUSED; - } - } - - resume() { - if (this.status === AnimateStatus.PAUSED) { - this.status = AnimateStatus.RUNNING; - } - } - - to(props: Record, duration: number, easing: EasingType, params?: IStepConfig) { - this.tailAnimate.to(props, duration, easing, params); - // 默认开始动画 - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - // if (this.subAnimates.length === 1 && this.tailAnimate.duration === duration) { - // this.trySetAttribute(props); - // } - return this; - } - from(props: Record, duration: number, easing: EasingType, params?: IStepConfig) { - this.tailAnimate.from(props, duration, easing, params); - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - wait(duration: number) { - this.tailAnimate.wait(duration); - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - startAt(t: number) { - this.tailAnimate.startAt(t); - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - - loop(l: number) { - this.tailAnimate.loop = l; - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - - reversed(r: boolean) { - this.tailAnimate.reversed = r; - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - - bounce(b: boolean) { - this.tailAnimate.bounce = b; - // todo: 考虑使用绑定的ticker执行 - if (this.target) { - const stage = (this.target as IGraphic).stage; - stage && stage.renderNextFrame(); - } - return this; - } - - subAnimate() { - const sa = new SubAnimate(this, this.tailAnimate); - this.tailAnimate = sa; - this.subAnimates.push(sa); - sa.bind(this.target); - return this; - } - - getStartProps(): Record { - return this.subAnimates[0].getStartProps(); - } - - getEndProps(): Record { - return this.tailAnimate.getEndProps(); - } - - depreventAttr(key: string) { - if (!this._preventAttrs) { - return; - } - this._preventAttrs.delete(key); - } - preventAttr(key: string) { - if (!this._preventAttrs) { - this._preventAttrs = new Set(); - } - this._preventAttrs.add(key); - } - preventAttrs(keys: string[]) { - keys.forEach(key => this.preventAttr(key)); - } - validAttr(key: string): boolean { - if (!this._preventAttrs) { - return true; - } - return !this._preventAttrs.has(key); - } - - bind(target: IAnimateTarget) { - this.target = target; - - if (this.target.onAnimateBind && !this.slience) { - this.target.onAnimateBind(this); - } - - this.subAnimates.forEach(sa => { - sa.bind(target); - }); - return this; - } - - advance(delta: number) { - // startTime之前的时间不计入耗时 - if (this._duringTime < this._startTime) { - if (this._duringTime + delta * this.timeScale < this._startTime) { - this._duringTime += delta * this.timeScale; - return; - } - delta = this._duringTime + delta * this.timeScale - this._startTime; - this._duringTime = this._startTime; - } - // 执行advance - if (this.status === AnimateStatus.INITIAL) { - this.status = AnimateStatus.RUNNING; - this._onStart && this._onStart.forEach(cb => cb()); - } - const end = this.setPosition(Math.max(this.rawPosition, 0) + delta * this.timeScale); - if (end && this.status === AnimateStatus.RUNNING) { - this.status = AnimateStatus.END; - this._onEnd && this._onEnd.forEach(cb => cb()); - } - } - - setPosition(rawPosition: number): boolean { - let d = 0; - let sa: SubAnimate | undefined; - const prevRawPos = this.rawPosition; - const maxRawPos = this.subAnimates.reduce((a, b) => a + b.totalDuration, 0); - - if (rawPosition < 0) { - rawPosition = 0; - } - - const end = rawPosition >= maxRawPos; - - if (end) { - rawPosition = maxRawPos; - } - - if (rawPosition === prevRawPos) { - return end; - } - - // 查找对应的subAnimate - for (let i = 0; i < this.subAnimates.length; i++) { - sa = this.subAnimates[i]; - if (d + sa.totalDuration >= rawPosition) { - break; - } else { - d += sa.totalDuration; - sa = undefined; - } - } - this.rawPosition = rawPosition; - sa.setPosition(rawPosition - d); - - return end; - } - - onStart(cb: () => void) { - if (!this._onStart) { - this._onStart = []; - } - this._onStart.push(cb); - } - onEnd(cb: () => void) { - if (!this._onEnd) { - this._onEnd = []; - } - this._onEnd.push(cb); - } - onRemove(cb: () => void) { - if (!this._onRemove) { - this._onRemove = []; - } - this._onRemove.push(cb); - } - onFrame(cb: (step: IStep, ratio: number) => void) { - if (!this._onFrame) { - this._onFrame = []; - } - this._onFrame.push(cb); - } - release() { - this.status = AnimateStatus.END; - return; - } - - stop(nextVal?: 'start' | 'end' | Record) { - if (!nextVal) { - this.target.onStop(); - } - if (nextVal === 'start') { - this.target.onStop(this.getStartProps()); - } else if (nextVal === 'end') { - this.target.onStop(this.getEndProps()); - } else { - this.target.onStop(nextVal); - } - this.release(); - } -} - -// Animate.mode |= AnimateMode.SET_ATTR_IMMEDIATELY; - -export class SubAnimate implements ISubAnimate { - declare target: IAnimateTarget; - declare animate: IAnimate; - // 默认的初始step,一定存在,且stepHead的props一定保存整个subAnimate阶段所有属性的最初 - protected declare stepHead: Step; - protected declare stepTail: Step; - // 结束时反转动画 - declare bounce: boolean; - // 是否reverse - declare reversed: boolean; - // 循环次数,0为执行一次,1为执行两次,Infinity为无限循环 - declare loop: number; - // 持续时间,不包括循环 - declare duration: number; - // 位置,在[0, duration]之间 - declare position: number; - // 绝对的位置,在[0, loops * duration]之间 - declare rawPosition: number; - declare dirty: boolean; - - declare _totalDuration: number; - declare _startAt: number; - declare _lastStep: IStep; - declare _deltaPosition: number; - - get totalDuration(): number { - this.calcAttr(); - return this._totalDuration + this._startAt; - } - - constructor(animate: IAnimate, lastSubAnimate?: SubAnimate) { - this.rawPosition = -1; - this.position = 0; - this.loop = 0; - this.duration = 0; - this.animate = animate; - if (lastSubAnimate) { - this.stepHead = new Step(0, 0, Object.assign({}, lastSubAnimate.stepTail.props)); - } else { - this.stepHead = new Step(0, 0, {}); - } - this.stepTail = this.stepHead; - this.dirty = true; - this._startAt = 0; - } - - // 计算按需计算的属性 - protected calcAttr() { - if (!this.dirty) { - return; - } - - this._totalDuration = this.duration * (this.loop + 1); - } - - bind(target: IAnimateTarget) { - this.target = target; - return this; - } - - play(customAnimate: ICustomAnimate) { - let duration = customAnimate.duration; - if (duration == null || duration < 0) { - duration = 0; - } - const easing = customAnimate.easing; - const easingFunc = typeof easing === 'string' ? Easing[easing] : easing; - const step = this._addStep(duration, null, easingFunc); - step.type = AnimateStepType.customAnimate; - this._appendProps(customAnimate.getEndProps(), step, false); - this._appendCustomAnimate(customAnimate, step); - return this; - } - - // _appendPlayProps(step: IStep) { - - // return; - // } - - to(props: Record, duration: number, easing: EasingType, params?: IStepConfig) { - if (duration == null || duration < 0) { - duration = 0; - } - - const easingFunc = typeof easing === 'string' ? Easing[easing] : easing; - - const step = this._addStep(duration, null, easingFunc); - step.type = AnimateStepType.to; - this._appendProps(props, step, params ? params.tempProps : false); - // this._appendPlayProps(step); - - if (!step.propKeys) { - step.propKeys = Object.keys(step.props); - } - if (!(params && params.noPreventAttrs)) { - this.target.animates && - this.target.animates.forEach(a => { - if (a.id !== this.animate.id) { - a.preventAttrs(step.propKeys); - } - }); - } - return this; - } - - from(props: Record, duration: number, easing: EasingType, params?: IStepConfig) { - this.to(props, 0, easing, params); - const toProps = {}; - if (!this.stepTail.propKeys) { - this.stepTail.propKeys = Object.keys(this.stepTail.props); - } - this.stepTail.propKeys.forEach(k => { - toProps[k] = this.getLastPropByName(k, this.stepTail); - }); - this.to(toProps, duration, easing, params); - this.stepTail.type = AnimateStepType.from; - } - - startAt(t: number) { - if (t < 0) { - t = 0; - } - this._startAt = t; - return this; - } - - getStartProps() { - return this.stepHead?.props; - } - - getEndProps() { - return this.stepTail.props; - } - - getLastStep() { - return this._lastStep; - } - - wait(duration: number) { - if (duration > 0) { - const step = this._addStep(+duration, null); - - step.type = AnimateStepType.wait; - // TODO 这里如果跳帧的话会存在bug - if (step.prev.customAnimate) { - step.props = step.prev.customAnimate.getEndProps(); - } else { - step.props = step.prev.props; - } - if (this.target.onAddStep) { - this.target.onAddStep(step); - } - // this._appendPlayProps(step); - } - return this; - } - - protected _addStep(duration: number, props: any, easingFunc?: EasingTypeFunc) { - const step = new Step(this.duration, duration, props, easingFunc); - this.duration += duration; - this.stepTail.append(step); - this.stepTail = step; - return step; - } - - protected _appendProps(props: any, step: Step, tempProps?: boolean) { - if (tempProps) { - step.props = props; - } else { - // todo: 是否需要深拷贝props - step.props = Object.assign({}, props); - } - let lastStep = step.prev; - const _props = step.props; - // 将undefined的属性设置到默认值 - if (!step.propKeys) { - step.propKeys = Object.keys(step.props); - } - step.propKeys.forEach(k => { - if (step.props[k] === undefined) { - step.props[k] = this.target.getDefaultAttribute(k); - } - }); - // 拷贝之前的step阶段属性 - while (lastStep.prev) { - if (lastStep.props) { - if (!lastStep.propKeys) { - lastStep.propKeys = Object.keys(lastStep.props); - } - lastStep.propKeys.forEach(key => { - if (_props[key] === undefined) { - _props[key] = lastStep.props[key]; - } - }); - } - // 重置propKeys - step.propKeys = Object.keys(step.props); - lastStep = lastStep.prev; - } - - // 设置最初的props属性 - const initProps = this.stepHead.props; - if (!step.propKeys) { - step.propKeys = Object.keys(_props); - } - step.propKeys.forEach(key => { - if (initProps[key] === undefined) { - const parentAnimateInitProps = this.animate.getStartProps(); - initProps[key] = parentAnimateInitProps[key] = this.target.getComputedAttribute(key); - } - }); - - if (this.target.onAddStep) { - this.target.onAddStep(step); - } - } - - protected _appendCustomAnimate(customAnimate: ICustomAnimate, step: Step) { - step.customAnimate = customAnimate; - customAnimate.step = step; - customAnimate.bind(this.target, this); - } - - setPosition(rawPosition: number) { - const d = this.duration; - const loopCount = this.loop; - const prevRawPos = this.rawPosition; - let end = false; - let loop: number; // 当前是第几次循环 - let position: number; // 当前周期的时间 - const startAt = this._startAt ?? 0; - - if (rawPosition < 0) { - rawPosition = 0; - } - if (rawPosition < startAt) { - this.rawPosition = rawPosition; - return false; - } - rawPosition = rawPosition - startAt; - if (d <= 0) { - // 如果不用执行,跳过 - end = true; - // 小于0的话,直接return,如果等于0,那还是得走动画逻辑,将end属性设置上去 - if (d < 0) { - return end; - } - } - loop = Math.floor(rawPosition / d); - position = rawPosition - loop * d; - - // 计算rawPosition - end = rawPosition >= loopCount * d + d; - // 如果结束,跳过 - if (end) { - position = d; - loop = loopCount; - rawPosition = position * loop + d; - } - - if (rawPosition === prevRawPos) { - return end; - } - - // reverse动画 - const rev = !this.reversed !== !(this.bounce && loop % 2); - if (rev) { - position = d - position; - } - - this._deltaPosition = position - this.position; - this.position = position; - this.rawPosition = rawPosition + startAt; - - this.updatePosition(end, rev); - - return end; - } - - protected updatePosition(end: boolean, rev: boolean) { - if (!this.stepHead) { - return; - } - let step = this.stepHead.next; - const position = this.position; - const duration = this.duration; - if (this.target && step) { - let stepNext = step.next; - while (stepNext && stepNext.position <= position) { - step = stepNext; - stepNext = step.next; - } - let ratio = end ? (duration === 0 ? 1 : position / duration) : (position - step.position) / step.duration; // TODO: revisit this. - if (step.easing) { - ratio = step.easing(ratio); - } - // 判断这次和上次过程中是否经历了自定义step,如果跳过了自定义step那么执行自定义step的onEnd - this.tryCallCustomAnimateLifeCycle(step, this._lastStep || (rev ? this.stepTail : this.stepHead), rev); - // if (step !== this._lastStep) { - // if (this._deltaPosition > 0) { - // let _step = step.prev; - // while (_step && _step !== this._lastStep) { - // if (_step.customAnimate) { - // _step.customAnimate.onEnd(); - // } - // _step = _step.prev; - // } - // if (_step && _step.customAnimate) { - // _step.customAnimate.onEnd(); - // } - // } else if (this._deltaPosition < 0) { - // let _step = step.next; - // while (_step && _step !== this._lastStep) { - // if (_step.customAnimate) { - // _step.customAnimate.onEnd(); - // } - // _step = _step.next; - // } - // if (_step && _step.customAnimate) { - // _step.customAnimate.onEnd(); - // } - // } - // } - this.updateTarget(step, ratio, end); - this._lastStep = step; - - this.animate._onFrame && this.animate._onFrame.forEach(cb => cb(step, ratio)); - } - } - - // 如果动画卡顿跳过了自定义动画,那么尝试执行自定义动画的生命周期 - tryCallCustomAnimateLifeCycle(step: IStep, lastStep: IStep, rev: boolean) { - if (step === lastStep) { - return; - } - if (rev) { - let _step = lastStep.prev; - while (_step && _step !== step) { - if (_step.customAnimate) { - _step.customAnimate.onStart && _step.customAnimate.onStart(); - _step.customAnimate.onEnd && _step.customAnimate.onEnd(); - } - _step = step.prev; - } - // 执行lastStep的onEnd和currentStep的onStart - if (lastStep && lastStep.customAnimate) { - lastStep.customAnimate.onEnd && lastStep.customAnimate.onEnd(); - } - if (step && step.customAnimate) { - step.customAnimate.onStart && step.customAnimate.onStart(); - } - } else { - let _step = lastStep.next; - while (_step && _step !== step) { - if (_step.customAnimate) { - _step.customAnimate.onStart && _step.customAnimate.onStart(); - _step.customAnimate.onEnd && _step.customAnimate.onEnd(); - } - _step = _step.next; - } - // 执行lastStep的onEnd和currentStep的onStart - if (lastStep && lastStep.customAnimate) { - lastStep.customAnimate.onEnd && lastStep.customAnimate.onEnd(); - } - if (step && step.customAnimate) { - step.customAnimate.onStart && step.customAnimate.onStart(); - } - } - } - - /** - * 获取这个属性的上一个值 - * @param name - * @param step - * @returns - */ - getLastPropByName(name: string, step: Step): any { - let lastStep = step.prev; - while (lastStep) { - if (lastStep.props && lastStep.props[name] !== undefined) { - return lastStep.props[name]; - } else if (lastStep.customAnimate) { - const val = lastStep.customAnimate.getEndProps()[name]; - if (val !== undefined) { - return val; - } - } - lastStep = lastStep.prev; - } - - Logger.getInstance().warn('未知错误,step中找不到属性'); - return step.props[name]; - } - - protected updateTarget(step: Step, ratio: number, end: boolean) { - if (step.props == null && step.customAnimate == null) { - return; - } - this.target.onStep(this, this.animate, step, ratio, end); - } -} - -// export class Animate implements IAnimate { -// declare target: IAnimateTarget; -// declare timeline: ITimeline; -// protected declare stepHead: Step; -// protected declare stepTail: Step; -// declare nextAnimate?: Animate; -// declare prevAnimate?: Animate; -// // 结束时反转动画 -// declare bounce: boolean; -// // 是否reverse -// declare reversed: boolean; -// // 循环次数,0为执行一次,1为执行两次,Infinity为无限循环 -// declare loop: number; -// // 持续时间,不包括循环 -// declare duration: number; -// // 当前Animate的状态,正常,暂停,结束 -// declare status: AnimateStatus; -// // 位置,在[0, duration]之间 -// declare position: number; -// // 绝对的位置,在[0, loops * duration]之间 -// declare rawPosition: number; -// // 开始时间 -// protected declare _startAt: number; -// // 时间的缩放,例如2表示2倍速 -// declare timeScale: number; -// declare props: Record; -// declare readonly id: string | number; - -// protected declare _onStart?: (() => void)[]; -// protected declare _onFrame?: ((step: IStep, ratio: number) => void)[]; -// protected declare _onEnd?: (() => void)[]; -// declare _onRemove?: (() => void)[]; -// declare _preventAttrs?: Set; - -// constructor(id: string | number = Generator.GenAutoIncrementId(), timeline: ITimeline = defaultTimeline) { -// this.timeline = timeline; -// this.status = AnimateStatus.INITIAL; -// this.rawPosition = -1; -// this.position = 0; -// this.loop = 0; -// this.timeline.addAnimate(this); -// this.timeScale = 1; -// this.id = id; -// this.props = {}; -// this.stepHead = new Step(0, 0, {}); -// this.stepTail = this.stepHead; -// } - -// preventAttr(key: string) { -// if (!this._preventAttrs) { -// this._preventAttrs = new Set(); -// } -// this._preventAttrs.add(key); -// } -// preventAttrs(keys: string[]) { -// keys.forEach(key => this.preventAttr(key)); -// } -// validAttr(key: string): boolean { -// if (!this._preventAttrs) { -// return true; -// } -// return !this._preventAttrs.has(key); -// } - -// getLastPropByName(name: string, step: Step): any { -// let lastStep = step.prev; -// while (lastStep) { -// if (lastStep.props && lastStep.props[name] !== undefined) { -// return lastStep.props[name]; -// } -// lastStep = lastStep.prev; -// } -// let val = this.props[name]; -// if (!val) { -// console.warn('未知错误,step中找不到属性'); -// val = this.target.getComputedAttribute(name); -// this.props[name] = val; -// } - -// return val; -// } - -// bind(target: IAnimateTarget) { -// this.target = target; -// this.duration = 0; -// return this; -// } - -// startAt(t: number) { -// if (t < 0) { -// return this; -// } -// this._startAt = t; -// return this; -// } - -// to(props: Record, duration: number, easing: EasingType, params?: IStepConfig) { -// if (duration == null || duration < 0) { -// duration = 0; -// } - -// const easingFunc = typeof easing === 'string' ? Easing[easing] : easing; - -// const step = this._addStep(duration, null, easingFunc); -// this._appendProps(props, step, params ? params.tempProps : false); -// return this; -// } - -// wait(duration: number) { -// if (duration > 0) { -// const step = this._addStep(+duration, null); -// if (step.prev) { -// step.props = step.prev.props; -// } -// if (this.target.onAddStep) { -// this.target.onAddStep(step); -// } -// } -// return this; -// } - -// protected _addStep(duration: number, props: any, easingFunc?: EasingTypeFunc) { -// const step = new Step(this.duration, duration, props, easingFunc); -// this.duration += duration; -// this.stepTail.append(step); -// this.stepTail = step; -// return step; -// } - -// protected _appendProps(props: any, step: Step, tempProps?: boolean) { -// if (tempProps) { -// step.props = props; -// } else { -// // todo: 是否需要深拷贝props -// step.props = Object.assign({}, props); -// } -// let lastStep = step.prev; -// const _props = step.props; -// // 拷贝之前的step阶段属性 -// while (lastStep.prev) { -// if (lastStep.props) { -// if (!lastStep.propKeys) { -// lastStep.propKeys = Object.keys(lastStep.props); -// } -// lastStep.propKeys.forEach(key => { -// if (_props[key] === undefined) { -// _props[key] = lastStep.props[key]; -// } -// }); -// } -// lastStep = lastStep.prev; -// } - -// // 设置最初的props属性 -// const initProps = this.stepHead.props; -// if (!step.propKeys) { -// step.propKeys = Object.keys(_props); -// step.propKeys.forEach(key => { -// initProps[key] = this.target.getComputedAttribute(key); -// }); -// } - -// if (this.target.onAddStep) { -// this.target.onAddStep(step); -// } -// } - -// advance(delta: number) { -// if (this.status === AnimateStatus.INITIAL) { -// this.status = AnimateStatus.RUNNING; -// this._onStart && this._onStart.forEach(cb => cb()); -// } -// const end = this.setPosition(this.rawPosition + delta * this.timeScale); -// if (end && this.status === AnimateStatus.RUNNING) { -// this.status = AnimateStatus.END; -// this._onEnd && this._onEnd.forEach(cb => cb()); -// } -// } - -// setPosition(rawPosition: number) { -// const d = this.duration; -// const loopCount = this.loop; -// const prevRawPos = this.rawPosition; -// let end = false; -// let loop: number; // 当前是第几次循环 -// let position: number; // 当前周期的时间 -// const startAt = this._startAt ?? 0; - -// if (rawPosition < 0) { -// rawPosition = 0; -// } -// if (rawPosition < startAt) { -// this.rawPosition = rawPosition; -// return false; -// } -// rawPosition = rawPosition - startAt; -// if (d <= 0) { -// // 如果不用执行,跳过 -// end = true; -// return end; -// } -// loop = Math.floor(rawPosition / d); -// position = rawPosition - loop * d; - -// // 计算rawPosition -// end = rawPosition >= loopCount * d + d; -// // 如果结束,跳过 -// if (end) { -// position = d; -// loop = loopCount; -// rawPosition = position * loop + d; -// } - -// if (rawPosition === prevRawPos) { -// return end; -// } - -// // reverse动画 -// const rev = !this.reversed !== !(this.bounce && loop % 2); -// if (rev) { -// position = d - position; -// } - -// this.position = position; -// this.rawPosition = rawPosition + startAt; - -// this.updatePosition(end); - -// return end; -// } - -// protected updatePosition(end: boolean) { -// if (!this.stepHead) { -// return; -// } -// let step = this.stepHead; -// const position = this.position; -// const duration = this.duration; -// if (this.target && step) { -// let stepNext = step.next; -// while (stepNext && stepNext.position <= position) { -// step = step.next; -// stepNext = step.next; -// } -// let ratio = end ? (duration === 0 ? 1 : position / duration) : (position - step.position) / step.duration; // TODO: revisit this. -// if (step.easing) { -// ratio = step.easing(ratio); -// } -// this.updateTarget(step, ratio, end); -// this._onFrame && this._onFrame.forEach(cb => cb(step, ratio)); -// } -// } - -// protected updateTarget(step: Step, ratio: number, end: boolean) { -// if (step.props == null) { -// return; -// } -// this.target.onStep(this, step, ratio, end); -// } - -// onStart(cb: () => void) { -// if (!this._onStart) { -// this._onStart = []; -// } -// this._onStart.push(cb); -// } -// onEnd(cb: () => void) { -// if (!this._onEnd) { -// this._onEnd = []; -// } -// this._onEnd.push(cb); -// } -// onRemove(cb: () => void) { -// if (!this._onRemove) { -// this._onRemove = []; -// } -// this._onRemove.push(cb); -// } -// onFrame(cb: (step: IStep, ratio: number) => void) { -// if (!this._onFrame) { -// this._onFrame = []; -// } -// this._onFrame.push(cb); -// } - -// getStartProps() { -// return this.stepHead?.props; -// } - -// getEndProps(target: Record = {}) { -// let step = this.stepHead; -// while (step) { -// if (step.props) { -// Object.assign(target, step.props); -// } -// step = step.next; -// } - -// return target; -// } - -// stop(nextVal?: 'start' | 'end' | Record) { -// this.status = AnimateStatus.END; -// if (!nextVal) { -// this.target.onStop(); -// } -// if (nextVal === 'start') { -// this.target.onStop(this.getStartProps()); -// } else if (nextVal === 'end') { -// this.target.onStop(this.getEndProps()); -// } else { -// this.target.onStop(nextVal); -// } -// } - -// release() { -// this.status = AnimateStatus.END; -// return; -// } -// } - -class Step implements IStep { - declare prev?: Step; - // 持续时间 - declare duration: number; - // 在animate中的位置 - declare position: number; - declare next?: Step; - declare props: any; - // 保存解析后的props,用于性能优化 - declare parsedProps?: any; - declare propKeys?: string[]; - declare easing?: EasingTypeFunc; - declare customAnimate?: ICustomAnimate; - // passive: boolean; - // index: number; - type: IAnimateStepType; - - constructor(position: number, duration: number, props?: any, easing?: EasingTypeFunc) { - this.duration = duration; - this.position = position; - this.props = props; - this.easing = easing; - } - - append(step: Step) { - step.prev = this; - step.next = this.next; - this.next = step; - } - - getLastProps() { - let step = this.prev; - while (step) { - if (step.props) { - return step.props; - } else if (step.customAnimate) { - return step.customAnimate.getMergedEndProps(); - } - step = step.prev as any; - } - return null; - } -} diff --git a/packages/vrender-core/src/animate/config.ts b/packages/vrender-core/src/animate/config.ts index 4cd0b74c3..fae7b1778 100644 --- a/packages/vrender-core/src/animate/config.ts +++ b/packages/vrender-core/src/animate/config.ts @@ -1,11 +1,6 @@ -import type { IAnimateConfig } from './../interface/graphic'; +import type { IAnimateConfig } from '../interface/graphic'; export const DefaultStateAnimateConfig: IAnimateConfig = { duration: 200, easing: 'cubicOut' }; - -export const DefaultMorphingAnimateConfig: IAnimateConfig = { - duration: 1000, - easing: 'quadInOut' -}; diff --git a/packages/vrender-core/src/animate/custom-animate.ts b/packages/vrender-core/src/animate/custom-animate.ts deleted file mode 100644 index c9152c57e..000000000 --- a/packages/vrender-core/src/animate/custom-animate.ts +++ /dev/null @@ -1,1371 +0,0 @@ -import type { IPoint, IPointLike } from '@visactor/vutils'; -import { - clamp, - cloneDeep, - getDecimalPlaces, - isArray, - isNumber, - isValidNumber, - pi, - pi2, - Point, - PointService -} from '@visactor/vutils'; -import { application } from '../application'; -import { AttributeUpdateType } from '../common/enums'; -import { CustomPath2D } from '../common/custom-path2d'; -import type { - EasingType, - IArcGraphicAttribute, - IArea, - IAreaCacheItem, - ICubicBezierCurve, - ICurve, - ICustomPath2D, - IGraphic, - IGroup, - ILine, - ILineAttribute, - ILinearGradient, - IRect, - IRectAttribute, - IRectGraphicAttribute, - ISegment, - IShadowRoot -} from '../interface'; -import { ACustomAnimate } from './animate'; -import { Easing } from './easing'; -import { pointInterpolation } from '../common/utils'; -import { divideCubic } from '../common/segment/curve/cubic-bezier'; - -export class IncreaseCount extends ACustomAnimate<{ text: string | number }> { - declare valid: boolean; - - private fromNumber: number; - private toNumber: number; - private decimalLength: number; - - constructor( - from: { text: string | number }, - to: { text: string | number }, - duration: number, - easing: EasingType, - params?: { fixed?: boolean } - ) { - super(from, to, duration, easing, params); - } - - getEndProps(): Record | void { - if (this.valid === false) { - return {}; - } - return { - text: this.to - }; - } - - onBind(): void { - this.fromNumber = isNumber(this.from?.text) ? this.from?.text : Number.parseFloat(this.from?.text); - this.toNumber = isNumber(this.to?.text) ? this.to?.text : Number.parseFloat(this.to?.text); - if (!Number.isFinite(this.toNumber)) { - this.fromNumber = 0; - } - if (!Number.isFinite(this.toNumber)) { - this.valid = false; - } - if (this.valid !== false) { - this.decimalLength = - this.params?.fixed ?? Math.max(getDecimalPlaces(this.fromNumber), getDecimalPlaces(this.toNumber)); - } - } - - onEnd(): void { - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.valid === false) { - return; - } - if (end) { - out.text = this.to?.text; - } else { - out.text = (this.fromNumber + (this.toNumber - this.fromNumber) * ratio).toFixed(this.decimalLength); - } - } -} - -enum Direction { - LEFT_TO_RIGHT = 0, - RIGHT_TO_LEFT = 1, - TOP_TO_BOTTOM = 2, - BOTTOM_TO_TOP = 3, - STROKE = 4 -} -export class FadeInPlus extends ACustomAnimate { - declare direction: number; - declare toFill: string; - declare toStroke: string; - declare fillGradient: ILinearGradient; - declare strokeGradient: ILinearGradient; - declare fill: boolean; - declare stroke: boolean; - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params?: { direction?: number; fill?: boolean; stroke?: boolean } - ) { - super(from, to, duration, easing, params); - const { direction = Direction.LEFT_TO_RIGHT, fill = true, stroke = true } = params || {}; - this.direction = direction; - this.fill = fill; - this.stroke = stroke; - this.fillGradient = { - gradient: 'linear', - stops: [] - }; - this.strokeGradient = { - gradient: 'linear', - stops: [] - }; - } - - getEndProps(): Record { - return { - fill: this.toFill, - stroke: this.toStroke - }; - } - - onBind(): void { - // this.to = parseFloat(this.target.getAnimatePropByName('text')); - this.toFill = this.target.getComputedAttribute('fill'); - this.toStroke = this.target.getComputedAttribute('stroke'); - } - - onEnd(): void { - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (!this.toFill) { - return; - } - if (!this.toStroke) { - return; - } - switch (this.direction) { - case Direction.RIGHT_TO_LEFT: - this.rightToLeft(end, ratio, out); - break; - case Direction.TOP_TO_BOTTOM: - this.topToBottom(end, ratio, out); - break; - case Direction.BOTTOM_TO_TOP: - this.bottomToTop(end, ratio, out); - break; - case Direction.STROKE: - this.strokePath(end, ratio, out); - break; - default: - this.leftToRight(end, ratio, out); - break; - } - } - - leftToRight(end: boolean, ratio: number, out: Record) { - if (this.fill) { - const toFillColor = this.toFill; - this.fillGradient.x0 = 0; - this.fillGradient.y0 = 0; - this.fillGradient.x1 = 1; - this.fillGradient.y1 = 0; - this.fillGradient.stops = [ - { offset: 0, color: toFillColor }, - { offset: ratio, color: toFillColor }, - { offset: Math.min(1, ratio * 2), color: 'transparent' } - ]; - out.fill = this.fillGradient; - } - if (this.stroke) { - const toStrokeColor = this.toStroke; - this.strokeGradient.x0 = 0; - this.strokeGradient.y0 = 0; - this.strokeGradient.x1 = 1; - this.strokeGradient.y1 = 0; - this.strokeGradient.stops = [ - { offset: 0, color: toStrokeColor }, - { offset: ratio, color: toStrokeColor }, - { offset: Math.min(1, ratio * 6), color: 'transparent' } - ]; - out.stroke = this.strokeGradient; - // const dashLen = 300; - // const offset = ratio * dashLen; - // out.lineDash = [offset, dashLen - offset]; - } - return; - } - - strokePath(end: boolean, ratio: number, out: Record) { - if (this.fill) { - const toFillColor = this.toFill; - this.fillGradient.x0 = 0; - this.fillGradient.y0 = 0; - this.fillGradient.x1 = 1; - this.fillGradient.y1 = 0; - this.fillGradient.stops = [ - { offset: 0, color: toFillColor }, - { offset: ratio, color: toFillColor }, - { offset: Math.min(1, ratio * 2), color: 'transparent' } - ]; - out.fill = this.fillGradient; - } - if (this.stroke) { - const dashLen = 300; - const offset = ratio * dashLen; - out.lineDash = [offset, dashLen - offset]; - } - return; - } - rightToLeft(end: boolean, ratio: number, out: Record) { - return; - } - topToBottom(end: boolean, ratio: number, out: Record) { - return; - } - bottomToTop(end: boolean, ratio: number, out: Record) { - return; - } -} - -export class InputText extends ACustomAnimate<{ text: string }> { - declare valid: boolean; - declare target: IGraphic; - - private fromText: string = ''; - private toText: string | string[] = ''; - - getEndProps(): Record { - if (this.valid === false) { - return {}; - } - return { - text: this.to - }; - } - - onBind(): void { - this.fromText = this.from?.text ?? ''; - this.toText = this.to?.text || ''; - if (!this.toText || (isArray(this.toText) && this.toText.length === 0)) { - this.valid = false; - } - if (isArray(this.toText)) { - this.toText = this.toText.map(item => (item || '').toString()); - } - // else { - // this.toText = this.toText.toString(); - // // const root = this.target.attachShadow(); - // // const line = application.graphicService.creator.line({ - // // x: 0, - // // y: 0, - // // points: [ - // // { x: 0, y: 0 }, - // // { x: 0, y: this.target.getComputedAttribute('fontSize') } - // // ], - // // stroke: 'black', - // // lineWidth: 1 - // // }); - // // root.add(line); - // } - } - - onEnd(): void { - this.target.detachShadow(); - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.valid === false) { - return; - } - // update text - const fromCount = this.fromText.length; - const toTextIsArray = isArray(this.toText); - const toCount = toTextIsArray - ? (this.toText as unknown as string[]).reduce((c, t) => c + (t || '').length, 0) - : this.toText.length; - const count = Math.ceil(fromCount + (toCount - fromCount) * ratio); - - if (toTextIsArray) { - out.text = []; - let len = 0; - (this.toText as unknown as string[]).forEach(t => { - if (len + t.length > count) { - out.text.push(t.substr(0, count - len)); - len = count; - } else { - out.text.push(t); - len += t.length; - } - }); - } else { - out.text = (this.toText as string).substr(0, count); - } - // console.log(out.text) - - // update line position - // const line = this.target.shadowRoot?.at(0) as IGraphic; - // const endX = (this.target as any).clipedWidth + 2; - // line.setAttribute('x', endX); - } -} - -export class StreamLight extends ACustomAnimate { - declare valid: boolean; - declare target: IGraphic; - - declare rect: IRect; - declare line: ILine; - declare area: IArea; - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params?: { attribute?: Partial; streamLength?: number; isHorizontal?: boolean } - ) { - super(from, to, duration, easing, params); - } - - getEndProps(): Record { - return {}; - } - - onStart(): void { - if (!this.target) { - return; - } - if (this.target.type === 'rect') { - this.onStartRect(); - } else if (this.target.type === 'line') { - this.onStartLineOrArea('line'); - } else if (this.target.type === 'area') { - this.onStartLineOrArea('area'); - } - } - - onStartLineOrArea(type: 'line' | 'area') { - const root = this.target.attachShadow(); - const line = application.graphicService.creator[type]({ - ...this.params?.attribute - }); - this[type] = line; - line.pathProxy = new CustomPath2D(); - root.add(line); - } - - onStartRect(): void { - const root = this.target.attachShadow(); - - const isHorizontal = this.params?.isHorizontal ?? true; - const sizeAttr = isHorizontal ? 'height' : 'width'; - const otherSizeAttr = isHorizontal ? 'width' : 'height'; - const size = this.target.AABBBounds[sizeAttr](); - const y = isHorizontal ? 0 : this.target.AABBBounds.y1; - - const rect = application.graphicService.creator.rect({ - [sizeAttr]: size, - fill: '#bcdeff', - shadowBlur: 30, - shadowColor: '#bcdeff', - ...this.params?.attribute, - x: 0, - y, - [otherSizeAttr]: 0 - }); - this.rect = rect; - root.add(rect); - } - - onBind(): void { - return; - } - - onEnd(): void { - this.target.detachShadow(); - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.rect) { - return this.onUpdateRect(end, ratio, out); - } else if (this.line || this.area) { - return this.onUpdateLineOrArea(end, ratio, out); - } - } - - protected onUpdateRect(end: boolean, ratio: number, out: Record): void { - const isHorizontal = this.params?.isHorizontal ?? true; - const parentAttr = (this.target as any).attribute; - if (isHorizontal) { - const parentWidth = parentAttr.width ?? Math.abs(parentAttr.x1 - parentAttr.x) ?? 250; - const streamLength = this.params?.streamLength ?? parentWidth; - const maxLength = this.params?.attribute?.width ?? 60; - // 起点,rect x右端点 对齐 parent左端点 - // 如果parent.x1 < parent.x, 需要把rect属性移到parent x1的位置上, 因为初始 rect.x = parent.x - const startX = -maxLength; - // 插值 - const currentX = startX + (streamLength - startX) * ratio; - // 位置限定 > 0 - const x = Math.max(currentX, 0); - // 宽度计算 - const w = Math.min(Math.min(currentX + maxLength, maxLength), streamLength - currentX); - // 如果 rect右端点 超出 parent右端点, 宽度动态调整 - const width = w + x > parentWidth ? Math.max(parentWidth - x, 0) : w; - this.rect.setAttributes( - { - x, - width, - dx: Math.min(parentAttr.x1 - parentAttr.x, 0) - } as any, - false, - { - type: AttributeUpdateType.ANIMATE_PLAY, - animationState: { - ratio, - end - } - } - ); - } else { - const parentHeight = parentAttr.height ?? Math.abs(parentAttr.y1 - parentAttr.y) ?? 250; - const streamLength = this.params?.streamLength ?? parentHeight; - const maxLength = this.params?.attribute?.height ?? 60; - // 起点,y上端点 对齐 parent下端点 - const startY = parentHeight; - // 插值 - const currentY = startY - (streamLength + maxLength) * ratio; - // 位置限定 < parentHeight - let y = Math.min(currentY, parentHeight); - // 高度最小值 - const h = Math.min(parentHeight - currentY, maxLength); - // 如果 rect上端点=y 超出 parent上端点 = 0, 则高度不断变小 - let height; - if (y <= 0) { - // 必须先得到高度再将y置为0, 顺序很重要 - height = Math.max(y + h, 0); - y = 0; - } else { - height = h; - } - this.rect.setAttributes( - { - y, - height, - dy: Math.min(parentAttr.y1 - parentAttr.y, 0) - } as any, - false, - { - type: AttributeUpdateType.ANIMATE_PLAY, - animationState: { - ratio, - end - } - } - ); - } - } - - protected onUpdateLineOrArea(end: boolean, ratio: number, out: Record) { - const target = this.line || this.area; - if (!target) { - return; - } - const customPath = target.pathProxy as ICustomPath2D; - const targetLine = this.target as ILine | IArea; - if (targetLine.cache || targetLine.cacheArea) { - this._onUpdateLineOrAreaWithCache(customPath, targetLine, end, ratio, out); - } else { - this._onUpdateLineWithoutCache(customPath, targetLine, end, ratio, out); - } - const targetAttrs = targetLine.attribute; - target.setAttributes({ - stroke: targetAttrs.stroke, - ...target.attribute - }); - target.addUpdateBoundTag(); - } - - // 针对有cache的linear - protected _onUpdateLineOrAreaWithCache( - customPath: ICustomPath2D, - g: ILine | IArea, - end: boolean, - ratio: number, - out: Record - ) { - customPath.clear(); - if (g.type === 'line') { - let cache = g.cache; - if (!Array.isArray(cache)) { - cache = [cache]; - } - const totalLen = cache.reduce((l: any, c: any) => l + c.getLength(), 0); - const curves: ICurve[] = []; - cache.forEach((c: any) => { - c.curves.forEach((ci: any) => curves.push(ci)); - }); - return this._updateCurves(customPath, curves, totalLen, ratio); - } else if (g.type === 'area' && g.cacheArea?.top?.curves) { - const cache = g.cacheArea as IAreaCacheItem; - const totalLen = cache.top.curves.reduce((a, b) => a + b.getLength(), 0); - return this._updateCurves(customPath, cache.top.curves, totalLen, ratio); - } - } - - protected _updateCurves(customPath: ICustomPath2D, curves: ICurve[], totalLen: number, ratio: number) { - const startLen = totalLen * ratio; - const endLen = Math.min(startLen + this.params?.streamLength ?? 10, totalLen); - let lastLen = 0; - let start = false; - for (let i = 0; i < curves.length; i++) { - if (curves[i].defined !== false) { - const curveItem = curves[i]; - const len = curveItem.getLength(); - const startPercent = 1 - (lastLen + len - startLen) / len; - let endPercent = 1 - (lastLen + len - endLen) / len; - let curveForStart: ICubicBezierCurve; - if (lastLen < startLen && lastLen + len > startLen) { - start = true; - if (curveItem.p2 && curveItem.p3) { - const [_, curve2] = divideCubic(curveItem as ICubicBezierCurve, startPercent); - customPath.moveTo(curve2.p0.x, curve2.p0.y); - curveForStart = curve2; - // console.log(curve2.p0.x, curve2.p0.y); - } else { - const p = curveItem.getPointAt(startPercent); - customPath.moveTo(p.x, p.y); - } - } - if (lastLen < endLen && lastLen + len > endLen) { - if (curveItem.p2 && curveItem.p3) { - if (curveForStart) { - endPercent = (endLen - startLen) / curveForStart.getLength(); - } - const [curve1] = divideCubic(curveForStart || (curveItem as ICubicBezierCurve), endPercent); - customPath.bezierCurveTo(curve1.p1.x, curve1.p1.y, curve1.p2.x, curve1.p2.y, curve1.p3.x, curve1.p3.y); - } else { - const p = curveItem.getPointAt(endPercent); - customPath.lineTo(p.x, p.y); - } - break; - } else if (start) { - if (curveItem.p2 && curveItem.p3) { - const curve = curveForStart || curveItem; - customPath.bezierCurveTo(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); - } else { - customPath.lineTo(curveItem.p1.x, curveItem.p1.y); - } - } - lastLen += len; - } - } - } - - // 只针对最简单的linear - protected _onUpdateLineWithoutCache( - customPath: ICustomPath2D, - line: ILine, - end: boolean, - ratio: number, - out: Record - ) { - const { points, curveType } = line.attribute; - if (!points || points.length < 2 || curveType !== 'linear') { - return; - } - let totalLen = 0; - for (let i = 1; i < points.length; i++) { - totalLen += PointService.distancePP(points[i], points[i - 1]); - } - const startLen = totalLen * ratio; - const endLen = Math.min(startLen + this.params?.streamLength ?? 10, totalLen); - const nextPoints = []; - let lastLen = 0; - for (let i = 1; i < points.length; i++) { - const len = PointService.distancePP(points[i], points[i - 1]); - if (lastLen < startLen && lastLen + len > startLen) { - nextPoints.push(PointService.pointAtPP(points[i - 1], points[i], 1 - (lastLen + len - startLen) / len)); - } - if (lastLen < endLen && lastLen + len > endLen) { - nextPoints.push(PointService.pointAtPP(points[i - 1], points[i], 1 - (lastLen + len - endLen) / len)); - break; - } else if (nextPoints.length) { - nextPoints.push(points[i]); - } - lastLen += len; - } - - if (!nextPoints.length || nextPoints.length < 2) { - return; - } - customPath.clear(); - customPath.moveTo(nextPoints[0].x, nextPoints[0].y); - for (let i = 1; i < nextPoints.length; i++) { - customPath.lineTo(nextPoints[i].x, nextPoints[i].y); - } - } -} - -export class Meteor extends ACustomAnimate { - declare size: number; - declare target: IGraphic; - declare root: IShadowRoot; - declare posList: IPoint[]; - - get lastPos(): IPoint { - return this.posList[this.posList.length - 1]; - } - - constructor(size: number, duration: number, easing: EasingType, params?: any) { - super(null, null, duration, easing, params); - this.size = size; - this.posList = []; - } - - onBind(): void { - const root = this.target.attachShadow(); - this.root = root; - for (let i = 0; i < this.size; i++) { - const g = this.target.clone(); - const scale = Math.min(((this.size - i) / this.size) * 3, 1); - const opacity = Math.min(0.2 + 0.7 / this.size); - g.setAttributes({ x: 0, y: 0, dx: 0, dy: 0, scaleX: scale, scaleY: scale, opacity }, false, { - type: AttributeUpdateType.ANIMATE_BIND - }); - root.add(g); - } - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (end) { - this.target.detachShadow(); - this.posList.length = 0; - return; - } - - const x = this.target.getComputedAttribute('x'); - const y = this.target.getComputedAttribute('y'); - - const nextPos = new Point(x, y); - if (!this.posList.length) { - this.posList.push(nextPos); - return; - } - - this.target.shadowRoot.forEachChildren((g: IGraphic, i) => { - const pos = this.posList[Math.max(this.posList.length - i - 1, 0)]; - g.setAttributes( - { - x: pos.x - x, - y: pos.y - y - }, - false - ); - }); - - this.posList.push(nextPos); - } -} - -export class MotionPath extends ACustomAnimate { - declare valid: boolean; - declare pathLength: number; - declare path: CustomPath2D; - declare distance: number; - declare initAngle: number; - declare changeAngle: boolean; - declare cb?: (from: any, to: any, ratio: number, target: IGraphic) => void; - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params?: { - path: CustomPath2D; - distance: number; - cb?: (from: any, to: any, ratio: number, target: IGraphic) => void; - initAngle?: number; - changeAngle?: boolean; - } - ) { - super(from, to, duration, easing, params); - if (params) { - this.pathLength = params.path.getLength(); - this.path = params.path; - this.distance = params.distance; - this.to = params.distance * this.pathLength; - this.initAngle = params.initAngle ?? 0; - this.changeAngle = !!params.changeAngle; - this.cb = params.cb; - } - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - // 计算位置 - const at = this.to * ratio; - const { pos, angle } = this.path.getAttrAt(at); - out.x = pos.x; - out.y = pos.y; - if (this.changeAngle) { - out.angle = angle + this.initAngle; - } - this.cb && this.cb(this.from, this.to, ratio, this.target as IGraphic); - // out.angle = angle + this.initAngle; - } -} - -export class TagPointsUpdate extends ACustomAnimate<{ points?: IPointLike[]; segments?: ISegment[] }> { - protected fromPoints: IPointLike[]; - protected toPoints: IPointLike[]; - protected points: IPointLike[]; - protected interpolatePoints: [IPointLike, IPointLike][]; - protected newPointAnimateType: 'grow' | 'appear' | 'clip'; - protected clipRange: number; - protected shrinkClipRange: number; - protected clipRangeByDimension: 'x' | 'y'; - protected segmentsCache: number[]; - - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params?: { newPointAnimateType?: 'grow' | 'appear' | 'clip'; clipRangeByDimension?: 'x' | 'y' } - ) { - super(from, to, duration, easing, params); - this.newPointAnimateType = params?.newPointAnimateType ?? 'grow'; - this.clipRangeByDimension = params?.clipRangeByDimension ?? 'x'; - } - - private getPoints(attribute: typeof this.from, cache = false): IPointLike[] { - if (attribute.points) { - return attribute.points; - } - - if (attribute.segments) { - const points = [] as IPointLike[]; - if (!this.segmentsCache) { - this.segmentsCache = []; - } - attribute.segments.map(segment => { - if (segment.points) { - points.push(...segment.points); - } - if (cache) { - this.segmentsCache.push(segment.points?.length ?? 0); - } - }); - return points; - } - return []; - } - - onBind(): void { - const originFromPoints = this.getPoints(this.from); - const originToPoints = this.getPoints(this.to, true); - this.fromPoints = !originFromPoints ? [] : !Array.isArray(originFromPoints) ? [originFromPoints] : originFromPoints; - this.toPoints = !originToPoints ? [] : !Array.isArray(originToPoints) ? [originToPoints] : originToPoints; - - const tagMap = new Map(); - this.fromPoints.forEach(point => { - if (point.context) { - tagMap.set(point.context, point); - } - }); - let firstMatchedIndex = Infinity; - let lastMatchedIndex = -Infinity; - let firstMatchedPoint: IPointLike; - let lastMatchedPoint: IPointLike; - for (let i = 0; i < this.toPoints.length; i += 1) { - if (tagMap.has(this.toPoints[i].context)) { - firstMatchedIndex = i; - firstMatchedPoint = tagMap.get(this.toPoints[i].context); - break; - } - } - for (let i = this.toPoints.length - 1; i >= 0; i -= 1) { - if (tagMap.has(this.toPoints[i].context)) { - lastMatchedIndex = i; - lastMatchedPoint = tagMap.get(this.toPoints[i].context); - break; - } - } - - if (this.newPointAnimateType === 'clip') { - if (this.toPoints.length !== 0) { - if (Number.isFinite(lastMatchedIndex)) { - this.clipRange = - this.toPoints[lastMatchedIndex][this.clipRangeByDimension] / - this.toPoints[this.toPoints.length - 1][this.clipRangeByDimension]; - if (this.clipRange === 1) { - this.shrinkClipRange = - this.toPoints[lastMatchedIndex][this.clipRangeByDimension] / - this.fromPoints[this.fromPoints.length - 1][this.clipRangeByDimension]; - } - if (!isValidNumber(this.clipRange)) { - this.clipRange = 0; - } else { - this.clipRange = clamp(this.clipRange, 0, 1); - } - } else { - this.clipRange = 0; - } - } - } - // TODO: shrink removed points - // if no point is matched, animation should start from toPoint[0] - let prevMatchedPoint = this.toPoints[0]; - this.interpolatePoints = this.toPoints.map((point, index) => { - const matchedPoint = tagMap.get(point.context); - if (matchedPoint) { - prevMatchedPoint = matchedPoint; - return [matchedPoint, point]; - } - // appear new point - if (this.newPointAnimateType === 'appear' || this.newPointAnimateType === 'clip') { - return [point, point]; - } - // grow new point - if (index < firstMatchedIndex && firstMatchedPoint) { - return [firstMatchedPoint, point]; - } else if (index > lastMatchedIndex && lastMatchedPoint) { - return [lastMatchedPoint, point]; - } - return [prevMatchedPoint, point]; - }); - this.points = this.interpolatePoints.map(interpolate => { - const fromPoint = interpolate[0]; - const toPoint = interpolate[1]; - const newPoint = new Point(fromPoint.x, fromPoint.y, fromPoint.x1, fromPoint.y1); - newPoint.defined = toPoint.defined; - newPoint.context = toPoint.context; - return newPoint; - }); - } - - onFirstRun(): void { - const lastClipRange = this.target.attribute.clipRange; - if (isValidNumber(lastClipRange * this.clipRange)) { - this.clipRange *= lastClipRange; - } - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (end) { - Object.keys(this.to).forEach(k => { - out[k] = this.to[k]; - }); - return; - } - // if not create new points, multi points animation might not work well. - this.points = this.points.map((point, index) => { - const newPoint = pointInterpolation(this.interpolatePoints[index][0], this.interpolatePoints[index][1], ratio); - newPoint.context = point.context; - return newPoint; - }); - if (this.clipRange) { - if (this.shrinkClipRange) { - // 折线变短 - if (!end) { - out.points = this.fromPoints; - out.clipRange = this.clipRange - (this.clipRange - this.shrinkClipRange) * ratio; - } else { - out.points = this.toPoints; - out.clipRange = 1; - } - return; - } - out.clipRange = this.clipRange + (1 - this.clipRange) * ratio; - } - if (this.segmentsCache && this.to.segments) { - let start = 0; - out.segments = this.to.segments.map((segment, index) => { - const end = start + this.segmentsCache[index]; - const points = this.points.slice(start, end); - start = end; - return { - ...segment, - points - }; - }); - } else { - out.points = this.points; - } - } -} - -export class GraphicAnimate extends ACustomAnimate { - graphic: IGraphic; - - constructor(from: any, to: any, duration: number, easing: EasingType, params?: { graphic: IGraphic }) { - super(from, to, duration, easing, params); - this.graphic = params?.graphic; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (!this.graphic) { - return; - } - Object.keys(this.from).forEach(k => { - out[k] = this.from[k] + (this.to[k] - this.from[k]) * ratio; - }); - } -} - -export class ClipGraphicAnimate extends ACustomAnimate { - private _group?: IGroup; - private _clipGraphic?: IGraphic; - protected clipFromAttribute?: any; - protected clipToAttribute?: any; - - private _lastClip?: boolean; - private _lastPath?: IGraphic[]; - - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params: { group: IGroup; clipGraphic: IGraphic } - ) { - super(null, null, duration, easing, params); - this.clipFromAttribute = from; - this.clipToAttribute = to; - this._group = params?.group; - this._clipGraphic = params?.clipGraphic; - } - - onBind() { - if (this._group && this._clipGraphic) { - this._lastClip = this._group.attribute.clip; - this._lastPath = this._group.attribute.path; - this._group.setAttributes( - { - clip: true, - path: [this._clipGraphic] - }, - false, - { type: AttributeUpdateType.ANIMATE_BIND } - ); - } - } - - onEnd() { - if (this._group) { - this._group.setAttributes( - { - clip: this._lastClip, - path: this._lastPath - }, - false, - { type: AttributeUpdateType.ANIMATE_END } - ); - } - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (!this._clipGraphic) { - return; - } - const res: any = {}; - Object.keys(this.clipFromAttribute).forEach(k => { - res[k] = this.clipFromAttribute[k] + (this.clipToAttribute[k] - this.clipFromAttribute[k]) * ratio; - }); - this._clipGraphic.setAttributes(res, false, { - type: AttributeUpdateType.ANIMATE_UPDATE, - animationState: { ratio, end } - }); - } -} - -export class ClipAngleAnimate extends ClipGraphicAnimate { - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params: { - group: IGroup; - center?: { x: number; y: number }; - startAngle?: number; - radius?: number; - orient?: 'clockwise' | 'anticlockwise'; - animationType?: 'in' | 'out'; - } - ) { - const groupAttribute = params?.group?.attribute ?? {}; - const width = groupAttribute.width ?? 0; - const height = groupAttribute.height ?? 0; - - const animationType = params?.animationType ?? 'in'; - const startAngle = params?.startAngle ?? 0; - const orient = params?.orient ?? 'clockwise'; - - let arcStartAngle = 0; - let arcEndAngle = 0; - if (orient === 'anticlockwise') { - arcEndAngle = animationType === 'in' ? startAngle + Math.PI * 2 : startAngle; - arcEndAngle = startAngle + Math.PI * 2; - } else { - arcStartAngle = startAngle; - arcEndAngle = animationType === 'out' ? startAngle + Math.PI * 2 : startAngle; - } - const arc = application.graphicService.creator.arc({ - x: params?.center?.x ?? width / 2, - y: params?.center?.y ?? height / 2, - outerRadius: params?.radius ?? (width + height) / 2, - innerRadius: 0, - startAngle: arcStartAngle, - endAngle: arcEndAngle, - fill: true - }); - let fromAttributes: Partial; - let toAttributes: Partial; - if (orient === 'anticlockwise') { - fromAttributes = { startAngle: startAngle + Math.PI * 2 }; - toAttributes = { startAngle: startAngle }; - } else { - fromAttributes = { endAngle: startAngle }; - toAttributes = { endAngle: startAngle + Math.PI * 2 }; - } - super( - animationType === 'in' ? fromAttributes : toAttributes, - animationType === 'in' ? toAttributes : fromAttributes, - duration, - easing, - { group: params?.group, clipGraphic: arc } - ); - } -} - -export class ClipRadiusAnimate extends ClipGraphicAnimate { - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params: { - group: IGroup; - center?: { x: number; y: number }; - startRadius?: number; - endRadius?: number; - animationType?: 'in' | 'out'; - } - ) { - const groupAttribute = params?.group?.attribute ?? {}; - const width = groupAttribute.width ?? 0; - const height = groupAttribute.height ?? 0; - - const animationType = params?.animationType ?? 'in'; - const startRadius = params?.startRadius ?? 0; - const endRadius = params?.endRadius ?? Math.sqrt((width / 2) ** 2 + (height / 2) ** 2); - - const arc = application.graphicService.creator.arc({ - x: params?.center?.x ?? width / 2, - y: params?.center?.y ?? height / 2, - outerRadius: animationType === 'out' ? endRadius : startRadius, - innerRadius: 0, - startAngle: 0, - endAngle: Math.PI * 2, - fill: true - }); - const fromAttributes: Partial = { outerRadius: startRadius }; - const toAttributes: Partial = { outerRadius: endRadius }; - super( - animationType === 'in' ? fromAttributes : toAttributes, - animationType === 'in' ? toAttributes : fromAttributes, - duration, - easing, - { group: params?.group, clipGraphic: arc } - ); - } -} - -export class ClipDirectionAnimate extends ClipGraphicAnimate { - constructor( - from: any, - to: any, - duration: number, - easing: EasingType, - params: { - group: IGroup; - direction?: 'x' | 'y'; - orient?: 'positive' | 'negative'; - width?: number; - height?: number; - animationType?: 'in' | 'out'; - } - ) { - const groupAttribute = params?.group?.attribute ?? {}; - const width = params?.width ?? groupAttribute.width ?? 0; - const height = params?.height ?? groupAttribute.height ?? 0; - - const animationType = params?.animationType ?? 'in'; - const direction = params?.direction ?? 'x'; - const orient = params?.orient ?? 'positive'; - - const rect = application.graphicService.creator.rect({ - x: 0, - y: 0, - width: animationType === 'in' && direction === 'x' ? 0 : width, - height: animationType === 'in' && direction === 'y' ? 0 : height, - fill: true - }); - let fromAttributes: Partial = {}; - let toAttributes: Partial = {}; - if (direction === 'y') { - if (orient === 'negative') { - fromAttributes = { y: height, height: 0 }; - toAttributes = { y: 0, height: height }; - } else { - fromAttributes = { height: 0 }; - toAttributes = { height: height }; - } - } else { - if (orient === 'negative') { - fromAttributes = { x: width, width: 0 }; - toAttributes = { x: 0, width: width }; - } else { - fromAttributes = { width: 0 }; - toAttributes = { width: width }; - } - } - super( - animationType === 'in' ? fromAttributes : toAttributes, - animationType === 'in' ? toAttributes : fromAttributes, - duration, - easing, - { group: params?.group, clipGraphic: rect } - ); - } -} - -type RotateSphereParams = - | { - center: { x: number; y: number; z: number }; - r: number; - cb?: (out: any) => void; - } - | (() => any); - -export class RotateBySphereAnimate extends ACustomAnimate { - declare params: RotateSphereParams; - declare theta: number; - declare phi: number; - - onStart(): void { - const { center, r } = typeof this.params === 'function' ? this.params() : this.params; - const startX = this.target.getComputedAttribute('x'); - const startY = this.target.getComputedAttribute('y'); - const startZ = this.target.getComputedAttribute('z'); - const phi = Math.acos((startY - center.y) / r); - let theta = Math.acos((startX - center.x) / r / Math.sin(phi)); - if (startZ - center.z < 0) { - theta = pi2 - theta; - } - this.theta = theta; - this.phi = phi; - } - - onBind() { - return; - } - - onEnd() { - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.phi == null || this.theta == null) { - return; - } - const { center, r, cb } = typeof this.params === 'function' ? this.params() : this.params; - const deltaAngle = Math.PI * 2 * ratio; - const theta = this.theta + deltaAngle; - const phi = this.phi; - const x = r * Math.sin(phi) * Math.cos(theta) + center.x; - const y = r * Math.cos(phi) + center.y; - const z = r * Math.sin(phi) * Math.sin(theta) + center.z; - out.x = x; - out.y = y; - out.z = z; - // out.beta = phi; - out.alpha = theta + pi / 2; - while (out.alpha > pi2) { - out.alpha -= pi2; - } - out.alpha = pi2 - out.alpha; - - out.zIndex = out.z * -10000; - - cb && cb(out); - } -} - -export class AttributeAnimate extends ACustomAnimate { - declare target: IGroup; - - constructor(to: Record, duration: number, easing: EasingType) { - super({}, to, duration, easing); - } - - getEndProps(): Record { - return this.to; - } - - onBind(): void { - Object.keys(this.to).forEach(k => { - this.from[k] = this.target.getComputedAttribute(k); - }); - return; - } - - onEnd(): void { - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - this.target.stepInterpolate( - this.subAnimate, - this.subAnimate.animate, - out, - this.step, - ratio, - end, - this.to, - this.from - ); - } -} - -export class AnimateGroup extends ACustomAnimate { - declare customAnimates: ACustomAnimate[]; - declare updating: boolean; - - constructor(duration: number, customAnimates: ACustomAnimate[]) { - super(null, null, duration, 'linear'); - this.customAnimates = customAnimates; - } - - initAnimates() { - this.customAnimates.forEach(a => { - a.step = this.step; - a.subAnimate = this.subAnimate; - a.target = this.target; - }); - } - - getEndProps(): Record { - const props = {}; - this.customAnimates.forEach(a => { - Object.assign(props, a.getEndProps()); - }); - return props; - } - - onBind(): void { - this.initAnimates(); - this.customAnimates.forEach(a => { - a.onBind(); - }); - return; - } - - onEnd(): void { - this.customAnimates.forEach(a => { - a.onEnd(); - }); - return; - } - - onStart(): void { - this.customAnimates.forEach(a => { - a.onStart(); - }); - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.updating) { - return; - } - this.updating = true; - this.customAnimates.forEach(a => { - const easing = a.easing; - const easingFunc = typeof easing === 'string' ? Easing[easing] : easing; - ratio = easingFunc(ratio); - a.onUpdate(end, ratio, out); - }); - this.updating = false; - return; - } -} - -export class AnimateGroup1 extends ACustomAnimate { - declare customAnimates: ACustomAnimate[]; - declare updating: boolean; - - constructor(duration: number, customAnimates: ACustomAnimate[]) { - super(null, null, duration, 'linear'); - this.customAnimates = customAnimates; - } - - initAnimates() { - this.customAnimates.forEach(a => { - a.step = this.step; - a.subAnimate = this.subAnimate; - a.target = this.target; - }); - } - - getEndProps(): Record { - const props = {}; - this.customAnimates.forEach(a => { - Object.assign(props, a.getEndProps()); - }); - return props; - } - - onBind(): void { - this.initAnimates(); - this.customAnimates.forEach(a => { - a.onBind(); - }); - return; - } - - onEnd(): void { - this.customAnimates.forEach(a => { - a.onEnd(); - }); - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - if (this.updating) { - return; - } - this.updating = true; - this.customAnimates.forEach(a => { - const easing = a.easing; - const easingFunc = typeof easing === 'string' ? Easing[easing] : easing; - ratio = easingFunc(ratio); - a.onUpdate(end, ratio, out); - }); - this.updating = false; - return; - } -} diff --git a/packages/vrender-core/src/animate/default-ticker.ts b/packages/vrender-core/src/animate/default-ticker.ts deleted file mode 100644 index 80d217394..000000000 --- a/packages/vrender-core/src/animate/default-ticker.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DefaultTicker } from './Ticker/default-ticker'; -import { defaultTimeline } from './timeline'; - -export const defaultTicker = new DefaultTicker(); -defaultTicker.addTimeline(defaultTimeline); -const TICKER_FPS = 60; -defaultTicker.setFPS(TICKER_FPS); diff --git a/packages/vrender-core/src/animate/group-fade.ts b/packages/vrender-core/src/animate/group-fade.ts deleted file mode 100644 index 838179d85..000000000 --- a/packages/vrender-core/src/animate/group-fade.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { IGroup } from '../interface/graphic/group'; -import { ACustomAnimate } from './animate'; - -export class GroupFadeIn extends ACustomAnimate { - declare target: IGroup; - - getEndProps(): Record { - return {}; - } - - onBind(): void { - this.target.setTheme({ - common: { - opacity: 0 - } - }); - return; - } - - onEnd(): void { - this.target.setTheme({ - common: { - opacity: 1 - } - }); - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - this.target.setTheme({ - common: { - opacity: ratio - } - }); - } -} - -export class GroupFadeOut extends ACustomAnimate { - declare target: IGroup; - - getEndProps(): Record { - return {}; - } - - onBind(): void { - this.target.setTheme({ - common: { - opacity: 1 - } - }); - return; - } - - onEnd(): void { - this.target.setTheme({ - common: { - opacity: 0 - } - }); - return; - } - - onUpdate(end: boolean, ratio: number, out: Record): void { - this.target.setTheme({ - common: { - opacity: 1 - ratio - } - }); - } -} diff --git a/packages/vrender-core/src/animate/index.ts b/packages/vrender-core/src/animate/index.ts deleted file mode 100644 index b04ca57b3..000000000 --- a/packages/vrender-core/src/animate/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from './Ticker'; -export * from './animate'; -export * from './config'; -export * from './custom-animate'; -export * from './morphing'; -export * from './timeline'; -export * from './group-fade'; -export * from './easing'; diff --git a/packages/vrender-core/src/animate/timeline.ts b/packages/vrender-core/src/animate/timeline.ts deleted file mode 100644 index 463e8b684..000000000 --- a/packages/vrender-core/src/animate/timeline.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { AnimateStatus } from '../common/enums'; -import { Generator } from '../common/generator'; -import type { IAnimate, ITimeline } from '../interface'; - -// 管理一组动画 -export class DefaultTimeline implements ITimeline { - declare id: number; - protected declare animateHead: IAnimate | null; - protected declare animateTail: IAnimate | null; - protected declare ticker: any; - declare animateCount: number; - protected declare paused: boolean; - - constructor() { - this.id = Generator.GenAutoIncrementId(); - this.animateHead = null; - this.animateTail = null; - this.animateCount = 0; - this.paused = false; - } - - addAnimate(animate: IAnimate) { - if (!this.animateTail) { - this.animateHead = animate; - this.animateTail = animate; - } else { - this.animateTail.nextAnimate = animate; - animate.prevAnimate = this.animateTail; - this.animateTail = animate; - animate.nextAnimate = null; - } - this.animateCount++; - } - - pause() { - this.paused = true; - } - resume() { - this.paused = false; - } - - tick(delta: number) { - if (this.paused) { - return; - } - let animate = this.animateHead; - this.animateCount = 0; - while (animate) { - if (animate.status === AnimateStatus.END) { - this.removeAnimate(animate); - } else if (animate.status === AnimateStatus.RUNNING || animate.status === AnimateStatus.INITIAL) { - this.animateCount++; - animate.advance(delta); - } else if (animate.status === AnimateStatus.PAUSED) { - // 暂停 - this.animateCount++; - } - animate = animate.nextAnimate; - } - } - - clear() { - let animate = this.animateHead; - while (animate) { - animate.release(); - animate = animate.nextAnimate; - } - this.animateHead = null; - this.animateTail = null; - this.animateCount = 0; - } - - removeAnimate(animate: IAnimate, release: boolean = true) { - animate._onRemove && animate._onRemove.forEach(cb => cb()); - if (animate === this.animateHead) { - this.animateHead = animate.nextAnimate; - if (animate === this.animateTail) { - // 只有一个元素 - this.animateTail = null; - } else { - // 有多个元素 - this.animateHead.prevAnimate = null; - } - } else if (animate === this.animateTail) { - // 有多个元素 - this.animateTail = animate.prevAnimate; - this.animateTail.nextAnimate = null; - // animate.prevAnimate = null; - } else { - animate.prevAnimate.nextAnimate = animate.nextAnimate; - animate.nextAnimate.prevAnimate = animate.prevAnimate; - // animate不支持二次复用,不需要重置 - // animate.prevAnimate = null; - // animate.nextAnimate = null; - } - release && animate.release(); - - return; - } -} - -export const defaultTimeline = new DefaultTimeline(); diff --git a/packages/vrender-core/src/application.ts b/packages/vrender-core/src/application.ts index 920a43b22..55f694eda 100644 --- a/packages/vrender-core/src/application.ts +++ b/packages/vrender-core/src/application.ts @@ -1,11 +1,12 @@ import type { ILayerService } from './interface/core'; import type { IGraphicUtil, ITransformUtil } from './interface/core'; -import type { IGlobal, IGraphicService } from './interface'; +import type { IGlobal, IGraphicService, IRenderService } from './interface'; export class Application { global: IGlobal; graphicUtil: IGraphicUtil; graphicService: IGraphicService; + renderService: IRenderService; transformUtil: ITransformUtil; layerService: ILayerService; } diff --git a/packages/vrender-core/src/canvas/empty-context.ts b/packages/vrender-core/src/canvas/empty-context.ts index c64eee7bd..a143b94bd 100644 --- a/packages/vrender-core/src/canvas/empty-context.ts +++ b/packages/vrender-core/src/canvas/empty-context.ts @@ -126,6 +126,12 @@ export class EmptyContext2d implements IContext2d { this.restore(); } + reset() { + this.matrix.setValue(1, 0, 0, 1, 0, 0); + this.applyedMatrix = new Matrix(1, 0, 0, 1, 0, 0); + this.stack.length = 0; + } + restore() { if (this.stack.length > 0) { matrixAllocate.free(this.matrix); diff --git a/packages/vrender-core/src/color-string/interpolate.ts b/packages/vrender-core/src/color-string/interpolate.ts index a1b2c5a87..fd6d61857 100644 --- a/packages/vrender-core/src/color-string/interpolate.ts +++ b/packages/vrender-core/src/color-string/interpolate.ts @@ -25,8 +25,8 @@ export function interpolateColor( // 待性能优化 const out: string[] = new Array(4).fill(0).map((_, index) => { return _interpolateColor( - isArray(from) ? (from[index] as string) : from, - isArray(to) ? (to[index] as string) : to, + isArray(from) ? ((from[index] ?? from[0]) as string) : from, + isArray(to) ? ((to[index] ?? to[0]) as string) : to, ratio, alphaChannel ) as string; @@ -186,6 +186,16 @@ export function interpolatePureColorArray( from[3] + (to[3] - from[3]) * ratio ]; } +export function interpolatePureColorArrayToStr( + from: [number, number, number, number], + to: [number, number, number, number], + ratio: number +): string { + // eslint-disable-next-line max-len + return `rgba(${from[0] + (to[0] - from[0]) * ratio},${from[1] + (to[1] - from[1]) * ratio},${ + from[2] + (to[2] - from[2]) * ratio + },${from[3] + (to[3] - from[3]) * ratio})`; +} const _fromColorRGB: [number, number, number, number] = [0, 0, 0, 0]; const _toColorRGB: [number, number, number, number] = [0, 0, 0, 0]; diff --git a/packages/vrender-core/src/common/custom-path2d.ts b/packages/vrender-core/src/common/custom-path2d.ts index 33dd884eb..c7dd551d7 100644 --- a/packages/vrender-core/src/common/custom-path2d.ts +++ b/packages/vrender-core/src/common/custom-path2d.ts @@ -139,15 +139,15 @@ export class CustomPath2D extends CurvePath implements ICustomPath2D { list[enumCommandMap.C] = (cmd: CommandType) => `C${cmd[1]} ${cmd[2]} ${cmd[3]} ${cmd[4]} ${cmd[5]} ${cmd[6]}`; list[enumCommandMap.A] = (cmd: CommandType) => { const bezierPathList: number[] = []; - addArcToBezierPath( - bezierPathList, - cmd[4] as number, - cmd[5] as number, - cmd[1] as number, - cmd[2] as number, - cmd[3] as number, - cmd[3] as number - ); + const x = cmd[1] as number; + const y = cmd[2] as number; + const radius = cmd[3] as number; + const startAngle = cmd[4] as number; + const endAngle = cmd[5] as number; + const counterclockwise = cmd[6] as boolean; + + addArcToBezierPath(bezierPathList, startAngle, endAngle, x, y, radius, radius, counterclockwise); + let path = ''; for (let i = 0; i < bezierPathList.length; i += 6) { path += `C${bezierPathList[i]} ${bezierPathList[i + 1]} ${bezierPathList[i + 2]} ${bezierPathList[i + 3]} ${ diff --git a/packages/vrender-core/src/common/diff.ts b/packages/vrender-core/src/common/diff.ts new file mode 100644 index 000000000..e2b045478 --- /dev/null +++ b/packages/vrender-core/src/common/diff.ts @@ -0,0 +1,38 @@ +import { isEqual } from '@visactor/vutils'; + +/** + * 比较两个对象的差异,深比较,返回差异的对象 + * @param oldAttrs 原始对象 + * @param newAttrs 目标对象 + * @param getAttr 获取属性值的函数(在于oldAttrs里有,newAttrs里没有的属性,调用函数获取,可选) + * @returns 差异的对象 + */ +export function diff>( + oldAttrs: T, + newAttrs: T, + getAttr?: (attr: keyof T) => any +): Record { + const diffObj: Record = {}; + + // 处理newAttrs中的属性 + for (const key in newAttrs) { + // 如果oldAttrs不存在该属性或者属性值不同 + if (!(key in oldAttrs) || !isEqual(oldAttrs[key], newAttrs[key])) { + diffObj[key] = newAttrs[key]; + } + } + + // 处理oldAttrs中有但newAttrs中没有的属性 + if (getAttr) { + for (const key in oldAttrs) { + if (!(key in newAttrs)) { + const value = getAttr(key); + if (value !== undefined) { + diffObj[key] = value; + } + } + } + } + + return diffObj; +} diff --git a/packages/vrender-core/src/common/enums.ts b/packages/vrender-core/src/common/enums.ts index 91ebdd128..4b1ff531c 100644 --- a/packages/vrender-core/src/common/enums.ts +++ b/packages/vrender-core/src/common/enums.ts @@ -41,25 +41,6 @@ export enum AttributeUpdateType { ROTATE_TO = 25 } -export enum AnimateStatus { - INITIAL = 0, - RUNNING = 1, - PAUSED = 2, - END = 3 -} - -export enum AnimateMode { - NORMAL = 0b0000, - SET_ATTR_IMMEDIATELY = 0b0001 -} - -export enum AnimateStepType { - 'wait' = 'wait', - 'from' = 'from', - 'to' = 'to', - 'customAnimate' = 'customAnimate' -} - export enum Direction { ROW = 1, COLUMN = 2 diff --git a/packages/vrender-core/src/common/morphing-utils.ts b/packages/vrender-core/src/common/morphing-utils.ts index 8bdf92ee2..f10d31ef2 100644 --- a/packages/vrender-core/src/common/morphing-utils.ts +++ b/packages/vrender-core/src/common/morphing-utils.ts @@ -332,263 +332,132 @@ export function alignBezierCurves(array1: number[][], array2: number[][]) { return [newArray1, newArray2]; } -const addLineToBezierPath = (bezierPath: number[], x0: number, y0: number, x1: number, y1: number) => { - if (!(isNumberClose(x0, x1) && isNumberClose(y0, y1))) { - bezierPath.push(x0, y0, x1, y1, x1, y1); - } -}; - +/** + * 将路径转换为贝塞尔曲线数组 + * 通过复用CustomPath2D中的方法,确保处理的一致性 + * @param path 要转换的路径 + * @returns 贝塞尔曲线数组 + */ export function pathToBezierCurves(path: ICustomPath2D): number[][] { - const commandList = path.commandList; - - const bezierArrayGroups: number[][] = []; - let currentSubpath: number[]; - - // end point - let xi: number = 0; - let yi: number = 0; - // start point - let x0: number = 0; - let y0: number = 0; - - const createNewSubpath = (x: number, y: number) => { - // More than one M command - if (currentSubpath && currentSubpath.length > 2) { - bezierArrayGroups.push(currentSubpath); - } - currentSubpath = [x, y]; - }; - - // the first control point - let x1: number; - let y1: number; - // the second control point - let x2: number; - let y2: number; - - for (let i = 0, len = commandList.length; i < len; i++) { - const cmd = commandList[i]; - - const isFirst = i === 0; - - if (isFirst) { - // 如果第一个命令是 L, C, Q - // 则 previous point 同绘制命令的第一个 point - // 第一个命令为 Arc 的情况下会在后面特殊处理 - x0 = xi = cmd[1] as number; - y0 = yi = cmd[2] as number; - - if ([enumCommandMap.L, enumCommandMap.C, enumCommandMap.Q].includes(cmd[0])) { - // Start point - currentSubpath = [x0, y0]; - } - } + // 创建临时路径和临时上下文 + const tempPath = new CustomPath2D(); - switch (cmd[0]) { - case enumCommandMap.M: - // moveTo 命令重新创建一个新的 subpath, 并且更新新的起点 - // 在 closePath 的时候使用 - xi = x0 = cmd[1] as number; - yi = y0 = cmd[2] as number; - - createNewSubpath(x0, y0); - break; - case enumCommandMap.L: - x1 = cmd[1] as number; - y1 = cmd[2] as number; - addLineToBezierPath(currentSubpath, xi, yi, x1, y1); - xi = x1; - yi = y1; - break; - case enumCommandMap.C: - currentSubpath.push( - cmd[1] as number, - cmd[2] as number, - cmd[3] as number, - cmd[4] as number, - (xi = cmd[5] as number), - (yi = cmd[6] as number) - ); - break; - case enumCommandMap.Q: - x1 = cmd[1] as number; - y1 = cmd[2] as number; - x2 = cmd[3] as number; - y2 = cmd[4] as number; - currentSubpath.push( - // Convert quadratic to cubic - xi + (2 / 3) * (x1 - xi), - yi + (2 / 3) * (y1 - yi), - x2 + (2 / 3) * (x1 - x2), - y2 + (2 / 3) * (y1 - y2), - x2, - y2 - ); - xi = x2; - yi = y2; - break; - case enumCommandMap.A: { - const cx = cmd[1] as number; - const cy = cmd[2] as number; - const rx = cmd[3] as number; - const ry = rx; - const startAngle = cmd[4] as number; - const endAngle = cmd[5] as number; - - const counterClockwise = !!(cmd[6] as number); - - x1 = Math.cos(startAngle) * rx + cx; - y1 = Math.sin(startAngle) * rx + cy; - if (isFirst) { - // 直接使用 arc 命令 - // 第一个命令起点还未定义 - x0 = x1; - y0 = y1; - createNewSubpath(x0, y0); - } else { - // Connect a line between current point to arc start point. - addLineToBezierPath(currentSubpath, xi, yi, x1, y1); - } - - xi = Math.cos(endAngle) * rx + cx; - yi = Math.sin(endAngle) * rx + cy; + // 将路径转换为SVG路径字符串,这样可以利用CustomPath2D中的解析能力 + const svgPathString = path.toString(); - const step = ((counterClockwise ? -1 : 1) * Math.PI) / 2; - - for (let angle = startAngle; counterClockwise ? angle > endAngle : angle < endAngle; angle += step) { - const nextAngle = counterClockwise ? Math.max(angle + step, endAngle) : Math.min(angle + step, endAngle); - addArcToBezierPath(currentSubpath, angle, nextAngle, cx, cy, rx, ry); - } - break; - } - case enumCommandMap.E: { - const cx = cmd[1] as number; - const cy = cmd[2] as number; - const rx = cmd[3] as number; - const ry = cmd[4] as number; - const rotate = cmd[5] as number; - const startAngle = cmd[6] as number; - const endAngle = (cmd[7] as number) + startAngle; - - const anticlockwise = !!(cmd[8] as number); - const hasRotate = !isNumberClose(rotate, 0); - const rc = Math.cos(rotate); - const rs = Math.sin(rotate); - - let xTemp = Math.cos(startAngle) * rx; - let yTemp = Math.sin(startAngle) * ry; - - if (hasRotate) { - x1 = xTemp * rc - yTemp * rs + cx; - y1 = xTemp * rs + yTemp * rc + cy; - } else { - x1 = xTemp + cx; - y1 = yTemp + cy; - } - if (isFirst) { - // 直接使用 arc 命令 - // 第一个命令起点还未定义 - x0 = x1; - y0 = y1; - createNewSubpath(x0, y0); - } else { - // Connect a line between current point to arc start point. - addLineToBezierPath(currentSubpath, xi, yi, x1, y1); - } + // 如果路径为空,直接返回空数组 + if (!svgPathString) { + return []; + } - xTemp = Math.cos(endAngle) * rx; - yTemp = Math.sin(endAngle) * ry; - if (hasRotate) { - xi = xTemp * rc - yTemp * rs + cx; - yi = xTemp * rs + yTemp * rc + cy; - } else { - xi = xTemp + cx; - yi = yTemp + cy; - } + // 使用临时路径解析SVG字符串 + tempPath.fromString(svgPathString); - const step = ((anticlockwise ? -1 : 1) * Math.PI) / 2; + // 确保曲线已经构建 + const curves = tempPath.tryBuildCurves(); - for (let angle = startAngle; anticlockwise ? angle > endAngle : angle < endAngle; angle += step) { - const nextAngle = anticlockwise ? Math.max(angle + step, endAngle) : Math.min(angle + step, endAngle); - addArcToBezierPath(currentSubpath, angle, nextAngle, cx, cy, rx, ry); + if (!curves || curves.length === 0) { + return []; + } - if (hasRotate) { - const curLen = currentSubpath.length; + // 用于存储分离的子路径 + const bezierSubpaths: number[][] = []; + let currentSubpath: number[] = null; + + // 初始化当前子路径 + currentSubpath = []; + let firstX = 0; // 记录子路径起点X (用于闭合路径) + let firstY = 0; // 记录子路径起点Y (用于闭合路径) + let lastX = 0; // 记录上一个点的X (用于连续线段) + let lastY = 0; // 记录上一个点的Y (用于连续线段) + let isSubpathStart = true; + let isPathClosed = false; + + for (let i = 0; i < curves.length; i++) { + const curve = curves[i]; + + // 如果是新的子路径开始或者第一个点 + if (isSubpathStart) { + firstX = curve.p0.x; + firstY = curve.p0.y; + lastX = firstX; + lastY = firstY; + currentSubpath = [firstX, firstY]; + bezierSubpaths.push(currentSubpath); + isSubpathStart = false; + } - for (let j = curLen - 6; j <= curLen - 1; j += 2) { - xTemp = currentSubpath[j]; - yTemp = currentSubpath[j + 1]; + // 处理不同类型的曲线 + if (curve.p1 && curve.p2 && curve.p3) { + // 三次贝塞尔曲线 + currentSubpath.push(curve.p1.x, curve.p1.y, curve.p2.x, curve.p2.y, curve.p3.x, curve.p3.y); + lastX = curve.p3.x; + lastY = curve.p3.y; + } else if (curve.p1 && curve.p2) { + // 二次贝塞尔曲线,转换为三次贝塞尔曲线 + const x1 = curve.p1.x; + const y1 = curve.p1.y; + const x2 = curve.p2.x; + const y2 = curve.p2.y; + + currentSubpath.push( + lastX + (2 / 3) * (x1 - lastX), + lastY + (2 / 3) * (y1 - lastY), + x2 + (2 / 3) * (x1 - x2), + y2 + (2 / 3) * (y1 - y2), + x2, + y2 + ); + + lastX = x2; + lastY = y2; + } else if (curve.p1) { + // 直线段,转换为贝塞尔曲线格式 + // 直线的情况,p1就是终点 + const endX = curve.p1.x; + const endY = curve.p1.y; + + // 避免添加长度为0的线段 + if (!(Math.abs(lastX - endX) < 1e-10 && Math.abs(lastY - endY) < 1e-10)) { + // 使用addLineToBezierPath的逻辑:x0,y0, x1,y1, x1,y1 + // 第一个控制点等于起点,第二个控制点等于终点,终点等于终点 + currentSubpath.push( + lastX, + lastY, // 第一个控制点 = 起点 + endX, + endY, // 第二个控制点 = 终点 + endX, + endY // 终点 + ); + } - currentSubpath[j] = (xTemp - cx) * rc - (yTemp - cy) * rs + cx; - currentSubpath[j + 1] = (xTemp - cx) * rs + (yTemp - cy) * rc + cy; - } - } - } + lastX = endX; + lastY = endY; + } - break; - } - case enumCommandMap.R: { - x0 = xi = cmd[1] as number; - y0 = yi = cmd[2] as number; - x1 = x0 + (cmd[3] as number); - y1 = y0 + (cmd[4] as number); - - // rect is an individual path. - createNewSubpath(x1, y0); - addLineToBezierPath(currentSubpath, x1, y0, x1, y1); - addLineToBezierPath(currentSubpath, x1, y1, x0, y1); - addLineToBezierPath(currentSubpath, x0, y1, x0, y0); - addLineToBezierPath(currentSubpath, x0, y0, x1, y0); - break; + // 检查是否是闭合路径(最后一个点回到起点) + if (i === curves.length - 1) { + if (Math.abs(lastX - firstX) < 1e-10 && Math.abs(lastY - firstY) < 1e-10) { + isPathClosed = true; } - case enumCommandMap.AT: { - const tx1 = cmd[1] as number; - const ty1 = cmd[2] as number; - const tx2 = cmd[3] as number; - const ty2 = cmd[4] as number; - const r = cmd[5] as number; - - const dis1 = PointService.distancePP({ x: xi, y: yi }, { x: tx1, y: ty1 }); - const dis2 = PointService.distancePP({ x: tx2, y: ty2 }, { x: tx1, y: ty1 }); - const theta = ((xi - tx1) * (tx2 - tx1) + (yi - ty1) * (ty2 - ty1)) / (dis1 * dis2); - const dis = r / Math.sin(theta / 2); - const midX = (xi + tx2 - 2 * tx1) / 2; - const midY = (yi + ty2 - 2 * ty1) / 2; - const midLen = PointService.distancePP({ x: midX, y: midY }, { x: 0, y: 0 }); - const cx = tx1 + (dis * midX) / midLen; - const cy = tx2 + (dis * midY) / midLen; - const disP = Math.sqrt(dis * dis - r * r); - x0 = tx1 + (disP * (xi - tx1)) / dis1; - y0 = ty1 + (disP * (yi - ty1)) / dis1; - - // Connect a line between current point to arc start point. - addLineToBezierPath(currentSubpath, xi, yi, x0, y0); - - xi = tx1 + (disP * (tx2 - tx1)) / dis2; - yi = ty1 + (disP * (ty2 - ty1)) / dis2; - - const startAngle = getAngleByPoint({ x: cx, y: cy }, { x: x0, y: y0 }); - - const endAngle = getAngleByPoint({ x: cx, y: cy }, { x: xi, y: yi }); - - addArcToBezierPath(currentSubpath, startAngle, endAngle, cx, cy, r, r); - - break; - } - case enumCommandMap.Z: { - currentSubpath && addLineToBezierPath(currentSubpath, xi, yi, x0, y0); - xi = x0; - yi = y0; - break; + } + + // 检查是否需要开始新的子路径 + // 只有在检测到明确的路径中断(不连续的点)时才开始新子路径 + if (i < curves.length - 1) { + const nextCurve = curves[i + 1]; + if (Math.abs(lastX - nextCurve.p0.x) > 1e-10 || Math.abs(lastY - nextCurve.p0.y) > 1e-10) { + // 当前子路径结束,需要创建新的子路径 + isSubpathStart = true; } } } - if (currentSubpath && currentSubpath.length > 2) { - bezierArrayGroups.push(currentSubpath); - } + // 移除空的子路径 + const validSubpaths = bezierSubpaths.filter(subpath => subpath.length > 2); - return bezierArrayGroups; + // 为了保持与原始函数一致,如果只有一个子路径,返回它的数组 + return validSubpaths.length === 1 ? [validSubpaths[0]] : validSubpaths; } export function applyTransformOnBezierCurves(bezierCurves: number[][], martrix: IMatrix) { diff --git a/packages/vrender-core/src/common/performance-raf.ts b/packages/vrender-core/src/common/performance-raf.ts new file mode 100644 index 000000000..d9d2bae09 --- /dev/null +++ b/packages/vrender-core/src/common/performance-raf.ts @@ -0,0 +1,45 @@ +import { application } from '../application'; + +let idx = 0; + +/** + * 性能优化,将requestAnimationFrame的回调函数存储起来,在下一帧执行 + */ +export class PerformanceRAF { + nextAnimationFrameCbs: Map = new Map(); + private _rafHandle: number | null = null; + + addAnimationFrameCb(callback: FrameRequestCallback) { + this.nextAnimationFrameCbs.set(++idx, callback); + // 下一帧执行nextAnimationFrameCbs + this.tryRunAnimationFrameNextFrame(); + return idx; + } + + /** + * 移除指定索引的回调函数 + * @param index raf索引,从1开始,相当于内部nextAnimationFrameCbs的idx + 1 + * @returns 是否移除成功 + */ + removeAnimationFrameCb(index: number): boolean { + if (this.nextAnimationFrameCbs.has(index)) { + this.nextAnimationFrameCbs.delete(index); + return true; + } + return false; + } + + protected runAnimationFrame = (time: number) => { + this._rafHandle = null; + const cbs = this.nextAnimationFrameCbs; + this.nextAnimationFrameCbs = new Map(); + cbs.forEach(cb => cb(time)); + }; + + protected tryRunAnimationFrameNextFrame = () => { + if (this._rafHandle !== null || this.nextAnimationFrameCbs.size === 0) { + return; + } + this._rafHandle = application.global.getRequestAnimationFrame()(this.runAnimationFrame); + }; +} diff --git a/packages/vrender-core/src/common/polygon.ts b/packages/vrender-core/src/common/polygon.ts index cc84518a6..014c5a730 100644 --- a/packages/vrender-core/src/common/polygon.ts +++ b/packages/vrender-core/src/common/polygon.ts @@ -10,6 +10,9 @@ import type { IPath2D } from '../interface'; * @param y */ export function drawPolygon(path: IPath2D, points: IPointLike[], x: number, y: number) { + if (!points || !points.length) { + return; + } path.moveTo(points[0].x + x, points[0].y + y); for (let i = 1; i < points.length; i++) { path.lineTo(points[i].x + x, points[i].y + y); diff --git a/packages/vrender-core/src/common/segment/curve/cubic-bezier.ts b/packages/vrender-core/src/common/segment/curve/cubic-bezier.ts index f3494aed6..c064203a2 100644 --- a/packages/vrender-core/src/common/segment/curve/cubic-bezier.ts +++ b/packages/vrender-core/src/common/segment/curve/cubic-bezier.ts @@ -35,35 +35,6 @@ export function divideCubic(curve: ICubicBezierCurve, t: number): ICubicBezierCu return [curve1, curve2]; } -/** - * 对三次贝塞尔曲线进行分割 - * @param p0 起点 - * @param p1 控制点1 - * @param p2 控制点2 - * @param p3 终点 - * @param t - */ -export function divideQuad(curve: IQuadraticBezierCurve, t: number): IQuadraticBezierCurve[] { - const { p0, p1, p2 } = curve; - - // 划分点 - const pt = quadPointAt(p0, p1, p2, t); - // const xt = pt.x; - // const yt = pt.y; - - // 计算两点之间的差值点 - const c1 = PointService.pointAtPP(p0, p1, t); - const c2 = PointService.pointAtPP(p1, p2, t); - // const c3 = PointService.pointAtPP(p2, p3, t); - // const c12 = PointService.pointAtPP(c1, c2, t); - // const c23 = PointService.pointAtPP(c2, c3, t); - // const direction = p1.x1 ? p1.y > p0.y ? 0 : 1 : p1.x > p0.x ? 0 : 1; - - const curve1 = new QuadraticBezierCurve(p0, c1, pt); - const curve2 = new QuadraticBezierCurve(pt, c2, p2); - - return [curve1, curve2]; -} export class CubicBezierCurve extends Curve implements ICubicBezierCurve { type: number = CurveTypeEnum.CubicBezierCurve; diff --git a/packages/vrender-core/src/common/segment/curve/quadratic-bezier.ts b/packages/vrender-core/src/common/segment/curve/quadratic-bezier.ts index 0d7834350..6313f9323 100644 --- a/packages/vrender-core/src/common/segment/curve/quadratic-bezier.ts +++ b/packages/vrender-core/src/common/segment/curve/quadratic-bezier.ts @@ -2,8 +2,37 @@ import type { IDirection, IPath2D, IQuadraticBezierCurve } from '../../../interf import { quadLength, quadPointAt } from '../../bezier-utils'; import { CurveTypeEnum, Direction } from '../../enums'; import { Curve } from './base'; -import { abs, atan2, max, min, type IPoint, type IPointLike } from '@visactor/vutils'; -import { divideQuad } from './cubic-bezier'; +import { abs, atan2, max, min, PointService, type IPoint, type IPointLike } from '@visactor/vutils'; + +/** + * 对三次贝塞尔曲线进行分割 + * @param p0 起点 + * @param p1 控制点1 + * @param p2 控制点2 + * @param p3 终点 + * @param t + */ +export function divideQuad(curve: IQuadraticBezierCurve, t: number): IQuadraticBezierCurve[] { + const { p0, p1, p2 } = curve; + + // 划分点 + const pt = quadPointAt(p0, p1, p2, t); + // const xt = pt.x; + // const yt = pt.y; + + // 计算两点之间的差值点 + const c1 = PointService.pointAtPP(p0, p1, t); + const c2 = PointService.pointAtPP(p1, p2, t); + // const c3 = PointService.pointAtPP(p2, p3, t); + // const c12 = PointService.pointAtPP(c1, c2, t); + // const c23 = PointService.pointAtPP(c2, c3, t); + // const direction = p1.x1 ? p1.y > p0.y ? 0 : 1 : p1.x > p0.x ? 0 : 1; + + const curve1 = new QuadraticBezierCurve(p0, c1, pt); + const curve2 = new QuadraticBezierCurve(pt, c2, p2); + + return [curve1, curve2]; +} export class QuadraticBezierCurve extends Curve implements IQuadraticBezierCurve { type: number = CurveTypeEnum.QuadraticBezierCurve; diff --git a/packages/vrender-core/src/common/segment/index.ts b/packages/vrender-core/src/common/segment/index.ts index 4c065929d..b8496c6df 100644 --- a/packages/vrender-core/src/common/segment/index.ts +++ b/packages/vrender-core/src/common/segment/index.ts @@ -14,6 +14,7 @@ export * from './basis'; export * from './monotone'; export * from './step'; export * from './curve/curve-context'; +export * from './curve/cubic-bezier'; export function calcLineCache( points: IPointLike[], diff --git a/packages/vrender-core/src/common/shape/arc.ts b/packages/vrender-core/src/common/shape/arc.ts index 2c6d36924..d9e4d432f 100644 --- a/packages/vrender-core/src/common/shape/arc.ts +++ b/packages/vrender-core/src/common/shape/arc.ts @@ -173,6 +173,17 @@ export function drawArc( * @license */ +/** + * 将弧形添加到贝塞尔路径 + * @param bezierPath 贝塞尔路径数组 + * @param startAngle 起始角度 + * @param endAngle 结束角度 + * @param cx 圆心x坐标 + * @param cy 圆心y坐标 + * @param rx x半径 + * @param ry y半径 + * @param counterclockwise 是否逆时针,默认为false(顺时针) + */ export const addArcToBezierPath = ( bezierPath: number[], startAngle: number, @@ -180,19 +191,47 @@ export const addArcToBezierPath = ( cx: number, cy: number, rx: number, - ry: number + ry: number, + counterclockwise: boolean = false ) => { // https://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves - const delta = Math.abs(endAngle - startAngle); - const count = delta > 0.5 * Math.PI ? Math.ceil((2 * delta) / Math.PI) : 1; - const stepAngle = (endAngle - startAngle) / count; + // 标准化角度到 [0, 2π] 范围 + const PI2 = Math.PI * 2; + const sAngle = ((startAngle % PI2) + PI2) % PI2; + let eAngle = ((endAngle % PI2) + PI2) % PI2; + + // 确定角度差并进行角度调整 + let deltaAngle; + if (counterclockwise) { + // 逆时针时,确保终点角度小于起点角度 + if (eAngle >= sAngle) { + eAngle -= PI2; + } + deltaAngle = eAngle - sAngle; + } else { + // 顺时针时,确保终点角度大于起点角度 + if (eAngle <= sAngle) { + eAngle += PI2; + } + deltaAngle = eAngle - sAngle; + } + + // 计算需要分成的段数,每段不超过90度 + const count = Math.ceil(Math.abs(deltaAngle) / (Math.PI * 0.5)); + // 每段的角度增量 + const stepAngle = deltaAngle / count; + + // 对每段生成贝塞尔曲线 for (let i = 0; i < count; i++) { - const sa = startAngle + stepAngle * i; - const ea = startAngle + stepAngle * (i + 1); - const len = (Math.tan(Math.abs(stepAngle) / 4) * 4) / 3; - const dir = ea < sa ? -1 : 1; + const sa = sAngle + stepAngle * i; + const ea = sAngle + stepAngle * (i + 1); + + // 计算贝塞尔控制点的参数 + // 4/3 * tan(θ/4) 是贝塞尔曲线近似圆弧的最佳比例 + const len = (4 / 3) * Math.tan(Math.abs(stepAngle) / 4); + // 计算起点和终点坐标 const c1 = Math.cos(sa); const s1 = Math.sin(sa); const c2 = Math.cos(ea); @@ -204,17 +243,19 @@ export const addArcToBezierPath = ( const x4 = c2 * rx + cx; const y4 = s2 * ry + cy; - const hx = rx * len * dir; - const hy = ry * len * dir; + // 计算控制点坐标,符号根据方向调整 + const sign = counterclockwise ? -1 : 1; + const hx = rx * len * sign; + const hy = ry * len * sign; + // 将贝塞尔曲线点添加到路径 bezierPath.push( - // Move control points on tangent. - x1 - hx * s1, - y1 + hy * c1, - x4 + hx * s2, - y4 - hy * c2, - x4, - y4 + x1 - hx * s1, // 第一个控制点x + y1 + hy * c1, // 第一个控制点y + x4 + hx * s2, // 第二个控制点x + y4 - hy * c2, // 第二个控制点y + x4, // 终点x + y4 // 终点y ); } }; diff --git a/packages/vrender-core/src/common/split-path.ts b/packages/vrender-core/src/common/split-path.ts index f3d56539d..3f7e0641d 100644 --- a/packages/vrender-core/src/common/split-path.ts +++ b/packages/vrender-core/src/common/split-path.ts @@ -231,7 +231,7 @@ export const splitArea = (area: IArea, count: number) => { const res: { points: IPointLike[] }[] = []; - recursiveCallBinarySplit(points, count, res); + recursiveCallBinarySplit(allPoints, count, res); return res; }; diff --git a/packages/vrender-core/src/common/utils.ts b/packages/vrender-core/src/common/utils.ts index 7a425a4af..8231abcdf 100644 --- a/packages/vrender-core/src/common/utils.ts +++ b/packages/vrender-core/src/common/utils.ts @@ -289,23 +289,6 @@ export function pointsInterpolation( return points; } -export const transformKeys = [ - 'x', - 'y', - 'dx', - 'dy', - 'scaleX', - 'scaleY', - 'angle', - 'anchor', - 'postMatrix', - 'scrollX', - 'scrollY' -]; -export const isTransformKey = (key: string) => { - return transformKeys.includes(key); -}; - export function getAttributeFromDefaultAttrList(attr: Record | Record[], key: string) { if (isArray(attr)) { let val; diff --git a/packages/vrender-core/src/core/global.ts b/packages/vrender-core/src/core/global.ts index f94e8edbe..597736668 100644 --- a/packages/vrender-core/src/core/global.ts +++ b/packages/vrender-core/src/core/global.ts @@ -16,6 +16,7 @@ import { EnvContribution } from '../constants'; import type { IAABBBoundsLike } from '@visactor/vutils'; import { container } from '../container'; import { Generator } from '../common/generator'; +import { PerformanceRAF } from '../common/performance-raf'; import { EventListenerManager } from '../common/event-listener-manager'; const defaultEnv: EnvType = 'browser'; @@ -26,6 +27,8 @@ export class DefaultGlobal extends EventListenerManager implements IGlobal { private _isSafari?: boolean; private _isChrome?: boolean; private _isImageAnonymous?: boolean = true; + private _performanceRAFList: PerformanceRAF[] = []; + get env(): EnvType { return this._env; } @@ -275,6 +278,50 @@ export class DefaultGlobal extends EventListenerManager implements IGlobal { return this.envContribution.getRequestAnimationFrame(); } + /** + * 获取特定的requestAnimationFrame,同一个id底层共用一个原生的requestAnimationFrame + * @param id 唯一标识,用于区分不同的requestAnimationFrame,请使用数字,不要太大,因为底层使用的是数组索引 + */ + getSpecifiedRequestAnimationFrame(id: number) { + if (!this._env) { + this.setEnv(defaultEnv); + } + + // Check if PerformanceRAF instance exists for this id + if (!this._performanceRAFList[id]) { + this._performanceRAFList[id] = new PerformanceRAF(); + } + + const performanceRAF = this._performanceRAFList[id]; + + // Return a function that adds the callback to the specific PerformanceRAF instance + return (callback: FrameRequestCallback): number => { + return performanceRAF.addAnimationFrameCb(callback); + }; + } + + /** + * 获取特定的cancelAnimationFrame,用于取消特定id的requestAnimationFrame + * @param id + */ + getSpecifiedCancelAnimationFrame(id: number) { + if (!this._env) { + this.setEnv(defaultEnv); + } + + // Return no-op if no PerformanceRAF instance exists for this id + if (!this._performanceRAFList[id]) { + return () => false; + } + + const performanceRAF = this._performanceRAFList[id]; + + // Return a function that removes the callback from the specific PerformanceRAF instance + return (handle: number): boolean => { + return performanceRAF.removeAnimationFrameCb(handle); + }; + } + getCancelAnimationFrame() { if (!this._env) { this.setEnv(defaultEnv); diff --git a/packages/vrender-core/src/core/stage.ts b/packages/vrender-core/src/core/stage.ts index 58277b4d9..efeb7220a 100644 --- a/packages/vrender-core/src/core/stage.ts +++ b/packages/vrender-core/src/core/stage.ts @@ -24,7 +24,8 @@ import type { IOptimizeType, LayerMode, PickResult, - IPlugin + IPlugin, + IGraphicService } from '../interface'; import { VWindow } from './window'; import type { Layer } from './layer'; @@ -39,13 +40,12 @@ import { AutoRenderPlugin } from '../plugins/builtin-plugin/auto-render-plugin'; import { AutoRefreshPlugin } from '../plugins/builtin-plugin/auto-refresh-plugin'; import { IncrementalAutoRenderPlugin } from '../plugins/builtin-plugin/incremental-auto-render-plugin'; import { DirtyBoundsPlugin } from '../plugins/builtin-plugin/dirty-bounds-plugin'; -import { defaultTicker } from '../animate/default-ticker'; import { SyncHook } from '../tapable'; import { LayerService } from './constants'; -import { DefaultTimeline } from '../animate'; import { application } from '../application'; import { isBrowserEnv } from '../env-check'; import { Factory } from '../factory'; +import { Graphic, GraphicService } from '../graphic'; const DefaultConfig = { WIDTH: 500, @@ -164,7 +164,7 @@ export class Stage extends Group implements IStage { return this.at(0) as unknown as ILayer; } - ticker: ITicker; + protected _ticker: ITicker; autoRender: boolean; autoRefresh: boolean; @@ -179,6 +179,7 @@ export class Stage extends Group implements IStage { protected pickerService?: IPickerService; readonly pluginService: IPluginService; readonly layerService: ILayerService; + readonly graphicService: IGraphicService; private _eventSystem?: EventSystem; private get eventSystem(): EventSystem { return this._eventSystem; @@ -201,6 +202,23 @@ export class Stage extends Group implements IStage { // 第一次render不需要强行走动画 protected tickedBeforeRender: boolean = true; + // 随机分配一个rafId + readonly rafId: number; + + get ticker() { + return this._ticker; + } + + set ticker(ticker: ITicker) { + ticker.bindStage(this); + if (this._ticker) { + this._ticker.removeListener('tick', this.afterTickCb); + } + ticker.addTimeline(this.timeline); + this._ticker = ticker; + this._ticker.on('tick', this.afterTickCb); + } + /** * 所有属性都具有默认值。 * Canvas为字符串或者Canvas元素,那么默认图层就会绑定到这个Canvas上 @@ -225,6 +243,7 @@ export class Stage extends Group implements IStage { this.renderService = container.get(RenderService); this.pluginService = container.get(PluginService); this.layerService = container.get(LayerService); + this.graphicService = container.get(GraphicService); this.pluginService.active(this, params); this.window.create({ @@ -284,20 +303,40 @@ export class Stage extends Group implements IStage { this.hooks.afterRender.tap('constructor', this.afterRender); this._beforeRender = params.beforeRender; this._afterRender = params.afterRender; - this.ticker = params.ticker || defaultTicker; this.supportInteractiveLayer = params.interactiveLayer !== false; - this.timeline = new DefaultTimeline(); - this.ticker.addTimeline(this.timeline); - this.timeline.pause(); if (!params.optimize) { - params.optimize = {}; + params.optimize = { + tickRenderMode: 'effect' + }; } this.optmize(params.optimize); // 如果背景是图片,触发加载图片操作 if (params.background && isString(this._background) && this._background.includes('/')) { this.setAttributes({ background: this._background }); } - this.ticker.on('afterTick', this.afterTickCb); + + this.initAnimate(params); + this.rafId = params.rafId ?? Math.floor(Math.random() * 6); + } + + initAnimate(params: Partial) { + if ((this as any).createTicker && (this as any).createTimeline) { + this._ticker = params.ticker || (this as any).createTicker(this); + this._ticker.bindStage(this); + if (this.params.optimize?.tickRenderMode === 'performance') { + this._ticker.setFPS(30); + } + this.timeline = (this as any).createTimeline(); + this._ticker.addTimeline(this.timeline); + this._ticker.on('tick', this.afterTickCb); + } + } + + startAnimate() { + if (this._ticker && this.timeline) { + this._ticker.start(); + this.timeline.resume(); + } } pauseRender(sr: number = -1) { @@ -463,12 +502,7 @@ export class Stage extends Group implements IStage { protected afterTickCb = () => { this.tickedBeforeRender = true; // 性能模式不用立刻渲染 - if (this.params.optimize?.tickRenderMode === 'performance') { - // do nothing - } else { - // 不是rendering的时候,render - this.state !== 'rendering' && this.render(); - } + this.state !== 'rendering' && this.renderNextFrame(); }; setBeforeRender(cb: (stage: IStage) => void) { @@ -723,14 +757,9 @@ export class Stage extends Group implements IStage { if (this.releaseStatus === 'released') { return; } - this.ticker.start(); - this.timeline.resume(); + this.startAnimate(); const state = this.state; this.state = 'rendering'; - // 判断是否需要手动执行tick - if (!this.tickedBeforeRender) { - this.ticker.trySyncTickStatus(); - } this.layerService.prepareStageLayer(this); if (!this._skipRender) { this.lastRenderparams = params; @@ -789,7 +818,7 @@ export class Stage extends Group implements IStage { } if (!this.willNextFrameRender) { this.willNextFrameRender = true; - this.global.getRequestAnimationFrame()(() => { + this.global.getSpecifiedRequestAnimationFrame(this.rafId)(() => { this._doRenderInThisFrame(), (this.willNextFrameRender = false); }); } @@ -799,8 +828,7 @@ export class Stage extends Group implements IStage { if (this.releaseStatus === 'released') { return; } - this.timeline.resume(); - this.ticker.start(); + this.startAnimate(); const state = this.state; this.state = 'rendering'; this.layerService.prepareStageLayer(this); @@ -959,10 +987,6 @@ export class Stage extends Group implements IStage { return false; } - // 动画相关 - startAnimate(t: number): void { - throw new Error('暂不支持'); - } setToFrame(t: number): void { throw new Error('暂不支持'); } @@ -988,8 +1012,8 @@ export class Stage extends Group implements IStage { this.interactiveLayer.release(); } this.window.release(); - this.ticker.remTimeline(this.timeline); - this.ticker.removeListener('afterTick', this.afterTickCb); + this._ticker?.remTimeline(this?.timeline); + this._ticker?.removeListener('tick', this.afterTickCb); this.renderService.renderTreeRoots = []; } diff --git a/packages/vrender-core/src/graphic/arc.ts b/packages/vrender-core/src/graphic/arc.ts index 1c81904ae..2e69dce91 100644 --- a/packages/vrender-core/src/graphic/arc.ts +++ b/packages/vrender-core/src/graphic/arc.ts @@ -343,6 +343,10 @@ export class Arc extends Graphic implements IArc { } toCustomPath() { + let path = super.toCustomPath(); + if (path) { + return path; + } const x = 0; const y = 0; @@ -359,7 +363,7 @@ export class Arc extends Graphic implements IArc { innerRadius = temp; } - const path = new CustomPath2D(); + path = new CustomPath2D(); if (outerRadius <= epsilon) { path.moveTo(x, y); diff --git a/packages/vrender-core/src/graphic/area.ts b/packages/vrender-core/src/graphic/area.ts index e617e219a..391da50be 100644 --- a/packages/vrender-core/src/graphic/area.ts +++ b/packages/vrender-core/src/graphic/area.ts @@ -124,7 +124,11 @@ export class Area extends Graphic implements IArea { } toCustomPath() { - const path = new CustomPath2D(); + let path = super.toCustomPath(); + if (path) { + return path; + } + path = new CustomPath2D(); const attribute = this.attribute; const segments = attribute.segments; diff --git a/packages/vrender-core/src/graphic/circle.ts b/packages/vrender-core/src/graphic/circle.ts index 9140f2259..1b07cd925 100644 --- a/packages/vrender-core/src/graphic/circle.ts +++ b/packages/vrender-core/src/graphic/circle.ts @@ -100,6 +100,10 @@ export class Circle extends Graphic implements ICircle } toCustomPath() { + let path = super.toCustomPath(); + if (path) { + return path; + } const x = 0; const y = 0; @@ -108,7 +112,7 @@ export class Circle extends Graphic implements ICircle const startAngle = attribute.startAngle ?? this.getDefaultAttribute('startAngle'); const endAngle = attribute.endAngle ?? this.getDefaultAttribute('endAngle'); - const path = new CustomPath2D(); + path = new CustomPath2D(); path.arc(x, y, radius, startAngle, endAngle); diff --git a/packages/vrender-core/src/graphic/config.ts b/packages/vrender-core/src/graphic/config.ts index 1265809da..25664bc9b 100644 --- a/packages/vrender-core/src/graphic/config.ts +++ b/packages/vrender-core/src/graphic/config.ts @@ -205,6 +205,7 @@ export const DefaultAttribute: Required = { shadowPickMode: 'graphic', keepStrokeScale: false, clipConfig: null, + roughStyle: null, ...DefaultDebugAttribute, ...DefaultStyle, ...DefaultTransform diff --git a/packages/vrender-core/src/graphic/graphic-service/graphic-module.ts b/packages/vrender-core/src/graphic/graphic-service/graphic-module.ts index 4a0eacacb..d46fbfd37 100644 --- a/packages/vrender-core/src/graphic/graphic-service/graphic-module.ts +++ b/packages/vrender-core/src/graphic/graphic-service/graphic-module.ts @@ -6,7 +6,7 @@ import { graphicCreator } from '../graphic-creator'; // import { DefaultThemeService, Theme, ThemeServce } from './theme-service'; export default new ContainerModule(bind => { - bind(GraphicService).to(DefaultGraphicService).inSingletonScope(); + bind(GraphicService).to(DefaultGraphicService); bind(GraphicCreator).toConstantValue(graphicCreator); }); diff --git a/packages/vrender-core/src/graphic/graphic-service/graphic-service.ts b/packages/vrender-core/src/graphic/graphic-service/graphic-service.ts index a184ff5bf..38a4ad720 100644 --- a/packages/vrender-core/src/graphic/graphic-service/graphic-service.ts +++ b/packages/vrender-core/src/graphic/graphic-service/graphic-service.ts @@ -23,7 +23,6 @@ import { BoundsContext } from '../../common/bounds-context'; import { renderCommandList } from '../../common/render-command-list'; import { GraphicCreator } from '../constants'; import { identityMat4, multiplyMat4Mat4, rotateX, rotateY, rotateZ, scaleMat4, translate } from '../../common/matrix'; -import { application } from '../../application'; export function getExtraModelMatrix(dx: number, dy: number, graphic: IGraphic): mat4 | null { const { alpha, beta } = graphic.attribute; @@ -381,7 +380,7 @@ export class DefaultGraphicService implements IGraphicService { // application.graphicService.beforeUpdateAABBBounds(graphic, graphic.stage, true, aabbBounds); if (!aabbBounds.empty()) { graphic.parent && aabbBounds.transformWithMatrix((graphic.parent as IGroup).globalTransMatrix); - application.graphicService.clearAABBBounds(graphic, graphic.stage, aabbBounds); + this.clearAABBBounds(graphic, graphic.stage, aabbBounds); aabbBounds.clear(); } return false; diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index b4239bf63..31403cca3 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -35,13 +35,11 @@ import type { IShadowRoot, IStage, IStep, - ISubAnimate, ISymbolClass } from '../interface'; import { EventTarget, CustomEvent } from '../event'; import { DefaultTransform } from './config'; import { application } from '../application'; -import { Animate, DefaultStateAnimateConfig, defaultTimeline } from '../animate'; import { interpolateColor } from '../color-string/interpolate'; import { CustomPath2D } from '../common/custom-path2d'; import { ResourceLoader } from '../resource-loader/loader'; @@ -52,6 +50,8 @@ import { parsePadding } from '../common/utils'; import { builtinSymbolsMap, builtInSymbolStrMap, CustomSymbolClass } from './builtin-symbol'; import { isSvg, XMLParser } from '../common/xml'; import { SVG_PARSE_ATTRIBUTE_MAP, SVG_PARSE_ATTRIBUTE_MAP_KEYS } from './constants'; +import { DefaultStateAnimateConfig } from '../animate/config'; +import { EmptyContext2d } from '../canvas'; const _tempBounds = new AABBBounds(); /** @@ -155,6 +155,33 @@ export const NOWORK_ANIMATE_ATTR = { html: 1 }; +// function createTrackableObject(obj: any) { +// // const accessedProperties = new Set(); // 记录被读取的属性 +// // const modifiedProperties = new Set(); // 记录被设置/修改的属性 + +// const handler = { +// get(target: any, property: any) { +// // accessedProperties.add(property); // 记录读取操作 +// return Reflect.get(target, property); +// }, +// set(target: any, property: any, value: any) { +// if (property === 'size' && !isFinite(value)) { +// console.log('set', property, value); +// } +// // modifiedProperties.add(property); // 记录设置/修改操作 +// return Reflect.set(target, property, value); +// } +// }; + +// const proxy = new Proxy(obj, handler); + +// // 提供方法获取被追踪的属性 +// // proxy.getAccessedProperties = () => [...accessedProperties]; +// // proxy.getModifiedProperties = () => [...modifiedProperties]; + +// return proxy; +// } + /** * globalTransMatrix更新逻辑 * 1. group的transform修改,会下发到所有下层group,将所有下层的tag修改 @@ -192,6 +219,9 @@ export abstract class Graphic = Partial; + static userSymbolMap: Record = {}; declare onBeforeAttributeUpdate?: ( @@ -274,9 +304,11 @@ export abstract class Graphic = Partial T; declare animates: Map; - declare nextAttrs?: T; - declare prevAttrs?: T; - declare finalAttrs?: T; + declare animate?: () => IAnimate; + + // declare nextAttrs?: T; + // declare prevAttrs?: T; + // declare finalAttrs?: T; declare pathProxy?: ICustomPath2D; // 依附于某个theme,如果该节点不存在parent,那么这个Theme就作为节点的Theme,避免添加到节点前计算属性 @@ -297,6 +329,15 @@ export abstract class Graphic = Partial = Partial = Partial = Partial, this._AABBBounds, @@ -570,12 +611,7 @@ export abstract class Graphic = Partial, - this._AABBBounds, - this - ) + this.getGraphicService().validCheck(this.attribute, this.getGraphicTheme() as Required, this._AABBBounds, this) ); } @@ -635,6 +671,15 @@ export abstract class Graphic = Partial, forceUpdateTag: boolean = false, context?: ISetAttributeContext) { + this.setAttributes(params, forceUpdateTag, context); + this.animates && + this.animates.forEach(animate => { + Object.keys(params).forEach(key => { + animate.preventAttr(key); + }); + }); + } setAttributes(params: Partial, forceUpdateTag: boolean = false, context?: ISetAttributeContext) { params = @@ -906,39 +951,11 @@ export abstract class Graphic = Partial { - animate.stop(); - this.animates.delete(animate.id); - }); - - return animate; - } - onAttributeUpdate(context?: ISetAttributeContext) { if (context && context.skipUpdateCallback) { return; } - application.graphicService.onAttributeUpdate(this); + this.getGraphicService().onAttributeUpdate(this); this._emitCustomEvent('afterAttributeUpdate', context); } @@ -974,6 +991,7 @@ export abstract class Graphic = Partial, stateNames: string[], hasAnimation?: boolean, isClear?: boolean) { + // 应用状态的时候要停掉动画 if (hasAnimation) { const keys = Object.keys(attrs); const noWorkAttrs = this.getNoWorkAnimateAttr(); @@ -992,18 +1010,34 @@ export abstract class Graphic = Partial) { @@ -1041,20 +1075,22 @@ export abstract class Graphic = Partial { - if ((animate as any).stateNames) { - const endProps = animate.getEndProps(); - if (has(endProps, key)) { - value = endProps[key]; - } - } - }); + // this.animates.forEach(animate => { + // if ((animate as any).stateNames) { + // const endProps = animate.getEndProps(); + // if (has(endProps, key)) { + // value = endProps[key]; + // } + // } + // }); + // console.log(this.finalAttrs); + return (this as any).finalAttribute?.[key]; } - return value; + return value ?? (this as any).finalAttribute?.[key]; } clearStates(hasAnimation?: boolean) { @@ -1326,13 +1362,14 @@ export abstract class Graphic = Partial { - if (a.timeline === defaultTimeline) { + if (a.timeline.isGlobal) { a.setTimeline(timeline); + timeline.addAnimate(a); } }); } this._onSetStage && this._onSetStage(this, stage, layer); - application.graphicService.onSetStage(this, stage); + this.getGraphicService().onSetStage(this, stage); } } @@ -1350,169 +1387,6 @@ export abstract class Graphic = Partial, - step: IStep, - ratio: number, - end: boolean, - nextProps: Record, - lastProps?: Record, - nextParsedProps?: any, - propKeys?: string[] - ) { - if (!propKeys) { - propKeys = Object.keys(nextProps); - step.propKeys = propKeys; - } - if (end) { - step.propKeys.forEach(key => { - if (!animate.validAttr(key)) { - return; - } - nextAttributes[key] = nextProps[key]; - }); - } else { - propKeys.forEach(key => { - // 如果属性不合法,那直接return - if (!animate.validAttr(key)) { - return; - } - const nextStepVal = nextProps[key]; - const lastStepVal = (lastProps && lastProps[key]) ?? subAnimate.getLastPropByName(key, step); - if (nextStepVal == null || lastStepVal == null || nextStepVal === lastStepVal) { - // 用户直接调用stepInterpolate可能会走进来,如果传入的参数出现null或者undefined,直接赋值最终的值 - nextAttributes[key] = nextStepVal; - return; - } - let match: boolean; - match = - animate.interpolateFunc && animate.interpolateFunc(key, ratio, lastStepVal, nextStepVal, nextAttributes); - if (match) { - return; - } - match = animate.customInterpolate(key, ratio, lastStepVal, nextStepVal, this, nextAttributes); - if (match) { - return; - } - if (!this.defaultInterpolate(nextStepVal, lastStepVal, key, nextAttributes, nextParsedProps, ratio)) { - this._interpolate(key, ratio, lastStepVal, nextStepVal, nextAttributes); - } - }); - } - - step.parsedProps = nextParsedProps; - } - - defaultInterpolate( - nextStepVal: any, - lastStepVal: any, - key: string, - nextAttributes: Record, - nextParsedProps: any, - ratio: number - ) { - if (Number.isFinite(nextStepVal) && Number.isFinite(lastStepVal)) { - nextAttributes[key] = lastStepVal + (nextStepVal - lastStepVal) * ratio; - return true; - } else if (key === 'fill') { - if (!nextParsedProps) { - nextParsedProps = {}; - } - // 保存解析的结果到step - const fillColorArray: [number, number, number, number] = nextParsedProps.fillColorArray; - const color = interpolateColor(lastStepVal, fillColorArray ?? nextStepVal, ratio, false, (fArray, tArray) => { - nextParsedProps.fillColorArray = tArray; - }); - if (color) { - nextAttributes[key] = color; - } - return true; - } else if (key === 'stroke') { - if (!nextParsedProps) { - nextParsedProps = {}; - } - // 保存解析的结果到step - const strokeColorArray: [number, number, number, number] = nextParsedProps.strokeColorArray; - const color = interpolateColor(lastStepVal, strokeColorArray ?? nextStepVal, ratio, false, (fArray, tArray) => { - nextParsedProps.strokeColorArray = tArray; - }); - if (color) { - nextAttributes[key] = color; - } - return true; - } else if (key === 'shadowColor') { - if (!nextParsedProps) { - nextParsedProps = {}; - } - // 保存解析的结果到step - const shadowColorArray: [number, number, number, number] = nextParsedProps.shadowColorArray; - const color = interpolateColor(lastStepVal, shadowColorArray ?? nextStepVal, ratio, true, (fArray, tArray) => { - nextParsedProps.shadowColorArray = tArray; - }); - if (color) { - nextAttributes[key] = color; - } - - return true; - } else if (Array.isArray(nextStepVal) && nextStepVal.length === lastStepVal.length) { - const nextList = []; - let valid = true; - for (let i = 0; i < nextStepVal.length; i++) { - const v = lastStepVal[i]; - const val = v + (nextStepVal[i] - v) * ratio; - if (!Number.isFinite(val)) { - valid = false; - break; - } - nextList.push(val); - } - if (valid) { - nextAttributes[key] = nextList; - } - } - - return false; - } - - protected _interpolate(key: string, ratio: number, lastStepVal: any, nextStepVal: any, nextAttributes: any) { - return; - } getDefaultAttribute(name: string) { return (this.getGraphicTheme() as any)[name]; @@ -1695,6 +1569,19 @@ export abstract class Graphic = Partial; abstract clone(): IGraphic; + + toCustomPath(): ICustomPath2D { + // throw new Error('暂不支持'); + const renderer = (this.stage?.renderService || application.renderService)?.drawContribution?.getRenderContribution( + this + ); + if (renderer) { + const context = new EmptyContext2d(null, 1); + renderer.drawShape(this, context, 0, 0, {} as any, {}); + return context.path; + } + return null; + } } Graphic.mixin(EventTarget); diff --git a/packages/vrender-core/src/graphic/group.ts b/packages/vrender-core/src/graphic/group.ts index 0c3caeff6..de8ee232e 100644 --- a/packages/vrender-core/src/graphic/group.ts +++ b/packages/vrender-core/src/graphic/group.ts @@ -142,11 +142,11 @@ export class Group extends Graphic implements IGroup { if (!this.shouldUpdateAABBBounds()) { return this._AABBBounds; } - application.graphicService.beforeUpdateAABBBounds(this, this.stage, true, this._AABBBounds); + this.getGraphicService().beforeUpdateAABBBounds(this, this.stage, true, this._AABBBounds); const selfChange = this.shouldSelfChangeUpdateAABBBounds(); const bounds = this.doUpdateAABBBounds(); this.addUpdateLayoutTag(); - application.graphicService.afterUpdateAABBBounds(this, this.stage, this._AABBBounds, this, selfChange); + this.getGraphicService().afterUpdateAABBBounds(this, this.stage, this._AABBBounds, this, selfChange); // 直接返回空Bounds,但是前面的流程还是要走 if (this.attribute.boundsMode === 'empty') { bounds.clear(); @@ -259,13 +259,13 @@ export class Group extends Graphic implements IGroup { (data as unknown as this).layer = this.layer; } this.addUpdateBoundTag(); - application.graphicService.onAddIncremental(node as unknown as IGraphic, this, this.stage); + this.getGraphicService().onAddIncremental(node as unknown as IGraphic, this, this.stage); return data; } incrementalClearChild(): void { super.removeAllChild(); this.addUpdateBoundTag(); - application.graphicService.onClearIncremental(this, this.stage); + this.getGraphicService().onClearIncremental(this, this.stage); return; } @@ -298,14 +298,14 @@ export class Group extends Graphic implements IGroup { removeChild(child: IGraphic): IGraphic { const data = super.removeChild(child); child.stage = null; - application.graphicService.onRemove(child); + this.getGraphicService().onRemove(child); this.addUpdateBoundTag(); return data as IGraphic; } removeAllChild(deep: boolean = false): void { this.forEachChildren((child: IGraphic) => { - application.graphicService.onRemove(child); + this.getGraphicService().onRemove(child); if (deep && child.isContainer) { child.removeAllChild(deep); } @@ -320,7 +320,7 @@ export class Group extends Graphic implements IGroup { this.layer = layer; this.setStageToShadowRoot(stage, layer); this._onSetStage && this._onSetStage(this, stage, layer); - application.graphicService.onSetStage(this, stage); + this.getGraphicService().onSetStage(this, stage); this.forEachChildren(item => { (item as any).setStage(stage, this.layer); }); diff --git a/packages/vrender-core/src/graphic/line.ts b/packages/vrender-core/src/graphic/line.ts index d8bfa3f77..b69d6a2c8 100644 --- a/packages/vrender-core/src/graphic/line.ts +++ b/packages/vrender-core/src/graphic/line.ts @@ -117,8 +117,12 @@ export class Line extends Graphic implements ILine { } toCustomPath() { + let path = super.toCustomPath(); + if (path) { + return path; + } const attribute = this.attribute; - const path = new CustomPath2D(); + path = new CustomPath2D(); const segments = attribute.segments; const parsePoints = (points: IPointLike[]) => { diff --git a/packages/vrender-core/src/graphic/rect.ts b/packages/vrender-core/src/graphic/rect.ts index 7b2e4acc6..b586a79d1 100644 --- a/packages/vrender-core/src/graphic/rect.ts +++ b/packages/vrender-core/src/graphic/rect.ts @@ -7,6 +7,7 @@ import { application } from '../application'; import { RECT_NUMBER_TYPE } from './constants'; import { normalizeRectAttributes } from '../common/rect-utils'; import { updateBoundsOfCommonOuterBorder } from './graphic-service/common-outer-boder-bounds'; +import { EmptyContext2d } from '../canvas'; const RECT_UPDATE_TAG_KEY = ['width', 'x1', 'y1', 'height', 'cornerRadius', ...GRAPHIC_UPDATE_TAG_KEY]; @@ -71,10 +72,14 @@ export class Rect extends Graphic implements IRect { toCustomPath(): ICustomPath2D { // throw new Error('暂不支持'); + let path = super.toCustomPath(); + if (path) { + return path; + } const attribute = this.attribute; const { x, y, width, height } = normalizeRectAttributes(attribute); - const path = new CustomPath2D(); + path = new CustomPath2D(); path.moveTo(x, y); path.rect(x, y, width, height); diff --git a/packages/vrender-core/src/graphic/richtext/icon.ts b/packages/vrender-core/src/graphic/richtext/icon.ts index 4d801fbcd..5e1c5f639 100644 --- a/packages/vrender-core/src/graphic/richtext/icon.ts +++ b/packages/vrender-core/src/graphic/richtext/icon.ts @@ -53,7 +53,7 @@ export class RichTextIcon extends Image implements IRichTextIcon { } animationBackUps?: { from: Record; to: Record } | undefined; incrementalAt?: number | undefined; - toCustomPath?: (() => ICustomPath2D) | undefined; + toCustomPath: (() => ICustomPath2D) | undefined; get width(): number { return (this.attribute.width ?? 0) + this._marginArray[1] + this._marginArray[3]; diff --git a/packages/vrender-core/src/graphic/richtext/paragraph.ts b/packages/vrender-core/src/graphic/richtext/paragraph.ts index 57daf8d73..2ce869834 100644 --- a/packages/vrender-core/src/graphic/richtext/paragraph.ts +++ b/packages/vrender-core/src/graphic/richtext/paragraph.ts @@ -338,11 +338,11 @@ export default class Paragraph { const { lineWidth = 1 } = this.character; if (this.character.stroke && lineWidth) { - ctx.strokeText(text, left, baseline + this.dy); + ctx.strokeText(text, left + this.dx, baseline + this.dy); } if (this.character.fill) { - ctx.fillText(text, left, baseline + this.dy); + ctx.fillText(text, left + this.dx, baseline + this.dy); } if (this.character.fill) { diff --git a/packages/vrender-core/src/graphic/star.ts b/packages/vrender-core/src/graphic/star.ts index 6c601801e..7bfdbefad 100644 --- a/packages/vrender-core/src/graphic/star.ts +++ b/packages/vrender-core/src/graphic/star.ts @@ -127,9 +127,13 @@ export class Star extends Graphic implements IStar { } toCustomPath() { + let path = super.toCustomPath(); + if (path) { + return path; + } const starTheme = this.getGraphicTheme(); const points = this.getStarPoints(this.attribute, starTheme); - const path = new CustomPath2D(); + path = new CustomPath2D(); points.forEach((point, index) => { if (index === 0) { diff --git a/packages/vrender-core/src/index.ts b/packages/vrender-core/src/index.ts index 9a5378bbe..a999a6b29 100644 --- a/packages/vrender-core/src/index.ts +++ b/packages/vrender-core/src/index.ts @@ -15,7 +15,6 @@ export * from './core'; export * from './core/light'; export * from './core/camera'; export * from './picker'; -export * from './animate'; export * from './resource-loader/loader'; export * from './color-string'; export * from './factory'; @@ -35,6 +34,7 @@ export * from './common/shape/arc'; export * from './common/shape/rect'; export * from './common/matrix'; export * from './common/simplify'; +export * from './common/diff'; export * from './common/path-svg'; export * from './common/render-curve'; @@ -45,6 +45,7 @@ export * from './common/morphing-utils'; export * from './common/split-path'; export * from './common/enums'; export * from './common/generator'; +export * from './common/performance-raf'; export * from './common/event-transformer'; export * from './plugins/constants'; export * from './plugins/builtin-plugin/richtext-edit-plugin'; @@ -53,7 +54,6 @@ export * from './allocator/canvas-allocate'; export * from './allocator/graphic-allocate'; export * from './common/contribution-provider'; -export * from './animate/default-ticker'; export { wrapCanvas, wrapContext } from './canvas/util'; export * from './common/xml'; export * from './common/inversify-lite'; @@ -104,5 +104,12 @@ export * from './plugins/builtin-plugin/react-attribute-plugin'; export * from './plugins/builtin-plugin/3dview-transform-plugin'; export * from './plugins/builtin-plugin/flex-layout-plugin'; -export * from './animate/easing-func'; export * from './plugins/builtin-plugin/edit-module'; + +// export const morphPath = {}; +// export const multiToOneMorph = {}; +// export const oneToMultiMorph = {}; +// export class ACustomAnimate {} +// export const AnimateGroup = {}; +// export const Animate = {}; +// export const defaultTicker = {}; diff --git a/packages/vrender-core/src/interface/animate.ts b/packages/vrender-core/src/interface/animate.ts index 85104bda2..e2d978971 100644 --- a/packages/vrender-core/src/interface/animate.ts +++ b/packages/vrender-core/src/interface/animate.ts @@ -1,360 +1,363 @@ -import type { EventEmitter } from '@visactor/vutils'; -import type { AnimateMode, AnimateStatus, AnimateStepType } from '../common/enums'; -import type { Releaseable } from './common'; -import type { IGraphic } from './graphic'; +// import type { EventEmitter } from '@visactor/vutils'; +// import type { AnimateStepType } from '../common/enums'; +// import type { Releaseable } from './common'; +// import type { IGraphic } from './graphic'; + +// enum AnimateStatus { +// INITIAL = 0, +// RUNNING = 1, +// PAUSED = 2, +// END = 3 +// } + +// enum AnimateMode { +// NORMAL = 0b0000, +// SET_ATTR_IMMEDIATELY = 0b0001 +// } + +// // export type EasingType = (...args: any) => any; + +// // export declare class Easing { +// // static linear(t: number): number; +// // static none(): typeof Easing.linear; +// // /** +// // * 获取缓动函数,amount指示这个缓动函数的插值方式 +// // * @param amount +// // * @returns +// // */ +// // static get(amount: number): (t: number) => number; +// // static getPowIn(pow: number): (t: number) => number; +// // static getPowOut(pow: number): (t: number) => number; +// // static getPowInOut(pow: number): (t: number) => number; +// // static quadIn: (t: number) => number; +// // static quadOut: (t: number) => number; +// // static quadInOut: (t: number) => number; +// // static cubicIn: (t: number) => number; +// // static cubicOut: (t: number) => number; +// // static cubicInOut: (t: number) => number; +// // static quartIn: (t: number) => number; +// // static quartOut: (t: number) => number; +// // static quartInOut: (t: number) => number; +// // static quintIn: (t: number) => number; +// // static quintOut: (t: number) => number; +// // static quintInOut: (t: number) => number; +// // static getBackIn(amount: number): (t: number) => number; +// // static getBackOut(amount: number): (t: number) => number; +// // static getBackInOut(amount: number): (t: number) => number; +// // static backIn: (t: number) => number; +// // static backOut: (t: number) => number; +// // static backInOut: (t: number) => number; +// // static circIn(t: number): number; +// // static circOut(t: number): number; +// // static circInOut(t: number): number; +// // static bounceOut(t: number): number; +// // static bounceIn(t: number): number; +// // static bounceInOut(t: number): number; +// // static getElasticIn(amplitude: number, period: number): (t: number) => number; +// // static getElasticOut(amplitude: number, period: number): (t: number) => number; +// // static getElasticInOut(amplitude: number, period: number): (t: number) => number; +// // static elasticIn: (t: number) => number; +// // static elasticOut: (t: number) => number; +// // static elasticInOut: (t: number) => number; +// // } + +// // timeline管理一堆的animate,多个timeline互不影响 +// // timeline主要作用是基于layer层面的整体管理 +// // 每个layer默认带有一个timeline +// export interface Timeline { +// AnimateList: IAnimate[]; +// } + +// type IStopType = 'end' | 'start' | 'current'; + +// // TODO: 提供options配置可序列化 +// interface AnimateSpecItem { +// type: 'to' | 'delay' | 'stop' | 'any'; +// params: any[]; +// } + +// export type EasingTypeStr = +// | 'linear' +// | 'quadIn' +// | 'quadOut' +// | 'quadInOut' +// | 'quadInOut' +// | 'cubicIn' +// | 'cubicOut' +// | 'cubicInOut' +// | 'quartIn' +// | 'quartOut' +// | 'quartInOut' +// | 'quintIn' +// | 'quintOut' +// | 'quintInOut' +// | 'backIn' +// | 'backOut' +// | 'backInOut' +// | 'circIn' +// | 'circOut' +// | 'circInOut' +// | 'bounceOut' +// | 'bounceIn' +// | 'bounceInOut' +// | 'elasticIn' +// | 'elasticOut' +// | 'elasticInOut' +// | 'sineIn' +// | 'sineOut' +// | 'sineInOut' +// | 'expoIn' +// | 'expoOut' +// | 'expoInOut' +// // @since 0.21.0 +// | 'easeInOutQuad' +// | 'easeOutElastic' +// | 'easeInOutElastic' +// | ''; +// export type EasingTypeFunc = (t: number) => number; + +// export type EasingType = EasingTypeStr | EasingTypeFunc; + +// export type IAnimateStepType = keyof typeof AnimateStepType; + +// export interface IStep { +// type: IAnimateStepType; +// prev?: IStep; +// // 持续时间 +// duration: number; +// // 在animate中的位置 +// position: number; +// next?: IStep; +// props?: any; +// parsedProps?: any; +// propKeys?: string[]; +// easing?: EasingTypeFunc; +// customAnimate?: ICustomAnimate; + +// append: (step: IStep) => void; +// getLastProps: () => any; +// } -// export type EasingType = (...args: any) => any; +// export interface IStepConfig { +// tempProps?: boolean; // props为临时props,可以直接使用不用拷贝 +// noPreventAttrs?: boolean; +// } + +// export interface IAnimateTarget { +// onAnimateBind?: (animte: IAnimate | ISubAnimate) => void; +// // 获取属性 +// getComputedAttribute: (name: string) => any; +// // 获取默认属性 +// getDefaultAttribute: (name: string) => any; +// onStop: (props?: Record) => void; +// animates: Map; +// [key: string]: any; +// } + +// export interface ICustomAnimate { +// duration: number; +// easing: EasingType; +// step?: IStep; +// mode?: AnimateMode; + +// bind: (target: IAnimateTarget, subAni: ISubAnimate) => void; +// // 在第一次调用的时候触发 +// onBind: () => void; +// // 第一次执行的时候调用 +// onFirstRun: () => void; +// // 开始执行的时候调用(如果有循环,那每个周期都会调用) +// onStart: () => void; +// // 结束执行的时候调用(如果有循环,那每个周期都会调用) +// onEnd: () => void; +// onUpdate: (end: boolean, ratio: number, out: Record) => void; +// update: (end: boolean, ratio: number, out: Record) => void; +// getEndProps: () => Record | void; +// getFromProps: () => Record | void; +// getMergedEndProps: () => Record | void; +// } + +// export type IAnimateConstructor = new (...args: any[]) => IAnimate; + +// // 每一个animate绑定一个graphic,用于描述这个graphic的动画内容 +// // 在timeline层面,animate相当于是一段timeslice +// export interface IAnimate { +// readonly id: string | number; +// status: AnimateStatus; + +// interpolateFunc: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; + +// _onStart?: (() => void)[]; +// _onFrame?: ((step: IStep, ratio: number) => void)[]; +// _onEnd?: (() => void)[]; +// _onRemove?: (() => void)[]; + +// getStartProps: () => Record; +// getEndProps: () => Record; + +// setTimeline: (timeline: ITimeline) => void; +// // getTimeline: () => ITimeline; +// readonly timeline: ITimeline; + +// bind: (target: IAnimateTarget) => this; +// to: (props: Record, duration: number, easing: EasingType, params?: IStepConfig) => this; +// from: (props: Record, duration: number, easing: EasingType, params?: IStepConfig) => this; +// pause: () => void; +// resume: () => void; +// onStart: (cb: () => void) => void; +// onEnd: (cb: () => void) => void; +// onFrame: (cb: (step: IStep, ratio: number) => void) => void; +// onRemove: (cb: () => void) => void; +// // 屏蔽属性 +// preventAttr: (key: string) => void; +// // 屏蔽属性 +// preventAttrs: (key: string[]) => void; +// // 属性是否合法 +// validAttr: (key: string) => boolean; + +// runCb: (cb: (a: IAnimate, step: IStep) => void) => IAnimate; + +// // 自定义插值,返回false表示没有匹配上 +// customInterpolate: ( +// key: string, +// ratio: number, +// from: any, +// to: any, +// target: IAnimateTarget, +// ret: Record +// ) => boolean; +// // +// play: (customAnimate: ICustomAnimate) => this; + +// // 获取该属性的上一个值 +// // getLastPropByName: (name: string, step: IStep) => any; +// // delay: (duration: number) => IAnimate; +// stop: (type?: 'start' | 'end' | Record) => void; +// /** 打上END标志,下一帧被删除 */ +// release: () => void; +// // 获取持续的时长 +// getDuration: () => number; +// // 获取动画开始时间(注意并不是子动画的startAt) +// getStartTime: () => number; +// // done: (cb: (_: any) => any) => IAnimate; +// // pause: () => IAnimate; +// // spec: (spec: AnimateSpecItem[]) => IAnimate; +// // start: () => void; // 有start方法,避免动画提前开始(VGrammar需要时间处理数据) +// wait: (delay: number) => this; + +// // // 编排 +// afterAll: (list: IAnimate[]) => this; +// after: (animate: IAnimate) => this; +// parallel: (animate: IAnimate) => this; + +// // // timislice (getter) +// // startTime: number; +// // endTime: number; +// // startTimes: number[]; +// // endTimes: number[]; + +// // // 高级参数,frame到frameEnd之间可以进行reverse,loop,bounce效果 +// // frame: () => IAnimate; +// // frameEnd: () => IAnimate; +// reversed: (r: boolean) => IAnimate; +// loop: (n: number) => IAnimate; +// bounce: (b: boolean) => IAnimate; + +// nextAnimate?: IAnimate; +// prevAnimate?: IAnimate; + +// advance: (delta: number) => void; + +// startAt: (t: number) => IAnimate; + +// // // 语法糖 +// // create: (duration: number) => IAnimate; +// // fadeIn: (duration: number) => IAnimate; +// } + +// export interface ISubAnimate { +// getLastStep: () => IStep; +// animate: IAnimate; +// // 获取该属性的上一个值 +// getLastPropByName: (name: string, step: IStep) => any; +// } -// export declare class Easing { -// static linear(t: number): number; -// static none(): typeof Easing.linear; +// // rect.animate().abc().to({}, 1000).delay(1000).frame().to().delay().to().frameEnd().loop().bounce() + +// export interface BaseAnimateConfig { +// id?: number | string; +// interpolate?: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; +// onStart?: () => void; +// onFrame?: (step: IStep, ratio: number) => void; +// onEnd?: () => void; +// onRemove?: () => void; +// } + +// // VGrammar和 vrender命名不一致,好尴尬 +// export interface MorphingAnimateConfig extends Omit { +// duration?: number; +// easing?: EasingType; // 统一到easing +// delay?: number; +// } + +// export interface MultiMorphingAnimateConfig extends MorphingAnimateConfig { +// splitPath?: 'clone' | ((graphic: IGraphic, count: number, needAppend?: boolean) => IGraphic[]); +// individualDelay?: (index: number, count: number, fromGraphic: IGraphic, toGraphic: IGraphic) => number; +// } + +// export interface ITimeline { +// id: number; +// animateCount: number; +// isGlobal: boolean; +// addAnimate: (animate: IAnimate) => void; +// removeAnimate: (animate: IAnimate, release?: boolean) => void; +// tick: (delta: number) => void; +// clear: () => void; +// pause: () => void; +// resume: () => void; +// } + +// export type ITimelineConstructor = new (...args: any[]) => ITimeline; + +// export type ITickerConstructor = new (...args: any[]) => ITicker; + +// export interface ITickHandler extends Releaseable { +// avaliable: () => boolean; // /** -// * 获取缓动函数,amount指示这个缓动函数的插值方式 -// * @param amount -// * @returns +// * 开始执行tick +// * @param interval 延时 ms +// * @param cb 执行的回调 // */ -// static get(amount: number): (t: number) => number; -// static getPowIn(pow: number): (t: number) => number; -// static getPowOut(pow: number): (t: number) => number; -// static getPowInOut(pow: number): (t: number) => number; -// static quadIn: (t: number) => number; -// static quadOut: (t: number) => number; -// static quadInOut: (t: number) => number; -// static cubicIn: (t: number) => number; -// static cubicOut: (t: number) => number; -// static cubicInOut: (t: number) => number; -// static quartIn: (t: number) => number; -// static quartOut: (t: number) => number; -// static quartInOut: (t: number) => number; -// static quintIn: (t: number) => number; -// static quintOut: (t: number) => number; -// static quintInOut: (t: number) => number; -// static getBackIn(amount: number): (t: number) => number; -// static getBackOut(amount: number): (t: number) => number; -// static getBackInOut(amount: number): (t: number) => number; -// static backIn: (t: number) => number; -// static backOut: (t: number) => number; -// static backInOut: (t: number) => number; -// static circIn(t: number): number; -// static circOut(t: number): number; -// static circInOut(t: number): number; -// static bounceOut(t: number): number; -// static bounceIn(t: number): number; -// static bounceInOut(t: number): number; -// static getElasticIn(amplitude: number, period: number): (t: number) => number; -// static getElasticOut(amplitude: number, period: number): (t: number) => number; -// static getElasticInOut(amplitude: number, period: number): (t: number) => number; -// static elasticIn: (t: number) => number; -// static elasticOut: (t: number) => number; -// static elasticInOut: (t: number) => number; +// tick: (interval: number, cb: (handler: ITickHandler) => void) => void; // 开始 +// tickTo?: (t: number, cb: (handler: ITickHandler, params?: { once: boolean }) => void) => void; +// getTime: () => number; // 获取时间 // } -// timeline管理一堆的animate,多个timeline互不影响 -// timeline主要作用是基于layer层面的整体管理 -// 每个layer默认带有一个timeline -export interface Timeline { - AnimateList: IAnimate[]; -} - -type IStopType = 'end' | 'start' | 'current'; - -// TODO: 提供options配置可序列化 -interface AnimateSpecItem { - type: 'to' | 'delay' | 'stop' | 'any'; - params: any[]; -} - -export type EasingTypeStr = - | 'linear' - | 'quadIn' - | 'quadOut' - | 'quadInOut' - | 'quadInOut' - | 'cubicIn' - | 'cubicOut' - | 'cubicInOut' - | 'quartIn' - | 'quartOut' - | 'quartInOut' - | 'quintIn' - | 'quintOut' - | 'quintInOut' - | 'backIn' - | 'backOut' - | 'backInOut' - | 'circIn' - | 'circOut' - | 'circInOut' - | 'bounceOut' - | 'bounceIn' - | 'bounceInOut' - | 'elasticIn' - | 'elasticOut' - | 'elasticInOut' - | 'sineIn' - | 'sineOut' - | 'sineInOut' - | 'expoIn' - | 'expoOut' - | 'expoInOut' - // @since 0.21.0 - | 'easeInOutQuad' - | 'easeOutElastic' - | 'easeInOutElastic' - | ''; -export type EasingTypeFunc = (t: number) => number; - -export type EasingType = EasingTypeStr | EasingTypeFunc; - -export type IAnimateStepType = keyof typeof AnimateStepType; - -export interface IStep { - type: IAnimateStepType; - prev?: IStep; - // 持续时间 - duration: number; - // 在animate中的位置 - position: number; - next?: IStep; - props?: any; - parsedProps?: any; - propKeys?: string[]; - easing?: EasingTypeFunc; - customAnimate?: ICustomAnimate; - - append: (step: IStep) => void; - getLastProps: () => any; -} - -export interface IStepConfig { - tempProps?: boolean; // props为临时props,可以直接使用不用拷贝 - noPreventAttrs?: boolean; -} - -export interface IAnimateTarget { - onAnimateBind?: (animte: IAnimate | ISubAnimate) => void; - // 添加动画step的时候调用 - onAddStep?: (step: IStep) => void; - // step时调用 - onStep: (subAnimate: ISubAnimate, animate: IAnimate, step: IStep, ratio: number, end: boolean) => void; - // 插值函数 - stepInterpolate: ( - subAnimate: ISubAnimate, - animate: IAnimate, - nextAttributes: Record, - step: IStep, - ratio: number, - end: boolean, - nextProps: Record, - lastProps?: Record, - nextParsedProps?: any, - propKeys?: string[] - ) => void; - // 获取属性 - getComputedAttribute: (name: string) => any; - // 获取默认属性 - getDefaultAttribute: (name: string) => any; - onStop: (props?: Record) => void; - animates: Map; - [key: string]: any; -} - -export interface ICustomAnimate { - duration: number; - easing: EasingType; - step?: IStep; - mode?: AnimateMode; - - bind: (target: IAnimateTarget, subAni: ISubAnimate) => void; - // 在第一次调用的时候触发 - onBind: () => void; - // 第一次执行的时候调用 - onFirstRun: () => void; - // 开始执行的时候调用(如果有循环,那每个周期都会调用) - onStart: () => void; - // 结束执行的时候调用(如果有循环,那每个周期都会调用) - onEnd: () => void; - onUpdate: (end: boolean, ratio: number, out: Record) => void; - update: (end: boolean, ratio: number, out: Record) => void; - getEndProps: () => Record | void; - getFromProps: () => Record | void; - getMergedEndProps: () => Record | void; -} - -// 每一个animate绑定一个graphic,用于描述这个graphic的动画内容 -// 在timeline层面,animate相当于是一段timeslice -export interface IAnimate { - readonly id: string | number; - status: AnimateStatus; - - interpolateFunc: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; - - _onStart?: (() => void)[]; - _onFrame?: ((step: IStep, ratio: number) => void)[]; - _onEnd?: (() => void)[]; - _onRemove?: (() => void)[]; - - getStartProps: () => Record; - getEndProps: () => Record; - - setTimeline: (timeline: ITimeline) => void; - // getTimeline: () => ITimeline; - readonly timeline: ITimeline; - - bind: (target: IAnimateTarget) => this; - to: (props: Record, duration: number, easing: EasingType, params?: IStepConfig) => this; - from: (props: Record, duration: number, easing: EasingType, params?: IStepConfig) => this; - pause: () => void; - resume: () => void; - onStart: (cb: () => void) => void; - onEnd: (cb: () => void) => void; - onFrame: (cb: (step: IStep, ratio: number) => void) => void; - // 屏蔽属性 - preventAttr: (key: string) => void; - // 屏蔽属性 - preventAttrs: (key: string[]) => void; - // 属性是否合法 - validAttr: (key: string) => boolean; - - runCb: (cb: (a: IAnimate, step: IStep) => void) => IAnimate; - - // 自定义插值,返回false表示没有匹配上 - customInterpolate: ( - key: string, - ratio: number, - from: any, - to: any, - target: IAnimateTarget, - ret: Record - ) => boolean; - // - play: (customAnimate: ICustomAnimate) => this; - - // 获取该属性的上一个值 - // getLastPropByName: (name: string, step: IStep) => any; - // delay: (duration: number) => IAnimate; - stop: (type?: 'start' | 'end' | Record) => void; - /** 打上END标志,下一帧被删除 */ - release: () => void; - // 获取持续的时长 - getDuration: () => number; - // 获取动画开始时间(注意并不是子动画的startAt) - getStartTime: () => number; - // done: (cb: (_: any) => any) => IAnimate; - // pause: () => IAnimate; - // spec: (spec: AnimateSpecItem[]) => IAnimate; - // start: () => void; // 有start方法,避免动画提前开始(VGrammar需要时间处理数据) - wait: (delay: number) => this; - - // // 编排 - afterAll: (list: IAnimate[]) => this; - after: (animate: IAnimate) => this; - parallel: (animate: IAnimate) => this; - - // // timislice (getter) - // startTime: number; - // endTime: number; - // startTimes: number[]; - // endTimes: number[]; - - // // 高级参数,frame到frameEnd之间可以进行reverse,loop,bounce效果 - // frame: () => IAnimate; - // frameEnd: () => IAnimate; - reversed: (r: boolean) => IAnimate; - loop: (n: number) => IAnimate; - bounce: (b: boolean) => IAnimate; - - nextAnimate?: IAnimate; - prevAnimate?: IAnimate; - - advance: (delta: number) => void; - - startAt: (t: number) => IAnimate; - - // // 语法糖 - // create: (duration: number) => IAnimate; - // fadeIn: (duration: number) => IAnimate; -} - -export interface ISubAnimate { - getLastStep: () => IStep; - animate: IAnimate; - // 获取该属性的上一个值 - getLastPropByName: (name: string, step: IStep) => any; -} - -// rect.animate().abc().to({}, 1000).delay(1000).frame().to().delay().to().frameEnd().loop().bounce() - -export interface BaseAnimateConfig { - id?: number | string; - interpolate?: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; - onStart?: () => void; - onFrame?: (step: IStep, ratio: number) => void; - onEnd?: () => void; - onRemove?: () => void; -} - -// VGrammar和 vrender命名不一致,好尴尬 -export interface MorphingAnimateConfig extends Omit { - duration?: number; - easing?: EasingType; // 统一到easing - delay?: number; -} - -export interface MultiMorphingAnimateConfig extends MorphingAnimateConfig { - splitPath?: 'clone' | ((graphic: IGraphic, count: number, needAppend?: boolean) => IGraphic[]); - individualDelay?: (index: number, count: number, fromGraphic: IGraphic, toGraphic: IGraphic) => number; -} - -export interface ITimeline { - id: number; - animateCount: number; - addAnimate: (animate: IAnimate) => void; - removeAnimate: (animate: IAnimate, release?: boolean) => void; - tick: (delta: number) => void; - clear: () => void; - pause: () => void; - resume: () => void; -} - -export interface ITickHandler extends Releaseable { - avaliable: () => boolean; - /** - * 开始执行tick - * @param interval 延时 ms - * @param cb 执行的回调 - */ - tick: (interval: number, cb: (handler: ITickHandler) => void) => void; // 开始 - tickTo?: (t: number, cb: (handler: ITickHandler, params?: { once: boolean }) => void) => void; - getTime: () => number; // 获取时间 -} - -export interface ITickerHandlerStatic { - Avaliable: () => boolean; - new (): ITickHandler; -} - -export interface ITicker extends EventEmitter { - setFPS?: (fps: number) => void; - setInterval?: (interval: number) => void; - getFPS?: () => number; - getInterval?: () => number; - tick: (interval: number) => void; - tickAt?: (time: number) => void; - pause: () => boolean; - resume: () => boolean; - /** - * 开启tick,force为true强制开启,否则如果timeline为空则不开启 - */ - start: (force?: boolean) => boolean; - stop: () => void; - addTimeline: (timeline: ITimeline) => void; - remTimeline: (timeline: ITimeline) => void; - trySyncTickStatus: () => void; - getTimelines: () => ITimeline[]; - - release: () => void; - - // 是否自动停止,默认为true - autoStop: boolean; -} +// export interface ITickerHandlerStatic { +// Avaliable: () => boolean; +// new (): ITickHandler; +// } + +// export interface ITicker extends EventEmitter { +// setFPS?: (fps: number) => void; +// setInterval?: (interval: number) => void; +// getFPS?: () => number; +// getInterval?: () => number; +// tick: (interval: number) => void; +// tickAt?: (time: number) => void; +// pause: () => boolean; +// resume: () => boolean; +// /** +// * 开启tick,force为true强制开启,否则如果timeline为空则不开启 +// */ +// start: (force?: boolean) => boolean; +// stop: () => void; +// addTimeline: (timeline: ITimeline) => void; +// remTimeline: (timeline: ITimeline) => void; +// trySyncTickStatus: () => void; +// getTimelines: () => ITimeline[]; + +// release: () => void; + +// // 是否自动停止,默认为true +// autoStop: boolean; +// } diff --git a/packages/vrender-core/src/interface/animation/animate.ts b/packages/vrender-core/src/interface/animation/animate.ts new file mode 100644 index 000000000..8673a5a97 --- /dev/null +++ b/packages/vrender-core/src/interface/animation/animate.ts @@ -0,0 +1,201 @@ +import type { IGraphic } from '../graphic'; +import type { EasingType, EasingTypeFunc } from './easing'; +import type { AnimateStatus, IAnimateStepType } from './type'; +import type { ITimeline } from './timeline'; + +export interface ICustomAnimate extends IStep { + type: IAnimateStepType; +} + +export interface IStep { + type: IAnimateStepType; + prev?: IStep; + // 持续时间 + duration: number; + // 链表,下一个 + next?: IStep; + // 属性 + props?: Record; + // 解析后的属性(用于性能优化,避免每次tick都解析) + fromParsedProps?: Record; + toParsedProps?: Record; + fromProps?: Record; + // 解析后的属性列表(用于性能优化,避免每次tick都解析) + propKeys?: string[]; + // 缓动函数 + easing?: EasingTypeFunc; + + // 添加一个 + append: (step: IStep) => void; + // 获取上一个props,用于完成这次的fromValue 和 toValue的插值 + getLastProps: () => any; + + animate: IAnimate; + + // 设置持续时间 + setDuration: (duration: number, updateDownstream?: boolean) => void; + // 获取持续时间 + getDuration: () => number; + // 确定插值更新函数(在开始的时候就确定,避免每次tick都解析) + determineInterpolateUpdateFunction: () => void; + + // 设置开始时间 + setStartTime: (time: number, updateDownstream?: boolean) => void; + // 获取开始时间 + getStartTime: () => number; + + bind: (target: IGraphic, animate: IAnimate) => void; + // 在第一次绑定到Animate的时候触发 + onBind: () => void; + // 第一次执行的时候调用 + onFirstRun: () => void; + // 开始执行的时候调用(如果有循环,那每个周期都会调用) + onStart: () => void; + // 结束执行的时候调用(如果有循环,那每个周期都会调用) + onEnd: (cb?: (animate: IAnimate, step: IStep) => void) => void; + // 更新执行的时候调用(如果有循环,那每个周期都会调用) + update: (end: boolean, ratio: number, out: Record) => void; + onUpdate: (end: boolean, ratio: number, out: Record) => void; + + getEndProps: () => Record | void; + getFromProps: () => Record | void; + getMergedEndProps: () => Record | void; + + // 屏蔽自身属性,会直接从props等内容里删除掉 + deleteSelfAttr: (key: string) => void; + + // 停止 + stop: () => void; +} + +export interface IAnimate { + readonly id: string | number; + status: AnimateStatus; + target: IGraphic; + priority: number; + interpolateUpdateFunction: + | ((from: Record, to: Record, ratio: number, step: IStep, target: IGraphic) => void) + | null; + + _onStart?: (() => void)[]; + _onFrame?: ((step: IStep, ratio: number) => void)[]; + _onEnd?: (() => void)[]; + _onRemove?: (() => void)[]; + + getStartProps: () => Record; + getEndProps: () => Record; + + // 设置timeline + setTimeline: (timeline: ITimeline) => void; + // 获取timeline + getTimeline: () => ITimeline; + readonly timeline: ITimeline; + + bind: (target: IGraphic) => this; + to: (props: Record, duration: number, easing: EasingType) => this; + from: (props: Record, duration: number, easing: EasingType) => this; + pause: () => void; + resume: () => void; + onStart: (cb?: () => void) => void; + onEnd: (cb?: () => void) => void; + onFrame: (cb: (step: IStep, ratio: number) => void) => void; + onRemove: (cb?: () => void) => void; + // 屏蔽属性 + preventAttr: (key: string) => void; + // 屏蔽属性 + preventAttrs: (key: string[]) => void; + // 属性是否合法 + validAttr: (key: string) => boolean; + + runCb: (cb: (a: IAnimate, step: IStep) => void) => IAnimate; + + // 自定义插值,返回false表示没有匹配上 + customInterpolate: ( + key: string, + ratio: number, + from: any, + to: any, + target: IGraphic, + ret: Record + ) => boolean; + play: (customAnimate: ICustomAnimate) => this; + + getFromValue: () => Record; + getToValue: () => Record; + // 停止,可以设置停止后设置target的属性为开始的值(fromValue),还是结束的值(toValue) + stop: (type?: 'start' | 'end' | Record) => void; + /** 打上END标志,下一帧被删除 */ + release: () => void; + // 获取持续的时长 + getDuration: () => number; + getTotalDuration: () => number; + // 获取动画开始时间(注意并不是子动画的startAt) + getStartTime: () => number; + // 等待delay + wait: (delay: number) => this; + + /* 动画编排 */ + // 所有动画结束后执行 + afterAll: (list: IAnimate[]) => this; + // 在某个动画结束后执行 + after: (animate: IAnimate) => this; + // 并行执行 + parallel: (animate: IAnimate) => this; + + getLoop: () => number; + + // 反转动画 + // reversed: (r: boolean) => IAnimate; + // 循环动画 + loop: (n: number | boolean) => IAnimate; + // 反弹动画 + bounce: (b: boolean) => IAnimate; + + advance: (delta: number) => void; + + // 设置开始时间(startAt之前是完全不会进入动画生命周期的) + // 它和wait不一样,如果调用的是wait,wait过程中还算是一个动画阶段,只是空的阶段,而startAt之前是完全不会进入动画生命周期的 + startAt: (t: number) => IAnimate; + + // 重新同步和计算props,用于内部某些step发生了变更后,重新计算自身 + reSyncProps: () => void; + + // 更新duration + updateDuration: () => void; +} + +export enum AnimateMode { + NORMAL = 0b0000, + SET_ATTR_IMMEDIATELY = 0b0001 +} + +export interface IAnimateTarget { + onAnimateBind?: (animte: IAnimate) => void; + // 获取属性 + getComputedAttribute: (name: string) => any; + // 获取默认属性 + getDefaultAttribute: (name: string) => any; + onStop: (props?: Record) => void; + animates: Map; + [key: string]: any; +} + +export interface BaseAnimateConfig { + id?: number | string; + interpolate?: (key: string, ratio: number, from: any, to: any, nextAttributes: any) => boolean; + onStart?: () => void; + onFrame?: (step: IStep, ratio: number) => void; + onEnd?: () => void; + onRemove?: () => void; +} + +export interface MorphingAnimateConfig extends Omit { + duration?: number; + easing?: EasingType; // 统一到easing + delay?: number; +} + +export interface MultiMorphingAnimateConfig extends MorphingAnimateConfig { + splitPath?: 'clone' | ((graphic: IGraphic, count: number, needAppend?: boolean) => IGraphic[]); + individualDelay?: (index: number, count: number, fromGraphic: IGraphic, toGraphic: IGraphic) => number; +} diff --git a/packages/vrender-core/src/interface/animation/easing.ts b/packages/vrender-core/src/interface/animation/easing.ts new file mode 100644 index 000000000..b488418c7 --- /dev/null +++ b/packages/vrender-core/src/interface/animation/easing.ts @@ -0,0 +1,41 @@ +export type EasingTypeStr = + | 'linear' + | 'quadIn' + | 'quadOut' + | 'quadInOut' + | 'quadInOut' + | 'cubicIn' + | 'cubicOut' + | 'cubicInOut' + | 'quartIn' + | 'quartOut' + | 'quartInOut' + | 'quintIn' + | 'quintOut' + | 'quintInOut' + | 'backIn' + | 'backOut' + | 'backInOut' + | 'circIn' + | 'circOut' + | 'circInOut' + | 'bounceOut' + | 'bounceIn' + | 'bounceInOut' + | 'elasticIn' + | 'elasticOut' + | 'elasticInOut' + | 'sineIn' + | 'sineOut' + | 'sineInOut' + | 'expoIn' + | 'expoOut' + | 'expoInOut' + // @since 0.21.0 + | 'easeInOutQuad' + | 'easeOutElastic' + | 'easeInOutElastic' + | ''; +export type EasingTypeFunc = (t: number) => number; + +export type EasingType = EasingTypeStr | EasingTypeFunc; diff --git a/packages/vrender-core/src/interface/animation/index.ts b/packages/vrender-core/src/interface/animation/index.ts new file mode 100644 index 000000000..a4b93f563 --- /dev/null +++ b/packages/vrender-core/src/interface/animation/index.ts @@ -0,0 +1,5 @@ +export * from './animate'; +export * from './ticker'; +export * from './timeline'; +export * from './type'; +export * from './easing'; diff --git a/packages/vrender-core/src/interface/animation/ticker.ts b/packages/vrender-core/src/interface/animation/ticker.ts new file mode 100644 index 000000000..497266498 --- /dev/null +++ b/packages/vrender-core/src/interface/animation/ticker.ts @@ -0,0 +1,58 @@ +/** + * Ticker Types for Animation Graph + */ + +import type { EventEmitter } from '@visactor/vutils'; +import type { ITimeline } from './timeline'; +import type { IStage } from '../stage'; + +export type TickerMode = 'raf' | 'timeout' | 'manual'; + +export enum STATUS { + INITIAL = 0, // initial represents initial state + RUNNING = 1, // running represents executing + PAUSE = 2 // PAUSE represents tick continues but functions are not executed +} + +export interface ITickHandler { + /** + * Start executing tick + * @param interval Delay in ms + * @param cb Callback to execute + */ + tick: (interval: number, cb: (handler: ITickHandler) => void) => void; + tickTo?: (t: number, cb: (handler: ITickHandler, params?: { once: boolean }) => void) => void; + getTime: () => number; // Get current time + release: () => void; +} + +export interface ITickerHandlerStatic { + new (): ITickHandler; +} + +export interface ITicker extends EventEmitter { + setFPS?: (fps: number) => void; + setInterval?: (interval: number) => void; + getFPS?: () => number; + getInterval?: () => number; + tick: (interval: number) => void; + tickAt?: (time: number) => void; + pause: () => boolean; + resume: () => boolean; + /** + * Start ticking, if force is true, start regardless; + * otherwise, don't start if timeline is empty + */ + start: (force?: boolean) => boolean; + stop: () => void; + addTimeline: (timeline: ITimeline) => void; + remTimeline: (timeline: ITimeline) => void; + trySyncTickStatus: () => void; + getTimelines: () => ITimeline[]; + release: () => void; + + bindStage: (stage: IStage) => void; + + // Whether to automatically stop, default is true + autoStop: boolean; +} diff --git a/packages/vrender-core/src/interface/animation/timeline.ts b/packages/vrender-core/src/interface/animation/timeline.ts new file mode 100644 index 000000000..2c4e1b391 --- /dev/null +++ b/packages/vrender-core/src/interface/animation/timeline.ts @@ -0,0 +1,30 @@ +import type { IAnimate } from './animate'; + +export interface ITimeline { + id: number; + isGlobal?: boolean; + // 包含的动画数量(animate数组的数量),包含所有动画 + animateCount: number; + // 添加动画 + addAnimate: (animate: IAnimate) => void; + // 移除动画 + removeAnimate: (animate: IAnimate, release?: boolean) => void; + // 更新动画 + tick: (delta: number) => void; + // 清除动画 + clear: () => void; + // 暂停动画 + pause: () => void; + // 恢复动画 + resume: () => void; + // 获取动画总时长 + getTotalDuration: () => number; + // 获取动画的播放速度 + getPlaySpeed: () => number; + // 设置动画的播放速度 + setPlaySpeed: (speed: number) => void; + // 获取动画的播放状态 + getPlayState: () => 'playing' | 'paused' | 'stopped'; + // 获取动画是否正在运行 + isRunning: () => boolean; +} diff --git a/packages/vrender-core/src/interface/animation/type.ts b/packages/vrender-core/src/interface/animation/type.ts new file mode 100644 index 000000000..780eb36ef --- /dev/null +++ b/packages/vrender-core/src/interface/animation/type.ts @@ -0,0 +1,15 @@ +export enum AnimateStepType { + wait = 'wait', + from = 'from', + to = 'to', + customAnimate = 'customAnimate' +} + +export enum AnimateStatus { + INITIAL = 0, + RUNNING = 1, + PAUSED = 2, + END = 3 +} + +export type IAnimateStepType = keyof typeof AnimateStepType; diff --git a/packages/vrender-core/src/interface/context.ts b/packages/vrender-core/src/interface/context.ts index dc51a3736..06a92d7ca 100644 --- a/packages/vrender-core/src/interface/context.ts +++ b/packages/vrender-core/src/interface/context.ts @@ -100,6 +100,8 @@ export interface IContext2d extends Releaseable { getContext: () => any; + reset: (setTransform?: boolean) => void; + /** * 设置当前ctx 的transform信息 */ diff --git a/packages/vrender-core/src/interface/global.ts b/packages/vrender-core/src/interface/global.ts index 1f26204ec..22976598f 100644 --- a/packages/vrender-core/src/interface/global.ts +++ b/packages/vrender-core/src/interface/global.ts @@ -255,6 +255,8 @@ export interface IGlobal extends Omit null | ((callback: FrameRequestCallback) => number); getCancelAnimationFrame: () => null | ((h: number) => void); + getSpecifiedRequestAnimationFrame: (id: number) => (callback: FrameRequestCallback) => number; + getSpecifiedCancelAnimationFrame: (id: number) => (h: number) => void; /** * 将窗口坐标转换为画布坐标,小程序/小组件环境需要兼容 diff --git a/packages/vrender-core/src/interface/graphic.ts b/packages/vrender-core/src/interface/graphic.ts index 36b3232e9..fc33ce802 100644 --- a/packages/vrender-core/src/interface/graphic.ts +++ b/packages/vrender-core/src/interface/graphic.ts @@ -1,5 +1,5 @@ import type { IAABBBounds, IMatrix, IPointLike, IPoint, BoundsAnchorType, IOBBBounds } from '@visactor/vutils'; -import type { IAnimate, IStep, EasingType, IAnimateTarget, ITimeline } from './animate'; +import type { IAnimate, IStep, EasingType, IAnimateTarget, ITimeline } from './animation'; import type { IColor } from './color'; import type { IGroup } from './graphic/group'; import type { IShadowRoot } from './graphic/shadow-root'; @@ -376,6 +376,12 @@ export interface CommonDomOptions { anchorType?: 'position' | 'boundsLeftTop' | BoundsAnchorType; } +export type IRoughStyle = { + fillStyle: 'hachure' | 'solid' | 'zigzag' | 'cross-hatch' | 'dots' | 'sunburst' | 'dashed' | 'zigzag-line'; + roughness: number; + bowing: number; +}; + export type IGraphicStyle = ILayout & IFillStyle & IStrokeStyle & @@ -495,8 +501,10 @@ export type IGraphicStyle = ILayout & * 设置图形对应的鼠标样式 */ cursor: Cursor | null; + // @deprecated 用处少废弃,后续考虑新设计API filter: string; renderStyle?: 'default' | 'rough' | any; + roughStyle?: IRoughStyle | null; /** * HTML的dom或者string */ @@ -685,7 +693,10 @@ export interface IGraphic = Partial; backgroundImg?: boolean; attachedThemeGraphic?: IGraphic; - + /** + * 保存语法上下文 + */ + context?: Record; bindDom?: Map< string | HTMLElement, { container: HTMLElement | string; dom: HTMLElement | any; wrapGroup: HTMLDivElement | any; root?: any } @@ -721,7 +732,7 @@ export interface IGraphic = Partial Partial; findFace?: () => IFace3d; toggleState: (stateName: string, hasAnimation?: boolean) => void; - removeState: (stateName: string, hasAnimation?: boolean) => void; + removeState: (stateName: string | string[], hasAnimation?: boolean) => void; clearStates: (hasAnimation?: boolean) => void; useStates: (states: string[], hasAnimation?: boolean) => void; addState: (stateName: string, keepCurrentStates?: boolean, hasAnimation?: boolean) => void; @@ -769,7 +780,7 @@ export interface IGraphic = Partial void; // animate - animate: (params?: IGraphicAnimateParams) => IAnimate; + animate?: (params?: IGraphicAnimateParams) => IAnimate; // 语法糖,可有可无,有的为了首屏性能考虑做成get方法,有的由外界直接托管,内部不赋值 name?: string; @@ -822,6 +833,8 @@ export interface IGraphic = Partial void; getNoWorkAnimateAttr: () => Record; getGraphicTheme: () => T; + + getAttributes: (final?: boolean) => Partial; } export interface IRoot extends IGraphic { diff --git a/packages/vrender-core/src/interface/index.ts b/packages/vrender-core/src/interface/index.ts index 24126c1eb..3be5a2ecb 100644 --- a/packages/vrender-core/src/interface/index.ts +++ b/packages/vrender-core/src/interface/index.ts @@ -57,7 +57,6 @@ export * from './context'; export * from './path'; export * from './color'; export * from './common'; -export * from './animate'; export * from './camera'; export * from './matrix'; export * from './light'; @@ -74,3 +73,4 @@ export * from './plugin'; export * from './picker'; export * from './text'; export * from './window'; +export * from './animation'; diff --git a/packages/vrender-core/src/interface/render.ts b/packages/vrender-core/src/interface/render.ts index 27e9831d8..f21b0c3b2 100644 --- a/packages/vrender-core/src/interface/render.ts +++ b/packages/vrender-core/src/interface/render.ts @@ -31,6 +31,7 @@ export interface IRenderService { renderTreeRoots: IGraphic[]; // 此次render的数组 renderLists: IGraphic[]; drawParams: IRenderServiceDrawParams; + drawContribution: IDrawContribution; prepare: (updateBounds: boolean) => void; prepareRenderList: () => void; @@ -54,6 +55,8 @@ export interface IDrawContext extends IRenderServiceDrawParams { drawContribution?: IDrawContribution; // hack内容 hack_pieFace?: 'inside' | 'bottom' | 'top' | 'outside'; + // group是否有旋转,每一个renderGroup都会更新,用于在renderItem的时候给子节点使用 + isGroupScroll?: boolean; } export interface IDrawContribution { @@ -80,6 +83,14 @@ export interface IGraphicRenderDrawParams { drawingCb?: () => void; skipDraw?: boolean; theme?: IFullThemeSpec; + // TODO 这里是为了性能优化,之前使用匿名函数的方式闭包等逻辑会影响性能,现在直接将函数显示定义,将参数传入提升性能,就是牺牲了代码可读性 + // 用于在group中进行递归渲染的参数 + renderInGroupParams?: { + skipSort?: boolean; + nextM?: IMatrixLike; + }; + // 用于在group中进行递归渲染的函数 + renderInGroup?: (skipSort: boolean, group: IGroup, drawContext: IDrawContext, nextM: IMatrixLike) => void; } export interface IGraphicRender { diff --git a/packages/vrender-core/src/interface/stage.ts b/packages/vrender-core/src/interface/stage.ts index c439cfe7a..77508e7ca 100644 --- a/packages/vrender-core/src/interface/stage.ts +++ b/packages/vrender-core/src/interface/stage.ts @@ -7,12 +7,13 @@ import type { vec3 } from './matrix'; import type { IDirectionLight } from './light'; import type { ISyncHook } from './sync-hook'; import type { IDrawContext, IRenderService } from './render'; -import type { ITicker, ITimeline } from './animate'; +import type { ITicker, ITimeline } from './animation'; import type { IPickerService, PickResult } from './picker'; import type { IPlugin, IPluginService } from './plugin'; import type { IWindow } from './window'; import type { ILayerService } from './core'; import type { IFullThemeSpec } from './graphic/theme'; +import type { IGraphicService } from './graphic-service'; export type IExportType = 'canvas' | 'imageData'; @@ -85,6 +86,9 @@ export interface IStageParams { supportsPointerEvents?: boolean; context?: IStageCreateContext; + + // 被分配的rafId,用于renderNextFrame,避免使用大量原生的RAF + rafId?: number; } export type EventConfig = { @@ -106,6 +110,9 @@ export type IOptimizeType = { // 如果有dirtyBounds那么该配置不生效 disableCheckGraphicWidthOutRange?: boolean; // tick渲染模式,effect会在tick之后立刻执行render,保证动画效果正常。performance模式中tick和render均是RAF,属性可能会被篡改 + // 是否开启高性能动画,默认开启 + // 开启后不会执行某些安全校验,比如跳帧处理 + // 开启后会自动降帧,最高60fps tickRenderMode?: 'effect' | 'performance'; }; @@ -166,6 +173,7 @@ export interface IStage extends INode { ticker: ITicker; increaseAutoRender: boolean; readonly renderService: IRenderService; + readonly graphicService: IGraphicService; getPickerService: () => IPickerService; readonly pluginService: IPluginService; readonly layerService: ILayerService; diff --git a/packages/vrender-core/src/modules.ts b/packages/vrender-core/src/modules.ts index acbf798c9..5a91cf26d 100644 --- a/packages/vrender-core/src/modules.ts +++ b/packages/vrender-core/src/modules.ts @@ -11,13 +11,14 @@ import loadRenderContributions from './render/contributions/modules'; import { LayerService } from './core/constants'; // import { IMat4Allocate, IMatrixAllocate, Mat4Allocate, MatrixAllocate } from './allocator/matrix-allocate'; // import { GlobalPickerService } from './picker/constants'; -import type { IGlobal, IGraphicService, IPickerService } from './interface'; +import type { IGlobal, IGraphicService, IPickerService, IRenderService } from './interface'; import { application } from './application'; import type { IGraphicUtil, ILayerService, ITransformUtil } from './interface/core'; import { GraphicService } from './graphic/constants'; import { GraphicUtil, TransformUtil } from './core/constants'; import { container } from './container'; import { VGlobal } from './constants'; +import { RenderService } from './render'; export function preLoadAllModule() { if (preLoadAllModule.__loaded) { @@ -49,6 +50,8 @@ export const transformUtil = container.get(TransformUtil); application.transformUtil = transformUtil; export const graphicService = container.get(GraphicService); application.graphicService = graphicService; +export const renderService = container.get(RenderService); +application.renderService = renderService; // export const matrixAllocate = container.get(MatrixAllocate); // export const mat4Allocate = container.get(Mat4Allocate); // export const canvasAllocate = container.get(CanvasAllocate); diff --git a/packages/vrender-core/src/plugins/builtin-plugin/auto-render-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/auto-render-plugin.ts index c47bf877c..758d5854d 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/auto-render-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/auto-render-plugin.ts @@ -20,9 +20,13 @@ export class AutoRenderPlugin implements IPlugin { activate(context: IPluginService): void { this.pluginService = context; - application.graphicService.hooks.onAttributeUpdate.tap(this.key, this.handleChange); - application.graphicService.hooks.onSetStage.tap(this.key, this.handleChange); - application.graphicService.hooks.onRemove.tap(this.key, this.handleChange); + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.onAttributeUpdate.tap(this.key, this.handleChange); + stage.graphicService.hooks.onSetStage.tap(this.key, this.handleChange); + stage.graphicService.hooks.onRemove.tap(this.key, this.handleChange); } deactivate(context: IPluginService): void { const filterByName = (taps: FullTap[]) => { @@ -30,11 +34,13 @@ export class AutoRenderPlugin implements IPlugin { return item.name !== this.key; }); }; + const stage = this.pluginService.stage; + if (!stage) { + return; + } - application.graphicService.hooks.onAttributeUpdate.taps = filterByName( - application.graphicService.hooks.onAttributeUpdate.taps - ); - application.graphicService.hooks.onSetStage.taps = filterByName(application.graphicService.hooks.onSetStage.taps); - application.graphicService.hooks.onRemove.taps = filterByName(application.graphicService.hooks.onRemove.taps); + stage.graphicService.hooks.onAttributeUpdate.taps = filterByName(stage.graphicService.hooks.onAttributeUpdate.taps); + stage.graphicService.hooks.onSetStage.taps = filterByName(stage.graphicService.hooks.onSetStage.taps); + stage.graphicService.hooks.onRemove.taps = filterByName(stage.graphicService.hooks.onRemove.taps); } } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/dirty-bounds-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/dirty-bounds-plugin.ts index 9e4557724..cec9c9226 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/dirty-bounds-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/dirty-bounds-plugin.ts @@ -21,7 +21,11 @@ export class DirtyBoundsPlugin implements IPlugin { } stage.dirtyBounds.clear(); }); - application.graphicService.hooks.beforeUpdateAABBBounds.tap( + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.beforeUpdateAABBBounds.tap( this.key, (graphic: IGraphic, stage: IStage, willUpdate: boolean, bounds: IAABBBounds) => { if (graphic.glyphHost) { @@ -40,7 +44,7 @@ export class DirtyBoundsPlugin implements IPlugin { } } ); - application.graphicService.hooks.afterUpdateAABBBounds.tap( + stage.graphicService.hooks.afterUpdateAABBBounds.tap( this.key, ( graphic: IGraphic, @@ -59,7 +63,7 @@ export class DirtyBoundsPlugin implements IPlugin { stage.dirty(params.globalAABBBounds); } ); - application.graphicService.hooks.clearAABBBounds.tap( + stage.graphicService.hooks.clearAABBBounds.tap( this.key, (graphic: IGraphic, stage: IStage, bounds: IAABBBounds) => { if (!(stage && stage === this.pluginService.stage && stage.renderCount)) { @@ -70,7 +74,7 @@ export class DirtyBoundsPlugin implements IPlugin { } } ); - application.graphicService.hooks.onRemove.tap(this.key, (graphic: IGraphic) => { + stage.graphicService.hooks.onRemove.tap(this.key, (graphic: IGraphic) => { const stage = graphic.stage; if (!(stage && stage === this.pluginService.stage && stage.renderCount)) { return; @@ -81,22 +85,25 @@ export class DirtyBoundsPlugin implements IPlugin { }); } deactivate(context: IPluginService): void { - application.graphicService.hooks.beforeUpdateAABBBounds.taps = - application.graphicService.hooks.beforeUpdateAABBBounds.taps.filter(item => { + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.beforeUpdateAABBBounds.taps = + stage.graphicService.hooks.beforeUpdateAABBBounds.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.afterUpdateAABBBounds.taps = - application.graphicService.hooks.afterUpdateAABBBounds.taps.filter(item => { + stage.graphicService.hooks.afterUpdateAABBBounds.taps = + stage.graphicService.hooks.afterUpdateAABBBounds.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.clearAABBBounds.taps = - application.graphicService.hooks.clearAABBBounds.taps.filter(item => { - return item.name !== this.key; - }); - context.stage.hooks.afterRender.taps = context.stage.hooks.afterRender.taps.filter(item => { + stage.graphicService.hooks.clearAABBBounds.taps = stage.graphicService.hooks.clearAABBBounds.taps.filter(item => { + return item.name !== this.key; + }); + stage.hooks.afterRender.taps = stage.hooks.afterRender.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.onRemove.taps = application.graphicService.hooks.onRemove.taps.filter(item => { + stage.graphicService.hooks.onRemove.taps = stage.graphicService.hooks.onRemove.taps.filter(item => { return item.name !== this.key; }); } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/flex-layout-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/flex-layout-plugin.ts index d56888e39..752281ea7 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/flex-layout-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/flex-layout-plugin.ts @@ -456,8 +456,12 @@ export class FlexLayoutPlugin implements IPlugin { activate(context: IPluginService): void { this.pluginService = context; + const stage = this.pluginService.stage; + if (!stage) { + return; + } // 属性更新 - application.graphicService.hooks.onAttributeUpdate.tap(this.key, graphic => { + stage.graphicService.hooks.onAttributeUpdate.tap(this.key, graphic => { if (graphic.glyphHost) { graphic = graphic.glyphHost; } @@ -467,7 +471,7 @@ export class FlexLayoutPlugin implements IPlugin { this.tryLayout(graphic, false); }); // 包围盒更新(如果包围盒发生变化,就重新布局 - application.graphicService.hooks.beforeUpdateAABBBounds.tap( + stage.graphicService.hooks.beforeUpdateAABBBounds.tap( this.key, (graphic: IGraphic, stage: IStage, willUpdate: boolean, bounds: IAABBBounds) => { if (graphic.glyphHost) { @@ -482,7 +486,7 @@ export class FlexLayoutPlugin implements IPlugin { _tempBounds.copy(bounds); } ); - application.graphicService.hooks.afterUpdateAABBBounds.tap( + stage.graphicService.hooks.afterUpdateAABBBounds.tap( this.key, ( graphic: IGraphic, @@ -503,7 +507,7 @@ export class FlexLayoutPlugin implements IPlugin { } ); // 添加到场景树 - application.graphicService.hooks.onSetStage.tap(this.key, graphic => { + stage.graphicService.hooks.onSetStage.tap(this.key, graphic => { if (graphic.glyphHost) { graphic = graphic.glyphHost; } @@ -511,19 +515,24 @@ export class FlexLayoutPlugin implements IPlugin { }); } deactivate(context: IPluginService): void { - application.graphicService.hooks.onAttributeUpdate.taps = - application.graphicService.hooks.onAttributeUpdate.taps.filter(item => { + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.onAttributeUpdate.taps = stage.graphicService.hooks.onAttributeUpdate.taps.filter( + item => { return item.name !== this.key; - }); - application.graphicService.hooks.beforeUpdateAABBBounds.taps = - application.graphicService.hooks.beforeUpdateAABBBounds.taps.filter(item => { + } + ); + stage.graphicService.hooks.beforeUpdateAABBBounds.taps = + stage.graphicService.hooks.beforeUpdateAABBBounds.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.afterUpdateAABBBounds.taps = - application.graphicService.hooks.afterUpdateAABBBounds.taps.filter(item => { + stage.graphicService.hooks.afterUpdateAABBBounds.taps = + stage.graphicService.hooks.afterUpdateAABBBounds.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.onSetStage.taps = application.graphicService.hooks.onSetStage.taps.filter(item => { + stage.graphicService.hooks.onSetStage.taps = stage.graphicService.hooks.onSetStage.taps.filter(item => { return item.name !== this.key; }); } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/html-attribute-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/html-attribute-plugin.ts index 261c2a6b4..d0104d318 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/html-attribute-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/html-attribute-plugin.ts @@ -52,8 +52,8 @@ export class HtmlAttributePlugin implements IPlugin { context.stage.hooks.afterRender.taps = context.stage.hooks.afterRender.taps.filter(item => { return item.name !== this.key; }); - application.graphicService.hooks.onRemove.unTap(this.key); - application.graphicService.hooks.onRelease.unTap(this.key); + // application.graphicService.hooks.onRemove.unTap(this.key); + // application.graphicService.hooks.onRelease.unTap(this.key); this.release(); } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/incremental-auto-render-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/incremental-auto-render-plugin.ts index aa0c8af05..598569818 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/incremental-auto-render-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/incremental-auto-render-plugin.ts @@ -14,7 +14,11 @@ export class IncrementalAutoRenderPlugin implements IPlugin { activate(context: IPluginService): void { this.pluginService = context; - application.graphicService.hooks.onAddIncremental.tap(this.key, (graphic, group, stage) => { + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.onAddIncremental.tap(this.key, (graphic, group, stage) => { if (graphic.glyphHost) { graphic = graphic.glyphHost; } @@ -23,7 +27,7 @@ export class IncrementalAutoRenderPlugin implements IPlugin { this.renderNextFrame(group); } }); - application.graphicService.hooks.onClearIncremental.tap(this.key, (group, stage) => { + stage.graphicService.hooks.onClearIncremental.tap(this.key, (group, stage) => { if (group.stage === context.stage && group.stage != null) { this.nextUserParams.startAtId = group._uid; this.nextUserParams.restartIncremental = true; @@ -32,14 +36,18 @@ export class IncrementalAutoRenderPlugin implements IPlugin { }); } deactivate(context: IPluginService): void { - application.graphicService.hooks.onAddIncremental.taps = - application.graphicService.hooks.onAddIncremental.taps.filter(item => { - return item.name !== this.key; - }); - application.graphicService.hooks.onClearIncremental.taps = - application.graphicService.hooks.onClearIncremental.taps.filter(item => { + const stage = this.pluginService.stage; + if (!stage) { + return; + } + stage.graphicService.hooks.onAddIncremental.taps = stage.graphicService.hooks.onAddIncremental.taps.filter(item => { + return item.name !== this.key; + }); + stage.graphicService.hooks.onClearIncremental.taps = stage.graphicService.hooks.onClearIncremental.taps.filter( + item => { return item.name !== this.key; - }); + } + ); } renderNextFrame(group: IGroup): void { diff --git a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts deleted file mode 100644 index 5594309da..000000000 --- a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts +++ /dev/null @@ -1,671 +0,0 @@ -// import type { IPointLike } from '@visactor/vutils'; -// import { isObject, isString, max, merge } from '@visactor/vutils'; -// import { Generator } from '../../common/generator'; -// import { createGroup, createLine, createRect } from '../../graphic'; -// import type { -// IGroup, -// ILine, -// IPlugin, -// IPluginService, -// IRect, -// IRichText, -// IRichTextCharacter, -// IRichTextFrame, -// IRichTextIcon, -// IRichTextLine, -// IRichTextParagraph, -// IRichTextParagraphCharacter, -// ITicker, -// ITimeline -// } from '../../interface'; -// import { EditModule, findCursorIndexIgnoreLinebreak } from './edit-module'; -// import { Animate, DefaultTicker, DefaultTimeline } from '../../animate'; - -// type UpdateType = 'input' | 'change' | 'onfocus' | 'defocus' | 'selection' | 'dispatch'; - -// class Selection { -// cacheSelectionStartCursorIdx: number; -// cacheCurCursorIdx: number; -// selectionStartCursorIdx: number; -// curCursorIdx: number; -// rt: IRichText; - -// constructor( -// cacheSelectionStartCursorIdx: number, -// cacheCurCursorIdx: number, -// selectionStartCursorIdx: number, -// curCursorIdx: number, -// rt: IRichText -// ) { -// this.curCursorIdx = curCursorIdx; -// this.selectionStartCursorIdx = selectionStartCursorIdx; -// this.cacheCurCursorIdx = cacheCurCursorIdx; -// this.cacheSelectionStartCursorIdx = cacheSelectionStartCursorIdx; -// this.rt = rt; -// } - -// isEmpty(): boolean { -// return this.selectionStartCursorIdx === this.curCursorIdx; -// } - -// hasFormat(key: string): boolean { -// return this.getFormat(key) != null; -// } - -// /** -// * 获取第idx中key的值 -// * @param key -// * @param idx cursor左侧字符的值,如果idx为-1则认为是特殊情况,为右侧字符的值 -// */ -// _getFormat(key: string, idx: number) { -// if (!this.rt) { -// return null; -// } -// const config = this.rt.attribute.textConfig as any; -// if (idx < 0) { -// idx = 0; -// } -// if (idx >= config.length) { -// return null; -// } -// return config[idx][key] ?? (this.rt.attribute as any)[key]; -// } -// getFormat(key: string): any { -// return this.getAllFormat(key)[0]; -// } - -// getAllFormat(key: string): any { -// const valSet = new Set(); -// let minCursorIdx = Math.min(this.selectionStartCursorIdx, this.curCursorIdx); -// let maxCursorIdx = Math.max(this.selectionStartCursorIdx, this.curCursorIdx); -// if (minCursorIdx === maxCursorIdx) { -// return [this._getFormat(key, minCursorIdx)]; -// } -// minCursorIdx++; -// maxCursorIdx++; -// const maxConfigIdx = this.rt.attribute.textConfig.length - 1; -// if (minCursorIdx > maxConfigIdx) { -// minCursorIdx = maxConfigIdx; -// } -// if (maxCursorIdx > maxConfigIdx) { -// maxCursorIdx = maxConfigIdx; -// } -// for (let i = minCursorIdx; i < maxCursorIdx; i++) { -// const val = this._getFormat(key, i); -// val && valSet.add(val); -// } -// return Array.from(valSet.values()); -// } -// } - -// export const FORMAT_TEXT_COMMAND = 'FORMAT_TEXT_COMMAND'; -// export const FORMAT_ELEMENT_COMMAND = 'FORMAT_ELEMENT_COMMAND'; -// export class RichTextEditPlugin implements IPlugin { -// name: 'RichTextEditPlugin' = 'RichTextEditPlugin'; -// activeEvent: 'onRegister' = 'onRegister'; -// pluginService: IPluginService; -// _uid: number = Generator.GenAutoIncrementId(); -// key: string = this.name + this._uid; -// editing: boolean = false; -// editLine: ILine; -// editBg: IGroup; -// pointerDown: boolean = false; -// // 用于selection中保存上一次click时候的位置 -// lastPoint?: IPointLike; -// editModule: EditModule; -// currRt: IRichText; - -// // 当前的cursor信息 -// // 0.1为第一个字符右侧, -0.1为第一个字符左侧 -// // 1.1为第二个字符右侧,0.9为第二个字符左侧 -// curCursorIdx: number; -// selectionStartCursorIdx: number; - -// commandCbs: Map void>>; -// updateCbs: Array<(type: UpdateType, p: RichTextEditPlugin) => void>; - -// ticker: ITicker; -// timeline: ITimeline; - -// // 富文本有align或者baseline的时候,需要对光标做偏移 -// protected declare deltaX: number; -// protected declare deltaY: number; - -// constructor() { -// this.commandCbs = new Map(); -// this.commandCbs.set(FORMAT_TEXT_COMMAND, [this.formatTextCommandCb]); -// this.updateCbs = []; -// this.timeline = new DefaultTimeline(); -// this.ticker = new DefaultTicker([this.timeline]); -// this.deltaX = 0; -// this.deltaY = 0; -// } - -// static CreateSelection(rt: IRichText) { -// if (!rt) { -// return null; -// } -// const { textConfig = [] } = rt.attribute; -// return new Selection( -// -1, -// textConfig.length - 1, -// findCursorIndexIgnoreLinebreak(textConfig, -1), -// findCursorIndexIgnoreLinebreak(textConfig, textConfig.length - 1), -// rt -// ); -// } - -// /** -// * 获取当前选择的区间范围 -// * @param defaultAll 如果force为true,又没有选择,则认为选择了所有然后进行匹配,如果为false,则认为什么都没有选择,返回null -// * @returns -// */ -// getSelection(defaultAll: boolean = false) { -// if (!this.currRt) { -// return null; -// } -// if ( -// this.selectionStartCursorIdx != null && -// this.curCursorIdx != null -// // this.selectionStartCursorIdx !== this.curCursorIdx && -// ) { -// return new Selection( -// this.selectionStartCursorIdx, -// this.curCursorIdx, -// findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.selectionStartCursorIdx), -// findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.curCursorIdx), -// this.currRt -// ); -// } else if (defaultAll) { -// return RichTextEditPlugin.CreateSelection(this.currRt); -// } -// return null; -// } - -// /* command */ -// formatTextCommandCb(payload: string, p: RichTextEditPlugin) { -// const rt = p.currRt; -// if (!rt) { -// return; -// } -// const selectionData = p.getSelection(); -// if (!selectionData) { -// return; -// } -// const { selectionStartCursorIdx, curCursorIdx } = selectionData; -// const minCursorIdx = Math.min(selectionStartCursorIdx, curCursorIdx); -// const maxCursorIdx = Math.max(selectionStartCursorIdx, curCursorIdx); -// const config = rt.attribute.textConfig.slice(minCursorIdx + 1, maxCursorIdx + 1); -// if (payload === 'bold') { -// config.forEach((item: IRichTextParagraphCharacter) => (item.fontWeight = 'bold')); -// } else if (payload === 'italic') { -// config.forEach((item: IRichTextParagraphCharacter) => (item.fontStyle = 'italic')); -// } else if (payload === 'underline') { -// config.forEach((item: IRichTextParagraphCharacter) => (item.underline = true)); -// } else if (payload === 'lineThrough') { -// config.forEach((item: IRichTextParagraphCharacter) => (item.lineThrough = true)); -// } else if (isObject(payload)) { -// config.forEach((item: IRichTextParagraphCharacter) => merge(item, payload)); -// } -// rt.setAttributes(rt.attribute); -// } - -// dispatchCommand(command: string, payload: any) { -// const cbs = this.commandCbs.get(command); -// cbs && cbs.forEach(cb => cb(payload, this)); -// this.updateCbs.forEach(cb => cb('dispatch', this)); -// } - -// registerCommand(command: string, cb: (payload: any, p: RichTextEditPlugin) => void) { -// const cbs: Array<(payload: any, p: RichTextEditPlugin) => void> = this.commandCbs.get(command) || []; -// cbs.push(cb); -// } - -// registerUpdateListener(cb: (type: UpdateType, p: RichTextEditPlugin) => void) { -// const cbs = this.updateCbs || []; -// cbs.push(cb); -// } - -// activate(context: IPluginService): void { -// this.pluginService = context; -// this.editModule = new EditModule(); -// // context.stage.on('click', this.handleClick); -// context.stage.on('pointermove', this.handleMove); -// context.stage.on('pointerdown', this.handlePointerDown); -// context.stage.on('pointerup', this.handlePointerUp); -// context.stage.on('pointerleave', this.handlePointerUp); - -// this.editModule.onInput(this.handleInput); -// this.editModule.onChange(this.handleChange); -// } - -// handleInput = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { -// // 修改cursor的位置,但并不同步,因为这可能是临时的 -// const p = this.getPointByColumnIdx(cursorIdx, rt, orient); -// this.hideSelection(); -// this.setCursor(p.x, p.y1, p.y2); -// this.updateCbs.forEach(cb => cb('input', this)); -// }; -// handleChange = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { -// // 修改cursor的位置,并同步到editModule -// const p = this.getPointByColumnIdx(cursorIdx, rt, orient); -// this.curCursorIdx = cursorIdx; -// this.selectionStartCursorIdx = cursorIdx; -// this.setCursorAndTextArea(p.x, p.y1, p.y2, rt); -// this.hideSelection(); -// this.updateCbs.forEach(cb => cb('change', this)); -// }; - -// handleMove = (e: PointerEvent) => { -// if (!this.isRichtext(e)) { -// return; -// } -// this.currRt = e.target as IRichText; -// this.handleEnter(e); -// (e.target as any).once('pointerleave', this.handleLeave); - -// this.showSelection(e); -// }; - -// showSelection(e: PointerEvent) { -// const cache = (e.target as IRichText).getFrameCache(); -// if (!(cache && this.editBg)) { -// return; -// } -// if (this.pointerDown) { -// let p0 = this.lastPoint; -// // 计算p1在字符中的位置 -// let p1 = this.getEventPosition(e); -// let line1Info = this.getLineByPoint(cache, p1); -// if (!line1Info) { -// return; -// } -// const column1 = this.getColumnByLinePoint(line1Info, p1); -// const y1 = line1Info.top; -// const y2 = line1Info.top + line1Info.height; -// let x = column1.left + column1.width; -// let cursorIndex = this.getColumnIndex(cache, column1); -// if (p1.x < column1.left + column1.width / 2) { -// x = column1.left; -// cursorIndex -= 1; -// } -// p1.x = x; -// p1.y = (y1 + y2) / 2; -// let line0Info = this.getLineByPoint(cache, p0); -// if (p0.y > p1.y || (p0.y === p1.y && p0.x > p1.x)) { -// [p0, p1] = [p1, p0]; -// [line1Info, line0Info] = [line0Info, line1Info]; -// } - -// this.editBg.removeAllChild(); -// if (line0Info === line1Info) { -// // const column0 = this.getColumnByLinePoint(line0Info, p0); -// this.editBg.setAttributes({ -// x: p0.x, -// y: line0Info.top, -// width: p1.x - p0.x, -// height: line0Info.height, -// fill: '#336df4', -// fillOpacity: 0.2 -// }); -// } else { -// this.editBg.setAttributes({ x: 0, y: line0Info.top, width: 0, height: 0 }); -// const startIdx = cache.lines.findIndex(item => item === line0Info); -// const endIdx = cache.lines.findIndex(item => item === line1Info); -// let y = 0; -// for (let i = startIdx; i <= endIdx; i++) { -// const line = cache.lines[i]; -// if (i === startIdx) { -// const p = line.paragraphs[line.paragraphs.length - 1]; -// this.editBg.add( -// createRect({ -// x: p0.x, -// y, -// width: p.left + p.width - p0.x, -// height: line.height, -// fill: '#336df4', -// fillOpacity: 0.2 -// }) -// ); -// } else if (i === endIdx) { -// const p = line.paragraphs[0]; -// this.editBg.add( -// createRect({ -// x: p.left, -// y, -// width: p1.x - p.left, -// height: line.height, -// fill: '#336df4', -// fillOpacity: 0.2 -// }) -// ); -// } else { -// const p0 = line.paragraphs[0]; -// const p1 = line.paragraphs[line.paragraphs.length - 1]; -// this.editBg.add( -// createRect({ -// x: p0.left, -// y, -// width: p1.left + p1.width - p0.left, -// height: line.height, -// fill: '#336df4', -// fillOpacity: 0.2 -// }) -// ); -// } -// y += line.height; -// } -// } - -// this.curCursorIdx = cursorIndex; -// this.setCursorAndTextArea(x, y1 + 2, y2 - 2, e.target as IRichText); - -// this.applyUpdate(); -// this.updateCbs.forEach(cb => cb('selection', this)); -// } -// } - -// hideSelection() { -// if (this.editBg) { -// this.editBg.removeAllChild(); -// this.editBg.setAttributes({ fill: 'transparent' }); -// } -// } - -// handlePointerDown = (e: PointerEvent) => { -// if (this.editing) { -// this.onFocus(e); -// } else { -// this.deFocus(e); -// } -// this.applyUpdate(); -// this.pointerDown = true; -// this.updateCbs.forEach(cb => cb(this.editing ? 'onfocus' : 'defocus', this)); -// console.log(this.selectionStartCursorIdx); -// }; -// handlePointerUp = (e: PointerEvent) => { -// this.pointerDown = false; -// }; - -// forceFocus(e: PointerEvent) { -// this.handleEnter(e); -// this.handlePointerDown(e); -// this.handlePointerUp(e); -// } - -// // 鼠标进入 -// handleEnter = (e: PointerEvent) => { -// this.editing = true; -// this.pluginService.stage.setCursor('text'); -// }; - -// // 鼠标离开 -// handleLeave = (e: PointerEvent) => { -// this.editing = false; -// this.pluginService.stage.setCursor('default'); -// }; - -// isRichtext(e: PointerEvent) { -// return !!(e.target && (e.target as any).type === 'richtext' && (e.target as any).attribute.editable); -// } - -// protected getEventPosition(e: PointerEvent): IPointLike { -// const p = this.pluginService.stage.eventPointTransform(e); - -// const p1 = { x: 0, y: 0 }; -// (e.target as IRichText).globalTransMatrix.transformPoint(p, p1); -// p1.x -= this.deltaX; -// p1.y -= this.deltaY; -// return p1; -// } - -// protected getLineByPoint(cache: IRichTextFrame, p1: IPointLike): IRichTextLine { -// let lineInfo = cache.lines[0]; -// for (let i = 0; i < cache.lines.length; i++) { -// if (lineInfo.top <= p1.y && lineInfo.top + lineInfo.height >= p1.y) { -// break; -// } -// lineInfo = cache.lines[i + 1]; -// } - -// return lineInfo; -// } -// protected getColumnByLinePoint(lineInfo: IRichTextLine, p1: IPointLike): IRichTextParagraph | IRichTextIcon { -// let columnInfo = lineInfo.paragraphs[0]; -// for (let i = 0; i < lineInfo.paragraphs.length; i++) { -// if (columnInfo.left <= p1.x && columnInfo.left + columnInfo.width >= p1.x) { -// break; -// } -// columnInfo = lineInfo.paragraphs[i]; -// } - -// return columnInfo; -// } - -// onFocus(e: PointerEvent) { -// this.deFocus(e); -// this.currRt = e.target as IRichText; - -// // 添加shadowGraphic -// const target = e.target as IRichText; -// RichTextEditPlugin.tryUpdateRichtext(target); -// const shadowRoot = target.attachShadow(); -// const cache = target.getFrameCache(); -// if (!cache) { -// return; -// } - -// this.deltaX = 0; -// this.deltaY = 0; -// const height = cache.actualHeight; -// const width = cache.lines.reduce((w, item) => Math.max(w, item.actualWidth), 0); -// if (cache.globalAlign === 'center') { -// this.deltaX = -width / 2; -// } else if (cache.globalAlign === 'right') { -// this.deltaX = -width; -// } -// if (cache.globalBaseline === 'middle') { -// this.deltaY = -height / 2; -// } else if (cache.globalBaseline === 'bottom') { -// this.deltaY = -height; -// } - -// shadowRoot.setAttributes({ shadowRootIdx: -1, x: this.deltaX, y: this.deltaY }); -// if (!this.editLine) { -// const line = createLine({ x: 0, y: 0, lineWidth: 1, stroke: 'black' }); -// // 不使用stage的Ticker,避免影响其他的动画以及受到其他动画影响 -// const animate = line.animate(); -// animate.setTimeline(this.timeline); -// animate.to({ opacity: 1 }, 10, 'linear').wait(700).to({ opacity: 0 }, 10, 'linear').wait(700).loop(Infinity); -// this.editLine = line; -// this.ticker.start(true); - -// const g = createGroup({ x: 0, y: 0, width: 0, height: 0 }); -// this.editBg = g; -// shadowRoot.add(this.editLine); -// shadowRoot.add(this.editBg); -// } - -// const p1 = this.getEventPosition(e); - -// const lineInfo = this.getLineByPoint(cache, p1); - -// if (lineInfo) { -// const columnInfo = this.getColumnByLinePoint(lineInfo, p1); -// if (!columnInfo) { -// return; -// } - -// let y1 = lineInfo.top; -// let y2 = lineInfo.top + lineInfo.height; -// let x = columnInfo.left + columnInfo.width; -// y1 += 2; -// y2 -= 2; -// let cursorIndex = this.getColumnIndex(cache, columnInfo); -// if (p1.x < columnInfo.left + columnInfo.width / 2) { -// x = columnInfo.left; -// cursorIndex -= 1; -// } - -// this.lastPoint = { x, y: (y1 + y2) / 2 }; - -// this.curCursorIdx = cursorIndex; -// this.selectionStartCursorIdx = cursorIndex; -// this.setCursorAndTextArea(x, y1, y2, target); -// } -// } - -// protected getPointByColumnIdx(idx: number, rt: IRichText, orient: 'left' | 'right') { -// const cache = rt.getFrameCache(); -// const column = this.getColumnByIndex(cache, idx); -// const height = rt.attribute.fontSize ?? (rt.attribute.textConfig?.[0] as any)?.fontSize; -// if (!column) { -// return { -// x: 0, -// y1: 0, -// y2: height -// }; -// } -// const { lineInfo, columnInfo } = column; -// let y1 = lineInfo.top; -// let y2 = lineInfo.top + lineInfo.height; -// const x = columnInfo.left + (orient === 'left' ? 0 : columnInfo.width); -// y1 += 2; -// y2 -= 2; - -// return { x, y1, y2 }; -// } - -// protected getColumnIndex(cache: IRichTextFrame, cInfo: IRichTextParagraph | IRichTextIcon) { -// // TODO 认为都是单个字符拆分的 -// let inputIndex = -1; -// for (let i = 0; i < cache.lines.length; i++) { -// const line = cache.lines[i]; -// for (let j = 0; j < line.paragraphs.length; j++) { -// inputIndex++; -// if (cInfo === line.paragraphs[j]) { -// return inputIndex; -// } -// } -// } -// return -1; -// } -// protected getColumnByIndex( -// cache: IRichTextFrame, -// index: number -// ): { -// lineInfo: IRichTextLine; -// columnInfo: IRichTextParagraph | IRichTextIcon; -// } | null { -// // TODO 认为都是单个字符拆分的 -// let inputIndex = -1; -// for (let i = 0; i < cache.lines.length; i++) { -// const lineInfo = cache.lines[i]; -// for (let j = 0; j < lineInfo.paragraphs.length; j++) { -// const columnInfo = lineInfo.paragraphs[j]; -// inputIndex++; -// if (inputIndex === index) { -// return { -// lineInfo, -// columnInfo -// }; -// } -// } -// } -// return null; -// } - -// protected setCursorAndTextArea(x: number, y1: number, y2: number, rt: IRichText) { -// this.editLine.setAttributes({ -// points: [ -// { x, y: y1 }, -// { x, y: y2 } -// ] -// }); -// const out = { x: 0, y: 0 }; -// rt.globalTransMatrix.getInverse().transformPoint({ x, y: y1 }, out); -// // TODO 考虑stage变换 -// const { left, top } = this.pluginService.stage.window.getBoundingClientRect(); -// out.x += left; -// out.y += top; - -// this.editModule.moveTo(out.x, out.y, rt, this.curCursorIdx, this.selectionStartCursorIdx); -// } -// protected setCursor(x: number, y1: number, y2: number) { -// this.editLine.setAttributes({ -// points: [ -// { x, y: y1 }, -// { x, y: y2 } -// ] -// }); -// } - -// applyUpdate() { -// this.pluginService.stage.renderNextFrame(); -// } -// deFocus(e: PointerEvent) { -// const target = this.currRt as IRichText; -// if (!target) { -// return; -// } -// target.detachShadow(); -// this.currRt = null; -// if (this.editLine) { -// this.editLine.parent.removeChild(this.editLine); -// this.editLine.release(); -// this.editLine = null; - -// this.editBg.parent.removeChild(this.editBg); -// this.editBg.release(); -// this.editBg = null; -// } -// } - -// static splitText(text: string) { -// // 😁这种emoji长度算两个,所以得处理一下 -// return Array.from(text); -// } - -// static tryUpdateRichtext(richtext: IRichText) { -// const cache = richtext.getFrameCache(); -// if ( -// !cache.lines.every(line => -// line.paragraphs.every( -// item => !(item.text && isString(item.text) && RichTextEditPlugin.splitText(item.text).length > 1) -// ) -// ) -// ) { -// const tc: IRichTextCharacter[] = []; -// richtext.attribute.textConfig.forEach((item: IRichTextParagraphCharacter) => { -// const textList = RichTextEditPlugin.splitText(item.text.toString()); -// if (isString(item.text) && textList.length > 1) { -// // 拆分 -// for (let i = 0; i < textList.length; i++) { -// const t = textList[i]; -// tc.push({ ...item, text: t }); -// } -// } else { -// tc.push(item); -// } -// }); -// richtext.setAttributes({ textConfig: tc }); -// richtext.doUpdateFrameCache(tc); -// } -// } - -// onSelect() { -// return; -// } - -// deactivate(context: IPluginService): void { -// // context.stage.off('pointerdown', this.handleClick); -// context.stage.off('pointermove', this.handleMove); -// context.stage.off('pointerdown', this.handlePointerDown); -// context.stage.off('pointerup', this.handlePointerUp); -// context.stage.off('pointerleave', this.handlePointerUp); -// } - -// release() { -// this.editModule.release(); -// } -// } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts index ba20f03f4..b37593641 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts @@ -8,6 +8,7 @@ import { createRichText, createText, getRichTextBounds, + Graphic, RichText } from '../../graphic'; import type { @@ -27,7 +28,6 @@ import type { ITicker, ITimeline } from '../../interface'; -import { Animate, DefaultTicker, DefaultTimeline } from '../../animate'; import { EditModule, findConfigIndexByCursorIdx, getDefaultCharacterConfig } from './edit-module'; import { application } from '../../application'; import { getWordStartEndIdx } from '../../graphic/richtext/utils'; @@ -148,8 +148,8 @@ export class RichTextEditPlugin implements IPlugin { shadowPlaceHolder: IRichText; shadowBounds: IRect; - ticker: ITicker; - timeline: ITimeline; + ticker?: ITicker; + timeline?: ITimeline; currRt: IRichText; @@ -208,8 +208,6 @@ export class RichTextEditPlugin implements IPlugin { this.commandCbs.set(FORMAT_TEXT_COMMAND, [this.formatTextCommandCb]); this.commandCbs.set(FORMAT_ALL_TEXT_COMMAND, [this.formatAllTextCommandCb]); this.updateCbs = []; - this.timeline = new DefaultTimeline(); - this.ticker = new DefaultTicker([this.timeline]); this.deltaX = 0; this.deltaY = 0; } @@ -310,6 +308,9 @@ export class RichTextEditPlugin implements IPlugin { this.editModule.onInput(this.handleInput); this.editModule.onChange(this.handleChange); this.editModule.onFocusOut(this.handleFocusOut); + + this.timeline = (this as any).createTimeline && (this as any).createTimeline(); + this.ticker = (this as any).createTicker && (this as any).createTicker(context.stage); } copyToClipboard(e: KeyboardEvent): boolean { @@ -793,7 +794,7 @@ export class RichTextEditPlugin implements IPlugin { // 不使用stage的Ticker,避免影响其他的动画以及受到其他动画影响 this.addAnimateToLine(line); this.editLine = line; - this.ticker.start(true); + this.ticker && this.ticker.start(true); const g = createGroup({ x: 0, y: 0, width: 0, height: 0 }); this.editBg = g; @@ -933,6 +934,9 @@ export class RichTextEditPlugin implements IPlugin { } protected addAnimateToLine(line: ILine) { + if (!line.animate) { + return; + } line.setAttributes({ opacity: 1 }); line.animates && line.animates.forEach(animate => { diff --git a/packages/vrender-core/src/render/contributions/render/arc-render.ts b/packages/vrender-core/src/render/contributions/render/arc-render.ts index 295b20bd2..1e11157ee 100644 --- a/packages/vrender-core/src/render/contributions/render/arc-render.ts +++ b/packages/vrender-core/src/render/contributions/render/arc-render.ts @@ -19,7 +19,8 @@ import type { IGraphicRender, IGraphicRenderDrawParams, IContributionProvider, - IConicalGradient + IConicalGradient, + IArcGraphicAttribute } from '../../../interface'; import { cornerTangents, drawArcPath, fillVisible } from './utils'; import { getConicGradientAt } from '../../../canvas/conical-gradient'; @@ -208,10 +209,11 @@ export class DefaultCanvasArcRender extends BaseRender implements IGraphic ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute - ) => boolean + ) => boolean, + arcAttribute?: Required ) { // const arcAttribute = graphicService.themeService.getCurrentTheme().arcAttribute; - const arcAttribute = getTheme(arc, params?.theme).arc; + arcAttribute = arcAttribute ?? getTheme(arc, params?.theme).arc; const { fill = arcAttribute.fill, stroke = arcAttribute.stroke, @@ -282,34 +284,35 @@ export class DefaultCanvasArcRender extends BaseRender implements IGraphic strokeCb ); - const _runFill = () => { - if (doFill) { - if (fillCb) { - fillCb(context, arc.attribute, arcAttribute); - } else if (fVisible) { - context.setCommonStyle(arc, arc.attribute, originX - x, originY - y, arcAttribute); - context.fill(); - } - } - }; - - const _runStroke = () => { - if (doStroke && isFullStroke) { - if (strokeCb) { - strokeCb(context, arc.attribute, arcAttribute); - } else if (sVisible) { - context.setStrokeStyle(arc, arc.attribute, originX - x, originY - y, arcAttribute); - context.stroke(); - } - } - }; + // 内联的函数性能差 + // const _runFill = () => { + // if (doFill) { + // if (fillCb) { + // fillCb(context, arc.attribute, arcAttribute); + // } else if (fVisible) { + // context.setCommonStyle(arc, arc.attribute, originX - x, originY - y, arcAttribute); + // context.fill(); + // } + // } + // }; + + // const _runStroke = () => { + // if (doStroke && isFullStroke) { + // if (strokeCb) { + // strokeCb(context, arc.attribute, arcAttribute); + // } else if (sVisible) { + // context.setStrokeStyle(arc, arc.attribute, originX - x, originY - y, arcAttribute); + // context.stroke(); + // } + // } + // }; if (!fillStrokeOrder) { - _runFill(); - _runStroke(); + this._runFill(arc, context, x, y, arcAttribute, doFill, fVisible, originX, originY, fillCb); + this._runStroke(arc, context, x, y, arcAttribute, doStroke, isFullStroke, sVisible, strokeCb); } else { - _runStroke(); - _runFill(); + this._runStroke(arc, context, x, y, arcAttribute, doStroke, isFullStroke, sVisible, strokeCb); + this._runFill(arc, context, x, y, arcAttribute, doFill, fVisible, originX, originY, fillCb); } } @@ -434,8 +437,60 @@ export class DefaultCanvasArcRender extends BaseRender implements IGraphic } } + private _runFill( + arc: IArc, + context: IContext2d, + x: number, + y: number, + arcAttribute: Required, + doFill: boolean, + fVisible: boolean, + originX: number, + originY: number, + fillCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doFill) { + if (fillCb) { + fillCb(context, arc.attribute, arcAttribute); + } else if (fVisible) { + context.setCommonStyle(arc, arc.attribute, originX - x, originY - y, arcAttribute); + context.fill(); + } + } + } + + private _runStroke( + arc: IArc, + context: IContext2d, + x: number, + y: number, + arcAttribute: Required, + doStroke: boolean, + isFullStroke: boolean, + sVisible: boolean, + strokeCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doStroke && isFullStroke) { + if (strokeCb) { + // fillCb(context, arc.attribute, arcAttribute); + } else if (sVisible) { + context.setStrokeStyle(arc, arc.attribute, x, y, arcAttribute); + // context.strokeStyle = 'red'; + context.stroke(); + } + } + } + draw(arc: IArc, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { const arcAttribute = getTheme(arc, params?.theme).arc; - this._draw(arc, arcAttribute, false, drawContext, params); + this._draw(arc, arcAttribute, false, drawContext, params, arcAttribute); } } diff --git a/packages/vrender-core/src/render/contributions/render/area-render.ts b/packages/vrender-core/src/render/contributions/render/area-render.ts index c4cbb4536..cc0792f25 100644 --- a/packages/vrender-core/src/render/contributions/render/area-render.ts +++ b/packages/vrender-core/src/render/contributions/render/area-render.ts @@ -551,7 +551,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph } else { direction = xTotalLength > yTotalLength ? Direction.ROW : Direction.COLUMN; } - drawAreaSegments(context.camera ? context : context.nativeContext, cache, clipRange, { + drawAreaSegments(context, cache, clipRange, { offsetX, offsetY, offsetZ, @@ -599,7 +599,7 @@ export class DefaultCanvasAreaRender extends BaseRender implements IGraph if (isArray(stroke) && (stroke[0] || stroke[2]) && stroke[1] === false) { context.beginPath(); drawSegments( - context.camera ? context : context.nativeContext, + context, stroke[0] ? cache.top : cache.bottom, clipRange, direction === Direction.ROW ? 'x' : 'y', diff --git a/packages/vrender-core/src/render/contributions/render/base-render.ts b/packages/vrender-core/src/render/contributions/render/base-render.ts index f27b8dce9..7e42261b6 100644 --- a/packages/vrender-core/src/render/contributions/render/base-render.ts +++ b/packages/vrender-core/src/render/contributions/render/base-render.ts @@ -510,7 +510,8 @@ export abstract class BaseRender { defaultAttr: IGraphicAttribute, computed3dMatrix: boolean, drawContext: IDrawContext, - params?: IGraphicRenderDrawParams + params?: IGraphicRenderDrawParams, + themeAttribute?: IGraphicAttribute ) { const { context } = drawContext; if (!context) { @@ -533,7 +534,7 @@ export abstract class BaseRender { return; } - this.drawShape(graphic, context, x, y, drawContext, params); + this.drawShape(graphic, context, x, y, drawContext, params, null, null, themeAttribute); this.z = 0; if (context.modelMatrix !== lastModelMatrix) { @@ -560,7 +561,8 @@ export abstract class BaseRender { ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute - ) => boolean + ) => boolean, + themeAttribute?: IGraphicAttribute ): void; // abstract drawShape( diff --git a/packages/vrender-core/src/render/contributions/render/draw-contribution.ts b/packages/vrender-core/src/render/contributions/render/draw-contribution.ts index 0aed84f12..0e8d291aa 100644 --- a/packages/vrender-core/src/render/contributions/render/draw-contribution.ts +++ b/packages/vrender-core/src/render/contributions/render/draw-contribution.ts @@ -136,11 +136,13 @@ export class DefaultDrawContribution implements IDrawContribution { dirtyBounds.y2 = Math.ceil(dirtyBounds.y2 * context.dpr) / context.dpr; } this.backupDirtyBounds.copy(dirtyBounds); - context.inuse = true; + // TODO:不需要设置context.transform,后续translate会设置 + context.reset(false); + context.save(); context.setClearMatrix(transMatrix.a, transMatrix.b, transMatrix.c, transMatrix.d, transMatrix.e, transMatrix.f); // 初始化context - context.clearMatrix(); - context.setTransformForCurrent(true); + context.clearMatrix(false); + // context.setTransformForCurrent(true); // const drawInArea = // dirtyBounds.width() * context.dpr < context.canvas.width || @@ -167,7 +169,7 @@ export class DefaultDrawContribution implements IDrawContribution { // // 设置translate // context.translate(x, y, true); - context.save(); + // context.save(); renderService.renderTreeRoots .sort((a, b) => { return (a.attribute.zIndex ?? DefaultAttribute.zIndex) - (b.attribute.zIndex ?? DefaultAttribute.zIndex); @@ -179,10 +181,11 @@ export class DefaultDrawContribution implements IDrawContribution { }); // context.restore(); - context.restore(); - context.setClearMatrix(1, 0, 0, 1, 0, 0); + // context.restore(); + // context.setClearMatrix(1, 0, 0, 1, 0, 0); // this.break = false; - context.inuse = false; + // context.inuse = false; + context.restore(); context.draw(); } @@ -235,36 +238,14 @@ export class DefaultDrawContribution implements IDrawContribution { this.dirtyBounds.copy(this.backupDirtyBounds).transformWithMatrix(nextM.getInverse()); } + drawContext.isGroupScroll = !!(group.attribute.scrollX || group.attribute.scrollY); + this.renderItem(group, drawContext, { - drawingCb: () => { - skipSort - ? group.forEachChildren((item: IGraphic) => { - if (drawContext.break) { - return; - } - if (item.isContainer) { - this.renderGroup(item as IGroup, drawContext, nextM); - } else { - this.renderItem(item, drawContext); - } - }) - : foreach( - group, - DefaultAttribute.zIndex, - (item: IGraphic) => { - if (drawContext.break) { - return; - } - if (item.isContainer) { - this.renderGroup(item as IGroup, drawContext, nextM); - } else { - this.renderItem(item, drawContext); - } - }, - false, - !!drawContext.context?.camera - ); - } + renderInGroupParams: { + skipSort, + nextM + }, + renderInGroup: this._renderInGroup }); if (this.useDirtyBounds) { @@ -274,6 +255,36 @@ export class DefaultDrawContribution implements IDrawContribution { } } + _renderInGroup = (skipSort: boolean, group: IGroup, drawContext: IDrawContext, nextM: IMatrix) => { + skipSort + ? group.forEachChildren((item: IGraphic) => { + if (drawContext.break) { + return; + } + if (item.isContainer) { + this.renderGroup(item as IGroup, drawContext, nextM); + } else { + this.renderItem(item, drawContext); + } + }) + : foreach( + group, + DefaultAttribute.zIndex, + (item: IGraphic) => { + if (drawContext.break) { + return; + } + if (item.isContainer) { + this.renderGroup(item as IGroup, drawContext, nextM); + } else { + this.renderItem(item, drawContext); + } + }, + false, + !!drawContext.context?.camera + ); + }; + protected _increaseRender(group: IGroup, drawContext: IDrawContext) { const { layer, stage } = drawContext; const { subLayers } = layer; @@ -363,19 +374,27 @@ export class DefaultDrawContribution implements IDrawContribution { return; } - let retrans: boolean = this.scrollMatrix && (this.scrollMatrix.e !== 0 || this.scrollMatrix.f !== 0); + let retrans: boolean = false; let tempBounds: IBounds; - if (graphic.parent) { + if (drawContext.isGroupScroll) { const { scrollX = 0, scrollY = 0 } = graphic.parent.attribute; - if (!!(scrollX || scrollY)) { - retrans = true; - if (!this.scrollMatrix) { - this.scrollMatrix = matrixAllocate.allocate(1, 0, 0, 1, 0, 0); - } - this.scrollMatrix.translate(-scrollX, -scrollY); + retrans = true; + if (!this.scrollMatrix) { + this.scrollMatrix = matrixAllocate.allocate(1, 0, 0, 1, 0, 0); } + this.scrollMatrix.translate(-scrollX, -scrollY); } + // if (graphic.parent) { + // const { scrollX = 0, scrollY = 0 } = graphic.parent.attribute; + // if (!!(scrollX || scrollY)) { + // retrans = true; + // if (!this.scrollMatrix) { + // this.scrollMatrix = matrixAllocate.allocate(1, 0, 0, 1, 0, 0); + // } + // this.scrollMatrix.translate(-scrollX, -scrollY); + // } + // } // 需要二次变化,那就重新算一个变换后的Bounds if (retrans) { tempBounds = this.dirtyBounds.clone().transformWithMatrix(this.scrollMatrix); diff --git a/packages/vrender-core/src/render/contributions/render/draw-interceptor.ts b/packages/vrender-core/src/render/contributions/render/draw-interceptor.ts index 71f34908d..7dbe9ce4b 100644 --- a/packages/vrender-core/src/render/contributions/render/draw-interceptor.ts +++ b/packages/vrender-core/src/render/contributions/render/draw-interceptor.ts @@ -187,6 +187,16 @@ export class CommonDrawItemInterceptorContribution implements IDrawItemIntercept drawContribution: IDrawContribution, params?: IGraphicRenderDrawParams ): boolean { + // 【性能方案】判定写在外层,减少遍历判断耗时,10000条数据减少1ms + if ( + (!graphic.in3dMode || drawContext.in3dInterceptor) && + !graphic.shadowRoot && + !graphic.attribute._debug_bounds && + !(graphic.baseGraphic || graphic.attribute.globalZIndex || graphic.interactiveGraphic) + ) { + return false; + } + for (let i = 0; i < this.interceptors.length; i++) { if ( this.interceptors[i].afterDrawItem && @@ -209,6 +219,7 @@ export class CommonDrawItemInterceptorContribution implements IDrawItemIntercept if ( (!graphic.in3dMode || drawContext.in3dInterceptor) && !graphic.shadowRoot && + !graphic.attribute._debug_bounds && !(graphic.baseGraphic || graphic.attribute.globalZIndex || graphic.interactiveGraphic) ) { return false; diff --git a/packages/vrender-core/src/render/contributions/render/group-render.ts b/packages/vrender-core/src/render/contributions/render/group-render.ts index 8ba74ab6c..c74f08d7c 100644 --- a/packages/vrender-core/src/render/contributions/render/group-render.ts +++ b/packages/vrender-core/src/render/contributions/render/group-render.ts @@ -10,7 +10,8 @@ import type { IRenderService, IGraphicRender, IGraphicRenderDrawParams, - IContributionProvider + IContributionProvider, + IGroupGraphicAttribute } from '../../../interface'; import { getTheme } from '../../../graphic/theme'; import { getModelMatrix } from '../../../graphic/graphic-service/graphic-service'; @@ -25,7 +26,7 @@ import { GROUP_NUMBER_TYPE } from '../../../graphic/constants'; import { BaseRenderContributionTime } from '../../../common/enums'; import { defaultGroupBackgroundRenderContribution } from './contributions'; import { multiplyMat4Mat4 } from '../../../common/matrix'; -import { vglobal } from '../../../modules'; +import { application } from '../../../application'; @injectable() export class DefaultCanvasGroupRender implements IGraphicRender { @@ -61,18 +62,22 @@ export class DefaultCanvasGroupRender implements IGraphicRender { ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute - ) => boolean + ) => boolean, + groupAttribute?: Required ) { - // const groupAttribute = graphicService.themeService.getCurrentTheme().groupAttribute; - const groupAttribute = getTheme(group, params?.theme).group; + // 提前判定,否则每次都要获取一堆属性 + const { clip, fill, stroke, background } = group.attribute; + + if (!(clip || fill || stroke || background)) { + return; + } + + groupAttribute = groupAttribute ?? getTheme(group, params?.theme).group; + const { - fill = groupAttribute.fill, - background, - stroke = groupAttribute.stroke, opacity = groupAttribute.opacity, width = groupAttribute.width, height = groupAttribute.height, - clip = groupAttribute.clip, fillOpacity = groupAttribute.fillOpacity, strokeOpacity = groupAttribute.strokeOpacity, cornerRadius = groupAttribute.cornerRadius, @@ -227,15 +232,15 @@ export class DefaultCanvasGroupRender implements IGraphicRender { return; } - // debugger; - const { clip, baseOpacity = 1, drawMode, x, y, width, height } = group.attribute; + const { clip, baseOpacity = 1, drawMode } = group.attribute; const lastNativeContext = context.nativeContext; const lastNativeCanvas = context.canvas.nativeCanvas; if (drawMode > 0) { + const { x, y, width, height } = group.attribute; // 绘制到新的Canvas上,然后再绘制回来 const canvas = context.canvas; - const newCanvas = vglobal.createCanvas({ width: canvas.width, height: canvas.height, dpr: 1 }); + const newCanvas = application.global.createCanvas({ width: canvas.width, height: canvas.height, dpr: 1 }); const newContext = newCanvas.getContext('2d'); const transform = context.nativeContext.getTransform(); // 首先应用transform @@ -275,8 +280,6 @@ export class DefaultCanvasGroupRender implements IGraphicRender { const baseGlobalAlpha = context.baseGlobalAlpha; context.baseGlobalAlpha *= baseOpacity; - const groupAttribute = getTheme(group, params?.theme).group; - // const lastMatrix = context.modelMatrix; // if (context.camera) { // const m = group.transMatrix; @@ -298,6 +301,7 @@ export class DefaultCanvasGroupRender implements IGraphicRender { const lastModelMatrix = context.modelMatrix; const camera = context.camera; if (camera) { + const groupAttribute = getTheme(group, params?.theme).group; const nextModelMatrix = mat4Allocate.allocate(); // 计算模型矩阵 const modelMatrix = mat4Allocate.allocate(); @@ -329,17 +333,22 @@ export class DefaultCanvasGroupRender implements IGraphicRender { () => false ); } else { - this.drawShape(group, context, 0, 0, drawContext); + this.drawShape(group, context, 0, 0, drawContext, null, null, null); } // 绘制子元素的时候要添加scroll - const { scrollX = groupAttribute.scrollX, scrollY = groupAttribute.scrollY } = group.attribute; + const { scrollX, scrollY } = group.attribute; if (scrollX || scrollY) { context.translate(scrollX, scrollY); } let p: any; - if (params && params.drawingCb) { - p = params.drawingCb(); + if (params && params.renderInGroup) { + p = params.renderInGroup( + params.renderInGroupParams?.skipSort, + group, + drawContext, + params.renderInGroupParams?.nextM + ); } if (context.modelMatrix !== lastModelMatrix) { @@ -350,6 +359,7 @@ export class DefaultCanvasGroupRender implements IGraphicRender { context.baseGlobalAlpha = baseGlobalAlpha; if (drawMode > 0) { + const { x, y, width, height } = group.attribute; // 将原始的context和canvas恢复,另外将newCanvas上的内容绘制到lastCanvas上 const newContext = context.nativeContext; const newCanvas = context.canvas.nativeCanvas; diff --git a/packages/vrender-core/src/render/contributions/render/line-render.ts b/packages/vrender-core/src/render/contributions/render/line-render.ts index ed7730d8d..55ba43608 100644 --- a/packages/vrender-core/src/render/contributions/render/line-render.ts +++ b/packages/vrender-core/src/render/contributions/render/line-render.ts @@ -83,7 +83,7 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph const z = this.z ?? 0; - drawSegments(context.camera ? context : context.nativeContext, cache, clipRange, clipRangeByDimension, { + drawSegments(context, cache, clipRange, clipRangeByDimension, { offsetX, offsetY, offsetZ: z diff --git a/packages/vrender-core/src/render/contributions/render/rect-render.ts b/packages/vrender-core/src/render/contributions/render/rect-render.ts index 9500d4be8..a8ba6e91d 100644 --- a/packages/vrender-core/src/render/contributions/render/rect-render.ts +++ b/packages/vrender-core/src/render/contributions/render/rect-render.ts @@ -64,9 +64,10 @@ export class DefaultCanvasRectRender extends BaseRender implements IGraph ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute - ) => boolean + ) => boolean, + rectAttribute?: Required ) { - const rectAttribute = this.tempTheme ?? getTheme(rect, params?.theme).rect; + rectAttribute = rectAttribute ?? getTheme(rect, params?.theme).rect; const { fill = rectAttribute.fill, background, @@ -142,35 +143,36 @@ export class DefaultCanvasRectRender extends BaseRender implements IGraph doFillOrStroke ); - const _runFill = () => { - if (doFillOrStroke.doFill) { - if (fillCb) { - fillCb(context, rect.attribute, rectAttribute); - } else if (fVisible) { - // 存在fill - context.setCommonStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); - context.fill(); - } - } - }; - const _runStroke = () => { - if (doFillOrStroke.doStroke) { - if (strokeCb) { - strokeCb(context, rect.attribute, rectAttribute); - } else if (sVisible) { - // 存在stroke - context.setStrokeStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); - context.stroke(); - } - } - }; + // 内联的函数性能差 + // const _runFill = () => { + // if (doFillOrStroke.doFill) { + // if (fillCb) { + // fillCb(context, rect.attribute, rectAttribute); + // } else if (fVisible) { + // // 存在fill + // context.setCommonStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); + // context.fill(); + // } + // } + // }; + // const _runStroke = () => { + // if (doFillOrStroke.doStroke) { + // if (strokeCb) { + // strokeCb(context, rect.attribute, rectAttribute); + // } else if (sVisible) { + // // 存在stroke + // context.setStrokeStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); + // context.stroke(); + // } + // } + // }; if (!fillStrokeOrder) { - _runFill(); - _runStroke(); + this._runFill(rect, context, x, y, rectAttribute, doFillOrStroke, fVisible, originX, originY, fillCb); + this._runStroke(rect, context, x, y, rectAttribute, doFillOrStroke, sVisible, originX, originY, strokeCb); } else { - _runStroke(); - _runFill(); + this._runStroke(rect, context, x, y, rectAttribute, doFillOrStroke, sVisible, originX, originY, strokeCb); + this._runFill(rect, context, x, y, rectAttribute, doFillOrStroke, fVisible, originX, originY, fillCb); } this.afterRenderStep( @@ -189,10 +191,62 @@ export class DefaultCanvasRectRender extends BaseRender implements IGraph ); } + private _runFill( + rect: IRect, + context: IContext2d, + x: number, + y: number, + rectAttribute: Required, + doFillOrStroke: { doFill: boolean; doStroke: boolean }, + fVisible: boolean, + originX: number, + originY: number, + fillCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doFillOrStroke.doFill) { + if (fillCb) { + fillCb(context, rect.attribute, rectAttribute); + } else if (fVisible) { + // 存在fill + context.setCommonStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); + context.fill(); + } + } + } + + private _runStroke( + rect: IRect, + context: IContext2d, + x: number, + y: number, + rectAttribute: Required, + doFillOrStroke: { doFill: boolean; doStroke: boolean }, + sVisible: boolean, + originX: number, + originY: number, + strokeCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doFillOrStroke.doStroke) { + if (strokeCb) { + strokeCb(context, rect.attribute, rectAttribute); + } else if (sVisible) { + // 存在stroke + context.setStrokeStyle(rect, rect.attribute, originX - x, originY - y, rectAttribute); + context.stroke(); + } + } + } + draw(rect: IRect, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { const rectAttribute = getTheme(rect, params?.theme).rect; - this.tempTheme = rectAttribute; - this._draw(rect, rectAttribute, false, drawContext, params); - this.tempTheme = null; + this._draw(rect, rectAttribute, false, drawContext, params, rectAttribute); } } diff --git a/packages/vrender-core/src/render/contributions/render/symbol-render.ts b/packages/vrender-core/src/render/contributions/render/symbol-render.ts index 8e6d96733..258de80cb 100644 --- a/packages/vrender-core/src/render/contributions/render/symbol-render.ts +++ b/packages/vrender-core/src/render/contributions/render/symbol-render.ts @@ -15,7 +15,8 @@ import type { IGraphicRender, IGraphicRenderDrawParams, IContributionProvider, - ICustomPath2D + ICustomPath2D, + ISymbolGraphicAttribute } from '../../../interface'; import type {} from '../../render-service'; import { BaseRender } from './base-render'; @@ -64,10 +65,11 @@ export class DefaultCanvasSymbolRender extends BaseRender implements IG ctx: IContext2d, markAttribute: Partial, themeAttribute: IThemeAttribute - ) => boolean + ) => boolean, + symbolAttribute?: Required ) { // const symbolAttribute = graphicService.themeService.getCurrentTheme().symbolAttribute; - const symbolAttribute = getTheme(symbol, params?.theme).symbol; + symbolAttribute = symbolAttribute ?? getTheme(symbol, params?.theme).symbol; const { size = symbolAttribute.size, @@ -180,40 +182,71 @@ export class DefaultCanvasSymbolRender extends BaseRender implements IG // } // svg就不用fill和stroke了 - const _runFill = () => { - if (doFill && !parsedPath.isSvg) { - if (fillCb) { - fillCb(context, symbol.attribute, symbolAttribute); - } else if (fVisible) { - context.setCommonStyle(symbol, symbol.attribute, originX - x, originY - y, symbolAttribute); - context.fill(); - } - } - }; - const _runStroke = () => { - if (doStroke && !parsedPath.isSvg) { - if (strokeCb) { - strokeCb(context, symbol.attribute, symbolAttribute); - } else if (sVisible && clipRange >= 1) { - // 如果clipRange < 1,就需要靠afterRender进行绘制了 - context.setStrokeStyle( - symbol, - symbol.attribute, - (originX - x) / scaleX, - (originY - y) / scaleY, - symbolAttribute - ); - context.stroke(); - } - } - }; + // 内联的函数性能差 + // const _runFill = () => { + // if (doFill && !parsedPath.isSvg) { + // if (fillCb) { + // fillCb(context, symbol.attribute, symbolAttribute); + // } else if (fVisible) { + // context.setCommonStyle(symbol, symbol.attribute, originX - x, originY - y, symbolAttribute); + // context.fill(); + // } + // } + // }; + // const _runStroke = () => { + // if (doStroke && !parsedPath.isSvg) { + // if (strokeCb) { + // strokeCb(context, symbol.attribute, symbolAttribute); + // } else if (sVisible && clipRange >= 1) { + // // 如果clipRange < 1,就需要靠afterRender进行绘制了 + // context.setStrokeStyle( + // symbol, + // symbol.attribute, + // (originX - x) / scaleX, + // (originY - y) / scaleY, + // symbolAttribute + // ); + // context.stroke(); + // } + // } + // }; if (!fillStrokeOrder) { - _runFill(); - _runStroke(); + this._runFill(symbol, context, x, y, symbolAttribute, doFill, fVisible, originX, originY, parsedPath, fillCb); + this._runStroke( + symbol, + context, + x, + y, + symbolAttribute, + doStroke, + sVisible, + originX, + originY, + parsedPath, + clipRange, + scaleX, + scaleY, + strokeCb + ); } else { - _runStroke(); - _runFill(); + this._runStroke( + symbol, + context, + x, + y, + symbolAttribute, + doStroke, + sVisible, + originX, + originY, + parsedPath, + clipRange, + scaleX, + scaleY, + strokeCb + ); + this._runFill(symbol, context, x, y, symbolAttribute, doFill, fVisible, originX, originY, parsedPath, fillCb); } this.afterRenderStep( @@ -232,6 +265,70 @@ export class DefaultCanvasSymbolRender extends BaseRender implements IG ); } + private _runFill( + symbol: ISymbol, + context: IContext2d, + x: number, + y: number, + symbolAttribute: Required, + doFill: boolean, + fVisible: boolean, + originX: number, + originY: number, + parsedPath: any, + fillCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doFill && !parsedPath.isSvg) { + if (fillCb) { + fillCb(context, symbol.attribute, symbolAttribute); + } else if (fVisible) { + context.setCommonStyle(symbol, symbol.attribute, originX - x, originY - y, symbolAttribute); + context.fill(); + } + } + } + + private _runStroke( + symbol: ISymbol, + context: IContext2d, + x: number, + y: number, + symbolAttribute: Required, + doStroke: boolean, + sVisible: boolean, + originX: number, + originY: number, + parsedPath: any, + clipRange: number, + scaleX: number, + scaleY: number, + strokeCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + if (doStroke && !parsedPath.isSvg) { + if (strokeCb) { + strokeCb(context, symbol.attribute, symbolAttribute); + } else if (sVisible && clipRange >= 1) { + // 如果clipRange < 1,就需要靠afterRender进行绘制了 + context.setStrokeStyle( + symbol, + symbol.attribute, + (originX - x) / scaleX, + (originY - y) / scaleY, + symbolAttribute + ); + context.stroke(); + } + } + } + draw(symbol: ISymbol, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { const symbolAttribute = getTheme(symbol, params?.theme).symbol; this._draw(symbol, symbolAttribute, false, drawContext, params); diff --git a/packages/vrender-core/src/render/render-service.ts b/packages/vrender-core/src/render/render-service.ts index fddf41590..a15dd3e40 100644 --- a/packages/vrender-core/src/render/render-service.ts +++ b/packages/vrender-core/src/render/render-service.ts @@ -20,7 +20,7 @@ export class DefaultRenderService implements IRenderService { constructor( @inject(DrawContribution) - private readonly drawContribution: IDrawContribution + public readonly drawContribution: IDrawContribution ) {} // 渲染前准备工作,计算bounds等逻辑 diff --git a/packages/vrender-kits/package.json b/packages/vrender-kits/package.json index 2e9637019..b68c959a2 100644 --- a/packages/vrender-kits/package.json +++ b/packages/vrender-kits/package.json @@ -20,7 +20,7 @@ "test": "" }, "dependencies": { - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "@visactor/vrender-core": "workspace:0.22.11", "@resvg/resvg-js": "2.4.1", "roughjs": "4.5.2", diff --git a/packages/vrender-kits/src/canvas/contributions/browser/context.ts b/packages/vrender-kits/src/canvas/contributions/browser/context.ts index 3e5fef416..acfc8be43 100644 --- a/packages/vrender-kits/src/canvas/contributions/browser/context.ts +++ b/packages/vrender-kits/src/canvas/contributions/browser/context.ts @@ -125,6 +125,8 @@ export class BrowserContext2d implements IContext2d { declare fontFamily: string; declare fontSize: number; declare _clearMatrix: IMatrix; + declare _font?: string; + // 属性代理 set fillStyle(d: string | CanvasGradient | CanvasPattern) { this.nativeContext.fillStyle = d; @@ -133,10 +135,14 @@ export class BrowserContext2d implements IContext2d { return this.nativeContext.fillStyle; } set font(d: string) { + if (d === this._font) { + return; + } + this._font = d; this.nativeContext.font = d; } get font(): string { - return this.nativeContext.font; + return this._font ?? this.nativeContext.font; } set globalAlpha(d: number) { this.nativeContext.globalAlpha = d * this.baseGlobalAlpha; @@ -251,14 +257,14 @@ export class BrowserContext2d implements IContext2d { this.baseGlobalAlpha = 1; } - reset() { + reset(setTransform: boolean = true) { if (this.stack.length) { Logger.getInstance().warn('可能存在bug,matrix没有清空'); } this.matrix.setValue(1, 0, 0, 1, 0, 0); this.applyedMatrix = new Matrix(1, 0, 0, 1, 0, 0); this.stack.length = 0; - this.nativeContext.setTransform(1, 0, 0, 1, 0, 0); + setTransform && this.nativeContext.setTransform(1, 0, 0, 1, 0, 0); } getCanvas() { @@ -325,6 +331,9 @@ export class BrowserContext2d implements IContext2d { this.matrix = this.stack.pop() as Matrix; this.setTransformForCurrent(true); } + this.font = ''; + this._clearFilterStyle = false; + this._clearShadowStyle = false; } highPerformanceRestore() { if (this.stack.length > 0) { @@ -1053,17 +1062,21 @@ export class BrowserContext2d implements IContext2d { const { opacity = defaultParams.opacity, shadowBlur = defaultParams.shadowBlur, - shadowColor = defaultParams.shadowColor, shadowOffsetX = defaultParams.shadowOffsetX, shadowOffsetY = defaultParams.shadowOffsetY, blur = defaultParams.blur, - filter = defaultParams.filter, globalCompositeOperation = defaultParams.globalCompositeOperation } = attribute; if (opacity <= 1e-12) { return; } - if (shadowBlur || shadowOffsetX || shadowOffsetY) { + if (shadowOffsetX || shadowOffsetY || shadowBlur) { + const { + shadowColor = defaultParams.shadowColor, + shadowOffsetX = defaultParams.shadowOffsetX, + shadowOffsetY = defaultParams.shadowOffsetY + } = attribute; + // canvas的shadow不支持dpr,这里手动设置 _context.shadowBlur = shadowBlur * this.dpr; _context.shadowColor = shadowColor; @@ -1080,9 +1093,6 @@ export class BrowserContext2d implements IContext2d { if (blur) { _context.filter = `blur(${blur}px)`; this._clearFilterStyle = true; - } else if (filter) { - _context.filter = filter; - this._clearFilterStyle = true; } else if (this._clearFilterStyle) { _context.filter = 'blur(0px)'; this._clearFilterStyle = false; @@ -1167,13 +1177,9 @@ export class BrowserContext2d implements IContext2d { } const { scaleIn3d = defaultParams.scaleIn3d } = params; if (params.font) { - _context.font = params.font; + this.font = params.font; } else { - _context.font = getContextFont( - params, - defaultParams, - scaleIn3d && this.camera && this.camera.getProjectionScale(z) - ); + this.font = getContextFont(params, defaultParams, scaleIn3d && this.camera && this.camera.getProjectionScale(z)); } const { fontFamily = defaultParams.fontFamily, fontSize = defaultParams.fontSize } = params; this.fontFamily = fontFamily; @@ -1190,9 +1196,9 @@ export class BrowserContext2d implements IContext2d { defaultParams = this.textAttributes; } if (params.font) { - _context.font = params.font; + this.font = params.font; } else { - _context.font = getContextFont(params, defaultParams, this.camera && this.camera.getProjectionScale(z)); + this.font = getContextFont(params, defaultParams, this.camera && this.camera.getProjectionScale(z)); } const { fontFamily = defaultParams.fontFamily, fontSize = defaultParams.fontSize } = params; this.fontFamily = fontFamily; diff --git a/packages/vrender-kits/src/render/contributions/rough/base-render.ts b/packages/vrender-kits/src/render/contributions/rough/base-render.ts index 5636248ab..31fa1084f 100644 --- a/packages/vrender-kits/src/render/contributions/rough/base-render.ts +++ b/packages/vrender-kits/src/render/contributions/rough/base-render.ts @@ -1,13 +1,18 @@ -import type { - IGraphicAttribute, - IContext2d, - IGraphic, - IMarkAttribute, - IThemeAttribute, - IDrawContext, - IGraphicRenderDrawParams, - IGraphicRender +import type { IRenderService } from '@visactor/vrender-core'; +import { + type IGraphicAttribute, + type IContext2d, + type IGraphic, + type IMarkAttribute, + type IThemeAttribute, + type IDrawContext, + type IGraphicRenderDrawParams, + type IGraphicRender, + CustomPath2D } from '@visactor/vrender-core'; +import rough from 'roughjs'; +import { RoughContext2d } from './context'; +import { defaultRouthThemeSpec } from './config'; export abstract class RoughBaseRender { canvasRenderer!: IGraphicRender; @@ -34,6 +39,111 @@ export abstract class RoughBaseRender { } } + doDraw( + graphic: IGraphic, + renderService: IRenderService, + drawContext: IDrawContext, + params?: IGraphicRenderDrawParams + ) { + const { context } = drawContext; + if (!context) { + return; + } + // 获取到原生canvas + const canvas = context.canvas.nativeCanvas; + const rc = rough.canvas(canvas); + + // context.highPerformanceSave(); + + const customPath = new CustomPath2D(); + const roughContext = new RoughContext2d(context, customPath); + + context.save(); + // 不管怎么样,都transform + context.transformFromMatrix(graphic.transMatrix, true); + + const { fill, stroke, roughStyle = {}, lineWidth } = graphic.attribute as any; + + const { + maxRandomnessOffset = defaultRouthThemeSpec.maxRandomnessOffset, + roughness = defaultRouthThemeSpec.roughness, + bowing = defaultRouthThemeSpec.bowing, + curveFitting = defaultRouthThemeSpec.curveFitting, + curveTightness = defaultRouthThemeSpec.curveTightness, + curveStepCount = defaultRouthThemeSpec.curveStepCount, + fillStyle = defaultRouthThemeSpec.fillStyle, + fillWeight = defaultRouthThemeSpec.fillWeight, + hachureAngle = defaultRouthThemeSpec.hachureAngle, + hachureGap = defaultRouthThemeSpec.hachureGap, + simplification = defaultRouthThemeSpec.simplification, + dashOffset = defaultRouthThemeSpec.dashOffset, + dashGap = defaultRouthThemeSpec.dashGap, + zigzagOffset = defaultRouthThemeSpec.zigzagOffset, + seed = defaultRouthThemeSpec.seed, + fillLineDash = defaultRouthThemeSpec.fillLineDash, + fillLineDashOffset = defaultRouthThemeSpec.fillLineDashOffset, + disableMultiStroke = defaultRouthThemeSpec.disableMultiStroke, + disableMultiStrokeFill = defaultRouthThemeSpec.disableMultiStrokeFill, + preserveVertices = defaultRouthThemeSpec.preserveVertices, + fixedDecimalPlaceDigits = defaultRouthThemeSpec.fixedDecimalPlaceDigits + } = roughStyle; + + let rendered = false; + const doRender = () => { + if (rendered) { + return; + } + rendered = true; + const path = customPath.toString(); + context.beginPath(); + rc.path(path, { + fill, + stroke, + strokeWidth: lineWidth, + maxRandomnessOffset, + roughness, + bowing, + curveFitting, + curveTightness, + curveStepCount, + fillStyle, + fillWeight, + hachureAngle, + hachureGap, + simplification, + dashOffset, + dashGap, + zigzagOffset, + seed, + fillLineDash, + fillLineDashOffset, + disableMultiStroke, + disableMultiStrokeFill, + preserveVertices, + fixedDecimalPlaceDigits + }); + }; + this.canvasRenderer.drawShape( + graphic, + roughContext, + 0, + 0, + drawContext, + params, + () => { + doRender(); + return false; + }, + () => { + doRender(); + return false; + } + ); + + context.restore(); + + // context.highPerformanceRestore(); + } reInit() { this.canvasRenderer?.reInit(); } diff --git a/packages/vrender-kits/src/render/contributions/rough/config.ts b/packages/vrender-kits/src/render/contributions/rough/config.ts index 0a817da27..d1a50ebe6 100644 --- a/packages/vrender-kits/src/render/contributions/rough/config.ts +++ b/packages/vrender-kits/src/render/contributions/rough/config.ts @@ -3,7 +3,7 @@ import type { Options } from 'roughjs/bin/core'; export const defaultRouthThemeSpec: Options = { maxRandomnessOffset: 3, // 粗糙度,值越大绘制的越乱 - roughness: 1, + roughness: 1.6, // 线段的弯曲度 bowing: 1, // 曲线拟合程度 @@ -12,7 +12,7 @@ export const defaultRouthThemeSpec: Options = { // 近似曲线的点数 curveStepCount: 9, // 填充形式,默认斜线 - fillStyle: 'hachure', + fillStyle: 'cross-hatch', // 填充线的粗细、填充点的大小 fillWeight: undefined, // 填充为hachure时的转角 @@ -25,12 +25,12 @@ export const defaultRouthThemeSpec: Options = { dashGap: undefined, zigzagOffset: undefined, // 生成随机形状的种子 - seed: 1, + seed: 3, fillLineDash: undefined, fillLineDashOffset: undefined, // 禁止用多个笔画绘制 disableMultiStroke: false, - disableMultiStrokeFill: false, + disableMultiStrokeFill: true, preserveVertices: true, fixedDecimalPlaceDigits: undefined }; diff --git a/packages/vrender-kits/src/render/contributions/rough/context.ts b/packages/vrender-kits/src/render/contributions/rough/context.ts new file mode 100644 index 000000000..680e21276 --- /dev/null +++ b/packages/vrender-kits/src/render/contributions/rough/context.ts @@ -0,0 +1,583 @@ +/** + * 部分源码参考konva + * MIT License + + Original work Copyright (C) 2011 - 2013 by Eric Rowell (KineticJS) + Modified work Copyright (C) 2014 - present by Anton Lavrenov (Konva) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + */ +import type { IPointLike, TextMeasure, ITextMeasureSpec, IMatrix, Matrix } from '@visactor/vutils'; +import type { + ICamera, + ICanvas, + ICommonStyleParams, + IConicalGradientData, + IContext2d, + ISetCommonStyleParams, + ISetStrokeStyleParams, + IStrokeStyleParams, + ITextStyleParams, + mat4, + EnvType, + vec3, + CustomPath2D +} from '@visactor/vrender-core'; + +/** + * RoughContext2d serves as a proxy to the original context (BrowserContext2d) + * while also updating a custom path for path-related operations + */ +export class RoughContext2d implements IContext2d { + static env: EnvType = 'browser'; + originContext: IContext2d; + customPath: CustomPath2D; + + constructor(originContext: IContext2d, customPath: CustomPath2D) { + this.originContext = originContext; + this.customPath = customPath; + } + + reset(setTransform: boolean = true) { + return this.originContext.reset(setTransform); + } + + // Path-related methods that affect both the original context and the custom path + beginPath(): void { + this.originContext.beginPath(); + this.customPath.beginPath(); + } + + moveTo(x: number, y: number, z?: number): void { + this.originContext.moveTo(x, y, z); + this.customPath.moveTo(x, y); + } + + lineTo(x: number, y: number, z?: number): void { + this.originContext.lineTo(x, y, z); + this.customPath.lineTo(x, y); + } + + bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number, z?: number): void { + (this.originContext.bezierCurveTo as any)(cp1x, cp1y, cp2x, cp2y, x, y, z); + this.customPath.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); + } + + quadraticCurveTo(cpx: number, cpy: number, x: number, y: number, z?: number): void { + this.originContext.quadraticCurveTo(cpx, cpy, x, y, z); + this.customPath.quadraticCurveTo(cpx, cpy, x, y); + } + + arc( + x: number, + y: number, + radius: number, + startAngle: number, + endAngle: number, + anticlockwise?: boolean, + z?: number + ): void { + this.originContext.arc(x, y, radius, startAngle, endAngle, anticlockwise, z); + this.customPath.arc(x, y, radius, startAngle, endAngle, anticlockwise); + } + + arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void { + this.originContext.arcTo(x1, y1, x2, y2, radius); + this.customPath.arcTo(x1, y1, x2, y2, radius); + } + + ellipse( + x: number, + y: number, + radiusX: number, + radiusY: number, + rotation: number, + startAngle: number, + endAngle: number, + anticlockwise?: boolean + ): void { + this.originContext.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise); + this.customPath.ellipse(x, y, radiusX, radiusY, rotation, startAngle, endAngle, anticlockwise); + } + + rect(x: number, y: number, w: number, h: number, z?: number): void { + this.originContext.rect(x, y, w, h, z); + this.customPath.rect(x, y, w, h); + } + + closePath(): void { + this.originContext.closePath(); + this.customPath.closePath(); + } + + // Property forwarding using getters and setters + // Define getters and setters for all properties to forward to originContext + // Canvas + get canvas(): ICanvas { + return this.originContext.canvas; + } + set canvas(value: ICanvas) { + this.originContext.canvas = value; + } + + // Camera + get camera(): ICamera | undefined { + return this.originContext.camera; + } + set camera(value: ICamera | undefined) { + this.originContext.camera = value; + } + + // ModelMatrix + get modelMatrix(): mat4 | undefined { + return this.originContext.modelMatrix; + } + set modelMatrix(value: mat4 | undefined) { + this.originContext.modelMatrix = value; + } + + // NativeContext + get nativeContext(): CanvasRenderingContext2D | any { + return this.originContext.nativeContext; + } + set nativeContext(value: CanvasRenderingContext2D | any) { + this.originContext.nativeContext = value; + } + + // Inuse + get _inuse(): boolean { + return this.originContext._inuse; + } + set _inuse(value: boolean) { + this.originContext._inuse = value; + } + + get inuse(): boolean { + return this.originContext.inuse; + } + set inuse(value: boolean) { + this.originContext.inuse = value; + } + + // Stack + get stack(): Matrix[] { + return this.originContext.stack; + } + set stack(value: Matrix[]) { + this.originContext.stack = value; + } + + // Disable flags + get disableFill(): boolean | undefined { + return this.originContext.disableFill; + } + set disableFill(value: boolean | undefined) { + this.originContext.disableFill = value; + } + + get disableStroke(): boolean | undefined { + return this.originContext.disableStroke; + } + set disableStroke(value: boolean | undefined) { + this.originContext.disableStroke = value; + } + + get disableBeginPath(): boolean | undefined { + return this.originContext.disableBeginPath; + } + set disableBeginPath(value: boolean | undefined) { + this.originContext.disableBeginPath = value; + } + + // Font properties + get fontFamily(): string { + return this.originContext.fontFamily; + } + set fontFamily(value: string) { + this.originContext.fontFamily = value; + } + + get fontSize(): number { + return this.originContext.fontSize; + } + set fontSize(value: number) { + this.originContext.fontSize = value; + } + + // Matrix + get _clearMatrix(): IMatrix { + return this.originContext._clearMatrix; + } + set _clearMatrix(value: IMatrix) { + this.originContext._clearMatrix = value; + } + + // DPR + get dpr(): number { + return this.originContext.dpr; + } + set dpr(value: number) { + this.originContext.dpr = value; + } + + // Other properties + get baseGlobalAlpha(): number { + return this.originContext.baseGlobalAlpha; + } + set baseGlobalAlpha(value: number) { + this.originContext.baseGlobalAlpha = value; + } + + get drawPromise(): Promise | undefined { + return this.originContext.drawPromise; + } + set drawPromise(value: Promise | undefined) { + this.originContext.drawPromise = value; + } + + get mathTextMeasure(): TextMeasure { + return this.originContext.mathTextMeasure; + } + set mathTextMeasure(value: TextMeasure) { + this.originContext.mathTextMeasure = value; + } + + // Canvas context style properties + get fillStyle(): string | CanvasGradient | CanvasPattern { + return this.originContext.fillStyle; + } + set fillStyle(value: string | CanvasGradient | CanvasPattern) { + this.originContext.fillStyle = value; + } + + get font(): string { + return this.originContext.font; + } + set font(value: string) { + this.originContext.font = value; + } + + get globalAlpha(): number { + return this.originContext.globalAlpha; + } + set globalAlpha(value: number) { + this.originContext.globalAlpha = value; + } + + get lineCap(): CanvasLineCap { + return this.originContext.lineCap as any; + } + set lineCap(value: CanvasLineCap) { + this.originContext.lineCap = value; + } + + get lineDashOffset(): number { + return this.originContext.lineDashOffset; + } + set lineDashOffset(value: number) { + this.originContext.lineDashOffset = value; + } + + get lineJoin(): CanvasLineJoin { + return this.originContext.lineJoin as any; + } + set lineJoin(value: CanvasLineJoin) { + this.originContext.lineJoin = value; + } + + get lineWidth(): number { + return this.originContext.lineWidth; + } + set lineWidth(value: number) { + this.originContext.lineWidth = value; + } + + get miterLimit(): number { + return this.originContext.miterLimit; + } + set miterLimit(value: number) { + this.originContext.miterLimit = value; + } + + get shadowBlur(): number { + return this.originContext.shadowBlur; + } + set shadowBlur(value: number) { + this.originContext.shadowBlur = value; + } + + get shadowColor(): string { + return this.originContext.shadowColor; + } + set shadowColor(value: string) { + this.originContext.shadowColor = value; + } + + get shadowOffsetX(): number { + return this.originContext.shadowOffsetX; + } + set shadowOffsetX(value: number) { + this.originContext.shadowOffsetX = value; + } + + get shadowOffsetY(): number { + return this.originContext.shadowOffsetY; + } + set shadowOffsetY(value: number) { + this.originContext.shadowOffsetY = value; + } + + get strokeStyle(): string | CanvasGradient | CanvasPattern { + return this.originContext.strokeStyle; + } + set strokeStyle(value: string | CanvasGradient | CanvasPattern) { + this.originContext.strokeStyle = value; + } + + get textAlign(): CanvasTextAlign { + return this.originContext.textAlign as any; + } + set textAlign(value: CanvasTextAlign) { + this.originContext.textAlign = value as any; + } + + get textBaseline(): CanvasTextBaseline { + return this.originContext.textBaseline as any; + } + set textBaseline(value: CanvasTextBaseline) { + this.originContext.textBaseline = value; + } + + // Matrix-related getter + get currentMatrix() { + return this.originContext.currentMatrix; + } + + // Forward all other methods to originContext + // Transform methods + save(): void { + return this.originContext.save(); + } + restore(): void { + return this.originContext.restore(); + } + highPerformanceSave(): void { + return this.originContext.highPerformanceSave(); + } + highPerformanceRestore(): void { + return this.originContext.highPerformanceRestore(); + } + rotate(rad: number, setTransform?: boolean): void { + return this.originContext.rotate(rad, setTransform); + } + scale(sx: number, sy: number, setTransform?: boolean): void { + return this.originContext.scale(sx, sy, setTransform); + } + setScale(sx: number, sy: number, setTransform?: boolean): void { + return this.originContext.setScale(sx, sy, setTransform); + } + scalePoint(sx: number, sy: number, px: number, py: number, setTransform?: boolean): void { + return this.originContext.scalePoint(sx, sy, px, py, setTransform); + } + setTransform( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + setTransform?: boolean, + dpr?: number + ): void { + return this.originContext.setTransform(a, b, c, d, e, f, setTransform, dpr); + } + setTransformFromMatrix(matrix: Matrix, setTransform?: boolean, dpr?: number): void { + return this.originContext.setTransformFromMatrix(matrix, setTransform, dpr); + } + resetTransform(setTransform?: boolean, dpr?: number): void { + return this.originContext.resetTransform(setTransform, dpr); + } + transform(a: number, b: number, c: number, d: number, e: number, f: number, setTransform?: boolean): void { + return this.originContext.transform(a, b, c, d, e, f, setTransform); + } + transformFromMatrix(matrix: Matrix, setTransform?: boolean): void { + return this.originContext.transformFromMatrix(matrix, setTransform); + } + translate(x: number, y: number, setTransform?: boolean): void { + return this.originContext.translate(x, y, setTransform); + } + rotateDegrees(deg: number, setTransform?: boolean): void { + return this.originContext.rotateDegrees(deg, setTransform); + } + rotateAbout(rad: number, x: number, y: number, setTransform?: boolean): void { + return this.originContext.rotateAbout(rad, x, y, setTransform); + } + rotateDegreesAbout(deg: number, x: number, y: number, setTransform?: boolean): void { + return this.originContext.rotateDegreesAbout(deg, x, y, setTransform); + } + + setTransformForCurrent(force: boolean = false) { + return this.originContext.setTransformForCurrent(force); + } + + // Drawing methods + clip(fillRule?: CanvasFillRule): void; + clip(path: Path2D, fillRule?: CanvasFillRule): void; + clip(path?: Path2D | CanvasFillRule, fillRule?: CanvasFillRule): void { + return (this.originContext.clip as any)(path, fillRule); + } + fill(path?: Path2D, fillRule?: CanvasFillRule): void { + return this.originContext.fill(path, fillRule); + } + stroke(path?: Path2D): void { + return this.originContext.stroke(path); + } + fillRect(x: number, y: number, width: number, height: number): void { + return this.originContext.fillRect(x, y, width, height); + } + strokeRect(x: number, y: number, width: number, height: number): void { + return this.originContext.strokeRect(x, y, width, height); + } + fillText(text: string, x: number, y: number, z?: number): void { + return this.originContext.fillText(text, x, y, z); + } + strokeText(text: string, x: number, y: number, z?: number): void { + return this.originContext.strokeText(text, x, y, z); + } + clearRect(x: number, y: number, w: number, h: number): void { + return this.originContext.clearRect(x, y, w, h); + } + + // Image methods + drawImage(...args: any[]): void { + return (this.originContext.drawImage as any)(...args); + } + createImageData(...args: any[]): ImageData { + return (this.originContext.createImageData as any)(...args); + } + getImageData(sx: number, sy: number, sw: number, sh: number): ImageData { + return this.originContext.getImageData(sx, sy, sw, sh); + } + putImageData(imagedata: ImageData, dx: number, dy: number): void { + return this.originContext.putImageData(imagedata, dx, dy); + } + + // Gradient/Pattern methods + createLinearGradient(x0: number, y0: number, x1: number, y1: number): CanvasGradient { + return this.originContext.createLinearGradient(x0, y0, x1, y1); + } + createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number): CanvasGradient { + return this.originContext.createRadialGradient(x0, y0, r0, x1, y1, r1); + } + createConicGradient(x: number, y: number, startAngle: number, endAngle: number): IConicalGradientData { + return this.originContext.createConicGradient(x, y, startAngle, endAngle); + } + createPattern(image: HTMLImageElement | HTMLCanvasElement | HTMLVideoElement, repetition: string): CanvasPattern { + return this.originContext.createPattern(image, repetition); + } + + // Line style methods + getLineDash(): number[] { + return this.originContext.getLineDash(); + } + setLineDash(segments: number[]): void { + return this.originContext.setLineDash(segments); + } + + // Utility methods + clear(): void { + return this.originContext.clear(); + } + measureText(text: string, method?: 'native' | 'simple' | 'quick'): { width: number } { + return this.originContext.measureText(text, method); + } + isPointInPath(x: number, y: number): boolean { + return this.originContext.isPointInPath(x, y); + } + isPointInStroke(x: number, y: number): boolean { + return this.originContext.isPointInStroke(x, y); + } + project(x: number, y: number, z?: number): IPointLike { + return this.originContext.project(x, y, z); + } + view(x: number, y: number, z?: number): vec3 { + return this.originContext.view(x, y, z); + } + + // Helper methods + getCanvas(): ICanvas { + return this.originContext.getCanvas(); + } + getContext(): CanvasRenderingContext2D | any { + return this.originContext.getContext(); + } + setCommonStyle( + params: ISetCommonStyleParams, + attribute: ICommonStyleParams, + offsetX: number, + offsetY: number, + defaultParams?: ICommonStyleParams | Partial[] + ): void { + return this.originContext.setCommonStyle(params, attribute, offsetX, offsetY, defaultParams); + } + setShadowBlendStyle( + params: ISetCommonStyleParams, + attribute: ICommonStyleParams, + defaultParams?: ICommonStyleParams | Partial[] + ): void { + return this.originContext.setShadowBlendStyle(params, attribute, defaultParams); + } + setStrokeStyle( + params: ISetStrokeStyleParams, + attribute: IStrokeStyleParams, + offsetX: number, + offsetY: number, + defaultParams?: IStrokeStyleParams | any + ): void { + return this.originContext.setStrokeStyle(params, attribute, offsetX, offsetY, defaultParams); + } + setTextStyle(params: Partial, defaultParams?: ITextStyleParams, z?: number): void { + return this.originContext.setTextStyle(params, defaultParams, z); + } + setTextStyleWithoutAlignBaseline( + params: Partial, + defaultParams?: ITextStyleParams, + z?: number + ): void { + return this.originContext.setTextStyleWithoutAlignBaseline(params, defaultParams, z); + } + clearMatrix(setTransform?: boolean, dpr?: number): void { + return this.originContext.clearMatrix(setTransform, dpr); + } + setClearMatrix(a: number, b: number, c: number, d: number, e: number, f: number): void { + return this.originContext.setClearMatrix(a, b, c, d, e, f); + } + onlyTranslate(dpr?: number): boolean { + return this.originContext.onlyTranslate(dpr); + } + cloneMatrix(m: Matrix): Matrix { + return this.originContext.cloneMatrix(m); + } + draw(): void { + return this.originContext.draw(); + } + release(...params: any): void { + return this.originContext.release(...params); + } +} diff --git a/packages/vrender-kits/src/render/contributions/rough/rough-line.ts b/packages/vrender-kits/src/render/contributions/rough/rough-line.ts index eaf933ce8..02fe6ea48 100644 --- a/packages/vrender-kits/src/render/contributions/rough/rough-line.ts +++ b/packages/vrender-kits/src/render/contributions/rough/rough-line.ts @@ -7,163 +7,30 @@ import type { ISegPath2D, ILine, ILineGraphicAttribute, - IClipRangeByDimensionType + IClipRangeByDimensionType, + IDrawContext, + IGraphicRenderDrawParams, + IRenderService } from '@visactor/vrender-core'; -import { - IRenderService, - IGraphic, - DefaultCanvasLineRender, - getTheme, - CustomPath2D, - drawSegments, - injectable -} from '@visactor/vrender-core'; -import rough from 'roughjs'; -import { defaultRouthThemeSpec } from './config'; +import { DefaultCanvasLineRender, injectable, inject, LINE_NUMBER_TYPE } from '@visactor/vrender-core'; +import { RoughBaseRender } from './base-render'; @injectable() -export class RoughCanvasLineRender extends DefaultCanvasLineRender implements IGraphicRender { +export class RoughCanvasLineRender extends RoughBaseRender implements IGraphicRender { declare type: 'line'; declare numberType: number; style: 'rough' = 'rough'; - /** - * 绘制segment - * @param context - * @param cache - * @param fill - * @param stroke - * @param attribute - * @param defaultAttribute - * @param clipRange - * @param offsetX - * @param offsetY - * @param fillCb - * @param strokeCb - * @returns 返回true代表跳过后续绘制 - */ - protected drawSegmentItem( - context: IContext2d, - cache: ISegPath2D, - fill: boolean, - stroke: boolean, - fillOpacity: number, - strokeOpacity: number, - attribute: Partial, - defaultAttribute: Required | Partial[], - clipRange: number, - clipRangeByDimension: IClipRangeByDimensionType, - offsetX: number, - offsetY: number, - line: ILine, - fillCb?: ( - ctx: IContext2d, - lineAttribute: Partial, - themeAttribute: IThemeAttribute | IThemeAttribute[] - ) => boolean, - strokeCb?: ( - ctx: IContext2d, - lineAttribute: Partial, - themeAttribute: IThemeAttribute | IThemeAttribute[] - ) => boolean - ): boolean { - if (fillCb || strokeCb) { - return super.drawSegmentItem( - context, - cache, - fill, - stroke, - fillOpacity, - strokeOpacity, - attribute, - defaultAttribute, - clipRange, - clipRangeByDimension, - offsetX, - offsetY, - line, - fillCb, - strokeCb - ); - } - context.highPerformanceSave(); - // 获取到原生canvas - const canvas = context.canvas.nativeCanvas; - const rc = rough.canvas(canvas, {}); - - const customPath = new CustomPath2D(); - - drawSegments(context.camera ? context : context.nativeContext, cache, clipRange, clipRangeByDimension, { - offsetX, - offsetY - }); - const { - maxRandomnessOffset = defaultRouthThemeSpec.maxRandomnessOffset, - roughness = defaultRouthThemeSpec.roughness, - bowing = defaultRouthThemeSpec.bowing, - curveFitting = defaultRouthThemeSpec.curveFitting, - curveTightness = defaultRouthThemeSpec.curveTightness, - curveStepCount = defaultRouthThemeSpec.curveStepCount, - fillStyle = defaultRouthThemeSpec.fillStyle, - fillWeight = defaultRouthThemeSpec.fillWeight, - hachureAngle = defaultRouthThemeSpec.hachureAngle, - hachureGap = defaultRouthThemeSpec.hachureGap, - simplification = defaultRouthThemeSpec.simplification, - dashOffset = defaultRouthThemeSpec.dashOffset, - dashGap = defaultRouthThemeSpec.dashGap, - zigzagOffset = defaultRouthThemeSpec.zigzagOffset, - seed = defaultRouthThemeSpec.seed, - fillLineDash = defaultRouthThemeSpec.fillLineDash, - fillLineDashOffset = defaultRouthThemeSpec.fillLineDashOffset, - disableMultiStroke = defaultRouthThemeSpec.disableMultiStroke, - disableMultiStrokeFill = defaultRouthThemeSpec.disableMultiStrokeFill, - preserveVertices = defaultRouthThemeSpec.preserveVertices, - fixedDecimalPlaceDigits = defaultRouthThemeSpec.fixedDecimalPlaceDigits - } = attribute as any; - - let { fill: fillColor, stroke: strokeColor, lineWidth } = attribute; - - if (Array.isArray(defaultAttribute)) { - defaultAttribute.forEach(item => { - fillColor = fillColor ?? item.fill; - strokeColor = strokeColor ?? item.stroke; - lineWidth = lineWidth ?? item.lineWidth; - }); - } else { - fillColor = fillColor ?? defaultAttribute.fill; - strokeColor = strokeColor ?? defaultAttribute.stroke; - lineWidth = lineWidth ?? defaultAttribute.lineWidth; - } - - rc.path(customPath.toString(), { - fill: fill ? (fillColor as string) : undefined, - stroke: stroke ? (strokeColor as string) : undefined, - strokeWidth: lineWidth, - maxRandomnessOffset, - roughness, - bowing, - curveFitting, - curveTightness, - curveStepCount, - fillStyle, - fillWeight, - hachureAngle, - hachureGap, - simplification, - dashOffset, - dashGap, - zigzagOffset, - seed, - fillLineDash, - fillLineDashOffset, - disableMultiStroke, - disableMultiStrokeFill, - preserveVertices, - fixedDecimalPlaceDigits - }); - - context.highPerformanceRestore(); + constructor( + @inject(DefaultCanvasLineRender) + public readonly canvasRenderer: IGraphicRender + ) { + super(); + this.type = 'line'; + this.numberType = LINE_NUMBER_TYPE; + } - return false; + draw(line: ILine, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { + this.doDraw(line, renderService, drawContext, params); } } diff --git a/packages/vrender-kits/src/render/contributions/rough/rough-rect.ts b/packages/vrender-kits/src/render/contributions/rough/rough-rect.ts index 60b5397f8..a6c76cf1a 100644 --- a/packages/vrender-kits/src/render/contributions/rough/rough-rect.ts +++ b/packages/vrender-kits/src/render/contributions/rough/rough-rect.ts @@ -1,14 +1,20 @@ -import type { - IGraphicRender, - IRenderService, - IRect, - IDrawContext, - IGraphicRenderDrawParams +import { + type IGraphicRender, + type IRenderService, + type IRect, + type IDrawContext, + type IGraphicRenderDrawParams, + RECT_NUMBER_TYPE, + DefaultCanvasRectRender, + inject, + injectable, + CustomPath2D } from '@visactor/vrender-core'; -import { RECT_NUMBER_TYPE, DefaultCanvasRectRender, getTheme, inject, injectable } from '@visactor/vrender-core'; + import rough from 'roughjs'; import { defaultRouthThemeSpec } from './config'; import { RoughBaseRender } from './base-render'; +import { RoughContext2d } from './context'; @injectable() export class RoughCanvasRectRender extends RoughBaseRender implements IGraphicRender { @@ -26,95 +32,6 @@ export class RoughCanvasRectRender extends RoughBaseRender implements IGraphicRe } draw(rect: IRect, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { - const { context } = drawContext; - if (!context) { - return; - } - // 获取到原生canvas - const canvas = context.canvas.nativeCanvas; - const rc = rough.canvas(canvas); - - context.highPerformanceSave(); - - // const rectAttribute = graphicService.themeService.getCurrentTheme().rectAttribute; - const rectAttribute = rect.getGraphicTheme(); - let { x = rectAttribute.x, y = rectAttribute.y } = rect.attribute; - if (!rect.transMatrix.onlyTranslate()) { - // 性能较差 - x = 0; - y = 0; - context.transformFromMatrix(rect.transMatrix, true); - } else { - const { dx = rectAttribute.dx, dy = rectAttribute.dy } = rect.attribute; - x += dx; - y += dy; - // 当前context有rotate/scale,重置matrix - context.setTransformForCurrent(); - } - - const { - fill = rectAttribute.fill, - stroke = rectAttribute.stroke, - fillColor = rectAttribute.fill, - strokeColor = rectAttribute.stroke, - x1, - y1, - lineWidth = rectAttribute.lineWidth, - maxRandomnessOffset = defaultRouthThemeSpec.maxRandomnessOffset, - roughness = defaultRouthThemeSpec.roughness, - bowing = defaultRouthThemeSpec.bowing, - curveFitting = defaultRouthThemeSpec.curveFitting, - curveTightness = defaultRouthThemeSpec.curveTightness, - curveStepCount = defaultRouthThemeSpec.curveStepCount, - fillStyle = defaultRouthThemeSpec.fillStyle, - fillWeight = defaultRouthThemeSpec.fillWeight, - hachureAngle = defaultRouthThemeSpec.hachureAngle, - hachureGap = defaultRouthThemeSpec.hachureGap, - simplification = defaultRouthThemeSpec.simplification, - dashOffset = defaultRouthThemeSpec.dashOffset, - dashGap = defaultRouthThemeSpec.dashGap, - zigzagOffset = defaultRouthThemeSpec.zigzagOffset, - seed = defaultRouthThemeSpec.seed, - fillLineDash = defaultRouthThemeSpec.fillLineDash, - fillLineDashOffset = defaultRouthThemeSpec.fillLineDashOffset, - disableMultiStroke = defaultRouthThemeSpec.disableMultiStroke, - disableMultiStrokeFill = defaultRouthThemeSpec.disableMultiStrokeFill, - preserveVertices = defaultRouthThemeSpec.preserveVertices, - fixedDecimalPlaceDigits = defaultRouthThemeSpec.fixedDecimalPlaceDigits - } = rect.attribute as any; - - let { width = rectAttribute.width, height = rectAttribute.height } = rect.attribute; - - width = (width ?? x1 - x) || 0; - height = (height ?? y1 - y) || 0; - - rc.rectangle(x, y, width, height, { - fill: fill ? (fillColor as string) : undefined, - stroke: stroke ? (strokeColor as string) : undefined, - strokeWidth: lineWidth, - maxRandomnessOffset, - roughness, - bowing, - curveFitting, - curveTightness, - curveStepCount, - fillStyle, - fillWeight, - hachureAngle, - hachureGap, - simplification, - dashOffset, - dashGap, - zigzagOffset, - seed, - fillLineDash, - fillLineDashOffset, - disableMultiStroke, - disableMultiStrokeFill, - preserveVertices, - fixedDecimalPlaceDigits - }); - - context.highPerformanceRestore(); + this.doDraw(rect, renderService, drawContext, params); } } diff --git a/packages/vrender-kits/src/render/contributions/rough/rough-symbol.ts b/packages/vrender-kits/src/render/contributions/rough/rough-symbol.ts index 9950db09b..f338efe17 100644 --- a/packages/vrender-kits/src/render/contributions/rough/rough-symbol.ts +++ b/packages/vrender-kits/src/render/contributions/rough/rough-symbol.ts @@ -21,9 +21,11 @@ import { } from '@visactor/vrender-core'; import rough from 'roughjs'; import { defaultRouthThemeSpec } from './config'; +import { RoughContext2d } from './context'; +import { RoughBaseRender } from './base-render'; @injectable() -export class RoughCanvasSymbolRender extends BaseRender implements IGraphicRender { +export class RoughCanvasSymbolRender extends RoughBaseRender implements IGraphicRender { type: 'symbol'; numberType: number; style: 'rough'; @@ -39,116 +41,6 @@ export class RoughCanvasSymbolRender extends BaseRender implements IGra } draw(symbol: ISymbol, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { - const { context } = drawContext; - if (!context) { - return; - } - // 获取到原生canvas - const canvas = context.canvas.nativeCanvas; - const rc = rough.canvas(canvas); - - context.highPerformanceSave(); - const symbolAttribute = symbol.getGraphicTheme(); - const data = this.transform(symbol, symbolAttribute, context); - const { x, y, z, lastModelMatrix } = data; - - const parsedPath = symbol.getParsedPath(); - // todo: 考虑使用path - if (!parsedPath) { - return; - } - - const { - fill = symbolAttribute.fill, - stroke = symbolAttribute.stroke, - fillColor = symbolAttribute.fill, - strokeColor = symbolAttribute.stroke, - size = symbolAttribute.size, - lineWidth = symbolAttribute.lineWidth, - maxRandomnessOffset = defaultRouthThemeSpec.maxRandomnessOffset, - roughness = defaultRouthThemeSpec.roughness, - bowing = defaultRouthThemeSpec.bowing, - curveFitting = defaultRouthThemeSpec.curveFitting, - curveTightness = defaultRouthThemeSpec.curveTightness, - curveStepCount = defaultRouthThemeSpec.curveStepCount, - fillStyle = defaultRouthThemeSpec.fillStyle, - fillWeight = defaultRouthThemeSpec.fillWeight, - hachureAngle = defaultRouthThemeSpec.hachureAngle, - hachureGap = defaultRouthThemeSpec.hachureGap, - simplification = defaultRouthThemeSpec.simplification, - dashOffset = defaultRouthThemeSpec.dashOffset, - dashGap = defaultRouthThemeSpec.dashGap, - zigzagOffset = defaultRouthThemeSpec.zigzagOffset, - seed = defaultRouthThemeSpec.seed, - fillLineDash = defaultRouthThemeSpec.fillLineDash, - fillLineDashOffset = defaultRouthThemeSpec.fillLineDashOffset, - disableMultiStroke = defaultRouthThemeSpec.disableMultiStroke, - disableMultiStrokeFill = defaultRouthThemeSpec.disableMultiStrokeFill, - preserveVertices = defaultRouthThemeSpec.preserveVertices, - fixedDecimalPlaceDigits = defaultRouthThemeSpec.fixedDecimalPlaceDigits - } = symbol.attribute as any; - - let svgPath = ''; - if (parsedPath.drawToSvgPath) { - svgPath = parsedPath.drawToSvgPath(size, x, y); - } else { - const customPath = new CustomPath2D(); - if (parsedPath.draw(customPath, size, x, y)) { - customPath.closePath(); - } - svgPath = customPath.toString(); - } - - rc.path(svgPath, { - fill: fill ? (fillColor as string) : undefined, - stroke: stroke ? (strokeColor as string) : undefined, - strokeWidth: lineWidth, - maxRandomnessOffset, - roughness, - bowing, - curveFitting, - curveTightness, - curveStepCount, - fillStyle, - fillWeight, - hachureAngle, - hachureGap, - simplification, - dashOffset, - dashGap, - zigzagOffset, - seed, - fillLineDash, - fillLineDashOffset, - disableMultiStroke, - disableMultiStrokeFill, - preserveVertices, - fixedDecimalPlaceDigits - }); - - context.highPerformanceRestore(); - } - - drawShape( - graphic: IGraphic, - ctx: IContext2d, - x: number, - y: number, - drawContext: IDrawContext, - params?: IGraphicRenderDrawParams, - fillCb?: ( - ctx: IContext2d, - markAttribute: Partial, - themeAttribute: IThemeAttribute - ) => boolean, - strokeCb?: ( - ctx: IContext2d, - markAttribute: Partial, - themeAttribute: IThemeAttribute - ) => boolean - ): void { - if (this.canvasRenderer.drawShape) { - return this.canvasRenderer.drawShape(graphic, ctx, x, y, drawContext, params, fillCb, strokeCb); - } + this.doDraw(symbol, renderService, drawContext, params); } } diff --git a/packages/vrender/__tests__/browser/src/pages/animate-next.ts b/packages/vrender/__tests__/browser/src/pages/animate-next.ts new file mode 100644 index 000000000..09b392831 --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/animate-next.ts @@ -0,0 +1,1113 @@ +import { + DefaultTicker, + DefaultTimeline, + Animate, + registerAnimate, + IncreaseCount, + InputText, + AnimateExecutor, + ACustomAnimate, + registerCustomAnimate +} from '@visactor/vrender-animate'; +import { + container, + createRect, + createStage, + createSymbol, + IGraphic, + vglobal, + createCircle, + createText, + createGroup, + createLine, + createPath +} from '@visactor/vrender'; +import type { EasingType } from '@visactor/vrender-animate'; +// container.load(roughModule); + +vglobal.setEnv('browser'); + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; + +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + cb(stage); + }); +} + +// Custom rainbow color interpolator function +function rainbowColorInterpolator( + ratio: number, + from: any, + to: any, + out: Record, + datum: any, + target: IGraphic, + params: any +) { + // Create rainbow effect using HSL colors + const hue = (ratio * 360) % 360; + target.attribute.fill = `hsl(${hue}, 80%, 60%)`; +} + +// Path tracing interpolator function +function pathTracingInterpolator( + ratio: number, + from: any, + to: any, + out: Record, + datum: any, + target: IGraphic, + params: any +) { + const path = params.path || 'M0,0 L100,0 L100,100 L0,100 Z'; + const length = 1000; // Estimate of path length + + // Set stroke-dasharray and stroke-dashoffset to create tracing effect + // out.strokeDasharray = length; + console.log(target.attribute); + // out.strokeDashoffset = length * (1 - ratio); + target.attribute.lineDash = [length, length]; + target.attribute.lineDashOffset = length * (1 - ratio); +} + +export const page = () => { + const btnContainer = document.createElement('div'); + btnContainer.style.width = '1000px'; + btnContainer.style.background = '#cecece'; + btnContainer.style.display = 'flex'; + btnContainer.style.flexDirection = 'row'; + btnContainer.style.gap = '3px'; + btnContainer.style.flexWrap = 'wrap'; + btnContainer.style.height = '120px'; + const canvas = document.getElementById('main'); + // 将btnContainer添加到canvas之前 + canvas.parentNode.insertBefore(btnContainer, canvas); + // ========== Performance Example ========== + addCase('Ticker Performance Example', btnContainer, stage => { + let count = 0; + for (let i = 0; i < 1000; i++) { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + const ticker = new DefaultTicker(stage); + const timeline = new DefaultTimeline(); + ticker.addTimeline(timeline); + const animate = new Animate(); + animate.bind(rect); + animate.to({ x: 200 }, 1000000, 'linear'); + timeline.addAnimate(animate); + + ticker.start(); + ticker.on('tick', () => { + count++; + }); + } + }); + addCase('Animate Performance Example', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + const ticker = new DefaultTicker(stage); + ticker.setFPS(30); + const timeline = new DefaultTimeline(); + ticker.addTimeline(timeline); + + for (let i = 0; i < 200000; i++) { + const animate = new Animate(); + animate.bind(rect); + animate.to({ x: 2000, fill: 'blue' }, 100000, 'linear'); + timeline.addAnimate(animate); + } + + ticker.start(); + ticker.on('tick', () => { + stage.render(); + }); + }); + + addCase('Animate basic', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + + rect.animate().to({ x: 300 }, 1000, 'linear').to({ y: 300 }, 1000, 'linear'); + // 中途设置值没问题,它会从orange开始 + rect.setAttribute('fill', 'orange'); + }); + addCase('Animate chain', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + + rect.animate().to({ x: 300 }, 1000, 'linear').to({ y: 300 }, 1000, 'linear').to({ fill: 'blue' }, 1000, 'linear'); + // 中途设置值没问题,它会从orange开始 + rect.setAttribute('fill', 'orange'); + }); + addCase('Animate conflict', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + + rect.animate().to({ x: 600, y: 300 }, 6000, 'linear'); + setTimeout(() => { + rect.animate().to({ fill: 'orange' }, 1000, 'linear').to({ x: 0 }, 2000, 'linear'); + }, 1000); + }); + addCase('Animate chain loop', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + stage.defaultLayer.add(rect); + + rect + .animate() + .to({ x: 300 }, 1000, 'linear') + .to({ y: 300 }, 1000, 'linear') + .to({ fill: 'blue' }, 1000, 'linear') + .loop(2); + // 中途设置值没问题,它会从orange开始 + rect.setAttribute('fill', 'purple'); + }); + + addCase('Bounce Demo', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'green' + }); + stage.defaultLayer.add(rect); + + // Create a bouncing animation that moves right, then down, then changes color + // With bounce enabled, it will play forward then backward + rect + .animate() + .to({ x: 400 }, 1000, 'linear') + .to({ y: 400 }, 1000, 'linear') + .to({ fill: 'yellow' }, 1000, 'linear') + .loop(3) // Play the animation 3 times + .bounce(true); // Enable bounce so it goes forward and backward + + // Add explanatory text + const text = createText({ + x: 100, + y: 50, + text: 'Bounce Demo: Animation plays forward then backward with bounce(true)', + fontSize: 16, + fill: 'black' + }); + stage.defaultLayer.add(text); + }); + addCase('Animate Schedule', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'green' + }); + stage.defaultLayer.add(rect); + + // Create a bouncing animation that moves right, then down, then changes color + // With bounce enabled, it will play forward then backward + const rectAnimate = rect + .animate() + .to({ x: 400 }, 1000, 'linear') + .to({ y: 400 }, 1000, 'linear') + .to({ fill: 'yellow' }, 1000, 'linear') + .loop(3) // Play the animation 3 times + .bounce(true); // Enable bounce so it goes forward and backward + + // Add explanatory text + const text = createText({ + x: 300, + y: 50, + text: 'Animate Schedule', + fontSize: 16, + fill: 'black', + textAlign: 'center', + opacity: 0 + }); + const textAnimate = text.animate().to({ opacity: 1 }, 1000, 'linear'); + textAnimate.after(rectAnimate); + stage.defaultLayer.add(text); + }); + + addCase('startAt', btnContainer, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'green' + }); + stage.defaultLayer.add(rect); + + // Create a bouncing animation that moves right, then down, then changes color + // With bounce enabled, it will play forward then backward + const rectAnimate = rect + .animate() + .startAt(2000) + .to({ x: 400 }, 1000, 'linear') + .to({ y: 400 }, 1000, 'linear') + .to({ fill: 'yellow' }, 1000, 'linear') + .loop(3) // Play the animation 3 times + .bounce(true); // Enable bounce so it goes forward and backward + + // Add explanatory text + const text = createText({ + x: 300, + y: 50, + text: 'Animate Schedule', + fontSize: 16, + fill: 'black', + textAlign: 'center', + opacity: 0 + }); + const textAnimate = text.animate().to({ opacity: 1 }, 1000, 'linear'); + textAnimate.after(rectAnimate); + stage.defaultLayer.add(text); + }); + addCase('custom IncreaseCount', btnContainer, stage => { + // Add explanatory text + const text = createText({ + x: 300, + y: 50, + text: '0%咿呀呀', + fontSize: 16, + fill: 'black', + textAlign: 'center', + opacity: 1 + }); + const customAnimate = new IncreaseCount(null, { text: '12,345,678%咿呀呀' }, 1000, 'linear', { + decimalLength: 0, + format: 'thousandth', + formatTemplate: '{{var}}%咿呀呀' + }); + // Use cast to avoid type errors + text.animate().play(customAnimate as any); + stage.defaultLayer.add(text); + }); + addCase('custom InputText', btnContainer, stage => { + // Add explanatory text + const text = createText({ + x: 300, + y: 50, + text: '', + fontSize: 16, + fill: 'black', + textAlign: 'center', + opacity: 1 + }); + // Terminal-style animation with prompt + const terminalAnimation = new InputText({ text: '' }, { text: '这是一段文本内容' }, 1000, 'linear'); + // Use cast to avoid type errors + text.animate().play(terminalAnimation as any); + stage.defaultLayer.add(text); + }); + addCase('AnimateExecutor Basic', btnContainer, stage => { + // Add explanatory text + const group = createGroup({ + x: 100, + y: 100 + }); + + for (let i = 0; i < 6; i++) { + const rect = createRect({ + x: i * 100, + y: 100, + width: 80, + height: 100, + fill: 'black' + }); + group.add(rect); + } + const executor = new AnimateExecutor(group); + + // Basic animation - all elements fade and change color simultaneously + executor.execute({ + // type: 'to', + channel: { + fill: { + to: 'red' + }, + opacity: { + to: 0.5 + } + }, + duration: 1000, + easing: 'elasticOut' + }); + + // Add title + const text = createText({ + x: 300, + y: 50, + text: 'Basic AnimateExecutor - Simultaneous Animation', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + addCase('AnimateExecutor Item', btnContainer, stage => { + // Add explanatory text + + for (let i = 0; i < 6; i++) { + const rect = createRect({ + x: i * 100, + y: 100, + width: 80, + height: 100, + fill: 'black' + }); + const executor = new AnimateExecutor(rect); + + // Basic animation - all elements fade and change color simultaneously + executor.execute({ + type: 'to', + channel: { + fill: { + to: 'red' + }, + opacity: { + to: 0.5 + } + }, + duration: 1000, + easing: 'elasticOut' + }); + stage.defaultLayer.add(rect); + } + + // Add title + const text = createText({ + x: 300, + y: 50, + text: 'Basic AnimateExecutor - Simultaneous Animation', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + + stage.defaultLayer.add(text); + }); + + addCase('AnimateExecutor oneByOne', btnContainer, stage => { + // Create a group with multiple rects + const group = createGroup({ + x: 100, + y: 100 + }); + + for (let i = 0; i < 6; i++) { + const rect = createRect({ + x: i * 100, + y: 0, + width: 80, + height: 100, + fill: 'blue', + opacity: 0.3 + }); + group.add(rect); + } + const executor = new AnimateExecutor(group); + + // Sequential animation - elements animate one after another + executor.execute({ + type: 'to', + channel: { + y: { + to: 200 + }, + fill: { + to: 'green' + }, + opacity: { + to: 1 + } + }, + oneByOne: true, // Enable sequential animation + duration: 500, + easing: 'quadOut' + }); + + // Add title + const text = createText({ + x: 300, + y: 50, + text: 'AnimateExecutor with oneByOne - Sequential Animation', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + + addCase('AnimateExecutor totalTime', btnContainer, stage => { + // Create a group with multiple elements + const group = createGroup({ + x: 50, + y: 150 + }); + + for (let i = 0; i < 10; i++) { + const circle = createCircle({ + x: i * 80, + y: 0, + radius: 30, + fill: 'purple', + opacity: 0.5 + }); + group.add(circle); + } + const executor = new AnimateExecutor(group); + + // Sequential animation with fixed total time + executor.execute({ + type: 'to', + channel: { + y: { + to: (datum: any, graphic: IGraphic, params: any) => { + // Alternate between up and down + return (graphic as any).idx % 2 === 0 ? 100 : -100; + } + }, + radius: { + to: 50 + }, + opacity: { + to: 1 + } + }, + oneByOne: true, // Enable sequential animation + totalTime: 600, // Entire animation sequence takes exactly 3 seconds + duration: 500, // Base duration before scaling + easing: 'bounceOut' + }); + + // Add title + const text = createText({ + x: 400, + y: 50, + text: 'AnimateExecutor with totalTime - Fixed Duration Animation', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + + addCase('AnimateExecutor Timeline', btnContainer, stage => { + // Create a group with elements + const group = createGroup({ + x: 200, + y: 200 + }); + + // Create 8 shapes arranged in a circle + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + const x = Math.cos(angle) * 150; + const y = Math.sin(angle) * 150; + + // Create a symbol for variety + const symbol = createSymbol({ + x: x, + y: y, + size: 40, + symbolType: i % 4 === 0 ? 'circle' : i % 4 === 1 ? 'square' : i % 4 === 2 ? 'triangle' : 'diamond', + fill: 'orange', + stroke: 'black', + lineWidth: 2, + angle: 0 + }); + group.add(symbol); + } + + const executor = new AnimateExecutor(group); + + // Complex timeline animation + executor.execute({ + // Use a timeline configuration with multiple time slices + timeSlices: [ + { + // First slice - scale up + effects: { + type: 'to', + channel: { + size: { + to: 60 + }, + opacity: { + to: 0.7 + } + }, + easing: 'quadIn' + }, + duration: 500 + }, + { + // Second slice - rotate + effects: { + type: 'to', + channel: { + angle: { + to: Math.PI * 2 + } + }, + easing: 'linear' + }, + duration: 1000 + }, + { + // Third slice - change color and scale down + effects: [ + { + type: 'to', + channel: { + fill: { + to: 'red' + } + } + }, + { + type: 'to', + channel: { + size: { + to: 40 + } + } + } + ], + duration: 500 + } + ], + oneByOne: 100, // Sequential with 100ms interval + loop: 2 // Repeat twice + }); + + // Add title + const text = createText({ + x: 400, + y: 50, + text: 'AnimateExecutor with Timeline - Complex Animation Sequence', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + + addCase('AnimateExecutor Partitioner', btnContainer, stage => { + // Create a group with a grid of rectangles + const group = createGroup({ + x: 100, + y: 150 + }); + + // Create a 6x4 grid of rectangles + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 6; col++) { + const rect = createRect({ + x: col * 100, + y: row * 100, + width: 80, + height: 80, + fill: 'gray' + }); + rect.context = { + data: [{ row, col, even: (row + col) % 2 === 0 }] + }; + group.add(rect); + } + } + + const executor = new AnimateExecutor(group); + + // Apply animation only to elements where row + col is even + executor.execute({ + timeSlices: [ + { + effects: { + type: 'to', + channel: { + fill: { + to: 'blue' + }, + width: { + to: 90 + }, + height: { + to: 90 + } + }, + easing: 'elasticOut' + }, + duration: 1000 + } + ], + // Partitioner function to filter elements + partitioner: (datum: any, graphic: IGraphic, params: any) => { + return datum && datum.length && datum[0].even === true; + }, + oneByOne: 50 + }); + + // Add title + const text = createText({ + x: 400, + y: 50, + text: 'AnimateExecutor with Partitioner - Filtered Animation', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + addCase('AnimateExecutor lifecycle', btnContainer, stage => { + // Create a group with a grid of rectangles + const group = createGroup({ + x: 100, + y: 150 + }); + + // Create a 6x4 grid of rectangles + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 6; col++) { + const rect = createRect({ + x: col * 100, + y: row * 100, + width: 80, + height: 80, + fill: 'gray' + }); + rect.context = { + data: [{ row, col, even: (row + col) % 2 === 0 }] + }; + group.add(rect); + } + } + + const executor = new AnimateExecutor(group); + + // Apply animation only to elements where row + col is even + executor.execute({ + timeSlices: { + effects: { + type: 'to', + channel: { + fill: { + to: 'blue' + }, + width: { + to: 90 + }, + height: { + to: 90 + } + }, + easing: 'elasticOut' + }, + duration: 1000 + }, + loop: 2, + // Partitioner function to filter elements + partitioner: (datum: any, graphic: IGraphic, params: any) => { + return datum && datum.length && datum[0].even === true; + }, + oneByOne: 50 + }); + + // Add title + const text = createText({ + x: 400, + y: 50, + text: 'AnimateExecutor with lifecycle', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + + executor.onStart(() => { + console.log('onStart'); + }); + executor.onEnd(() => { + console.log('onEnd'); + alert('完成'); + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + addCase('AnimateExecutor builtInAnimate', btnContainer, stage => { + // Create a group with a grid of rectangles + const group = createGroup({ + x: 100, + y: 150 + }); + + // Create a 6x4 grid of rectangles + for (let row = 0; row < 4; row++) { + for (let col = 0; col < 6; col++) { + const rect = createRect({ + x: col * 100, + y: row * 100, + width: 80, + height: 80, + fill: 'gray' + }); + rect.context = { + data: [{ row, col, even: (row + col) % 2 === 0 }] + }; + group.add(rect); + } + } + + const executor = new AnimateExecutor(group); + + // Apply animation only to elements where row + col is even + executor.execute({ + timeSlices: { + effects: { + type: 'scaleIn', + easing: 'elasticOut' + }, + duration: 1000 + }, + // Partitioner function to filter elements + partitioner: (datum: any, graphic: IGraphic, params: any) => { + return datum && datum.length && datum[0].even === true; + }, + oneByOne: 50 + }); + + // Add title + const text = createText({ + x: 400, + y: 50, + text: 'AnimateExecutor with lifecycle', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + + executor.onStart(() => { + console.log('onStart'); + }); + executor.onEnd(() => { + console.log('onEnd'); + alert('完成'); + }); + stage.defaultLayer.add(group); + stage.defaultLayer.add(text); + }); + + // New test cases for custom animations + addCase('AnimateExecutor Custom Interpolator', btnContainer, stage => { + // Create a group to hold all elements + const group = createGroup({ + x: 100, + y: 100 + }); + + // Add a title + const title = createText({ + x: 400, + y: 30, + text: 'AnimateExecutor with Custom Interpolator Function', + fontSize: 18, + fill: 'black', + textAlign: 'center' + }); + + // Create a row of rectangles for the rainbow effect + const rectangles = []; + for (let i = 0; i < 10; i++) { + const rect = createRect({ + x: i * 70, + y: 80, + width: 60, + height: 60, + fill: 'gray', + cornerRadius: 5 // Using cornerRadius instead of radius + }); + rectangles.push(rect); + group.add(rect); + } + + // Create path for the path tracing demo + const pathElement = createPath({ + path: 'M50,250 C150,150 250,350 350,250 S550,150 650,250', + stroke: 'black', + lineWidth: 5, + fill: 'orange' + // strokeDasharray and strokeDashoffset will be set by the animation + }); + group.add(pathElement); + + // Create a text label for the path tracing demo + const pathLabel = createText({ + x: 350, + y: 210, + text: 'Path Tracing Animation', + fontSize: 14, + fill: 'black', + textAlign: 'center' + }); + group.add(pathLabel); + + // Create executor + const executor = new AnimateExecutor(group); + + // Rainbow color animation using custom interpolator + executor.execute({ + type: 'to', + custom: rainbowColorInterpolator, + channel: {}, + oneByOne: 50, + duration: 2000, + loop: Infinity + }); + + // Path tracing animation using custom interpolator + executor.executeItem( + { + type: 'to', + custom: pathTracingInterpolator, + customParameters: { + path: 'M50,250 C150,150 250,350 350,250 S550,150 650,250', + length: 800 // Approximate length of the path + }, + channel: { + strokeDasharray: { to: 800 }, + strokeDashoffset: { to: 0 } + }, + duration: 3000, + loop: Infinity + }, + pathElement + ); + + stage.defaultLayer.add(group); + stage.defaultLayer.add(title); + }); + + addCase('AnimateExecutor Custom Animation Class', btnContainer, stage => { + // Create a group to hold all elements + const group = createGroup({ + x: 100, + y: 100 + }); + + // Add a title + const title = createText({ + x: 400, + y: 30, + text: 'AnimateExecutor with Custom Animation Classes', + fontSize: 18, + fill: 'black', + textAlign: 'center' + }); + + // Create text element for typewriter effect + const typewriterText = createText({ + x: 350, + y: 100, + text: '', + fontSize: 20, + fill: 'blue', + textAlign: 'center' + }); + group.add(typewriterText); + + // Create a row of circles for wave animation + const circles = []; + for (let i = 0; i < 12; i++) { + const circle = createCircle({ + x: 50 + i * 60, + y: 200, + radius: 15, + fill: 'purple', + opacity: 0.6 + }); + circles.push(circle); + group.add(circle); + } + + // Create executor + const executor = new AnimateExecutor(group); + + // Apply typewriter animation + executor.executeItem( + { + type: 'to', + custom: InputText, + channel: { + text: { to: 'This is a custom typewriter animation effect!' } + }, + duration: 3000, + loop: true + }, + typewriterText + ); + + // Apply wave animation to circles + // circles.forEach((circle, index) => { + // executor.executeItem( + // { + // type: 'to', + // custom: WaveAnimate, + // customParameters: { + // amplitude: 30, + // frequency: 2 + index * 0.2 // Different frequency for each circle + // }, + // channel: { + // y: { to: 200 } // This will be replaced by the wave animation + // }, + // duration: 3000, + // loop: true + // }, + // circle + // ); + // }); + + stage.defaultLayer.add(group); + stage.defaultLayer.add(title); + }); + + addCase('AnimateExecutor conflict', btnContainer, stage => { + // Create a group to hold all elements + const group = createGroup({ + x: 100, + y: 100 + }); + + // Add a title + const title = createText({ + x: 400, + y: 30, + text: 'AnimateExecutor with Custom Animation Classes', + fontSize: 18, + fill: 'black', + textAlign: 'center' + }); + + // Create text element for typewriter effect + const typewriterText = createText({ + x: 350, + y: 100, + text: '', + fontSize: 20, + fill: 'blue', + textAlign: 'center' + }); + group.add(typewriterText); + + // Create a row of circles for wave animation + const circles = []; + for (let i = 0; i < 12; i++) { + const circle = createCircle({ + x: 50 + i * 60, + y: 200, + radius: 15, + fill: 'purple', + opacity: 0.6 + }); + circles.push(circle); + group.add(circle); + } + + // Create executor + const executor = new AnimateExecutor(group); + + // Apply typewriter animation + executor.executeItem( + { + type: 'to', + custom: InputText, + channel: { + text: { to: 'This is a custom typewriter animation effect!' } + }, + duration: 3000, + loop: true + }, + typewriterText + ); + + // Apply wave animation to circles + // circles.forEach((circle, index) => { + // executor.executeItem( + // { + // type: 'to', + // custom: WaveAnimate, + // customParameters: { + // amplitude: 30, + // frequency: 2 + index * 0.2 // Different frequency for each circle + // }, + // channel: { + // y: { to: 200 } // This will be replaced by the wave animation + // }, + // duration: 3000, + // loop: true + // }, + // circle + // ); + // }); + + stage.defaultLayer.add(group); + stage.defaultLayer.add(title); + }); +}; diff --git a/packages/vrender/__tests__/browser/src/pages/animate-state.ts b/packages/vrender/__tests__/browser/src/pages/animate-state.ts new file mode 100644 index 000000000..8397c3024 --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/animate-state.ts @@ -0,0 +1,976 @@ +import { + DefaultTicker, + DefaultTimeline, + Animate, + registerAnimate, + IncreaseCount, + InputText, + AnimateExecutor, + ACustomAnimate, + registerCustomAnimate +} from '@visactor/vrender-animate'; +import { + container, + createRect, + createStage, + createSymbol, + IGraphic, + vglobal, + createCircle, + createText, + createGroup, + createLine, + createPath +} from '@visactor/vrender'; +import type { EasingType } from '@visactor/vrender-animate'; +// container.load(roughModule); + +vglobal.setEnv('browser'); + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; + +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + cb(stage); + }); +} + +export const page = () => { + const btnContainer = document.createElement('div'); + btnContainer.style.width = '1000px'; + btnContainer.style.background = '#cecece'; + btnContainer.style.display = 'flex'; + btnContainer.style.flexDirection = 'row'; + btnContainer.style.gap = '3px'; + btnContainer.style.flexWrap = 'wrap'; + btnContainer.style.height = '120px'; + const canvas = document.getElementById('main'); + // 将btnContainer添加到canvas之前 + canvas.parentNode.insertBefore(btnContainer, canvas); + + // Basic animation state registration and application + addCase('Basic Animation States', btnContainer, stage => { + // Create a title + const title = createText({ + x: 450, + y: 50, + text: 'Basic Animation States', + fontSize: 20, + fontWeight: 'bold', + fill: 'black', + textAlign: 'center' + }); + + const instructions = createText({ + x: 450, + y: 80, + text: 'Click the buttons below to apply different animation states', + fontSize: 14, + fill: 'black', + textAlign: 'center' + }); + + // Create a rectangle to animate + const rect = createRect({ + x: 300, + y: 200, + width: 100, + height: 100, + fill: 'blue' + }); + rect.context = { id: 'rect1' }; + + console.log(rect); + + // Create control buttons + const createControlButton = (x: number, y: number, label: string, action: () => void) => { + const buttonGroup = createGroup({ + x, + y + }); + + const buttonBg = createRect({ + x: 0, + y: 0, + width: 120, + height: 30, + fill: 'lightgray', + stroke: 'gray', + lineWidth: 1, + cornerRadius: 5 + }); + + const buttonText = createText({ + x: 60, + y: 15, + text: label, + fontSize: 14, + fill: 'black', + textAlign: 'center', + textBaseline: 'middle' + }); + + buttonGroup.add(buttonBg); + buttonGroup.add(buttonText); + + // Add click handler + buttonGroup.addEventListener('click', action); + + return buttonGroup; + }; + + // Add control buttons + const buttonsGroup = createGroup({ + x: 0, + y: 350 + }); + + buttonsGroup.add( + createControlButton(200, 0, 'Pulse', () => { + rect.applyAnimationState( + ['pulse'], + [ + { + name: 'pulse', + animation: { + timeSlices: [ + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1.2 }, + scaleY: { to: 1.2 } + }, + easing: 'linear' + } + }, + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1 }, + scaleY: { to: 1 } + }, + easing: 'linear' + } + } + ], + loop: true + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(340, 0, 'Spin', () => { + rect.applyAnimationState( + ['spin'], + [ + { + name: 'spin', + animation: { + type: 'to', + channel: { + angle: { to: 360 } + }, + duration: 2000, + easing: 'linear', + loop: true + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(480, 0, 'Highlight', () => { + rect.applyAnimationState( + ['highlight'], + [ + [ + { + name: 'pulse', + animation: { + timeSlices: [ + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1.2 }, + scaleY: { to: 1.2 } + }, + easing: 'linear' + } + }, + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1 }, + scaleY: { to: 1 } + }, + easing: 'linear' + } + } + ], + loop: true + } + }, + { + name: 'highlight', + animation: { + timeSlices: [ + { + duration: 1000, + effects: { + channel: { + fill: { to: 'orange' }, + strokeWidth: { to: 3 }, + stroke: { to: 'red' } + } + }, + easing: 'sineOut' + }, + { + duration: 1000, + effects: { + channel: { + fill: { to: 'green' }, + strokeWidth: { to: 3 }, + stroke: { to: 'pink' } + } + }, + easing: 'sineOut' + } + ], + loop: true, + duration: 300, + easing: 'sineOut' + } + } + ] + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(620, 0, 'Clear', () => { + rect.clearAnimationStates(); + }) + ); + + // Add to stage + stage.defaultLayer.add(title); + stage.defaultLayer.add(instructions); + stage.defaultLayer.add(rect); + stage.defaultLayer.add(buttonsGroup); + }); + + // Animation state transitions + addCase('Animation State Transitions', btnContainer, stage => { + // Create a title + const title = createText({ + x: 450, + y: 50, + text: 'Animation State Transitions', + fontSize: 20, + fontWeight: 'bold', + fill: 'black', + textAlign: 'center' + }); + + const instructions = createText({ + x: 450, + y: 80, + text: 'Apply states in sequence to see transition behavior', + fontSize: 14, + fill: 'black', + textAlign: 'center' + }); + + // Create a circle to animate + const circle = createCircle({ + x: 450, + y: 250, + radius: 50, + fill: 'green' + }); + circle.context = { id: 'circle1' }; + + // Create control buttons + const createControlButton = (x: number, y: number, label: string, action: () => void) => { + const buttonGroup = createGroup({ + x, + y + }); + + const buttonBg = createRect({ + x: 0, + y: 0, + width: 120, + height: 30, + fill: 'lightgray', + stroke: 'gray', + lineWidth: 1, + cornerRadius: 5 + }); + + const buttonText = createText({ + x: 60, + y: 15, + text: label, + fontSize: 14, + fill: 'black', + textAlign: 'center', + textBaseline: 'middle' + }); + + buttonGroup.add(buttonBg); + buttonGroup.add(buttonText); + + // Add click handler + buttonGroup.addEventListener('click', action); + + return buttonGroup; + }; + + // Add transition explanations + const statusText = createText({ + x: 450, + y: 350, + text: 'Current state: None', + fontSize: 16, + fill: 'black', + textAlign: 'center' + }); + + const ruleExplanation = createText({ + x: 450, + y: 380, + text: 'Try different state sequences to see transition rules in action', + fontSize: 14, + fill: 'darkgray', + textAlign: 'center' + }); + + // Add control buttons + const buttonsGroup = createGroup({ + x: 0, + y: 430 + }); + + // First reset circle to simplify state + circle.opacity = 0; + circle.scaleX = 0; + circle.scaleY = 0; + + // Helper to update status text + const updateStatus = (state: string) => { + statusText.setAttribute('text', `Current state: ${state}`); + }; + console.log(circle); + + buttonsGroup.add( + createControlButton(200, 0, 'Appear', () => { + circle.applyAnimationState( + ['appear'], + [ + { + name: 'appear', + animation: { + type: 'to', + channel: { + opacity: { from: 0, to: 1 }, + scaleX: { from: 0, to: 1 }, + scaleY: { from: 0, to: 1 }, + fill: { from: 'green', to: 'blue' } + }, + duration: 3000, + easing: 'linear' + } + } + ] + ); + updateStatus('appear (enter)'); + ruleExplanation.attribute.text = 'Enter animations can be interrupted by any animation'; + }) + ); + + buttonsGroup.add( + createControlButton(340, 0, 'normal', () => { + circle.applyAnimationState( + ['normal'], + [ + { + name: 'normal', + animation: { + timeSlices: [ + { + duration: 600, + effects: { + type: 'to', + channel: { + y: { to: 200 }, + fill: { to: 'red' } + }, + easing: 'linear' + } + }, + { + duration: 600, + effects: { + type: 'to', + channel: { + y: { to: 300 } + }, + easing: 'linear' + } + } + ], + priority: 1, + loop: true, + bounce: true + } + } + ] + ); + updateStatus('normal'); + ruleExplanation.attribute.text = 'normal animations can be overridden by other animations'; + }) + ); + + buttonsGroup.add( + createControlButton(480, 0, 'Disappear', () => { + circle.applyAnimationState( + ['disappear'], + [ + { + name: 'disappear', + animation: { + type: 'to', + channel: { + opacity: { from: 1, to: 0 } + }, + duration: 800, + easing: 'sineIn' + } + } + ] + ); + updateStatus('disappear (exit)'); + ruleExplanation.attribute.text = 'Exit animations cannot be interrupted except by enter animations'; + }) + ); + + buttonsGroup.add( + createControlButton(620, 0, 'Clear', () => { + circle.clearAnimationStates(); + updateStatus('None'); + ruleExplanation.attribute.text = 'Try different state sequences to see transition rules in action'; + }) + ); + + // for (let i = 0; i < 3; i++) { + // const rect = createRect({ + // x: i * 100, + // y: 100, + // width: 80, + // height: 100, + // fill: 'black' + // }); + // setTimeout(() => { + // const executor = new AnimateExecutor(rect); + + // // Basic animation - all elements fade and change color simultaneously + // executor.execute({ + // type: 'to', + // channel: { + // fill: { + // to: 'red' + // }, + // opacity: { + // to: 0.5 + // } + // }, + // duration: 1000, + // easing: 'elasticOut' + // }); + // }, 2000); + // stage.defaultLayer.add(rect); + // } + + // Add to stage + stage.defaultLayer.add(title); + stage.defaultLayer.add(instructions); + stage.defaultLayer.add(circle); + stage.defaultLayer.add(statusText); + stage.defaultLayer.add(ruleExplanation); + stage.defaultLayer.add(buttonsGroup); + }); + + // Multiple state sequences + addCase('State Sequences', btnContainer, stage => { + // Create a title + const title = createText({ + x: 450, + y: 50, + text: 'Animation State Sequences', + fontSize: 20, + fontWeight: 'bold', + fill: 'black', + textAlign: 'center' + }); + + const instructions = createText({ + x: 450, + y: 80, + text: 'Apply state sequences to see chained animations', + fontSize: 14, + fill: 'black', + textAlign: 'center' + }); + + // Create a path to animate + const path = createPath({ + path: 'M250,200 C300,100 400,100 450,200 S600,300 650,200', + stroke: 'blue', + lineWidth: 3, + fill: 'none' + }); + path.context = { id: 'path1' }; + + // Register animation states + path.registerAnimationState({ + name: 'draw', + animation: { + type: 'to', + channel: { + lineDash: { from: [1000, 1000], to: [0, 0] }, + lineDashOffset: { from: 1000, to: 0 } + }, + duration: 2000, + easing: 'linear' + } + }); + + path.registerAnimationState({ + name: 'fill', + animation: { + type: 'to', + channel: { + fill: { from: 'none', to: 'rgba(0, 0, 255, 0.2)' } + }, + duration: 1000, + easing: 'sineOut' + } + }); + + path.registerAnimationState({ + name: 'pulse', + animation: { + type: 'to', + channel: { + lineWidth: { from: 3, to: 6 } + }, + duration: 500, + easing: 'linear' + } + }); + + path.registerAnimationState({ + name: 'reset', + animation: { + type: 'to', + channel: { + lineDash: { to: [1000, 1000] }, + lineDashOffset: { to: 1000 }, + fill: { to: 'none' }, + lineWidth: { to: 3 } + }, + duration: 1000, + easing: 'sineIn' + } + }); + + // Create control buttons + const createControlButton = (x: number, y: number, label: string, action: () => void) => { + const buttonGroup = createGroup({ + x, + y + }); + + const buttonBg = createRect({ + x: 0, + y: 0, + width: 160, + height: 30, + fill: 'lightgray', + stroke: 'gray', + lineWidth: 1, + cornerRadius: 5 + }); + + const buttonText = createText({ + x: 80, + y: 15, + text: label, + fontSize: 14, + fill: 'black', + textAlign: 'center', + textBaseline: 'middle' + }); + + buttonGroup.add(buttonBg); + buttonGroup.add(buttonText); + + // Add click handler + buttonGroup.addEventListener('click', action); + + return buttonGroup; + }; + + // Add control buttons + const buttonsGroup = createGroup({ + x: 0, + y: 350 + }); + + buttonsGroup.add( + createControlButton(200, 0, 'Draw → Fill → Pulse', () => { + debugger; + path.applyAnimationState( + ['draw', 'fill', 'pulse'], + [ + { + name: 'draw', + animation: { + type: 'to', + channel: { + stroke: { from: 'none', to: 'orange' } + }, + duration: 2000, + easing: 'linear' + } + }, + { + name: 'fill', + animation: { + type: 'to', + channel: { + fill: { from: 'none', to: 'rgba(0, 0, 255, 0.2)' } + }, + duration: 1000, + easing: 'sineOut' + } + }, + { + name: 'pulse', + animation: { + type: 'to', + channel: { + lineWidth: { from: 3, to: 6 } + }, + duration: 500, + easing: 'linear' + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(380, 0, 'Reset', () => { + path.applyAnimationState( + ['reset'], + [ + { + name: 'reset', + animation: { + type: 'to', + channel: { + lineDash: { to: [1000, 1000] }, + lineDashOffset: { to: 1000 }, + fill: { to: 'none' }, + lineWidth: { to: 3 } + }, + duration: 1000, + easing: 'sineIn' + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(560, 0, 'Clear', () => { + path.clearAnimationStates(); + }) + ); + + // Add to stage + stage.defaultLayer.add(title); + stage.defaultLayer.add(instructions); + stage.defaultLayer.add(path); + stage.defaultLayer.add(buttonsGroup); + }); + + // Group animation states + addCase('Group Animation States', btnContainer, stage => { + // Create a title + const title = createText({ + x: 450, + y: 50, + text: 'Group Animation States', + fontSize: 20, + fontWeight: 'bold', + fill: 'black', + textAlign: 'center' + }); + + const instructions = createText({ + x: 450, + y: 80, + text: 'Apply states to a group and see the effects', + fontSize: 14, + fill: 'black', + textAlign: 'center' + }); + + // Create a group with multiple elements + const group = createGroup({ + x: 300, + y: 200 + }); + group.context = { id: 'animationGroup' }; + + const rect1 = createRect({ + x: 0, + y: 0, + width: 60, + height: 60, + fill: 'red' + }); + rect1.context = { id: 'rect1' }; + + const rect2 = createRect({ + x: 100, + y: 0, + width: 60, + height: 60, + fill: 'green' + }); + rect2.context = { id: 'rect2' }; + + const rect3 = createRect({ + x: 200, + y: 0, + width: 60, + height: 60, + fill: 'blue' + }); + rect3.context = { id: 'rect3' }; + + group.add(rect1); + group.add(rect2); + group.add(rect3); + + // Create control buttons + const createControlButton = (x: number, y: number, label: string, action: () => void) => { + const buttonGroup = createGroup({ + x, + y + }); + + const buttonBg = createRect({ + x: 0, + y: 0, + width: 120, + height: 30, + fill: 'lightgray', + stroke: 'gray', + lineWidth: 1, + cornerRadius: 5 + }); + + const buttonText = createText({ + x: 60, + y: 15, + text: label, + fontSize: 14, + fill: 'black', + textAlign: 'center', + textBaseline: 'middle' + }); + + buttonGroup.add(buttonBg); + buttonGroup.add(buttonText); + + // Add click handler + buttonGroup.addEventListener('click', action); + + return buttonGroup; + }; + + // Initialize group to be invisible + group.opacity = 0; + group.scaleX = 0.5; + group.scaleY = 0.5; + + // Add control buttons + const buttonsGroup = createGroup({ + x: 0, + y: 350 + }); + + buttonsGroup.add( + createControlButton(200, 0, 'Appear', () => { + const normalAnimation = { + name: 'normal', + animation: { + loop: true, + startTime: 100, + oneByOne: 100, + priority: 1, + timeSlices: [ + { + delay: 1000, + effects: { + channel: { + fillOpacity: { + to: 0.5 + } + }, + easing: 'linear' + }, + duration: 500 + }, + { + effects: { + channel: { + fillOpacity: { + to: 1 + } + }, + easing: 'linear' + }, + duration: 500 + } + ], + customParameters: null + } + }; + group.applyAnimationState( + ['appear', 'normal'], + [ + { + name: 'appear', + animation: { + type: 'to', + channel: { + scaleX: { from: 0, to: 1.6 }, + scaleY: { from: 0, to: 1.6 } + }, + duration: 1000, + easing: 'elasticOut' + } + }, + normalAnimation + ] + ); + + setTimeout(() => { + group.stopAnimationState('normal'); + group.applyAnimationState(['normal'], [normalAnimation]); + console.log(group); + }, 3000); + }) + ); + + buttonsGroup.add( + createControlButton(340, 0, 'Shuffle', () => { + group.applyAnimationState( + ['shuffle'], + [ + { + name: 'shuffle', + animation: { + type: 'to', + channel: { + x: { to: group.attribute.x === 300 ? 100 : 300 } + }, + duration: 1000, + easing: 'linear' + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(480, 0, 'Disappear', () => { + group.applyAnimationState( + ['disappear'], + [ + { + name: 'disappear', + animation: { + type: 'to', + channel: { + opacity: { to: 0 }, + y: { to: 250 } + }, + duration: 800, + easing: 'sineIn' + } + } + ] + ); + }) + ); + + buttonsGroup.add( + createControlButton(620, 0, 'Reset', () => { + group.clearAnimationStates(); + group.opacity = 0; + group.scaleX = 0.5; + group.scaleY = 0.5; + group.x = 300; + group.y = 200; + }) + ); + + // Add to stage + stage.defaultLayer.add(title); + stage.defaultLayer.add(instructions); + stage.defaultLayer.add(group); + stage.defaultLayer.add(buttonsGroup); + }); +}; diff --git a/packages/vrender/__tests__/browser/src/pages/animate-tick.ts b/packages/vrender/__tests__/browser/src/pages/animate-tick.ts new file mode 100644 index 000000000..6b71c5d4f --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/animate-tick.ts @@ -0,0 +1,131 @@ +import { + DefaultTicker, + DefaultTimeline, + Animate, + registerAnimate, + IncreaseCount, + InputText, + AnimateExecutor, + ACustomAnimate, + registerCustomAnimate, + ManualTicker +} from '@visactor/vrender-animate'; +import { + container, + createRect, + createStage, + createSymbol, + IGraphic, + vglobal, + createCircle, + createText, + createGroup, + createLine, + createPath +} from '@visactor/vrender'; +import type { EasingType } from '@visactor/vrender-animate'; +// container.load(roughModule); + +vglobal.setEnv('browser'); + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; + +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + cb(stage); + }); +} + +export const page = () => { + const btnContainer = document.createElement('div'); + btnContainer.style.width = '1000px'; + btnContainer.style.background = '#cecece'; + btnContainer.style.display = 'flex'; + btnContainer.style.flexDirection = 'row'; + btnContainer.style.gap = '3px'; + btnContainer.style.flexWrap = 'wrap'; + btnContainer.style.height = '120px'; + const canvas = document.getElementById('main'); + // 将btnContainer添加到canvas之前 + canvas.parentNode.insertBefore(btnContainer, canvas); + + // Basic animation state registration and application + addCase('Manual Ticker', btnContainer, stage => { + stage.ticker = new ManualTicker(stage); + // Create a rectangle to animate + const rect = createRect({ + x: 300, + y: 200, + width: 100, + height: 100, + fill: 'blue' + }); + rect.context = { id: 'rect1' }; + + console.log(rect); + + // Create control buttons + rect.applyAnimationState( + ['pulse'], + [ + { + name: 'pulse', + animation: { + timeSlices: [ + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1.8 }, + scaleY: { to: 1.8 } + }, + easing: 'linear' + } + }, + { + duration: 500, + effects: { + type: 'to', + channel: { + scaleX: { to: 1 }, + scaleY: { to: 1 } + }, + easing: 'linear' + } + } + ], + loop: true + } + } + ] + ); + stage.defaultLayer.add(rect); + + stage.ticker.tickAt(200); + setTimeout(() => { + stage.ticker.tickAt(300); + setTimeout(() => { + stage.ticker.tickAt(400); + }, 1000); + }, 1000); + // stage.ticker.tickAt(800); + }); +}; diff --git a/packages/vrender/__tests__/browser/src/pages/animate-ticker.ts b/packages/vrender/__tests__/browser/src/pages/animate-ticker.ts new file mode 100644 index 000000000..119f34b76 --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/animate-ticker.ts @@ -0,0 +1,93 @@ +import { + DefaultTicker, + DefaultTimeline, + Animate, + registerAnimate, + IncreaseCount, + InputText, + AnimateExecutor, + ACustomAnimate, + registerCustomAnimate, + ManualTicker +} from '@visactor/vrender-animate'; +import { + container, + createRect, + createStage, + createSymbol, + IGraphic, + vglobal, + createCircle, + createText, + createGroup, + createLine, + createPath +} from '@visactor/vrender'; +import type { EasingType } from '@visactor/vrender-animate'; +// container.load(roughModule); + +vglobal.setEnv('browser'); + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; +let ticker; + +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + ticker = new ManualTicker(stage); + stage.ticker = ticker; + cb(stage); + }); +} + +export const page = () => { + const btnContainer = document.createElement('div'); + btnContainer.style.width = '1000px'; + btnContainer.style.background = '#cecece'; + btnContainer.style.display = 'flex'; + btnContainer.style.flexDirection = 'row'; + btnContainer.style.gap = '3px'; + btnContainer.style.flexWrap = 'wrap'; + btnContainer.style.height = '120px'; + const canvas = document.getElementById('main'); + // 将btnContainer添加到canvas之前 + canvas.parentNode.insertBefore(btnContainer, canvas); + + // Basic animation state registration and application + addCase('Basic Animation States', btnContainer, stage => { + // Create a rectangle to animate + const rect = createRect({ + x: 300, + y: 200, + width: 100, + height: 100, + fill: 'blue' + }); + rect.context = { id: 'rect1' }; + + console.log(rect); + + rect.animate().to({ x: 800 }, 1000, 'linear'); + + // Add to stage + stage.defaultLayer.add(rect); + + ticker.tickAt(300); + }); +}; diff --git a/packages/vrender/__tests__/browser/src/pages/animate.ts b/packages/vrender/__tests__/browser/src/pages/animate.ts index 8f7ce82cf..29649e2d7 100644 --- a/packages/vrender/__tests__/browser/src/pages/animate.ts +++ b/packages/vrender/__tests__/browser/src/pages/animate.ts @@ -21,7 +21,9 @@ import { Meteor, AttributeUpdateType, IStage, - Easing + Easing, + DefaultTicker, + DefaultTimeline } from '@visactor/vrender'; import { addShapesToStage, colorPools } from '../utils'; @@ -50,6 +52,27 @@ export const page = () => { const container = document.querySelector('#container')!; const br = document.createElement('br'); container.appendChild(br); + addCase('Animate Performance Example', container, stage => { + const rect = createRect({ + x: 100, + y: 100, + width: 100, + height: 100, + fill: 'red' + }); + const ticker = new DefaultTicker([]); + const timeline = new DefaultTimeline(); + ticker.addTimeline(timeline); + + console.time('animate'); + for (let i = 0; i < 2; i++) { + const animate = rect.animate().to({ fill: 'green' }, 100000, 'linear'); + timeline.addAnimate(animate); + } + + ticker.start(); + console.timeEnd('animate'); + }); addCase('text', container, stage => { stage.background = 'black'; const g = createGroup({}); diff --git a/packages/vrender/__tests__/browser/src/pages/custom-animate.ts b/packages/vrender/__tests__/browser/src/pages/custom-animate.ts new file mode 100644 index 000000000..f47feec5b --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/custom-animate.ts @@ -0,0 +1,822 @@ +import { createStage, createGroup } from '@visactor/vrender'; +import { createText, createRichText } from '@visactor/vrender'; +import { AnimateExecutor, registerAnimate, registerCustomAnimate } from '@visactor/vrender-animate'; + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; + +// 基础富文本配置 +const basicTextConfig = [ + { + text: 'VRender 富文本', + fontSize: 24, + fill: '#3A86FF', + fontWeight: 'bold' + } +]; + +// 带格式的富文本配置 +const formattedTextConfig = [ + { + text: '富文本', + fontSize: 24, + fill: '#3A86FF', + fontWeight: 'bold' + }, + { + text: '退场', + fontSize: 24, + fill: '#FF006E', + fontWeight: 'bold' + }, + { + text: '动画效果', + fontSize: 24, + fill: '#FFBE0B', + fontWeight: 'bold' + } +]; + +// 段落富文本配置 +const paragraphTextConfig = [ + { + text: 'VRender中的富文本动画\n', + fontSize: 24, + fill: '#3A86FF', + fontWeight: 'bold' + }, + { + text: '这是一段用于演示的多行文本,\n', + fontSize: 18, + fill: '#000' + }, + { + text: '支持逐字符退场和逐单词退场效果', + fontSize: 18, + fill: '#FF006E' + } +]; + +// Utility function to add test cases to the page +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + cb(stage); + }); +} + +export const page = () => { + const container = document.createElement('div'); + container.style.width = '1000px'; + container.style.background = '#cecece'; + container.style.display = 'flex'; + container.style.flexDirection = 'row'; + container.style.gap = '3px'; + container.style.flexWrap = 'wrap'; + container.style.height = '120px'; + const canvas = document.getElementById('main'); + // 将container添加到canvas之前 + canvas.parentNode.insertBefore(container, canvas); + + // Test case for InputText animation + addCase('InputText Animation', container, stage => { + const text = createText({ + x: 20, + y: 80, + text: '', + fontSize: 20, + fill: '#000', + textBaseline: 'middle' + }); + + // Create a group and add the text to it + const group = createGroup({}); + group.add(text); + stage.defaultLayer.add(group); + stage.render(); + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'inputText', + to: { text: 'Hello, this is a typing animation!' }, + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true + }, + duration: 2000, + easing: 'linear' + }); + }); + + // Test case for InputRichText animation + addCase('InputRichText Animation', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the final textConfig with different styles + const finalTextConfig = [ + { + text: 'Rich ', + fontSize: 24, + fill: '#FF5500', + fontWeight: 'bold' + }, + { + text: 'Text ', + fontSize: 24, + fill: '#0055FF', + fontStyle: 'italic' + }, + { + text: 'Typing ', + fontSize: 24, + fill: '#33AA33' + }, + { + text: 'Animation!', + fontSize: 24, + fill: '#AA33AA', + textDecoration: 'underline' + } + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'inputRichText', + to: { textConfig: finalTextConfig }, + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + fadeInChars: true, + fadeInDuration: 0.3 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // Slide RichText Animation - Right direction (default) + addCase('SlideRichText - Right Direction', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle', + ascentDescentMode: 'font' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the textConfig + const textConfig = [ + ...Array.from('Slide from Right ').map(item => ({ + text: item, + fontSize: 24, + fill: '#FF5500', + fontWeight: 'bold' + })), + ...Array.from('- Characters slide in while typing!').map(item => ({ + text: item, + fontSize: 24, + fill: '#0055FF' + })) + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'slideRichText', + to: { textConfig }, + customParameters: { + wordByWord: true, + fadeInDuration: 0.3, + slideDirection: 'right', + slideDistance: 50 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // Slide RichText Animation - Left direction + addCase('SlideRichText - Left Direction', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle', + ascentDescentMode: 'font' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the textConfig + const textConfig = [ + ...Array.from('Slide from Left ').map(item => ({ + text: item, + fontSize: 24, + fill: '#FF5500', + fontWeight: 'bold' + })), + ...Array.from('- Characters slide in while typing!').map(item => ({ + text: item, + fontSize: 24, + fill: '#0055FF' + })) + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'slideRichText', + to: { textConfig }, + customParameters: { + wordByWord: true, + fadeInDuration: 0.3, + slideDirection: 'left', + slideDistance: 50 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // Slide RichText Animation - Up direction + addCase('SlideRichText - Up Direction', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle', + ascentDescentMode: 'font' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the textConfig + const textConfig = [ + ...Array.from('Slide from Bottom ').map(item => ({ + text: item, + fontSize: 24, + fill: '#FF5500', + fontWeight: 'bold' + })), + ...Array.from('- Characters slide upward while typing!').map(item => ({ + text: item, + fontSize: 24, + fill: '#0055FF' + })) + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(richText); + executor.execute({ + type: 'slideRichText', + to: { textConfig }, + customParameters: { + wordByWord: true, + fadeInDuration: 0.3, + slideDirection: 'up', + slideDistance: 50 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // Slide RichText Animation - Down direction + addCase('SlideRichText - Down Direction', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle', + ascentDescentMode: 'font' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the textConfig + const textConfig = [ + ...Array.from('从上方').map(item => ({ + text: item, + fontSize: 24, + fill: '#FF5500', + fontWeight: 'bold' + })), + ...Array.from('- 字符向下滑动!').map(item => ({ + text: item, + fontSize: 24, + fill: '#0055FF' + })) + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'slideRichText', + to: { textConfig }, + customParameters: { + wordByWord: true, + fadeInDuration: 0.3, + slideDirection: 'down', + slideDistance: 50 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // 基础退格删除效果 + addCase('OutputRichText - 退格删除', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: basicTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行退格删除动画 + setTimeout(() => { + executor.execute({ + type: 'outputRichText', + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + fadeOutChars: true, + direction: 'backward' // 从后往前删除(退格效果) + }, + duration: 2000, + easing: 'linear' + }); + }, 1000); + }); + + // 正向删除效果 + addCase('OutputRichText - 正向删除', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: basicTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行正向删除动画 + setTimeout(() => { + executor.execute({ + type: 'outputRichText', + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + fadeOutChars: true, + direction: 'forward' // 从前往后删除 + }, + duration: 2000, + easing: 'linear' + }); + }, 1000); + }); + + // 无光标的淡出效果 + addCase('OutputRichText - 无光标淡出', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: formattedTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行无光标淡出动画 + setTimeout(() => { + executor.execute({ + type: 'outputRichText', + customParameters: { + showCursor: false, + fadeOutChars: true, + direction: 'backward' + }, + duration: 2000, + easing: 'linear' + }); + }, 1000); + }); + + // 完整的出入场序列 + addCase('输入后退出序列', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: [], + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 创建动画执行器 + const executor = new AnimateExecutor(richText); + + // 1. 首先执行输入动画 + executor.execute({ + type: 'inputRichText', + to: { + textConfig: formattedTextConfig + }, + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + fadeInChars: true + }, + duration: 2000, + easing: 'linear' + }); + + // 2. 等待2秒,然后执行退出动画 + setTimeout(() => { + executor.execute({ + type: 'outputRichText', + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + fadeOutChars: true, + direction: 'backward' + }, + duration: 2000, + easing: 'linear' + }); + }, 4000); + }); + + // ==== SlideOutRichText演示 ==== + + // 向右滑出 + addCase('SlideOutRichText - 向右滑出', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: formattedTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行向右滑出动画 + setTimeout(() => { + executor.execute({ + type: 'slideOutRichText', + customParameters: { + slideDirection: 'right', + slideDistance: 100, + fadeOutDuration: 0.3 + }, + duration: 1500, + easing: 'quadOut' + }); + }, 1000); + }); + + // 向上滑出 + addCase('SlideOutRichText - 向上滑出', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: formattedTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行向上滑出动画 + setTimeout(() => { + executor.execute({ + type: 'slideOutRichText', + customParameters: { + slideDirection: 'up', + slideDistance: 100, + fadeOutDuration: 0.3 + }, + duration: 1500, + easing: 'quadOut' + }); + }, 1000); + }); + + // 按单词滑出 + addCase('SlideOutRichText - 按单词滑出', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: paragraphTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行按单词滑出动画 + setTimeout(() => { + executor.execute({ + type: 'slideOutRichText', + customParameters: { + slideDirection: 'right', + slideDistance: 80, + fadeOutDuration: 0.3, + wordByWord: true + }, + duration: 2000, + easing: 'quadOut' + }); + }, 1000); + }); + + // 反向顺序滑出 + addCase('SlideOutRichText - 反向顺序滑出', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: formattedTextConfig, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 启动退场动画 + const executor = new AnimateExecutor(richText); + + // 先等待1秒,然后执行反向顺序滑出动画 + setTimeout(() => { + executor.execute({ + type: 'slideOutRichText', + customParameters: { + slideDirection: 'right', + slideDistance: 100, + fadeOutDuration: 0.3, + reverseOrder: true // 反转顺序 + }, + duration: 1500, + easing: 'quadOut' + }); + }, 1000); + }); + + // 完整的滑动入场和退场序列 + addCase('滑动入场后退场序列', container, stage => { + const richText = createRichText({ + x: 400, + y: 200, + textConfig: [], + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(richText); + + // 创建动画执行器 + const executor = new AnimateExecutor(richText); + + // 1. 首先执行滑动入场动画 + executor.execute({ + type: 'slideRichText', + to: { + textConfig: paragraphTextConfig + }, + customParameters: { + slideDirection: 'right', + slideDistance: 100, + fadeInDuration: 0.3, + wordByWord: true + }, + duration: 2000, + easing: 'quadOut' + }); + + // 2. 等待2.5秒,然后执行滑动退场动画(使用相反方向) + setTimeout(() => { + executor.execute({ + type: 'slideOutRichText', + customParameters: { + slideDirection: 'left', // 反方向滑出 + slideDistance: 100, + fadeOutDuration: 0.3, + wordByWord: true + }, + duration: 2000, + easing: 'quadOut' + }); + }, 4500); + }); + + // Test case for InputRichText with stroke animation + addCase('InputRichText - Stroke First Animation', container, stage => { + // Create a richText with empty textConfig + const richText = createRichText({ + x: 20, + y: 80, + width: 600, + height: 100, + textConfig: [], + textBaseline: 'middle' + }); + + // Create a group and add the richText to it + const group = createGroup({}); + group.add(richText); + stage.defaultLayer.add(group); + stage.render(); + + // Define the final textConfig with different styles + const finalTextConfig = [ + { + text: 'Hello, ', + fontSize: 40, + fill: '#FF5500', + stroke: '#FF5500', + lineWidth: 2, + fontWeight: 'bold' + }, + { + text: 'Manim', + fontSize: 40, + fill: '#0055FF', + stroke: '#0055FF', + lineWidth: 2, + fontStyle: 'italic' + }, + { + text: '!', + fontSize: 40, + fill: '#FF5500', + stroke: '#FF5500', + lineWidth: 2, + fontWeight: 'bold' + } + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(group); + executor.execute({ + type: 'inputRichText', + to: { textConfig: finalTextConfig }, + customParameters: { + showCursor: true, + cursorChar: '|', + blinkCursor: true, + strokeFirst: true, + strokeToFillRatio: 0 + }, + duration: 3000, + easing: 'linear' + }); + }); + + // Test case with a more complex stroke first animation + addCase('InputRichText - Complex Stroke Animation', container, stage => { + // Black background to better show the effect + stage.background = '#000000'; + + // Create a richText with empty textConfig + const richText = createRichText({ + x: 250, + y: 200, + width: 600, + height: 200, + textConfig: [], + textAlign: 'center', + textBaseline: 'middle' + }); + + stage.defaultLayer.add(richText); + stage.render(); + + // Define the final textConfig with different styles + const finalTextConfig = [ + { + text: 'Hello, ', + fontSize: 60, + fill: '#FFFFFF', + stroke: '#FFFFFF', + lineWidth: 1, + fontWeight: 'bold' + }, + { + text: 'Manim', + fontSize: 60, + fill: '#3A86FF', + stroke: '#3A86FF', + lineWidth: 1, + fontWeight: 'bold' + }, + { + text: '!', + fontSize: 60, + fill: '#FF006E', + stroke: '#FF006E', + lineWidth: 1, + fontWeight: 'bold' + } + ]; + + // Create an AnimateExecutor and run the animation + const executor = new AnimateExecutor(richText); + executor.execute({ + type: 'inputRichText', + to: { textConfig: finalTextConfig }, + customParameters: { + showCursor: false, + strokeFirst: true, + strokeToFillRatio: 1, + fadeInChars: true, + fadeInDuration: 0.2 + }, + duration: 4000, + easing: 'quadOut' + }); + }); + + return container; +}; diff --git a/packages/vrender/__tests__/browser/src/pages/index.ts b/packages/vrender/__tests__/browser/src/pages/index.ts index fc3029595..2a7ed832d 100644 --- a/packages/vrender/__tests__/browser/src/pages/index.ts +++ b/packages/vrender/__tests__/browser/src/pages/index.ts @@ -19,6 +19,30 @@ export const pages = [ name: '性能测试2', path: 'performance' }, + { + name: 'animate-next', + path: 'animate-next' + }, + { + name: 'animate-state', + path: 'animate-state' + }, + { + name: 'animate-ticker', + path: 'animate-ticker' + }, + { + name: 'custom-animate', + path: 'custom-animate' + }, + { + name: 'story-animate', + path: 'story-animate' + }, + { + name: 'animate-tick', + path: 'animate-tick' + }, { name: '内存', path: 'memory' diff --git a/packages/vrender/__tests__/browser/src/pages/line.ts b/packages/vrender/__tests__/browser/src/pages/line.ts index 2219418ab..3f88041d9 100644 --- a/packages/vrender/__tests__/browser/src/pages/line.ts +++ b/packages/vrender/__tests__/browser/src/pages/line.ts @@ -3,7 +3,7 @@ import { roughModule } from '@visactor/vrender-kits'; import { addShapesToStage, colorPools } from '../utils'; import { createSymbol } from '@visactor/vrender'; -// container.load(roughModule); +container.load(roughModule); const subP1 = [ [0, 100], @@ -174,6 +174,7 @@ export const page = () => { x: ((i * 300) % 900) + 100, y: Math.floor((i * 300) / 900) * 200, closePath: true, + renderStyle: 'rough', // segments: [ // { // points: subP1, diff --git a/packages/vrender/__tests__/browser/src/pages/morphing.ts b/packages/vrender/__tests__/browser/src/pages/morphing.ts index 93502018e..c844e9d5a 100644 --- a/packages/vrender/__tests__/browser/src/pages/morphing.ts +++ b/packages/vrender/__tests__/browser/src/pages/morphing.ts @@ -3,22 +3,22 @@ import { createRect, createLine, createCircle, - MorphingPath, - morphPath, - pathToBezierCurves, createSymbol, - oneToMultiMorph, multiToOneMorph, createPolygon, - splitPolygon, - createText, createArea, createArc, - splitGraphic, - defaultTicker + registerAnimate, + registerCustomAnimate, + oneToMultiMorph, + morphPath, + createGroup } from '@visactor/vrender'; import { colorPools } from '../utils'; +registerAnimate(); +registerCustomAnimate(); + // container.load(roughModule); export const page = () => { @@ -113,13 +113,14 @@ export const page = () => { }); const area = createArea({ - curveType: 'basis', + curveType: 'monotoneX', x: 400, y: 200, points: [ - { x: 0, y: 100, y1: 50 }, - { x: 50, y: 80, y1: 60 }, - { x: 80, y: 150, y1: 20 } + { x: 0, y: 100, y1: 200 }, + { x: 50, y: 80, y1: 200 }, + { x: 80, y: 150, y1: 200 }, + { x: 100, y: 100, y1: 200 } ], fill: colorPools[10], stroke: 'green' @@ -153,7 +154,7 @@ export const page = () => { // rect.pathProxy = pathToBezierCurves(circle.toCustomPath()); const line = createLine({ - x: 800, + x: 300, y: 100, points: [ { x: 0, y: 0 }, @@ -179,24 +180,24 @@ export const page = () => { // morphPath(circle, rect, { duration: 2000, easing: 'quadIn' }); // morphPath(line, rect, { duration: 2000, ease: 'cubicIn' }); - const symbolList = []; - for (let i = 0; i < 21; i++) { - const symbols = createSymbol({ - x: Math.random() * 500, - y: Math.random() * 500, - symbolType: 'arrow', - size: 10, - fill: colorPools[2], - // angle: Math.PI / 4, - lineWidth: 6 - }); - symbolList.push(symbols); - stage.defaultLayer.appendChild(symbols); - } + // const symbolList = []; + // for (let i = 0; i < 21; i++) { + // const symbols = createSymbol({ + // x: Math.random() * 500, + // y: Math.random() * 500, + // symbolType: 'arrow', + // size: 10, + // fill: colorPools[2], + // // angle: Math.PI / 4, + // lineWidth: 6 + // }); + // symbolList.push(symbols); + // stage.defaultLayer.appendChild(symbols); + // } - // stage.defaultLayer.appendChild(arc); + // // stage.defaultLayer.appendChild(area); - // oneToMultiMorph(arc, symbolList, { duration: 2000, easing: 'quadIn' }); + // oneToMultiMorph(area, symbolList, { duration: 2000, easing: 'quadIn' }); const fromSymbolList = []; for (let i = 0; i < 23; i++) { @@ -222,22 +223,63 @@ export const page = () => { return index * 100; } }); - // morphPath(fromSymbolList[0], polygon, { duration: 2000, easing: 'quadIn' }); - const fromSymbolList2 = []; - for (let i = 0; i < 20; i++) { - const symbols = createSymbol({ - x: 300 + i * 20, - y: 300, - symbolType: 'triangleLeft', - size: 5 + Math.floor(Math.random() * 10), - fill: 'green', - // angle: Math.PI / 4, - lineWidth: 6 - }); - fromSymbolList2.push(symbols); - // stage.defaultLayer.appendChild(symbols); - } + // const r1 = createRect({ + // visible: true, + // lineWidth: 0, + // fillOpacity: 0.8, + // // cornerRadius: 100, + // fill: '#FF8A00', + // stroke: '#FF8A00', + // x: 200, + // y: 200, + // height: 300, + // width: 100 + // }); + + // const a1 = createArc({ + // visible: true, + // lineWidth: 0, + // innerPadding: 0, + // outerPadding: 0, + // fillOpacity: 1, + // fill: '#FF8A00', + // padAngle: 0, + // stroke: '#FF8A00', + // x: 200, + // y: 200, + // startAngle: 0, + // endAngle: Math.PI, + // outerRadius: 200, + // innerRadius: 100, + // cornerRadius: 100 + // }); + // stage.defaultLayer.ad(r1); + // const group = createGroup({ + // x: 300, + // y: 200 + // }); + // group.add(a1); + // stage.defaultLayer.appendChild(a1); + // morphPath(r1, a1, { duration: 2000, easing: 'linear' }); + // stage.defaultLayer.appendChild(r1); + // stage.defaultLayer.appendChild(a1); + // morphPath(a1, r1, { duration: 2000, easing: 'linear' }); + + // const fromSymbolList2 = []; + // for (let i = 0; i < 20; i++) { + // const symbols = createSymbol({ + // x: 300 + i * 20, + // y: 300, + // symbolType: 'triangleLeft', + // size: 5 + Math.floor(Math.random() * 10), + // fill: 'green', + // // angle: Math.PI / 4, + // lineWidth: 6 + // }); + // fromSymbolList2.push(symbols); + // // stage.defaultLayer.appendChild(symbols); + // } // stage.defaultLayer.appendChild(rect4); // multiToOneMorph(fromSymbolList2, rect4, { // duration: 2000, @@ -254,10 +296,10 @@ export const page = () => { stage.on('click', () => { if (isPause) { isPause = false; - defaultTicker.resume(); + // defaultTicker.resume(); } else { isPause = true; - defaultTicker.pause(); + // defaultTicker.pause(); } }); diff --git a/packages/vrender/__tests__/browser/src/pages/performance.ts b/packages/vrender/__tests__/browser/src/pages/performance.ts index 0624430d1..daea0aab2 100644 --- a/packages/vrender/__tests__/browser/src/pages/performance.ts +++ b/packages/vrender/__tests__/browser/src/pages/performance.ts @@ -38,152 +38,74 @@ export const page = () => { } console.timeEnd('setFont'); }); - addTest('array map set', () => { - const list = new Array(100000).fill(0).map(item => createArc({})); - - let array = []; - let map = new Map(); - let set = new Set(); - function createArray() { - list.forEach(item => { - array.push(item); - }); - } - - function createMap() { - list.forEach(item => { - map.set(item._uid, item); - }); - } - - function createSet() { - list.forEach(item => { - set.add(item); - }); - } - - function forEachArray() { - let id = 0; - array.forEach(item => (id += item._uid)); - return id; - } - - function forEachMap() { - let id = 0; - map.forEach(item => (id += item._uid)); - return id; - } - - function forEachSet() { - let id = 0; - set.forEach(item => (id += item._uid)); - return id; - } - - function deleteArray() { - list.forEach(item => { - array.push(item); - }); - } - - function delteMap() { - list.forEach(item => { - map.delete(item._uid); - }); - } - - function deletSet() { - list.forEach(item => { - set.delete(item); - }); - } - - console.time('array'); - createArray(); - console.timeEnd('array'); - - console.time('map'); - createMap(); - console.timeEnd('map'); - - console.time('set'); - createSet(); - console.timeEnd('set'); - - console.time('array foreach'); - const arrayCount = forEachArray(); - console.timeEnd('array foreach'); - - console.time('map foreach'); - const mapCount = forEachMap(); - console.timeEnd('map foreach'); - - console.time('set foreach'); - const setCount = forEachSet(); - console.timeEnd('set foreach'); - - console.time('map delete'); - delteMap(); - console.timeEnd('map delete'); - - console.time('set delete'); - deletSet(); - console.timeEnd('set delete'); - - console.log(list, map, set, arrayCount, mapCount, setCount); - }); - addTest('raf calls', () => { - const createRun = () => { - let i = 0; - function run() { - requestAnimationFrame(run); - i++; - } - run(); - }; - - for (let i = 0; i < 600; i++) { - createRun(); - } - }); - addTest('mock raf', () => { - class PerformanceRAF { - nextAnimationFrameCbs: FrameRequestCallback[] = []; - - addAnimationFrameCb(callback: FrameRequestCallback) { - this.nextAnimationFrameCbs.push(callback); - // 下一帧执行nextAnimationFrameCbs - this.tryRunAnimationFrameNextFrame(); - return this.nextAnimationFrameCbs.length - 1; + addTest('state map', () => { + class AAA { + transitions: Map> = new Map(); + constructor(public from: string, public to: string) {} + /** + * 注册默认的转换规则 + */ + registerDefaultTransitions(): void { + // 设置默认的转换规则 + // 退出动画不能被中断,除非是进入动画 + this.registerTransition('exit', 'enter', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('exit', '*', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + + // 进入动画可以被任何动画中断 + this.registerTransition('enter', '*', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + // Disappear 是一个退出动画,遵循相同的规则 + this.registerTransition('disappear', 'enter', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('disappear', 'appear', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + this.registerTransition('disappear', '*', () => ({ + allowTransition: false, + stopOriginalTransition: false + })); + + // Appear 是一个进入动画,可以被任何动画中断 + this.registerTransition('appear', '*', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); } - protected runAnimationFrame = (time: number) => { - const cbs = this.nextAnimationFrameCbs; - this.nextAnimationFrameCbs = []; - for (let i = 0; i < cbs.length; i++) { - cbs[i](time); + registerTransition(fromState: string, toState: string, transition: any): void { + if (!this.transitions.has(fromState)) { + this.transitions.set(fromState, new Map()); } - }; - protected tryRunAnimationFrameNextFrame = () => { - if (!(this.nextAnimationFrameCbs && this.nextAnimationFrameCbs.length === 1)) { - return; - } - requestAnimationFrame(this.runAnimationFrame); - }; - } - const performanceRAF = new PerformanceRAF(); - const createRun = () => { - let i = 0; - function run() { - performanceRAF.addAnimationFrameCb(run); - i++; + this.transitions.get(fromState)!.set(toState, transition); } - run(); - }; + } - for (let i = 0; i < 600; i++) { - createRun(); + const aaa = new AAA('from', 'to'); + aaa.registerDefaultTransitions(); + aaa.registerTransition('from', 'to', () => ({ + allowTransition: true, + stopOriginalTransition: true + })); + + const fromList = new Array(10000).fill(0).map((_, i) => ['from', 'to', 'appear', 'disappear'][i % 4]); + const toList = new Array(10000).fill(0).map((_, i) => ['from', 'to', 'appear', 'disappear'][i % 4]); + console.time('transitions'); + for (let i = 0; i < 10000; i++) { + aaa.transitions.get(fromList[i])?.get(toList[i])?.transition(); } + console.timeEnd('transitions'); }); }; diff --git a/packages/vrender/__tests__/browser/src/pages/rect.ts b/packages/vrender/__tests__/browser/src/pages/rect.ts index 53682c067..93f71d824 100644 --- a/packages/vrender/__tests__/browser/src/pages/rect.ts +++ b/packages/vrender/__tests__/browser/src/pages/rect.ts @@ -1,8 +1,7 @@ -import { createStage, createRect, IGraphic, createGroup, createSymbol } from '@visactor/vrender'; +import { createStage, container, createRect, IGraphic, createGroup, createSymbol } from '@visactor/vrender'; import { roughModule } from '@visactor/vrender-kits'; -import { addShapesToStage, colorPools } from '../utils'; -// container.load(roughModule); +container.load(roughModule); export const page = () => { const graphics: IGraphic[] = []; // graphics.push( @@ -19,17 +18,15 @@ export const page = () => { // ); const rect = createRect({ - x: 20, - y: 20, - width: 101.55555555555556, - height: 30, - cornerRadius: -4, - background: - 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzFfMTUxNjkpIj4KPHBhdGggZD0iTTEwIDIwQzE1LjUyMjggMjAgMjAgMTUuNTIyOCAyMCAxMEMyMCA0LjQ3NzE1IDE1LjUyMjggMCAxMCAwQzQuNDc3MTUgMCAwIDQuNDc3MTUgMCAxMEMwIDE1LjUyMjggNC40NzcxNSAyMCAxMCAyMFoiIGZpbGw9IiNGMEYwRjAiLz4KPHBhdGggZD0iTTIwIDkuOTk5OTZDMjAgNS43MDAzMSAxNy4yODYzIDIuMDM0ODggMTMuNDc4MyAwLjYyMTk0OFYxOS4zNzhDMTcuMjg2MyAxNy45NjUgMjAgMTQuMjk5NiAyMCA5Ljk5OTk2WiIgZmlsbD0iI0Q4MDAyNyIvPgo8cGF0aCBkPSJNMCA5Ljk5OTk2QzAgMTQuMjk5NiAyLjcxMzc1IDE3Ljk2NSA2LjUyMTc2IDE5LjM3OFYwLjYyMTk0OEMyLjcxMzc1IDIuMDM0ODggMCA1LjcwMDMxIDAgOS45OTk5NloiIGZpbGw9IiM2REE1NDQiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF8xXzE1MTY5Ij4KPHJlY3Qgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=', - fill: 'rgba(0,0,0,0.3)', - backgroundMode: 'repeat-x', - boundsPadding: [2, 2, 2, 2], - pickMode: 'imprecise' + visible: true, + lineWidth: 0, + fill: '#FF8A00', + stroke: '#FF8A00', + x: 207.40897089999999, + y: 148.53125, + width: NaN, + x1: 49.113898, + height: 381.9375 }); rect.states = { @@ -60,13 +57,14 @@ export const page = () => { stroke: 'red', // scaleCenter: ['50%', '50%'], // _debug_bounds: true, - fill: 'conic-gradient(from 90deg, rgba(5,0,255,1) 16%, rgba(0,255,10,1) 41%, rgba(9,9,121,1) 53%, rgba(0,212,255,1) 100%)', + fill: 'red', // cornerRadius: [5, 10, 15, 20], - lineWidth: 5, + lineWidth: 2, anchor: ['50%', '50%'], // anchor: [400, 200], lineDash: [100, 10], - lineDashOffset: -100 + lineDashOffset: -100, + renderStyle: 'rough' }); const star = createSymbol({ x: 300, @@ -75,7 +73,7 @@ export const page = () => { scaleY: 2, angle: 30, size: 100, - symbolType: 'square', + symbolType: 'star', // cornerRadius: [0, 10, 10, 0], stroke: 'red', // scaleCenter: ['50%', '50%'], @@ -87,7 +85,8 @@ export const page = () => { anchor: ['50%', '50%'], // anchor: [400, 200], lineDash: [100, 10], - lineDashOffset: -100 + lineDashOffset: -100, + renderStyle: 'rough' }); const group = createGroup({ diff --git a/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts b/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts index 0dd451c3d..7501b9c0c 100644 --- a/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts +++ b/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts @@ -64,10 +64,31 @@ export const page = () => { fontWeight: 'normal', fontFamily: 'D-Din', lineHeight: '150%', - text: 'fkdlajfldsfjlsaa', - isComposing: false, - dy: 5, - space: 6 + text: 'a', + dy: 10, + isComposing: false + }, + { + fill: '#1F2329', + stroke: false, + fontSize: 16, + fontWeight: 'normal', + fontFamily: 'D-Din', + lineHeight: '150%', + text: 'b', + dy: 20, + isComposing: false + }, + { + fill: '#1F2329', + stroke: false, + fontSize: 16, + fontWeight: 'normal', + fontFamily: 'D-Din', + lineHeight: '150%', + text: 'c', + dx: 30, + isComposing: false } ], upgradeAttrs: { diff --git a/packages/vrender/__tests__/browser/src/pages/state.ts b/packages/vrender/__tests__/browser/src/pages/state.ts index 91672b935..262a8c3dd 100644 --- a/packages/vrender/__tests__/browser/src/pages/state.ts +++ b/packages/vrender/__tests__/browser/src/pages/state.ts @@ -1,13 +1,8 @@ -import { - createStage, - createCircle, - FederatedEvent, - DefaultTicker, - defaultTimeline, - defaultTicker -} from '@visactor/vrender'; +import { createStage, createCircle, FederatedEvent } from '@visactor/vrender'; +import { registerAnimate } from '@visactor/vrender-animate'; import { addShapesToStage, colorPools } from '../utils'; +registerAnimate(); // container.load(roughModule); export const page = () => { @@ -154,13 +149,6 @@ export const page = () => { viewHeight: 600 }); - setTimeout(() => { - defaultTicker.pause(); - setTimeout(() => { - defaultTicker.resume(); - }, 2000); - }, 2000); - (window as any).stage = stage; addShapesToStage(stage, shapes as any, true); stage.render(); diff --git a/packages/vrender/__tests__/browser/src/pages/story-animate.ts b/packages/vrender/__tests__/browser/src/pages/story-animate.ts new file mode 100644 index 000000000..5a8881225 --- /dev/null +++ b/packages/vrender/__tests__/browser/src/pages/story-animate.ts @@ -0,0 +1,1654 @@ +import { + DefaultTicker, + DefaultTimeline, + Animate, + registerAnimate, + IncreaseCount, + InputText, + AnimateExecutor, + ACustomAnimate, + registerCustomAnimate +} from '@visactor/vrender-animate'; +import { + container, + createRect, + createStage, + createSymbol, + IGraphic, + vglobal, + createCircle, + createText, + createGroup, + createLine, + createPath +} from '@visactor/vrender'; +import type { EasingType } from '@visactor/vrender-core'; +// container.load(roughModule); + +vglobal.setEnv('browser'); + +registerAnimate(); +registerCustomAnimate(); + +let stage: any; + +function addCase(name: string, container: HTMLElement, cb: (stage: any) => void) { + const button = document.createElement('button'); + button.innerText = name; + button.style.height = '26px'; + container.appendChild(button); + button.addEventListener('click', () => { + stage && stage.release(); + stage = createStage({ + canvas: 'main', + width: 900, + height: 600, + background: 'pink', + disableDirtyBounds: false, + canvasControled: false, + autoRender: true + }); + cb(stage); + }); +} + +export const page = () => { + const btnContainer = document.createElement('div'); + btnContainer.style.width = '1000px'; + btnContainer.style.background = '#cecece'; + btnContainer.style.display = 'flex'; + btnContainer.style.flexDirection = 'row'; + btnContainer.style.gap = '3px'; + btnContainer.style.flexWrap = 'wrap'; + btnContainer.style.height = '230px'; + const canvas = document.getElementById('main'); + // 将btnContainer添加到canvas之前 + canvas.parentNode.insertBefore(btnContainer, canvas); + + // SlideIn Animation Demo + addCase('SlideIn - From Right', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1890FF', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'right', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideIn - From Left', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FF7A45', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'left', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideIn - From Top', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#52C41A', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'top', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideIn - From Bottom', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#722ED1', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'bottom', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + // GrowIn Animation Demo + addCase('GrowIn - XY Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FA8C16', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.2, + direction: 'xy' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + addCase('GrowIn - X Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#13C2C2', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.2, + direction: 'x' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + addCase('GrowIn - Y Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#EB2F96', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.2, + direction: 'y' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + // SpinIn Animation Demo + addCase('SpinIn - 360 Rotation', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#F5222D', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'spinIn', + customParameters: { + fromAngle: Math.PI * 2, + fromScale: 0.6 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SpinIn - 180 Rotation', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#2F54EB', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'spinIn', + customParameters: { + fromAngle: Math.PI / 3, + fromScale: 0.6 + }, + duration: 500, + easing: 'quadOut' + }); + }); + + // MoveScaleIn Animation Demo + addCase('MoveScaleIn - From Right', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#A0D911', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveScaleIn', + customParameters: { + slideDirection: 'right', + slideDistance: 200, + fromScale: 0.6, + scaleDirection: 'xy', + slideRatio: 0.6 + }, + duration: 2000, + easing: 'cubicOut' + }); + }); + + addCase('MoveScaleIn - From Bottom', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FAAD14', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveScaleIn', + customParameters: { + slideDirection: 'bottom', + slideDistance: 200, + fromScale: 0.2, + scaleDirection: 'xy', + slideRatio: 0.6 + }, + duration: 2000, + easing: 'cubicOut' + }); + }); + + // MoveRotateIn Animation Demo + addCase('MoveRotateIn - From Left', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1D39C4', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveRotateIn', + customParameters: { + slideDirection: 'left', + slideDistance: 200, + fromAngle: Math.PI * 2, + fromScale: 0.2, + slideRatio: 0.4 + }, + duration: 2000, + easing: 'quadOut' + }); + }); + + addCase('MoveRotateIn - From Top', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#C41D7F', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveRotateIn', + customParameters: { + slideDirection: 'top', + slideDistance: 200, + fromAngle: Math.PI * 2, + fromScale: 0.2, + slideRatio: 0.4 + }, + duration: 2000, + easing: 'quadOut' + }); + }); + + // Complex Animation Demo with Multiple Rectangles + addCase('Story Animation Sequence', btnContainer, stage => { + // Create a group of rectangles in different positions + const positions = [ + { x: 150, y: 150, color: '#FF4D4F' }, + { x: 300, y: 150, color: '#FADB14' }, + { x: 450, y: 150, color: '#52C41A' }, + { x: 600, y: 150, color: '#1890FF' }, + { x: 150, y: 300, color: '#722ED1' }, + { x: 300, y: 300, color: '#EB2F96' }, + { x: 450, y: 300, color: '#FA8C16' }, + { x: 600, y: 300, color: '#13C2C2' } + ]; + + const rects = positions.map((pos, index) => { + const rect = createRect({ + x: pos.x, + y: pos.y, + width: 80, + height: 80, + fill: pos.color, + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + return rect; + }); + + // Apply different animations with staggered delays + rects.forEach((rect, index) => { + const executor = new AnimateExecutor(rect); + + // Choose different animation types based on index + const delay = index * 200; // 200ms stagger + + switch (index % 4) { + case 0: + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'right', + distance: 150 + }, + duration: 1000, + easing: 'quadOut', + delay + }); + break; + case 1: + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.2, + direction: 'xy' + }, + duration: 1000, + easing: 'elasticOut', + delay + }); + break; + case 2: + executor.execute({ + type: 'spinIn', + customParameters: { + fromAngle: Math.PI * 2, + fromScale: 0.2 + }, + duration: 1000, + easing: 'backOut', + delay + }); + break; + case 3: + executor.execute({ + type: index < 4 ? 'moveScaleIn' : 'moveRotateIn', + customParameters: { + slideDirection: 'bottom', + slideDistance: 150, + fromScale: 0.2, + fromAngle: Math.PI, + slideRatio: 0.5 + }, + duration: 1500, + easing: 'cubicOut', + delay + }); + break; + } + }); + }); + + // ====== EXIT ANIMATIONS DEMOS ====== + + // SlideOut Animation Demo + addCase('SlideOut - To Right', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1890FF', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'right', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideOut - To Left', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FF7A45', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'left', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideOut - To Top', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#52C41A', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'top', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SlideOut - To Bottom', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#722ED1', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'bottom', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + // GrowOut Animation Demo + addCase('GrowOut - XY Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FA8C16', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growOut', + customParameters: { + fromScale: 0.2, + direction: 'xy' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + addCase('GrowOut - X Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#13C2C2', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growOut', + customParameters: { + fromScale: 0.2, + direction: 'x' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + addCase('GrowOut - Y Scale', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#EB2F96', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'growOut', + customParameters: { + fromScale: 0.2, + direction: 'y' + }, + duration: 1000, + easing: 'elasticOut' + }); + }); + + // SpinOut Animation Demo + addCase('SpinOut - 360 Rotation', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#F5222D', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'spinOut', + customParameters: { + fromAngle: Math.PI * 2, + fromScale: 0.6 + }, + duration: 1000, + easing: 'quadOut' + }); + }); + + addCase('SpinOut - 180 Rotation', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#2F54EB', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'spinOut', + customParameters: { + fromAngle: Math.PI / 3, + fromScale: 0.6 + }, + duration: 500, + easing: 'quadOut' + }); + }); + + // MoveScaleOut Animation Demo + addCase('MoveScaleOut - To Right', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#A0D911', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveScaleOut', + customParameters: { + slideDirection: 'right', + slideDistance: 200, + fromScale: 0.6, + scaleDirection: 'xy', + slideRatio: 0.6 + }, + duration: 2000, + easing: 'cubicOut' + }); + }); + + addCase('MoveScaleOut - To Bottom', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FAAD14', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveScaleOut', + customParameters: { + slideDirection: 'bottom', + slideDistance: 200, + fromScale: 0.2, + scaleDirection: 'xy', + slideRatio: 0.6 + }, + duration: 2000, + easing: 'cubicOut' + }); + }); + + // MoveRotateOut Animation Demo + addCase('MoveRotateOut - To Left', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1D39C4', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveRotateOut', + customParameters: { + slideDirection: 'left', + slideDistance: 200, + fromAngle: Math.PI * 2, + fromScale: 0.2, + slideRatio: 0.4 + }, + duration: 2000, + easing: 'quadOut' + }); + }); + + addCase('MoveRotateOut - To Top', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#C41D7F', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'moveRotateOut', + customParameters: { + slideDirection: 'top', + slideDistance: 200, + fromAngle: Math.PI * 2, + fromScale: 0.2, + slideRatio: 0.4 + }, + duration: 2000, + easing: 'quadOut' + }); + }); + + // Apple iMessage Animation with iosSpringOut + addCase('Apple iMessage (iosSpringOut)', btnContainer, stage => { + // Create a group to hold the message bubble and its components + const messageGroup = createGroup({ + x: 450, + y: 250 + }); + + // Main bubble (rounded rectangle) + const messageBubble = createRect({ + x: 0, + y: 0, + width: 200, + height: 80, + fill: '#34C759', // Updated to match Apple's green color more closely + cornerRadius: 20, + shadowBlur: 5, + shadowColor: 'rgba(0, 0, 0, 0.1)', + shadowOffsetX: 0, + shadowOffsetY: 2 + }); + + // Message text + const messageText = createText({ + x: 20, + y: 40, + text: 'Hello! 👋', + fontSize: 18, + fontFamily: 'SF Pro, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif', // Apple system fonts + fill: '#FFFFFF', + textAlign: 'left', + textBaseline: 'middle' + }); + + // Add "now" timestamp text + const timestampText = createText({ + x: 200, + y: 90, + text: 'now', + fontSize: 12, + fontFamily: 'SF Pro, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif', + fill: 'rgba(120, 120, 128, 0.8)', // Light gray color like iOS + textAlign: 'right', + textBaseline: 'middle' + }); + + // Add all elements to the group + messageGroup.add(messageBubble); + messageGroup.add(messageText); + messageGroup.add(timestampText); + + // Add group to stage + stage.defaultLayer.add(messageGroup); + + // Apply animation to the group + const executor = new AnimateExecutor(messageGroup); + + // Use our custom Apple-style animation + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.3, + direction: 'xy', + fromOpacity: 0 + }, + selfOnly: true, + duration: 400, + easing: 'backOut' // Use our new iOS-style easing function + }); + }); + + // In-Out Animation Sequence + addCase('In-Out Animation Sequence', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#FF4D4F', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + + // First perform entrance animation + executor.execute({ + type: 'slideIn', + customParameters: { + direction: 'right', + distance: 200 + }, + duration: 1000, + easing: 'quadOut' + }); + + // Then perform exit animation after delay + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'left', + distance: 200 + }, + duration: 1000, + easing: 'quadOut', + delay: 2000 // Wait 2 seconds before starting exit animation + }); + }); + + // Opacity Control in Exit Animations Demo + addCase('Exit Animation Opacity Control', btnContainer, stage => { + // Create a row of rectangles + const colors = ['#1890FF', '#13C2C2', '#F5222D', '#A0D911', '#1D39C4']; + const rects = []; + + // Create 5 rectangles in a row + for (let i = 0; i < 5; i++) { + const rect = createRect({ + x: 150 + i * 150, + y: 200, + width: 80, + height: 80, + fill: colors[i], + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + rects.push(rect); + } + + // Standard moveScaleOut + const executor1 = new AnimateExecutor(rects[0]); + executor1.execute({ + type: 'moveScaleOut', + customParameters: { + slideDirection: 'right', + slideDistance: 100, + fromScale: 0.2, + scaleDirection: 'xy', + slideRatio: 0.5 + }, + duration: 2000, + easing: 'cubicOut' + }); + + // GrowOut with no opacity change (toOpacity: 1) + const executor2 = new AnimateExecutor(rects[1]); + executor2.execute({ + type: 'growOut', + customParameters: { + fromScale: 0.2, + direction: 'xy', + toOpacity: 1 // Keep fully visible during scale + }, + duration: 1000, + easing: 'quadOut' + }); + + // SpinOut with no opacity change, followed by fade out + const executor3 = new AnimateExecutor(rects[2]); + // First spin without opacity change + executor3.execute({ + type: 'spinOut', + customParameters: { + fromAngle: Math.PI * 1.5, + fromScale: 0.4, + toOpacity: 1 // Keep fully visible during spin + }, + duration: 1000, + easing: 'quadOut' + }); + // Then fade out + executor3.execute({ + type: 'fadeOut', + duration: 500, + easing: 'quadOut', + delay: 1000 + }); + + // SlideOut with partial opacity + const executor4 = new AnimateExecutor(rects[3]); + executor4.execute({ + type: 'slideOut', + customParameters: { + direction: 'top', + distance: 200, + toOpacity: 0.3 // Fade to 30% opacity rather than fully invisible + }, + duration: 1000, + easing: 'quadOut' + }); + + // Custom two-phase exit animation + const executor5 = new AnimateExecutor(rects[4]); + // Phase 1: Scale down without opacity change + executor5.execute({ + type: 'growOut', + customParameters: { + fromScale: 0.6, + direction: 'xy', + toOpacity: 1 // Maintain visibility + }, + duration: 800, + easing: 'quadOut' + }); + // Phase 2: Move out with opacity change + executor5.execute({ + type: 'slideOut', + customParameters: { + direction: 'right', + distance: 150 + }, + duration: 1000, + easing: 'quadOut', + delay: 800 // Start after first animation + }); + }); + + // Combined Enter-Exit Sequence Demo with Opacity Control + addCase('Enter-Exit Sequence with Opacity Control', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#EB2F96', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + + // First: entrance animation + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 0.2, + direction: 'xy' + }, + duration: 1000, + easing: 'elasticOut' + }); + + // Middle: wait in visible state + + // Last: custom exit animation - first spin without fading, then slide out with fade + executor.execute({ + type: 'spinOut', + customParameters: { + fromAngle: Math.PI, + fromScale: 0.7, + toOpacity: 1 // Keep fully visible during spin + }, + duration: 800, + easing: 'quadOut', + delay: 1500 // Wait 1.5s after entrance completes + }); + + executor.execute({ + type: 'slideOut', + customParameters: { + direction: 'bottom', + distance: 100 + // Default toOpacity: 0 for complete fade-out + }, + duration: 600, + easing: 'quadOut', + delay: 2300 // Wait for spin to complete + }); + }); + + // New: StrokeIn Animation Demo + addCase('StrokeIn - Rectangle', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: 'red', + stroke: '#FF4D4F', + cornerRadius: 10, + fillOpacity: 0 // Start with no fill + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 3, + strokeColor: '#FF4D4F', + showFill: true, + fillOpacity: 0 + }, + duration: 1500, + easing: 'quadOut' + }); + }); + + addCase('StrokeIn - Circle', btnContainer, stage => { + const circle = createCircle({ + x: 400, + y: 200, + radius: 50, + fill: '#52C41A', + fillOpacity: 0 // Start with no fill + }); + stage.defaultLayer.add(circle); + + const executor = new AnimateExecutor(circle); + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 4, + strokeColor: '#722ED1', + showFill: true + }, + duration: 1500, + easing: 'cubicOut' + }); + }); + + // StrokeOut Animation Demo + addCase('StrokeOut - Rectangle', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1890FF', + stroke: '#FF4D4F', + lineWidth: 3, + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'strokeOut', + customParameters: { + lineWidth: 3, + strokeColor: '#FF4D4F', + showFill: true + }, + duration: 1500, + easing: 'quadOut' + }); + }); + + // Combined Animations with Stroke + addCase('StrokeIn + GrowIn', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1890FF', + cornerRadius: 10, + fillOpacity: 0 // Start with no fill + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + // First do the stroke animation + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 3, + strokeColor: '#FF4D4F' + }, + duration: 1000, + easing: 'quadOut' + }); + + // Then do the fill grow animation + executor.execute({ + type: 'growIn', + customParameters: { + fromScale: 1, + direction: 'xy', + fromOpacity: 0 + }, + duration: 800, + easing: 'quadOut', + delay: 1000 // Start after stroke animation completes + }); + }); + + // Path StrokeIn Animation + addCase('StrokeIn - Path', btnContainer, stage => { + // Create a simple path (e.g., a triangle) + const path = createPath({ + x: 400, + y: 200, + path: 'M 0 -50 L 50 50 L -50 50 Z', + fill: '#FA8C16', + fillOpacity: 0 + }); + stage.defaultLayer.add(path); + + const executor = new AnimateExecutor(path); + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 4, + strokeColor: '#EB2F96', + dashLength: 300, // Custom dash length for the path + showFill: true + }, + duration: 2000, + easing: 'elasticOut' + }); + }); + + // Text with StrokeIn Animation + addCase('StrokeIn - Text', btnContainer, stage => { + const text = createText({ + x: 400, + y: 200, + text: 'Hello World', + fontSize: 40, + fontWeight: 'bold', + fill: '#1D39C4', + fillOpacity: 0, + textAlign: 'center', + textBaseline: 'middle' + }); + stage.defaultLayer.add(text); + + const executor = new AnimateExecutor(text); + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 2, + strokeColor: '#1D39C4', + dashLength: 800, // Longer dash length for text + showFill: true + }, + duration: 2000, + easing: 'cubicOut' + }); + }); + + // Sequence demo with multiple shapes using StrokeIn + addCase('Sequence - StrokeIn', btnContainer, stage => { + // Create multiple shapes in a row + const positions = [150, 300, 450, 600]; + const colors = ['#1890FF', '#52C41A', '#FA8C16', '#EB2F96']; + const shapes = []; + + // Create different shapes + shapes.push( + createRect({ + x: positions[0], + y: 200, + width: 80, + height: 80, + fill: colors[0], + fillOpacity: 0, + cornerRadius: 10 + }) + ); + + shapes.push( + createCircle({ + x: positions[1], + y: 200, + radius: 40, + fill: colors[1], + fillOpacity: 0 + }) + ); + + shapes.push( + createRect({ + x: positions[2], + y: 200, + width: 80, + height: 80, + fill: colors[2], + fillOpacity: 0 + }) + ); + + shapes.push( + createCircle({ + x: positions[3], + y: 200, + radius: 40, + fill: colors[3], + fillOpacity: 0 + }) + ); + + // Add all shapes to the stage + shapes.forEach(shape => stage.defaultLayer.add(shape)); + + // Animate each shape with a delay + shapes.forEach((shape, index) => { + const executor = new AnimateExecutor(shape); + executor.execute({ + type: 'strokeIn', + customParameters: { + lineWidth: 3, + strokeColor: colors[index], + showFill: true + }, + duration: 1500, + easing: 'quadOut', + delay: index * 300 // Stagger the animations + }); + }); + }); + + // PULSE ANIMATION DEMOS ========================= + + // Basic Opacity Pulse Animation + addCase('Pulse - Opacity', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + opacity: 0.8, + fill: '#1890FF', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: true, + useScale: false, + useColor: false, + pulseOpacity: 1.3, + pulseCount: 3 // 3 pulses over the duration + }, + duration: 2000, // Duration for all 3 pulses + easing: 'linear' + }); + }); + + // Scale Pulse Animation + addCase('Pulse - Scale', btnContainer, stage => { + const circle = createCircle({ + x: 400, + y: 200, + radius: 50, + fill: '#FA8C16' + }); + stage.defaultLayer.add(circle); + + const executor = new AnimateExecutor(circle); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: false, + useScale: true, + useColor: false, + pulseScale: 1.2, // Grow to 1.2x size + pulseCount: 3 // 5 complete pulses + }, + duration: 2000, // 2 seconds for all 5 pulses + easing: 'linear' + }); + }); + + // Color Pulse Animation + addCase('Pulse - Color', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#52C41A', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: false, + useScale: false, + useColor: true, + pulseColor: 'orange', // Pulse to red + pulseColorIntensity: 1, // Strong color shift + pulseCount: 2 // 4 pulses + }, + duration: 1000, + easing: 'linear' + }); + }); + + // Combined Effects Pulse Animation + addCase('Pulse - Combined', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#722ED1', + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: true, + useScale: true, + useColor: true, + opacityMin: 0.6, + opacityMax: 1, + pulseScale: 1.1, + pulseColor: '#EB2F96', // Pulse to pink + pulseColorIntensity: 0.5, + pulseCount: 6 // 6 pulses + }, + duration: 3000, // 3 seconds for all 6 pulses + easing: 'linear' + }); + }); + + // Stroke Only Pulse Animation + addCase('Pulse - Stroke Only', btnContainer, stage => { + const rect = createRect({ + x: 400, + y: 200, + width: 100, + height: 100, + fill: '#1D39C4', + stroke: '#FFC53D', + lineWidth: 4, + cornerRadius: 10 + }); + stage.defaultLayer.add(rect); + + const executor = new AnimateExecutor(rect); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: true, + useScale: false, + useColor: true, + opacityMin: 0.2, + opacityMax: 1, + pulseColor: '#FFFFFF', // Pulse to white + pulseColorIntensity: 0.7, + useFill: false, // Only pulse the stroke, not the fill + pulseCount: 3 // 3 pulses + }, + duration: 1500, // 1.5 seconds for all 3 pulses + easing: 'linear' + }); + }); + + // Looping Pulse Animation using repeat + addCase('Pulse - With Repeat', btnContainer, stage => { + const circle = createCircle({ + x: 400, + y: 200, + radius: 50, + fill: '#FF4D4F' + }); + stage.defaultLayer.add(circle); + + const executor = new AnimateExecutor(circle); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: true, + useScale: true, + useColor: false, + opacityMin: 0.3, + opacityMax: 1, + pulseScale: 1.3, + pulseCount: 2 // Only 2 pulses per duration cycle + }, + duration: 1000, // 1 second for 2 pulses + easing: 'linear', + loop: 4 // Repeat the animation 4 times (total 8 pulses) + }); + }); + + // Fast pulse animation (heart beat) + addCase('Pulse - Heart Beat', btnContainer, stage => { + // Create a heart shape + const heart = createPath({ + x: 400, + y: 200, + path: 'M 0 -15 C -15 -15 -20 0 0 15 C 20 0 15 -15 0 -15 Z', // Simple heart shape + fill: '#FF4D4F', + scaleX: 3, + scaleY: 3, + lineWidth: 0 + }); + stage.defaultLayer.add(heart); + + const executor = new AnimateExecutor(heart); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: false, + useScale: true, + useColor: false, + pulseScale: 1.15, // Subtle scale change + pulseCount: 2 // Two beats per cycle - like a heart rhythm + }, + duration: 1000, // 1 second for normal heart rate + easing: 'linear', + loop: 6 // Continue beating for a while + }); + }); + + // Highlight Attention Animation (realistic use case) + addCase('Highlight Element', btnContainer, stage => { + // Create background element + const background = createRect({ + x: 0, + y: 0, + width: 900, + height: 600, + fill: '#F0F2F5' + }); + stage.defaultLayer.add(background); + + // Create a group of elements to simulate a UI + const group = createGroup({ + x: 100, + y: 100 + }); + + // Create multiple shapes representing UI elements + for (let i = 0; i < 5; i++) { + const rect = createRect({ + x: i * 140, + y: 0, + width: 120, + height: 80, + fill: i === 2 ? '#1890FF' : '#FFFFFF', + stroke: '#D9D9D9', + lineWidth: 1, + cornerRadius: 4 + }); + group.add(rect); + + // Add some text + const text = createText({ + x: i * 140 + 60, + y: 40, + text: `Item ${i + 1}`, + fontSize: 16, + fill: i === 2 ? '#FFFFFF' : '#000000', + textAlign: 'center', + textBaseline: 'middle' + }); + group.add(text); + } + + stage.defaultLayer.add(group); + + // After a short delay, highlight the middle element with pulse animation + setTimeout(() => { + const targetElement = group.children[4]; // Element at index 2 (3rd element) + const executor = new AnimateExecutor(targetElement); + + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: false, + useScale: true, + useColor: true, + pulseScale: 1.08, + pulseColor: '#1890FF', + pulseColorIntensity: 0.2, + pulseCount: 5 + }, + duration: 2000, // 2 seconds for all 5 pulses + easing: 'quadInOut' + }); + }, 1000); + }); + + // Error State Pulse Animation + addCase('Error State Pulse', btnContainer, stage => { + // Create a form input field + const inputField = createRect({ + x: 300, + y: 200, + width: 300, + height: 40, + fill: '#FFFFFF', + stroke: '#D9D9D9', + lineWidth: 1, + cornerRadius: 4 + }); + + // Add label + const label = createText({ + x: 310, + y: 180, + text: 'Email', + fontSize: 14, + fill: '#000000', + textAlign: 'left', + textBaseline: 'middle' + }); + + // Add placeholder text + const placeholder = createText({ + x: 310, + y: 220, + text: 'example@invalid', + fontSize: 14, + fill: '#BFBFBF', + textAlign: 'left', + textBaseline: 'middle' + }); + + stage.defaultLayer.add(inputField); + stage.defaultLayer.add(label); + stage.defaultLayer.add(placeholder); + + // After a delay, simulate validation error with pulse animation + setTimeout(() => { + // Change border to red + inputField.setAttribute('stroke', '#FF4D4F'); + + // Add error message + const errorMessage = createText({ + x: 310, + y: 250, + text: 'Please enter a valid email address', + fontSize: 12, + fill: '#FF4D4F', + textAlign: 'left', + textBaseline: 'middle' + }); + stage.defaultLayer.add(errorMessage); + + // Pulse animation for the input field + const executor = new AnimateExecutor(inputField); + executor.execute({ + type: 'pulse', + customParameters: { + useOpacity: false, + useScale: false, + useColor: true, + pulseColor: '#FF7875', // Lighter red + pulseColorIntensity: 0.3, + strokeOnly: true, + pulseCount: 3 + }, + duration: 1200, // 1.2 seconds for all 3 pulses + easing: 'quadInOut' + }); + }, 1000); + }); +}; diff --git a/packages/vrender/__tests__/browser/vite.config.ts b/packages/vrender/__tests__/browser/vite.config.ts index 2d7ff256a..6bc1783fa 100644 --- a/packages/vrender/__tests__/browser/vite.config.ts +++ b/packages/vrender/__tests__/browser/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ '@visactor/vrender': path.resolve(__dirname, '../../../vrender/src/index.ts'), '@visactor/vrender-core': path.resolve(__dirname, '../../../vrender-core/src/index.ts'), '@visactor/vrender-kits': path.resolve(__dirname, '../../../vrender-kits/src/index.ts'), + '@visactor/vrender-animate': path.resolve(__dirname, '../../../vrender-animate/src/index.ts'), '@visactor/vrender-components': path.resolve(__dirname, '../../../vrender-components/src/index.ts'), util: 'rollup-plugin-node-polyfills/polyfills/util' } diff --git a/packages/vrender/__tests__/common/morphing-utils.test.ts b/packages/vrender/__tests__/common/morphing-utils.test.ts index d05c557be..77694abfb 100644 --- a/packages/vrender/__tests__/common/morphing-utils.test.ts +++ b/packages/vrender/__tests__/common/morphing-utils.test.ts @@ -7,7 +7,7 @@ it('pathToBezierCurves', () => { expect(pathToBezierCurves(path)).toEqual([[100, 100, 100, 100, 200, 200, 200, 200]]); path.fromString('L200,200C50,50,150,150,300,300'); - expect(pathToBezierCurves(path)).toEqual([[200, 200, 50, 50, 150, 150, 300, 300]]); + expect(pathToBezierCurves(path)).toEqual([[0, 0, 0, 0, 200, 200, 200, 200, 50, 50, 150, 150, 300, 300]]); }); it('alignSubpath empty paths', () => { diff --git a/packages/vrender/package.json b/packages/vrender/package.json index 837931a21..9a35901b6 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -25,14 +25,15 @@ }, "dependencies": { "@visactor/vrender-core": "workspace:0.22.11", - "@visactor/vrender-kits": "workspace:0.22.11" + "@visactor/vrender-kits": "workspace:0.22.11", + "@visactor/vrender-animate": "workspace:0.22.11" }, "devDependencies": { "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", "@internal/ts-config": "workspace:*", "@rushstack/eslint-patch": "~1.1.4", - "@visactor/vutils": "~0.19.5", + "@visactor/vutils": "1.0.4", "canvas": "2.11.2", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/packages/vrender/src/index.ts b/packages/vrender/src/index.ts index 1d770df83..a1449334c 100644 --- a/packages/vrender/src/index.ts +++ b/packages/vrender/src/index.ts @@ -70,3 +70,4 @@ registerDirectionalLight(); registerOrthoCamera(); export * from '@visactor/vrender-core'; export * from '@visactor/vrender-kits'; +export * from '@visactor/vrender-animate'; diff --git a/rush.json b/rush.json index 00fb58500..82285353f 100644 --- a/rush.json +++ b/rush.json @@ -40,6 +40,13 @@ "shouldPublish": true, "versionPolicyName": "vrenderMain" }, + { + "packageName": "@visactor/vrender-animate", + "projectFolder": "packages/vrender-animate", + "tags": ["package"], + "shouldPublish": true, + "versionPolicyName": "vrenderMain" + }, { "packageName": "@visactor/vrender-kits", "projectFolder": "packages/vrender-kits",