Skip to content

Commit 6566f57

Browse files
committed
perf: Optimize primitive component instantiation (~3-5x faster for Rect, Circle, Ellipse, Line, Text, Path, Group)
1 parent b87ae66 commit 6566f57

19 files changed

Lines changed: 401 additions & 155 deletions

.changeset/fast-primitives-perf.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
perf: Optimize primitive component instantiation (~3-5x faster for Rect, Circle, Ellipse, Line, Text, Path, Group)
6+
7+
- `createMotion`: Fast-path passthrough when no `motion` prop is provided, avoiding `$state`/`$effect` overhead per axis
8+
- `createDataMotionMap`: Short-circuit when `motion` is `undefined`, skipping `parseMotionProp` overhead
9+
- `createKey`: Only create fill/stroke key trackers in canvas layer (skipped for SVG/HTML)
10+
- `registerComponent`: Skip `registerMark` for empty `MarkInfo` (pixel-mode marks)
11+
- All primitives: Skip `$effect` for data motion tracking when no motion is configured
12+
- Rect/Image: Avoid per-axis `parseMotionProp` calls when `motion` is `undefined`

packages/layerchart/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
1616
"test:unit": "TZ=UTC-5 vitest",
1717
"test:ui": "TZ=UTC-5 vitest --ui",
18-
"bench": "pnpm bench:linechart && pnpm bench:composable && pnpm bench:svg-vs-canvas",
18+
"bench": "pnpm bench:primitives && pnpm bench:linechart && pnpm bench:composable && pnpm bench:svg-vs-canvas",
19+
"bench:primitives": "TZ=UTC-5 vitest bench --project bench src/lib/bench/primitives.svelte.bench.ts",
1920
"bench:linechart": "TZ=UTC-5 vitest bench --project bench src/lib/components/charts/LineChart.svelte.bench.ts",
2021
"bench:composable": "TZ=UTC-5 vitest bench --project bench src/lib/bench/composable-vs-linechart.svelte.bench.ts",
2122
"bench:svg-vs-canvas": "TZ=UTC-5 vitest bench --project bench src/lib/bench/svg-vs-canvas.svelte.bench.ts",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<script lang="ts">
2+
import Chart from '../components/Chart.svelte';
3+
import Layer from '../components/layers/Layer.svelte';
4+
import Rect from '../components/Rect.svelte';
5+
import Circle from '../components/Circle.svelte';
6+
import Ellipse from '../components/Ellipse.svelte';
7+
import Line from '../components/Line.svelte';
8+
import Group from '../components/Group.svelte';
9+
import Text from '../components/Text.svelte';
10+
import Path from '../components/Path.svelte';
11+
12+
type Primitive = 'rect' | 'circle' | 'ellipse' | 'line' | 'group' | 'text' | 'path';
13+
type Mode = 'layerchart' | 'native';
14+
15+
type Props = {
16+
primitive: Primitive;
17+
mode: Mode;
18+
count?: number;
19+
};
20+
21+
let { primitive, mode, count = 100 }: Props = $props();
22+
</script>
23+
24+
{#if mode === 'layerchart'}
25+
<Chart width={500} height={300}>
26+
<Layer type="svg">
27+
{#each Array(count) as _, i (i)}
28+
{#if primitive === 'rect'}
29+
<Rect x={10} y={10} width={50} height={30} fill="steelblue" />
30+
{:else if primitive === 'circle'}
31+
<Circle cx={30} cy={30} r={15} fill="steelblue" />
32+
{:else if primitive === 'ellipse'}
33+
<Ellipse cx={30} cy={30} rx={20} ry={10} fill="steelblue" />
34+
{:else if primitive === 'line'}
35+
<Line x1={0} y1={0} x2={50} y2={50} stroke="steelblue" strokeWidth={2} />
36+
{:else if primitive === 'group'}
37+
<Group x={10} y={10} />
38+
{:else if primitive === 'text'}
39+
<Text x={10} y={20} value="Hello" fill="steelblue" />
40+
{:else if primitive === 'path'}
41+
<Path pathData="M0,0 L50,50 L100,0 Z" fill="none" stroke="steelblue" strokeWidth={2} />
42+
{/if}
43+
{/each}
44+
</Layer>
45+
</Chart>
46+
{:else}
47+
<svg width={500} height={300}>
48+
{#each Array(count) as _, i (i)}
49+
{#if primitive === 'rect'}
50+
<rect x={10} y={10} width={50} height={30} fill="steelblue" />
51+
{:else if primitive === 'circle'}
52+
<circle cx={30} cy={30} r={15} fill="steelblue" />
53+
{:else if primitive === 'ellipse'}
54+
<ellipse cx={30} cy={30} rx={20} ry={10} fill="steelblue" />
55+
{:else if primitive === 'line'}
56+
<line x1={0} y1={0} x2={50} y2={50} stroke="steelblue" stroke-width={2} />
57+
{:else if primitive === 'group'}
58+
<g transform="translate(10,10)"></g>
59+
{:else if primitive === 'text'}
60+
<text x={10} y={20} fill="steelblue">Hello</text>
61+
{:else if primitive === 'path'}
62+
<path d="M0,0 L50,50 L100,0 Z" fill="none" stroke="steelblue" stroke-width={2} />
63+
{/if}
64+
{/each}
65+
</svg>
66+
{/if}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, bench, afterEach } from 'vitest';
2+
import { render, cleanup } from 'vitest-browser-svelte';
3+
4+
import PrimitiveBench from './PrimitiveBench.svelte';
5+
6+
afterEach(() => {
7+
cleanup();
8+
});
9+
10+
const PRIMITIVES = ['rect', 'circle', 'ellipse', 'line', 'group', 'text', 'path'] as const;
11+
const COUNT = 100;
12+
13+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
14+
// LayerChart primitives vs native SVG elements — 100 instances each
15+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16+
17+
for (const primitive of PRIMITIVES) {
18+
describe(`${primitive}${COUNT} instances`, () => {
19+
bench(`Native <${primitive}>`, () => {
20+
cleanup();
21+
render(PrimitiveBench, { primitive, mode: 'native', count: COUNT });
22+
});
23+
24+
bench(`LayerChart <${primitive[0].toUpperCase()}${primitive.slice(1)}>`, () => {
25+
cleanup();
26+
render(PrimitiveBench, { primitive, mode: 'layerchart', count: COUNT });
27+
});
28+
});
29+
}
30+
31+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32+
// Scaling: 10, 100, 500, 1000 instances (rect as representative)
33+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
34+
35+
describe('rect — scaling', () => {
36+
for (const count of [10, 100, 500, 1000]) {
37+
bench(`Native <rect> × ${count}`, () => {
38+
cleanup();
39+
render(PrimitiveBench, { primitive: 'rect', mode: 'native', count });
40+
});
41+
42+
bench(`LayerChart <Rect> × ${count}`, () => {
43+
cleanup();
44+
render(PrimitiveBench, { primitive: 'rect', mode: 'layerchart', count });
45+
});
46+
}
47+
});
48+
49+
describe('circle — scaling', () => {
50+
for (const count of [10, 100, 500, 1000]) {
51+
bench(`Native <circle> × ${count}`, () => {
52+
cleanup();
53+
render(PrimitiveBench, { primitive: 'circle', mode: 'native', count });
54+
});
55+
56+
bench(`LayerChart <Circle> × ${count}`, () => {
57+
cleanup();
58+
render(PrimitiveBench, { primitive: 'circle', mode: 'layerchart', count });
59+
});
60+
}
61+
});

packages/layerchart/src/lib/components/Circle.svelte

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -154,19 +154,21 @@
154154
// --- Data mode: resolved items with optional motion ---
155155
const dataMotionMap = createDataMotionMap(motion);
156156
157-
// Update motion targets when resolved values change
158-
$effect(() => {
159-
if (!dataMode || !dataMotionMap) return;
160-
const activeKeys = new Set<any>();
161-
for (let i = 0; i < resolvedData.length; i++) {
162-
const d = resolvedData[i];
163-
const key = keyFn(d, i);
164-
activeKeys.add(key);
165-
const resolved = resolveCircle(d);
166-
untrack(() => dataMotionMap.update(key, resolved));
167-
}
168-
untrack(() => dataMotionMap.cleanup(activeKeys));
169-
});
157+
// Only create the data motion tracking effect when motion is actually configured
158+
if (dataMotionMap) {
159+
$effect(() => {
160+
if (!dataMode) return;
161+
const activeKeys = new Set<any>();
162+
for (let i = 0; i < resolvedData.length; i++) {
163+
const d = resolvedData[i];
164+
const key = keyFn(d, i);
165+
activeKeys.add(key);
166+
const resolved = resolveCircle(d);
167+
untrack(() => dataMotionMap.update(key, resolved));
168+
}
169+
untrack(() => dataMotionMap.cleanup(activeKeys));
170+
});
171+
}
170172
171173
// Single source of truth: resolved values with animated overlay
172174
// Reading Spring .current here makes this reactive to animation frames
@@ -266,8 +268,9 @@
266268
}
267269
268270
// TODO: Use objectId to work around Svelte 4 reactivity issue (even when memoizing gradients)
269-
const fillKey = createKey(() => fill);
270-
const strokeKey = createKey(() => stroke);
271+
// Only create key trackers when in canvas mode (they're only used for canvas dep tracking)
272+
const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
273+
const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
271274
272275
chartCtx.registerComponent({
273276
name: 'Circle',
@@ -296,9 +299,9 @@
296299
motionCx.current,
297300
motionCy.current,
298301
motionR.current,
299-
fillKey.current,
302+
fillKey!.current,
300303
fillOpacity,
301-
strokeKey.current,
304+
strokeKey!.current,
302305
strokeWidth,
303306
opacity,
304307
className,

packages/layerchart/src/lib/components/Ellipse.svelte

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,20 @@
170170
// --- Data mode motion ---
171171
const dataMotionMap = createDataMotionMap(motion);
172172
173-
$effect(() => {
174-
if (!dataMode || !dataMotionMap) return;
175-
const activeKeys = new Set<any>();
176-
for (let i = 0; i < resolvedData.length; i++) {
177-
const d = resolvedData[i];
178-
const key = keyFn(d, i);
179-
activeKeys.add(key);
180-
const resolved = resolveEllipse(d);
181-
untrack(() => dataMotionMap.update(key, resolved));
182-
}
183-
untrack(() => dataMotionMap.cleanup(activeKeys));
184-
});
173+
if (dataMotionMap) {
174+
$effect(() => {
175+
if (!dataMode) return;
176+
const activeKeys = new Set<any>();
177+
for (let i = 0; i < resolvedData.length; i++) {
178+
const d = resolvedData[i];
179+
const key = keyFn(d, i);
180+
activeKeys.add(key);
181+
const resolved = resolveEllipse(d);
182+
untrack(() => dataMotionMap.update(key, resolved));
183+
}
184+
untrack(() => dataMotionMap.cleanup(activeKeys));
185+
});
186+
}
185187
186188
// Single source of truth: resolved values with animated overlay
187189
const resolvedItems = $derived.by(() => {
@@ -290,8 +292,8 @@
290292
}
291293
292294
// TODO: Use objectId to work around Svelte 4 reactivity issue (even when memoizing gradients)
293-
const fillKey = createKey(() => fill);
294-
const strokeKey = createKey(() => stroke);
295+
const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
296+
const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
295297
296298
chartCtx.registerComponent({
297299
name: 'Ellipse',
@@ -321,9 +323,9 @@
321323
motionCy.current,
322324
motionRx.current,
323325
motionRy.current,
324-
fillKey.current,
326+
fillKey!.current,
325327
fillOpacity,
326-
strokeKey.current,
328+
strokeKey!.current,
327329
strokeWidth,
328330
opacity,
329331
className,

packages/layerchart/src/lib/components/Group.svelte

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,20 @@
156156
// --- Data mode motion ---
157157
const dataMotionMap = createDataMotionMap(motion);
158158
159-
$effect(() => {
160-
if (!dataMode || !dataMotionMap) return;
161-
const activeKeys = new Set<any>();
162-
for (let i = 0; i < resolvedData.length; i++) {
163-
const d = resolvedData[i];
164-
const key = keyFn(d, i);
165-
activeKeys.add(key);
166-
const resolved = resolveGroup(d);
167-
untrack(() => dataMotionMap.update(key, resolved));
168-
}
169-
untrack(() => dataMotionMap.cleanup(activeKeys));
170-
});
159+
if (dataMotionMap) {
160+
$effect(() => {
161+
if (!dataMode) return;
162+
const activeKeys = new Set<any>();
163+
for (let i = 0; i < resolvedData.length; i++) {
164+
const d = resolvedData[i];
165+
const key = keyFn(d, i);
166+
activeKeys.add(key);
167+
const resolved = resolveGroup(d);
168+
untrack(() => dataMotionMap.update(key, resolved));
169+
}
170+
untrack(() => dataMotionMap.cleanup(activeKeys));
171+
});
172+
}
171173
172174
// Single source of truth: resolved values with animated overlay
173175
const resolvedItems = $derived.by(() => {

packages/layerchart/src/lib/components/Image.svelte

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,18 +238,20 @@
238238
// --- Data mode motion ---
239239
const dataMotionMap = createDataMotionMap(motion as MotionOptions | undefined);
240240
241-
$effect(() => {
242-
if (!dataMode || !dataMotionMap) return;
243-
const activeKeys = new Set<any>();
244-
for (let i = 0; i < resolvedData.length; i++) {
245-
const d = resolvedData[i];
246-
const key = keyFn(d, i);
247-
activeKeys.add(key);
248-
const resolved = resolveImage(d);
249-
untrack(() => dataMotionMap.update(key, { x: resolved.x, y: resolved.y, width: resolved.width, height: resolved.height }));
250-
}
251-
untrack(() => dataMotionMap.cleanup(activeKeys));
252-
});
241+
if (dataMotionMap) {
242+
$effect(() => {
243+
if (!dataMode) return;
244+
const activeKeys = new Set<any>();
245+
for (let i = 0; i < resolvedData.length; i++) {
246+
const d = resolvedData[i];
247+
const key = keyFn(d, i);
248+
activeKeys.add(key);
249+
const resolved = resolveImage(d);
250+
untrack(() => dataMotionMap.update(key, { x: resolved.x, y: resolved.y, width: resolved.width, height: resolved.height }));
251+
}
252+
untrack(() => dataMotionMap.cleanup(activeKeys));
253+
});
254+
}
253255
254256
// Single source of truth: resolved values with animated overlay
255257
const resolvedItems = $derived.by(() => {
@@ -292,22 +294,22 @@
292294
const motionX = createMotion(
293295
_initialX,
294296
() => (typeof x === 'number' ? x : 0),
295-
parseMotionProp(motion, 'x')
297+
motion === undefined ? undefined : parseMotionProp(motion, 'x')
296298
);
297299
const motionY = createMotion(
298300
_initialY,
299301
() => (typeof y === 'number' ? y : 0),
300-
parseMotionProp(motion, 'y')
302+
motion === undefined ? undefined : parseMotionProp(motion, 'y')
301303
);
302304
const motionWidth = createMotion(
303305
_initialWidth,
304306
() => resolvedPixelWidth,
305-
parseMotionProp(motion, 'width')
307+
motion === undefined ? undefined : parseMotionProp(motion, 'width')
306308
);
307309
const motionHeight = createMotion(
308310
_initialHeight,
309311
() => resolvedPixelHeight,
310-
parseMotionProp(motion, 'height')
312+
motion === undefined ? undefined : parseMotionProp(motion, 'height')
311313
);
312314
313315
// Pixel mode r and rotate (only when direct number values)

0 commit comments

Comments
 (0)