From d101f62d68576c59777976d02e7c09a6e592fbb5 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Fri, 24 Jul 2026 16:40:14 +0800 Subject: [PATCH 01/14] docs: design Lynx native RAF scheduling --- .../2026-07-24-lynx-native-raf-design.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-lynx-native-raf-design.md diff --git a/docs/superpowers/specs/2026-07-24-lynx-native-raf-design.md b/docs/superpowers/specs/2026-07-24-lynx-native-raf-design.md new file mode 100644 index 000000000..6eec7835e --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-lynx-native-raf-design.md @@ -0,0 +1,75 @@ +# Lynx Native RAF Design + +## Context + +`LynxEnvContribution` currently implements VRender's animation-frame API with +`rafBasedSto`, which is backed by `setTimeout`. Modern Lynx runtimes expose +`requestAnimationFrame` and `cancelAnimationFrame`, so the current default does +not align animation ticks with the host's VSYNC. + +## Goal + +Use the Lynx runtime's native animation-frame scheduler by default while +preserving compatibility with hosts that do not expose the complete native +scheduler pair. + +## Non-goals + +- Change ticker or timeline semantics in `@visactor/vrender-animate`. +- Change animation scheduling for browser, Node, Harmony, Feishu, TT, WX, or + Taro environments. +- Add a new public scheduler option to Lynx environment parameters. + +## Design + +Extend the internal `LynxRuntime` type with `requestAnimationFrame` and +`cancelAnimationFrame`. + +`LynxEnvContribution.configure()` selects the scheduler once: + +- If both native methods are functions, bind both methods to the selected Lynx + runtime and cache them. +- If either method is unavailable, cache the existing `rafBasedSto` request and + cancel functions as a pair. + +`getRequestAnimationFrame()` and `getCancelAnimationFrame()` return the cached +functions without repeated capability checks or wrapper allocation in the +animation scheduling path. + +Treating request and cancel as an atomic pair prevents a native RAF handle from +being passed to `clearTimeout`, or a timeout handle from being passed to the +native Lynx cancellation API. + +## Compatibility and lifecycle + +The existing `rafBasedSto` behavior remains the fallback, so older or restricted +Lynx hosts retain their current animation behavior. Scheduler selection follows +the existing environment lifecycle: a later `configure()` call re-evaluates the +runtime and replaces the cached pair. + +Native methods are bound to the runtime object because a host implementation may +depend on its receiver. + +## Tests + +Add focused unit coverage in the existing Lynx environment test file: + +- A complete native scheduler pair is preferred, returns the host handle, calls + both host methods with the Lynx runtime as `this`, and forwards the callback. +- A runtime with an incomplete native scheduler pair uses `rafBasedSto` for both + request and cancellation. + +The new native-scheduler test must fail before the implementation is added. + +## Validation + +Run: + +- The focused Lynx environment unit test. +- The full `@visactor/vrender-kits` unit test suite. +- `rushx compile` in `packages/vrender-kits`. +- ESLint for the changed source and test files without automatic fixes. + +No benchmark is required because the change removes timer adaptation from the +default Lynx path and performs capability selection only during environment +configuration, not per animation frame. From f2494b2fa0c9f82fca728c8a459aa1bdb9b3c26c Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Fri, 24 Jul 2026 17:12:29 +0800 Subject: [PATCH 02/14] docs: plan Lynx native RAF implementation --- .../plans/2026-07-24-lynx-native-raf.md | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-lynx-native-raf.md diff --git a/docs/superpowers/plans/2026-07-24-lynx-native-raf.md b/docs/superpowers/plans/2026-07-24-lynx-native-raf.md new file mode 100644 index 000000000..f0351f04b --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-lynx-native-raf.md @@ -0,0 +1,199 @@ +# Lynx Native RAF Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the Lynx environment prefer the host's native animation-frame scheduler while preserving the existing timeout-backed fallback. + +**Architecture:** Select and bind one request/cancel scheduler pair during `LynxEnvContribution.configure()`. The animation hot path reads stable cached functions, and incomplete native capability falls back atomically to `rafBasedSto`. + +**Tech Stack:** TypeScript 4.9, Jest 26, Rush + +## Global Constraints + +- Native `requestAnimationFrame` and `cancelAnimationFrame` must be used only when both are functions. +- Native methods must retain the Lynx runtime as their `this` receiver. +- Missing or incomplete native capability must preserve the existing `rafBasedSto` behavior. +- Do not change ticker semantics or non-Lynx environments. +- Capability selection must stay out of the per-frame animation path. + +--- + +### Task 1: Select the Lynx animation-frame scheduler pair + +**Files:** +- Modify: `packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts` +- Modify: `packages/vrender-kits/src/env/contributions/lynx-contribution.ts` + +**Interfaces:** +- Consumes: `LynxEnvContribution.configure(service, params)`, `rafBasedSto.call(callback)`, and `rafBasedSto.clear(handle)`. +- Produces: `getRequestAnimationFrame(): (callback: FrameRequestCallback) => number` and `getCancelAnimationFrame(): (handle: number) => void`, backed by one scheduler pair selected during configuration. + +- [ ] **Step 1: Add focused scheduler tests** + +Add tests inside the existing `describe('lynx window event contribution', ...)` block: + +```ts +test('uses the complete native lynx animation frame scheduler pair', () => { + const env = new LynxEnvContribution(); + const service = { + env: 'lynx', + setActiveEnvContribution: jest.fn() + }; + let requestReceiver: unknown; + let cancelReceiver: unknown; + let scheduledCallback: FrameRequestCallback; + let cancelledHandle: number; + const runtime = { + requestAnimationFrame(this: unknown, callback: FrameRequestCallback) { + requestReceiver = this; + scheduledCallback = callback; + return 17; + }, + cancelAnimationFrame(this: unknown, handle: number) { + cancelReceiver = this; + cancelledHandle = handle; + } + }; + const callback = jest.fn(); + + env.configure(service as any, { lynx: runtime }); + + expect(env.getRequestAnimationFrame()(callback)).toBe(17); + env.getCancelAnimationFrame()(17); + + expect(requestReceiver).toBe(runtime); + expect(cancelReceiver).toBe(runtime); + expect(scheduledCallback).toBe(callback); + expect(cancelledHandle).toBe(17); +}); + +test('falls back as a pair when the native lynx scheduler is incomplete', () => { + jest.useFakeTimers(); + try { + const env = new LynxEnvContribution(); + const service = { + env: 'lynx', + setActiveEnvContribution: jest.fn() + }; + const nativeRequest = jest.fn(() => 17); + const callback = jest.fn(); + + env.configure(service as any, { + lynx: { + requestAnimationFrame: nativeRequest + } + }); + + const handle = env.getRequestAnimationFrame()(callback); + env.getCancelAnimationFrame()(handle); + jest.runOnlyPendingTimers(); + + expect(nativeRequest).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd packages/vrender-kits +rushx test -- --runInBand __tests__/unit/lynx-window-event.test.ts +``` + +Expected: the native scheduler test fails because `getRequestAnimationFrame()` still schedules through `rafBasedSto`; the incomplete-scheduler fallback test passes. + +- [ ] **Step 3: Implement scheduler selection** + +Extend `LynxRuntime`: + +```ts +requestAnimationFrame: (callback: FrameRequestCallback) => number; +cancelAnimationFrame: (handle: number) => void; +``` + +Add stable fallback functions near the Lynx runtime helpers: + +```ts +const requestAnimationFrameBasedSTO = (callback: FrameRequestCallback): number => rafBasedSto.call(callback); +const cancelAnimationFrameBasedSTO = (handle: number): void => rafBasedSto.clear(handle); +``` + +Cache them on `LynxEnvContribution`: + +```ts +private requestAnimationFrame = requestAnimationFrameBasedSTO; +private cancelAnimationFrame = cancelAnimationFrameBasedSTO; +``` + +After resolving `this.lynxRuntime` in `configure()`, atomically select the pair: + +```ts +if ( + typeof this.lynxRuntime?.requestAnimationFrame === 'function' && + typeof this.lynxRuntime?.cancelAnimationFrame === 'function' +) { + this.requestAnimationFrame = this.lynxRuntime.requestAnimationFrame.bind(this.lynxRuntime); + this.cancelAnimationFrame = this.lynxRuntime.cancelAnimationFrame.bind(this.lynxRuntime); +} else { + this.requestAnimationFrame = requestAnimationFrameBasedSTO; + this.cancelAnimationFrame = cancelAnimationFrameBasedSTO; +} +``` + +Replace the legacy copied miniapp implementation: + +```ts +getRequestAnimationFrame(): (callback: FrameRequestCallback) => number { + return this.requestAnimationFrame; +} + +getCancelAnimationFrame(): (handle: number) => void { + return this.cancelAnimationFrame; +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: + +```bash +cd packages/vrender-kits +rushx test -- --runInBand __tests__/unit/lynx-window-event.test.ts +``` + +Expected: all tests in `lynx-window-event.test.ts` pass with no warnings or open handles. + +- [ ] **Step 5: Run impacted-package and entry validation** + +Run: + +```bash +cd packages/vrender-kits +rushx test -- --runInBand +rushx compile +./node_modules/.bin/eslint src/env/contributions/lynx-contribution.ts __tests__/unit/lynx-window-event.test.ts +cd ../vrender +rushx test -- --runInBand __tests__/unit/entries.test.ts __tests__/unit/shared-app.test.ts +``` + +Expected: every command exits with status 0. + +- [ ] **Step 6: Review and commit the implementation** + +Run: + +```bash +git diff --check +git diff -- packages/vrender-kits/src/env/contributions/lynx-contribution.ts packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts +git status --short +git add packages/vrender-kits/src/env/contributions/lynx-contribution.ts packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts +git diff --cached --check +git commit -m "fix(lynx): prefer native animation frames" +``` + +Expected: the commit contains only the Lynx scheduler implementation and its tests. From 8424a94c82a7188fb05f249189457e0ba1832592 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Fri, 24 Jul 2026 17:13:10 +0800 Subject: [PATCH 03/14] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d654e3001..cabe68bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ docs/public/documents spec-types *.tsbuildinfo .omx/ +.worktrees/ From de1dd3f1681499a70e04ad1d1e7f3132dd2c56b2 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Fri, 24 Jul 2026 17:26:41 +0800 Subject: [PATCH 04/14] fix(lynx): prefer native animation frames --- .../__tests__/unit/lynx-window-event.test.ts | 78 +++++++++++++++++++ .../env/contributions/lynx-contribution.ts | 40 ++++++---- 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts b/packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts index 5c420f312..66f382946 100644 --- a/packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts +++ b/packages/vrender-kits/__tests__/unit/lynx-window-event.test.ts @@ -90,6 +90,84 @@ describe('lynx window event contribution', () => { }); }); + test('uses the complete native lynx animation frame scheduler pair', () => { + const env = new LynxEnvContribution(); + const service = { + env: 'lynx', + setActiveEnvContribution: (): void => undefined + }; + let requestReceiver: unknown; + let cancelReceiver: unknown; + let scheduledCallback: FrameRequestCallback; + let cancelledHandle: number; + const runtime = { + requestAnimationFrame(this: unknown, callback: FrameRequestCallback) { + requestReceiver = this; + scheduledCallback = callback; + return 17; + }, + cancelAnimationFrame(this: unknown, handle: number) { + cancelReceiver = this; + cancelledHandle = handle; + } + }; + const callback = (): void => undefined; + + env.configure(service as any, { lynx: runtime as any }); + + expect(env.getRequestAnimationFrame()(callback)).toBe(17); + env.getCancelAnimationFrame()(17); + + expect(requestReceiver).toBe(runtime); + expect(cancelReceiver).toBe(runtime); + expect(scheduledCallback).toBe(callback); + expect(cancelledHandle).toBe(17); + }); + + test('falls back as a pair when the native lynx scheduler is incomplete', () => { + jest.useFakeTimers(); + try { + const env = new LynxEnvContribution(); + const service = { + env: 'lynx', + setActiveEnvContribution: (): void => undefined + }; + let completeNativeRequestCallCount = 0; + let nativeRequestCallCount = 0; + let callbackCalled = false; + + env.configure(service as any, { + lynx: { + requestAnimationFrame: () => { + completeNativeRequestCallCount++; + return 23; + }, + cancelAnimationFrame: (): void => undefined + } as any + }); + env.configure(service as any, { + lynx: { + requestAnimationFrame: () => { + nativeRequestCallCount++; + return 17; + } + } as any + }); + + const handle = env.getRequestAnimationFrame()(() => { + callbackCalled = true; + }); + env.getCancelAnimationFrame()(handle); + jest.runOnlyPendingTimers(); + + expect(completeNativeRequestCallCount).toBe(0); + expect(nativeRequestCallCount).toBe(0); + expect(callbackCalled).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + test('reports missing lynx canvas bridge with a clear error', () => { const env = new LynxEnvContribution(); const service = { diff --git a/packages/vrender-kits/src/env/contributions/lynx-contribution.ts b/packages/vrender-kits/src/env/contributions/lynx-contribution.ts index 733305842..ca6f2b752 100644 --- a/packages/vrender-kits/src/env/contributions/lynx-contribution.ts +++ b/packages/vrender-kits/src/env/contributions/lynx-contribution.ts @@ -39,6 +39,8 @@ type LynxRuntime = Partial<{ createCanvasNG: (id?: string) => any; createImage: (id: string) => any; createOffscreenCanvas: () => any; + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; krypton: LynxKryptonRuntime; }>; @@ -128,6 +130,9 @@ function getLynxRuntime(params?: LynxEnvParams): LynxRuntime | undefined { return params?.lynx ?? params?.runtime ?? getGlobalLynxRuntime(); } +const requestAnimationFrameBasedSTO = (callback: FrameRequestCallback): number => rafBasedSto.call(callback); +const cancelAnimationFrameBasedSTO = (handle: number): void => rafBasedSto.clear(handle); + function getLynxPixelRatio(params?: LynxEnvParams, runtime?: LynxRuntime): number { return params?.pixelRatio ?? runtime?.getSystemInfoSync?.()?.pixelRatio ?? getGlobalSystemPixelRatio() ?? 1; } @@ -257,6 +262,8 @@ export class LynxEnvContribution extends BaseEnvContribution implements IEnvCont canvasIdx: number = 0; private lynxRuntime?: LynxRuntime; private lynxEnvParams?: LynxEnvParams; + private requestAnimationFrame = requestAnimationFrameBasedSTO; + private cancelAnimationFrame = cancelAnimationFrameBasedSTO; constructor() { super(); @@ -279,6 +286,17 @@ export class LynxEnvContribution extends BaseEnvContribution implements IEnvCont this.lynxEnvParams = params; this.lynxRuntime = getLynxRuntime(params); + if ( + typeof this.lynxRuntime?.requestAnimationFrame === 'function' && + typeof this.lynxRuntime?.cancelAnimationFrame === 'function' + ) { + this.requestAnimationFrame = this.lynxRuntime.requestAnimationFrame.bind(this.lynxRuntime); + this.cancelAnimationFrame = this.lynxRuntime.cancelAnimationFrame.bind(this.lynxRuntime); + } else { + this.requestAnimationFrame = requestAnimationFrameBasedSTO; + this.cancelAnimationFrame = cancelAnimationFrameBasedSTO; + } + // loadFeishuContributions(); } } @@ -375,23 +393,11 @@ export class LynxEnvContribution extends BaseEnvContribution implements IEnvCont } getRequestAnimationFrame(): (callback: FrameRequestCallback) => number { - // return requestAnimationFrame; - - // 飞书小组件,在云文档浏览器环境中,没有requestAnimationFrame - // 但是在小组件工作台环境和模拟器中正常 - // 反馈飞书修改,目前先使用setTimeout模拟,进行测试,飞书修复后替换回requestAnimationFrame - // return function (callback: FrameRequestCallback) { - // return setTimeout(callback, 1000 / 60, true); - // } as any; - return function (callback: FrameRequestCallback) { - return rafBasedSto.call(callback); - } as any; - } - - getCancelAnimationFrame(): (h: number) => void { - return (h: number) => { - rafBasedSto.clear(h); - }; + return this.requestAnimationFrame; + } + + getCancelAnimationFrame(): (handle: number) => void { + return this.cancelAnimationFrame; } mapToCanvasPoint(event: any) { From 4458b0da1b51861786f757678141521cddd41ddd Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 11:04:53 +0800 Subject: [PATCH 05/14] fix(core): clear globalZIndex synchronously on state exit --- .../unit/graphic/state-animation.test.ts | 17 +++++++++++++++++ packages/vrender-core/src/graphic/graphic.ts | 9 +++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 28c628d1a..d6dabc874 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -279,6 +279,23 @@ describe('Graphic state animation integration', () => { ); }); + test('should synchronously remove a no-work state attribute when clearing states', () => { + const graphic = createGraphic(); + graphic.states = { + hover: { + globalZIndex: 400 + } + } as any; + + graphic.useStates(['hover'], true); + + expect(graphic.attribute.globalZIndex).toBe(400); + + graphic.clearStates(true); + + expect(graphic.attribute.globalZIndex).toBeUndefined(); + }); + test('should allow explicit animateConfig to override graphic and context defaults in applyStateAttrs', () => { const graphic = createGraphic(); graphic.stateAnimateConfig = { diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index f7456939c..7e22b6be7 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -755,19 +755,16 @@ abstract class GraphicImpl = Partial)[key]); return; } if (Object.prototype.hasOwnProperty.call(snapshot, key)) { - const snapshotValue = snapshot[key]; - assignFallbackAttr( - snapshotValue === undefined ? this.getStateTransitionDefaultAttribute(key, staticTargetAttrs) : snapshotValue - ); + assignFallbackAttr(snapshot[key]); return; } - assignFallbackAttr(this.getStateTransitionDefaultAttribute(key, staticTargetAttrs)); + assignFallbackAttr(undefined); }); return extraAttrs as Partial; From d9b9687fae0fdc9e7dc4a217c1c11660905b2658 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 14:44:20 +0800 Subject: [PATCH 06/14] fix(core): preserve state exit transition defaults --- .../unit/graphic/state-animation.test.ts | 55 +++++++++++++++++++ .../state-transition-orchestrator.test.ts | 14 +++++ packages/vrender-core/src/graphic/graphic.ts | 6 +- .../state/state-transition-orchestrator.ts | 19 ++++++- 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index d6dabc874..1d1fd9247 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -296,6 +296,61 @@ describe('Graphic state animation integration', () => { expect(graphic.attribute.globalZIndex).toBeUndefined(); }); + test('should animate a removed rect alias back to its derived static value when clearing states', () => { + const graphic = createGraphic(); + graphic.states = { + hover: { + x1: 40 + } + } as any; + const applyAnimationState = jest.fn(); + (graphic as any).applyAnimationState = applyAnimationState; + + graphic.useStates(['hover'], true); + graphic.clearStates(true); + + expect(applyAnimationState).toHaveBeenLastCalledWith( + ['state'], + [ + { + name: 'state', + animation: expect.objectContaining({ + to: expect.objectContaining({ x1: 10 }) + }) + } + ] + ); + }); + + test('should animate a removed rect alias back to its derived static value when changing states', () => { + const graphic = createGraphic(); + graphic.states = { + hover: { + x1: 40 + }, + selected: { + fill: 'green' + } + } as any; + const applyAnimationState = jest.fn(); + (graphic as any).applyAnimationState = applyAnimationState; + + graphic.useStates(['hover'], true); + graphic.useStates(['selected'], true); + + expect(applyAnimationState).toHaveBeenLastCalledWith( + ['state'], + [ + { + name: 'state', + animation: expect.objectContaining({ + to: expect.objectContaining({ x1: 10 }) + }) + } + ] + ); + }); + test('should allow explicit animateConfig to override graphic and context defaults in applyStateAttrs', () => { const graphic = createGraphic(); graphic.stateAnimateConfig = { diff --git a/packages/vrender-core/__tests__/unit/graphic/state-transition-orchestrator.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-transition-orchestrator.test.ts index fd8224b95..7924c46a2 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-transition-orchestrator.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-transition-orchestrator.test.ts @@ -426,6 +426,20 @@ describe('StateTransitionOrchestrator', () => { expect(plan.targetAttrs).not.toHaveProperty('fillOpacity'); }); + test('should retain an undefined removed attribute when no default resolver is provided', () => { + const orchestrator = new StateTransitionOrchestrator(); + + const plan = orchestrator.analyzeTransition({}, true, { + extraAnimateAttrs: { + fillOpacity: undefined + } + }); + + expect(plan.animateAttrs).toEqual({ + fillOpacity: undefined + }); + }); + test('should allow extra animation attrs to replace an undefined clear target transiently', () => { const orchestrator = new StateTransitionOrchestrator(); diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index 7e22b6be7..f4d1fd7ab 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -740,7 +740,6 @@ abstract class GraphicImpl = Partial; - const staticTargetAttrs = snapshot as Partial; Object.keys(previousResolvedStatePatch).forEach(key => { const hasTargetAttr = Object.prototype.hasOwnProperty.call(targetStateAttrs, key); if (hasTargetAttr && (targetStateAttrs as Record)[key] !== undefined) { @@ -748,9 +747,6 @@ abstract class GraphicImpl = Partial { - if (value === undefined && this.shouldSkipStateTransitionDefaultAttribute(key, staticTargetAttrs)) { - return; - } extraAttrs[key] = value === undefined ? value : cloneAttributeValue(value); }; @@ -2314,6 +2310,7 @@ abstract class GraphicImpl = Partial, + getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this), shouldSkipDefaultAttribute: this.shouldSkipStateTransitionDefaultAttribute.bind(this) as ( key: string, targetAttrs: Record @@ -2330,6 +2327,7 @@ abstract class GraphicImpl = Partial, + getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this), shouldSkipDefaultAttribute: this.shouldSkipStateTransitionDefaultAttribute.bind(this) as ( key: string, targetAttrs: Record diff --git a/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts b/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts index 634d52b61..21b0c5e4e 100644 --- a/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts +++ b/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts @@ -12,6 +12,7 @@ export interface IStateTransitionAnalysisOptions { noWorkAnimateAttr?: Record; isClear?: boolean; getDefaultAttribute?: (key: string) => unknown; + getStateTransitionDefaultAttribute?: (key: string) => unknown; shouldSkipDefaultAttribute?: (key: string, targetAttrs: Record) => boolean; animateConfig?: IAnimateConfig; extraAnimateAttrs?: Record; @@ -20,6 +21,7 @@ export interface IStateTransitionAnalysisOptions { export interface IStateTransitionApplyOptions { animateConfig?: IAnimateConfig; extraAnimateAttrs?: Record; + getStateTransitionDefaultAttribute?: (key: string) => unknown; shouldSkipDefaultAttribute?: (key: string, targetAttrs: Record) => boolean; } @@ -107,14 +109,26 @@ export class StateTransitionOrchestrator = Record< const isClear = options.isClear === true; const getDefaultAttribute = options.getDefaultAttribute; const readDefaultAttribute = getDefaultAttribute as (key: string) => unknown; + const readStateTransitionDefaultAttribute = (options.getStateTransitionDefaultAttribute ?? getDefaultAttribute) as + | ((key: string) => unknown) + | undefined; const shouldSkipDefaultAttribute = options.shouldSkipDefaultAttribute; - const assignTransitionAttr = (key: string, value: any): void => { + const assignTransitionAttr = (key: string, value: any, isRemovedStateAttr: boolean = false): void => { if (noWorkAnimateAttr[key]) { (plan.noAnimateAttrs as Record)[key] = value; return; } + if (isRemovedStateAttr && value === undefined) { + const defaultValue = readStateTransitionDefaultAttribute?.(key); + if (defaultValue === undefined && shouldSkipDefaultAttribute?.(key, targetAttrs as Record)) { + return; + } + (plan.animateAttrs as Record)[key] = defaultValue; + return; + } + if (isClear && value === undefined) { if (shouldSkipDefaultAttribute?.(key, targetAttrs as Record)) { return; @@ -145,7 +159,7 @@ export class StateTransitionOrchestrator = Record< ) { continue; } - assignTransitionAttr(key, (extraAnimateAttrs as Record)[key]); + assignTransitionAttr(key, (extraAnimateAttrs as Record)[key], true); } } @@ -202,6 +216,7 @@ export class StateTransitionOrchestrator = Record< noWorkAnimateAttr: graphic.getNoWorkAnimateAttr(), isClear: true, getDefaultAttribute: graphic.getDefaultAttribute.bind(graphic), + getStateTransitionDefaultAttribute: options.getStateTransitionDefaultAttribute, shouldSkipDefaultAttribute: options.shouldSkipDefaultAttribute ?? graphic.shouldSkipStateTransitionDefaultAttribute.bind(graphic), animateConfig: options.animateConfig, From b06277a7045c1d23009af86a4d4c99a99d55d5f1 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 15:23:54 +0800 Subject: [PATCH 07/14] fix(core): preserve target state attrs on exit --- .../unit/graphic/state-animation.test.ts | 13 +++-- packages/vrender-core/src/graphic/graphic.ts | 52 ++++++++++++++----- .../state/state-transition-orchestrator.ts | 16 +++--- 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 1d1fd9247..5997c0a72 100644 --- a/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts @@ -322,14 +322,19 @@ describe('Graphic state animation integration', () => { ); }); - test('should animate a removed rect alias back to its derived static value when changing states', () => { + test('should derive a removed rect alias from the target static state when changing states', () => { const graphic = createGraphic(); + delete (graphic as any).baseAttributes.width; + delete (graphic.attribute as any).width; + (graphic as any).baseAttributes.x1 = 10; + (graphic.attribute as any).x1 = 10; graphic.states = { hover: { - x1: 40 + width: 40 }, selected: { - fill: 'green' + x: 5, + x1: 30 } } as any; const applyAnimationState = jest.fn(); @@ -344,7 +349,7 @@ describe('Graphic state animation integration', () => { { name: 'state', animation: expect.objectContaining({ - to: expect.objectContaining({ x1: 10 }) + to: expect.objectContaining({ width: 25 }) }) } ] diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index f4d1fd7ab..dc34cb200 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -732,14 +732,13 @@ abstract class GraphicImpl = Partial, previousResolvedStatePatch?: Partial - ): Partial { - const extraAttrs: Record = {}; - + ): { extraAttrs: Partial; stateTransitionTargetAttrs: Partial } | undefined { if (!previousResolvedStatePatch) { - return extraAttrs as Partial; + return; } const snapshot = this.buildStaticAttributeSnapshot() as Record; + const extraAttrs: Record = {}; Object.keys(previousResolvedStatePatch).forEach(key => { const hasTargetAttr = Object.prototype.hasOwnProperty.call(targetStateAttrs, key); if (hasTargetAttr && (targetStateAttrs as Record)[key] !== undefined) { @@ -763,7 +762,10 @@ abstract class GraphicImpl = Partial; + return { + extraAttrs: extraAttrs as Partial, + stateTransitionTargetAttrs: snapshot as Partial + }; } protected syncObjectToSnapshot( @@ -2272,13 +2274,18 @@ abstract class GraphicImpl = Partial = Partial + extraAnimateAttrs?: Partial, + stateTransitionTargetAttrs?: Partial ) { const resolvedAnimateConfig = hasAnimation ? this.resolveStateAnimateConfig(animateConfig) : undefined; const transitionOptions = resolvedAnimateConfig ? { animateConfig: resolvedAnimateConfig, extraAnimateAttrs: extraAnimateAttrs as Record, - getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this), + getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this) as ( + key: string, + targetAttrs?: Record + ) => unknown, shouldSkipDefaultAttribute: this.shouldSkipStateTransitionDefaultAttribute.bind(this) as ( key: string, targetAttrs: Record - ) => boolean + ) => boolean, + stateTransitionTargetAttrs: stateTransitionTargetAttrs as Record } : undefined; @@ -2327,11 +2339,15 @@ abstract class GraphicImpl = Partial, - getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this), + getStateTransitionDefaultAttribute: this.getStateTransitionDefaultAttribute.bind(this) as ( + key: string, + targetAttrs?: Record + ) => unknown, shouldSkipDefaultAttribute: this.shouldSkipStateTransitionDefaultAttribute.bind(this) as ( key: string, targetAttrs: Record - ) => boolean + ) => boolean, + stateTransitionTargetAttrs: stateTransitionTargetAttrs as Record }); this.getStateTransitionOrchestrator().applyTransition(this as any, plan, hasAnimation, transitionOptions); @@ -2399,13 +2415,18 @@ abstract class GraphicImpl = Partial = Partial; isClear?: boolean; getDefaultAttribute?: (key: string) => unknown; - getStateTransitionDefaultAttribute?: (key: string) => unknown; + getStateTransitionDefaultAttribute?: (key: string, targetAttrs?: Record) => unknown; shouldSkipDefaultAttribute?: (key: string, targetAttrs: Record) => boolean; animateConfig?: IAnimateConfig; extraAnimateAttrs?: Record; + stateTransitionTargetAttrs?: Record; } export interface IStateTransitionApplyOptions { animateConfig?: IAnimateConfig; extraAnimateAttrs?: Record; - getStateTransitionDefaultAttribute?: (key: string) => unknown; + getStateTransitionDefaultAttribute?: (key: string, targetAttrs?: Record) => unknown; shouldSkipDefaultAttribute?: (key: string, targetAttrs: Record) => boolean; + stateTransitionTargetAttrs?: Record; } export interface IStateTransitionGraphic { @@ -110,9 +112,10 @@ export class StateTransitionOrchestrator = Record< const getDefaultAttribute = options.getDefaultAttribute; const readDefaultAttribute = getDefaultAttribute as (key: string) => unknown; const readStateTransitionDefaultAttribute = (options.getStateTransitionDefaultAttribute ?? getDefaultAttribute) as - | ((key: string) => unknown) + | ((key: string, targetAttrs?: Record) => unknown) | undefined; const shouldSkipDefaultAttribute = options.shouldSkipDefaultAttribute; + const stateTransitionTargetAttrs = options.stateTransitionTargetAttrs ?? (targetAttrs as Record); const assignTransitionAttr = (key: string, value: any, isRemovedStateAttr: boolean = false): void => { if (noWorkAnimateAttr[key]) { @@ -121,8 +124,8 @@ export class StateTransitionOrchestrator = Record< } if (isRemovedStateAttr && value === undefined) { - const defaultValue = readStateTransitionDefaultAttribute?.(key); - if (defaultValue === undefined && shouldSkipDefaultAttribute?.(key, targetAttrs as Record)) { + const defaultValue = readStateTransitionDefaultAttribute?.(key, stateTransitionTargetAttrs); + if (defaultValue === undefined && shouldSkipDefaultAttribute?.(key, stateTransitionTargetAttrs)) { return; } (plan.animateAttrs as Record)[key] = defaultValue; @@ -220,7 +223,8 @@ export class StateTransitionOrchestrator = Record< shouldSkipDefaultAttribute: options.shouldSkipDefaultAttribute ?? graphic.shouldSkipStateTransitionDefaultAttribute.bind(graphic), animateConfig: options.animateConfig, - extraAnimateAttrs: options.extraAnimateAttrs + extraAnimateAttrs: options.extraAnimateAttrs, + stateTransitionTargetAttrs: options.stateTransitionTargetAttrs }); return this.applyTransition(graphic, plan, hasAnimation, options); From f911862b5b4ecd91751141b4863dc918c73ef4c0 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 19:08:27 +0800 Subject: [PATCH 08/14] test(core): cover negative rect outer border paths --- .../unit/render/rect-split-stroke.test.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts index 262b8131e..b95cec268 100644 --- a/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts +++ b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts @@ -1,4 +1,5 @@ import { + DefaultRectRenderContribution, SplitRectAfterRenderContribution, SplitRectBeforeRenderContribution } from '../../../src/render/contributions/render/contributions/rect-contribution-render'; @@ -71,4 +72,113 @@ describe('rect split stroke contribution', () => { expect(context.lineTo).toHaveBeenCalledWith(10, 60); expect(context.stroke).toHaveBeenCalledTimes(1); }); + + test('emits the same outer-border path for positive and negative rect widths', () => { + const contribution = new DefaultRectRenderContribution(); + const positiveContext = { beginPath: jest.fn(), rect: jest.fn() }; + const negativeContext = { beginPath: jest.fn(), rect: jest.fn() }; + const rectAttribute = { + x: 92, + y: 20, + opacity: 1, + scaleX: 1, + scaleY: 1, + keepStrokeScale: true, + cornerRadius: 0, + cornerType: 'round', + outerBorder: { distance: 6 }, + innerBorder: { distance: 0 } + }; + + contribution.drawShape( + { attribute: { x: 92, y: 20, width: 8, height: 10, outerBorder: { distance: 6 } } } as any, + positiveContext as any, + 92, + 20, + true, + true, + true, + true, + rectAttribute as any, + {} as any + ); + contribution.drawShape( + { attribute: { x: 100, y: 20, x1: 92, y1: 30, outerBorder: { distance: 6 } } } as any, + negativeContext as any, + 100, + 20, + true, + true, + true, + true, + { ...rectAttribute, x: 100 } as any, + {} as any + ); + + expect(positiveContext.rect).toHaveBeenCalledWith(86, 14, 20, 22); + expect(negativeContext.rect).toHaveBeenCalledWith(86, 14, 20, 22); + }); + + test('extends a negative-width outer-border path across the physical bounds', () => { + const contribution = new DefaultRectRenderContribution(); + const context = { beginPath: jest.fn(), rect: jest.fn() }; + + contribution.drawShape( + { attribute: { x: 100, y: 20, x1: 92, y1: 30, outerBorder: { distance: 6 } } } as any, + context as any, + 100, + 20, + true, + true, + true, + true, + { + x: 100, + y: 20, + opacity: 1, + scaleX: 1, + scaleY: 1, + keepStrokeScale: true, + cornerRadius: 0, + cornerType: 'round', + outerBorder: { distance: 6 }, + innerBorder: { distance: 0 } + } as any, + {} as any + ); + + const [x, , width] = context.rect.mock.calls[0]; + expect([x, x + width]).toEqual([86, 106]); + }); + + test('preserves the non-degenerate positive inner-border path', () => { + const contribution = new DefaultRectRenderContribution(); + const context = { beginPath: jest.fn(), rect: jest.fn() }; + + contribution.drawShape( + { attribute: { x: 92, y: 20, width: 8, height: 10, innerBorder: { distance: 2 } } } as any, + context as any, + 92, + 20, + true, + true, + true, + true, + { + x: 92, + y: 20, + opacity: 1, + scaleX: 1, + scaleY: 1, + keepStrokeScale: true, + cornerRadius: 0, + cornerType: 'round', + outerBorder: { distance: 0 }, + innerBorder: { distance: 2 } + } as any, + {} as any + ); + + expect(context.rect).toHaveBeenCalledWith(94, 22, 4, 6); + }); }); From a046fa43c37d60ed4d0628b6e8258687315463f0 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 19:10:27 +0800 Subject: [PATCH 09/14] fix(core): normalize signed rect border bounds --- .../contributions/rect-contribution-render.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts index e6924ca9d..1a8298148 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts @@ -70,6 +70,10 @@ export class DefaultRectRenderContribution implements IRectRenderContribution { width = (width ?? x1 - x) || 0; height = (height ?? y1 - y) || 0; + const borderX = width < 0 ? x + width : x; + const borderY = height < 0 ? y + height : y; + const borderWidth = width < 0 ? -width : width; + const borderHeight = height < 0 ? -height : height; const renderBorder = (borderStyle: Partial, key: 'outerBorder' | 'innerBorder') => { const doStroke = !!(borderStyle && borderStyle.stroke); @@ -77,13 +81,13 @@ export class DefaultRectRenderContribution implements IRectRenderContribution { const sign = key === 'outerBorder' ? -1 : 1; const { distance = rectAttribute[key].distance } = borderStyle; const d = keepStrokeScale ? (distance as number) : getScaledStroke(context, distance as number, context.dpr); - const nextX = x + sign * d; - const nextY = y + sign * d; + const nextX = borderX + sign * d; + const nextY = borderY + sign * d; const dw = d * 2; if (cornerRadius === 0 || (isArray(cornerRadius) && (cornerRadius).every(num => num === 0))) { // 不需要处理圆角 context.beginPath(); - context.rect(nextX, nextY, width - sign * dw, height - sign * dw); + context.rect(nextX, nextY, borderWidth - sign * dw, borderHeight - sign * dw); } else { context.beginPath(); @@ -92,8 +96,8 @@ export class DefaultRectRenderContribution implements IRectRenderContribution { context, nextX, nextY, - width - sign * dw, - height - sign * dw, + borderWidth - sign * dw, + borderHeight - sign * dw, cornerRadius, cornerType !== 'bevel' ); From 187decdb829762f7179045518fc4c7a10c8924e8 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Tue, 28 Jul 2026 19:18:38 +0800 Subject: [PATCH 10/14] fix(core): resolve rect border fallback in attribute space --- .../unit/render/rect-split-stroke.test.ts | 40 +++++++++++++++++++ .../contributions/rect-contribution-render.ts | 4 +- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts index b95cec268..46194ff56 100644 --- a/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts +++ b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts @@ -151,6 +151,46 @@ describe('rect split stroke contribution', () => { expect([x, x + width]).toEqual([86, 106]); }); + test('resolves x1 width in attribute space when the draw origin includes dx', () => { + const contribution = new DefaultRectRenderContribution(); + const context = { beginPath: jest.fn(), rect: jest.fn() }; + + contribution.drawShape( + { + attribute: { + x: 100, + x1: 92, + y: 20, + height: 10, + dx: 10, + outerBorder: { distance: 6 } + } + } as any, + context as any, + 110, + 20, + true, + true, + true, + true, + { + x: 100, + y: 20, + opacity: 1, + scaleX: 1, + scaleY: 1, + keepStrokeScale: true, + cornerRadius: 0, + cornerType: 'round', + outerBorder: { distance: 6 }, + innerBorder: { distance: 0 } + } as any, + {} as any + ); + + expect(context.rect).toHaveBeenCalledWith(96, 14, 20, 22); + }); + test('preserves the non-degenerate positive inner-border path', () => { const contribution = new DefaultRectRenderContribution(); const context = { beginPath: jest.fn(), rect: jest.fn() }; diff --git a/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts index 1a8298148..48cbd1f44 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/rect-contribution-render.ts @@ -68,8 +68,8 @@ export class DefaultRectRenderContribution implements IRectRenderContribution { let { width, height } = rect.attribute; - width = (width ?? x1 - x) || 0; - height = (height ?? y1 - y) || 0; + width = (width ?? x1 - originX) || 0; + height = (height ?? y1 - originY) || 0; const borderX = width < 0 ? x + width : x; const borderY = height < 0 ? y + height : y; const borderWidth = width < 0 ? -width : width; From d696aa425709da64688ef93677765218a2290e14 Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 29 Jul 2026 15:31:54 +0800 Subject: [PATCH 11/14] fix(axis): align rotated text OBB offsets --- .../__tests__/electron/axis/line.test.ts | 44 +++++++++++++++++++ packages/vrender-core/src/graphic/text.ts | 6 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/vrender-components/__tests__/electron/axis/line.test.ts b/packages/vrender-components/__tests__/electron/axis/line.test.ts index afe025677..05bf5ee87 100644 --- a/packages/vrender-components/__tests__/electron/axis/line.test.ts +++ b/packages/vrender-components/__tests__/electron/axis/line.test.ts @@ -198,6 +198,50 @@ describe('Line Axis', () => { expect(labelWidth).toBeLessThan(40); }); + it('hides a flushed rotated endpoint label that overlaps its predecessor', () => { + const axis = new LineAxis({ + orient: 'left', + start: { x: 0, y: 0 }, + end: { x: 0, y: 400 }, + verticalFactor: 1, + items: [ + [ + { id: -200000000000, label: '-200000000000', rawValue: -200000000000, value: 1 }, + { id: -100000000000, label: '-100000000000', rawValue: -100000000000, value: 0.8571428571428571 }, + { id: 0, label: '0', rawValue: 0, value: 0.7142857142857143 }, + { id: 100000000000, label: '100000000000', rawValue: 100000000000, value: 0.5714285714285714 }, + { id: 200000000000, label: '200000000000', rawValue: 200000000000, value: 0.4285714285714286 }, + { id: 300000000000, label: '300000000000', rawValue: 300000000000, value: 0.2857142857142857 }, + { id: 400000000000, label: '400000000000', rawValue: 400000000000, value: 0.14285714285714285 }, + { id: 500000000000, label: '500000000000', rawValue: 500000000000, value: 0 } + ] + ], + label: { + visible: true, + autoLimit: false, + autoHide: true, + autoHideMethod: 'greedy', + flush: true, + style: { + angle: Math.PI / 2, + textAlign: 'center', + textBaseline: 'top' + } + } + }); + + (axis as any).render(); + + const labels = axis.getElementsByName(AXIS_ELEMENT_NAME.label) as unknown as IText[]; + const first = labels.find(label => label.attribute.text === '-200000000000'); + const second = labels.find(label => label.attribute.text === '-100000000000'); + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first.attribute.visible).toBe(true); + expect(second.attribute.visible).toBe(false); + }); + it('Line Axis with Title', () => { const scale = new LinearScale().domain([0, 100]).range([0, 1]).nice(); const items = scale.ticks(10).map(tick => { diff --git a/packages/vrender-core/src/graphic/text.ts b/packages/vrender-core/src/graphic/text.ts index bc9df09e3..cda8e5f4f 100644 --- a/packages/vrender-core/src/graphic/text.ts +++ b/packages/vrender-core/src/graphic/text.ts @@ -172,13 +172,15 @@ export class Text extends Graphic implements IText { if (!this.obbText) { this.obbText = new Text({}); } - this.obbText.setAttributes({ ...attribute, angle: 0 }); + const { dx = 0, dy = 0 } = attribute; + this.obbText.setAttributes({ ...attribute, angle: 0, dx: 0, dy: 0 }); const bounds1 = this.obbText.AABBBounds; const { x, y } = attribute; const boundsCenter = { x: (bounds1.x1 + bounds1.x2) / 2, y: (bounds1.y1 + bounds1.y2) / 2 }; const center = rotatePoint(boundsCenter, angle, { x, y }); this._OBBBounds.copy(bounds1); - this._OBBBounds.translate(center.x - boundsCenter.x, center.y - boundsCenter.y); + // dx/dy are applied as a world-space translation after the text rotation. + this._OBBBounds.translate(center.x - boundsCenter.x + dx, center.y - boundsCenter.y + dy); this._OBBBounds.angle = angle; return this._OBBBounds; } From 7fb3d287f46b81bdf6a8d1235d6ad308b619a181 Mon Sep 17 00:00:00 2001 From: g1f9 <32266694+g1f9@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:17:17 +0800 Subject: [PATCH 12/14] feat: support outerBorder / innerBorder for polygon graphic Add equidistant polygon border outlines and register the polygon render contribution. Handle transformed bounds, open paths, convex and concave rounded corners, and degenerate or duplicate edges without emitting non-finite coordinates. Add focused geometry, bounds, and render regression coverage. --- .../__tests__/unit/common/polygon.test.ts | 64 +++++- .../polygon-outer-border-bounds.test.ts | 125 +++++++++++ .../unit/render/polygon-border.test.ts | 194 ++++++++++++++++++ .../vrender-core/src/common/canvas-utils.ts | 13 +- packages/vrender-core/src/common/polygon.ts | 186 ++++++++++++++++- .../polygon-outer-border-bounds.ts | 63 ++++++ packages/vrender-core/src/graphic/polygon.ts | 18 ++ .../polygon-contribution-render.ts | 138 +++++++++++++ .../contributions/render/polygon-render.ts | 7 +- 9 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts create mode 100644 packages/vrender-core/__tests__/unit/render/polygon-border.test.ts create mode 100644 packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts diff --git a/packages/vrender-core/__tests__/unit/common/polygon.test.ts b/packages/vrender-core/__tests__/unit/common/polygon.test.ts index c1f523739..458f86304 100644 --- a/packages/vrender-core/__tests__/unit/common/polygon.test.ts +++ b/packages/vrender-core/__tests__/unit/common/polygon.test.ts @@ -1,4 +1,4 @@ -import { drawPolygon, drawRoundedPolygon } from '../../../src/common/polygon'; +import { drawPolygon, drawRoundedPolygon, offsetPolygonPoints } from '../../../src/common/polygon'; type Call = { method: string; args: any[] }; @@ -42,7 +42,16 @@ describe('common/polygon', () => { test('drawRoundedPolygon falls back to drawPolygon when points length < 3', () => { const path = new MockPath(); - drawRoundedPolygon(path as any, [{ x: 0, y: 0 }, { x: 10, y: 0 }], 0, 0, 2); + drawRoundedPolygon( + path as any, + [ + { x: 0, y: 0 }, + { x: 10, y: 0 } + ], + 0, + 0, + 2 + ); expect(path.calls[0].method).toBe('moveTo'); expect(path.calls.some(c => c.method === 'arcTo')).toBe(false); }); @@ -81,4 +90,55 @@ describe('common/polygon', () => { expect(last.method).toBe('lineTo'); expect(last.args).toEqual([points[3].x + 3, points[3].y + 4]); }); + + test('offsetPolygonPoints keeps collinear vertices on the offset edge', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + 10 + ); + + expect(result[1]).toEqual({ x: 50, y: -10 }); + }); + + test('offsetPolygonPoints skips duplicate edges when finding adjacent offset lines', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + 10 + ); + + expect(result[0].x).toBeCloseTo(-10); + expect(result[0].y).toBeCloseTo(-10); + expect(result[1].x).toBeCloseTo(-10); + expect(result[1].y).toBeCloseTo(-10); + expect(result.every(point => Number.isFinite(point.x) && Number.isFinite(point.y))).toBe(true); + }); + + test('offsetPolygonPoints does not use the closing edge for an open polygon', () => { + const result = offsetPolygonPoints( + [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 } + ], + 10, + false + ); + + expect(result[0]).toEqual({ x: 0, y: -10 }); + expect(result[1].x).toBeCloseTo(110); + expect(result[1].y).toBeCloseTo(-10); + expect(result[2]).toEqual({ x: 110, y: 100 }); + }); }); diff --git a/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts new file mode 100644 index 000000000..5d41799c4 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts @@ -0,0 +1,125 @@ +import { AABBBounds } from '@visactor/vutils'; +import { application } from '../../../src/application'; +import { offsetPolygonPoints } from '../../../src/common/polygon'; +import { Group } from '../../../src/graphic/group'; +import { Polygon } from '../../../src/graphic/polygon'; +import { DefaultGraphicService } from '../../../src/graphic/graphic-service/graphic-service'; +import { updateBoundsOfPolygonOuterBorder } from '../../../src/graphic/graphic-service/polygon-outer-border-bounds'; + +describe('polygon outer border bounds', () => { + test('contains the actual offset outline for non-right-angle vertices', () => { + const points = [ + { x: 0, y: 0 }, + { x: 120, y: 0 }, + { x: 90, y: 60 }, + { x: 30, y: 60 } + ]; + const bounds = new AABBBounds(); + points.forEach(point => bounds.add(point.x, point.y)); + + updateBoundsOfPolygonOuterBorder( + { + points, + outerBorder: { stroke: '#f00', distance: 3, lineWidth: 1 } + }, + { + points: [], + closePath: true, + shadowBlur: 0, + scaleX: 1, + scaleY: 1, + keepStrokeScale: false, + outerBorder: { + distance: 0, + lineWidth: 1, + lineJoin: 'miter', + strokeBoundsBuffer: 0 + } + } as any, + bounds, + { + globalTransMatrix: { a: 1, b: 0, c: 0, d: 1 } + } as any + ); + + offsetPolygonPoints(points, 3).forEach(point => { + expect(point.x).toBeGreaterThanOrEqual(bounds.x1); + expect(point.x).toBeLessThanOrEqual(bounds.x2); + expect(point.y).toBeGreaterThanOrEqual(bounds.y1); + expect(point.y).toBeLessThanOrEqual(bounds.y2); + }); + expect(bounds.x1).toBeLessThan(-4.8); + expect(bounds.x2).toBeGreaterThan(124.8); + }); + + test('contains a non-scaling outer border after a shrinking transform', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + scaleX: 0.5, + scaleY: 0.5, + keepStrokeScale: false, + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-10); + expect(polygon.AABBBounds.y1).toBeLessThanOrEqual(-10); + expect(polygon.AABBBounds.x2).toBeGreaterThanOrEqual(60); + expect(polygon.AABBBounds.y2).toBeGreaterThanOrEqual(60); + } finally { + application.graphicService = previousGraphicService; + } + }); + + test('contains a non-scaling border width and distance under a shrinking parent', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const group = new Group({ scaleX: 0.5, scaleY: 0.5 }); + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + keepStrokeScale: false, + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 2, + lineJoin: 'round', + strokeBoundsBuffer: 0 + } + }); + group.appendChild(polygon); + + expect(polygon.globalAABBBounds.x1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.y1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.x2).toBeGreaterThanOrEqual(61); + expect(polygon.globalAABBBounds.y2).toBeGreaterThanOrEqual(61); + + group.setAttributes({ scaleX: 0.25, scaleY: 0.25 }); + expect(polygon.globalAABBBounds.x1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.y1).toBeLessThanOrEqual(-11); + expect(polygon.globalAABBBounds.x2).toBeGreaterThanOrEqual(36); + expect(polygon.globalAABBBounds.y2).toBeGreaterThanOrEqual(36); + } finally { + application.graphicService = previousGraphicService; + } + }); +}); diff --git a/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts new file mode 100644 index 000000000..31c9657c6 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -0,0 +1,194 @@ +import { DefaultPolygonRenderContribution } from '../../../src/render/contributions/render/contributions/polygon-contribution-render'; + +describe('polygon border contribution', () => { + test('keeps a rounded open polygon border open', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 } + ], + cornerRadius: 2, + closePath: false, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10 } + } + }; + const strokeCb = jest.fn(); + + contribution.drawShape( + polygon as any, + context as any, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + strokeCb + ); + + expect(context.closePath).not.toHaveBeenCalled(); + expect(nativeContext.lineTo).toHaveBeenLastCalledWith(110, 100); + expect(strokeCb).toHaveBeenCalledTimes(1); + }); + + test('adjusts rounded convex and concave corners in opposite directions', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 40 }, + { x: 40, y: 40 }, + { x: 40, y: 100 }, + { x: 0, y: 100 } + ], + cornerRadius: 10, + closePath: true, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 5 }, + innerBorder: { stroke: '#00f', distance: 5 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + jest.fn() + ); + + const radiusAt = (x: number, y: number) => + nativeContext.arcTo.mock.calls.find(args => args[0] === x && args[1] === y)?.[4]; + expect(radiusAt(-5, -5)).toBe(15); + expect(radiusAt(45, 45)).toBe(5); + expect(radiusAt(5, 5)).toBe(5); + expect(radiusAt(35, 35)).toBe(15); + }); + + test('does not emit non-finite coordinates for rounded borders with duplicate points', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + cornerRadius: 2, + closePath: true, + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0 }, + innerBorder: { distance: 0 } + } as any, + {} as any, + undefined, + jest.fn() + ); + + const pathCalls = ([] as number[]).concat( + ...nativeContext.moveTo.mock.calls, + ...nativeContext.lineTo.mock.calls, + ...nativeContext.arcTo.mock.calls + ); + expect(pathCalls.length).toBeGreaterThan(0); + expect(pathCalls.every(Number.isFinite)).toBe(true); + }); +}); diff --git a/packages/vrender-core/src/common/canvas-utils.ts b/packages/vrender-core/src/common/canvas-utils.ts index 946e66dbf..efe50e74a 100644 --- a/packages/vrender-core/src/common/canvas-utils.ts +++ b/packages/vrender-core/src/common/canvas-utils.ts @@ -1,12 +1,13 @@ import type { IColor, IConicalGradient, ILinearGradient, IRadialGradient } from '../interface/color'; import type { IContext2d, ITransform } from '../interface'; -import type { IBoundsLike } from '@visactor/vutils'; -import { isArray } from '@visactor/vutils'; +import { isArray, type IBoundsLike, type IMatrixLike } from '@visactor/vutils'; import { GradientParser } from './color-utils'; -export function getScaledStroke(context: IContext2d, width: number, dpr: number) { +type StrokeScaleMatrix = Pick; + +export function getScaledStrokeWithMatrix(matrix: StrokeScaleMatrix, width: number, dpr: number) { let strokeWidth = width; - const { a, b, c, d } = context.currentMatrix; + const { a, b, c, d } = matrix; const scaleX = Math.sign(a) * Math.sqrt(a * a + b * b); const scaleY = Math.sign(d) * Math.sqrt(c * c + d * d); // 如果没有scaleX和scaleY,那么认为什么都不用绘制 @@ -17,6 +18,10 @@ export function getScaledStroke(context: IContext2d, width: number, dpr: number) return strokeWidth; } +export function getScaledStroke(context: IContext2d, width: number, dpr: number) { + return getScaledStrokeWithMatrix(context.currentMatrix, width, dpr); +} + export function createColor( context: IContext2d, c: string | IColor | Array | boolean, diff --git a/packages/vrender-core/src/common/polygon.ts b/packages/vrender-core/src/common/polygon.ts index 746586488..b0e9e0555 100644 --- a/packages/vrender-core/src/common/polygon.ts +++ b/packages/vrender-core/src/common/polygon.ts @@ -1,6 +1,21 @@ import type { IPointLike } from '@visactor/vutils'; import type { IPath2D } from '../interface'; +type NormalizedPolygonPoints = { + points: IPointLike[]; + cornerRadius: number | number[]; +}; + +type OffsetLine = { + x: number; + y: number; + dx: number; + dy: number; + len: number; + offsetX: number; + offsetY: number; +}; + /** * 绘制闭合的常规多边形 * TODO polygon 图元的xy属性没有意义 @@ -30,6 +45,12 @@ export function drawRoundedPolygon( cornerRadius: number | number[], closePath: boolean = true ) { + const normalized = normalizePolygonPoints(points, cornerRadius, closePath); + if (normalized) { + drawRoundedPolygon(path, normalized.points, x, y, normalized.cornerRadius, closePath); + return; + } + if (points.length < 3) { drawPolygon(path, points, x, y); return; @@ -63,7 +84,7 @@ export function drawRoundedPolygon( const tan = Math.abs(Math.tan(angle)); // get config radius - let radius = Array.isArray(cornerRadius) ? (cornerRadius[i % points.length] ?? 0) : cornerRadius; + let radius = Array.isArray(cornerRadius) ? cornerRadius[i % points.length] ?? 0 : cornerRadius; let segment = radius / tan; //Check the segment @@ -139,3 +160,166 @@ function getProportionPoint(point: IPointLike, segment: number, length: number, y: point.y - dy * factor }; } + +/** + * 合并连续重复点,避免圆角计算在零长度边上产生 0 / 0。 + * 仅在确实存在退化边时创建新数组,正常绘制路径不增加额外分配。 + */ +export function normalizePolygonPoints( + points: IPointLike[], + cornerRadius: number | number[], + closePath: boolean = true +): NormalizedPolygonPoints | null { + let hasDuplicate = false; + + for (let i = 1; i < points.length; i++) { + if (isSamePoint(points[i - 1], points[i])) { + hasDuplicate = true; + break; + } + } + if (!hasDuplicate && closePath && points.length > 1 && isSamePoint(points[0], points[points.length - 1])) { + hasDuplicate = true; + } + if (!hasDuplicate) { + return null; + } + + const normalizedPoints: IPointLike[] = []; + const normalizedCornerRadius: number[] | null = Array.isArray(cornerRadius) ? [] : null; + for (let i = 0; i < points.length; i++) { + if (normalizedPoints.length && isSamePoint(normalizedPoints[normalizedPoints.length - 1], points[i])) { + continue; + } + normalizedPoints.push(points[i]); + normalizedCornerRadius?.push((cornerRadius as number[])[i] ?? 0); + } + + if ( + closePath && + normalizedPoints.length > 1 && + isSamePoint(normalizedPoints[0], normalizedPoints[normalizedPoints.length - 1]) + ) { + normalizedPoints.pop(); + normalizedCornerRadius?.pop(); + } + + return { + points: normalizedPoints, + cornerRadius: normalizedCornerRadius ?? cornerRadius + }; +} + +function isSamePoint(a: IPointLike, b: IPointLike) { + return a.x === b.x && a.y === b.y; +} + +export function getPolygonWinding(points: IPointLike[]) { + let signedArea = 0; + for (let i = 0; i < points.length; i++) { + const current = points[i]; + const next = points[(i + 1) % points.length]; + signedArea += current.x * next.y - next.x * current.y; + } + return signedArea > 0 ? 1 : -1; +} + +/** + * 把多边形的每条边沿法线平移 distance,再用相邻两条平移后的直线求交点得到等距轮廓。 + * distance 为正表示向外扩(outerBorder),为负表示向内缩(innerBorder)。 + * 退化边会被跳过;相邻边共线(求交无解)时使用平移后的原始点,避免边框凹回原轮廓。 + */ +export function offsetPolygonPoints(points: IPointLike[], distance: number, closePath: boolean = true): IPointLike[] { + const n = points?.length ?? 0; + if (n < 2 || (closePath && n < 3) || !distance) { + return points; + } + + // 用带符号面积判断顶点绕向,保证 distance > 0 时法线一致朝外 + const sign = getPolygonWinding(points); + + // 每条边平移后的直线,用点 + 方向表示;开放路径不创建末点到首点的边 + const edgeCount = closePath ? n : n - 1; + const lines: (OffsetLine | null)[] = []; + for (let i = 0; i < edgeCount; i++) { + const cur = points[i]; + const next = points[(i + 1) % n]; + const dx = next.x - cur.x; + const dy = next.y - cur.y; + const len = Math.sqrt(dx * dx + dy * dy); + if (!len) { + lines.push(null); + continue; + } + const offsetX = (sign * dy * distance) / len; + const offsetY = (-sign * dx * distance) / len; + lines.push({ x: cur.x + offsetX, y: cur.y + offsetY, dx, dy, len, offsetX, offsetY }); + } + + // 预先找出每个顶点前后的有效边,避免连续退化边导致逐点回溯成 O(n²) + const prevLines: (OffsetLine | null)[] = new Array(n); + let prevLine: OffsetLine | null = null; + if (closePath) { + for (let i = edgeCount - 1; i >= 0; i--) { + if (lines[i]) { + prevLine = lines[i]; + break; + } + } + } + for (let i = 0; i < n; i++) { + prevLines[i] = prevLine; + if (i < edgeCount && lines[i]) { + prevLine = lines[i]; + } + } + + const nextLines: (OffsetLine | null)[] = new Array(n); + let nextLine: OffsetLine | null = null; + if (closePath) { + for (let count = 0; count < edgeCount; count++) { + const line = lines[(n - 1 + count) % edgeCount]; + if (line) { + nextLine = line; + break; + } + } + } + for (let i = n - 1; i >= 0; i--) { + if (i < edgeCount && lines[i]) { + nextLine = lines[i]; + } + nextLines[i] = nextLine; + } + + const offsetPointByLine = (point: IPointLike, line: OffsetLine) => ({ + x: point.x + line.offsetX, + y: point.y + line.offsetY + }); + + const result: IPointLike[] = []; + for (let i = 0; i < n; i++) { + const prev = prevLines[i]; + const cur = nextLines[i]; + const line = prev || cur; + if (!line) { + result.push(points[i]); + continue; + } + if (!prev || !cur) { + result.push(offsetPointByLine(points[i], line)); + continue; + } + + const denominator = prev.dx * cur.dy - prev.dy * cur.dx; + if (Math.abs(denominator) <= 1e-12 * prev.len * cur.len) { + // 平行边没有唯一交点,沿有效边的法线平移原顶点 + result.push(offsetPointByLine(points[i], cur)); + continue; + } + const t = ((cur.x - prev.x) * cur.dy - (cur.y - prev.y) * cur.dx) / denominator; + const point = { x: prev.x + prev.dx * t, y: prev.y + prev.dy * t }; + result.push(Number.isFinite(point.x) && Number.isFinite(point.y) ? point : offsetPointByLine(points[i], cur)); + } + return result; +} diff --git a/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts new file mode 100644 index 000000000..c5aaefedd --- /dev/null +++ b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts @@ -0,0 +1,63 @@ +import type { IAABBBounds, IMatrixLike } from '@visactor/vutils'; +import { getScaledStrokeWithMatrix } from '../../common/canvas-utils'; +import { offsetPolygonPoints } from '../../common/polygon'; +import type { IPolygon, IPolygonGraphicAttribute } from '../../interface'; +import { boundStroke } from '../tools'; + +type BoundsScaleMatrix = Pick; + +const getBoundsScaleMatrix = (polygon: IPolygon): BoundsScaleMatrix => { + const globalMatrix = polygon.globalTransMatrix; + const viewBoxMatrix = polygon.stage?.window.getViewBoxTransform(); + if (!viewBoxMatrix) { + return globalMatrix; + } + return { + a: viewBoxMatrix.a * globalMatrix.a + viewBoxMatrix.c * globalMatrix.b, + b: viewBoxMatrix.b * globalMatrix.a + viewBoxMatrix.d * globalMatrix.b, + c: viewBoxMatrix.a * globalMatrix.c + viewBoxMatrix.c * globalMatrix.d, + d: viewBoxMatrix.b * globalMatrix.c + viewBoxMatrix.d * globalMatrix.d + }; +}; + +export const getPolygonBoundsScale = (polygon: IPolygon): number => + getScaledStrokeWithMatrix(getBoundsScaleMatrix(polygon), 1, 1); + +export const updateBoundsOfPolygonOuterBorder = ( + attribute: IPolygonGraphicAttribute, + polygonTheme: Required, + aabbBounds: IAABBBounds, + polygon: IPolygon +): IAABBBounds => { + const { + outerBorder, + points = polygonTheme.points, + closePath = polygonTheme.closePath, + shadowBlur = polygonTheme.shadowBlur, + keepStrokeScale = polygonTheme.keepStrokeScale + } = attribute; + + if (outerBorder && outerBorder.visible !== false) { + const defaultOuterBorder = polygonTheme.outerBorder; + const { + distance = defaultOuterBorder.distance, + lineWidth = defaultOuterBorder.lineWidth, + lineJoin = defaultOuterBorder.lineJoin, + strokeBoundsBuffer = defaultOuterBorder.strokeBoundsBuffer + } = outerBorder; + + const boundsScale = getPolygonBoundsScale(polygon); + let scaledDistance = distance as number; + if (!keepStrokeScale) { + scaledDistance *= boundsScale; + } + offsetPolygonPoints(points, scaledDistance, closePath).forEach(point => { + aabbBounds.add(point.x, point.y); + }); + const scaledLineWidth = lineWidth * boundsScale; + const scaledShadowBlur = shadowBlur * boundsScale; + boundStroke(aabbBounds, (scaledShadowBlur + scaledLineWidth) / 2, lineJoin === 'miter', strokeBoundsBuffer); + } + + return aabbBounds; +}; diff --git a/packages/vrender-core/src/graphic/polygon.ts b/packages/vrender-core/src/graphic/polygon.ts index ca75b5e78..64b56da10 100644 --- a/packages/vrender-core/src/graphic/polygon.ts +++ b/packages/vrender-core/src/graphic/polygon.ts @@ -7,11 +7,13 @@ import { CustomPath2D } from '../common/custom-path2d'; import { application } from '../application'; import type { GraphicType } from '../interface'; import { POLYGON_NUMBER_TYPE } from './constants'; +import { getPolygonBoundsScale, updateBoundsOfPolygonOuterBorder } from './graphic-service/polygon-outer-border-bounds'; const POLYGON_UPDATE_TAG_KEY = ['points', 'cornerRadius', ...GRAPHIC_UPDATE_TAG_KEY]; export class Polygon extends Graphic implements IPolygon { type: GraphicType = 'polygon'; + private _outerBorderBoundsScale?: number; static NOWORK_ANIMATE_ATTR = NOWORK_ANIMATE_ATTR; @@ -32,6 +34,20 @@ export class Polygon extends Graphic implements IPolyg return getTheme(this).polygon; } + protected tryUpdateAABBBounds(): IAABBBounds { + const { outerBorder } = this.attribute; + if (outerBorder && outerBorder.visible !== false) { + const boundsScale = getPolygonBoundsScale(this); + if (boundsScale !== this._outerBorderBoundsScale) { + this._outerBorderBoundsScale = boundsScale; + this.addUpdateBoundTag(); + } + } else { + this._outerBorderBoundsScale = undefined; + } + return super.tryUpdateAABBBounds(); + } + protected updateAABBBounds( attribute: IPolygonGraphicAttribute, polygonTheme: Required, @@ -60,6 +76,8 @@ export class Polygon extends Graphic implements IPolyg aabbBounds.add(p.x, p.y); }); + updateBoundsOfPolygonOuterBorder(attribute, polygonTheme, aabbBounds, this); + return aabbBounds; } diff --git a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts index 45cb92e43..23616efcd 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts @@ -1,5 +1,143 @@ +import { isArray } from '@visactor/vutils'; +import type { + IGraphicAttribute, + IContext2d, + IMarkAttribute, + IPolygon, + IPolygonGraphicAttribute, + IThemeAttribute, + IPolygonRenderContribution, + IDrawContext, + IBorderStyle +} from '../../../../interface'; +import { getScaledStroke } from '../../../../common/canvas-utils'; +import { + drawPolygon, + drawRoundedPolygon, + getPolygonWinding, + normalizePolygonPoints, + offsetPolygonPoints +} from '../../../../common/polygon'; +import { BaseRenderContributionTime } from '../../../../common/enums'; import { defaultBaseBackgroundRenderContribution } from './base-contribution-render'; import { defaultBaseTextureRenderContribution } from './base-texture-contribution-render'; export const defaultPolygonTextureRenderContribution = defaultBaseTextureRenderContribution; export const defaultPolygonBackgroundRenderContribution = defaultBaseBackgroundRenderContribution; + +export class DefaultPolygonRenderContribution implements IPolygonRenderContribution { + time: BaseRenderContributionTime = BaseRenderContributionTime.afterFillStroke; + useStyle: boolean = true; + order: number = 0; + drawShape( + polygon: IPolygon, + context: IContext2d, + x: number, + y: number, + doFill: boolean, + doStroke: boolean, + fVisible: boolean, + sVisible: boolean, + polygonAttribute: Required, + drawContext: IDrawContext, + fillCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean, + strokeCb?: ( + ctx: IContext2d, + markAttribute: Partial, + themeAttribute: IThemeAttribute + ) => boolean + ) { + const { outerBorder, innerBorder } = polygon.attribute; + const doOuterBorder = outerBorder && outerBorder.visible !== false; + const doInnerBorder = innerBorder && innerBorder.visible !== false; + if (!(doOuterBorder || doInnerBorder)) { + return; + } + const { + points = polygonAttribute.points, + cornerRadius = polygonAttribute.cornerRadius, + opacity = polygonAttribute.opacity, + x: originX = polygonAttribute.x, + y: originY = polygonAttribute.y, + scaleX = polygonAttribute.scaleX, + scaleY = polygonAttribute.scaleY, + keepStrokeScale = polygonAttribute.keepStrokeScale, + closePath = polygonAttribute.closePath + } = polygon.attribute; + + const renderBorder = (borderStyle: Partial, key: 'outerBorder' | 'innerBorder') => { + const doBorderStroke = !!borderStyle.stroke; + + const distanceDirection = key === 'outerBorder' ? 1 : -1; + const { distance = polygonAttribute[key].distance } = borderStyle; + const borderDistance = + distanceDirection * + (keepStrokeScale ? (distance as number) : getScaledStroke(context, distance as number, context.dpr)); + const normalized = normalizePolygonPoints(points, cornerRadius, closePath); + const normalizedPoints = normalized?.points ?? points; + const normalizedCornerRadius = normalized?.cornerRadius ?? cornerRadius; + const borderPoints = offsetPolygonPoints(normalizedPoints, borderDistance, closePath); + + const winding = getPolygonWinding(normalizedPoints); + // 凸角外扩时半径增大,凹角则减小;内缩时关系相反。 + const borderCornerRadius = normalizedPoints.map((point, i) => { + const radius = isArray(normalizedCornerRadius) + ? (normalizedCornerRadius)[i] ?? 0 + : (normalizedCornerRadius as number) || 0; + if ((!closePath && (i === 0 || i === normalizedPoints.length - 1)) || normalizedPoints.length < 3) { + return radius; + } + const prev = normalizedPoints[(i - 1 + normalizedPoints.length) % normalizedPoints.length]; + const next = normalizedPoints[(i + 1) % normalizedPoints.length]; + const cross = (point.x - prev.x) * (next.y - point.y) - (point.y - prev.y) * (next.x - point.x); + const cornerDirection = cross * winding < 0 ? -1 : 1; + return Math.max(0, radius + borderDistance * cornerDirection); + }); + const noCorner = borderCornerRadius.length === 0 || borderCornerRadius.every(r => r === 0); + + context.beginPath(); + if (noCorner) { + drawPolygon(context.camera ? context : context.nativeContext, borderPoints, x, y); + } else { + drawRoundedPolygon( + context.camera ? context : context.nativeContext, + borderPoints, + x, + y, + borderCornerRadius, + closePath + ); + } + closePath && context.closePath(); + + // shadow + context.setShadowBlendStyle && context.setShadowBlendStyle(polygon, polygon.attribute, polygonAttribute); + + if (strokeCb) { + strokeCb(context, borderStyle, polygonAttribute[key]); + } else if (doBorderStroke) { + // 主题里 border 的默认值不带 opacity,缺了会让 setStrokeStyle 整段空转,与 rect 一样先临时注入 + const lastOpacity = (polygonAttribute[key] as any).opacity; + (polygonAttribute[key] as any).opacity = opacity; + context.setStrokeStyle( + polygon, + borderStyle, + (originX - x) / scaleX, + (originY - y) / scaleY, + polygonAttribute[key] as any + ); + (polygonAttribute[key] as any).opacity = lastOpacity; + context.stroke(); + } + }; + + doOuterBorder && renderBorder(outerBorder, 'outerBorder'); + doInnerBorder && renderBorder(innerBorder, 'innerBorder'); + } +} + +export const defaultPolygonRenderContribution = new DefaultPolygonRenderContribution(); diff --git a/packages/vrender-core/src/render/contributions/render/polygon-render.ts b/packages/vrender-core/src/render/contributions/render/polygon-render.ts index 63fa32f9f..dc2893359 100644 --- a/packages/vrender-core/src/render/contributions/render/polygon-render.ts +++ b/packages/vrender-core/src/render/contributions/render/polygon-render.ts @@ -19,6 +19,7 @@ import { PolygonRenderContribution } from './contributions/constants'; import { BaseRender } from './base-render'; import { defaultPolygonBackgroundRenderContribution, + defaultPolygonRenderContribution, defaultPolygonTextureRenderContribution } from './contributions/polygon-contribution-render'; @@ -28,7 +29,11 @@ export class DefaultCanvasPolygonRender extends BaseRender implements constructor(protected readonly graphicRenderContributions: IContributionProvider) { super(); - this.builtinContributions = [defaultPolygonBackgroundRenderContribution, defaultPolygonTextureRenderContribution]; + this.builtinContributions = [ + defaultPolygonRenderContribution, + defaultPolygonBackgroundRenderContribution, + defaultPolygonTextureRenderContribution + ]; this.init(graphicRenderContributions); } From 14afa6c2493b1b8f5ac2e0d06ddc2ca5649f4e8c Mon Sep 17 00:00:00 2001 From: "lixuefei.1313" Date: Wed, 29 Jul 2026 17:27:20 +0800 Subject: [PATCH 13/14] fix(polygon): align border bounds with stroke scale --- .../polygon-outer-border-bounds.test.ts | 73 +++++++++++++++++++ .../unit/render/polygon-border.test.ts | 60 +++++++++++++++ .../polygon-outer-border-bounds.ts | 2 +- packages/vrender-core/src/graphic/polygon.ts | 2 +- .../polygon-contribution-render.ts | 10 ++- 5 files changed, 144 insertions(+), 3 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts index 5d41799c4..99591a489 100644 --- a/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts +++ b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts @@ -122,4 +122,77 @@ describe('polygon outer border bounds', () => { application.graphicService = previousGraphicService; } }); + + test('updates bounds when a state changes the outer border distance', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + outerBorder: { + stroke: '#f00', + distance: 10, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-10); + + polygon.states = { + hover: { + outerBorder: { + stroke: '#f00', + distance: 20, + lineWidth: 0, + strokeBoundsBuffer: 0 + } + } + } as any; + polygon.useStates(['hover'], false); + + expect(polygon.AABBBounds.x1).toBeLessThanOrEqual(-20); + } finally { + application.graphicService = previousGraphicService; + } + }); + + test('scales the outer border width when keepStrokeScale is true', () => { + const previousGraphicService = application.graphicService; + application.graphicService = new DefaultGraphicService(); + + try { + const polygon = new Polygon({ + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + scaleX: 0.5, + scaleY: 0.5, + keepStrokeScale: true, + outerBorder: { + stroke: '#f00', + distance: 0, + lineWidth: 10, + lineJoin: 'round', + strokeBoundsBuffer: 0 + } + }); + + expect(polygon.AABBBounds.x1).toBeCloseTo(-2.5); + expect(polygon.AABBBounds.y1).toBeCloseTo(-2.5); + expect(polygon.AABBBounds.x2).toBeCloseTo(52.5); + expect(polygon.AABBBounds.y2).toBeCloseTo(52.5); + } finally { + application.graphicService = previousGraphicService; + } + }); }); diff --git a/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts index 31c9657c6..ba8622479 100644 --- a/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -191,4 +191,64 @@ describe('polygon border contribution', () => { expect(pathCalls.length).toBeGreaterThan(0); expect(pathCalls.every(Number.isFinite)).toBe(true); }); + + test('passes keepStrokeScale to the outer border stroke style', () => { + const contribution = new DefaultPolygonRenderContribution(); + const nativeContext = { + moveTo: jest.fn(), + lineTo: jest.fn(), + arcTo: jest.fn() + }; + let receivedKeepStrokeScale: boolean | undefined; + const context: any = { + camera: null, + nativeContext, + beginPath: jest.fn(), + closePath: jest.fn(), + setStrokeStyle: jest.fn((_polygon, attribute) => { + receivedKeepStrokeScale = attribute.keepStrokeScale; + }), + stroke: jest.fn() + }; + const polygon = { + attribute: { + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 } + ], + keepStrokeScale: true, + outerBorder: { stroke: '#f00', distance: 10, lineWidth: 4 } + } + }; + + contribution.drawShape( + polygon as any, + context, + 0, + 0, + true, + true, + true, + true, + { + points: [], + cornerRadius: 0, + closePath: true, + keepStrokeScale: false, + opacity: 1, + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + outerBorder: { distance: 0, lineWidth: 1 }, + innerBorder: { distance: 0 } + } as any, + {} as any + ); + + expect(context.setStrokeStyle).toHaveBeenCalledTimes(1); + expect(receivedKeepStrokeScale).toBe(true); + }); }); diff --git a/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts index c5aaefedd..e32c3e9e6 100644 --- a/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts +++ b/packages/vrender-core/src/graphic/graphic-service/polygon-outer-border-bounds.ts @@ -54,7 +54,7 @@ export const updateBoundsOfPolygonOuterBorder = ( offsetPolygonPoints(points, scaledDistance, closePath).forEach(point => { aabbBounds.add(point.x, point.y); }); - const scaledLineWidth = lineWidth * boundsScale; + const scaledLineWidth = lineWidth * (keepStrokeScale ? 1 : boundsScale); const scaledShadowBlur = shadowBlur * boundsScale; boundStroke(aabbBounds, (scaledShadowBlur + scaledLineWidth) / 2, lineJoin === 'miter', strokeBoundsBuffer); } diff --git a/packages/vrender-core/src/graphic/polygon.ts b/packages/vrender-core/src/graphic/polygon.ts index 64b56da10..3a7fba066 100644 --- a/packages/vrender-core/src/graphic/polygon.ts +++ b/packages/vrender-core/src/graphic/polygon.ts @@ -9,7 +9,7 @@ import type { GraphicType } from '../interface'; import { POLYGON_NUMBER_TYPE } from './constants'; import { getPolygonBoundsScale, updateBoundsOfPolygonOuterBorder } from './graphic-service/polygon-outer-border-bounds'; -const POLYGON_UPDATE_TAG_KEY = ['points', 'cornerRadius', ...GRAPHIC_UPDATE_TAG_KEY]; +const POLYGON_UPDATE_TAG_KEY = ['points', 'cornerRadius', 'outerBorder', 'keepStrokeScale', ...GRAPHIC_UPDATE_TAG_KEY]; export class Polygon extends Graphic implements IPolygon { type: GraphicType = 'polygon'; diff --git a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts index 23616efcd..df8458895 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/polygon-contribution-render.ts @@ -122,15 +122,23 @@ export class DefaultPolygonRenderContribution implements IPolygonRenderContribut } else if (doBorderStroke) { // 主题里 border 的默认值不带 opacity,缺了会让 setStrokeStyle 整段空转,与 rect 一样先临时注入 const lastOpacity = (polygonAttribute[key] as any).opacity; + const borderStyleWithStrokeScale = borderStyle as IBorderStyle & { keepStrokeScale?: boolean }; + const lastKeepStrokeScale = borderStyleWithStrokeScale.keepStrokeScale; (polygonAttribute[key] as any).opacity = opacity; + borderStyleWithStrokeScale.keepStrokeScale = keepStrokeScale; context.setStrokeStyle( polygon, - borderStyle, + borderStyleWithStrokeScale, (originX - x) / scaleX, (originY - y) / scaleY, polygonAttribute[key] as any ); (polygonAttribute[key] as any).opacity = lastOpacity; + if (lastKeepStrokeScale === undefined) { + delete borderStyleWithStrokeScale.keepStrokeScale; + } else { + borderStyleWithStrokeScale.keepStrokeScale = lastKeepStrokeScale; + } context.stroke(); } }; From e1f2e6205c81e4815c1065055d8483e29ab268a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 11:56:19 +0000 Subject: [PATCH 14/14] build: prelease version 1.1.6 --- common/config/rush/pnpm-lock.yaml | 36 ++++++++++----------- common/config/rush/version-policies.json | 2 +- docs/package.json | 2 +- packages/react-vrender-utils/CHANGELOG.json | 6 ++++ packages/react-vrender-utils/CHANGELOG.md | 7 +++- packages/react-vrender-utils/package.json | 6 ++-- packages/react-vrender/CHANGELOG.json | 6 ++++ packages/react-vrender/CHANGELOG.md | 7 +++- packages/react-vrender/package.json | 4 +-- packages/vrender-animate/CHANGELOG.json | 6 ++++ packages/vrender-animate/CHANGELOG.md | 7 +++- packages/vrender-animate/package.json | 4 +-- packages/vrender-components/CHANGELOG.json | 6 ++++ packages/vrender-components/CHANGELOG.md | 7 +++- packages/vrender-components/package.json | 8 ++--- packages/vrender-core/CHANGELOG.json | 6 ++++ packages/vrender-core/CHANGELOG.md | 7 +++- packages/vrender-core/package.json | 2 +- packages/vrender-kits/CHANGELOG.json | 6 ++++ packages/vrender-kits/CHANGELOG.md | 7 +++- packages/vrender-kits/package.json | 4 +-- packages/vrender/CHANGELOG.json | 6 ++++ packages/vrender/CHANGELOG.md | 7 +++- packages/vrender/package.json | 10 +++--- tools/bugserver-trigger/package.json | 10 +++--- 25 files changed, 128 insertions(+), 51 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 142231695..6e658c881 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: specifier: ~0.5.7 version: 0.5.7 '@visactor/vrender': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../packages/vrender '@visactor/vutils': specifier: ~1.0.12 @@ -95,7 +95,7 @@ importers: ../../packages/react-vrender: dependencies: '@visactor/vrender': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -168,10 +168,10 @@ importers: ../../packages/react-vrender-utils: dependencies: '@visactor/react-vrender': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../react-vrender '@visactor/vrender': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender '@visactor/vutils': specifier: ~1.0.12 @@ -241,16 +241,16 @@ importers: ../../packages/vrender: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-kits devDependencies: '@internal/bundler': @@ -320,7 +320,7 @@ importers: ../../packages/vrender-animate: dependencies: '@visactor/vrender-core': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -393,13 +393,13 @@ importers: ../../packages/vrender-components: dependencies: '@visactor/vrender-animate': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-animate '@visactor/vrender-core': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-kits '@visactor/vscale': specifier: ~1.0.12 @@ -518,7 +518,7 @@ importers: specifier: 2.4.1 version: 2.4.1 '@visactor/vrender-core': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../vrender-core '@visactor/vutils': specifier: ~1.0.12 @@ -649,19 +649,19 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vrender': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../../packages/vrender '@visactor/vrender-animate': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../../packages/vrender-animate '@visactor/vrender-components': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../../packages/vrender-components '@visactor/vrender-core': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../../packages/vrender-core '@visactor/vrender-kits': - specifier: workspace:1.1.5 + specifier: workspace:1.1.6 version: link:../../packages/vrender-kits devDependencies: '@internal/bundler': diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 0653469ef..75ffa5e77 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -1 +1 @@ -[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.5","nextBump":"patch"}] +[{"definitionName":"lockStepVersion","policyName":"vrenderMain","version":"1.1.6","nextBump":"patch"}] diff --git a/docs/package.json b/docs/package.json index db0fba53c..2f05f4d66 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,7 @@ "@visactor/vchart": "1.3.0", "@visactor/vutils": "~1.0.12", "@visactor/vgrammar": "~0.5.7", - "@visactor/vrender": "workspace:1.1.5", + "@visactor/vrender": "workspace:1.1.6", "markdown-it": "^13.0.0", "highlight.js": "^11.8.0", "axios": "^1.4.0", diff --git a/packages/react-vrender-utils/CHANGELOG.json b/packages/react-vrender-utils/CHANGELOG.json index b5a838780..ee3066034 100644 --- a/packages/react-vrender-utils/CHANGELOG.json +++ b/packages/react-vrender-utils/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/react-vrender-utils", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/react-vrender-utils_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/react-vrender-utils_v1.1.5", diff --git a/packages/react-vrender-utils/CHANGELOG.md b/packages/react-vrender-utils/CHANGELOG.md index a366d633a..b1273edb3 100644 --- a/packages/react-vrender-utils/CHANGELOG.md +++ b/packages/react-vrender-utils/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/react-vrender-utils -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/react-vrender-utils/package.json b/packages/react-vrender-utils/package.json index 804323712..63577c069 100644 --- a/packages/react-vrender-utils/package.json +++ b/packages/react-vrender-utils/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender-utils", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -27,8 +27,8 @@ "react-dom": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.5", - "@visactor/react-vrender": "workspace:1.1.5", + "@visactor/vrender": "workspace:1.1.6", + "@visactor/react-vrender": "workspace:1.1.6", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/react-vrender/CHANGELOG.json b/packages/react-vrender/CHANGELOG.json index dfaff4e46..62079d205 100644 --- a/packages/react-vrender/CHANGELOG.json +++ b/packages/react-vrender/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/react-vrender", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/react-vrender_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/react-vrender_v1.1.5", diff --git a/packages/react-vrender/CHANGELOG.md b/packages/react-vrender/CHANGELOG.md index 5e83e6401..1b8bdf8f6 100644 --- a/packages/react-vrender/CHANGELOG.md +++ b/packages/react-vrender/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/react-vrender -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/react-vrender/package.json b/packages/react-vrender/package.json index 8e9b14209..de40bc430 100644 --- a/packages/react-vrender/package.json +++ b/packages/react-vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/react-vrender", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -26,7 +26,7 @@ "react": "^18.2.0" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.5", + "@visactor/vrender": "workspace:1.1.6", "@visactor/vutils": "~1.0.12", "react-reconciler": "^0.29.0", "tslib": "^2.3.1" diff --git a/packages/vrender-animate/CHANGELOG.json b/packages/vrender-animate/CHANGELOG.json index d32b36dc4..7d0a9034c 100644 --- a/packages/vrender-animate/CHANGELOG.json +++ b/packages/vrender-animate/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/vrender-animate", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/vrender-animate_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/vrender-animate_v1.1.5", diff --git a/packages/vrender-animate/CHANGELOG.md b/packages/vrender-animate/CHANGELOG.md index 018c936a8..870e38d0c 100644 --- a/packages/vrender-animate/CHANGELOG.md +++ b/packages/vrender-animate/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/vrender-animate -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/vrender-animate/package.json b/packages/vrender-animate/package.json index abaceab30..ada94c229 100644 --- a/packages/vrender-animate/package.json +++ b/packages/vrender-animate/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-animate", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": false, "main": "cjs/index.js", @@ -37,7 +37,7 @@ }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.5" + "@visactor/vrender-core": "workspace:1.1.6" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-components/CHANGELOG.json b/packages/vrender-components/CHANGELOG.json index cca0019f5..ac8dabca8 100644 --- a/packages/vrender-components/CHANGELOG.json +++ b/packages/vrender-components/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/vrender-components", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/vrender-components_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/vrender-components_v1.1.5", diff --git a/packages/vrender-components/CHANGELOG.md b/packages/vrender-components/CHANGELOG.md index c1dada650..49f277631 100644 --- a/packages/vrender-components/CHANGELOG.md +++ b/packages/vrender-components/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/vrender-components -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/vrender-components/package.json b/packages/vrender-components/package.json index eca69c7e2..3ac630cbc 100644 --- a/packages/vrender-components/package.json +++ b/packages/vrender-components/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-components", - "version": "1.1.5", + "version": "1.1.6", "description": "components library for dp visualization", "sideEffects": false, "main": "cjs/index.js", @@ -69,9 +69,9 @@ "dependencies": { "@visactor/vutils": "~1.0.12", "@visactor/vscale": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.5", - "@visactor/vrender-kits": "workspace:1.1.5", - "@visactor/vrender-animate": "workspace:1.1.5" + "@visactor/vrender-core": "workspace:1.1.6", + "@visactor/vrender-kits": "workspace:1.1.6", + "@visactor/vrender-animate": "workspace:1.1.6" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/packages/vrender-core/CHANGELOG.json b/packages/vrender-core/CHANGELOG.json index 6e5d1ce5f..521eee7bb 100644 --- a/packages/vrender-core/CHANGELOG.json +++ b/packages/vrender-core/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/vrender-core", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/vrender-core_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/vrender-core_v1.1.5", diff --git a/packages/vrender-core/CHANGELOG.md b/packages/vrender-core/CHANGELOG.md index 77bdf0765..519b1d978 100644 --- a/packages/vrender-core/CHANGELOG.md +++ b/packages/vrender-core/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/vrender-core -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/vrender-core/package.json b/packages/vrender-core/package.json index 1902fdb21..3ff125c38 100644 --- a/packages/vrender-core/package.json +++ b/packages/vrender-core/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-core", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": [ "./src/modules.ts", diff --git a/packages/vrender-kits/CHANGELOG.json b/packages/vrender-kits/CHANGELOG.json index 03ad7f5fa..c15c42833 100644 --- a/packages/vrender-kits/CHANGELOG.json +++ b/packages/vrender-kits/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/vrender-kits", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/vrender-kits_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/vrender-kits_v1.1.5", diff --git a/packages/vrender-kits/CHANGELOG.md b/packages/vrender-kits/CHANGELOG.md index c3283774e..fce0a6cc4 100644 --- a/packages/vrender-kits/CHANGELOG.md +++ b/packages/vrender-kits/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/vrender-kits -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/vrender-kits/package.json b/packages/vrender-kits/package.json index 40fe9b20b..d6bca3620 100644 --- a/packages/vrender-kits/package.json +++ b/packages/vrender-kits/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender-kits", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": false, "main": "cjs/index-node.js", @@ -37,7 +37,7 @@ }, "dependencies": { "@visactor/vutils": "~1.0.12", - "@visactor/vrender-core": "workspace:1.1.5", + "@visactor/vrender-core": "workspace:1.1.6", "@resvg/resvg-js": "2.4.1", "roughjs": "4.6.6", "gifuct-js": "2.1.2", diff --git a/packages/vrender/CHANGELOG.json b/packages/vrender/CHANGELOG.json index 6adf715a6..a46060be3 100644 --- a/packages/vrender/CHANGELOG.json +++ b/packages/vrender/CHANGELOG.json @@ -1,6 +1,12 @@ { "name": "@visactor/vrender", "entries": [ + { + "version": "1.1.6", + "tag": "@visactor/vrender_v1.1.6", + "date": "Wed, 29 Jul 2026 11:52:34 GMT", + "comments": {} + }, { "version": "1.1.5", "tag": "@visactor/vrender_v1.1.5", diff --git a/packages/vrender/CHANGELOG.md b/packages/vrender/CHANGELOG.md index c499807d8..d45011b3c 100644 --- a/packages/vrender/CHANGELOG.md +++ b/packages/vrender/CHANGELOG.md @@ -1,6 +1,11 @@ # Change Log - @visactor/vrender -This log was last generated on Fri, 17 Jul 2026 06:53:33 GMT and should not be manually modified. +This log was last generated on Wed, 29 Jul 2026 11:52:34 GMT and should not be manually modified. + +## 1.1.6 +Wed, 29 Jul 2026 11:52:34 GMT + +_Version update only_ ## 1.1.5 Fri, 17 Jul 2026 06:53:33 GMT diff --git a/packages/vrender/package.json b/packages/vrender/package.json index 69efda3a3..a2b1af86b 100644 --- a/packages/vrender/package.json +++ b/packages/vrender/package.json @@ -1,6 +1,6 @@ { "name": "@visactor/vrender", - "version": "1.1.5", + "version": "1.1.6", "description": "", "sideEffects": true, "main": "cjs/index.js", @@ -31,10 +31,10 @@ "test-watch": "cross-env DEBUG_MODE=1 jest --watch -c jest.config.js" }, "dependencies": { - "@visactor/vrender-core": "workspace:1.1.5", - "@visactor/vrender-kits": "workspace:1.1.5", - "@visactor/vrender-animate": "workspace:1.1.5", - "@visactor/vrender-components": "workspace:1.1.5" + "@visactor/vrender-core": "workspace:1.1.6", + "@visactor/vrender-kits": "workspace:1.1.6", + "@visactor/vrender-animate": "workspace:1.1.6", + "@visactor/vrender-components": "workspace:1.1.6" }, "devDependencies": { "@internal/bundler": "workspace:*", diff --git a/tools/bugserver-trigger/package.json b/tools/bugserver-trigger/package.json index 663a368c0..072a96f3e 100644 --- a/tools/bugserver-trigger/package.json +++ b/tools/bugserver-trigger/package.json @@ -8,11 +8,11 @@ "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts" }, "dependencies": { - "@visactor/vrender": "workspace:1.1.5", - "@visactor/vrender-core": "workspace:1.1.5", - "@visactor/vrender-kits": "workspace:1.1.5", - "@visactor/vrender-components": "workspace:1.1.5", - "@visactor/vrender-animate": "workspace:1.1.5" + "@visactor/vrender": "workspace:1.1.6", + "@visactor/vrender-core": "workspace:1.1.6", + "@visactor/vrender-kits": "workspace:1.1.6", + "@visactor/vrender-components": "workspace:1.1.6", + "@visactor/vrender-animate": "workspace:1.1.6" }, "devDependencies": { "@rushstack/eslint-patch": "~1.1.4",