Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/olive-berries-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Spline): Only tween path data when `motion` is set (~20x less memory growth while streaming, issue #585)
5 changes: 5 additions & 0 deletions .changeset/olive-crabs-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

perf(Chart): Remove quadratic domain recalculation on mount for series-based charts
5 changes: 5 additions & 0 deletions .changeset/olive-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

perf: Memoize props in component state classes (~3x faster `<Rect>` mount in benchmarks)
5 changes: 5 additions & 0 deletions .changeset/quiet-donuts-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

perf(Chart): Resolve stacked value domain in a single pass
23 changes: 21 additions & 2 deletions docs/src/content/guides/bundle-size.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ That flexibility has a cost: every consumer of `import { Chart } from 'layerchar
2. **Sub-path exports for heavy dependencies** — Components that pull in big external deps live behind opt-in sub-paths
3. **Per-layer variants** — Almost every component has SVG/Canvas/HTML-specific variants for users who commit to one layer (primitives, compound marks, geo, graph, and the high-level chart wrappers like `<LineChart>`)

The first two cost you nothing — they're transparent. The third is opt-in: you swap an import to get a smaller bundle in exchange for losing layer flexibility on that import.
The first two cost you nothing — they're transparent. The third is opt-in: you swap an import to get a smaller bundle — and a slightly faster mount, see [below](#per-layer-is-also-faster-to-mount) — in exchange for losing layer flexibility on that import.

## What you get for free

Expand Down Expand Up @@ -109,14 +109,33 @@ import { Circle } from 'layerchart/canvas';
import { Circle } from 'layerchart/html';
```

The agnostic version (`Circle.svelte`) dispatches to the appropriate per-layer variant under the hood at runtime, so you can mix per-layer and agnostic imports in the same chart — the resolved code path is identical.
The agnostic version (`Circle.svelte`) dispatches to the appropriate per-layer variant under the hood at runtime, so you can mix per-layer and agnostic imports in the same chart — the rendered result is identical.

### Per-layer is also faster to mount

The dispatch isn't free. An agnostic import mounts **two** components per instance — the dispatcher that reads the layer context, plus the layer-specific variant it renders. A per-layer import mounts only the variant, so you skip that wrapper entirely.

Mounting **100 instances** into an `<Svg>` layer, measured by the repo's primitives benchmark:

| Import | `<Rect>` | `<Text>` |
| ----------------------- | ----------- | ----------- |
| `from 'layerchart'` | ~7.0 ms | ~10.2 ms |
| `from 'layerchart/svg'` | ~4.7 ms | ~8.4 ms |
| **Saved** | **~2.3 ms** | **~1.8 ms** |

Those are totals for 100 instances, so the wrapper works out to roughly **0.02 ms each** (2.3 ms ÷ 100). It's one extra component per instance, so the cost scales with how many marks you render — not with how large your chart or dataset is.

Note that it's a flat cost per instance rather than a fixed percentage. The same ~2 ms is ~33% of `<Rect>`'s total but only ~18% of `<Text>`'s, because `<Text>` does considerably more work of its own.

So the saving matters when you render **many instances** — a scatter plot of thousands of `<Circle>`s, a heatmap of `<Rect>`s. For a chart with a handful of marks it won't be measurable.

The `layerchart/svg`, `layerchart/canvas`, and `layerchart/html` sub-paths re-export every layer-agnostic helper too (layouts, scales, tooltip primitives, etc.), so a single per-layer import path can cover a typical chart end-to-end.

### When per-layer is worth it

- ✅ You're building many charts in a single layer (most likely SVG)
- ✅ You're shipping to a bandwidth-sensitive context (mobile, embedded views, AMP-style pages)
- ✅ You're rendering many instances of one primitive, where skipping the dispatcher measurably speeds up mounting
- ✅ You want to sketch out the absolute minimum bundle for a specific use case

### When to stay on the agnostic API
Expand Down
34 changes: 34 additions & 0 deletions docs/src/content/guides/primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,40 @@ Some primitives are not available in all layer types based on the primitive's ne

LayerChart does provide extended support than what is natively possible in some cases. For example `Text` provides word wrapping in `Svg` and `Canvas` layers, and all primitives support pointer and css styling in `Canvas`.

## Using native elements

Primitives are components, and mounting a component costs more than rendering a plain element. Inside an `Svg` layer you can always drop down to native SVG elements and position them yourself using the chart's scales:

```svelte
<Chart {data} x="date" y="value">
{#snippet marks({ context })}
{#each data as d}
<rect x={context.xScale(d.date)} y={context.yScale(d.value)} width={4} height={10} />
{/each}
{/snippet}
</Chart>
```

Mounting 100 instances into an `Svg` layer, measured by the repo's primitives benchmark:

| Element | Native | Primitive |
| ------- | ------- | --------- |
| `rect` | ~1.2 ms | ~7.0 ms |
| `text` | ~1.3 ms | ~10.2 ms |

That gap is worth having when you're rendering thousands of marks. But a native element is only an element — you give up everything primitives add:

- **Data mode** — no scale resolution or automatic per-item iteration; you index scales and write the `{#each}` yourself
- **Motion** — no tween/spring transitions on position or dimensions
- **Layer portability** — native SVG renders only in `Svg`; moving that chart to `Canvas` means rewriting the marks
- **Canvas pointer events and CSS styling**, which primitives provide even on `Canvas`
- **Extended behavior** like `Text` word wrapping
- **Mark registration** — in data mode, primitives register with the chart and contribute to automatic domains, legends, and tooltips. Native elements are invisible to all of that, so a domain you were relying on may quietly change.

Reach for native elements for static, high-count, decorative content where you already have pixel coordinates and need none of the above — backdrops, reference marks, dense scatter overlays. Use primitives everywhere else.

If you want part of the speedup without giving any of this up, importing from a [layer-specific entrypoint](/docs/guides/bundle-size#per-layer-is-also-faster-to-mount) skips the layer-dispatch wrapper while keeping full primitive behavior.

## Components

<div class="grid grid-cols-sm gap-3 mt-8">
Expand Down
41 changes: 40 additions & 1 deletion packages/layerchart/src/lib/bench/PrimitiveBench.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,23 @@
import Text from '../components/Text/Text.svelte';
import Path from '../components/Path/Path.svelte';

// Layer-specific variants, as re-exported by `layerchart/svg`. These skip
// the dispatcher that reads the layer context — see `mode: 'direct'`.
import RectSvg from '../components/Rect/Rect.svg.svelte';
import TextSvg from '../components/Text/Text.svg.svelte';

type Primitive = 'rect' | 'circle' | 'ellipse' | 'line' | 'group' | 'text' | 'path';
type Mode = 'layerchart' | 'native';

/**
* - `native` — bare `<svg>`, no Chart at all
* - `native-in-chart` — native elements inside Chart + Layer (isolates fixed setup)
* - `direct` — layer-specific component, as exported by `layerchart/svg`
* - `layerchart` — the dispatcher, as exported by `layerchart`
*
* `native-in-chart` and `direct` are only wired up for the primitives used by
* the overhead-decomposition benchmark.
*/
type Mode = 'layerchart' | 'native' | 'native-in-chart' | 'direct';

type Props = {
primitive: Primitive;
Expand Down Expand Up @@ -43,6 +58,30 @@
{/each}
</Layer>
</Chart>
{:else if mode === 'direct'}
<Chart width={500} height={300}>
<Layer type="svg">
{#each Array(count) as _, i (i)}
{#if primitive === 'rect'}
<RectSvg x={10} y={10} width={50} height={30} fill="steelblue" />
{:else if primitive === 'text'}
<TextSvg x={10} y={20} value="Hello" fill="steelblue" />
{/if}
{/each}
</Layer>
</Chart>
{:else if mode === 'native-in-chart'}
<Chart width={500} height={300}>
<Layer type="svg">
{#each Array(count) as _, i (i)}
{#if primitive === 'rect'}
<rect x={10} y={10} width={50} height={30} fill="steelblue" />
{:else if primitive === 'text'}
<text x={10} y={20} fill="steelblue">Hello</text>
{/if}
{/each}
</Layer>
</Chart>
{:else}
<svg width={500} height={300}>
{#each Array(count) as _, i (i)}
Expand Down
45 changes: 45 additions & 0 deletions packages/layerchart/src/lib/bench/primitives.svelte.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,48 @@ describe('rect — scaling', () => {
});
}
});

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Where the overhead above actually goes
//
// `import { Rect } from 'layerchart'` mounts TWO components: the dispatcher
// reads the layer context and renders the layer-specific variant. The
// layer-specific entrypoints (`layerchart/svg`) export that variant
// directly, skipping the dispatcher — so both arms are public API.
//
// Holding Chart + Layer constant and varying only the component makes the
// cost decompose:
//
// dispatcher = layerchart - layerchart/svg (the wrapper)
// component = layerchart/svg - native in Chart (State + template)
// chart/layer = native in Chart - native (bare) (fixed setup)
//
// rect and text are the two heaviest primitives; the rest share the same
// architecture and show the same shape.
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

for (const primitive of ['rect', 'text'] as const) {
const Name = `${primitive[0].toUpperCase()}${primitive.slice(1)}`;

describe(`${primitive} — overhead decomposition, ${COUNT} instances`, () => {
bench(`native <${primitive}>, no Chart`, () => {
cleanup();
render(PrimitiveBench, { primitive, mode: 'native', count: COUNT });
});

bench(`native <${primitive}> in Chart+Layer`, () => {
cleanup();
render(PrimitiveBench, { primitive, mode: 'native-in-chart', count: COUNT });
});

bench(`<${Name}> from 'layerchart/svg'`, () => {
cleanup();
render(PrimitiveBench, { primitive, mode: 'direct', count: COUNT });
});

bench(`<${Name}> from 'layerchart'`, () => {
cleanup();
render(PrimitiveBench, { primitive, mode: 'layerchart', count: COUNT });
});
});
}
48 changes: 26 additions & 22 deletions packages/layerchart/src/lib/components/Arc/Arc.shared.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ function getOuterRadius(outerRadius: number | undefined, chartRadius: number) {
*/
export class ArcState {
#getProps: () => ArcProps = () => ({}) as ArcProps;

/**
* Memoized props — the component's props closure allocates a fresh object
* (it spreads `rest`), so calling it once per derived meant one allocation
* per derived per update. Read it once here instead.
*/
#props: ArcProps = $derived(this.#getProps());

ctx: ChartState = getChartContext();

trackRef = $state<SVGPathElement>();
Expand All @@ -115,7 +123,7 @@ export class ArcState {
const initial = getProps();
this.#motionEndAngle = createMotion(
initial.initialValue ?? 0,
() => getProps().value ?? 0,
() => this.#props.value ?? 0,
initial.motion
);
}
Expand All @@ -124,11 +132,11 @@ export class ArcState {
return this.#motionEndAngle.current;
}

range = $derived(this.#getProps().range ?? ([0, 360] as [number, number]));
domain = $derived(this.#getProps().domain ?? ([0, 100] as [number, number]));
range = $derived(this.#props.range ?? ([0, 360] as [number, number]));
domain = $derived(this.#props.domain ?? ([0, 100] as [number, number]));

endAngle = $derived.by(() => {
const props = this.#getProps();
const props = this.#props;
return (
props.endAngle ??
degreesToRadians(
Expand All @@ -141,9 +149,9 @@ export class ArcState {

chartRadius = $derived((Math.min(this.ctx.width, this.ctx.height) ?? 0) / 2);

outerRadius = $derived(getOuterRadius(this.#getProps().outerRadius, this.chartRadius));
outerRadius = $derived(getOuterRadius(this.#props.outerRadius, this.chartRadius));
trackOuterRadius = $derived.by(() => {
const trackOuterRadiusProp = this.#getProps().trackOuterRadius;
const trackOuterRadiusProp = this.#props.trackOuterRadius;
return trackOuterRadiusProp
? getOuterRadius(trackOuterRadiusProp, this.chartRadius)
: this.outerRadius;
Expand All @@ -157,34 +165,30 @@ export class ArcState {
return innerRadius;
}

innerRadius = $derived(this.#getInnerRadius(this.#getProps().innerRadius, this.outerRadius));
innerRadius = $derived(this.#getInnerRadius(this.#props.innerRadius, this.outerRadius));
trackInnerRadius = $derived.by(() => {
const trackInnerRadiusProp = this.#getProps().trackInnerRadius;
const trackInnerRadiusProp = this.#props.trackInnerRadius;
return trackInnerRadiusProp
? this.#getInnerRadius(trackInnerRadiusProp, this.trackOuterRadius)
: this.innerRadius;
});

startAngle = $derived(this.#getProps().startAngle ?? degreesToRadians(this.range[0]));
startAngle = $derived(this.#props.startAngle ?? degreesToRadians(this.range[0]));
trackStartAngle = $derived(
this.#getProps().trackStartAngle ??
this.#getProps().startAngle ??
degreesToRadians(this.range[0])
this.#props.trackStartAngle ?? this.#props.startAngle ?? degreesToRadians(this.range[0])
);
trackEndAngle = $derived(
this.#getProps().trackEndAngle ?? this.#getProps().endAngle ?? degreesToRadians(this.range[1])
);
trackCornerRadius = $derived(
this.#getProps().trackCornerRadius ?? this.#getProps().cornerRadius ?? 0
this.#props.trackEndAngle ?? this.#props.endAngle ?? degreesToRadians(this.range[1])
);
trackPadAngle = $derived(this.#getProps().trackPadAngle ?? this.#getProps().padAngle ?? 0);
trackCornerRadius = $derived(this.#props.trackCornerRadius ?? this.#props.cornerRadius ?? 0);
trackPadAngle = $derived(this.#props.trackPadAngle ?? this.#props.padAngle ?? 0);

arcEndAngle = $derived(
this.#getProps().endAngle ?? degreesToRadians(this.scale(this.motionEndAngleValue))
this.#props.endAngle ?? degreesToRadians(this.scale(this.motionEndAngleValue))
);

arc = $derived.by(() => {
const props = this.#getProps();
const props = this.#props;
return d3arc()
.innerRadius(this.innerRadius)
.outerRadius(this.outerRadius)
Expand All @@ -205,8 +209,8 @@ export class ArcState {
);

angle = $derived(((this.startAngle ?? 0) + (this.endAngle ?? 0)) / 2);
xOffset = $derived(Math.sin(this.angle) * (this.#getProps().offset ?? 0));
yOffset = $derived(-Math.cos(this.angle) * (this.#getProps().offset ?? 0));
xOffset = $derived(Math.sin(this.angle) * (this.#props.offset ?? 0));
yOffset = $derived(-Math.cos(this.angle) * (this.#props.offset ?? 0));

trackArcCentroid = $derived.by<[number, number]>(() => {
// @ts-expect-error - this is fine.
Expand Down Expand Up @@ -238,7 +242,7 @@ export class ArcState {
endAngle: () => this.arcEndAngle,
outerRadius: () => this.outerRadius + (opts.outerPadding ?? 0),
innerRadius: () => this.innerRadius - (opts.innerPadding ?? 0),
cornerRadius: () => this.#getProps().cornerRadius ?? 0,
cornerRadius: () => this.#props.cornerRadius ?? 0,
centroid: () => this.trackArcCentroid,
},
opts,
Expand Down
Loading
Loading