From a00a2ae8bfa55540a471f72b63421e7c7d9681d7 Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Tue, 15 Sep 2026 15:54:42 +0800 Subject: [PATCH 1/3] feat: support render contributions for line graphic DefaultCanvasLineRender was the only canvas graphic renderer that never resolved or invoked render contributions: its constructor took no IContributionProvider, never called this.init(), and neither drawSegmentItem() nor drawLinearLineHighPerformance() called beforeRenderStep()/afterRenderStep(). As a result there was no LineRenderContribution symbol at all, and no way to extend how a line is painted, while all the other renderers (rect/arc/area/symbol/path/polygon/ text/image/circle/star/richtext) support it. This wires line up the same way rect does: - add ILineRenderContribution type and LineRenderContribution symbol - DefaultCanvasLineRender now takes an IContributionProvider and calls init() - bindLineRenderModule creates the contribution provider and calls bindContributionProvider - both draw paths invoke beforeRenderStep()/afterRenderStep() - drawSegmentItem() now receives drawContext so contributions get the same arguments they do on other graphics No behaviour change when no contribution is registered. --- ...-render-contribution_2026-09-15-08-00.json | 10 +++ .../src/interface/contribution.ts | 3 + .../render/contributions/constants.ts | 1 + .../contributions/render/line-module.ts | 9 +- .../contributions/render/line-render.ts | 82 +++++++++++++++++-- 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 common/changes/@visactor/vrender-core/feat-line-render-contribution_2026-09-15-08-00.json diff --git a/common/changes/@visactor/vrender-core/feat-line-render-contribution_2026-09-15-08-00.json b/common/changes/@visactor/vrender-core/feat-line-render-contribution_2026-09-15-08-00.json new file mode 100644 index 000000000..e3eef34bf --- /dev/null +++ b/common/changes/@visactor/vrender-core/feat-line-render-contribution_2026-09-15-08-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@visactor/vrender-core", + "comment": "feat: support render contributions for line graphic", + "type": "none" + } + ], + "packageName": "@visactor/vrender-core" +} \ No newline at end of file diff --git a/packages/vrender-core/src/interface/contribution.ts b/packages/vrender-core/src/interface/contribution.ts index 3a71c6d0d..66730f3d1 100644 --- a/packages/vrender-core/src/interface/contribution.ts +++ b/packages/vrender-core/src/interface/contribution.ts @@ -6,6 +6,7 @@ import type { ISymbol, ISymbolGraphicAttribute } from './graphic/symbol'; import type { BaseRenderContributionTime } from '../common/enums'; import type { IArc, IArcGraphicAttribute } from './graphic/arc'; import type { IArea, IAreaGraphicAttribute } from './graphic/area'; +import type { ILine, ILineGraphicAttribute } from './graphic/line'; import type { IText, ITextGraphicAttribute } from './graphic/text'; import type { ICircle, ICircleGraphicAttribute } from './graphic/circle'; import type { IGroup, IGroupGraphicAttribute } from './graphic/group'; @@ -75,6 +76,8 @@ export type IPolygonRenderContribution = IBaseRenderContribution; +export type ILineRenderContribution = IBaseRenderContribution; + export interface IContributionProvider { getContributions: () => T[]; } diff --git a/packages/vrender-core/src/render/contributions/render/contributions/constants.ts b/packages/vrender-core/src/render/contributions/render/contributions/constants.ts index ec8c14b4d..e78fc82b4 100644 --- a/packages/vrender-core/src/render/contributions/render/contributions/constants.ts +++ b/packages/vrender-core/src/render/contributions/render/contributions/constants.ts @@ -6,6 +6,7 @@ export const ImageRenderContribution = Symbol.for('ImageRenderContribution'); export const PathRenderContribution = Symbol.for('PathRenderContribution'); export const PolygonRenderContribution = Symbol.for('PolygonRenderContribution'); export const RectRenderContribution = Symbol.for('RectRenderContribution'); +export const LineRenderContribution = Symbol.for('LineRenderContribution'); export const SymbolRenderContribution = Symbol.for('SymbolRenderContribution'); export const TextRenderContribution = Symbol.for('TextRenderContribution'); export const StarRenderContribution = Symbol.for('StarRenderContribution'); diff --git a/packages/vrender-core/src/render/contributions/render/line-module.ts b/packages/vrender-core/src/render/contributions/render/line-module.ts index e74ead036..60b444d61 100644 --- a/packages/vrender-core/src/render/contributions/render/line-module.ts +++ b/packages/vrender-core/src/render/contributions/render/line-module.ts @@ -1,4 +1,6 @@ +import { bindContributionProvider, createContributionProvider } from '../../../common/contribution-provider'; import { isBindingContextLoaded } from '../../../common/module-guard'; +import { LineRenderContribution } from './contributions/constants'; import { DefaultCanvasLineRender } from './line-render'; import { GraphicRender, LineRender } from './symbol'; @@ -9,10 +11,15 @@ export function bindLineRenderModule({ bind }: { bind: any }) { } // line渲染器 bind(DefaultCanvasLineRender) - .toDynamicValue(() => new DefaultCanvasLineRender()) + .toDynamicValue( + ({ container }: { container: any }) => + new DefaultCanvasLineRender(createContributionProvider(LineRenderContribution, container)) + ) .inSingletonScope(); bind(LineRender).toService(DefaultCanvasLineRender); bind(GraphicRender).toService(LineRender); + // line渲染器注入contributions + bindContributionProvider(bind, LineRenderContribution); } export const lineModule = bindLineRenderModule; diff --git a/packages/vrender-core/src/render/contributions/render/line-render.ts b/packages/vrender-core/src/render/contributions/render/line-render.ts index ba08b091e..6c15407d7 100644 --- a/packages/vrender-core/src/render/contributions/render/line-render.ts +++ b/packages/vrender-core/src/render/contributions/render/line-render.ts @@ -12,7 +12,9 @@ import type { IDrawContext, IRenderService, IGraphicRender, - IGraphicRenderDrawParams + IGraphicRenderDrawParams, + IContributionProvider, + ILineRenderContribution } from '../../../interface'; import { getTheme } from '../../../graphic/theme'; import { LINE_NUMBER_TYPE } from '../../../graphic/constants'; @@ -29,6 +31,12 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph numberType: number = LINE_NUMBER_TYPE; declare z: number; + constructor(protected readonly graphicRenderContributions: IContributionProvider) { + super(); + this.builtinContributions = []; + this.init(graphicRenderContributions); + } + draw(line: ILine, renderService: IRenderService, drawContext: IDrawContext, params?: IGraphicRenderDrawParams) { const lineAttribute = getTheme(line, params?.theme).line; this._draw(line, lineAttribute, false, drawContext, params); @@ -72,7 +80,8 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph ctx: IContext2d, lineAttribute: Partial, themeAttribute: IThemeAttribute | IThemeAttribute[] - ) => boolean + ) => boolean, + drawContext?: IDrawContext ): boolean { if (!cache) { return; @@ -103,6 +112,21 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph const { x: originX = 0, x: originY = 0 } = attribute; const ret: boolean = false; + + this.beforeRenderStep( + line, + context, + offsetX, + offsetY, + !!fill, + !!stroke, + fillOpacity, + strokeOpacity, + defaultAttribute as Required, + drawContext, + fillCb, + strokeCb + ); if (fill !== false) { if (fillCb) { fillCb(context, attribute, defaultAttribute); @@ -119,6 +143,21 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph context.stroke(); } } + + this.afterRenderStep( + line, + context, + offsetX, + offsetY, + !!fill, + !!stroke, + fillOpacity, + strokeOpacity, + defaultAttribute as Required, + drawContext, + fillCb, + strokeCb + ); return !!ret; } @@ -162,6 +201,21 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph context.setShadowBlendStyle && context.setShadowBlendStyle(line, line.attribute, lineAttribute); const { x: originX = 0, x: originY = 0 } = line.attribute; + + this.beforeRenderStep( + line, + context, + offsetX, + offsetY, + !!fill, + !!stroke, + fillOpacity, + strokeOpacity, + lineAttribute, + drawContext, + fillCb, + strokeCb + ); if (fill !== false) { if (fillCb) { fillCb(context, line.attribute, lineAttribute); @@ -178,6 +232,21 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph context.stroke(); } } + + this.afterRenderStep( + line, + context, + offsetX, + offsetY, + !!fill, + !!stroke, + fillOpacity, + strokeOpacity, + lineAttribute, + drawContext, + fillCb, + strokeCb + ); } drawShape( @@ -350,7 +419,8 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph y, line, fillCb, - strokeCb + strokeCb, + drawContext ); }); } else { @@ -385,7 +455,8 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph y, line, fillCb, - strokeCb + strokeCb, + drawContext ); } }); @@ -406,7 +477,8 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph y, line, fillCb, - strokeCb + strokeCb, + drawContext ); } } From 73bc781c11c85546dd5362a63acb14c80fc625b4 Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Wed, 16 Sep 2026 16:25:38 +0800 Subject: [PATCH 2/3] fix: pass the line render contribution provider to the incremental renderer DefaultIncrementalCanvasLineRender extends DefaultCanvasLineRender and declares no constructor of its own, so the provider parameter added in the previous commit turned the zero-argument call in runtime-installer.ts into a type error. Pass the provider the same way the sibling incremental area renderer right below it already does. This also closes a behaviour gap: without it the incremental line renderer would never resolve any LineRenderContribution. Also add line to the renderer table in runtime-renderer-contributions.test.ts. That table covers every other canvas renderer and had no line entry only because line had no contribution support until now; the existing assertions now verify that a runtime-bound LineRenderContribution reaches the renderer. --- .../entries/runtime-renderer-contributions.test.ts | 5 +++++ packages/vrender-core/src/entries/runtime-installer.ts | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/vrender-core/__tests__/unit/entries/runtime-renderer-contributions.test.ts b/packages/vrender-core/__tests__/unit/entries/runtime-renderer-contributions.test.ts index 82e9f03bf..39487e879 100644 --- a/packages/vrender-core/__tests__/unit/entries/runtime-renderer-contributions.test.ts +++ b/packages/vrender-core/__tests__/unit/entries/runtime-renderer-contributions.test.ts @@ -39,6 +39,11 @@ describe('runtime graphic renderer contributions', () => { rendererName: 'DefaultCanvasAreaRender', contributionExport: 'AreaRenderContribution' }, + { + moduleExport: 'lineModule', + rendererName: 'DefaultCanvasLineRender', + contributionExport: 'LineRenderContribution' + }, { moduleExport: 'pathModule', rendererName: 'DefaultCanvasPathRender', diff --git a/packages/vrender-core/src/entries/runtime-installer.ts b/packages/vrender-core/src/entries/runtime-installer.ts index f6da4f946..38be95851 100644 --- a/packages/vrender-core/src/entries/runtime-installer.ts +++ b/packages/vrender-core/src/entries/runtime-installer.ts @@ -1,6 +1,7 @@ import type { IContributionProvider, IAreaRenderContribution, + ILineRenderContribution, IDrawItemInterceptorContribution, IEnvContribution, IGlobal, @@ -21,7 +22,10 @@ import graphicModule from '../graphic/graphic-service/graphic-module'; import type { ILegacyBindingContext } from '../legacy/binding-context'; import { getLegacyBindingContext, preLoadAllModule } from '../legacy/bootstrap'; import pickModule from '../picker/pick-modules'; -import { AreaRenderContribution } from '../render/contributions/render/contributions/constants'; +import { + AreaRenderContribution, + LineRenderContribution +} from '../render/contributions/render/contributions/constants'; import { DrawItemInterceptor } from '../render/contributions/render/draw-interceptor'; import { DefaultIncrementalCanvasAreaRender } from '../render/contributions/render/incremental-area-render'; import { DefaultIncrementalDrawContribution } from '../render/contributions/render/incremental-draw-contribution'; @@ -169,7 +173,9 @@ export function configureRuntimeApplicationForApp(app: IApp): void { application.incrementalDrawContributionFactory = () => new DefaultIncrementalDrawContribution( [], - new DefaultIncrementalCanvasLineRender(), + new DefaultIncrementalCanvasLineRender( + createContributionProvider(LineRenderContribution, bindingContext) + ), new DefaultIncrementalCanvasAreaRender( createContributionProvider(AreaRenderContribution, bindingContext) ), From bd8ee6c630b5d12b99c71426c9abf83ce3c634d0 Mon Sep 17 00:00:00 2001 From: "chendaxin.tk" Date: Wed, 16 Sep 2026 16:44:04 +0800 Subject: [PATCH 3/3] fix: address review feedback on the line render contribution hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass computed visibility instead of opacity (P1) BaseRender.valid() already returns { doFill, doStroke, fVisible, sVisible } and drawShape() already called it and threw the result away — there was even a commented-out destructuring left in the file. Thread that result into both drawSegmentItem() and drawLinearLineHighPerformance() and hand it to the hooks, so a line with fill:false and strokeOpacity:0.5 now delivers [false, true, false, true] instead of [false, true, 1, 0.5]. This also clears the four TS2345 errors. Preserve the graphic path across the built-in clip contribution (P2) BaseRender.init() always installs the before/after clip contributions, so invoking the hooks activates DefaultBaseClipRenderBeforeContribution even when no custom contribution is registered. That contribution calls beginPath() to build the clip shape, which discarded the line path that had just been constructed, and the following stroke() painted the clip rectangle. Path construction now lives in a local buildPath() that is replayed after the hook when clipConfig is present. Note this makes clipConfig actually take effect on lines; before this PR the line renderer never ran the hooks, so the attribute was silently ignored. Say the word if you would rather keep strict parity with the old behaviour and gate it. Expose the active segment attributes (P2) Both hooks now receive { attribute } as the trailing argument, matching what the area renderer already does, so a per-segment contribution can resolve the stroke/lineWidth/opacity of the segment being drawn rather than only the parent line. Complete the incremental wiring (P1) drawIncreaseSegment() now invokes beforeRenderStep()/afterRenderStep() with the visibility flags drawShape() had already computed, and applies the same clip-path handling. Together with the provider passed through runtime-installer.ts this closes both halves of the incremental gap. Coverage __tests__/unit/render/line-render-contribution.test.ts adds five cases: the unchanged no-contribution drawing sequence, the clipConfig path regression, the visibility argument contract, per-segment attributes, and the incremental renderer wiring. Each was verified to fail against the unfixed code. __tests__/perf/line-render-contribution-performance.test.ts follows the existing opt-in perf convention (VRENDER_LINE_RENDER_PERF=1) and measures the hook overhead on the high-performance path by comparing against a subclass with both hooks stubbed out, interleaving the two variants because measuring them in sequence charges JIT warmup to whichever runs first. --- ...ne-render-contribution-performance.test.ts | 112 ++++++++++ .../render/line-render-contribution.test.ts | 199 ++++++++++++++++++ .../render/incremental-line-render.ts | 59 +++++- .../contributions/render/line-render.ts | 147 ++++++++----- 4 files changed, 460 insertions(+), 57 deletions(-) create mode 100644 packages/vrender-core/__tests__/perf/line-render-contribution-performance.test.ts create mode 100644 packages/vrender-core/__tests__/unit/render/line-render-contribution.test.ts diff --git a/packages/vrender-core/__tests__/perf/line-render-contribution-performance.test.ts b/packages/vrender-core/__tests__/perf/line-render-contribution-performance.test.ts new file mode 100644 index 000000000..d34b06ded --- /dev/null +++ b/packages/vrender-core/__tests__/perf/line-render-contribution-performance.test.ts @@ -0,0 +1,112 @@ +import { createLine } from '../../src/graphic/line'; +import { DefaultCanvasLineRender } from '../../src/render/contributions/render/line-render'; + +declare const process: { + env: Record; + stdout: { + write: (message: string) => void; + }; +}; + +// 默认跳过,和 attribute-model-performance 一样按需开:VRENDER_LINE_RENDER_PERF=1 +const runPerf = process.env.VRENDER_LINE_RENDER_PERF === '1' ? describe : describe.skip; + +const POINT_COUNT = 200; +const ITERATIONS = 2000; +const ROUNDS = 9; + +function createNoopContext() { + const noop = (): void => undefined; + return { + beginPath: noop, + closePath: noop, + moveTo: noop, + lineTo: noop, + stroke: noop, + fill: noop, + setCommonStyle: noop, + setStrokeStyle: noop, + setShadowBlendStyle: noop + }; +} + +function createBenchmarkLine() { + const points: { x: number; y: number }[] = []; + for (let i = 0; i < POINT_COUNT; i++) { + points.push({ x: i, y: (i * 7) % 100 }); + } + return createLine({ points, stroke: '#1664ff', lineWidth: 1, curveType: 'linear' }); +} + +/** 把两个钩子改成空实现,用来隔离「多出来的那点活」到底值多少 */ +class NoHookLineRender extends DefaultCanvasLineRender { + beforeRenderStep(): void { + return undefined; + } + afterRenderStep(): void { + return undefined; + } +} + +function warmUp(render: DefaultCanvasLineRender): void { + const line = createBenchmarkLine(); + const context = createNoopContext(); + for (let i = 0; i < 500; i++) { + render.drawShape(line as any, context as any, 0, 0, {} as any); + } +} + +function measure(render: DefaultCanvasLineRender): number { + const line = createBenchmarkLine(); + const context = createNoopContext(); + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + render.drawShape(line as any, context as any, 0, 0, {} as any); + } + return performance.now() - start; +} + +/** + * 两个变体交替测、各取最小值。 + * 顺序测会把 JIT 预热成本算到先测的那个头上,实测能让开销算出负数。 + */ +function compare(a: DefaultCanvasLineRender, b: DefaultCanvasLineRender): { a: number; b: number } { + warmUp(a); + warmUp(b); + let bestA = Infinity; + let bestB = Infinity; + for (let round = 0; round < ROUNDS; round++) { + bestA = Math.min(bestA, measure(a)); + bestB = Math.min(bestB, measure(b)); + } + return { a: bestA, b: bestB }; +} + +runPerf('line render contribution overhead on the high-performance path', () => { + it('measures the cost added by the render contribution hooks', () => { + const emptyProvider = { getContributions: (): any[] => [] } as any; + // init() 无条件塞两个内置裁剪贡献,所以「没有自定义贡献」并不是空列表 + const withHooks = new DefaultCanvasLineRender(emptyProvider); + const withoutHooks = new NoHookLineRender(emptyProvider); + + expect((withHooks as any)._beforeRenderContribitions.length).toBe(1); + expect((withHooks as any)._afterRenderContribitions.length).toBe(1); + + const { a: baseline, b: actual } = compare(withoutHooks, withHooks); + + process.stdout.write( + JSON.stringify({ + benchmark: 'line-render-contribution-overhead', + pointCount: POINT_COUNT, + iterations: ITERATIONS, + rounds: ROUNDS, + hooksStubbedMinMs: Number(baseline.toFixed(3)), + hooksActiveMinMs: Number(actual.toFixed(3)), + overheadPerDrawUs: Number((((actual - baseline) / ITERATIONS) * 1000).toFixed(4)), + overheadPct: Number((((actual - baseline) / baseline) * 100).toFixed(2)) + }) + '\n' + ); + + expect(actual).toBeGreaterThan(0); + }); +}); diff --git a/packages/vrender-core/__tests__/unit/render/line-render-contribution.test.ts b/packages/vrender-core/__tests__/unit/render/line-render-contribution.test.ts new file mode 100644 index 000000000..4a3a31529 --- /dev/null +++ b/packages/vrender-core/__tests__/unit/render/line-render-contribution.test.ts @@ -0,0 +1,199 @@ +import { createLine } from '../../../src/graphic/line'; +import { DefaultCanvasLineRender } from '../../../src/render/contributions/render/line-render'; +import { DefaultIncrementalCanvasLineRender } from '../../../src/render/contributions/render/incremental-line-render'; +import { BaseRenderContributionTime } from '../../../src/common/enums'; + +type RecordingContext = ReturnType; + +/** 记录画布调用序列,用来判「最后 stroke 的到底是折线还是裁剪形状」 */ +function createRecordingContext() { + const calls: string[] = []; + const record = (name: string) => (): void => { + calls.push(name); + }; + return { + calls, + beginPath: record('beginPath'), + closePath: record('closePath'), + moveTo: record('moveTo'), + lineTo: record('lineTo'), + rect: record('rect'), + clip: record('clip'), + save: record('save'), + restore: record('restore'), + fill: record('fill'), + stroke: record('stroke'), + setCommonStyle: jest.fn(), + setStrokeStyle: jest.fn(), + setShadowBlendStyle: jest.fn(), + nativeContext: { + moveTo: record('moveTo'), + lineTo: record('lineTo'), + closePath: record('closePath') + } + }; +} + +function createRender(contributions: any[] = []) { + return new DefaultCanvasLineRender({ getContributions: () => contributions } as any); +} + +/** 画布调用序列里,最后一次 stroke 之前那一段路径指令 */ +function pathBeforeLastStroke(context: RecordingContext) { + const strokeAt = context.calls.lastIndexOf('stroke'); + const beginAt = context.calls.lastIndexOf('beginPath', strokeAt); + return context.calls.slice(beginAt, strokeAt); +} + +describe('line render contributions', () => { + it('keeps the base drawing sequence when no contribution is registered', () => { + const line = createLine({ + points: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + { x: 20, y: 0 } + ], + stroke: '#000', + lineWidth: 1 + }); + const context = createRecordingContext(); + + createRender().drawShape(line as any, context as any, 0, 0, {} as any); + + expect(context.calls).toEqual(['beginPath', 'moveTo', 'lineTo', 'lineTo', 'stroke']); + expect(context.calls).not.toContain('clip'); + }); + + it('strokes the line itself, not the clip shape, when clipConfig is set', () => { + const line = createLine({ + points: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + { x: 20, y: 0 } + ], + stroke: '#000', + lineWidth: 1, + clipConfig: { shape: 'rect' } + } as any); + // 只关心内置裁剪贡献会 beginPath 冲掉图元路径这件事,裁剪形状本身用最小桩 + Object.defineProperty(line, 'AABBBounds', { + get: () => ({ width: () => 20, height: () => 20 }) + }); + (line as any).getClipPath = () => ({ + draw: (ctx: any) => { + ctx.rect(0, 0, 20, 20); + return true; + } + }); + const context = createRecordingContext(); + + createRender().drawShape(line as any, context as any, 0, 0, {} as any); + + expect(context.calls).toContain('clip'); + // 裁剪生效之后重建了折线路径,stroke 画的是折线而不是裁剪矩形 + expect(pathBeforeLastStroke(context)).toEqual(['beginPath', 'moveTo', 'lineTo', 'lineTo']); + expect(pathBeforeLastStroke(context)).not.toContain('rect'); + }); + + it('passes computed visibility booleans rather than opacity numbers', () => { + const drawShape = jest.fn(); + const line = createLine({ + points: [ + { x: 0, y: 0 }, + { x: 10, y: 10 } + ], + fill: false, + stroke: '#000', + strokeOpacity: 0.5, + lineWidth: 1 + }); + const context = createRecordingContext(); + + createRender([ + { time: BaseRenderContributionTime.beforeFillStroke, useStyle: false, order: 1, drawShape } + ]).drawShape(line as any, context as any, 0, 0, {} as any); + + expect(drawShape).toHaveBeenCalledTimes(1); + const [, , , , doFill, doStroke, fVisible, sVisible] = drawShape.mock.calls[0]; + expect([doFill, doStroke, fVisible, sVisible]).toEqual([false, true, false, true]); + }); + + it('exposes the active segment attributes to per-segment contributions', () => { + const drawShape = jest.fn(); + const line = createLine({ + stroke: '#000', + lineWidth: 1, + segments: [ + { + points: [ + { x: 0, y: 0 }, + { x: 10, y: 10 } + ], + stroke: 'red' + }, + { + points: [ + { x: 10, y: 10 }, + { x: 20, y: 0 } + ], + stroke: 'blue' + } + ] + } as any); + const context = createRecordingContext(); + + createRender([ + { time: BaseRenderContributionTime.beforeFillStroke, useStyle: false, order: 1, drawShape } + ]).drawShape(line as any, context as any, 0, 0, {} as any); + + const strokes = drawShape.mock.calls.map(args => args[args.length - 1]?.attribute?.stroke); + expect(strokes).toEqual(['red', 'blue']); + }); + + it('wires contributions through the incremental renderer', () => { + const drawShape = jest.fn(); + const afterDrawShape = jest.fn(); + const render = new DefaultIncrementalCanvasLineRender({ + getContributions: () => [ + { time: BaseRenderContributionTime.beforeFillStroke, useStyle: false, order: 1, drawShape }, + { + time: BaseRenderContributionTime.afterFillStroke, + useStyle: false, + order: 1, + drawShape: afterDrawShape + } + ] + } as any); + const line = createLine({ + stroke: '#000', + lineWidth: 1, + segments: [ + { + points: [ + { x: 0, y: 0 }, + { x: 10, y: 10 } + ] + }, + { + points: [ + { x: 10, y: 10 }, + { x: 20, y: 0 } + ] + } + ] + } as any); + (line as any).incremental = 1; + const context = createRecordingContext(); + + render.drawShape(line as any, context as any, 0, 0, { + context, + multiGraphicOptions: { startAtIdx: 1, length: 1 } + } as any); + + expect(context.calls).toContain('stroke'); + expect(drawShape).toHaveBeenCalledTimes(1); + expect(afterDrawShape).toHaveBeenCalledTimes(1); + const [, , , , doFill, doStroke, fVisible, sVisible] = drawShape.mock.calls[0]; + expect([doFill, doStroke, fVisible, sVisible]).toEqual([false, true, false, true]); + }); +}); diff --git a/packages/vrender-core/src/render/contributions/render/incremental-line-render.ts b/packages/vrender-core/src/render/contributions/render/incremental-line-render.ts index b8c146eb8..8e302bbab 100644 --- a/packages/vrender-core/src/render/contributions/render/incremental-line-render.ts +++ b/packages/vrender-core/src/render/contributions/render/incremental-line-render.ts @@ -13,6 +13,7 @@ import type { import { getTheme } from '../../../graphic/theme'; import { LINE_NUMBER_TYPE } from '../../../graphic/constants'; import { fillVisible, runFill, runStroke, strokeVisible } from './utils'; +import type { ILineRenderVisibility } from './line-render'; import { DefaultCanvasLineRender } from './line-render'; import { drawIncrementalSegments } from '../../../common/render-curve'; @@ -82,6 +83,7 @@ export class DefaultIncrementalCanvasLineRender extends DefaultCanvasLineRender } const { context } = drawContext; + const visibility: ILineRenderVisibility = { doFill, doStroke, fVisible, sVisible }; // 不支持clipRange,不支持pick,仅支持最基础的线段绘制 for (let i = startAtIdx; i < startAtIdx + length; i++) { this.drawIncreaseSegment( @@ -92,7 +94,9 @@ export class DefaultIncrementalCanvasLineRender extends DefaultCanvasLineRender line.attribute.segments[i], [lineAttribute, line.attribute], x, - y + y, + drawContext, + visibility ); } } else { @@ -108,18 +112,65 @@ export class DefaultIncrementalCanvasLineRender extends DefaultCanvasLineRender attribute: Partial, defaultAttribute: Required | Partial[], offsetX: number, - offsetY: number + offsetY: number, + drawContext: IDrawContext, + visibility: ILineRenderVisibility ) { if (!seg) { return; } - context.beginPath(); - drawIncrementalSegments(context.nativeContext, lastSeg, seg, { offsetX, offsetY }); + // 内置裁剪贡献在 beforeFillStroke 里 beginPath 建裁剪路径,会冲掉这里建好的折线路径,故要能重建 + const buildPath = () => { + context.beginPath(); + drawIncrementalSegments(context.nativeContext, lastSeg, seg, { offsetX, offsetY }); + }; + + buildPath(); // shadow context.setShadowBlendStyle && context.setShadowBlendStyle(line, attribute, defaultAttribute); + + const { doFill, doStroke, fVisible, sVisible } = visibility; + + this.beforeRenderStep( + line, + context, + offsetX, + offsetY, + doFill, + doStroke, + fVisible, + sVisible, + defaultAttribute as Required, + drawContext, + null, + null, + { attribute } + ); + + // 有裁剪配置时上面的贡献点已经 beginPath 建了裁剪路径,重建折线路径,否则 stroke 画的是裁剪形状 + if (line.attribute.clipConfig) { + buildPath(); + } + context.setStrokeStyle(line, attribute, offsetX, offsetY, defaultAttribute); context.stroke(); + + this.afterRenderStep( + line, + context, + offsetX, + offsetY, + doFill, + doStroke, + fVisible, + sVisible, + defaultAttribute as Required, + drawContext, + null, + null, + { attribute } + ); } } diff --git a/packages/vrender-core/src/render/contributions/render/line-render.ts b/packages/vrender-core/src/render/contributions/render/line-render.ts index 6c15407d7..b429edd1f 100644 --- a/packages/vrender-core/src/render/contributions/render/line-render.ts +++ b/packages/vrender-core/src/render/contributions/render/line-render.ts @@ -22,6 +22,14 @@ import { BaseRender } from './base-render'; import { drawSegments } from '../../../common/render-curve'; import { calcLineCache } from '../../../common/segment'; +/** BaseRender.valid() 算出的填充/描边资格与可见性,用于喂给渲染贡献点 */ +export interface ILineRenderVisibility { + doFill: boolean; + doStroke: boolean; + fVisible: boolean; + sVisible: boolean; +} + /** * 默认的基于canvas的line渲染器 * 单例 @@ -71,6 +79,8 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph offsetX: number, offsetY: number, line: ILine, + drawContext: IDrawContext, + visibility: ILineRenderVisibility, fillCb?: ( ctx: IContext2d, lineAttribute: Partial, @@ -80,53 +90,66 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph ctx: IContext2d, lineAttribute: Partial, themeAttribute: IThemeAttribute | IThemeAttribute[] - ) => boolean, - drawContext?: IDrawContext + ) => boolean ): boolean { if (!cache) { return; } - context.beginPath(); const z = this.z ?? 0; - drawSegments(context, cache, clipRange, clipRangeByDimension, { - offsetX, - offsetY, - offsetZ: z - }); - - // 如果是一根线,且是Closed,并且没有defined为false的点,需要close - if ( - line.cache && - !isArray(line.cache) && - line.cache.curves.every(c => c.defined) && - line.attribute.curveType && - line.attribute.curveType.includes('Closed') - ) { - context.closePath(); - } + // 内置裁剪贡献在 beforeFillStroke 里 beginPath 建裁剪路径,会冲掉这里建好的折线路径,故要能重建 + const buildPath = () => { + context.beginPath(); + + drawSegments(context, cache, clipRange, clipRangeByDimension, { + offsetX, + offsetY, + offsetZ: z + }); + + // 如果是一根线,且是Closed,并且没有defined为false的点,需要close + if ( + line.cache && + !isArray(line.cache) && + line.cache.curves.every(c => c.defined) && + line.attribute.curveType && + line.attribute.curveType.includes('Closed') + ) { + context.closePath(); + } + }; + + buildPath(); // shadow context.setShadowBlendStyle && context.setShadowBlendStyle(line, attribute, defaultAttribute); const { x: originX = 0, x: originY = 0 } = attribute; const ret: boolean = false; + const { doFill, doStroke, fVisible, sVisible } = visibility; this.beforeRenderStep( line, context, offsetX, offsetY, - !!fill, - !!stroke, - fillOpacity, - strokeOpacity, + doFill, + doStroke, + fVisible, + sVisible, defaultAttribute as Required, drawContext, fillCb, - strokeCb + strokeCb, + { attribute } ); + + // 有裁剪配置时上面的贡献点已经 beginPath 建了裁剪路径,重建折线路径,否则 fill/stroke 画的是裁剪形状 + if (line.attribute.clipConfig) { + buildPath(); + } + if (fill !== false) { if (fillCb) { fillCb(context, attribute, defaultAttribute); @@ -149,14 +172,15 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph context, offsetX, offsetY, - !!fill, - !!stroke, - fillOpacity, - strokeOpacity, + doFill, + doStroke, + fVisible, + sVisible, defaultAttribute as Required, drawContext, fillCb, - strokeCb + strokeCb, + { attribute } ); return !!ret; } @@ -173,6 +197,7 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph offsetY: number, lineAttribute: Required, drawContext: IDrawContext, + visibility: ILineRenderVisibility, params?: IGraphicRenderDrawParams, fillCb?: ( ctx: IContext2d, @@ -185,37 +210,49 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph themeAttribute: IThemeAttribute ) => boolean ) { - context.beginPath(); - const z = this.z ?? 0; const { points } = line.attribute; const startP = points[0]; - context.moveTo(startP.x + offsetX, startP.y + offsetY, z); - for (let i = 1; i < points.length; i++) { - const p = points[i]; - context.lineTo(p.x + offsetX, p.y + offsetY, z); - } + // 内置裁剪贡献在 beforeFillStroke 里 beginPath 建裁剪路径,会冲掉这里建好的折线路径,故要能重建 + const buildPath = () => { + context.beginPath(); + context.moveTo(startP.x + offsetX, startP.y + offsetY, z); + for (let i = 1; i < points.length; i++) { + const p = points[i]; + context.lineTo(p.x + offsetX, p.y + offsetY, z); + } + }; + + buildPath(); // shadow context.setShadowBlendStyle && context.setShadowBlendStyle(line, line.attribute, lineAttribute); const { x: originX = 0, x: originY = 0 } = line.attribute; + const { doFill, doStroke, fVisible, sVisible } = visibility; this.beforeRenderStep( line, context, offsetX, offsetY, - !!fill, - !!stroke, - fillOpacity, - strokeOpacity, + doFill, + doStroke, + fVisible, + sVisible, lineAttribute, drawContext, fillCb, - strokeCb + strokeCb, + { attribute: line.attribute } ); + + // 有裁剪配置时上面的贡献点已经 beginPath 建了裁剪路径,重建折线路径,否则 fill/stroke 画的是裁剪形状 + if (line.attribute.clipConfig) { + buildPath(); + } + if (fill !== false) { if (fillCb) { fillCb(context, line.attribute, lineAttribute); @@ -238,14 +275,15 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph context, offsetX, offsetY, - !!fill, - !!stroke, - fillOpacity, - strokeOpacity, + doFill, + doStroke, + fVisible, + sVisible, lineAttribute, drawContext, fillCb, - strokeCb + strokeCb, + { attribute: line.attribute } ); } @@ -307,12 +345,12 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph y, lineAttribute, drawContext, + data, params, fillCb, strokeCb ); } - // const { fVisible, sVisible, doFill, doStroke } = data; function parsePoint(points: IPointLike[], connectedType: 'none' | 'connect') { if (connectedType === 'none') { @@ -418,9 +456,10 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph x, y, line, + drawContext, + data, fillCb, - strokeCb, - drawContext + strokeCb ); }); } else { @@ -454,9 +493,10 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph x, y, line, + drawContext, + data, fillCb, - strokeCb, - drawContext + strokeCb ); } }); @@ -476,9 +516,10 @@ export class DefaultCanvasLineRender extends BaseRender implements IGraph x, y, line, + drawContext, + data, fillCb, - strokeCb, - drawContext + strokeCb ); } }