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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vrender-core",
"comment": "feat: support render contributions for line graphic",
"type": "none"
}
],
"packageName": "@visactor/vrender-core"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { createLine } from '../../src/graphic/line';
import { DefaultCanvasLineRender } from '../../src/render/contributions/render/line-render';

declare const process: {
env: Record<string, string | undefined>;
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ describe('runtime graphic renderer contributions', () => {
rendererName: 'DefaultCanvasAreaRender',
contributionExport: 'AreaRenderContribution'
},
{
moduleExport: 'lineModule',
rendererName: 'DefaultCanvasLineRender',
contributionExport: 'LineRenderContribution'
},
{
moduleExport: 'pathModule',
rendererName: 'DefaultCanvasPathRender',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof createRecordingContext>;

/** ่ฎฐๅฝ•็”ปๅธƒ่ฐƒ็”จๅบๅˆ—๏ผŒ็”จๆฅๅˆคใ€Œๆœ€ๅŽ 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]);
});
});
10 changes: 8 additions & 2 deletions packages/vrender-core/src/entries/runtime-installer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
IContributionProvider,
IAreaRenderContribution,
ILineRenderContribution,
IDrawItemInterceptorContribution,
IEnvContribution,
IGlobal,
Expand All @@ -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';
Expand Down Expand Up @@ -169,7 +173,9 @@ export function configureRuntimeApplicationForApp(app: IApp): void {
application.incrementalDrawContributionFactory = () =>
new DefaultIncrementalDrawContribution(
[],
new DefaultIncrementalCanvasLineRender(),
new DefaultIncrementalCanvasLineRender(
createContributionProvider<ILineRenderContribution>(LineRenderContribution, bindingContext)
),
new DefaultIncrementalCanvasAreaRender(
createContributionProvider<IAreaRenderContribution>(AreaRenderContribution, bindingContext)
),
Expand Down
3 changes: 3 additions & 0 deletions packages/vrender-core/src/interface/contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,6 +76,8 @@ export type IPolygonRenderContribution = IBaseRenderContribution<IPolygon, IPoly

export type IRectRenderContribution = IBaseRenderContribution<IRect, IRectGraphicAttribute>;

export type ILineRenderContribution = IBaseRenderContribution<ILine, ILineGraphicAttribute>;

export interface IContributionProvider<T> {
getContributions: () => T[];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading