From 1c656ddfa1573f47946fd9cefd6dce663e76d8a5 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 13:15:49 -0400 Subject: [PATCH 1/6] perf(Chart): Resolve stacked value domain in a single pass (~5.5x faster) --- .changeset/quiet-donuts-shave.md | 5 ++++ .../layerchart/src/lib/states/chart.svelte.ts | 16 ++--------- .../src/lib/states/series.svelte.ts | 28 +++++++++++++++++++ 3 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 .changeset/quiet-donuts-shave.md diff --git a/.changeset/quiet-donuts-shave.md b/.changeset/quiet-donuts-shave.md new file mode 100644 index 000000000..1f269370f --- /dev/null +++ b/.changeset/quiet-donuts-shave.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Resolve stacked value domain in a single pass (~5.5x faster) diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index 3f026b015..e6ea28159 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -786,19 +786,9 @@ export class ChartState< if (this.valueAxis === axis && this.seriesState) { // For stacked series, collect all y0/y1 values for domain calculation if (this.seriesState.isStacked) { - const stackAccessor = (d: TData) => { - const values: number[] = []; - for (const s of this.seriesState.visibleSeries) { - const stackValue = this.seriesState.getStackValue(s.key, d); - if (stackValue) { - values.push(stackValue[0], stackValue[1]); - } - } - return values.length ? values : undefined; - }; - - // @ts-ignore - fix type - return extent(chartDataArray(this.data).flatMap(stackAccessor)); + // Collect in a single pass — see `getStackedValues`, which hoists the + // `keyBy` accessor and stack derived reads out of the per-row loop. + return extent(this.seriesState.getStackedValues(chartDataArray(this.data))); } // For non-default series, calculate domain from all visible series values diff --git a/packages/layerchart/src/lib/states/series.svelte.ts b/packages/layerchart/src/lib/states/series.svelte.ts index 32e563df6..a96f9f49d 100644 --- a/packages/layerchart/src/lib/states/series.svelte.ts +++ b/packages/layerchart/src/lib/states/series.svelte.ts @@ -216,6 +216,34 @@ export class SeriesState { return this.#stackMap.get(catKey)?.get(seriesKey) ?? null; } + /** + * Collect every stacked [y0, y1] value across `rows` for the visible series. + * + * Calling `getStackValue()` once per row per series rebuilds the `keyBy` + * accessor and re-reads the `#stackMap`/`#stackConfig` deriveds O(rows × series) + * times. Hoisting both out of the loop measured ~5.5x faster across 30–1000 rows. + */ + getStackedValues(rows: TData[]): number[] { + const stackMap = this.#stackMap; + const config = this.#stackConfig; + if (!stackMap || !config) return []; + + const keyByAcc = accessor(config.keyBy); + const visibleKeys = this.visibleSeries.map((s) => s.key); + const values: number[] = []; + + for (const d of rows) { + const seriesMap = stackMap.get(keyByAcc(d)); + if (!seriesMap) continue; + for (const key of visibleKeys) { + const stackValue = seriesMap.get(key); + if (stackValue) values.push(stackValue[0], stackValue[1]); + } + } + + return values; + } + /** * Create stack-aware y0/y1 accessor functions for a series. * Use these in Area, Bars, etc. when stacking is enabled. From 5ec07c2ec057c689eb0a89f51412a67c3943c545 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 13:36:51 -0400 Subject: [PATCH 2/6] perf: Memoize props in component state classes (~3x faster ``, ~2x faster `LineChart` mount) --- .changeset/olive-moons-repeat.md | 5 ++ .../lib/components/Arc/Arc.shared.svelte.ts | 48 ++++++------ .../ArcLabel/ArcLabel.shared.svelte.ts | 23 ++++-- .../lib/components/Area/Area.shared.svelte.ts | 28 ++++--- .../lib/components/Axis/Axis.shared.svelte.ts | 48 +++++++----- .../lib/components/Bar/Bar.shared.svelte.ts | 44 ++++++----- .../Ellipse/Ellipse.shared.svelte.ts | 48 ++++++------ .../lib/components/Grid/Grid.shared.svelte.ts | 20 +++-- .../components/Group/Group.shared.svelte.ts | 25 ++++--- .../Highlight/Highlight.shared.svelte.ts | 20 +++-- .../components/Image/Image.shared.svelte.ts | 51 +++++++------ .../lib/components/Line/Line.shared.svelte.ts | 50 ++++++------- .../components/Points/Points.shared.svelte.ts | 26 ++++--- .../lib/components/Rect/Rect.shared.svelte.ts | 75 ++++++++----------- .../components/Spline/Spline.shared.svelte.ts | 32 ++++---- .../lib/components/Text/Text.shared.svelte.ts | 66 ++++++++-------- .../components/Waffle/Waffle.shared.svelte.ts | 42 ++++++----- 17 files changed, 357 insertions(+), 294 deletions(-) create mode 100644 .changeset/olive-moons-repeat.md diff --git a/.changeset/olive-moons-repeat.md b/.changeset/olive-moons-repeat.md new file mode 100644 index 000000000..28feb42b9 --- /dev/null +++ b/.changeset/olive-moons-repeat.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf: Memoize props in component state classes (~3x faster ``, ~2x faster `LineChart` mount) diff --git a/packages/layerchart/src/lib/components/Arc/Arc.shared.svelte.ts b/packages/layerchart/src/lib/components/Arc/Arc.shared.svelte.ts index c4c1324c6..8eb8e0dfe 100644 --- a/packages/layerchart/src/lib/components/Arc/Arc.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Arc/Arc.shared.svelte.ts @@ -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(); @@ -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 ); } @@ -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( @@ -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; @@ -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) @@ -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. @@ -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, diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts index 84508ea1e..dee510483 100644 --- a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts @@ -65,17 +65,24 @@ export type ArcLabelCalloutGeometry = { export class ArcLabelState { #getProps: () => ArcLabelProps = () => ({}) as ArcLabelProps; + /** + * 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: ArcLabelProps = $derived(this.#getProps()); + constructor(getProps: () => ArcLabelProps) { this.#getProps = getProps; } midAngle = $derived.by(() => { - const { startAngle, endAngle } = this.#getProps(); + const { startAngle, endAngle } = this.#props; return startAngle != null && endAngle != null ? (startAngle + endAngle) / 2 : 0; }); offsetCentroid = $derived.by<[number, number] | undefined>(() => { - const { centroid, offset = 0, startAngle, endAngle } = this.#getProps(); + const { centroid, offset = 0, startAngle, endAngle } = this.#props; if (!centroid) return centroid; if (!offset || startAngle == null || endAngle == null) return centroid; const angle = this.midAngle - Math.PI / 2; @@ -83,7 +90,7 @@ export class ArcLabelState { }); effectiveOuterPadding = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const base = props.outerPadding ?? 0; if (props.placement === 'outer' || props.placement === 'middle') { return base + (props.offset ?? 0); @@ -92,17 +99,17 @@ export class ArcLabelState { }); effectiveInnerPadding = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (props.placement === 'inner' || props.placement === 'middle') return props.offset ?? 0; return 0; }); effectiveCalloutLineLength = $derived( - (this.#getProps().calloutLineLength ?? 16) + (this.#getProps().offset ?? 0) + (this.#props.calloutLineLength ?? 16) + (this.#props.offset ?? 0) ); centroidRotation = $derived.by(() => { - const { startAngle, endAngle, placement } = this.#getProps(); + const { startAngle, endAngle, placement } = this.#props; if (startAngle == null || endAngle == null) return 0; let deg = radiansToDegrees(this.midAngle); if (placement === 'centroid-radial') { @@ -124,7 +131,7 @@ export class ArcLabelState { outerRadius, calloutLabelOffset = 12, calloutPadding = 4, - } = this.#getProps(); + } = this.#props; if (placement !== 'callout' || startAngle == null || endAngle == null || outerRadius == null) { return null; } @@ -153,7 +160,7 @@ export class ArcLabelState { }); arcTextProps = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const { placement = 'centroid', startOffset, outerPadding, getArcTextProps } = props; if (placement === 'centroid') { diff --git a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts index a7c3f7c97..d58e5931c 100644 --- a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts @@ -49,6 +49,14 @@ export type AreaProps = AreaPropsWithoutHTML & */ export class AreaState { #getProps: () => AreaProps = () => ({}) as AreaProps; + + /** + * 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: AreaProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); #tweenState!: ReturnType>; @@ -61,7 +69,7 @@ export class AreaState { name: 'Area', kind: 'composite-mark', markInfo: () => { - const p = getProps(); + const p = this.#props; return { data: p.data, x: p.x, @@ -87,26 +95,26 @@ export class AreaState { ); } - series = $derived(this.ctx.series.series.find((s) => s.key === this.#getProps().seriesKey)); + series = $derived(this.ctx.series.series.find((s) => s.key === this.#props.seriesKey)); seriesData = $derived(this.series?.data); seriesAccessor = $derived( this.series?.value ?? (this.series?.data ? undefined : this.series?.key) ); stackAccessors = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey && this.ctx.series.isStacked ? this.ctx.series.getStackAccessors(seriesKey) : null; }); xAccessor = $derived.by(() => { - const x = this.#getProps().x; + const x = this.#props.x; return x ? accessor(x) : this.ctx.x; }); y0Accessor = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (props.y0) return accessor(props.y0); if (this.stackAccessors) return this.stackAccessors.y0; if (Array.isArray(this.seriesAccessor)) return accessor(this.seriesAccessor[0]); @@ -118,7 +126,7 @@ export class AreaState { }); y1Accessor = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (props.y1) return accessor(props.y1); if (this.stackAccessors) return this.stackAccessors.y1; if (Array.isArray(this.seriesAccessor)) return accessor(this.seriesAccessor[1]); @@ -129,13 +137,13 @@ export class AreaState { return this.ctx.y; }); - resolvedData = $derived(this.#getProps().data ?? this.seriesData ?? this.ctx.data); + resolvedData = $derived(this.#props.data ?? this.seriesData ?? this.ctx.data); xOffset = $derived(isScaleBand(this.ctx.xScale) ? this.ctx.xScale.bandwidth() / 2 : 0); yOffset = $derived(isScaleBand(this.ctx.yScale) ? this.ctx.yScale.bandwidth() / 2 : 0); #defaultPathData(tweenOptions: ResolvedMotion | undefined): string { - const props = this.#getProps(); + const props = this.#props; if (!tweenOptions) return ''; if (props.pathData) { return flattenPathData(props.pathData, Math.min(this.ctx.yScale(0), this.ctx.yRange[0])); @@ -162,7 +170,7 @@ export class AreaState { } d = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const _path = this.ctx.radial ? areaRadial() .angle((d) => this.ctx.xScale(this.xAccessor(d))) @@ -186,7 +194,7 @@ export class AreaState { } lineYAccessor = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (this.stackAccessors && this.ctx.series.stackLayout === 'stackDiverging') { const firstPoint = this.resolvedData?.[0]; if (firstPoint) { diff --git a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts index ec315e9b4..eed5362b7 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts @@ -173,6 +173,14 @@ export type AxisTickItem = { */ export class AxisState { #getProps: () => AxisProps = () => ({}) as AxisProps; + + /** + * 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: AxisProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); constructor(getProps: () => AxisProps) { @@ -183,7 +191,7 @@ export class AxisState { // --- Derived from placement --- orientation = $derived.by(() => { - const placement = this.#getProps().placement; + const placement = this.#props.placement; return placement === 'angle' ? 'angle' : placement === 'radius' @@ -194,7 +202,7 @@ export class AxisState { }); scale = $derived.by(() => { - const scaleProp = this.#getProps().scale; + const scaleProp = this.#props.scale; return ( scaleProp ?? (['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale) @@ -206,7 +214,7 @@ export class AxisState { ); defaultTickSpacing = $derived.by(() => { - const placement = this.#getProps().placement; + const placement = this.#props.placement; return ['top', 'bottom', 'angle'].includes(placement) ? 80 : ['left', 'right', 'radius'].includes(placement) @@ -215,14 +223,14 @@ export class AxisState { }); tickSpacing = $derived.by(() => { - const tickSpacingProp = this.#getProps().tickSpacing; + const tickSpacingProp = this.#props.tickSpacing; if (tickSpacingProp !== undefined) return tickSpacingProp; if (isScaleBand(this.scale) && this.interval == null) return null; return this.defaultTickSpacing; }); resolvedFormat = $derived.by(() => { - const format = this.#getProps().format; + const format = this.#props.format; if (format !== undefined) return format; if (this.ctx.series.stackLayout === 'stackExpand') { @@ -260,7 +268,7 @@ export class AxisState { }); tickCount = $derived.by(() => { - const ticks = this.#getProps().ticks; + const ticks = this.#props.ticks; if (typeof ticks === 'number') return ticks; if (this.tickSpacing && this.effectiveSize) return Math.round(this.effectiveSize / this.tickSpacing); @@ -268,7 +276,7 @@ export class AxisState { }); formatCount = $derived.by(() => { - const ticks = this.#getProps().ticks; + const ticks = this.#props.ticks; if (typeof ticks === 'number') return ticks; if (this.defaultTickSpacing && this.effectiveSize) return Math.round(this.effectiveSize / this.defaultTickSpacing); @@ -276,7 +284,7 @@ export class AxisState { }); tickVals = $derived.by(() => { - const ticks = this.#getProps().ticks; + const ticks = this.#props.ticks; let tickVals = autoTickVals(this.scale, ticks, this.tickCount); if (this.interval != null) { @@ -316,16 +324,16 @@ export class AxisState { tickFormat = $derived.by(() => autoTickFormat({ scale: this.scale, - ticks: this.#getProps().ticks, + ticks: this.#props.ticks, count: this.formatCount, formatType: this.resolvedFormat, - multiline: this.#getProps().tickMultiline ?? false, - placement: this.#getProps().placement, + multiline: this.#props.tickMultiline ?? false, + placement: this.#props.placement, }) ); getCoords(tick: any): { x: number; y: number } { - const placement = this.#getProps().placement; + const placement = this.#props.placement; const scale = this.scale; switch (placement) { case 'top': @@ -370,7 +378,7 @@ export class AxisState { } getDefaultTickLabelProps(tick: any): Partial { - const { placement, tickLength = 4 } = this.#getProps(); + const { placement, tickLength = 4 } = this.#props; // Cap-height anchoring (`verticalAnchor` start/end, see Text `startDy`) places the label // edge exactly `tickLength` from the axis, leaving no gap to the tick. Add a little padding // above/below so the label clears the tick — matching the `left`/`right` visual, whose @@ -433,7 +441,7 @@ export class AxisState { } resolvedLabelX = $derived.by(() => { - const { placement, labelPlacement = 'middle' } = this.#getProps(); + const { placement, labelPlacement = 'middle' } = this.#props; if (placement === 'left' || (this.orientation === 'horizontal' && labelPlacement === 'start')) { return -this.ctx.padding.left; } else if ( @@ -446,7 +454,7 @@ export class AxisState { }); resolvedLabelY = $derived.by(() => { - const { placement, labelPlacement = 'middle' } = this.#getProps(); + const { placement, labelPlacement = 'middle' } = this.#props; if (placement === 'top' || (this.orientation === 'vertical' && labelPlacement === 'start')) { return -this.ctx.padding.top; } else if (this.orientation === 'vertical' && labelPlacement === 'middle') { @@ -458,7 +466,7 @@ export class AxisState { }); resolvedLabelTextAnchor = $derived.by(() => { - const { placement, labelPlacement = 'middle' } = this.#getProps(); + const { placement, labelPlacement = 'middle' } = this.#props; if (labelPlacement === 'middle') return 'middle'; if (placement === 'right' || (this.orientation === 'horizontal' && labelPlacement === 'end')) return 'end'; @@ -466,7 +474,7 @@ export class AxisState { }); resolvedLabelVerticalAnchor = $derived.by(() => { - const { placement, labelPlacement = 'middle' } = this.#getProps(); + const { placement, labelPlacement = 'middle' } = this.#props; if ( placement === 'top' || (this.orientation === 'vertical' && labelPlacement === 'start') || @@ -485,7 +493,7 @@ export class AxisState { stroke, fill, classes = {}, - } = this.#getProps(); + } = this.#props; return { value: typeof label === 'function' ? '' : label, x: this.resolvedLabelX, @@ -503,13 +511,13 @@ export class AxisState { }); tickItems = $derived.by(() => { - const { motion, stroke, fill, tickLabelProps, classes = {} } = this.#getProps(); + const { motion, stroke, fill, tickLabelProps, classes = {} } = this.#props; return this.tickVals.map((tick, index) => { const tickCoords = this.getCoords(tick); const [radialTickCoordsX, radialTickCoordsY] = pointRadial(tickCoords.x, tickCoords.y); const [radialTickMarkCoordsX, radialTickMarkCoordsY] = pointRadial( tickCoords.x, - tickCoords.y + (this.#getProps().tickLength ?? 4) + tickCoords.y + (this.#props.tickLength ?? 4) ); const labelProps: TextProps = { x: this.orientation === 'angle' ? radialTickCoordsX : tickCoords.x, diff --git a/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts b/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts index cf009cc99..bfa6ca0d7 100644 --- a/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts @@ -63,6 +63,14 @@ export type BarProps = BarPropsWithoutHTML & */ export class BarState { #getProps: () => BarProps = () => ({}) as BarProps; + + /** + * 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: BarProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); constructor(getProps: () => BarProps) { @@ -70,7 +78,7 @@ export class BarState { } series = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey ? this.ctx.series.series.find((s) => s.key === seriesKey) : undefined; }); @@ -81,14 +89,14 @@ export class BarState { ); stackAccessors = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey && this.ctx.series.isStacked ? this.ctx.series.getStackAccessors(seriesKey) : null; }); x = $derived.by(() => { - const xProp = this.#getProps().x; + const xProp = this.#props.x; return ( xProp ?? (this.ctx.valueAxis === 'x' @@ -98,7 +106,7 @@ export class BarState { ); }); y = $derived.by(() => { - const yProp = this.#getProps().y; + const yProp = this.#props.y; return ( yProp ?? (this.ctx.valueAxis === 'y' @@ -107,11 +115,11 @@ export class BarState { this.ctx.y ); }); - x1 = $derived(this.#getProps().x1); - y1 = $derived(this.#getProps().y1); + x1 = $derived(this.#props.x1); + y1 = $derived(this.#props.y1); seriesIndex = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey ? this.ctx.series.visibleSeries.findIndex((s) => s.key === seriesKey) : undefined; @@ -119,7 +127,7 @@ export class BarState { seriesCount = $derived(this.ctx.series.visibleSeries.length); stackInsets = $derived.by(() => { - const stackPadding = this.#getProps().stackPadding ?? 0; + const stackPadding = this.#props.stackPadding ?? 0; if (!this.ctx.series.isStacked || stackPadding === 0 || this.seriesIndex === undefined) { return undefined; } @@ -140,7 +148,7 @@ export class BarState { }; }); - insets = $derived(this.#getProps().insets ?? this.stackInsets); + insets = $derived(this.#props.insets ?? this.stackInsets); getDimensions = $derived( createDimensionGetter(this.ctx, () => ({ @@ -153,12 +161,12 @@ export class BarState { ); scaleDimensions = $derived( - this.getDimensions(this.#getProps().data) ?? { x: 0, y: 0, width: 0, height: 0 } + this.getDimensions(this.#props.data) ?? { x: 0, y: 0, width: 0, height: 0 } ); dimensions = $derived.by(() => { let { x, y, width, height } = this.scaleDimensions; - const props = this.#getProps(); + const props = this.#props; if (props.width != null) { x = x + (width - props.width) / 2; @@ -175,13 +183,13 @@ export class BarState { valueAccessor = $derived(accessor(this.ctx.valueAxis === 'y' ? this.y : this.x)); resolvedValue = $derived.by(() => { - const value = this.valueAccessor(this.#getProps().data); + const value = this.valueAccessor(this.#props.data); return Array.isArray(value) ? greatestAbs(value) : value; }); /** Resolved `rounded="edge"` based on orientation and value */ rounded = $derived.by(() => { - const roundedProp = this.#getProps().rounded ?? 'all'; + const roundedProp = this.#props.rounded ?? 'all'; if (roundedProp !== 'edge') return roundedProp; if (this.ctx.valueAxis === 'y') { return this.resolvedValue >= 0 && this.ctx.yRange[0] > this.ctx.yRange[1] ? 'top' : 'bottom'; @@ -190,7 +198,7 @@ export class BarState { }); corners = $derived.by<[number, number, number, number]>(() => { - const radius = this.#getProps().radius ?? 0; + const radius = this.#props.radius ?? 0; const rounded = this.rounded; const topLeft = ['all', 'top', 'left', 'top-left'].includes(rounded); const topRight = ['all', 'top', 'right', 'top-right'].includes(rounded); @@ -205,7 +213,7 @@ export class BarState { }); resolvedInitialY = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; return ( props.initialY ?? (props.motion && this.ctx.valueAxis === 'y' @@ -214,11 +222,11 @@ export class BarState { ); }); resolvedInitialHeight = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; return props.initialHeight ?? (props.motion && this.ctx.valueAxis === 'y' ? 0 : undefined); }); resolvedInitialX = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; return ( props.initialX ?? (props.motion && this.ctx.valueAxis === 'x' @@ -227,7 +235,7 @@ export class BarState { ); }); resolvedInitialWidth = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; return props.initialWidth ?? (props.motion && this.ctx.valueAxis === 'x' ? 0 : undefined); }); } diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts b/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts index 762f9dec4..65f375776 100644 --- a/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts @@ -61,25 +61,27 @@ export function ellipseMarkInfo(props: EllipseProps, dataMode: boolean) { export class EllipseState { #getProps: () => EllipseProps = () => ({}) as EllipseProps; + /** + * 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: EllipseProps = $derived(this.#getProps()); + chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); dataMode = $derived( - hasAnyDataProp( - this.#getProps().cx, - this.#getProps().cy, - this.#getProps().rx, - this.#getProps().ry - ) + hasAnyDataProp(this.#props.cx, this.#props.cy, this.#props.rx, this.#props.ry) ); #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -97,7 +99,7 @@ export class EllipseState { }); #resolveEllipse(d: any) { - const props = this.#getProps(); + const props = this.#props; if (this.geo.projection) { const [projX, projY] = resolveGeoDataPair(props.cx, props.cy, d, this.geo.projection); return { @@ -155,29 +157,25 @@ export class EllipseState { } staticFill = $derived( - typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + typeof this.#props.fill === 'string' ? (this.#props.fill as string) : undefined ); staticFillOpacity = $derived( - typeof this.#getProps().fillOpacity === 'number' - ? (this.#getProps().fillOpacity as number) - : undefined + typeof this.#props.fillOpacity === 'number' ? (this.#props.fillOpacity as number) : undefined ); staticStroke = $derived( - typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + typeof this.#props.stroke === 'string' ? (this.#props.stroke as string) : undefined ); staticStrokeWidth = $derived( - typeof this.#getProps().strokeWidth === 'number' - ? (this.#getProps().strokeWidth as number) - : undefined + typeof this.#props.strokeWidth === 'number' ? (this.#props.strokeWidth as number) : undefined ); staticOpacity = $derived( - typeof this.#getProps().opacity === 'number' ? (this.#getProps().opacity as number) : undefined + typeof this.#props.opacity === 'number' ? (this.#props.opacity as number) : undefined ); staticClassName = $derived( - typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + typeof this.#props.class === 'string' ? (this.#props.class as string) : undefined ); staticBorderWidth = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (typeof props.strokeWidth === 'number') return `${props.strokeWidth}px`; if (typeof props.stroke === 'string') return '1px'; return undefined; @@ -194,22 +192,22 @@ export class EllipseState { this.#motionCx = createMotion( initialCx, - () => (typeof getProps().cx === 'number' ? (getProps().cx as number) : 0), + () => (typeof this.#props.cx === 'number' ? (this.#props.cx as number) : 0), initial.motion ); this.#motionCy = createMotion( initialCy, - () => (typeof getProps().cy === 'number' ? (getProps().cy as number) : 0), + () => (typeof this.#props.cy === 'number' ? (this.#props.cy as number) : 0), initial.motion ); this.#motionRx = createMotion( initialRx, - () => (typeof getProps().rx === 'number' ? (getProps().rx as number) : 1), + () => (typeof this.#props.rx === 'number' ? (this.#props.rx as number) : 1), initial.motion ); this.#motionRy = createMotion( initialRy, - () => (typeof getProps().ry === 'number' ? (getProps().ry as number) : 1), + () => (typeof this.#props.ry === 'number' ? (this.#props.ry as number) : 1), initial.motion ); @@ -218,7 +216,7 @@ export class EllipseState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts b/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts index 133f8bad1..f558e74a4 100644 --- a/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts @@ -110,6 +110,14 @@ export type GridProps = Omit< */ export class GridState { #getProps: () => GridProps = () => ({}) as GridProps; + + /** + * 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: GridProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); constructor(getProps: () => GridProps) { @@ -118,20 +126,20 @@ export class GridState { this.ctx.registerComponent({ name: 'Grid', kind: 'composite-mark' }); } - yTicks = $derived(this.#getProps().yTicks ?? (!isScaleBand(this.ctx.yScale) ? 4 : undefined)); + yTicks = $derived(this.#props.yTicks ?? (!isScaleBand(this.ctx.yScale) ? 4 : undefined)); - tweenConfig = $derived(extractTweenConfig(this.#getProps().motion)); + tweenConfig = $derived(extractTweenConfig(this.#props.motion)); defaultTransitionIn = $derived( - (this.#getProps().transitionIn ?? this.tweenConfig?.options) ? fade : () => ({}) + (this.#props.transitionIn ?? this.tweenConfig?.options) ? fade : () => ({}) ); defaultTransitionInParams: TransitionParams = { easing: cubicIn }; - xTickVals = $derived(autoTickVals(this.ctx.xScale, this.#getProps().xTicks)); + xTickVals = $derived(autoTickVals(this.ctx.xScale, this.#props.xTicks)); yTickVals = $derived(autoTickVals(this.ctx.yScale, this.yTicks)); xBandOffset = $derived.by(() => { - const bandAlign = this.#getProps().bandAlign ?? 'center'; + const bandAlign = this.#props.bandAlign ?? 'center'; if (!isScaleBand(this.ctx.xScale)) return 0; return bandAlign === 'between' ? -(this.ctx.xScale.padding() * this.ctx.xScale.step()) / 2 @@ -139,7 +147,7 @@ export class GridState { }); yBandOffset = $derived.by(() => { - const bandAlign = this.#getProps().bandAlign ?? 'center'; + const bandAlign = this.#props.bandAlign ?? 'center'; if (!isScaleBand(this.ctx.yScale)) return 0; return bandAlign === 'between' ? -(this.ctx.yScale.padding() * this.ctx.yScale.step()) / 2 diff --git a/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts b/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts index 4981307a3..ba692742e 100644 --- a/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts @@ -100,19 +100,26 @@ const defaultKey = (_: any, i: number) => i; export class GroupState { #getProps: () => GroupProps = () => ({}) as GroupProps; + /** + * 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: GroupProps = $derived(this.#getProps()); + chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); // Data mode detection - dataMode = $derived(hasAnyDataProp(this.#getProps().x, this.#getProps().y)); + dataMode = $derived(hasAnyDataProp(this.#props.x, this.#props.y)); #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -128,7 +135,7 @@ export class GroupState { }); #resolveGroup(d: any): { x: number; y: number } { - const props = this.#getProps(); + const props = this.#props; if (this.geo.projection) { const [projX, projY] = resolveGeoDataPair(props.x, props.y, d, this.geo.projection); return { x: projX, y: projY }; @@ -141,14 +148,14 @@ export class GroupState { // Pixel-mode position (with center support) trueX = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (typeof props.x === 'number') return props.x; if (props.x == null && (props.center === 'x' || props.center === true)) return this.chartCtx.width / 2; return 0; }); trueY = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (typeof props.y === 'number') return props.y; if (props.y == null && (props.center === 'y' || props.center === true)) return this.chartCtx.height / 2; @@ -168,7 +175,7 @@ export class GroupState { // Transform string for SVG/HTML pixel mode transform = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (props.center || props.x != null || props.y != null) { return `translate(${this.motionX}px, ${this.motionY}px)`; } @@ -177,7 +184,7 @@ export class GroupState { // Default transition (fade when motion is tweened) defaultTransitionIn = $derived( - extractTweenConfig(this.#getProps().motion)?.options ? fade : () => ({}) + extractTweenConfig(this.#props.motion)?.options ? fade : () => ({}) ); defaultTransitionInParams = { easing: cubicIn }; @@ -196,7 +203,7 @@ export class GroupState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts index bb0887b00..b4402661a 100644 --- a/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts @@ -111,16 +111,24 @@ export type HighlightProps = HighlightPropsWithoutHTML; */ export class HighlightState { #getProps: () => HighlightProps = () => ({}) as HighlightProps; + + /** + * 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: HighlightProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); constructor(getProps: () => HighlightProps) { this.#getProps = getProps; } - x = $derived(accessor(this.#getProps().x ?? this.ctx.x)); - y = $derived(accessor(this.#getProps().y ?? this.ctx.y)); + x = $derived(accessor(this.#props.x ?? this.ctx.x)); + y = $derived(accessor(this.#props.y ?? this.ctx.y)); - highlightData = $derived(this.#getProps().data ?? this.ctx.tooltip.data); + highlightData = $derived(this.#props.data ?? this.ctx.tooltip.data); xValue = $derived(this.x(this.highlightData)); xCoord = $derived( @@ -153,7 +161,7 @@ export class HighlightState { ); axis = $derived.by(() => { - const axisProp = this.#getProps().axis; + const axisProp = this.#props.axis; return axisProp == null ? isScaleBand(this.ctx.yScale) || isScaleTime(this.ctx.yScale) || this.ctx.valueAxis === 'x' ? 'y' @@ -163,7 +171,7 @@ export class HighlightState { /** Resolve radius for a data item using the chart's rScale */ getPointRadius(d: any): number | undefined { - const rProp = this.#getProps().r; + const rProp = this.#props.r; if (!rProp || !d) return undefined; if (rProp === true) { return this.ctx.config.r ? this.ctx.rGet(d) : undefined; @@ -310,7 +318,7 @@ export class HighlightState { points = $derived.by(() => { let tmpPoints: HighlightPoint[] = []; if (!this.highlightData) return tmpPoints; - const props = this.#getProps(); + const props = this.#props; if (props.data === undefined && this.ctx.tooltip.series.length > 0) { tmpPoints = this.ctx.tooltip.series diff --git a/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts b/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts index db3c01914..ff9c263d2 100644 --- a/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts @@ -70,25 +70,32 @@ export function imageMarkInfo(props: ImageProps, dataMode: boolean) { export class ImageState { #getProps: () => ImageProps = () => ({}) as ImageProps; + /** + * 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: ImageProps = $derived(this.#getProps()); + chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); dataMode = $derived( hasAnyDataProp( - this.#getProps().x, - this.#getProps().y, - this.#getProps().width, - this.#getProps().height, - this.#getProps().r - ) || typeof this.#getProps().href === 'function' + this.#props.x, + this.#props.y, + this.#props.width, + this.#props.height, + this.#props.r + ) || typeof this.#props.href === 'function' ); #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolveImage(d: any) { - const props = this.#getProps(); + const props = this.#props; const resolvedR = props.r !== undefined ? resolveDataProp(props.r, d, null, 0) : undefined; const defaultSize = resolvedR !== undefined ? resolvedR * 2 : 16; const resolvedWidth = @@ -117,7 +124,7 @@ export class ImageState { } resolveHref(d: any): string | undefined { - const href = this.#getProps().href; + const href = this.#props.href; if (!href) return undefined; if (typeof href === 'function') return href(d); const dataValue = get(d, href); @@ -127,7 +134,7 @@ export class ImageState { resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -147,24 +154,16 @@ export class ImageState { }); // Pixel-mode helpers - defaultSize = $derived( - typeof this.#getProps().r === 'number' ? (this.#getProps().r as number) * 2 : 16 - ); + defaultSize = $derived(typeof this.#props.r === 'number' ? (this.#props.r as number) * 2 : 16); resolvedPixelWidth = $derived( - typeof this.#getProps().width === 'number' - ? (this.#getProps().width as number) - : this.defaultSize + typeof this.#props.width === 'number' ? (this.#props.width as number) : this.defaultSize ); resolvedPixelHeight = $derived( - typeof this.#getProps().height === 'number' - ? (this.#getProps().height as number) - : this.defaultSize - ); - pixelR = $derived( - typeof this.#getProps().r === 'number' ? (this.#getProps().r as number) : undefined + typeof this.#props.height === 'number' ? (this.#props.height as number) : this.defaultSize ); + pixelR = $derived(typeof this.#props.r === 'number' ? (this.#props.r as number) : undefined); pixelRotate = $derived( - typeof this.#getProps().rotate === 'number' ? (this.#getProps().rotate as number) : undefined + typeof this.#props.rotate === 'number' ? (this.#props.rotate as number) : undefined ); #dataMotionMap: ReturnType = null; @@ -210,12 +209,12 @@ export class ImageState { this.#motionX = createMotion( initialX, - () => (typeof getProps().x === 'number' ? (getProps().x as number) : 0), + () => (typeof this.#props.x === 'number' ? (this.#props.x as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'x') ); this.#motionY = createMotion( initialY, - () => (typeof getProps().y === 'number' ? (getProps().y as number) : 0), + () => (typeof this.#props.y === 'number' ? (this.#props.y as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'y') ); this.#motionWidth = createMotion( @@ -234,7 +233,7 @@ export class ImageState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts b/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts index c4c1e901b..314f1ac10 100644 --- a/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts @@ -132,27 +132,29 @@ export function lineMarkInfo(props: LineProps, dataMode: boolean) { export class LineState { #getProps: () => LineProps = () => ({}) as LineProps; + /** + * 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: LineProps = $derived(this.#getProps()); + // Contexts chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); // Data mode detection dataMode = $derived( - hasAnyDataProp( - this.#getProps().x1, - this.#getProps().y1, - this.#getProps().x2, - this.#getProps().y2 - ) + hasAnyDataProp(this.#props.x1, this.#props.y1, this.#props.x2, this.#props.y2) ); #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -170,7 +172,7 @@ export class LineState { }); #resolveLine(d: any): { x1: number; y1: number; x2: number; y2: number } { - const props = this.#getProps(); + const props = this.#props; if (this.geo.projection) { const [projX1, projY1] = resolveGeoDataPair(props.x1, props.y1, d, this.geo.projection); const [projX2, projY2] = resolveGeoDataPair(props.x2, props.y2, d, this.geo.projection); @@ -185,7 +187,7 @@ export class LineState { } // Dash array - dashArrayResolved = $derived(parseDashArray(this.#getProps().dashArray)); + dashArrayResolved = $derived(parseDashArray(this.#props.dashArray)); dashArrayAttr = $derived(this.dashArrayResolved ? this.dashArrayResolved.join(' ') : undefined); // Pixel-mode motion sources @@ -210,30 +212,26 @@ export class LineState { // Static (non-data-driven) values for SVG/HTML pixel mode staticFill = $derived( - typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + typeof this.#props.fill === 'string' ? (this.#props.fill as string) : undefined ); staticFillOpacity = $derived( - typeof this.#getProps().fillOpacity === 'number' - ? (this.#getProps().fillOpacity as number) - : undefined + typeof this.#props.fillOpacity === 'number' ? (this.#props.fillOpacity as number) : undefined ); staticStroke = $derived( - typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + typeof this.#props.stroke === 'string' ? (this.#props.stroke as string) : undefined ); staticStrokeWidth = $derived( - typeof this.#getProps().strokeWidth === 'number' - ? (this.#getProps().strokeWidth as number) - : undefined + typeof this.#props.strokeWidth === 'number' ? (this.#props.strokeWidth as number) : undefined ); staticOpacity = $derived( - typeof this.#getProps().opacity === 'number' ? (this.#getProps().opacity as number) : undefined + typeof this.#props.opacity === 'number' ? (this.#props.opacity as number) : undefined ); staticClassName = $derived( - typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + typeof this.#props.class === 'string' ? (this.#props.class as string) : undefined ); // For HTML rendering: stroke-width fallback as div height staticHeight = $derived( - typeof this.#getProps().strokeWidth === 'number' ? `${this.#getProps().strokeWidth}px` : '1px' + typeof this.#props.strokeWidth === 'number' ? `${this.#props.strokeWidth}px` : '1px' ); constructor(getProps: () => LineProps) { @@ -247,22 +245,22 @@ export class LineState { this.#motionX1 = createMotion( initialX1, - () => (typeof getProps().x1 === 'number' ? (getProps().x1 as number) : 0), + () => (typeof this.#props.x1 === 'number' ? (this.#props.x1 as number) : 0), initial.motion ); this.#motionY1 = createMotion( initialY1, - () => (typeof getProps().y1 === 'number' ? (getProps().y1 as number) : 0), + () => (typeof this.#props.y1 === 'number' ? (this.#props.y1 as number) : 0), initial.motion ); this.#motionX2 = createMotion( initialX2, - () => (typeof getProps().x2 === 'number' ? (getProps().x2 as number) : 0), + () => (typeof this.#props.x2 === 'number' ? (this.#props.x2 as number) : 0), initial.motion ); this.#motionY2 = createMotion( initialY2, - () => (typeof getProps().y2 === 'number' ? (getProps().y2 as number) : 0), + () => (typeof this.#props.y2 === 'number' ? (this.#props.y2 as number) : 0), initial.motion ); @@ -271,7 +269,7 @@ export class LineState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts index 7a2ca4b98..fb3ce4867 100644 --- a/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts @@ -50,6 +50,14 @@ export type PointsProps = PointsPropsWithoutHTML & */ export class PointsState { #getProps: () => PointsProps = () => ({}) as PointsProps; + + /** + * 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: PointsProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); @@ -59,7 +67,7 @@ export class PointsState { name: 'Points', kind: 'mark', markInfo: () => { - const p = getProps(); + const p = this.#props; return { data: p.data, x: p.x, @@ -71,13 +79,13 @@ export class PointsState { }); } - series = $derived(this.ctx.series.series.find((s) => s.key === this.#getProps().seriesKey)); + series = $derived(this.ctx.series.series.find((s) => s.key === this.#props.seriesKey)); seriesAccessor = $derived( this.series?.value ?? (this.series?.data ? undefined : this.series?.key) ); stackAccessors = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey && this.ctx.series.isStacked ? this.ctx.series.getStackAccessors(seriesKey) : null; @@ -85,14 +93,12 @@ export class PointsState { xAccessor = $derived( accessor( - this.#getProps().x ?? - (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? - this.ctx.x + this.#props.x ?? (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? this.ctx.x ) ); yAccessor = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (props.y) return accessor(props.y); if (this.stackAccessors) return this.stackAccessors.y1; if (Array.isArray(this.seriesAccessor) && this.ctx.valueAxis === 'y') { @@ -101,10 +107,10 @@ export class PointsState { return accessor((this.ctx.valueAxis === 'y' ? this.seriesAccessor : undefined) ?? this.ctx.y); }); - pointsData = $derived(this.#getProps().data ?? this.series?.data ?? this.ctx.data); + pointsData = $derived(this.#props.data ?? this.series?.data ?? this.ctx.data); #getOffset(value: any, offset: Offset, scale: AnyScale, subScale?: AnyScale): number { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; if (typeof offset === 'function') { return offset(value, this.ctx); } else if (offset != null) { @@ -118,7 +124,7 @@ export class PointsState { } #getPointObject(xVal: number, yVal: number, d: any, edgeIndex?: number): Point { - const props = this.#getProps(); + const props = this.#props; // In a geo chart, project the [x, y] pair directly (no band offsets / radial) if (this.geo.projection) { diff --git a/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts b/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts index e854d48dd..19f42eed0 100644 --- a/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts @@ -184,36 +184,33 @@ export function rectMarkInfo(props: RectProps, dataMode: boolean) { export class RectState { #getProps: () => RectProps = () => ({}) as RectProps; + /** + * Memoized props. `#getProps()` allocates a fresh object (it spreads `rest`), + * so calling it once per derived meant ~30 allocations per instance per update. + */ + #props: RectProps = $derived(this.#getProps()); + // Contexts chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); // Data mode detection hasEdgeProps = $derived( - hasAnyDataProp( - this.#getProps().x0, - this.#getProps().y0, - this.#getProps().x1, - this.#getProps().y1 - ) + hasAnyDataProp(this.#props.x0, this.#props.y0, this.#props.x1, this.#props.y1) ); dataMode = $derived( - hasAnyDataProp( - this.#getProps().x, - this.#getProps().y, - this.#getProps().width, - this.#getProps().height - ) || this.hasEdgeProps + hasAnyDataProp(this.#props.x, this.#props.y, this.#props.width, this.#props.height) || + this.hasEdgeProps ); // Data resolution #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -231,7 +228,7 @@ export class RectState { }); #resolveRect(d: any): { x: number; y: number; width: number; height: number } { - const props = this.#getProps(); + const props = this.#props; const resolvedInsets = resolveInsets(props.insets); if (this.hasEdgeProps) { @@ -283,31 +280,27 @@ export class RectState { } // Dash array - dashArrayResolved = $derived(parseDashArray(this.#getProps().dashArray)); + dashArrayResolved = $derived(parseDashArray(this.#props.dashArray)); dashArrayAttr = $derived(this.dashArrayResolved ? this.dashArrayResolved.join(' ') : undefined); // Corners cornersUniformValue = $derived.by(() => { - const corners = this.#getProps().corners; + const corners = this.#props.corners; if (corners === undefined) return undefined; if (typeof corners === 'number') return corners; const resolved = resolveCorners(corners, Infinity, Infinity); return cornersUniform(resolved) ? resolved[0] : undefined; }); cornersNonUniform = $derived( - this.#getProps().corners !== undefined && this.cornersUniformValue === undefined + this.#props.corners !== undefined && this.cornersUniformValue === undefined ); // Normalize rx/ry: if only one provided, use for both (SVG behavior) rx = $derived( - Number( - (this.#getProps() as any).rx ?? (this.#getProps() as any).ry ?? this.cornersUniformValue - ) || 0 + Number((this.#props as any).rx ?? (this.#props as any).ry ?? this.cornersUniformValue) || 0 ); ry = $derived( - Number( - (this.#getProps() as any).ry ?? (this.#getProps() as any).rx ?? this.cornersUniformValue - ) || 0 + Number((this.#props as any).ry ?? (this.#props as any).rx ?? this.cornersUniformValue) || 0 ); // Pixel-mode motion sources @@ -332,7 +325,7 @@ export class RectState { // Resolved per-corner radii (clamped to current bounds) resolveCorners(width: number, height: number) { - const corners = this.#getProps().corners; + const corners = this.#props.corners; if (corners === undefined) return undefined; return resolveCorners(corners, width, height); } @@ -371,35 +364,31 @@ export class RectState { // Static (non-data-driven) values for SVG/HTML pixel mode staticFill = $derived( - typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + typeof this.#props.fill === 'string' ? (this.#props.fill as string) : undefined ); staticFillOpacity = $derived( - typeof this.#getProps().fillOpacity === 'number' - ? (this.#getProps().fillOpacity as number) - : undefined + typeof this.#props.fillOpacity === 'number' ? (this.#props.fillOpacity as number) : undefined ); staticStroke = $derived( - typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + typeof this.#props.stroke === 'string' ? (this.#props.stroke as string) : undefined ); staticStrokeOpacity = $derived( - typeof this.#getProps().strokeOpacity === 'number' - ? (this.#getProps().strokeOpacity as number) + typeof this.#props.strokeOpacity === 'number' + ? (this.#props.strokeOpacity as number) : undefined ); staticStrokeWidth = $derived( - typeof this.#getProps().strokeWidth === 'number' - ? (this.#getProps().strokeWidth as number) - : undefined + typeof this.#props.strokeWidth === 'number' ? (this.#props.strokeWidth as number) : undefined ); staticOpacity = $derived( - typeof this.#getProps().opacity === 'number' ? (this.#getProps().opacity as number) : undefined + typeof this.#props.opacity === 'number' ? (this.#props.opacity as number) : undefined ); staticClassName = $derived( - typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + typeof this.#props.class === 'string' ? (this.#props.class as string) : undefined ); // Match SVG's implicit `stroke-width: 1` default staticBorderWidth = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (typeof props.strokeWidth === 'number') return `${props.strokeWidth}px`; if (typeof props.stroke === 'string') return '1px'; return undefined; @@ -419,22 +408,22 @@ export class RectState { this.#motionX = createMotion( initialX, - () => (typeof getProps().x === 'number' ? (getProps().x as number) : 0), + () => (typeof this.#props.x === 'number' ? (this.#props.x as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'x') ); this.#motionY = createMotion( initialY, - () => (typeof getProps().y === 'number' ? (getProps().y as number) : 0), + () => (typeof this.#props.y === 'number' ? (this.#props.y as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'y') ); this.#motionWidth = createMotion( initialWidth, - () => (typeof getProps().width === 'number' ? (getProps().width as number) : 0), + () => (typeof this.#props.width === 'number' ? (this.#props.width as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'width') ); this.#motionHeight = createMotion( initialHeight, - () => (typeof getProps().height === 'number' ? (getProps().height as number) : 0), + () => (typeof this.#props.height === 'number' ? (this.#props.height as number) : 0), motion === undefined ? undefined : parseMotionProp(motion, 'height') ); @@ -443,7 +432,7 @@ export class RectState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts index 86ab2395b..92304b5f2 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -57,6 +57,14 @@ export type SplineSegment = { */ export class SplineState { #getProps: () => SplineProps = () => ({}) as SplineProps; + + /** + * 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: SplineProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); @@ -70,7 +78,7 @@ export class SplineState { name: 'Spline', kind: 'mark', markInfo: () => { - const p = getProps(); + const p = this.#props; return { data: p.data, x: p.x, @@ -98,23 +106,19 @@ export class SplineState { return value; } - series = $derived(this.ctx.series.series.find((s) => s.key === this.#getProps().seriesKey)); + series = $derived(this.ctx.series.series.find((s) => s.key === this.#props.seriesKey)); seriesAccessor = $derived( this.series?.value ?? (this.series?.data ? undefined : this.series?.key) ); xAccessor = $derived( accessor( - this.#getProps().x ?? - (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? - this.ctx.x + this.#props.x ?? (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? this.ctx.x ) ); yAccessor = $derived( accessor( - this.#getProps().y ?? - (this.ctx.valueAxis === 'y' ? this.seriesAccessor : undefined) ?? - this.ctx.y + this.#props.y ?? (this.ctx.valueAxis === 'y' ? this.seriesAccessor : undefined) ?? this.ctx.y ) ); @@ -122,7 +126,7 @@ export class SplineState { yOffset = $derived(isScaleBand(this.ctx.yScale) ? this.ctx.yScale.bandwidth() / 2 : 0); #buildPath(resolvedData: any[]): string { - const props = this.#getProps(); + const props = this.#props; const path = this.ctx.radial ? lineRadial() .angle((d) => this.#getScaleValue(d, this.ctx.xScale, this.xAccessor) + 0) @@ -138,7 +142,7 @@ export class SplineState { } hasAnyStyleFn = $derived.by(() => { - const p = this.#getProps(); + const p = this.#props; return ( typeof p.stroke === 'function' || typeof p.fill === 'function' || @@ -147,7 +151,7 @@ export class SplineState { }); d = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; if (this.hasAnyStyleFn && !this.geo.projection) return ''; const resolvedData = props.data ?? this.series?.data ?? this.ctx.data; @@ -169,7 +173,7 @@ export class SplineState { segments = $derived.by(() => { if (!this.hasAnyStyleFn) return null; - const props = this.#getProps(); + const props = this.#props; const resolvedData = props.data ?? this.series?.data ?? this.ctx.data; if (this.geo.projection) return null; @@ -187,7 +191,7 @@ export class SplineState { }); #defaultPathData(): string { - const props = this.#getProps(); + const props = this.#props; if (!extractTweenConfig(props.motion)) return ''; if (this.ctx.config.x) { @@ -213,7 +217,7 @@ export class SplineState { return ''; } - isTweened = $derived(extractTweenConfig(this.#getProps().motion) != null); + isTweened = $derived(extractTweenConfig(this.#props.motion) != null); get tweenedPath() { return this.#tweenState.current; diff --git a/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts index 78dd8e6ae..62cf904c7 100644 --- a/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts @@ -326,6 +326,12 @@ export function textMarkInfo(props: TextProps, dataMode: boolean) { export class TextState { #getProps: () => TextProps = () => ({}) as TextProps; + /** + * Memoized props — `#getProps()` allocates a fresh object (it spreads `rest`), + * so calling it per derived meant ~30 allocations per instance per update. + */ + #props: TextProps = $derived(this.#getProps()); + // Contexts chartCtx: ChartState = getChartContext(); geo: GeoState = getGeoContext(); @@ -335,19 +341,17 @@ export class TextState { // Data mode detection dataMode = $derived( - this.#getProps().data != null || - isTextDataProp(this.#getProps().x) || - isTextDataProp(this.#getProps().y) + this.#props.data != null || isTextDataProp(this.#props.x) || isTextDataProp(this.#props.y) ); // Data resolution #resolvedData: any[] = $derived( - this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + this.dataMode ? (this.#props.data ?? chartDataArray(this.chartCtx.data)) : [] ); resolvedItems = $derived.by(() => { if (!this.dataMode) return []; - const props = this.#getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; return this.#resolvedData.map((d, i) => { const key = keyFn(d, i); @@ -363,7 +367,7 @@ export class TextState { }); resolveTextPosition(d: any): { x: number; y: number } { - const props = this.#getProps(); + const props = this.#props; if (this.geo.projection) { const [projX, projY] = resolveGeoDataPair( props.x as any, @@ -394,7 +398,7 @@ export class TextState { } resolveTextValue(d: any): string { - const value = this.#getProps().value; + const value = this.#props.value; if (typeof value === 'function') { const v = value(d); return v != null ? String(v) : ''; @@ -421,9 +425,7 @@ export class TextState { } // Resolved width: for path text, defer to the (SVG-bound) pathRef length - resolvedWidth = $derived( - this.#getProps().path ? getPathLength(this.pathRef) : this.#getProps().width - ); + resolvedWidth = $derived(this.#props.path ? getPathLength(this.pathRef) : this.#props.width); #defaultTruncateOptions: TruncateTextOptions = $derived({ maxChars: undefined, @@ -432,7 +434,7 @@ export class TextState { }); truncateConfig: TruncateTextOptions | boolean = $derived.by(() => { - const truncate = this.#getProps().truncate; + const truncate = this.#props.truncate; if (typeof truncate === 'boolean') { if (truncate) return this.#defaultTruncateOptions; return false; @@ -442,9 +444,9 @@ export class TextState { // Numeric value tweening rawText = $derived.by(() => { - const value = this.#getProps().value; - const motion = this.#getProps().motion; - const format = this.#getProps().format; + const value = this.#props.value; + const motion = this.#props.motion; + const format = this.#props.format; if (typeof value === 'function' || value == null) return ''; if (typeof value === 'number' && motion) { const v = this.#motionValue.current; @@ -468,7 +470,7 @@ export class TextState { #spaceWidth = $derived(getStringWidth(' ', undefined as any) || 0); wordsByLines = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const width = props.width; const scaleToFit = props.scaleToFit ?? false; const lines = this.textValue.split('\n'); @@ -504,7 +506,7 @@ export class TextState { // Vertical positioning startDy = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const verticalAnchor = props.verticalAnchor ?? 'end'; const lineHeight = props.lineHeight ?? '1em'; const capHeight = resolveCapHeight(props.capHeight, props.fontSize); @@ -519,7 +521,7 @@ export class TextState { }); dataModeStartDy = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const verticalAnchor = props.verticalAnchor ?? 'end'; const capHeight = resolveCapHeight(props.capHeight, props.fontSize); // Match `startDy`, but single-line (data mode renders one tspan per item): @@ -530,7 +532,7 @@ export class TextState { }); scaleTransform = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const x = props.x; const y = props.y; const width = props.width; @@ -553,37 +555,33 @@ export class TextState { }); rotateTransform = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; return props.rotate ? `rotate(${props.rotate}, ${props.x}, ${props.y})` : ''; }); transform = $derived( - (this.#getProps().transform as string | undefined) ?? + (this.#props.transform as string | undefined) ?? `${this.scaleTransform} ${this.rotateTransform}` ); // Static (non-data-driven) values staticFill = $derived( - typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + typeof this.#props.fill === 'string' ? (this.#props.fill as string) : undefined ); staticFillOpacity = $derived( - typeof this.#getProps().fillOpacity === 'number' - ? (this.#getProps().fillOpacity as number) - : undefined + typeof this.#props.fillOpacity === 'number' ? (this.#props.fillOpacity as number) : undefined ); staticStroke = $derived( - typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + typeof this.#props.stroke === 'string' ? (this.#props.stroke as string) : undefined ); staticStrokeWidth = $derived( - typeof this.#getProps().strokeWidth === 'number' - ? (this.#getProps().strokeWidth as number) - : undefined + typeof this.#props.strokeWidth === 'number' ? (this.#props.strokeWidth as number) : undefined ); staticOpacity = $derived( - typeof this.#getProps().opacity === 'number' ? (this.#getProps().opacity as number) : undefined + typeof this.#props.opacity === 'number' ? (this.#props.opacity as number) : undefined ); staticClassName = $derived( - typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + typeof this.#props.class === 'string' ? (this.#props.class as string) : undefined ); constructor(getProps: () => TextProps) { @@ -598,7 +596,7 @@ export class TextState { this.#motionX = createMotion( _initialX, () => { - const x = getProps().x; + const x = this.#props.x; return typeof x === 'number' || typeof x === 'string' ? x : 0; }, initial.motion @@ -606,7 +604,7 @@ export class TextState { this.#motionY = createMotion( _initialY, () => { - const y = getProps().y; + const y = this.#props.y; return typeof y === 'number' || typeof y === 'string' ? y : 0; }, initial.motion @@ -615,7 +613,7 @@ export class TextState { // Tween numeric values when motion is configured this.#motionValue = createMotion( typeof initial.value === 'number' ? initial.value : 0, - () => (typeof getProps().value === 'number' ? (getProps().value as number) : 0), + () => (typeof this.#props.value === 'number' ? (this.#props.value as number) : 0), typeof initial.value === 'number' && initial.motion ? typeof initial.motion === 'object' && 'type' in initial.motion ? initial.motion @@ -628,7 +626,7 @@ export class TextState { const motionMap = this.#dataMotionMap; $effect(() => { if (!this.dataMode) return; - const props = getProps(); + const props = this.#props; const keyFn = props.key ?? defaultKey; const activeKeys = new Set(); for (let i = 0; i < this.#resolvedData.length; i++) { diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts index d220a6761..69be8c170 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -139,6 +139,14 @@ export type WaffleLayoutOptions = { */ export class WaffleState { #getProps: () => WaffleProps = () => ({}) as WaffleProps; + + /** + * 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: WaffleProps = $derived(this.#getProps()); + ctx: ChartState = getChartContext(); constructor(getProps: () => WaffleProps) { @@ -147,7 +155,7 @@ export class WaffleState { name: 'Waffle', kind: 'mark', markInfo: () => { - const p = getProps(); + const p = this.#props; return { data: p.data, seriesKey: p.seriesKey, @@ -157,14 +165,14 @@ export class WaffleState { }); } - axis = $derived<'x' | 'y'>(this.#getProps().axis ?? this.ctx.valueAxis); - unit = $derived(Math.max(0, this.#getProps().unit ?? 1)); - gap = $derived(+(this.#getProps().gap ?? 1)); - round = $derived(maybeRound(this.#getProps().round)); - multipleProp = $derived(maybeMultiple(this.#getProps().multiple)); + axis = $derived<'x' | 'y'>(this.#props.axis ?? this.ctx.valueAxis); + unit = $derived(Math.max(0, this.#props.unit ?? 1)); + gap = $derived(+(this.#props.gap ?? 1)); + round = $derived(maybeRound(this.#props.round)); + multipleProp = $derived(maybeMultiple(this.#props.multiple)); series = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey ? this.ctx.series.series.find((s) => s.key === seriesKey) : undefined; }); @@ -187,20 +195,20 @@ export class WaffleState { ); stackAccessors = $derived.by(() => { - const seriesKey = this.#getProps().seriesKey; + const seriesKey = this.#props.seriesKey; return seriesKey && this.ctx.series.isStacked ? this.ctx.series.getStackAccessors(seriesKey) : null; }); data = $derived.by(() => { - const dataProp = this.#getProps().data; + const dataProp = this.#props.data; if (dataProp) return dataProp; return this.series?.data ?? chartDataArray(this.ctx.data); }); x = $derived.by(() => { - const xProp = this.#getProps().x; + const xProp = this.#props.x; return ( xProp ?? (this.ctx.valueAxis === 'x' @@ -210,7 +218,7 @@ export class WaffleState { ); }); y = $derived.by(() => { - const yProp = this.#getProps().y; + const yProp = this.#props.y; return ( yProp ?? (this.ctx.valueAxis === 'y' @@ -224,9 +232,9 @@ export class WaffleState { createDimensionGetter(this.ctx, () => ({ x: this.x, y: this.y, - x1: this.#getProps().x1, - y1: this.#getProps().y1, - insets: this.#getProps().insets, + x1: this.#props.x1, + y1: this.#props.y1, + insets: this.#props.insets, })) ); @@ -248,7 +256,7 @@ export class WaffleState { }); items = $derived.by(() => { - const props = this.#getProps(); + const props = this.#props; const axis = this.axis; const unit = this.unit; const round = this.round; @@ -268,8 +276,8 @@ export class WaffleState { // produced by the chart's stack series; otherwise treats value as [0, v]. const valueAccessorFn = accessor( axis === 'y' - ? (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#getProps().y ?? this.ctx.y) - : (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#getProps().x ?? this.ctx.x) + ? (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#props.y ?? this.ctx.y) + : (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#props.x ?? this.ctx.x) ); for (let i = 0; i < data.length; i++) { From 1974ae55edda5bbd9fc6a38ba14577cc06464560 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 14:07:18 -0400 Subject: [PATCH 3/6] add notes to primitivies and bundle-size guides about perf benefit of using layer-specofic imports (ex. layerchart/svg) --- docs/src/content/guides/bundle-size.md | 23 +++++++++- docs/src/content/guides/primitives.md | 34 ++++++++++++++ .../src/lib/bench/PrimitiveBench.svelte | 41 ++++++++++++++++- .../src/lib/bench/primitives.svelte.bench.ts | 45 +++++++++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/docs/src/content/guides/bundle-size.md b/docs/src/content/guides/bundle-size.md index 8baf02af1..f1db40558 100644 --- a/docs/src/content/guides/bundle-size.md +++ b/docs/src/content/guides/bundle-size.md @@ -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 ``) -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 @@ -109,7 +109,25 @@ 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 `` layer, measured by the repo's primitives benchmark: + +| Import | `` | `` | +| ----------------------- | ----------- | ----------- | +| `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 ``'s total but only ~18% of ``'s, because `` does considerably more work of its own. + +So the saving matters when you render **many instances** — a scatter plot of thousands of ``s, a heatmap of ``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. @@ -117,6 +135,7 @@ The `layerchart/svg`, `layerchart/canvas`, and `layerchart/html` sub-paths re-ex - ✅ 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 diff --git a/docs/src/content/guides/primitives.md b/docs/src/content/guides/primitives.md index bea4279f9..62f43f311 100644 --- a/docs/src/content/guides/primitives.md +++ b/docs/src/content/guides/primitives.md @@ -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 + + {#snippet marks({ context })} + {#each data as d} + + {/each} + {/snippet} + +``` + +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
diff --git a/packages/layerchart/src/lib/bench/PrimitiveBench.svelte b/packages/layerchart/src/lib/bench/PrimitiveBench.svelte index 6103e8008..0a1af58a6 100644 --- a/packages/layerchart/src/lib/bench/PrimitiveBench.svelte +++ b/packages/layerchart/src/lib/bench/PrimitiveBench.svelte @@ -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 ``, 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; @@ -43,6 +58,30 @@ {/each} +{:else if mode === 'direct'} + + + {#each Array(count) as _, i (i)} + {#if primitive === 'rect'} + + {:else if primitive === 'text'} + + {/if} + {/each} + + +{:else if mode === 'native-in-chart'} + + + {#each Array(count) as _, i (i)} + {#if primitive === 'rect'} + + {:else if primitive === 'text'} + Hello + {/if} + {/each} + + {:else} {#each Array(count) as _, i (i)} diff --git a/packages/layerchart/src/lib/bench/primitives.svelte.bench.ts b/packages/layerchart/src/lib/bench/primitives.svelte.bench.ts index 7eb8a5857..eabf9d0b8 100644 --- a/packages/layerchart/src/lib/bench/primitives.svelte.bench.ts +++ b/packages/layerchart/src/lib/bench/primitives.svelte.bench.ts @@ -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 }); + }); + }); +} From df965019e1c59a08a129f110241503188b4e18d7 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 15:04:11 -0400 Subject: [PATCH 4/6] perf(Chart): Remove quadratic domain recalculation on mount for series-based charts (~2x faster with 10 series) --- .changeset/olive-crabs-invite.md | 5 ++ .../layerchart/src/lib/states/chart.svelte.ts | 59 ++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 .changeset/olive-crabs-invite.md diff --git a/.changeset/olive-crabs-invite.md b/.changeset/olive-crabs-invite.md new file mode 100644 index 000000000..3f1ec96aa --- /dev/null +++ b/.changeset/olive-crabs-invite.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +perf(Chart): Remove quadratic domain recalculation on mount for series-based charts (~2x faster with 10 series) diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index e6ea28159..b04f48ba6 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -768,6 +768,45 @@ export class ChartState< return undefined; } + /** + * Every non-null value the visible series contribute to `axis`'s domain. + * + * Inlines what `data.flatMap(acc).filter((v) => v != null)` would do — + * an array-valued accessor returns one array per datum, hence the single + * level of flattening — to avoid the intermediate arrays. + */ + #getAxisSeriesValues(axis: 'x' | 'y'): any[] { + const seriesState = this.seriesState; + if (!seriesState || seriesState.isDefaultSeries) return []; + + const axisAccessor = axis === 'x' ? this.props.x : this.props.y; + const values: any[] = []; + + for (const s of seriesState.visibleSeries) { + const acc = accessor(s.value ?? axisAccessor ?? s.key); + for (const d of s.data ?? chartDataArray(this.data)) { + const value = acc(d); + if (Array.isArray(value)) { + for (const v of value) if (v != null) values.push(v); + } else if (value != null) { + values.push(value); + } + } + } + + return values; + } + + /** + * Memoized per axis. `resolveDomain` re-runs on every mark registration — + * each mounting mark bumps `_markInfosVersion` — so recollecting these + * inline made mount cost O(series² × rows). Only the mark loop in + * `resolveDomain` depends on `_markInfos`; these values don't, so the + * repeated calls reuse them. + */ + #xSeriesValues: any[] = $derived(this.#getAxisSeriesValues('x')); + #ySeriesValues: any[] = $derived(this.#getAxisSeriesValues('y')); + private resolveDomain(axis: 'x' | 'y'): DomainType | undefined { const domain = axis === 'x' @@ -793,11 +832,7 @@ export class ChartState< // For non-default series, calculate domain from all visible series values if (!this.seriesState.isDefaultSeries) { - const seriesValues = this.series.visibleSeries.flatMap((s) => { - const acc = accessor(s.value ?? axisAccessor ?? s.key); - const data = s.data ?? chartDataArray(this.data); - return data.flatMap(acc); - }); + const seriesValues = axis === 'x' ? this.#xSeriesValues : this.#ySeriesValues; // Also include data from registered marks whose data isn't the primary // data for any visible series. This handles marks with the same accessor @@ -819,10 +854,16 @@ export class ChartState< } } - const allValues = [...seriesValues, ...extraMarkValues].filter((v) => v != null); + // `seriesValues` is already null-filtered, so skip the copy entirely in + // the common case where no mark contributes its own data. + const allValues = extraMarkValues.length + ? [...seriesValues, ...extraMarkValues].filter((v) => v != null) + : seriesValues; if (allValues.length > 0) { if (baseline != null) { - return [min([baseline, ...allValues]), max([baseline, ...allValues])]; + // Reduce first, then fold in the baseline — spreading every value + // into `min`/`max` copied the whole array twice. + return [min([baseline, min(allValues)]), max([baseline, max(allValues)])]; } return extent(allValues); } @@ -840,7 +881,9 @@ export class ChartState< // Baseline-based domain: include the baseline value in the extent if (baseline != null && Array.isArray(this.data)) { const values = this.data.flatMap(accessor(axisAccessor)); - return [min([baseline, ...values]), max([baseline, ...values])]; + // Reduce first, then fold in the baseline — spreading every value into + // `min`/`max` copied the whole array twice. + return [min([baseline, min(values)]), max([baseline, max(values)])]; } } From dbcc8c8aea78b105d279e8ce96ac1ec12f8724a7 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 21:29:23 -0400 Subject: [PATCH 5/6] fix(Spline): Only tween path data when `motion` is set (~20x less memory growth while streaming, issue #585) --- .changeset/olive-berries-tickle.md | 5 ++ .../Spline/Spline.motion.svelte.test.ts | 58 +++++++++++++++++++ .../components/Spline/Spline.shared.svelte.ts | 14 +++-- 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 .changeset/olive-berries-tickle.md create mode 100644 packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts diff --git a/.changeset/olive-berries-tickle.md b/.changeset/olive-berries-tickle.md new file mode 100644 index 000000000..b6d30670f --- /dev/null +++ b/.changeset/olive-berries-tickle.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Spline): Only tween path data when `motion` is set (~20x less memory growth while streaming, issue #585) diff --git a/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts new file mode 100644 index 000000000..1d18725dc --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.motion.svelte.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, cleanup } from 'vitest-browser-svelte'; + +// Count interpolator construction and invocation while keeping real behaviour. +const calls = { built: 0, invoked: 0 }; +vi.mock('d3-interpolate-path', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + interpolatePath: (a: any, b: any) => { + calls.built++; + const fn = actual.interpolatePath(a, b); + return (t: number) => { + calls.invoked++; + return fn(t); + }; + }, + }; +}); + +import LineChart from '../charts/LineChart/LineChart.svelte'; + +function data(offset: number, n = 300) { + return Array.from({ length: n }, (_, i) => ({ + date: new Date(Date.UTC(2024, 0, 1 + offset + i)), + value: (i * 37 + offset) % 100, + })); +} + +const frame = () => new Promise((r) => requestAnimationFrame(() => r(null))); + +/** + * `Spline.base` renders `c.d` unless `isTweened` (which requires `motion`), so + * building a path tween without `motion` does full `interpolatePath` work and + * discards it. That was the dominant allocation in streaming charts — see the + * `motion` gate in `Spline.shared.svelte.ts`. + */ +describe('Spline path tween is only built when `motion` is set', () => { + beforeEach(() => { + cleanup(); + calls.built = 0; + calls.invoked = 0; + }); + + it('does not interpolate while streaming a chart with no motion', async () => { + const props = { data: data(0), x: 'date', y: 'value', height: 300 }; + const { rerender } = render(LineChart, props); + await frame(); + + for (let i = 1; i <= 20; i++) { + await rerender({ ...props, data: data(i) }); + await frame(); + } + + expect(calls.built).toBe(0); + expect(calls.invoked).toBe(0); + }); +}); diff --git a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts index 92304b5f2..38a1f2675 100644 --- a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -89,10 +89,16 @@ export class SplineState { }, }); - this.#tweenState = createMotion(this.#defaultPathData(), () => this.d, { - type: 'tween', - interpolate: interpolatePath, - }); + // Only build the tween when `motion` asks for one. `Spline.base` renders + // `c.d` unless `isTweened`, so an unconditional tween re-interpolated the + // full path on every data change and threw the result away — the dominant + // cost in streaming charts. + const tween = extractTweenConfig(this.#props.motion); + this.#tweenState = createMotion( + this.#defaultPathData(), + () => this.d, + tween ? { type: 'tween', interpolate: interpolatePath, ...tween.options } : undefined + ); } #getScaleValue( From 4aa1b6f072e71b4fbf7960e71ea544e34aefadf3 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 10 Aug 2026 22:33:19 -0400 Subject: [PATCH 6/6] refine changesets --- .changeset/olive-crabs-invite.md | 2 +- .changeset/olive-moons-repeat.md | 2 +- .changeset/quiet-donuts-shave.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/olive-crabs-invite.md b/.changeset/olive-crabs-invite.md index 3f1ec96aa..8973f70a6 100644 --- a/.changeset/olive-crabs-invite.md +++ b/.changeset/olive-crabs-invite.md @@ -2,4 +2,4 @@ 'layerchart': patch --- -perf(Chart): Remove quadratic domain recalculation on mount for series-based charts (~2x faster with 10 series) +perf(Chart): Remove quadratic domain recalculation on mount for series-based charts diff --git a/.changeset/olive-moons-repeat.md b/.changeset/olive-moons-repeat.md index 28feb42b9..32d69c696 100644 --- a/.changeset/olive-moons-repeat.md +++ b/.changeset/olive-moons-repeat.md @@ -2,4 +2,4 @@ 'layerchart': patch --- -perf: Memoize props in component state classes (~3x faster ``, ~2x faster `LineChart` mount) +perf: Memoize props in component state classes (~3x faster `` mount in benchmarks) diff --git a/.changeset/quiet-donuts-shave.md b/.changeset/quiet-donuts-shave.md index 1f269370f..126359099 100644 --- a/.changeset/quiet-donuts-shave.md +++ b/.changeset/quiet-donuts-shave.md @@ -2,4 +2,4 @@ 'layerchart': patch --- -perf(Chart): Resolve stacked value domain in a single pass (~5.5x faster) +perf(Chart): Resolve stacked value domain in a single pass