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/ 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/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. 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. 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/__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-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/__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..99591a489 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/graphic/polygon-outer-border-bounds.test.ts @@ -0,0 +1,198 @@ +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; + } + }); + + 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/graphic/state-animation.test.ts b/packages/vrender-core/__tests__/unit/graphic/state-animation.test.ts index 28c628d1a..5997c0a72 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,83 @@ 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 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 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: { + width: 40 + }, + selected: { + x: 5, + x1: 30 + } + } 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({ width: 25 }) + }) + } + ] + ); + }); + 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/__tests__/unit/render/polygon-border.test.ts b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts new file mode 100644 index 000000000..ba8622479 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/polygon-border.test.ts @@ -0,0 +1,254 @@ +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); + }); + + 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/__tests__/unit/render/rect-split-stroke.test.ts b/packages/vrender-core/__tests__/unit/render/rect-split-stroke.test.ts index 262b8131e..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 @@ -1,4 +1,5 @@ import { + DefaultRectRenderContribution, SplitRectAfterRenderContribution, SplitRectBeforeRenderContribution } from '../../../src/render/contributions/render/contributions/rect-contribution-render'; @@ -71,4 +72,153 @@ 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('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() }; + + 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); + }); }); 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-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..e32c3e9e6 --- /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 * (keepStrokeScale ? 1 : boundsScale); + const scaledShadowBlur = shadowBlur * boundsScale; + boundStroke(aabbBounds, (scaledShadowBlur + scaledLineWidth) / 2, lineJoin === 'miter', strokeBoundsBuffer); + } + + return aabbBounds; +}; diff --git a/packages/vrender-core/src/graphic/graphic.ts b/packages/vrender-core/src/graphic/graphic.ts index f7456939c..dc34cb200 100644 --- a/packages/vrender-core/src/graphic/graphic.ts +++ b/packages/vrender-core/src/graphic/graphic.ts @@ -732,15 +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 staticTargetAttrs = snapshot as Partial; + const extraAttrs: Record = {}; Object.keys(previousResolvedStatePatch).forEach(key => { const hasTargetAttr = Object.prototype.hasOwnProperty.call(targetStateAttrs, key); if (hasTargetAttr && (targetStateAttrs as Record)[key] !== undefined) { @@ -748,29 +746,26 @@ abstract class GraphicImpl = Partial { - if (value === undefined && this.shouldSkipStateTransitionDefaultAttribute(key, staticTargetAttrs)) { - return; - } extraAttrs[key] = value === undefined ? value : cloneAttributeValue(value); }; if (hasTargetAttr) { - assignFallbackAttr(this.getStateTransitionDefaultAttribute(key, staticTargetAttrs)); + assignFallbackAttr((targetStateAttrs as Record)[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; + return { + extraAttrs: extraAttrs as Partial, + stateTransitionTargetAttrs: snapshot as Partial + }; } protected syncObjectToSnapshot( @@ -2279,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) as ( + key: string, + targetAttrs?: Record + ) => unknown, shouldSkipDefaultAttribute: this.shouldSkipStateTransitionDefaultAttribute.bind(this) as ( key: string, targetAttrs: Record - ) => boolean + ) => boolean, + stateTransitionTargetAttrs: stateTransitionTargetAttrs as Record } : undefined; @@ -2333,10 +2339,15 @@ abstract class GraphicImpl = Partial, + 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); @@ -2404,13 +2415,18 @@ abstract class GraphicImpl = Partial = Partial 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/graphic/state/state-transition-orchestrator.ts b/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts index 634d52b61..6028f3f68 100644 --- a/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts +++ b/packages/vrender-core/src/graphic/state/state-transition-orchestrator.ts @@ -12,15 +12,19 @@ export interface IStateTransitionAnalysisOptions { noWorkAnimateAttr?: Record; isClear?: boolean; getDefaultAttribute?: (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, targetAttrs?: Record) => unknown; shouldSkipDefaultAttribute?: (key: string, targetAttrs: Record) => boolean; + stateTransitionTargetAttrs?: Record; } export interface IStateTransitionGraphic { @@ -107,14 +111,27 @@ 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, targetAttrs?: Record) => unknown) + | undefined; const shouldSkipDefaultAttribute = options.shouldSkipDefaultAttribute; + const stateTransitionTargetAttrs = options.stateTransitionTargetAttrs ?? (targetAttrs as Record); - 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, stateTransitionTargetAttrs); + if (defaultValue === undefined && shouldSkipDefaultAttribute?.(key, stateTransitionTargetAttrs)) { + return; + } + (plan.animateAttrs as Record)[key] = defaultValue; + return; + } + if (isClear && value === undefined) { if (shouldSkipDefaultAttribute?.(key, targetAttrs as Record)) { return; @@ -145,7 +162,7 @@ export class StateTransitionOrchestrator = Record< ) { continue; } - assignTransitionAttr(key, (extraAnimateAttrs as Record)[key]); + assignTransitionAttr(key, (extraAnimateAttrs as Record)[key], true); } } @@ -202,10 +219,12 @@ 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, - extraAnimateAttrs: options.extraAnimateAttrs + extraAnimateAttrs: options.extraAnimateAttrs, + stateTransitionTargetAttrs: options.stateTransitionTargetAttrs }); return this.applyTransition(graphic, plan, hasAnimation, options); 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; } 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..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 @@ -1,5 +1,151 @@ +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; + const borderStyleWithStrokeScale = borderStyle as IBorderStyle & { keepStrokeScale?: boolean }; + const lastKeepStrokeScale = borderStyleWithStrokeScale.keepStrokeScale; + (polygonAttribute[key] as any).opacity = opacity; + borderStyleWithStrokeScale.keepStrokeScale = keepStrokeScale; + context.setStrokeStyle( + polygon, + 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(); + } + }; + + doOuterBorder && renderBorder(outerBorder, 'outerBorder'); + doInnerBorder && renderBorder(innerBorder, 'innerBorder'); + } +} + +export const defaultPolygonRenderContribution = new DefaultPolygonRenderContribution(); 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..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,12 @@ 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; + 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' ); 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); } 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/__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/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-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) { 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",