{
+ ctx.tooltip.isHoveringTooltipContent = true;
+ }}
+ onpointerleave={() => {
+ ctx.tooltip.isHoveringTooltipContent = false;
+ }}
>
- {#if $$slots.default}
-
-
+ {#if children}
+
+ {@render children({ data: ctx.tooltip.data })}
{/if}
{/if}
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts
new file mode 100644
index 000000000..c19389eb1
--- /dev/null
+++ b/packages/layerchart/src/lib/components/tooltip/Tooltip.svelte.test.ts
@@ -0,0 +1,352 @@
+import { describe, expect, it, vi } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+import LineChart from '../charts/LineChart.svelte';
+
+const data = [
+ { date: 0, value: 10 },
+ { date: 1, value: 30 },
+ { date: 2, value: 20 },
+ { date: 3, value: 50 },
+ { date: 4, value: 40 },
+];
+
+const baseProps = {
+ data,
+ x: 'date',
+ y: 'value',
+ height: 300,
+ width: 400,
+ tooltipContext: { mode: 'bisect-x' as const },
+};
+
+/** Dispatch pointer events to trigger the tooltip on a given element */
+function triggerTooltip(el: Element, position?: { clientX: number; clientY: number }) {
+ const rect = el.getBoundingClientRect();
+ const eventInit = {
+ bubbles: true,
+ clientX: position?.clientX ?? rect.x + rect.width / 2,
+ clientY: position?.clientY ?? rect.y + rect.height / 2,
+ };
+ el.dispatchEvent(new PointerEvent('pointerenter', eventInit));
+ el.dispatchEvent(new PointerEvent('pointermove', eventInit));
+}
+
+describe('Tooltip', () => {
+ describe('portal', () => {
+ it('should portal tooltip to body by default', async () => {
+ const { container } = render(LineChart, {
+ props: baseProps,
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+ triggerTooltip(tooltipCtx);
+
+ await vi.waitFor(() => {
+ // Tooltip root should be portaled to body (outside the chart container)
+ const tooltipInBody = document.body.querySelector('.lc-tooltip-root');
+ expect(tooltipInBody).not.toBeNull();
+
+ // Should use fixed positioning when portaled
+ const style = getComputedStyle(tooltipInBody!);
+ expect(style.position).toBe('fixed');
+ });
+ });
+
+ it('should render tooltip inline when portal is false', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { portal: false } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+ triggerTooltip(tooltipCtx);
+
+ await vi.waitFor(() => {
+ // Tooltip root should be inside the chart container
+ const tooltipInContainer = container.querySelector('.lc-tooltip-root');
+ expect(tooltipInContainer).not.toBeNull();
+
+ // Should use absolute positioning when not portaled
+ const style = getComputedStyle(tooltipInContainer!);
+ expect(style.position).toBe('absolute');
+ });
+ });
+
+ it('should portal tooltip to a custom selector target', async () => {
+ // Create a custom portal target
+ const portalTarget = document.createElement('div');
+ portalTarget.className = 'custom-portal-target';
+ document.body.appendChild(portalTarget);
+
+ try {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { portal: { target: '.custom-portal-target' } } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+ triggerTooltip(tooltipCtx);
+
+ await vi.waitFor(() => {
+ const tooltipInTarget = portalTarget.querySelector('.lc-tooltip-root');
+ expect(tooltipInTarget).not.toBeNull();
+ });
+ } finally {
+ portalTarget.remove();
+ }
+ });
+
+ it('should show tooltip content when portaled', async () => {
+ const { container } = render(LineChart, {
+ props: baseProps,
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+ triggerTooltip(tooltipCtx);
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root');
+ expect(tooltipRoot).not.toBeNull();
+
+ // Should contain tooltip content (items from default tooltip)
+ const tooltipItems = tooltipRoot!.querySelectorAll('.lc-tooltip-item-root');
+ expect(tooltipItems.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('should have numeric top and left styles when portaled', async () => {
+ const { container } = render(LineChart, {
+ props: baseProps,
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+ triggerTooltip(tooltipCtx);
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ // Should have valid pixel positions (not NaN or empty)
+ const top = tooltipRoot.style.top;
+ const left = tooltipRoot.style.left;
+ expect(top).toMatch(/^-?\d+(\.\d+)?px$/);
+ expect(left).toMatch(/^-?\d+(\.\d+)?px$/);
+ });
+ });
+ });
+
+ describe('contained="container" (default)', () => {
+ it('should flip tooltip left when pointer is near the right edge', async () => {
+ const { container } = render(LineChart, {
+ props: baseProps,
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the right edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.right - 5,
+ clientY: ctxRect.top + ctxRect.height / 2,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
+ // Tooltip should be positioned to the LEFT of the pointer (flipped),
+ // so its left edge should be less than the pointer position
+ expect(tooltipLeft).toBeLessThan(ctxRect.right - 5);
+ // And specifically, the tooltip's right edge should not exceed the container
+ expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(ctxRect.right + 1);
+ });
+ });
+
+ it('should flip tooltip right when pointer is near the left edge', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { anchor: 'top-right' as const } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the left edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.left + 5,
+ clientY: ctxRect.top + ctxRect.height / 2,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
+ // Tooltip should be positioned to the RIGHT of the pointer (flipped),
+ // so its left edge should be >= the container's left edge
+ expect(tooltipLeft).toBeGreaterThanOrEqual(ctxRect.left - 1);
+ });
+ });
+
+ it('should flip tooltip up when pointer is near the bottom edge', async () => {
+ const { container } = render(LineChart, {
+ props: baseProps,
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the bottom edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.left + ctxRect.width / 2,
+ clientY: ctxRect.bottom - 5,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
+ // Tooltip should be positioned ABOVE the pointer (flipped),
+ // so its bottom edge should not exceed the container
+ expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(ctxRect.bottom + 1);
+ });
+ });
+
+ it('should flip tooltip down when pointer is near the top edge', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { anchor: 'bottom-left' as const } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the top edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.left + ctxRect.width / 2,
+ clientY: ctxRect.top + 5,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
+ // Tooltip should be positioned BELOW the pointer (flipped),
+ // so its top edge should be >= the container's top
+ expect(tooltipTop).toBeGreaterThanOrEqual(ctxRect.top - 1);
+ });
+ });
+ });
+
+ describe('contained="window"', () => {
+ it('should flip tooltip left when it would overflow the right side of the viewport', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { contained: 'window' as const } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the right edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.right - 5,
+ clientY: ctxRect.top + ctxRect.height / 2,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
+ // Tooltip should not overflow the right side of the viewport
+ expect(tooltipLeft + tooltipRoot.offsetWidth).toBeLessThanOrEqual(window.innerWidth + 1);
+ });
+ });
+
+ it('should flip tooltip up when it would overflow the bottom of the viewport', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { contained: 'window' as const } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the bottom edge of the container
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.left + ctxRect.width / 2,
+ clientY: ctxRect.bottom - 5,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipTop = parseFloat(tooltipRoot.style.top);
+ // Tooltip should not overflow the bottom of the viewport
+ expect(tooltipTop + tooltipRoot.offsetHeight).toBeLessThanOrEqual(window.innerHeight + 1);
+ });
+ });
+ });
+
+ describe('contained={false}', () => {
+ it('should not constrain tooltip position', async () => {
+ const { container } = render(LineChart, {
+ props: {
+ ...baseProps,
+ props: { tooltip: { root: { contained: false } } },
+ },
+ });
+
+ const tooltipCtx = container.querySelector('.lc-tooltip-context') as HTMLElement;
+ await expect.element(tooltipCtx).toBeInTheDocument();
+
+ const ctxRect = tooltipCtx.getBoundingClientRect();
+ // Trigger near the right edge — tooltip should NOT flip
+ triggerTooltip(tooltipCtx, {
+ clientX: ctxRect.right - 5,
+ clientY: ctxRect.top + ctxRect.height / 2,
+ });
+
+ await vi.waitFor(() => {
+ const tooltipRoot = document.body.querySelector('.lc-tooltip-root') as HTMLElement;
+ expect(tooltipRoot).not.toBeNull();
+
+ const tooltipLeft = parseFloat(tooltipRoot.style.left);
+ // With default anchor='top-left', tooltip is placed to the right of pointer.
+ // Since contained=false, the tooltip left should be near/past the pointer x
+ // (i.e., it doesn't flip like contained="container" would)
+ const pointerViewportX = ctxRect.right - 5;
+ expect(tooltipLeft).toBeGreaterThanOrEqual(pointerViewportX - 1);
+ });
+ });
+ });
+});
diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte
index 52301af34..4c8564e15 100644
--- a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte
+++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte
@@ -1,86 +1,171 @@
-
-
+ if (xAccessorOverride) {
+ const scaled = ctx.xScale(xAccessorOverride(d));
+ return typeof scaled === 'number' ? scaled : 0;
+ }
-
-
-
{
- isHoveringTooltip = true;
+ if (geo.projection) {
+ const lat = ctx.x(d);
+ const long = ctx.y(d);
+ const geoValue = geo.projection([lat, long]) ?? [0, 0];
+ return geoValue[0];
+ }
+
+ const value = ctx.xGet(d);
+
+ if (Array.isArray(value)) {
+ // `x` accessor with multiple properties (ex. `x={['start', 'end']})`).
+ // Default to the max (typically the "target"/"end" endpoint); override
+ // via the `x` prop for explicit control.
+ return max(value);
+ } else {
+ return value;
+ }
+ })
+ .y((d) => {
+ if (mode === 'quadtree-x') {
+ return 0;
+ }
+
+ if (yAccessorOverride) {
+ const scaled = ctx.yScale(yAccessorOverride(d));
+ return typeof scaled === 'number' ? scaled : 0;
+ }
+
+ if (geo.projection) {
+ const lat = ctx.x(d);
+ const long = ctx.y(d);
+ const geoValue = geo.projection([lat, long]) ?? [0, 0];
+ return geoValue[1];
+ }
+
+ const value = ctx.yGet(d);
+
+ if (Array.isArray(value)) {
+ // `y` accessor with multiple properties — default to max endpoint.
+ return max(value);
+ } else {
+ return value;
+ }
+ })
+ .addAll(ctx.flatData as [number, number][]);
+ }
+ });
+
+ const rects: Array<{ x: number; y: number; width: number; height: number; data: any }> =
+ $derived.by(() => {
+ if (mode === 'bounds' || mode === 'band') {
+ return ctx.flatData
+ .map((d) => {
+ const xValue = ctx.xGet(d);
+ const yValue = ctx.yGet(d);
+
+ const x = Array.isArray(xValue) ? xValue[0] : xValue;
+ const y = Array.isArray(yValue) ? yValue[0] : yValue;
+
+ const xOffset = isScaleBand(ctx.xScale)
+ ? (ctx.xScale.padding() * ctx.xScale.step()) / 2
+ : 0;
+ const yOffset = isScaleBand(ctx.yScale)
+ ? (ctx.yScale.padding() * ctx.yScale.step()) / 2
+ : 0;
+
+ const fullWidth = max(ctx.xRange) - min(ctx.xRange);
+ const fullHeight = max(ctx.yRange) - min(ctx.yRange);
+
+ if (mode === 'band') {
+ if (isScaleBand(ctx.xScale)) {
+ // full band width/height regardless of value
+ return {
+ x: x - xOffset,
+ y: isScaleBand(ctx.yScale) ? y - yOffset : min(ctx.yRange),
+ width: ctx.xScale.step(),
+ height: isScaleBand(ctx.yScale) ? ctx.yScale.step() : fullHeight,
+ data: d,
+ };
+ } else if (isScaleBand(ctx.yScale)) {
+ return {
+ x: isScaleBand(ctx.xScale) ? x - xOffset : min(ctx.xRange),
+ y: y - yOffset,
+ width: isScaleBand(ctx.xScale) ? ctx.xScale.step() : fullWidth,
+ height: ctx.yScale.step(),
+ data: d,
+ };
+ } else if (ctx.xInterval) {
+ // x-axis time scale with interval
+ const xVal = ctx.x(d);
+ const start = ctx.xInterval.floor(xVal);
+ const end = ctx.xInterval.offset(start);
+ const xStart = ctx.xScale(start);
+ const xEnd = ctx.xScale(end);
+
+ return {
+ x: Math.min(xStart, xEnd),
+ y: isScaleBand(ctx.yScale) ? y - yOffset : min(ctx.yRange),
+ width: Math.abs(xEnd - xStart),
+ height: isScaleBand(ctx.yScale) ? ctx.yScale.step() : fullHeight,
+ data: d,
+ };
+ } else if (ctx.yInterval) {
+ // y-axis time scale with interval
+ const yVal = ctx.y(d);
+ const start = ctx.yInterval.floor(yVal);
+ const end = ctx.yInterval.offset(start);
+ const yStart = ctx.yScale(start);
+ const yEnd = ctx.yScale(end);
+
+ return {
+ x: isScaleBand(ctx.xScale) ? x - xOffset : min(ctx.xRange),
+ y: Math.min(yStart, yEnd),
+ width: isScaleBand(ctx.xScale) ? ctx.xScale.step() : fullWidth,
+ height: Math.abs(yEnd - yStart),
+ data: d,
+ };
+ } else if (Array.isArray(xValue)) {
+ return {
+ x: Math.min(xValue[0], xValue[1]) - xOffset,
+ y: Array.isArray(yValue)
+ ? Math.min(yValue[0], yValue[1]) - yOffset
+ : min(ctx.yRange),
+ width: Math.abs(xValue[1] - xValue[0]),
+ height: Array.isArray(yValue) ? Math.abs(yValue[1] - yValue[0]) : fullHeight,
+ data: d,
+ };
+ } else if (Array.isArray(yValue)) {
+ return {
+ x: min(ctx.xRange),
+ y: Math.min(yValue[0], yValue[1]) - yOffset,
+ width: fullWidth,
+ height: Math.abs(yValue[1] - yValue[0]),
+ data: d,
+ };
+ } else if (isScaleTime(ctx.xScale)) {
+ // Find width to next data point
+ const index = ctx.flatData.findIndex(
+ (d2) => Number(ctx.x(d2)) === Number(ctx.x(d))
+ );
+ const isLastPoint = index + 1 === ctx.flatData.length;
+ const nextDataPoint = isLastPoint
+ ? max(ctx.xDomain)
+ : ctx.x(ctx.flatData[index + 1]);
+
+ return {
+ x: x - xOffset,
+ y: isScaleBand(ctx.yScale) ? y - yOffset : min(ctx.yRange),
+ width: (ctx.xScale(nextDataPoint) ?? 0) - (xValue ?? 0),
+ height: isScaleBand(ctx.yScale) ? ctx.yScale.step() : fullHeight,
+ data: d,
+ };
+ } else if (isScaleTime(ctx.yScale)) {
+ // Find height to next data point
+ const index = ctx.flatData.findIndex(
+ (d2) => Number(ctx.y(d2)) === Number(ctx.y(d))
+ );
+ const isLastPoint = index + 1 === ctx.flatData.length;
+ const nextDataPoint = isLastPoint
+ ? max(ctx.yDomain)
+ : ctx.y(ctx.flatData[index + 1]);
+
+ return {
+ x: isScaleBand(ctx.xScale) ? x - xOffset : min(ctx.xRange),
+ y: y - yOffset,
+ width: isScaleBand(ctx.xScale) ? ctx.xScale.step() : fullWidth,
+ height: (ctx.yScale(nextDataPoint) ?? 0) - (yValue ?? 0),
+ data: d,
+ };
+ } else {
+ console.warn(
+ '[layerchart] TooltipContext band mode requires at least one scale to be band or time.'
+ );
+ return undefined;
+ }
+ } else if (mode === 'bounds') {
+ return {
+ x: isScaleBand(ctx.xScale) || Array.isArray(xValue) ? x - xOffset : min(ctx.xRange),
+ // y: isScaleBand($yScale) || Array.isArray(yValue) ? y - yOffset : min($yRange),
+ y: y - yOffset,
+
+ width: Array.isArray(xValue)
+ ? xValue[1] - xValue[0]
+ : isScaleBand(ctx.xScale)
+ ? ctx.xScale.step()
+ : min(ctx.xRange) + x,
+ height: Array.isArray(yValue)
+ ? yValue[1] - yValue[0]
+ : isScaleBand(ctx.yScale)
+ ? ctx.yScale.step()
+ : max(ctx.yRange) - y,
+ data: d,
+ };
+ }
+ })
+ .filter((x) => x !== undefined) // make typescript happy
+ .sort(sortFunc('x'));
+ }
+ return [];
+ });
+
+ const triggerPointerEvents = $derived(
+ ['bisect-x', 'bisect-y', 'bisect-band', 'quadtree', 'quadtree-x', 'quadtree-y'].includes(mode)
+ );
+
+ function onPointerEnter(e: PointerEvent | MouseEvent | TouchEvent) {
+ tooltipState.isHoveringTooltipArea = true;
if (triggerPointerEvents) {
showTooltip(e);
}
- }}
- on:pointermove={(e) => {
+ }
+
+ function onPointerMove(e: PointerEvent | MouseEvent | TouchEvent) {
if (triggerPointerEvents) {
showTooltip(e);
}
- }}
- on:pointerleave={(e) => {
- isHoveringTooltip = false;
+ }
+
+ function onPointerLeave(e: PointerEvent | MouseEvent | TouchEvent) {
+ tooltipState.isHoveringTooltipArea = false;
hideTooltip();
- }}
- on:click={(e) => {
+ }
+
+
+
+
{
// Ignore clicks without data (triggered from Legend clicks, for example)
- if (triggerPointerEvents && $tooltip?.data != null) {
- onclick(e, { data: $tooltip?.data });
+ if (triggerPointerEvents && tooltipState.data != null) {
+ onclick(e, { data: tooltipState.data });
}
}}
- bind:this={tooltipContextNode}
+ onkeydown={() => {}}
+ bind:this={ref}
>
-
+ {@render children?.({ state: tooltipState })}
{#if mode === 'voronoi'}
{
showTooltip(e, data);
}}
onpointermove={(e, { data }) => {
showTooltip(e, data);
}}
- onpointerleave={hideTooltip}
+ onpointerleave={() => hideTooltip()}
onpointerdown={(e) => {
// @ts-expect-error
if (e.target?.hasPointerCapture(e.pointerId)) {
@@ -458,52 +723,119 @@
onclick={(e, { data }) => {
onclick(e, { data });
}}
- classes={{ path: cls(debug && 'fill-danger/10 stroke-danger') }}
+ classes={{ path: cls('lc-tooltip-voronoi-path', debug && 'debug') }}
/>
{:else if mode === 'bounds' || mode === 'band'}
-
-
+
+
{#each rects as rect}
- showTooltip(e, rect.data)}
- on:pointermove={(e) => showTooltip(e, rect.data)}
- on:pointerleave={hideTooltip}
- on:pointerdown={(e) => {
- // @ts-expect-error
- if (e.target?.hasPointerCapture(e.pointerId)) {
- // @ts-expect-error
- e.target.releasePointerCapture(e.pointerId);
- }
- }}
- on:click={(e) => {
- onclick(e, { data: rect.data });
- }}
- />
+
+ {#if ctx.radial}
+ showTooltip(e, rect?.data)}
+ onpointermove={(e) => showTooltip(e, rect?.data)}
+ onpointerleave={() => hideTooltip()}
+ onpointerdown={(e) => {
+ const target = e.target as Element;
+ if (target?.hasPointerCapture(e.pointerId)) {
+ target.releasePointerCapture(e.pointerId);
+ }
+ }}
+ onclick={(e) => {
+ onclick(e, { data: rect?.data });
+ }}
+ />
+ {:else}
+ showTooltip(e, rect?.data)}
+ onpointermove={(e) => showTooltip(e, rect?.data)}
+ onpointerleave={() => hideTooltip()}
+ onpointerdown={(e) => {
+ const target = e.target as Element;
+ if (target?.hasPointerCapture(e.pointerId)) {
+ target.releasePointerCapture(e.pointerId);
+ }
+ }}
+ onclick={(e) => {
+ onclick(e, { data: rect?.data });
+ }}
+ />
+ {/if}
{/each}
- {:else if mode === 'quadtree' && debug}
+ {:else if ['quadtree', 'quadtree-x', 'quadtree-y'].includes(mode) && debug}
-
- {#each quadtreeRects(quadtree, false) as rect}
-
- {/each}
+
+ {#if quadtree}
+ {#each quadtreeRects(quadtree, false) as rect}
+
+ {/each}
+ {/if}
{/if}
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipHeader.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipHeader.svelte
index 0847dbffe..9ab2f9aab 100644
--- a/packages/layerchart/src/lib/components/tooltip/TooltipHeader.svelte
+++ b/packages/layerchart/src/lib/components/tooltip/TooltipHeader.svelte
@@ -1,30 +1,133 @@
+
+
{#if color}
{/if}
-
{format ? formatUtil(value, format) : value}
+ {#if children}
+ {@render children?.()}
+ {:else}
+
+ {format ? formatUtil(value, asAny(format)) : value}
+ {/if}
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte
index 600738da7..184e7ccb1 100644
--- a/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte
+++ b/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte
@@ -1,55 +1,215 @@
+
+
-
+
- {format ? formatUtil(value, format) : value}
+ {#if children}
+ {@render children()}
+ {:else}
+
+ {format ? formatUtil(value, asAny(format)) : value}
+ {/if}
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipList.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipList.svelte
index dc5c15908..c0fdf818d 100644
--- a/packages/layerchart/src/lib/components/tooltip/TooltipList.svelte
+++ b/packages/layerchart/src/lib/components/tooltip/TooltipList.svelte
@@ -1,13 +1,34 @@
-
-
+
+ {@render children?.()}
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipSeparator.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipSeparator.svelte
index 8ce53f18f..ce78ebb65 100644
--- a/packages/layerchart/src/lib/components/tooltip/TooltipSeparator.svelte
+++ b/packages/layerchart/src/lib/components/tooltip/TooltipSeparator.svelte
@@ -1,5 +1,39 @@
-
+
+ {@render children?.()}
+
+
+
diff --git a/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-1.png b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-1.png
new file mode 100644
index 000000000..47767d2f3
Binary files /dev/null and b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-1.png differ
diff --git a/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-2.png b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-2.png
new file mode 100644
index 000000000..47767d2f3
Binary files /dev/null and b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-portal-tooltip-to-a-custom-selector-target-2.png differ
diff --git a/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-1.png b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-1.png
new file mode 100644
index 000000000..47767d2f3
Binary files /dev/null and b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-1.png differ
diff --git a/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-2.png b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-2.png
new file mode 100644
index 000000000..47767d2f3
Binary files /dev/null and b/packages/layerchart/src/lib/components/tooltip/__screenshots__/Tooltip.svelte.test.ts/Tooltip-portal-should-render-tooltip-inline-when-portal-is-false-2.png differ
diff --git a/packages/layerchart/src/lib/components/tooltip/index.ts b/packages/layerchart/src/lib/components/tooltip/index.ts
index a3d575a8e..3f7d8a9db 100644
--- a/packages/layerchart/src/lib/components/tooltip/index.ts
+++ b/packages/layerchart/src/lib/components/tooltip/index.ts
@@ -1,6 +1,14 @@
export { default as Context } from './TooltipContext.svelte';
+export * from './TooltipContext.svelte';
export { default as Header } from './TooltipHeader.svelte';
+export * from './TooltipHeader.svelte';
export { default as Item } from './TooltipItem.svelte';
+export * from './TooltipItem.svelte';
export { default as List } from './TooltipList.svelte';
+export * from './TooltipList.svelte';
export { default as Separator } from './TooltipSeparator.svelte';
+export * from './TooltipSeparator.svelte';
export { default as Root } from './Tooltip.svelte';
+export * from './Tooltip.svelte';
+
+export { TooltipState, type TooltipSeries } from '$lib/states/tooltip.svelte.js';
diff --git a/packages/layerchart/src/lib/components/types.ts b/packages/layerchart/src/lib/components/types.ts
new file mode 100644
index 000000000..3f49f1b84
--- /dev/null
+++ b/packages/layerchart/src/lib/components/types.ts
@@ -0,0 +1,10 @@
+export type Placement =
+ | 'top-left'
+ | 'top'
+ | 'top-right'
+ | 'left'
+ | 'center'
+ | 'right'
+ | 'bottom-left'
+ | 'bottom'
+ | 'bottom-right';
diff --git a/packages/layerchart/src/lib/contexts/canvas.ts b/packages/layerchart/src/lib/contexts/canvas.ts
new file mode 100644
index 000000000..71a97bd3b
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/canvas.ts
@@ -0,0 +1,53 @@
+import { Context } from 'runed';
+import type { MouseEventHandler, PointerEventHandler, TouchEventHandler } from 'svelte/elements';
+
+import type { ComputedStylesOptions } from '$lib/utils/canvas.js';
+import type { ComponentNode } from '$lib/states/chart.svelte.js';
+
+export type ComponentRender
= {
+ render: (ctx: CanvasRenderingContext2D, styleOverrides?: ComputedStylesOptions) => any;
+ events?: {
+ click?: MouseEventHandler | null;
+ dblclick?: MouseEventHandler | null;
+ pointerenter?: PointerEventHandler | null;
+ pointerover?: PointerEventHandler | null;
+ pointermove?: PointerEventHandler | null;
+ pointerleave?: PointerEventHandler | null;
+ pointerout?: PointerEventHandler | null;
+ pointerdown?: PointerEventHandler | null;
+ touchmove?: TouchEventHandler | null;
+ };
+ /**
+ * Optional dependencies to track and invalidate the canvas context when they change.
+ */
+ deps?: () => any[];
+};
+
+export type CanvasContextValue = {
+ /**
+ * Register component to render.
+ *
+ * Returns method to unregister on component destroy
+ */
+ register(component: ComponentRender): () => void;
+ invalidate(): void;
+ /** Get the root ComponentNode of the canvas render tree. Used for server-side rendering. */
+ getRootNode?: () => ComponentNode;
+};
+
+const CanvasContext = new Context('CanvasContext');
+
+const defaultCanvasContext: CanvasContextValue = {
+ register: (_: ComponentRender) => {
+ return () => {};
+ },
+ invalidate: () => {},
+};
+
+export function getCanvasContext() {
+ return CanvasContext.getOr(defaultCanvasContext);
+}
+
+export function setCanvasContext(context: CanvasContextValue) {
+ return CanvasContext.set(context);
+}
diff --git a/packages/layerchart/src/lib/contexts/chart.ts b/packages/layerchart/src/lib/contexts/chart.ts
new file mode 100644
index 000000000..c369d46d1
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/chart.ts
@@ -0,0 +1,71 @@
+import { Context } from 'runed';
+import type { ChartState } from '$lib/states/chart.svelte.js';
+import type { AnyScale } from '$lib/utils/scales.svelte.js';
+
+export type { ChartState };
+export type {
+ NodeKind,
+ ComponentNode,
+ RegisterComponentOptions,
+} from '$lib/states/chart.svelte.js';
+
+const _ChartContext = new Context>('ChartContext');
+
+/**
+ * Fallback context when used outside of a Chart component.
+ * Provides safe defaults to prevent runtime errors.
+ */
+const fallbackContext = {
+ registerMark: () => () => {
+ /* no-op */
+ },
+ registerComponent: (_options: any) => ({
+ id: Symbol('noop'),
+ kind: 'mark' as const,
+ name: 'noop',
+ parent: null,
+ children: [],
+ insideCompositeMark: false,
+ }),
+ series: {
+ series: [],
+ visibleSeries: [],
+ highlightKey: null,
+ isVisible: () => true,
+ isHighlighted: () => false,
+ isDefaultSeries: true,
+ allSeriesData: [],
+ allSeriesColors: [],
+ selectedKeys: { isEmpty: () => true, isSelected: () => false },
+ },
+ tooltip: {
+ x: 0,
+ y: 0,
+ data: null,
+ series: [],
+ config: {},
+ isHoveringTooltipArea: false,
+ isHoveringTooltipContent: false,
+ mode: 'manual' as const,
+ show: () => {},
+ hide: () => {},
+ },
+} as unknown as ChartState;
+
+export function getChartContext<
+ T,
+ XScale extends AnyScale = AnyScale,
+ YScale extends AnyScale = AnyScale,
+>(): ChartState {
+ // @ts-expect-error - Type variance is acceptable here
+ return _ChartContext.getOr(fallbackContext);
+}
+
+export function setChartContext<
+ T,
+ XScale extends AnyScale = AnyScale,
+ YScale extends AnyScale = AnyScale,
+>(context: ChartState): ChartState {
+ // @ts-expect-error - shh
+ return _ChartContext.set(context);
+}
diff --git a/packages/layerchart/src/lib/contexts/componentTree.test.ts b/packages/layerchart/src/lib/contexts/componentTree.test.ts
new file mode 100644
index 000000000..2a11fa1d6
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/componentTree.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest';
+
+import type { ComponentNode } from '$lib/states/chart.svelte.js';
+// Note: registerComponent requires Svelte context (setContext/getContext)
+// and is tested indirectly via chart state and component integration tests.
+// _removeComponentNode is private on ChartState and tested indirectly.
+
+/** Helper to create a minimal node without Svelte context */
+function makeNode(name: string, parent: ComponentNode | null = null): ComponentNode {
+ const node: ComponentNode = {
+ id: Symbol(name),
+ kind: 'mark',
+ name,
+ parent,
+ children: [],
+ insideCompositeMark: false,
+ };
+ if (parent) {
+ parent.children.push(node);
+ }
+ return node;
+}
+
+describe('componentTree', () => {
+ describe('tree structure', () => {
+ it('should build a multi-level tree', () => {
+ const root = makeNode('root');
+ const group = makeNode('group', root);
+ const leaf1 = makeNode('leaf1', group);
+ const leaf2 = makeNode('leaf2', group);
+ const sibling = makeNode('sibling', root);
+
+ expect(root.children).toHaveLength(2);
+ expect(root.children).toContain(group);
+ expect(root.children).toContain(sibling);
+ expect(group.children).toHaveLength(2);
+ expect(group.children).toContain(leaf1);
+ expect(group.children).toContain(leaf2);
+ expect(leaf1.parent).toBe(group);
+ expect(group.parent).toBe(root);
+ });
+
+ it('should clean up subtrees correctly', () => {
+ const root = makeNode('root');
+ const group = makeNode('group', root);
+ makeNode('leaf1', group);
+ makeNode('leaf2', group);
+
+ // Simulate _removeComponentNode (private on ChartState) — remove group from root
+ const idx = root.children.indexOf(group);
+ if (idx >= 0) root.children.splice(idx, 1);
+
+ expect(root.children).toHaveLength(0);
+ // Group still has its children (for cleanup order - children destroy first)
+ expect(group.children).toHaveLength(2);
+ });
+ });
+
+ describe('insideCompositeMark', () => {
+ it('should be false for root-level nodes', () => {
+ const root = makeNode('root');
+ expect(root.insideCompositeMark).toBe(false);
+ });
+
+ it('should be true when parent is a composite-mark', () => {
+ const composite = makeNode('Area');
+ composite.kind = 'composite-mark';
+ const child: ComponentNode = {
+ id: Symbol('child'),
+ kind: 'mark',
+ name: 'Spline',
+ parent: composite,
+ children: [],
+ insideCompositeMark: composite.kind === 'composite-mark',
+ };
+ expect(child.insideCompositeMark).toBe(true);
+ });
+
+ it('should be false when parent is a group (not composite-mark)', () => {
+ const group = makeNode('Group');
+ group.kind = 'group';
+ const child: ComponentNode = {
+ id: Symbol('child'),
+ kind: 'mark',
+ name: 'Circle',
+ parent: group,
+ children: [],
+ insideCompositeMark: false, // group is not composite-mark
+ };
+ expect(child.insideCompositeMark).toBe(false);
+ });
+ });
+});
diff --git a/packages/layerchart/src/lib/contexts/geo.ts b/packages/layerchart/src/lib/contexts/geo.ts
new file mode 100644
index 000000000..19847a2e0
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/geo.ts
@@ -0,0 +1,17 @@
+import { Context } from 'runed';
+import type { GeoState, GeoStateProps } from '$lib/states/geo.svelte.js';
+
+export type { GeoState, GeoStateProps };
+
+/**
+ * Access or set the current GeoContext.
+ */
+const _GeoContext = new Context('GeoContext');
+
+export function getGeoContext() {
+ return _GeoContext.getOr({ projection: undefined } as GeoState);
+}
+
+export function setGeoContext(geo: GeoState) {
+ return _GeoContext.set(geo);
+}
diff --git a/packages/layerchart/src/lib/contexts/index.ts b/packages/layerchart/src/lib/contexts/index.ts
new file mode 100644
index 000000000..1c4c818bb
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/index.ts
@@ -0,0 +1,5 @@
+export * from './chart.js';
+export * from './geo.js';
+export * from './layer.js';
+export * from './legendPayload.js';
+export * from './settings.js';
diff --git a/packages/layerchart/src/lib/contexts/layer.ts b/packages/layerchart/src/lib/contexts/layer.ts
new file mode 100644
index 000000000..39b88f7d7
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/layer.ts
@@ -0,0 +1,13 @@
+import { Context } from 'runed';
+
+export type LayerContext = 'svg' | 'canvas' | 'html';
+
+const _LayerContext = new Context('LayerContext');
+
+export function getLayerContext(): LayerContext {
+ return _LayerContext.get();
+}
+
+export function setLayerContext(context: LayerContext): LayerContext {
+ return _LayerContext.set(context);
+}
diff --git a/packages/layerchart/src/lib/contexts/legendPayload.ts b/packages/layerchart/src/lib/contexts/legendPayload.ts
new file mode 100644
index 000000000..ef505fc92
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/legendPayload.ts
@@ -0,0 +1,17 @@
+import { Context } from 'runed';
+
+export type LegendPayload = {
+ key: string;
+ label?: string;
+ color?: string;
+};
+
+const _LegendPayloadContext = new Context('LegendContext');
+
+export function setLegendPayloadContext(payload: LegendPayload[]) {
+ return _LegendPayloadContext.set(payload);
+}
+
+export function getLegendPayloadContext() {
+ return _LegendPayloadContext.getOr([] as LegendPayload[]);
+}
diff --git a/packages/layerchart/src/lib/contexts/settings.ts b/packages/layerchart/src/lib/contexts/settings.ts
new file mode 100644
index 000000000..22c378ab6
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/settings.ts
@@ -0,0 +1,13 @@
+import { Context } from 'runed';
+import { Settings, defaultSettings, type SettingsOptions } from '$lib/states/settings.svelte.js';
+
+const _SettingsContext = new Context('Settings');
+
+/** Get the current settings context, or default if not set */
+export function getSettings(): Settings {
+ return _SettingsContext.getOr(defaultSettings);
+}
+
+export function setSettings(settings: SettingsOptions): Settings {
+ return _SettingsContext.set(new Settings(settings));
+}
diff --git a/packages/layerchart/src/lib/contexts/transform.ts b/packages/layerchart/src/lib/contexts/transform.ts
new file mode 100644
index 000000000..792b2a0ab
--- /dev/null
+++ b/packages/layerchart/src/lib/contexts/transform.ts
@@ -0,0 +1,113 @@
+import { Context } from 'runed';
+
+import {} from '$lib/components/TransformContext.svelte';
+import {
+ createDefaultTransformState,
+ type TransformMode,
+ type TransformScrollMode,
+} from '$lib/states/transform.svelte.js';
+import type { MotionProp } from '$lib/utils/motion.svelte.js';
+
+export type TransformContextValue = {
+ /**
+ * The current transform mode.
+ *
+ * - `canvas`: The transform is applied to the canvas element.
+ * - `domain`: The transform narrows/shifts the visible data domain.
+ * - `projection`: The transform updates the geo projection.
+ * - `none`: No transform is applied.
+ */
+ mode: TransformMode;
+
+ /**
+ * The current scale of the transform.
+ */
+ scale: number;
+
+ /**
+ * Set the scale of the transform
+ * @param value - the scale value to set
+ * @param options - motion options to apply to the transform (defaults to the motion options passed to the component)
+ */
+ setScale(value: number, options?: MotionProp): void;
+
+ /**
+ * The current translate of the transform.
+ */
+ translate: { x: number; y: number };
+
+ /**
+ * Set the translate of the transform
+ * @param point - the point to translate to
+ * @param options - motion options to apply to the transform (defaults to the motion options passed to the component)
+ */
+ setTranslate(point: { x: number; y: number }, options?: MotionProp): void;
+
+ /**
+ * Whether the transform is currently being moved
+ */
+ moving: boolean;
+
+ /**
+ * Whether the transform is currently being dragged
+ */
+ dragging: boolean;
+
+ /**
+ * The scroll mode of the transform.
+ *
+ * - `scale`: Scrolling will zoom in/out the canvas.
+ * - `translate`: Scrolling will pan the canvas.
+ * - `none`: No scroll mode is applied.
+ */
+ scrollMode: TransformScrollMode;
+
+ /**
+ * Set the scroll mode of the transform
+ *
+ * @param mode - the scroll mode to set
+ */
+ setScrollMode(mode: TransformScrollMode): void;
+
+ /**
+ * Reset the transform to its initial state
+ */
+ reset(): void;
+
+ /**
+ * Zoom in the transform
+ */
+ zoomIn(): void;
+
+ /**
+ * Zoom out the transform
+ *
+ */
+ zoomOut(): void;
+
+ /**
+ * Translate the transform to the center of the canvas
+ */
+ translateCenter(): void;
+
+ /**
+ * Zoom to a specific point in the canvas
+ *
+ * @param center - The point (in chart coordinates) that should become the new
+ * center of the view after zooming.
+ *
+ * @param rect - A rectangular region (in chart coordinates) that the view should scale to fit.
+ * If omitted, the scale defaults to 1 (no zoom).
+ */
+ zoomTo(center: { x: number; y: number }, rect?: { width: number; height: number }): void;
+};
+
+const _TransformContext = new Context('TransformContext');
+
+export function getTransformContext() {
+ return _TransformContext.getOr(createDefaultTransformState());
+}
+
+export function setTransformContext(transform: TransformContextValue) {
+ return _TransformContext.set(transform);
+}
diff --git a/packages/layerchart/src/lib/docs/Blockquote.svelte b/packages/layerchart/src/lib/docs/Blockquote.svelte
deleted file mode 100644
index adde7486c..000000000
--- a/packages/layerchart/src/lib/docs/Blockquote.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-a]:font-medium [&>a]:underline [&>a]:decoration-dashed [&>a]:decoration-primary/50 [&>a]:underline-offset-2'
- )}
->
-
-
-
diff --git a/packages/layerchart/src/lib/docs/Code.svelte b/packages/layerchart/src/lib/docs/Code.svelte
deleted file mode 100644
index c843968f9..000000000
--- a/packages/layerchart/src/lib/docs/Code.svelte
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
- {#if source}
-
-
- {@html highlightedSource}
-
-
-
-
-
-
- {/if}
-
diff --git a/packages/layerchart/src/lib/docs/CurveMenuField.svelte b/packages/layerchart/src/lib/docs/CurveMenuField.svelte
deleted file mode 100644
index 2c01e5e71..000000000
--- a/packages/layerchart/src/lib/docs/CurveMenuField.svelte
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
diff --git a/packages/layerchart/src/lib/docs/GeoDebug.svelte b/packages/layerchart/src/lib/docs/GeoDebug.svelte
deleted file mode 100644
index 829831a09..000000000
--- a/packages/layerchart/src/lib/docs/GeoDebug.svelte
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
scale: {format($geo.scale(), 'decimal')}
-
-
-
translate:
- {#each $geo.translate() as coord}
-
{format(coord, 'decimal')}
- {/each}
-
-
-
-
rotate:
- {#each $geo.rotate() as angle}
-
{format(angle, 'decimal')}
- {/each}
-
-
-
- center:
-
- {$geo.center?.()}
-
-
-
-
-
long/lat:
- {#each $geo.invert?.([$width / 2, $height / 2]) ?? [] as coord}
-
{format(coord, 'decimal')}
- {/each}
-
-
-
-
-{#if showCenter}
-
-{/if}
diff --git a/packages/layerchart/src/lib/docs/Header1.svelte b/packages/layerchart/src/lib/docs/Header1.svelte
deleted file mode 100644
index ff25855a4..000000000
--- a/packages/layerchart/src/lib/docs/Header1.svelte
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
diff --git a/packages/layerchart/src/lib/docs/Json.svelte b/packages/layerchart/src/lib/docs/Json.svelte
deleted file mode 100644
index 4ff157abc..000000000
--- a/packages/layerchart/src/lib/docs/Json.svelte
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
diff --git a/packages/layerchart/src/lib/docs/Layout.svelte b/packages/layerchart/src/lib/docs/Layout.svelte
deleted file mode 100644
index c05451dbe..000000000
--- a/packages/layerchart/src/lib/docs/Layout.svelte
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
diff --git a/packages/layerchart/src/lib/docs/Link.svelte b/packages/layerchart/src/lib/docs/Link.svelte
deleted file mode 100644
index e607ded31..000000000
--- a/packages/layerchart/src/lib/docs/Link.svelte
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/packages/layerchart/src/lib/docs/PathDataMenuField.svelte b/packages/layerchart/src/lib/docs/PathDataMenuField.svelte
deleted file mode 100644
index f4f540ee0..000000000
--- a/packages/layerchart/src/lib/docs/PathDataMenuField.svelte
+++ /dev/null
@@ -1,74 +0,0 @@
-
-
-
diff --git a/packages/layerchart/src/lib/docs/Preview.svelte b/packages/layerchart/src/lib/docs/Preview.svelte
deleted file mode 100644
index bbc71baab..000000000
--- a/packages/layerchart/src/lib/docs/Preview.svelte
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
-
-
-
-
-
- {#if code && showCode}
-
-
-
- {/if}
-
-
-{#if code}
- (showCode = !showCode)}
- >
- {showCode ? 'Hide' : 'Show'} Code
-
-{/if}
-
-{#if data}
-
- View data
-
-
-
-
-
- getDataAsString(data)} variant="fill-light" color="primary" />
-
-
-
-
-
-
- Close
-
-
-
-{/if}
diff --git a/packages/layerchart/src/lib/docs/TilesetField.svelte b/packages/layerchart/src/lib/docs/TilesetField.svelte
deleted file mode 100644
index 6f5958125..000000000
--- a/packages/layerchart/src/lib/docs/TilesetField.svelte
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
diff --git a/packages/layerchart/src/lib/docs/TransformDebug.svelte b/packages/layerchart/src/lib/docs/TransformDebug.svelte
deleted file mode 100644
index 7c1d85301..000000000
--- a/packages/layerchart/src/lib/docs/TransformDebug.svelte
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
-
-
-
scale: {format($scale, 'decimal')}
-
-
-
translate:
-
{format($translate.x, 'decimal')}
-
{format($translate.y, 'decimal')}
-
-
-
diff --git a/packages/layerchart/src/lib/docs/ViewSourceButton.svelte b/packages/layerchart/src/lib/docs/ViewSourceButton.svelte
deleted file mode 100644
index 3190a93c3..000000000
--- a/packages/layerchart/src/lib/docs/ViewSourceButton.svelte
+++ /dev/null
@@ -1,50 +0,0 @@
-
-
-{#if source}
-
- {label}
-
-
-
-
- {#if href}
-
- View on Github
-
- {/if}
-
-
-
-
-
-
-
- Close
-
-
-
-{:else if href}
-
-
- {label}
-
-
-{/if}
diff --git a/packages/layerchart/src/lib/index.ts b/packages/layerchart/src/lib/index.ts
index 1aca26f88..7a093b17d 100644
--- a/packages/layerchart/src/lib/index.ts
+++ b/packages/layerchart/src/lib/index.ts
@@ -1,2 +1,3 @@
export * from './components/index.js';
+export * from './contexts/index.js';
export * from './utils/index.js';
diff --git a/packages/layerchart/src/lib/server/ContextCapture.svelte b/packages/layerchart/src/lib/server/ContextCapture.svelte
new file mode 100644
index 000000000..6a3c02c40
--- /dev/null
+++ b/packages/layerchart/src/lib/server/ContextCapture.svelte
@@ -0,0 +1,30 @@
+
diff --git a/packages/layerchart/src/lib/server/ServerChart.svelte b/packages/layerchart/src/lib/server/ServerChart.svelte
new file mode 100644
index 000000000..77bd34564
--- /dev/null
+++ b/packages/layerchart/src/lib/server/ServerChart.svelte
@@ -0,0 +1,26 @@
+
+
+
+
+ {#if children}
+ {@render children()}
+ {/if}
+
+
diff --git a/packages/layerchart/src/lib/server/TestBarChart.svelte b/packages/layerchart/src/lib/server/TestBarChart.svelte
new file mode 100644
index 000000000..5654e1a54
--- /dev/null
+++ b/packages/layerchart/src/lib/server/TestBarChart.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/packages/layerchart/src/lib/server/TestLineChart.svelte b/packages/layerchart/src/lib/server/TestLineChart.svelte
new file mode 100644
index 000000000..623d3571e
--- /dev/null
+++ b/packages/layerchart/src/lib/server/TestLineChart.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+
+
diff --git a/packages/layerchart/src/lib/server/captureStore.ts b/packages/layerchart/src/lib/server/captureStore.ts
new file mode 100644
index 000000000..293f60b29
--- /dev/null
+++ b/packages/layerchart/src/lib/server/captureStore.ts
@@ -0,0 +1,35 @@
+import type { ChartState, ComponentNode } from '$lib/states/chart.svelte.js';
+
+export type CaptureTarget = {
+ chartState?: ChartState;
+ rootNode?: ComponentNode;
+};
+
+export type SSRCapture = CaptureTarget | null;
+
+const SSR_CAPTURE_KEY = Symbol.for('layerchart.ssr-capture');
+
+type GlobalWithSSRCapture = typeof globalThis & {
+ [SSR_CAPTURE_KEY]?: SSRCapture;
+};
+
+let _capture: SSRCapture = null;
+
+function getGlobalCaptureStore(): GlobalWithSSRCapture {
+ return globalThis as GlobalWithSSRCapture;
+}
+
+export function setSSRCapture(target: SSRCapture) {
+ _capture = target;
+
+ const globalStore = getGlobalCaptureStore();
+ if (target == null) {
+ delete globalStore[SSR_CAPTURE_KEY];
+ } else {
+ globalStore[SSR_CAPTURE_KEY] = target;
+ }
+}
+
+export function getSSRCapture() {
+ return getGlobalCaptureStore()[SSR_CAPTURE_KEY] ?? _capture;
+}
diff --git a/packages/layerchart/src/lib/server/index.ts b/packages/layerchart/src/lib/server/index.ts
new file mode 100644
index 000000000..e38abd12a
--- /dev/null
+++ b/packages/layerchart/src/lib/server/index.ts
@@ -0,0 +1,230 @@
+import { render } from 'svelte/server';
+import type { Component } from 'svelte';
+import type { ChartState } from '$lib/states/chart.svelte.js';
+import type { ComponentNode } from '$lib/states/chart.svelte.js';
+import type { CaptureTarget } from './captureStore.js';
+import { renderTree } from './renderTree.js';
+export { renderTree } from './renderTree.js';
+export { default as ServerChart } from './ServerChart.svelte';
+export {
+ getSSRCapture,
+ setSSRCapture,
+ type CaptureTarget,
+ type SSRCapture,
+} from './captureStore.js';
+
+export type CapturedChart = {
+ chartState: ChartState;
+ rootNode: ComponentNode;
+};
+
+export type CanvasRenderContext = Omit & {
+ canvas?: unknown;
+};
+
+export type CanvasFactory = (
+ width: number,
+ height: number
+) => {
+ getContext(type: '2d'): unknown;
+ toBuffer(mimeType: string, ...args: any[]): Buffer | Uint8Array;
+};
+
+export type RenderOptions = {
+ /** Pixel ratio for high-DPI output. @default 1 */
+ devicePixelRatio?: number;
+ /** Output format. @default 'png' */
+ format?: 'png' | 'jpeg';
+ /** JPEG quality (0-1). Only used when format is 'jpeg'. @default 0.92 */
+ quality?: number;
+ /**
+ * Background color to fill before rendering the chart.
+ * When omitted, PNG output is transparent.
+ * Set to `'white'` (or any CSS color) for an opaque background —
+ * recommended for JPEG which does not support transparency.
+ */
+ background?: string;
+ /**
+ * Canvas factory function.
+ *
+ * Example with \@napi-rs/canvas:
+ * ```ts
+ * import { createCanvas } from '\@napi-rs/canvas';
+ * createCanvas: (w, h) => createCanvas(w, h)
+ * ```
+ */
+ createCanvas: CanvasFactory;
+};
+
+export type RenderChartOptions = RenderOptions & {
+ /** Width of the output image in pixels. */
+ width: number;
+ /** Height of the output image in pixels. */
+ height: number;
+ /** Additional props to pass to the chart component. */
+ props?: Record;
+};
+
+/**
+ * Create a capture callback for use with `render()` from `svelte/server`.
+ * Pass the returned `onCapture` as the `onCapture` prop to your chart component.
+ * After `render()` completes, call `getCapture()` to retrieve the chart state and
+ * component tree.
+ *
+ * @example
+ * ```ts
+ * import { render } from 'svelte/server';
+ * import { createCaptureCallback, renderCapturedChart } from 'layerchart/server';
+ * import MyChart from './MyChart.svelte';
+ *
+ * const { onCapture, getCapture } = createCaptureCallback();
+ * const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
+ * rendered.body; // Force the SSR render to fully flush before reading capture state
+ * const capture = getCapture();
+ * ```
+ */
+export function createCaptureCallback() {
+ let captured: CaptureTarget | null = null;
+ return {
+ onCapture: (data: CaptureTarget) => {
+ captured = data;
+ },
+ getCapture: () => captured,
+ };
+}
+
+/**
+ * Render a chart component to an image buffer in a single call.
+ *
+ * This is a convenience function that handles SSR rendering, capture, and
+ * canvas rendering in one step. The component should use ``
+ * internally and accept `width`, `height`, and `capture` props.
+ *
+ * @example
+ * ```ts
+ * import { createCanvas, Path2D } from '\@napi-rs/canvas';
+ * import { renderChart } from 'layerchart/server';
+ * import MyChart from './MyChart.svelte';
+ *
+ * // Register Path2D globally for canvas rendering
+ * if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
+ *
+ * const buffer = renderChart(MyChart, {
+ * width: 800,
+ * height: 400,
+ * props: { data: myData },
+ * createCanvas: (w, h) => createCanvas(w, h),
+ * });
+ *
+ * // Use as a Response in a SvelteKit endpoint
+ * return new Response(buffer, {
+ * headers: { 'Content-Type': 'image/png' }
+ * });
+ * ```
+ */
+export function renderChart(
+ component: Component,
+ options: RenderChartOptions
+): Buffer | Uint8Array {
+ const { width, height, props = {}, ...renderOptions } = options;
+ const captureTarget: CaptureTarget = {};
+
+ // SSR render to build the component tree and capture chart state
+ const rendered = render(component, {
+ props: { ...props, width, height, capture: captureTarget },
+ });
+ // Force the SSR render to fully flush
+ void rendered.body;
+
+ if (!captureTarget.chartState || !captureTarget.rootNode) {
+ throw new Error(
+ 'Failed to capture chart state. Ensure the component uses with a `capture` prop.'
+ );
+ }
+
+ return renderCapturedChart(captureTarget as CapturedChart, {
+ width,
+ height,
+ ...renderOptions,
+ });
+}
+
+/**
+ * Render a captured chart component tree to an image buffer.
+ * Call this after `render()` from `svelte/server` has been used to build
+ * the component tree with a capture callback.
+ *
+ * For most use cases, prefer {@link renderChart} which handles the full pipeline.
+ *
+ * @example
+ * ```ts
+ * import { render } from 'svelte/server';
+ * import { createCanvas, Path2D } from '\@napi-rs/canvas';
+ * import { createCaptureCallback, renderCapturedChart } from 'layerchart/server';
+ * import MyChart from './MyChart.svelte';
+ *
+ * // Register canvas globals
+ * if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
+ *
+ * // Build component tree via SSR render
+ * const { onCapture, getCapture } = createCaptureCallback();
+ * const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
+ * rendered.body; // Force the SSR render to fully flush before reading capture state
+ *
+ * // Render to image
+ * const buffer = renderCapturedChart(getCapture()!, {
+ * width: 800,
+ * height: 400,
+ * createCanvas: (w, h) => createCanvas(w, h),
+ * });
+ * ```
+ */
+export function renderCapturedChart(
+ capture: CapturedChart,
+ options: RenderOptions & { width: number; height: number }
+): Buffer | Uint8Array {
+ const {
+ width,
+ height,
+ devicePixelRatio = 1,
+ format = 'png',
+ quality = 0.92,
+ background,
+ createCanvas,
+ } = options;
+
+ // Create canvas
+ const canvasWidth = Math.round(width * devicePixelRatio);
+ const canvasHeight = Math.round(height * devicePixelRatio);
+ const canvas = createCanvas(canvasWidth, canvasHeight);
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+
+ // Fill background (canvas is transparent by default)
+ if (background) {
+ ctx.fillStyle = background;
+ ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ }
+
+ // Apply DPI scaling
+ if (devicePixelRatio !== 1) {
+ ctx.scale(devicePixelRatio, devicePixelRatio);
+ }
+
+ // Apply padding translation (mirrors what Canvas.svelte's update() does)
+ if (capture.chartState) {
+ const padding = capture.chartState.padding;
+ if (padding) {
+ ctx.translate(padding.left ?? 0, padding.top ?? 0);
+ }
+ }
+
+ // Render the component tree onto the canvas
+ renderTree(ctx as CanvasRenderingContext2D, capture.rootNode);
+
+ // Export to buffer
+ const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
+ if (format === 'jpeg') {
+ return canvas.toBuffer(mimeType, quality);
+ }
+ return canvas.toBuffer(mimeType);
+}
diff --git a/packages/layerchart/src/lib/server/renderChart.ssr.test.ts b/packages/layerchart/src/lib/server/renderChart.ssr.test.ts
new file mode 100644
index 000000000..0f8e41f99
--- /dev/null
+++ b/packages/layerchart/src/lib/server/renderChart.ssr.test.ts
@@ -0,0 +1,257 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+import { render } from 'svelte/server';
+import { createCanvas, Path2D } from '@napi-rs/canvas';
+import {
+ renderChart,
+ renderCapturedChart,
+ createCaptureCallback,
+ type CaptureTarget,
+ type CapturedChart,
+ type CanvasFactory,
+} from './index.js';
+
+import TestLineChart from './TestLineChart.svelte';
+import TestBarChart from './TestBarChart.svelte';
+
+// Register Path2D globally for canvas rendering
+beforeAll(() => {
+ if (typeof globalThis.Path2D === 'undefined') {
+ (globalThis as any).Path2D = Path2D;
+ }
+});
+
+const createNodeCanvas: CanvasFactory = (w, h) =>
+ createCanvas(w, h) as unknown as ReturnType;
+
+const lineData = Array.from({ length: 20 }, (_, i) => ({
+ date: i,
+ value: 50 + 30 * Math.sin(i / 5),
+}));
+
+const barData = [
+ { category: 'A', value: 28 },
+ { category: 'B', value: 55 },
+ { category: 'C', value: 43 },
+ { category: 'D', value: 91 },
+];
+
+describe('renderChart', () => {
+ it('renders a line chart to PNG buffer', () => {
+ const buffer = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ expect(buffer).toBeInstanceOf(Buffer);
+ expect(buffer.length).toBeGreaterThan(0);
+ // PNG magic bytes
+ expect(buffer[0]).toBe(0x89);
+ expect(buffer[1]).toBe(0x50); // P
+ expect(buffer[2]).toBe(0x4e); // N
+ expect(buffer[3]).toBe(0x47); // G
+ });
+
+ it('renders a bar chart to PNG buffer', () => {
+ const buffer = renderChart(TestBarChart, {
+ width: 400,
+ height: 200,
+ props: { data: barData },
+ createCanvas: createNodeCanvas,
+ });
+
+ expect(buffer).toBeInstanceOf(Buffer);
+ expect(buffer.length).toBeGreaterThan(0);
+ // PNG magic bytes
+ expect(buffer[0]).toBe(0x89);
+ });
+
+ it('renders to JPEG format', () => {
+ const buffer = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ format: 'jpeg',
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ expect(buffer).toBeInstanceOf(Buffer);
+ // JPEG magic bytes (SOI marker)
+ expect(buffer[0]).toBe(0xff);
+ expect(buffer[1]).toBe(0xd8);
+ });
+
+ it('respects custom dimensions', () => {
+ const buffer1 = renderChart(TestLineChart, {
+ width: 200,
+ height: 100,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ const buffer2 = renderChart(TestLineChart, {
+ width: 800,
+ height: 600,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ // Larger image should produce a larger buffer
+ expect(buffer2.length).toBeGreaterThan(buffer1.length);
+ });
+
+ it('supports devicePixelRatio', () => {
+ const buffer1x = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ devicePixelRatio: 1,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ const buffer2x = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ devicePixelRatio: 2,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ // 2x DPI should produce a larger buffer (more pixels)
+ expect(buffer2x.length).toBeGreaterThan(buffer1x.length);
+ });
+
+ it('supports background color', () => {
+ const transparentBuffer = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ const whiteBuffer = renderChart(TestLineChart, {
+ width: 400,
+ height: 200,
+ background: 'white',
+ props: { data: lineData },
+ createCanvas: createNodeCanvas,
+ });
+
+ // Both should be valid PNGs but different content
+ expect(transparentBuffer[0]).toBe(0x89);
+ expect(whiteBuffer[0]).toBe(0x89);
+ expect(Buffer.compare(transparentBuffer, whiteBuffer)).not.toBe(0);
+ });
+
+ it('throws on missing ServerChart', () => {
+ // A bare component that doesn't use ServerChart should fail
+ expect(() =>
+ renderChart(
+ // Use a dummy component-like object
+ (() => {}) as any,
+ {
+ width: 400,
+ height: 200,
+ createCanvas: createNodeCanvas,
+ }
+ )
+ ).toThrow('Failed to capture chart state');
+ });
+});
+
+describe('renderCapturedChart', () => {
+ it('renders a captured chart tree to a buffer', () => {
+ const captureTarget: CaptureTarget = {};
+
+ const rendered = render(TestLineChart, {
+ props: { data: lineData, width: 400, height: 200, capture: captureTarget },
+ });
+ void rendered.body;
+
+ expect(captureTarget.chartState).toBeDefined();
+ expect(captureTarget.rootNode).toBeDefined();
+
+ const buffer = renderCapturedChart(captureTarget as CapturedChart, {
+ width: 400,
+ height: 200,
+ createCanvas: createNodeCanvas,
+ });
+
+ expect(buffer).toBeInstanceOf(Buffer);
+ expect(buffer[0]).toBe(0x89); // PNG
+ });
+});
+
+describe('createCaptureCallback', () => {
+ it('captures chart state via callback', () => {
+ const { onCapture, getCapture } = createCaptureCallback();
+
+ const rendered = render(TestLineChart, {
+ props: { data: lineData, width: 400, height: 200, onCapture },
+ });
+ void rendered.body;
+
+ const capture = getCapture();
+ expect(capture).not.toBeNull();
+ expect(capture?.chartState).toBeDefined();
+ expect(capture?.rootNode).toBeDefined();
+ });
+
+ it('returns null before render', () => {
+ const { getCapture } = createCaptureCallback();
+ expect(getCapture()).toBeNull();
+ });
+});
+
+describe('ServerChart capture prop', () => {
+ it('populates capture target via prop', () => {
+ const captureTarget: CaptureTarget = {};
+
+ const rendered = render(TestLineChart, {
+ props: { data: lineData, width: 400, height: 200, capture: captureTarget },
+ });
+ void rendered.body;
+
+ expect(captureTarget.chartState).toBeDefined();
+ expect(captureTarget.rootNode).toBeDefined();
+ expect(captureTarget.rootNode!.children.length).toBeGreaterThan(0);
+ });
+
+ it('captures chart state with correct padding', () => {
+ const captureTarget: CaptureTarget = {};
+
+ const rendered = render(TestLineChart, {
+ props: { data: lineData, width: 800, height: 400, capture: captureTarget },
+ });
+ void rendered.body;
+
+ const state = captureTarget.chartState!;
+ expect(state.padding).toEqual({ top: 20, right: 20, bottom: 20, left: 20 });
+ });
+
+ it('captures component tree with children', () => {
+ const captureTarget: CaptureTarget = {};
+
+ const rendered = render(TestLineChart, {
+ props: { data: lineData, width: 400, height: 200, capture: captureTarget },
+ });
+ void rendered.body;
+
+ // Root node (Canvas) should have children
+ const root = captureTarget.rootNode!;
+ expect(root.kind).toBe('group');
+ expect(root.children.length).toBeGreaterThan(0);
+
+ // Count all marks in the tree (may be nested in composite-marks)
+ function countMarks(node: typeof root): number {
+ let count = node.kind === 'mark' ? 1 : 0;
+ for (const child of node.children) {
+ count += countMarks(child);
+ }
+ return count;
+ }
+ // Should have at least 2 marks (Area and Spline)
+ expect(countMarks(root)).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/packages/layerchart/src/lib/server/renderTree.ts b/packages/layerchart/src/lib/server/renderTree.ts
new file mode 100644
index 000000000..3910d877b
--- /dev/null
+++ b/packages/layerchart/src/lib/server/renderTree.ts
@@ -0,0 +1,29 @@
+import type { ComponentNode } from '$lib/states/chart.svelte.js';
+
+/**
+ * Recursively render the component tree onto a canvas context.
+ * Group nodes: save → render → recurse children → restore
+ * Leaf nodes: save → render → restore
+ * Non-rendering nodes: just recurse children
+ */
+export function renderTree(ctx: CanvasRenderingContext2D, node: ComponentNode): void {
+ if (node.kind === 'group' && node.canvasRender) {
+ // Group: save state, apply transform, render children, restore
+ ctx.save();
+ node.canvasRender.render(ctx);
+ for (const child of node.children) {
+ renderTree(ctx, child);
+ }
+ ctx.restore();
+ } else if (node.canvasRender) {
+ // Leaf mark: save, render, restore
+ ctx.save();
+ node.canvasRender.render(ctx);
+ ctx.restore();
+ } else {
+ // Non-rendering node (e.g. root, composite-mark): just recurse children
+ for (const child of node.children) {
+ renderTree(ctx, child);
+ }
+ }
+}
diff --git a/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleChild.svelte b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleChild.svelte
new file mode 100644
index 000000000..e8eab4046
--- /dev/null
+++ b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleChild.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleHarness.svelte b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleHarness.svelte
new file mode 100644
index 000000000..aa88959ee
--- /dev/null
+++ b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleHarness.svelte
@@ -0,0 +1,48 @@
+
+
+toggle
+
+
+
+
+ {#if showChild}
+
+ {/if}
+
+
+
diff --git a/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleParent.svelte b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleParent.svelte
new file mode 100644
index 000000000..2d457cdd3
--- /dev/null
+++ b/packages/layerchart/src/lib/states/__fixtures__/ComponentNodeLifecycleParent.svelte
@@ -0,0 +1,26 @@
+
+
+{@render children?.()}
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png
new file mode 100644
index 000000000..dab2a6df3
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png
new file mode 100644
index 000000000..dab2a6df3
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-baseline-domain-multi-series-should-work-without-baseline--no-forced-0--2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-degenerate-domain-should-expand-degenerate-y-domain--5--5--to--5--6--2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-explicit-series-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-series-domain-update-on-visibility-toggle-should-update-y-domain-when-hiding-an-implicit-series-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-deduplicate-repeated-mark-x-keys-into-a-single-accessor-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-correct-y-domain-across-two-marks-with-different-data-and-no-y-prop-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-implicit-x-y-from-marks--no-x-y-on-Chart--should-derive-x-accessor-from-marks-when-x-prop-is-absent-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-aggregate-y-accessor-from-implicit-series-into-resolveAccessor-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-calculate-correct-y-domain-from-two-marks-with-same-y-accessor-but-different-data-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-deduplicate-implicit-series-with-the-same-key-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-seriesKey-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-marks-with-string-y-accessors-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-generate-implicit-series-from-x-accessor-for-vertical-charts--valueAxis-x--2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-data-from-two-marks-with-same-y-accessor-but-different-data-arrays-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-implicit-series-label-when-provided-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-mark-data-in-flatData-for-domain-calculation-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-include-per-mark-data-in-domain-via-implicit-series-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-not-double-include-data-when-mark-data-matches-series-data-reference-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-register-and-unregister-marks-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-revert-flatData-after-all-marks-unregister-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-1.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-2.png b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/chart.svelte.test.ts/ChartState-mark-registration-should-skip-marks-without-a-derivable-key-for-implicit-series-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-1.png b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-2.png b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-allow-toggling-after-initial-selected--false-2.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-1.png b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-1.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-1.png differ
diff --git a/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-2.png b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-2.png
new file mode 100644
index 000000000..850d5b364
Binary files /dev/null and b/packages/layerchart/src/lib/states/__screenshots__/series.svelte.test.ts/SeriesState-visibility-should-respect-selected--false-on-series-items-2.png differ
diff --git a/packages/layerchart/src/lib/states/brush.svelte.test.ts b/packages/layerchart/src/lib/states/brush.svelte.test.ts
new file mode 100644
index 000000000..0fb67a0a7
--- /dev/null
+++ b/packages/layerchart/src/lib/states/brush.svelte.test.ts
@@ -0,0 +1,568 @@
+import { describe, it, expect } from 'vitest';
+import { BrushState, expandBandBrushDomain } from './brush.svelte.js';
+import type { BrushChartContext } from './brush.svelte.js';
+
+/** Create a mock chart context with a simple linear scale over [0, 100] */
+function createMockCtx(options?: {
+ xDomain?: [number, number];
+ yDomain?: [number, number];
+ width?: number;
+ height?: number;
+}): BrushChartContext {
+ const xDomain = options?.xDomain ?? [0, 100];
+ const yDomain = options?.yDomain ?? [0, 100];
+ const width = options?.width ?? 500;
+ const height = options?.height ?? 300;
+
+ const xScale = (v: any) => ((v - xDomain[0]) / (xDomain[1] - xDomain[0])) * width;
+ const yScale = (v: any) => height - ((v - yDomain[0]) / (yDomain[1] - yDomain[0])) * height;
+
+ return {
+ xScale,
+ yScale,
+ baseXScale: { domain: () => xDomain },
+ baseYScale: { domain: () => yDomain },
+ width,
+ height,
+ };
+}
+
+describe('BrushState', () => {
+ describe('constructor', () => {
+ it('should initialize with default values', () => {
+ const brush = new BrushState(null);
+ expect(brush.x).toEqual([null, null]);
+ expect(brush.y).toEqual([null, null]);
+ expect(brush.active).toBeUndefined();
+ expect(brush.axis).toBe('x');
+ });
+
+ it('should initialize with provided options', () => {
+ const ctx = createMockCtx();
+ const brush = new BrushState(ctx, {
+ x: [10, 50],
+ y: [20, 80],
+ axis: 'both',
+ active: true,
+ });
+ expect(brush.x).toEqual([10, 50]);
+ expect(brush.y).toEqual([20, 80]);
+ expect(brush.axis).toBe('both');
+ expect(brush.active).toBe(true);
+ });
+ });
+
+ describe('domain bounds', () => {
+ it('should return domain min/max from base scales', () => {
+ const ctx = createMockCtx({ xDomain: [0, 200], yDomain: [10, 90] });
+ const brush = new BrushState(ctx);
+ expect(brush.xDomainMin).toBe(0);
+ expect(brush.xDomainMax).toBe(200);
+ expect(brush.yDomainMin).toBe(10);
+ expect(brush.yDomainMax).toBe(90);
+ });
+
+ it('should return undefined when no ctx', () => {
+ const brush = new BrushState(null);
+ expect(brush.xDomainMin).toBeUndefined();
+ expect(brush.xDomainMax).toBeUndefined();
+ });
+ });
+
+ describe('range', () => {
+ it('should return zero range when no ctx', () => {
+ const brush = new BrushState(null);
+ expect(brush.range).toEqual({ x: 0, y: 0, width: 0, height: 0 });
+ });
+
+ it('should compute pixel range from domain values (x-axis)', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], width: 500, height: 300 });
+ const brush = new BrushState(ctx, { x: [20, 80], axis: 'x' });
+
+ expect(brush.range.x).toBe(100); // 20/100 * 500
+ expect(brush.range.width).toBe(300); // (80-20)/100 * 500
+ // y-axis not active, should span full height
+ expect(brush.range.y).toBe(0);
+ expect(brush.range.height).toBe(300);
+ });
+
+ it('should compute pixel range for both axes', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100], width: 500, height: 300 });
+ const brush = new BrushState(ctx, { x: [20, 80], y: [25, 75], axis: 'both' });
+
+ expect(brush.range.x).toBe(100); // 20/100 * 500
+ expect(brush.range.width).toBe(300); // (80-20)/100 * 500
+ });
+ });
+
+ describe('reset', () => {
+ it('should clear x, y, and active state', () => {
+ const ctx = createMockCtx();
+ const brush = new BrushState(ctx, { x: [10, 50], y: [20, 80], active: true });
+
+ brush.reset();
+
+ expect(brush.x).toEqual([null, null]);
+ expect(brush.y).toEqual([null, null]);
+ expect(brush.active).toBe(false);
+ });
+ });
+
+ describe('selectAll', () => {
+ it('should set x and y to full domain extent', () => {
+ const ctx = createMockCtx({ xDomain: [0, 200], yDomain: [10, 90] });
+ const brush = new BrushState(ctx);
+
+ brush.selectAll();
+
+ expect(brush.x).toEqual([0, 200]);
+ expect(brush.y).toEqual([10, 90]);
+ });
+ });
+
+ describe('move', () => {
+ it('should set x domain and activate', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.move({ x: [20, 80] });
+
+ expect(brush.x).toEqual([20, 80]);
+ expect(brush.active).toBe(true);
+ });
+
+ it('should set y domain only', () => {
+ const ctx = createMockCtx({ yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'y', x: [10, 50] });
+
+ brush.move({ y: [30, 70] });
+
+ expect(brush.x).toEqual([10, 50]); // unchanged
+ expect(brush.y).toEqual([30, 70]);
+ expect(brush.active).toBe(true);
+ });
+
+ it('should set both axes', () => {
+ const ctx = createMockCtx();
+ const brush = new BrushState(ctx, { axis: 'both' });
+
+ brush.move({ x: [10, 90], y: [20, 80] });
+
+ expect(brush.x).toEqual([10, 90]);
+ expect(brush.y).toEqual([20, 80]);
+ expect(brush.active).toBe(true);
+ });
+
+ it('should clear with null', () => {
+ const ctx = createMockCtx();
+ const brush = new BrushState(ctx, { axis: 'x', x: [20, 80], active: true });
+
+ brush.move({ x: null });
+
+ expect(brush.x).toEqual([null, null]);
+ expect(brush.active).toBe(false);
+ });
+
+ it('should be inactive when only non-active axis has values', () => {
+ const ctx = createMockCtx();
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.move({ y: [20, 80] }); // axis is 'x', so y doesn't count
+
+ expect(brush.active).toBe(false);
+ });
+ });
+
+ describe('setRange', () => {
+ it('should set brush to new range with clamping', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx);
+
+ brush.setRange({ x: 20, y: 30 }, { x: 60, y: 70 });
+
+ expect(brush.active).toBe(true);
+ expect(brush.x).toEqual([20, 60]);
+ expect(brush.y).toEqual([30, 70]);
+ });
+
+ it('should handle reversed values (drag right-to-left)', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx);
+
+ brush.setRange({ x: 60, y: 70 }, { x: 20, y: 30 });
+
+ expect(brush.x).toEqual([20, 60]);
+ expect(brush.y).toEqual([30, 70]);
+ });
+
+ it('should clamp to domain bounds', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx);
+
+ brush.setRange({ x: -10, y: -10 }, { x: 150, y: 150 });
+
+ expect(brush.x).toEqual([0, 100]);
+ expect(brush.y).toEqual([0, 100]);
+ });
+ });
+
+ describe('moveRange', () => {
+ it('should move the range by delta', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { x: [20, 40], y: [20, 40] });
+
+ brush.moveRange({ x: [20, 40], y: [20, 40], value: { x: 30, y: 30 } }, { x: 40, y: 50 });
+
+ // Delta: x=+10, y=+20
+ expect(brush.x).toEqual([30, 50]);
+ expect(brush.y).toEqual([40, 60]);
+ });
+
+ it('should clamp movement to domain bounds', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx);
+
+ brush.moveRange({ x: [80, 100], y: [80, 100], value: { x: 90, y: 90 } }, { x: 110, y: 110 });
+
+ // Should not exceed domain max
+ expect(brush.x).toEqual([80, 100]);
+ expect(brush.y).toEqual([80, 100]);
+ });
+ });
+
+ describe('adjustEdge', () => {
+ it('should adjust the right edge', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { x: [20, 60] });
+
+ brush.adjustEdge('right', { x: [20, 60], y: [0, 100] }, { x: 80, y: 50 });
+
+ expect(brush.x).toEqual([20, 80]);
+ });
+
+ it('should adjust the left edge', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { x: [20, 60] });
+
+ brush.adjustEdge('left', { x: [20, 60], y: [0, 100] }, { x: 10, y: 50 });
+
+ expect(brush.x).toEqual([10, 60]);
+ });
+
+ it('should invert edges when dragged past the opposite edge', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { x: [20, 60] });
+
+ // Drag left handle past right edge
+ brush.adjustEdge('left', { x: [20, 60], y: [0, 100] }, { x: 80, y: 50 });
+
+ expect(brush.x).toEqual([60, 80]);
+ });
+
+ it('should adjust the top edge', () => {
+ const ctx = createMockCtx({ yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { y: [20, 60] });
+
+ // Top edge pivots around start.y[0] (bottom of range)
+ brush.adjustEdge('top', { x: [0, 100], y: [20, 60] }, { x: 50, y: 10 });
+
+ expect(brush.y).toEqual([10, 20]);
+ });
+
+ it('should adjust the bottom edge', () => {
+ const ctx = createMockCtx({ yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { y: [20, 60] });
+
+ // Bottom edge pivots around start.y[1] (top of range)
+ brush.adjustEdge('bottom', { x: [0, 100], y: [20, 60] }, { x: 50, y: 80 });
+
+ expect(brush.y).toEqual([60, 80]);
+ });
+
+ it('should clamp to domain bounds', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { x: [20, 60] });
+
+ brush.adjustEdge('right', { x: [20, 60], y: [0, 100] }, { x: 150, y: 50 });
+
+ expect(brush.x).toEqual([20, 100]);
+ });
+ });
+
+ describe('syncFromExternal', () => {
+ it('should sync x/y values and set active when different from domain', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.syncFromExternal([20, 80], null);
+
+ expect(brush.x).toEqual([20, 80]);
+ expect(brush.y).toEqual([null, null]);
+ expect(brush.active).toBe(true);
+ });
+
+ it('should set inactive when x matches full domain', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.syncFromExternal([0, 100], null);
+
+ expect(brush.active).toBe(false);
+ });
+
+ it('should set inactive when x is null (reset)', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x', active: true, x: [20, 80] });
+
+ brush.syncFromExternal(null, null);
+
+ expect(brush.x).toEqual([null, null]);
+ expect(brush.active).toBe(false);
+ });
+
+ it('should set inactive when x is [null, null] (reset via onChange)', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x', active: true, x: [20, 80] });
+
+ brush.syncFromExternal([null, null], null);
+
+ expect(brush.x).toEqual([null, null]);
+ expect(brush.active).toBe(false);
+ });
+
+ it('should handle both axis mode', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'both' });
+
+ brush.syncFromExternal([20, 80], [30, 70]);
+
+ expect(brush.active).toBe(true);
+ });
+
+ it('should be inactive in both mode when only y differs but axis is x', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100], yDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.syncFromExternal([0, 100], [20, 80]);
+
+ expect(brush.active).toBe(false);
+ });
+
+ it('should handle Date domains', () => {
+ const d1 = new Date('2024-01-01');
+ const d2 = new Date('2024-12-31');
+ const d3 = new Date('2024-03-01');
+ const d4 = new Date('2024-09-01');
+
+ const ctx = createMockCtx();
+ // Override baseXScale to use dates
+ ctx.baseXScale = { domain: () => [d1, d2] };
+
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ brush.syncFromExternal([d3, d4], null);
+
+ expect(brush.x).toEqual([d3, d4]);
+ expect(brush.active).toBe(true);
+ });
+
+ it('should be inactive when Date domain matches full extent', () => {
+ const d1 = new Date('2024-01-01');
+ const d2 = new Date('2024-12-31');
+
+ const ctx = createMockCtx();
+ ctx.baseXScale = { domain: () => [d1, d2] };
+
+ const brush = new BrushState(ctx, { axis: 'x' });
+
+ // Same date values but different object references — should compare by valueOf
+ brush.syncFromExternal([new Date('2024-01-01'), new Date('2024-12-31')], null);
+
+ expect(brush.active).toBe(false);
+ });
+
+ it('should not write when values have not changed', () => {
+ const ctx = createMockCtx({ xDomain: [0, 100] });
+ const brush = new BrushState(ctx, { axis: 'x', x: [20, 80] });
+
+ const originalX = brush.x;
+ brush.syncFromExternal([20, 80], null);
+
+ // Should be the same reference (no write)
+ expect(brush.x).toBe(originalX);
+ });
+ });
+
+ describe('band scale support', () => {
+ const categories = ['A', 'B', 'C', 'D', 'E'];
+ const bandwidth = 80; // 500 / 5 = 100 step, with padding ~80 band
+
+ function createBandMockCtx(): BrushChartContext {
+ const width = 500;
+ const height = 300;
+ const step = width / categories.length;
+ const bw = bandwidth;
+
+ const xScale = Object.assign(
+ (v: any) => {
+ const idx = categories.indexOf(v);
+ return idx * step + (step - bw) / 2;
+ },
+ { bandwidth: () => bw }
+ );
+ const yScale = (v: any) => height - ((v as number) / 100) * height;
+
+ return {
+ xScale,
+ yScale,
+ baseXScale: { domain: () => categories },
+ baseYScale: { domain: () => [0, 100] },
+ width,
+ height,
+ };
+ }
+
+ it('should return correct domain min/max for band scales', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx);
+
+ expect(brush.xDomainMin).toBe('A');
+ expect(brush.xDomainMax).toBe('E');
+ });
+
+ it('should selectAll with first and last categories', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx);
+
+ brush.selectAll();
+
+ expect(brush.x).toEqual(['A', 'E']);
+ });
+
+ it('should setRange with categorical values', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx);
+
+ brush.setRange({ x: 'B', y: 30 }, { x: 'D', y: 70 });
+
+ expect(brush.active).toBe(true);
+ expect(brush.x).toEqual(['B', 'D']);
+ });
+
+ it('should setRange with reversed categorical values', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx);
+
+ brush.setRange({ x: 'D', y: 30 }, { x: 'B', y: 70 });
+
+ expect(brush.x).toEqual(['B', 'D']);
+ });
+
+ it('should clamp setRange to domain bounds', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx);
+
+ // 'A' is the min, 'E' is the max
+ brush.setRange({ x: 'A', y: 0 }, { x: 'E', y: 100 });
+
+ expect(brush.x).toEqual(['A', 'E']);
+ });
+
+ it('should compute range with bandwidth for band scales', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['B', 'D'], axis: 'x' });
+
+ const range = brush.range;
+ // Right edge should include bandwidth of last category
+ const leftPx = ctx.xScale('B');
+ const rightPx = ctx.xScale('D') + bandwidth;
+ expect(range.x).toBe(leftPx);
+ expect(range.width).toBe(rightPx - leftPx);
+ });
+
+ it('should moveRange by category offset', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['B', 'C'], y: [20, 40] });
+
+ brush.moveRange({ x: ['B', 'C'], y: [20, 40], value: { x: 'B', y: 30 } }, { x: 'C', y: 40 });
+
+ // Delta of 1 category to the right
+ expect(brush.x).toEqual(['C', 'D']);
+ });
+
+ it('should clamp moveRange to domain bounds', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['D', 'E'] });
+
+ brush.moveRange(
+ { x: ['D', 'E'], y: [0, 100], value: { x: 'D', y: 50 } },
+ { x: 'E', y: 50 } // try to move right by 1
+ );
+
+ // Should stay at domain boundary
+ expect(brush.x).toEqual(['D', 'E']);
+ });
+
+ it('should adjustEdge right for categorical', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['B', 'D'] });
+
+ brush.adjustEdge('right', { x: ['B', 'D'], y: [0, 100] }, { x: 'E', y: 50 });
+
+ expect(brush.x).toEqual(['B', 'E']);
+ });
+
+ it('should adjustEdge left for categorical', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['B', 'D'] });
+
+ brush.adjustEdge('left', { x: ['B', 'D'], y: [0, 100] }, { x: 'A', y: 50 });
+
+ expect(brush.x).toEqual(['A', 'D']);
+ });
+
+ it('should invert edges when dragged past opposite edge (categorical)', () => {
+ const ctx = createBandMockCtx();
+ const brush = new BrushState(ctx, { x: ['B', 'D'] });
+
+ // Drag left handle past right edge
+ brush.adjustEdge('left', { x: ['B', 'D'], y: [0, 100] }, { x: 'E', y: 50 });
+
+ expect(brush.x).toEqual(['D', 'E']);
+ });
+ });
+});
+
+describe('expandBandBrushDomain', () => {
+ const baseDomain = ['A', 'B', 'C', 'D', 'E'];
+
+ it('should expand [first, last] to full category subarray', () => {
+ expect(expandBandBrushDomain(['B', 'D'], baseDomain)).toEqual(['B', 'C', 'D']);
+ });
+
+ it('should return full domain for [first, last] matching full extent', () => {
+ expect(expandBandBrushDomain(['A', 'E'], baseDomain)).toEqual(['A', 'B', 'C', 'D', 'E']);
+ });
+
+ it('should return single category when first equals last', () => {
+ expect(expandBandBrushDomain(['C', 'C'], baseDomain)).toEqual(['C']);
+ });
+
+ it('should pass through numeric domains unchanged', () => {
+ expect(expandBandBrushDomain([10, 50], [0, 100])).toEqual([10, 50]);
+ });
+
+ it('should pass through null domains unchanged', () => {
+ expect(expandBandBrushDomain([null, null], baseDomain)).toEqual([null, null]);
+ });
+
+ it('should pass through Date domains unchanged', () => {
+ const d1 = new Date('2024-01-01');
+ const d2 = new Date('2024-06-01');
+ expect(expandBandBrushDomain([d1, d2], [d1, d2])).toEqual([d1, d2]);
+ });
+
+ it('should return unchanged if category not found in domain', () => {
+ expect(expandBandBrushDomain(['X', 'Y'], baseDomain)).toEqual(['X', 'Y']);
+ });
+});
diff --git a/packages/layerchart/src/lib/states/brush.svelte.ts b/packages/layerchart/src/lib/states/brush.svelte.ts
new file mode 100644
index 000000000..90c08a137
--- /dev/null
+++ b/packages/layerchart/src/lib/states/brush.svelte.ts
@@ -0,0 +1,360 @@
+import { clamp } from '@layerstack/utils';
+import { min, max } from 'd3-array';
+
+import { add } from '../utils/math.js';
+
+export type BrushDomainType = Array;
+
+/**
+ * For band scales, expand a [first, last] brush selection into the full category subarray.
+ * For continuous scales, returns the domain unchanged.
+ */
+export function expandBandBrushDomain(
+ brushDomain: BrushDomainType,
+ baseDomain: any[]
+): BrushDomainType {
+ if (brushDomain[0] == null || brushDomain[1] == null || typeof brushDomain[0] !== 'string') {
+ return brushDomain;
+ }
+ const startIdx = baseDomain.indexOf(brushDomain[0]);
+ const endIdx = baseDomain.indexOf(brushDomain[1]);
+ if (startIdx === -1 || endIdx === -1) return brushDomain;
+ return baseDomain.slice(startIdx, endIdx + 1);
+}
+
+export type BrushRange = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+/**
+ * Minimal interface for the chart context that BrushState depends on.
+ * Narrowed from ChartState to only what brush needs, enabling easier testing.
+ */
+export type BrushChartContext = {
+ xScale: ((v: any) => number) & { bandwidth?: () => number };
+ yScale: ((v: any) => number) & { bandwidth?: () => number };
+ baseXScale: { domain: () => any[] };
+ baseYScale: { domain: () => any[] };
+ width: number;
+ height: number;
+};
+
+/** Check if a domain array is categorical (string-based) */
+function isCategoricalDomain(domain: any[]): boolean {
+ return domain.length > 0 && typeof domain[0] === 'string';
+}
+
+/** Get the min (by domain index) of two values */
+function minByIndex(a: any, b: any, domain: any[]): any {
+ return domain.indexOf(a) <= domain.indexOf(b) ? a : b;
+}
+
+/** Get the max (by domain index) of two values */
+function maxByIndex(a: any, b: any, domain: any[]): any {
+ return domain.indexOf(a) >= domain.indexOf(b) ? a : b;
+}
+
+/** Clamp a value to domain bounds by index */
+function clampByIndex(value: any, minVal: any, maxVal: any, domain: any[]): any {
+ const idx = domain.indexOf(value);
+ const minIdx = domain.indexOf(minVal);
+ const maxIdx = domain.indexOf(maxVal);
+ if (idx === -1) return minVal;
+ if (idx < minIdx) return minVal;
+ if (idx > maxIdx) return maxVal;
+ return value;
+}
+
+export class BrushState {
+ ctx: BrushChartContext | null;
+
+ x = $state([null, null]);
+ y = $state([null, null]);
+ active = $state();
+ axis = $state<'x' | 'y' | 'both'>('x');
+ handleSize = $state(0);
+
+ constructor(
+ ctx: typeof this.ctx,
+ options?: {
+ x?: BrushDomainType;
+ y?: BrushDomainType;
+ active?: boolean;
+ axis?: 'x' | 'y' | 'both';
+ }
+ ) {
+ this.ctx = ctx;
+
+ this.x = options?.x ?? [null, null];
+ this.y = options?.y ?? [null, null];
+ this.active = options?.active;
+ this.axis = options?.axis ?? 'x';
+ }
+
+ /** The domain extent bounds from the base (unzoomed) scales */
+ get xDomainMin() {
+ return this.ctx?.baseXScale.domain()[0];
+ }
+ get xDomainMax() {
+ return this.ctx?.baseXScale.domain().at(-1);
+ }
+ get yDomainMin() {
+ return this.ctx?.baseYScale.domain()[0];
+ }
+ get yDomainMax() {
+ return this.ctx?.baseYScale.domain().at(-1);
+ }
+
+ get range() {
+ if (!this.ctx) {
+ return { x: 0, y: 0, width: 0, height: 0 };
+ }
+
+ const xBw = this.ctx.xScale.bandwidth?.() ?? 0;
+ const yBw = this.ctx.yScale.bandwidth?.() ?? 0;
+
+ const left = this.ctx.xScale(this.x?.[0]);
+ const right = this.ctx.xScale(this.x?.[1]) + xBw;
+ const top = this.ctx.yScale(this.y?.[1]);
+ const bottom = this.ctx.yScale(this.y?.[0]) + yBw;
+
+ return {
+ x: this.axis === 'both' || this.axis === 'x' ? left : 0,
+ y: this.axis === 'both' || this.axis === 'y' ? top : 0,
+ width: this.axis === 'both' || this.axis === 'x' ? right - left : this.ctx.width,
+ height: this.axis === 'both' || this.axis === 'y' ? bottom - top : this.ctx.height,
+ };
+ }
+
+ /** Reset brush to cleared state */
+ reset() {
+ this.active = false;
+ this.x = [null, null];
+ this.y = [null, null];
+ }
+
+ /** Select the full domain extent */
+ selectAll() {
+ this.active = true;
+ this.x = [this.xDomainMin, this.xDomainMax];
+ this.y = [this.yDomainMin, this.yDomainMax];
+ }
+
+ /** Programmatically set the brush selection. Like d3's `brush.move()`. */
+ move(selection: { x?: BrushDomainType | null; y?: BrushDomainType | null }) {
+ if ('x' in selection) {
+ this.x = selection.x ?? [null, null];
+ }
+ if ('y' in selection) {
+ this.y = selection.y ?? [null, null];
+ }
+
+ // Determine active state from current values
+ const hasX = this.x[0] != null && this.x[1] != null;
+ const hasY = this.y[0] != null && this.y[1] != null;
+ this.active = this.axis === 'x' ? hasX : this.axis === 'y' ? hasY : hasX || hasY;
+ }
+
+ /** Set brush to a new range, clamped to domain bounds */
+ setRange(startValue: { x: any; y: any }, currentValue: { x: any; y: any }) {
+ this.active = true;
+ const xDomain = this.ctx?.baseXScale.domain() ?? [];
+ const yDomain = this.ctx?.baseYScale.domain() ?? [];
+
+ if (isCategoricalDomain(xDomain)) {
+ this.x = [
+ clampByIndex(
+ minByIndex(startValue.x, currentValue.x, xDomain),
+ this.xDomainMin,
+ this.xDomainMax,
+ xDomain
+ ),
+ clampByIndex(
+ maxByIndex(startValue.x, currentValue.x, xDomain),
+ this.xDomainMin,
+ this.xDomainMax,
+ xDomain
+ ),
+ ];
+ } else {
+ this.x = [
+ clamp(min([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
+ clamp(max([startValue.x, currentValue.x]), this.xDomainMin, this.xDomainMax),
+ ];
+ }
+
+ if (isCategoricalDomain(yDomain)) {
+ this.y = [
+ clampByIndex(
+ minByIndex(startValue.y, currentValue.y, yDomain),
+ this.yDomainMin,
+ this.yDomainMax,
+ yDomain
+ ),
+ clampByIndex(
+ maxByIndex(startValue.y, currentValue.y, yDomain),
+ this.yDomainMin,
+ this.yDomainMax,
+ yDomain
+ ),
+ ];
+ } else {
+ this.y = [
+ clamp(min([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
+ clamp(max([startValue.y, currentValue.y]), this.yDomainMin, this.yDomainMax),
+ ];
+ }
+ }
+
+ /** Move the entire brush range by a delta, clamped to domain bounds */
+ moveRange(
+ start: { x: [any, any]; y: [any, any]; value: { x: any; y: any } },
+ currentValue: { x: any; y: any }
+ ) {
+ const xDomain = this.ctx?.baseXScale.domain() ?? [];
+ const yDomain = this.ctx?.baseYScale.domain() ?? [];
+
+ if (isCategoricalDomain(xDomain)) {
+ const startIdx = xDomain.indexOf(start.value.x);
+ const currentIdx = xDomain.indexOf(currentValue.x);
+ const origStartIdx = xDomain.indexOf(start.x[0]);
+ const origEndIdx = xDomain.indexOf(start.x[1]);
+ const delta = Math.max(
+ -origStartIdx,
+ Math.min(xDomain.length - 1 - origEndIdx, currentIdx - startIdx)
+ );
+ this.x = [xDomain[origStartIdx + delta], xDomain[origEndIdx + delta]];
+ } else {
+ const dx = clamp(
+ currentValue.x - start.value.x,
+ this.xDomainMin - +start.x[0],
+ this.xDomainMax - +start.x[1]
+ );
+ this.x = [add(start.x[0], dx), add(start.x[1], dx)];
+ }
+
+ if (isCategoricalDomain(yDomain)) {
+ const startIdx = yDomain.indexOf(start.value.y);
+ const currentIdx = yDomain.indexOf(currentValue.y);
+ const origStartIdx = yDomain.indexOf(start.y[0]);
+ const origEndIdx = yDomain.indexOf(start.y[1]);
+ const delta = Math.max(
+ -origStartIdx,
+ Math.min(yDomain.length - 1 - origEndIdx, currentIdx - startIdx)
+ );
+ this.y = [yDomain[origStartIdx + delta], yDomain[origEndIdx + delta]];
+ } else {
+ const dy = clamp(
+ currentValue.y - start.value.y,
+ this.yDomainMin - +start.y[0],
+ this.yDomainMax - +start.y[1]
+ );
+ this.y = [add(start.y[0], dy), add(start.y[1], dy)];
+ }
+ }
+
+ /** Adjust a single edge of the brush, clamped to domain bounds. Handles inversion if dragged past opposite edge. */
+ adjustEdge(
+ edge: 'top' | 'bottom' | 'left' | 'right',
+ start: { x: [any, any]; y: [any, any] },
+ currentValue: { x: any; y: any }
+ ) {
+ const xDomain = this.ctx?.baseXScale.domain() ?? [];
+ const yDomain = this.ctx?.baseYScale.domain() ?? [];
+ const xCat = isCategoricalDomain(xDomain);
+ const yCat = isCategoricalDomain(yDomain);
+
+ const clampX = (v: any) =>
+ xCat
+ ? clampByIndex(v, this.xDomainMin, this.xDomainMax, xDomain)
+ : clamp(v, this.xDomainMin, this.xDomainMax);
+ const clampY = (v: any) =>
+ yCat
+ ? clampByIndex(v, this.yDomainMin, this.yDomainMax, yDomain)
+ : clamp(v, this.yDomainMin, this.yDomainMax);
+ const ltX = (a: any, b: any) => (xCat ? xDomain.indexOf(a) < xDomain.indexOf(b) : a < +b);
+ const gtX = (a: any, b: any) => (xCat ? xDomain.indexOf(a) > xDomain.indexOf(b) : a > +b);
+ const ltY = (a: any, b: any) => (yCat ? yDomain.indexOf(a) < yDomain.indexOf(b) : a < +b);
+ const gtY = (a: any, b: any) => (yCat ? yDomain.indexOf(a) > yDomain.indexOf(b) : a > +b);
+
+ switch (edge) {
+ case 'top':
+ this.y = [
+ clampY(ltY(currentValue.y, start.y[0]) ? currentValue.y : start.y[0]),
+ clampY(ltY(currentValue.y, start.y[0]) ? start.y[0] : currentValue.y),
+ ];
+ break;
+ case 'bottom':
+ this.y = [
+ clampY(gtY(currentValue.y, start.y[1]) ? start.y[1] : currentValue.y),
+ clampY(gtY(currentValue.y, start.y[1]) ? currentValue.y : start.y[1]),
+ ];
+ break;
+ case 'left':
+ this.x = [
+ clampX(gtX(currentValue.x, start.x[1]) ? start.x[1] : currentValue.x),
+ clampX(gtX(currentValue.x, start.x[1]) ? currentValue.x : start.x[1]),
+ ];
+ break;
+ case 'right':
+ this.x = [
+ clampX(ltX(currentValue.x, start.x[0]) ? currentValue.x : start.x[0]),
+ clampX(ltX(currentValue.x, start.x[0]) ? start.x[0] : currentValue.x),
+ ];
+ break;
+ }
+ }
+
+ /**
+ * Sync external domain values into brush state.
+ * Only writes when values actually differ to avoid reactive loops.
+ */
+ syncFromExternal(
+ externalX: BrushDomainType | null | undefined,
+ externalY: BrushDomainType | null | undefined
+ ) {
+ const newX = externalX ?? [null, null];
+ const newY = externalY ?? [null, null];
+
+ // Only write when values actually differ to avoid reactive loops
+ if (
+ this.x[0]?.valueOf() !== newX[0]?.valueOf() ||
+ this.x[1]?.valueOf() !== newX[1]?.valueOf()
+ ) {
+ this.x = newX;
+ }
+ if (
+ this.y[0]?.valueOf() !== newY[0]?.valueOf() ||
+ this.y[1]?.valueOf() !== newY[1]?.valueOf()
+ ) {
+ this.y = newY;
+ }
+
+ const isXAxisActive =
+ externalX != null &&
+ externalX[0] != null &&
+ externalX[1] != null &&
+ (externalX[0].valueOf() !== this.xDomainMin?.valueOf() ||
+ externalX[1].valueOf() !== this.xDomainMax?.valueOf());
+ const isYAxisActive =
+ externalY != null &&
+ externalY[0] != null &&
+ externalY[1] != null &&
+ (externalY[0].valueOf() !== this.yDomainMin?.valueOf() ||
+ externalY[1].valueOf() !== this.yDomainMax?.valueOf());
+
+ const newActive =
+ this.axis === 'x'
+ ? isXAxisActive
+ : this.axis === 'y'
+ ? isYAxisActive
+ : isXAxisActive || isYAxisActive;
+
+ if (this.active !== newActive) {
+ this.active = newActive;
+ }
+ }
+}
diff --git a/packages/layerchart/src/lib/states/chart.component-node.svelte.test.ts b/packages/layerchart/src/lib/states/chart.component-node.svelte.test.ts
new file mode 100644
index 000000000..8ef6bd2c6
--- /dev/null
+++ b/packages/layerchart/src/lib/states/chart.component-node.svelte.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import type { ChartState, ComponentNode } from './chart.svelte.js';
+import ComponentNodeLifecycleHarness from './__fixtures__/ComponentNodeLifecycleHarness.svelte';
+
+describe('ChartState registerComponent', () => {
+ it('cleans up child nodes and mark registrations when components unmount', async () => {
+ let chartContext: ChartState | undefined;
+ let parentNode: ComponentNode | undefined;
+
+ render(ComponentNodeLifecycleHarness, {
+ oncontext: (ctx: ChartState) => {
+ chartContext = ctx;
+ },
+ onparentnode: (node: ComponentNode) => {
+ parentNode = node;
+ },
+ });
+
+ const toggle = page.getByTestId('toggle-child');
+ const child = page.getByTestId('component-node-lifecycle-child');
+
+ await expect.element(toggle).toBeInTheDocument();
+ await expect.element(child).toBeInTheDocument();
+ await expect.poll(() => parentNode?.children.length ?? -1).toBe(1);
+ await expect.poll(() => chartContext?.series.isDefaultSeries ?? true).toBe(false);
+
+ await toggle.click();
+
+ await expect.element(child).not.toBeInTheDocument();
+ await expect.poll(() => parentNode?.children.length ?? -1).toBe(0);
+ await expect.poll(() => chartContext?.series.isDefaultSeries ?? false).toBe(true);
+ });
+});
diff --git a/packages/layerchart/src/lib/states/chart.svelte.test.ts b/packages/layerchart/src/lib/states/chart.svelte.test.ts
new file mode 100644
index 000000000..88b9ccb78
--- /dev/null
+++ b/packages/layerchart/src/lib/states/chart.svelte.test.ts
@@ -0,0 +1,1841 @@
+import { describe, it, expect } from 'vitest';
+import { flushSync } from 'svelte';
+
+import { scaleBand } from 'd3-scale';
+import { geoAlbersUsa } from 'd3-geo';
+import { timeDay } from 'd3-time';
+
+import { ChartState } from './chart.svelte.js';
+import type { ChartPropsWithoutHTML } from '$lib/components/Chart.svelte';
+import { isScaleBand, isScaleTime } from '$lib/utils/scales.svelte.js';
+
+type TestData = { date: string; value: number };
+type MultiSeriesData = { date: string; apples: number; bananas: number };
+type WideData = { year: string; apples: number; bananas: number; cherries: number; grapes: number };
+type GeoData = { name: string; longitude: number; latitude: number };
+
+function createChartState(props: Partial>) {
+ let cleanup: () => void;
+ let state: ChartState;
+
+ cleanup = $effect.root(() => {
+ state = new ChartState(() => props as ChartPropsWithoutHTML);
+ });
+
+ // Access derived values after reactive graph is set up
+ flushSync();
+
+ return { state: state!, cleanup };
+}
+
+describe('ChartState baseline domain', () => {
+ describe('single series (default)', () => {
+ it('should include yBaseline=0 in y domain when all values are positive', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ { date: '2024-03', value: 30 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ yBaseline: 0,
+ });
+
+ try {
+ expect(state._yDomain).toEqual([0, 30]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include yBaseline=0 in y domain when all values are negative', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: -30 },
+ { date: '2024-02', value: -20 },
+ { date: '2024-03', value: -10 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ yBaseline: 0,
+ });
+
+ try {
+ expect(state._yDomain).toEqual([-30, 0]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not alter domain when baseline is within data range', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: -10 },
+ { date: '2024-02', value: 20 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ yBaseline: 0,
+ });
+
+ try {
+ expect(state._yDomain).toEqual([-10, 20]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include xBaseline=0 in x domain for horizontal charts', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'value',
+ y: 'date',
+ xBaseline: 0,
+ });
+
+ try {
+ expect(state._xDomain).toEqual([0, 20]);
+ } finally {
+ cleanup();
+ }
+ });
+ });
+
+ describe('multi-series', () => {
+ it('should include yBaseline=0 in y domain for multi-series', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 15 },
+ { date: '2024-02', apples: 20, bananas: 25 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ yBaseline: 0,
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ expect(state._yDomain).toEqual([0, 25]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include yBaseline=0 when all multi-series values are positive and above 0', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ yBaseline: 0,
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // Without the fix, this would be [50, 80] (missing baseline)
+ expect(state._yDomain).toEqual([0, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include yBaseline=0 when all multi-series values are negative', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: -30, bananas: -20 },
+ { date: '2024-02', apples: -10, bananas: -5 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ yBaseline: 0,
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ expect(state._yDomain).toEqual([-30, 0]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include xBaseline=0 for horizontal multi-series', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 15 },
+ { date: '2024-02', apples: 20, bananas: 25 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ y: 'date',
+ xBaseline: 0,
+ valueAxis: 'x',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ expect(state._xDomain).toEqual([0, 25]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should work without baseline (no forced 0)', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // Without baseline, domain should just be extent of data
+ expect(state._yDomain).toEqual([50, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+ });
+});
+
+describe('ChartState mark registration', () => {
+ it('should register and unregister marks', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ });
+
+ try {
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+
+ const unregister = state.registerMark({ y: 'value', color: 'red' });
+ flushSync();
+
+ // After registration, implicit series should be created
+ expect(state.seriesState.isDefaultSeries).toBe(false);
+ expect(state.seriesState.series).toHaveLength(1);
+ expect(state.seriesState.series[0].key).toBe('value');
+ expect(state.seriesState.series[0].color).toBe('red');
+
+ unregister();
+ flushSync();
+
+ // After unregistration, should revert to default
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not create implicit series when mark accessor matches chart accessor', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ // Mark with same y as chart — not a new series, just using chart's axis
+ state.registerMark({ y: 'value', color: 'red' });
+ flushSync();
+
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should generate implicit series from marks with string y accessors', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 15 },
+ { date: '2024-02', apples: 20, bananas: 25 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ });
+
+ try {
+ state.registerMark({ y: 'apples', color: 'red' });
+ state.registerMark({ y: 'bananas', color: 'yellow' });
+ flushSync();
+
+ expect(state.seriesState.isDefaultSeries).toBe(false);
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state.seriesState.series[0]).toMatchObject({ key: 'apples', color: 'red' });
+ expect(state.seriesState.series[1]).toMatchObject({ key: 'bananas', color: 'yellow' });
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should generate implicit series from marks with seriesKey', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ seriesKey: 'temp', color: 'blue' });
+ state.registerMark({ seriesKey: 'humidity', color: 'green' });
+ flushSync();
+
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state.seriesState.series[0].key).toBe('temp');
+ expect(state.seriesState.series[1].key).toBe('humidity');
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should generate implicit series from x accessor for vertical charts (valueAxis=x)', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 15 },
+ { date: '2024-02', apples: 20, bananas: 25 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ y: 'date',
+ valueAxis: 'x',
+ });
+
+ try {
+ state.registerMark({ x: 'apples', color: 'red' });
+ state.registerMark({ x: 'bananas', color: 'yellow' });
+ flushSync();
+
+ expect(state.seriesState.isDefaultSeries).toBe(false);
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state.seriesState.series[0]).toMatchObject({
+ key: 'apples',
+ color: 'red',
+ value: 'apples',
+ });
+ expect(state.seriesState.series[1]).toMatchObject({
+ key: 'bananas',
+ color: 'yellow',
+ value: 'bananas',
+ });
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not generate implicit series when explicit series are provided', () => {
+ const data: MultiSeriesData[] = [{ date: '2024-01', apples: 10, bananas: 15 }];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // Register marks that would normally create implicit series
+ state.registerMark({ y: 'apples', color: 'red' });
+ state.registerMark({ y: 'bananas', color: 'yellow' });
+ flushSync();
+
+ // Explicit series should take precedence
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state.seriesState.series[0].color).toBeUndefined(); // explicit series has no color
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should deduplicate implicit series with the same key', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ });
+
+ try {
+ // Two marks referencing the same y accessor
+ state.registerMark({ y: 'value', color: 'red' });
+ state.registerMark({ y: 'value', color: 'blue' });
+ flushSync();
+
+ // Should only create one series (first wins)
+ expect(state.seriesState.series).toHaveLength(1);
+ expect(state.seriesState.series[0].key).toBe('value');
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include mark data in flatData for domain calculation', () => {
+ const markData = [
+ { date: '2024-01', value: 100 },
+ { date: '2024-02', value: 200 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ data: markData });
+ flushSync();
+
+ expect(state.flatData).toHaveLength(2);
+ expect(state.flatData).toEqual(markData);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should skip marks without a derivable key for implicit series', () => {
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ // Mark with data but no string y or seriesKey — no implicit series
+ state.registerMark({ data: [{ date: '2024-01', value: 10 }] });
+ flushSync();
+
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ // But data should still be in flatData
+ expect(state.flatData).toHaveLength(1);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should aggregate y accessor from implicit series into resolveAccessor', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 50 },
+ { date: '2024-02', apples: 20, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ // No y prop — should be derived from marks
+ });
+
+ try {
+ state.registerMark({ y: 'apples', color: 'red' });
+ state.registerMark({ y: 'bananas', color: 'yellow' });
+ flushSync();
+
+ // y accessor should return both values
+ const result = state.y(data[0]);
+ expect(result).toEqual([10, 50]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include per-mark data in domain via implicit series', () => {
+ const markData1 = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+ const markData2 = [
+ { date: '2024-01', value: 50 },
+ { date: '2024-02', value: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ });
+
+ try {
+ state.registerMark({ y: 'value', data: markData1, color: 'red' });
+ state.registerMark({ y: 'value', data: markData2, color: 'blue' });
+ flushSync();
+
+ // Both marks have the same y='value' key so they deduplicate to one series,
+ // but the first mark's data should be on the series
+ expect(state.seriesState.series).toHaveLength(1);
+ expect(state.seriesState.series[0].data).toBe(markData1);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include data from two marks with same y accessor but different data arrays', () => {
+ const data1: TestData[] = [
+ { date: '2024-01', value: 30 },
+ { date: '2024-02', value: 40 },
+ ];
+ const data2: TestData[] = [
+ { date: '2024-01', value: 60 },
+ { date: '2024-02', value: 70 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ y: 'value', data: data1, color: 'red' });
+ state.registerMark({ y: 'value', data: data2, color: 'blue' });
+ flushSync();
+
+ // Both datasets should appear in flatData for correct domain calculation.
+ // data1 is on the implicit series; data2 has a different reference so it's extra.
+ expect(state.flatData.length).toBeGreaterThanOrEqual(data1.length + data2.length);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should calculate correct y domain from two marks with same y accessor but different data', () => {
+ const data1: TestData[] = [
+ { date: '2024-01', value: 30 },
+ { date: '2024-02', value: 40 },
+ ];
+ const data2: TestData[] = [
+ { date: '2024-01', value: 60 },
+ { date: '2024-02', value: 70 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ y: 'value', data: data1, color: 'red' });
+ state.registerMark({ y: 'value', data: data2, color: 'blue' });
+ flushSync();
+
+ // Domain must span both datasets: [30, 70]
+ expect(state._yDomain).toEqual([30, 70]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not double-include data when mark data matches series data reference', () => {
+ const markData: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ // Register the same data reference twice (should not double-count in flatData)
+ state.registerMark({ y: 'value', data: markData, color: 'red' });
+ state.registerMark({ y: 'value', data: markData, color: 'blue' });
+ flushSync();
+
+ // Only one series (deduplication by key), data1 is its data.
+ // The second mark shares the same reference, so flatData only includes markData once
+ // (via the series). Total items = markData.length.
+ expect(state.flatData).toHaveLength(markData.length);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should revert flatData after all marks unregister', () => {
+ const chartData: TestData[] = [{ date: '2024-01', value: 5 }];
+ const markData: TestData[] = [
+ { date: '2024-01', value: 100 },
+ { date: '2024-02', value: 200 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data: chartData,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ const unregister = state.registerMark({ data: markData });
+ flushSync();
+
+ expect(state.flatData).toHaveLength(3); // 1 chart + 2 mark
+ unregister();
+ flushSync();
+
+ expect(state.flatData).toHaveLength(1); // back to chart data only
+ expect(state.flatData).toEqual(chartData);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include implicit series label when provided', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ });
+
+ try {
+ state.registerMark({ y: 'value', color: 'red', label: 'Temperature' });
+ flushSync();
+
+ expect(state.seriesState.series[0].label).toBe('Temperature');
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState data vs visibleSeriesData', () => {
+ it('should return props.data when explicit, even if a mark registers a filtered subset', () => {
+ const fullData: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ { date: '2024-03', value: 30 },
+ ];
+ const highlighted = [fullData[0]]; // filtered subset
+
+ const { state, cleanup } = createChartState({
+ data: fullData,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ // Simulate a decorative mark (e.g. ) registering
+ // its own filtered dataset with the same value accessor as the chart.
+ state.registerMark({ y: 'value', data: highlighted });
+ flushSync();
+
+ // ctx.data (used by sibling marks for iteration) should remain the full
+ // chart data, not be replaced by the filtered subset.
+ expect(state.data).toBe(fullData);
+ expect(state.data).toHaveLength(3);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should fall back to visibleSeriesData when props.data is not provided', () => {
+ const applesData: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+ const bananasData: TestData[] = [
+ { date: '2024-01', value: 15 },
+ { date: '2024-02', value: 25 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ x: 'date',
+ y: 'value',
+ series: [
+ { key: 'apples', data: applesData },
+ { key: 'bananas', data: bananasData },
+ ],
+ });
+
+ try {
+ // No props.data — ctx.data should flatten series data for iteration.
+ expect(state.data).toHaveLength(4);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not create an implicit series for a decorative mark when chart has own data', () => {
+ // Scenario: + (labels)
+ // The Text mark shouldn't create an implicit series that narrows the domain.
+ const fullData: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 50 },
+ { date: '2024-03', value: 100 },
+ ];
+ const highlighted = [fullData[1]];
+
+ const { state, cleanup } = createChartState({
+ data: fullData,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ y: 'value', data: highlighted });
+ flushSync();
+
+ // Decorative mark shouldn't turn this into a multi-series chart.
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not create an implicit series when chart uses array y accessor matching the mark key', () => {
+ // Scenario: +
+ // The chart declares both v1 and v2 as value axes; the Text mark using v2
+ // is just decorative, not a new series.
+ type Dual = { date: string; v1: number; v2: number };
+ const fullData: Dual[] = [
+ { date: '2024-01', v1: 10, v2: 20 },
+ { date: '2024-02', v1: 30, v2: 40 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data: fullData,
+ x: 'date',
+ y: ['v1', 'v2'],
+ });
+
+ try {
+ state.registerMark({ y: 'v2', data: [fullData[0]] });
+ flushSync();
+
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should compute yDomain from full chart data when a decorative mark has a filtered subset', () => {
+ // Regression: Text labeling highlighted rows shouldn't narrow the y domain.
+ const fullData: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 50 },
+ { date: '2024-03', value: 100 },
+ ];
+ const highlighted = [fullData[1]]; // only value=50
+
+ const { state, cleanup } = createChartState({
+ data: fullData,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ y: 'value', data: highlighted });
+ flushSync();
+
+ // yDomain should reflect the full data extent [10, 100], not just [50, 50].
+ expect(state.yDomain).toEqual([10, 100]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should compute yDomain across all array y accessors on Chart', () => {
+ // Scenario: arrow-variation chart with y={['v1', 'v2']} where v1/v2 span different ranges.
+ type Dual = { date: string; v1: number; v2: number };
+ const data: Dual[] = [
+ { date: '2024-01', v1: 3, v2: 5 },
+ { date: '2024-02', v1: 2, v2: 8 },
+ { date: '2024-03', v1: 4, v2: 9 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: ['v1', 'v2'],
+ });
+
+ try {
+ // Domain should span min(v1) = 2 to max(v2) = 9
+ expect(state.yDomain).toEqual([2, 9]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should keep full yDomain when decorative mark + array y both present', () => {
+ // End-to-end bended-arrows scenario
+ type Dual = { date: string; v1: number; v2: number };
+ const data: Dual[] = [
+ { date: '2024-01', v1: 3, v2: 5 },
+ { date: '2024-02', v1: 2, v2: 8 },
+ { date: '2024-03', v1: 4, v2: 9 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: ['v1', 'v2'],
+ });
+
+ try {
+ // Decorative Text mark with subset data and y matching one of chart's keys
+ state.registerMark({ y: 'v2', data: [data[0]] });
+ flushSync();
+
+ // Still the full range [2, 9], not narrowed to [5, 5]
+ expect(state.yDomain).toEqual([2, 9]);
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should fall back to visibleSeriesData when props.data is an empty array', () => {
+ // Composite charts (BarChart, etc.) default `data = []` when not passed.
+ const applesData: TestData[] = [{ date: '2024-01', value: 10 }];
+ const bananasData: TestData[] = [{ date: '2024-01', value: 15 }];
+
+ const { state, cleanup } = createChartState({
+ data: [],
+ x: 'date',
+ y: 'value',
+ series: [
+ { key: 'apples', data: applesData },
+ { key: 'bananas', data: bananasData },
+ ],
+ });
+
+ try {
+ expect(state.data).toHaveLength(2);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState geo projection skips markInfo', () => {
+ const geoData: GeoData[] = [
+ { name: 'New York', longitude: -74.006, latitude: 40.7128 },
+ { name: 'Los Angeles', longitude: -118.2437, latitude: 34.0522 },
+ { name: 'Chicago', longitude: -87.6298, latitude: 41.8781 },
+ ];
+
+ it('should not create implicit series from marks when geo projection is active', () => {
+ const { state, cleanup } = createChartState({
+ data: geoData,
+ x: 'longitude',
+ y: 'latitude',
+ geo: { projection: geoAlbersUsa },
+ });
+
+ try {
+ // Register a mark with its own data (like a tooltip highlight Circle)
+ state.registerMark({ data: [geoData[0]], x: 'longitude', y: 'latitude' });
+ flushSync();
+
+ // Should remain default series — mark should not create implicit "latitude" series
+ expect(state.seriesState.isDefaultSeries).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not add mark data to flatData when geo projection is active', () => {
+ const { state, cleanup } = createChartState({
+ data: geoData,
+ x: 'longitude',
+ y: 'latitude',
+ geo: { projection: geoAlbersUsa },
+ });
+
+ try {
+ state.registerMark({ data: [geoData[0]], x: 'longitude', y: 'latitude' });
+ flushSync();
+
+ // flatData should only contain chart data, not the mark's extra data
+ expect(state.flatData).toHaveLength(3);
+ expect(state.flatData).toBe(geoData);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not derive x/y accessors from marks when geo projection is active', () => {
+ // Chart with geo but no explicit x/y — marks should not fill in the accessors
+ const { state: stateWithGeo, cleanup: cleanupGeo } = createChartState({
+ data: geoData,
+ geo: { projection: geoAlbersUsa },
+ });
+
+ const { state: stateWithoutGeo, cleanup: cleanupNoGeo } = createChartState({
+ data: geoData,
+ });
+
+ try {
+ // Both start with null x accessor (no x prop set)
+ expect(stateWithGeo.x).toBeNull();
+ expect(stateWithoutGeo.x).toBeNull();
+
+ stateWithGeo.registerMark({ x: 'longitude', y: 'latitude' });
+ stateWithoutGeo.registerMark({ x: 'longitude', y: 'latitude' });
+ flushSync();
+
+ // Without geo: mark should derive x accessor
+ expect(stateWithoutGeo.x).not.toBeNull();
+ expect(stateWithoutGeo.x!(geoData[0])).toBe(geoData[0].longitude);
+
+ // With geo: mark should NOT derive x accessor
+ expect(stateWithGeo.x).toBeNull();
+ } finally {
+ cleanupGeo();
+ cleanupNoGeo();
+ }
+ });
+
+ it('should preserve seriesKey/color/label from marks in geo mode for legends', () => {
+ const { state, cleanup } = createChartState({
+ data: geoData,
+ x: 'longitude',
+ y: 'latitude',
+ geo: { projection: geoAlbersUsa },
+ });
+
+ try {
+ state.registerMark({ seriesKey: 'earthquakes', color: 'red', label: 'Earthquakes' });
+ state.registerMark({ seriesKey: 'volcanos', color: 'orange', label: 'Volcanos' });
+ flushSync();
+
+ // seriesKey/color/label should still create implicit series for legends
+ expect(state.seriesState.isDefaultSeries).toBe(false);
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state.seriesState.series[0]).toMatchObject({
+ key: 'earthquakes',
+ color: 'red',
+ label: 'Earthquakes',
+ });
+ expect(state.seriesState.series[1]).toMatchObject({
+ key: 'volcanos',
+ color: 'orange',
+ label: 'Volcanos',
+ });
+
+ // But flatData should not include extra mark data
+ expect(state.flatData).toHaveLength(3);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should still process marks normally without geo projection', () => {
+ const { state, cleanup } = createChartState({
+ data: geoData,
+ x: 'name',
+ });
+
+ try {
+ state.registerMark({ y: 'latitude', color: 'blue' });
+ flushSync();
+
+ // Without geo, marks should create implicit series as normal
+ expect(state.seriesState.isDefaultSeries).toBe(false);
+ expect(state.seriesState.series[0].key).toBe('latitude');
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState implicit series domain update on visibility toggle', () => {
+ it('should update y domain when hiding an implicit series', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 50 },
+ { date: '2024-02', apples: 20, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ // No y prop — will be derived from marks
+ });
+
+ try {
+ state.registerMark({ y: 'apples', color: 'red' });
+ state.registerMark({ y: 'bananas', color: 'yellow' });
+ flushSync();
+
+ // Both visible: domain should span all values
+ expect(state.seriesState.series).toHaveLength(2);
+ expect(state._yDomain).toEqual([10, 80]);
+
+ // Toggle "apples" — when selection is empty, toggling adds it,
+ // making only "apples" visible (bananas hidden)
+ state.seriesState.selectedKeys.toggle('apples');
+ flushSync();
+
+ // With only apples visible, domain should be [10, 20]
+ expect(state.seriesState.visibleSeries).toHaveLength(1);
+ expect(state.seriesState.visibleSeries[0].key).toBe('apples');
+ expect(state._yDomain).toEqual([10, 20]);
+ expect(state._baseYDomain).toEqual([10, 20]);
+ expect(state.yDomain).toEqual([10, 20]);
+ // Verify scale domain updated too
+ expect(state.yScale.domain()).toEqual([10, 20]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should update y domain when hiding an explicit series', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 10, bananas: 50 },
+ { date: '2024-02', apples: 20, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ expect(state._yDomain).toEqual([10, 80]);
+
+ // Select only apples (hides bananas)
+ state.seriesState.selectedKeys.toggle('apples');
+ flushSync();
+
+ expect(state.seriesState.visibleSeries).toHaveLength(1);
+ expect(state.seriesState.visibleSeries[0].key).toBe('apples');
+ expect(state._yDomain).toEqual([10, 20]);
+ expect(state._baseYDomain).toEqual([10, 20]);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState degenerate domain', () => {
+ it('should expand degenerate y domain [0, 0] to [0, 1]', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 0 },
+ { date: '2024-02', value: 0 },
+ { date: '2024-03', value: 0 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ yBaseline: 0,
+ });
+
+ try {
+ // Domain from data+baseline is [0,0] — scale should expand to [0,1]
+ expect(state._yDomain).toEqual([0, 0]);
+ expect(state.yScale.domain()).toEqual([0, 1]);
+ // yScale(0) should be a valid number (not NaN)
+ expect(state.yScale(0)).not.toBeNaN();
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should expand degenerate y domain [5, 5] to [5, 6]', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 5 },
+ { date: '2024-02', value: 5 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ // _yDomain is undefined (no baseline/explicit domain), domain comes from extents
+ expect(state.yDomain).toEqual([5, 5]);
+ expect(state.yScale.domain()).toEqual([5, 6]);
+ expect(state.yScale(5)).not.toBeNaN();
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not expand a non-degenerate domain', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ expect(state.yScale.domain()).toEqual([10, 20]);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState default padding', () => {
+ it('should apply default padding when using ChartChildren layout (no children snippet)', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ // No children prop => ChartChildren renders with axis=true by default
+ });
+
+ try {
+ expect(state.padding.left).toBeGreaterThan(0);
+ expect(state.padding.bottom).toBeGreaterThan(0);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not apply default padding when children snippet is provided', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ children: (() => {}) as any, // Simulates user providing children snippet (Treemap, Pack, etc.)
+ });
+
+ try {
+ expect(state.padding.left).toBe(0);
+ expect(state.padding.bottom).toBe(0);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not apply default padding when axis is explicitly false', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ axis: false,
+ });
+
+ try {
+ expect(state.padding.left).toBe(0);
+ expect(state.padding.bottom).toBe(0);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should use explicit padding over default', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ padding: { top: 10, right: 10, bottom: 10, left: 10 },
+ });
+
+ try {
+ expect(state.padding).toEqual({ top: 10, right: 10, bottom: 10, left: 10 });
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState implicit x/y from marks (no x/y on Chart)', () => {
+ type DateValueData = { date: Date; value: number };
+
+ it('should derive x accessor from marks when x prop is absent', () => {
+ const data: DateValueData[] = [
+ { date: new Date(2024, 0, 1), value: 30 },
+ { date: new Date(2024, 1, 1), value: 40 },
+ ];
+
+ const { state, cleanup } = createChartState({});
+
+ try {
+ state.registerMark({ x: 'date', y: 'value', data });
+ flushSync();
+
+ expect(state.x(data[0])).toEqual(new Date(2024, 0, 1));
+ // y is derived from implicit series — returns array form (single-element for one series)
+ expect(state.y(data[0])).toEqual([30]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should derive correct y domain across two marks with different data and no y prop', () => {
+ const temperatureData: DateValueData[] = [
+ { date: new Date(2024, 0, 1), value: 32 },
+ { date: new Date(2024, 1, 1), value: 28 },
+ ];
+ const humidityData: DateValueData[] = [
+ { date: new Date(2024, 0, 1), value: 60 },
+ { date: new Date(2024, 1, 1), value: 70 },
+ ];
+
+ const { state, cleanup } = createChartState({});
+
+ try {
+ state.registerMark({ x: 'date', y: 'value', data: temperatureData, color: 'red' });
+ state.registerMark({ x: 'date', y: 'value', data: humidityData, color: 'blue' });
+ flushSync();
+
+ // y domain should span both datasets
+ expect(state._yDomain).toEqual([28, 70]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should deduplicate repeated mark x keys into a single accessor', () => {
+ const data: DateValueData[] = [{ date: new Date(2024, 0, 1), value: 10 }];
+
+ const { state, cleanup } = createChartState({});
+
+ try {
+ // Two marks, same x='date' — should not create duplicate keys
+ state.registerMark({ x: 'date', y: 'value', data });
+ state.registerMark({ x: 'date', y: 'value', data });
+ flushSync();
+
+ // x accessor should work normally (not return array of duplicates)
+ expect(state.x(data[0])).toEqual(new Date(2024, 0, 1));
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should use explicit x/y from Chart props over mark-derived values', () => {
+ const data: DateValueData[] = [{ date: new Date(2024, 0, 1), value: 10 }];
+
+ const { state, cleanup } = createChartState({
+ x: 'value', // explicit — should override 'date' from marks
+ y: 'value',
+ });
+
+ try {
+ state.registerMark({ x: 'date', y: 'value', data });
+ flushSync();
+
+ // Chart props take precedence
+ expect(state.x(data[0])).toEqual(10);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState bandPadding auto-derives category axis scale', () => {
+ const wideData: WideData[] = [
+ { year: '2016', apples: 480, bananas: 240, cherries: 120, grapes: 50 },
+ { year: '2017', apples: 960, bananas: 480, cherries: 240, grapes: 100 },
+ { year: '2018', apples: 1920, bananas: 960, cherries: 480, grapes: 200 },
+ { year: '2019', apples: 3840, bananas: 1920, cherries: 960, grapes: 400 },
+ ];
+
+ it('should use scaleBand on x when bandPadding set and valueAxis=y', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ y: 'apples',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ });
+
+ try {
+ expect(isScaleBand(state.xScale)).toBe(true);
+ expect(state.xScale.bandwidth!()).toBeGreaterThan(0);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should use scaleBand on y when bandPadding set and valueAxis=x', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ y: 'year',
+ x: 'apples',
+ valueAxis: 'x',
+ bandPadding: 0.4,
+ });
+
+ try {
+ expect(isScaleBand(state.yScale)).toBe(true);
+ expect(state.yScale.bandwidth!()).toBeGreaterThan(0);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not use scaleBand when bandPadding is not set', () => {
+ const data: TestData[] = [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ valueAxis: 'y',
+ });
+
+ try {
+ // Without bandPadding, autoScale determines the scale from data type
+ // String data should still get scaleBand via autoScale, but without custom padding
+ expect(isScaleBand(state.xScale)).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState xInterval forces scaleTime over scaleBand', () => {
+ it('should use scaleTime when xInterval is set even with bandPadding', () => {
+ type DateData = { date: Date; value: number };
+ const data: DateData[] = [
+ { date: new Date(2024, 0, 1), value: 40 },
+ { date: new Date(2024, 0, 5), value: 60 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ y: 'value',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ xInterval: timeDay,
+ });
+
+ try {
+ expect(isScaleBand(state.xScale)).toBe(false);
+ expect(isScaleTime(state.xScale)).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should use scaleBand when xInterval is not set with bandPadding', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ year: '2016', apples: 480, bananas: 240, cherries: 120, grapes: 50 }],
+ x: 'year',
+ y: 'apples',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ });
+
+ try {
+ expect(isScaleBand(state.xScale)).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState explicit baseline=null disables auto-baseline', () => {
+ it('should not include baseline=0 in domain when xBaseline=null', () => {
+ type RangeData = { label: string; start: number; end: number };
+ const data: RangeData[] = [
+ { label: 'A', start: 15, end: 25 },
+ { label: 'B', start: 25, end: 35 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: ['start', 'end'] as any,
+ y: (d: any) => 1,
+ valueAxis: 'x',
+ bandPadding: 0,
+ xBaseline: null,
+ xNice: false,
+ });
+
+ try {
+ // Domain should be [15, 35], not [0, 35]
+ const domain = state.xScale.domain();
+ expect(domain[0]).toBe(15);
+ expect(domain[1]).toBe(35);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should include auto-baseline=0 when xBaseline is not provided', () => {
+ type RangeData = { label: string; start: number; end: number };
+ const data: RangeData[] = [
+ { label: 'A', start: 15, end: 25 },
+ { label: 'B', start: 25, end: 35 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: ['start', 'end'] as any,
+ y: (d: any) => 1,
+ valueAxis: 'x',
+ bandPadding: 0,
+ xNice: false,
+ // xBaseline not provided — auto-baseline should kick in
+ });
+
+ try {
+ // Domain should be [0, 35] due to auto-baseline
+ const domain = state.xScale.domain();
+ expect(domain[0]).toBe(0);
+ expect(domain[1]).toBe(35);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState yReverse with band scales', () => {
+ it('should not reverse y when auto-derived scaleBand (horizontal bar chart)', () => {
+ type AgeData = { age: string; male: number; female: number };
+ const data: AgeData[] = [
+ { age: '0-4', male: 200, female: 190 },
+ { age: '5-9', male: 180, female: 175 },
+ { age: '85+', male: 20, female: 15 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ y: 'age',
+ valueAxis: 'x',
+ bandPadding: 0.4,
+ series: [{ key: 'male' }, { key: 'female' }],
+ });
+
+ try {
+ expect(isScaleBand(state.yScale)).toBe(true);
+ expect(state.yReverse).toBe(false);
+ // Domain should preserve data order (0-4 first)
+ expect(state.yScale.domain()).toEqual(['0-4', '5-9', '85+']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not reverse y when explicit scaleBand is provided', () => {
+ const { state, cleanup } = createChartState({
+ data: [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ],
+ y: 'date',
+ yScale: scaleBand().padding(0.4),
+ });
+
+ try {
+ expect(state.yReverse).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should reverse y for linear scales (default)', () => {
+ const { state, cleanup } = createChartState({
+ data: [
+ { date: '2024-01', value: 10 },
+ { date: '2024-02', value: 20 },
+ ],
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ expect(state.yReverse).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState auto-baseline from bandPadding', () => {
+ it('should auto-derive yBaseline=0 when bandPadding set and valueAxis=y', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // With bandPadding, auto-baseline should include 0
+ expect(state._yDomain).toEqual([0, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should auto-derive xBaseline=0 when bandPadding set and valueAxis=x', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ y: 'date',
+ valueAxis: 'x',
+ bandPadding: 0.4,
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ expect(state._xDomain).toEqual([0, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not auto-derive baseline without bandPadding', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ valueAxis: 'y',
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // Without bandPadding, no auto-baseline — domain is just extent
+ expect(state._yDomain).toEqual([50, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should respect explicit baseline over auto-baseline', () => {
+ const data: MultiSeriesData[] = [
+ { date: '2024-01', apples: 50, bananas: 60 },
+ { date: '2024-02', apples: 70, bananas: 80 },
+ ];
+
+ const { state, cleanup } = createChartState({
+ data,
+ x: 'date',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ yBaseline: 10,
+ series: [{ key: 'apples' }, { key: 'bananas' }],
+ });
+
+ try {
+ // Explicit yBaseline=10 should take precedence over auto-baseline=0
+ expect(state._yDomain).toEqual([10, 80]);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState auto-nice from valueAxis', () => {
+ it('should auto-nice the value axis when valueAxis is set', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ valueAxis: 'y',
+ });
+
+ try {
+ expect(state.yNice).toBe(true);
+ expect(state.xNice).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should auto-nice xNice when valueAxis=x', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'value',
+ y: 'date',
+ valueAxis: 'x',
+ });
+
+ try {
+ expect(state.xNice).toBe(true);
+ expect(state.yNice).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not auto-nice when valueAxis is not set', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ });
+
+ try {
+ expect(state.xNice).toBe(false);
+ expect(state.yNice).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should respect explicit xNice/yNice over auto-derived', () => {
+ const { state, cleanup } = createChartState({
+ data: [{ date: '2024-01', value: 10 }],
+ x: 'date',
+ y: 'value',
+ valueAxis: 'y',
+ yNice: false,
+ xNice: true,
+ });
+
+ try {
+ expect(state.yNice).toBe(false);
+ expect(state.xNice).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState group layout auto-derives x1/y1', () => {
+ const wideData: WideData[] = [
+ { year: '2016', apples: 480, bananas: 240, cherries: 120, grapes: 50 },
+ { year: '2017', apples: 960, bananas: 480, cherries: 240, grapes: 100 },
+ ];
+
+ const series = [{ key: 'apples' }, { key: 'bananas' }, { key: 'cherries' }, { key: 'grapes' }];
+
+ it('should auto-derive x1Domain from series keys when seriesLayout=group and valueAxis=y', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ expect(state.x1Domain).toEqual(['apples', 'bananas', 'cherries', 'grapes']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should auto-derive y1Domain from series keys when seriesLayout=group and valueAxis=x', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ y: 'year',
+ valueAxis: 'x',
+ bandPadding: 0.4,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ expect(state.y1Domain).toEqual(['apples', 'bananas', 'cherries', 'grapes']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should auto-create x1Scale as scaleBand for group layout', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ expect(state.x1Scale).not.toBeNull();
+ expect(isScaleBand(state.x1Scale!)).toBe(true);
+ expect(state.x1Scale!.domain()).toEqual(['apples', 'bananas', 'cherries', 'grapes']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should not auto-derive x1 when seriesLayout is not group', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ seriesLayout: 'stack',
+ series,
+ });
+
+ try {
+ expect(state.x1Domain).toBeUndefined();
+ expect(state.x1Scale).toBeNull();
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should apply groupPadding to auto-derived x1Scale', () => {
+ const { state: stateNoPad, cleanup: c1 } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ groupPadding: 0,
+ seriesLayout: 'group',
+ series,
+ });
+
+ const { state: stateWithPad, cleanup: c2 } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ groupPadding: 0.5,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ // With more padding, bandwidth should be smaller
+ expect(stateWithPad.x1Scale!.bandwidth!()).toBeLessThan(stateNoPad.x1Scale!.bandwidth!());
+ } finally {
+ c1();
+ c2();
+ }
+ });
+
+ it('should update x1Domain to only visible series when toggling legend', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ expect(state.x1Domain).toEqual(['apples', 'bananas', 'cherries', 'grapes']);
+
+ // Select only 'apples' (hides the other 3)
+ state.seriesState.selectedKeys.toggle('apples');
+ flushSync();
+
+ expect(state.seriesState.visibleSeries).toHaveLength(1);
+ expect(state.x1Domain).toEqual(['apples']);
+
+ // x1Scale domain should also update
+ expect(state.x1Scale!.domain()).toEqual(['apples']);
+
+ // Deselect to show all again
+ state.seriesState.selectedKeys.toggle('apples');
+ flushSync();
+
+ expect(state.x1Domain).toEqual(['apples', 'bananas', 'cherries', 'grapes']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should update x1Scale bandwidth when series visibility changes', () => {
+ const { state, cleanup } = createChartState({
+ data: wideData,
+ x: 'year',
+ valueAxis: 'y',
+ bandPadding: 0.4,
+ groupPadding: 0,
+ seriesLayout: 'group',
+ series,
+ });
+
+ try {
+ const initialBandwidth = state.x1Scale!.bandwidth!();
+
+ // Select only 'apples'
+ state.seriesState.selectedKeys.toggle('apples');
+ flushSync();
+
+ // With only 1 series, bandwidth should be larger (full group band)
+ expect(state.x1Scale!.bandwidth!()).toBeGreaterThan(initialBandwidth);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('ChartState x1Domain/y1Domain without series', () => {
+ type LongData = { year: number; fruit: string; value: number };
+ const longData: LongData[] = [
+ { year: 2019, fruit: 'apples', value: 3840 },
+ { year: 2019, fruit: 'bananas', value: 1920 },
+ { year: 2018, fruit: 'apples', value: 1600 },
+ { year: 2018, fruit: 'bananas', value: 1440 },
+ ];
+
+ it('should pass through explicit x1Domain when no series are configured', () => {
+ const { state, cleanup } = createChartState({
+ data: longData,
+ x: 'year',
+ xScale: scaleBand(),
+ y: 'value',
+ x1: 'fruit',
+ x1Domain: ['apples', 'bananas'],
+ x1Range: ({ xScale }) => [0, (xScale as any).bandwidth()],
+ });
+
+ try {
+ expect(state.seriesState.series).toHaveLength(0);
+ expect(state.x1Domain).toEqual(['apples', 'bananas']);
+ expect(state.x1Scale!.domain()).toEqual(['apples', 'bananas']);
+ } finally {
+ cleanup();
+ }
+ });
+
+ it('should pass through explicit y1Domain when no series are configured', () => {
+ const { state, cleanup } = createChartState({
+ data: longData,
+ y: 'year',
+ yScale: scaleBand(),
+ x: 'value',
+ y1: 'fruit',
+ y1Domain: ['apples', 'bananas'],
+ y1Range: ({ yScale }) => [0, (yScale as any).bandwidth()],
+ });
+
+ try {
+ expect(state.seriesState.series).toHaveLength(0);
+ expect(state.y1Domain).toEqual(['apples', 'bananas']);
+ expect(state.y1Scale!.domain()).toEqual(['apples', 'bananas']);
+ } finally {
+ cleanup();
+ }
+ });
+});
diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts
new file mode 100644
index 000000000..cdc6339d3
--- /dev/null
+++ b/packages/layerchart/src/lib/states/chart.svelte.ts
@@ -0,0 +1,1353 @@
+import { untrack } from 'svelte';
+import { scaleBand, scaleOrdinal, scaleSqrt, scaleTime } from 'd3-scale';
+import { extent, max, min } from 'd3-array';
+import { unique } from '@layerstack/utils';
+import { Context, useDebounce } from 'runed';
+import type { ComponentRender } from '$lib/contexts/canvas.js';
+import { getCanvasContext } from '$lib/contexts/canvas.js';
+
+import type { AnyScale, DomainType } from '$lib/utils/scales.svelte.js';
+import {
+ autoScale,
+ createScale,
+ getRange,
+ isScaleBand,
+ isScaleTime,
+ makeAccessor,
+} from '$lib/utils/scales.svelte.js';
+import type { ChartPropsWithoutHTML } from '$lib/components/Chart.svelte';
+import type { Extents } from '$lib/utils/types.js';
+import { accessor, chartDataArray, defaultChartPadding, type Accessor } from '$lib/utils/common.js';
+import { filterObject } from '$lib/utils/filterObject.js';
+import { calcDomain, calcScaleExtents, createGetter, createChartScale } from '$lib/utils/chart.js';
+import { printDebug } from '$lib/utils/debug.js';
+
+import { GeoState } from './geo.svelte.js';
+import type { TransformState } from './transform.svelte.js';
+import type { TooltipState } from './tooltip.svelte.js';
+import type { BrushDomainType, BrushState } from './brush.svelte.js';
+import { SeriesState, type StackLayout } from './series.svelte.js';
+import type { SeriesData } from '$lib/components/charts/types.js';
+import { createControlledMotion, parseMotionProp } from '$lib/utils/motion.svelte.js';
+
+const defaultPadding = { top: 0, right: 0, bottom: 0, left: 0 };
+
+/** Stable empty array to avoid creating new [] references on each reactive update */
+const EMPTY_SERIES: any[] = [];
+
+interface ScaleEntry {
+ scale: AnyScale;
+ sort?: boolean;
+}
+
+/** Information a mark registers with the chart for domain/series calculation */
+export interface MarkInfo {
+ /** The mark's own data array (if overriding chart data) */
+ data?: any[];
+ /** x accessor override */
+ x?: Accessor;
+ /** y accessor override */
+ y?: Accessor;
+ /** Series key for this mark */
+ seriesKey?: string;
+ /** Color for legend/tooltip */
+ color?: string;
+ /** Label for legend/tooltip */
+ label?: string;
+}
+
+export type NodeKind = 'group' | 'mark' | 'composite-mark';
+
+export interface ComponentNode {
+ id: symbol;
+ kind: NodeKind;
+ name: string;
+ parent: ComponentNode | null;
+ children: ComponentNode[];
+ /** Canvas render info — only present for components that render on canvas */
+ canvasRender?: ComponentRender;
+ /** Whether this node has a composite-mark ancestor (computed on creation) */
+ insideCompositeMark: boolean;
+}
+
+export interface RegisterComponentOptions {
+ /** Display name for the node (used for debugging) */
+ name: string;
+ /** The type of node */
+ kind: NodeKind;
+ /** Canvas render info. When provided, sets up dependency tracking and cleanup automatically. */
+ canvasRender?: ComponentRender;
+ /**
+ * Mark info getter for chart domain/series calculation.
+ * When provided and not inside a composite mark, automatically registered reactively.
+ */
+ markInfo?: () => MarkInfo;
+}
+
+/** Svelte context key for tracking the nearest parent ComponentNode. */
+const _ParentNodeContext = new Context('ComponentTreeParent');
+
+export class ChartState<
+ TData = any,
+ XScale extends AnyScale = AnyScale,
+ YScale extends AnyScale = AnyScale,
+> {
+ // Props getter function - set in constructor
+ private _propsGetter!: () => ChartPropsWithoutHTML;
+
+ // Props - accessed via getter function for fine-grained reactivity
+ props = $derived(this._propsGetter());
+
+ // State / contexts
+ geoState: GeoState;
+ transformState = $state(null!);
+ tooltipState = $state(null!);
+ brushState = $state(null!);
+ // TODO: handle TComponent
+ seriesState: SeriesState;
+
+ // Container dimensions
+ _containerWidth = $state(100);
+ _containerHeight = $state(100);
+
+ // Mount state
+ isMounted = $state(false);
+
+ // Mark registration — marks register stable MarkInfo snapshots on mount for
+ // domain/series calculation. Snapshots are updated via $effect (not $derived)
+ // in registerComponent, so reads here never create circular derived refs.
+ // Composite marks set insideCompositeMark context so child marks skip registration.
+ //
+ // Use a plain array + reactive version counter instead of $state so that
+ // registerMark() never reads $state during execution. If it did (e.g. spreading
+ // a $state array), calling registerMark() from a $effect would subscribe that
+ // effect to _markInfos changes, then the effect's own write would trigger it to
+ // re-run → infinite loop.
+ private _markInfosRaw: Array<{ _id: number; info: MarkInfo }> = [];
+ private _markInfosVersion = $state(0);
+ private _nextMarkId = 0;
+
+ /** Reactive accessor — reads _markInfosVersion to create a reactive dependency,
+ * returns the plain array so items are never wrapped in Svelte proxies.
+ *
+ * When a geo projection is active, strips x/y/data from mark info — those
+ * values are geographic coordinates handled by the projection, not xScale/yScale.
+ * seriesKey/color/label are preserved so marks can still contribute to legends. */
+ private get _markInfos() {
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
+ this._markInfosVersion;
+ if (this.geoState.props.projection) {
+ return this._markInfosRaw.map(({ _id, info }) => ({
+ _id,
+ info: {
+ seriesKey: info.seriesKey,
+ color: info.color,
+ label: info.label,
+ } as MarkInfo,
+ }));
+ }
+ return this._markInfosRaw;
+ }
+
+ /**
+ * Register a mark with the chart. The MarkInfo snapshot is stored directly
+ * (not inside $derived) so chart deriveds can read _markInfos without creating
+ * circular references. Returns a cleanup function to call on unmount.
+ *
+ * For use in tests or synchronous contexts. In components, use `registerComponent` with `markInfo`.
+ */
+ registerMark(info: MarkInfo): () => void {
+ const id = ++this._nextMarkId;
+ this._markInfosRaw.push({ _id: id, info });
+ this._markInfosVersion++;
+ return () => {
+ const idx = this._markInfosRaw.findIndex((r) => r._id === id);
+ if (idx !== -1) this._markInfosRaw.splice(idx, 1);
+ this._markInfosVersion++;
+ };
+ }
+
+ /**
+ * Register a component tree node. Call at the top level of a component's
+ *
+ * ...
+ * downloadSvg(chartRef, { filename: 'my-chart' })}>
+ * Download SVG
+ *
+ * ```
+ */
+export function downloadSvg(container: HTMLElement, options: ChartSvgOptions = {}): boolean {
+ const { filename = 'chart' } = options;
+ const svgStr = getChartSvgString(container);
+ if (svgStr === null) return false;
+
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${filename}.svg`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ return true;
+}
+
+/**
+ * Download a chart container as an image file.
+ *
+ * @example
+ * ```svelte
+ *
+ *
+ * ...
+ * downloadImage(chartRef, { filename: 'my-chart' })}>
+ * Download PNG
+ *
+ * ```
+ */
+export async function downloadImage(
+ container: HTMLElement,
+ options: ChartImageOptions & {
+ /**
+ * File name without extension.
+ *
+ * @default 'chart'
+ */
+ filename?: string;
+ } = {}
+): Promise {
+ const { filename = 'chart', format = 'png', ...imageOptions } = options;
+ const blob = await getChartImageBlob(container, { format, ...imageOptions });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${filename}.${format}`;
+ a.click();
+ // Revoke after a short delay to ensure the browser has started the download
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+}
diff --git a/packages/layerchart/src/lib/utils/filterObject.ts b/packages/layerchart/src/lib/utils/filterObject.ts
new file mode 100644
index 000000000..f71f1ffd0
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/filterObject.ts
@@ -0,0 +1,14 @@
+/**
+ * Remove undefined fields from an object
+ * @param obj The object to filter
+ * @param comparisonObk An object that, for any key, if the key is not present on that object, the
+ * key will be filtered out. Note, this ignores the value on that object
+ */
+export function filterObject(obj: object, comparisonObj = {}) {
+ return Object.fromEntries(
+ Object.entries(obj).filter(([key, value]) => {
+ // @ts-expect-error - shh
+ return value !== undefined && comparisonObj[key] === undefined;
+ })
+ );
+}
diff --git a/packages/layerchart/src/lib/utils/genData.ts b/packages/layerchart/src/lib/utils/genData.ts
index d489f4848..87f0177a2 100644
--- a/packages/layerchart/src/lib/utils/genData.ts
+++ b/packages/layerchart/src/lib/utils/genData.ts
@@ -1,4 +1,4 @@
-import { addMinutes, startOfDay, startOfToday, subDays } from 'date-fns';
+import { timeMinute, timeDay } from 'd3-time';
import { cumsum } from 'd3-array';
import { randomNormal } from 'd3-random';
@@ -58,28 +58,31 @@ export function createSeries(options: {
});
}
-export function createDateSeries(options: {
- count?: number;
- min: number;
- max: number;
- keys?: TKey[];
- value?: 'number' | 'integer';
-}) {
- const now = startOfToday();
+export function createDateSeries(
+ options: {
+ count?: number;
+ min?: number;
+ max?: number;
+ keys?: TKey[];
+ value?: 'number' | 'integer';
+ } = {}
+) {
+ const now = timeDay.floor(new Date());
const count = options.count ?? 10;
- const min = options.min;
- const max = options.max;
+ const min = options.min ?? 0;
+ const max = options.max ?? 100;
const keys = options.keys ?? ['value'];
+ const valueType = options.value ?? 'number';
return Array.from({ length: count }).map((_, i) => {
return {
- date: subDays(now, count - i - 1),
+ date: timeDay.offset(now, -count + i),
...Object.fromEntries(
keys.map((key) => {
return [
key,
- options.value === 'integer' ? getRandomInteger(min, max) : getRandomNumber(min, max),
+ valueType === 'integer' ? getRandomInteger(min, max) : getRandomNumber(min, max),
];
})
),
@@ -87,23 +90,26 @@ export function createDateSeries(options: {
});
}
-export function createTimeSeries(options: {
- count?: number;
- min: number;
- max: number;
- keys: TKey[];
- value: 'number' | 'integer';
-}) {
+export function createTimeSeries(
+ options: {
+ count?: number;
+ min?: number;
+ max?: number;
+ keys?: TKey[];
+ value?: 'number' | 'integer';
+ } = {}
+) {
const count = options.count ?? 10;
- const min = options.min;
- const max = options.max;
+ const min = options.min ?? 0;
+ const max = options.max ?? 100;
const keys = options.keys ?? ['value'];
+ const valueType = options.value ?? 'number';
- let lastStartDate = startOfDay(new Date());
+ let lastStartDate = timeDay.floor(new Date());
const timeSeries = Array.from({ length: count }).map((_, i) => {
- const startDate = addMinutes(lastStartDate, getRandomInteger(0, 60));
- const endDate = addMinutes(startDate, getRandomInteger(5, 60));
+ const startDate = timeMinute.offset(lastStartDate, getRandomInteger(0, 60));
+ const endDate = timeMinute.offset(startDate, getRandomInteger(5, 60));
lastStartDate = startDate;
return {
name: `item ${i + 1}`,
@@ -113,7 +119,7 @@ export function createTimeSeries(options: {
keys.map((key) => {
return [
key,
- options.value === 'integer' ? getRandomInteger(min, max) : getRandomNumber(min, max),
+ valueType === 'integer' ? getRandomInteger(min, max) : getRandomNumber(min, max),
];
})
),
@@ -190,3 +196,47 @@ export function getSpiral({
};
});
}
+
+interface SineWaveOptions {
+ numPoints: number;
+ frequency?: number;
+ amplitude?: number;
+ noiseLevel?: number;
+ phase?: number;
+ xMin?: number;
+ xMax?: number;
+}
+
+export function generateSineWave(options: SineWaveOptions) {
+ const {
+ numPoints,
+ frequency = 1,
+ amplitude = 1,
+ noiseLevel = 0,
+ phase = 0,
+ xMin = 0,
+ xMax = 2 * Math.PI,
+ } = options;
+
+ if (numPoints <= 0) {
+ throw new Error('Number of points must be greater than 0');
+ }
+
+ const points: { x: number; y: number }[] = [];
+ const xStep = (xMax - xMin) / (numPoints - 1);
+
+ for (let i = 0; i < numPoints; i++) {
+ const x = xMin + i * xStep;
+
+ // Generate base sine wave
+ const sineValue = amplitude * Math.sin(frequency * x + phase);
+
+ // Add random noise if specified
+ const noise = noiseLevel > 0 ? (Math.random() - 0.5) * 2 * noiseLevel : 0;
+ const y = sineValue + noise;
+
+ points.push({ x, y });
+ }
+
+ return points;
+}
diff --git a/packages/layerchart/src/lib/utils/graph.test.ts b/packages/layerchart/src/lib/utils/graph.test.ts
deleted file mode 100644
index f91db9b71..000000000
--- a/packages/layerchart/src/lib/utils/graph.test.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import dagre from '@dagrejs/dagre';
-import { ancestors, descendants } from './graph.js';
-
-const exampleGraph = {
- nodes: [
- { id: 'A' },
- { id: 'B' },
- { id: 'C' },
- { id: 'D' },
- { id: 'E' },
- { id: 'F' },
- { id: 'G' },
- { id: 'H' },
- { id: 'I' },
- ],
- edges: [
- { source: 'A', target: 'B' },
- { source: 'C', target: 'B' },
- { source: 'B', target: 'E' },
- { source: 'B', target: 'F' },
- { source: 'D', target: 'E' },
- { source: 'D', target: 'F' },
- { source: 'E', target: 'H' },
- { source: 'G', target: 'H' },
- { source: 'H', target: 'I' },
- ],
-};
-
-function buildGraph(data: typeof exampleGraph) {
- const g = new dagre.graphlib.Graph();
-
- g.setGraph({});
-
- data.nodes.forEach((n) => {
- g.setNode(n.id, {
- label: n.id,
- });
- });
-
- data.edges.forEach((e) => {
- g.setEdge(e.source, e.target);
- });
-
- return g;
-}
-
-describe('accessors', () => {
- it('start of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = ancestors(graph, 'A');
- expect(actual).length(0);
- });
-
- it('middle of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = ancestors(graph, 'E');
- expect(actual).to.have.members(['A', 'B', 'C', 'D']);
- });
-
- it('end of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = ancestors(graph, 'I');
- expect(actual).to.have.members(['A', 'B', 'C', 'D', 'E', 'G', 'H']);
- });
-
- it('max depth', () => {
- const graph = buildGraph(exampleGraph);
- const actual = ancestors(graph, 'H', 2);
- expect(actual).to.have.members(['B', 'D', 'E', 'G']);
- });
-});
-
-describe('descendants', () => {
- it('start of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = descendants(graph, 'A');
- expect(actual).to.have.members(['B', 'E', 'F', 'H', 'I']);
- });
-
- it('middle of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = descendants(graph, 'E');
- expect(actual).to.have.members(['H', 'I']);
- });
-
- it('end of graph ', () => {
- const graph = buildGraph(exampleGraph);
- const actual = descendants(graph, 'I');
- expect(actual).length(0);
- });
-
- it('max depth', () => {
- const graph = buildGraph(exampleGraph);
- const actual = descendants(graph, 'B', 2);
- expect(actual).to.have.members(['E', 'F', 'H']);
- });
-});
diff --git a/packages/layerchart/src/lib/utils/graph.ts b/packages/layerchart/src/lib/utils/graph.ts
deleted file mode 100644
index 0d42d3a6e..000000000
--- a/packages/layerchart/src/lib/utils/graph.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { csvParseRows } from 'd3-dsv';
-import type {
- SankeyExtraProperties,
- SankeyGraph,
- SankeyLink,
- SankeyNode,
- SankeyNodeMinimal,
-} from 'd3-sankey';
-import type { hierarchy as d3Hierarchy } from 'd3-hierarchy';
-import dagre from '@dagrejs/dagre';
-
-/**
- * Convert CSV rows in format: 'source,target,value' to SankeyGraph
- */
-export function graphFromCsv(csv: string): SankeyGraph {
- const links = csvParseRows(csv, ([source, target, value /*, linkColor = color*/]) =>
- source && target
- ? {
- source,
- target,
- // @ts-expect-error
- value: !value || isNaN((value = +value)) ? 1 : +value,
- // color: linkColor,
- }
- : null
- );
-
- return { nodes: nodesFromLinks(links), links };
-}
-
-/**
- * Convert d3-hierarchy to graph (nodes/links)
- */
-export function graphFromHierarchy(hierarchy: ReturnType) {
- return {
- nodes: hierarchy.descendants(),
- links: hierarchy.links().map((link) => ({ ...link, value: link.target.value })),
- };
-}
-
-/**
- * Create graph from node (and target node/links downward)
- */
-export function graphFromNode(node: SankeyNodeMinimal) {
- const nodes: SankeyNode[] = [node];
- const links: SankeyLink[] = [];
-
- node.sourceLinks?.forEach((link) => {
- nodes.push(link.target);
- links.push(link);
-
- if (link.target.sourceLinks.length) {
- const targetData = graphFromNode(link.target);
-
- // Only add new nodes
- targetData.nodes.forEach((node) => {
- if (!nodes.includes(node)) {
- nodes.push(node);
- }
- });
-
- targetData.links.forEach((link) => {
- if (!links.includes(link)) {
- links.push(link);
- }
- });
- }
- });
-
- return { nodes, links };
-}
-
-/**
- * Get distinct nodes from link.source and link.target
- */
-export function nodesFromLinks(
- links: Array>
-) {
- const nodesByName = new Map();
- for (const link of links) {
- if (!nodesByName.has(link.source)) {
- nodesByName.set(link.source, { name: link.source });
- }
- if (!nodesByName.has(link.target)) {
- nodesByName.set(link.target, { name: link.target });
- }
- }
- return Array.from(nodesByName.values());
-}
-
-/**
- * Get all upstream predecessors for dagre nodeId
- */
-export function ancestors(
- graph: dagre.graphlib.Graph,
- nodeId: string,
- maxDepth = Infinity,
- currentDepth = 0
-): dagre.Node[] {
- if (currentDepth === maxDepth) {
- return [];
- }
-
- const predecessors = graph.predecessors(nodeId) ?? [];
- return [
- ...predecessors,
- // @ts-expect-error: Types from dagre appear incorrect
- ...predecessors.flatMap((pId) => ancestors(graph, pId, maxDepth, currentDepth + 1)),
- ];
-}
-
-/**
- * Get all downstream descendants for dagre nodeId
- */
-export function descendants(
- graph: dagre.graphlib.Graph,
- nodeId: string,
- maxDepth = Infinity,
- currentDepth = 0
-): dagre.Node[] {
- if (currentDepth === maxDepth) {
- return [];
- }
-
- const predecessors = graph.successors(nodeId) ?? [];
- return [
- ...predecessors,
- // @ts-expect-error: Types from dagre appear incorrect
- ...predecessors.flatMap((pId) => descendants(graph, pId, maxDepth, currentDepth + 1)),
- ];
-}
diff --git a/packages/layerchart/src/lib/utils/graph/dagre.test.ts b/packages/layerchart/src/lib/utils/graph/dagre.test.ts
new file mode 100644
index 000000000..d20018a4a
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/graph/dagre.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from 'vitest';
+import { dagreGraph, dagreAncestors, dagreDescendants } from './dagre.js';
+
+const exampleGraph = {
+ nodes: [
+ { id: 'A' },
+ { id: 'B' },
+ { id: 'C' },
+ { id: 'D' },
+ { id: 'E' },
+ { id: 'F' },
+ { id: 'G' },
+ { id: 'H' },
+ { id: 'I' },
+ ],
+ edges: [
+ { source: 'A', target: 'B' },
+ { source: 'C', target: 'B' },
+ { source: 'B', target: 'E' },
+ { source: 'B', target: 'F' },
+ { source: 'D', target: 'E' },
+ { source: 'D', target: 'F' },
+ { source: 'E', target: 'H' },
+ { source: 'G', target: 'H' },
+ { source: 'H', target: 'I' },
+ ],
+};
+
+describe('dagreAncestors', () => {
+ it('start of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreAncestors(graph, 'L');
+ expect(actual).length(0);
+ });
+
+ it('middle of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreAncestors(graph, 'E');
+ expect(actual).to.have.members(['A', 'B', 'C', 'D']);
+ });
+
+ it('end of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreAncestors(graph, 'I');
+ expect(actual).to.have.members(['A', 'B', 'C', 'D', 'E', 'G', 'H']);
+ });
+
+ it('max depth', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreAncestors(graph, 'H', 2);
+ expect(actual).to.have.members(['B', 'D', 'E', 'G']);
+ });
+});
+
+describe('dagreDescendants', () => {
+ it('start of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreDescendants(graph, 'A');
+ expect(actual).to.have.members(['B', 'E', 'F', 'H', 'I']);
+ });
+
+ it('middle of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreDescendants(graph, 'E');
+ expect(actual).to.have.members(['H', 'I']);
+ });
+
+ it('end of graph ', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreDescendants(graph, 'I');
+ expect(actual).length(0);
+ });
+
+ it('max depth', () => {
+ const graph = dagreGraph(exampleGraph);
+ const actual = dagreDescendants(graph, 'B', 2);
+ expect(actual).to.have.members(['E', 'F', 'H']);
+ });
+});
diff --git a/packages/layerchart/src/lib/utils/graph/dagre.ts b/packages/layerchart/src/lib/utils/graph/dagre.ts
new file mode 100644
index 000000000..5b7adf70d
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/graph/dagre.ts
@@ -0,0 +1,149 @@
+import dagre from '@dagrejs/dagre';
+import { Align, EdgeLabelPosition, RankDir, type DagreProps } from '$lib/components/Dagre.svelte';
+
+/**
+ * Build `dagre.graphlib.Graph` instance from DagreGraphData (`{ nodes, edges }`)
+ */
+export function dagreGraph(
+ data: DagreProps['data'],
+ {
+ nodes = (d: any) => d.nodes,
+ nodeId = (d: any) => d.id,
+ edges = (d: any) => d.edges,
+ directed = true,
+ multigraph = false,
+ compound = false,
+ ranker = 'network-simplex',
+ direction = 'top-bottom',
+ align,
+ rankSeparation = 50,
+ nodeSeparation = 50,
+ edgeSeparation = 10,
+ nodeWidth = 100,
+ nodeHeight = 50,
+ edgeLabelWidth = 100,
+ edgeLabelHeight = 20,
+ edgeLabelPosition = 'center',
+ edgeLabelOffset = 10,
+ filterNodes = () => true,
+ }: {
+ nodes?: DagreProps['nodes'];
+ nodeId?: DagreProps['nodeId'];
+ edges?: DagreProps['edges'];
+ directed?: DagreProps['directed'];
+ multigraph?: DagreProps['multigraph'];
+ compound?: DagreProps['compound'];
+ ranker?: DagreProps['ranker'];
+ direction?: DagreProps['direction'];
+ align?: DagreProps['align'];
+ rankSeparation?: DagreProps['rankSeparation'];
+ nodeSeparation?: DagreProps['nodeSeparation'];
+ edgeSeparation?: DagreProps['edgeSeparation'];
+ nodeWidth?: DagreProps['nodeWidth'];
+ nodeHeight?: DagreProps['nodeHeight'];
+ edgeLabelWidth?: DagreProps['edgeLabelWidth'];
+ edgeLabelHeight?: DagreProps['edgeLabelHeight'];
+ edgeLabelPosition?: DagreProps['edgeLabelPosition'];
+ edgeLabelOffset?: DagreProps['edgeLabelOffset'];
+ filterNodes?: DagreProps['filterNodes'];
+ } = {}
+) {
+ let g = new dagre.graphlib.Graph({ directed, multigraph, compound });
+
+ g.setGraph({
+ ranker: ranker,
+ rankdir: RankDir[direction],
+ align: align ? Align[align] : undefined,
+ ranksep: rankSeparation,
+ nodesep: nodeSeparation,
+ edgesep: edgeSeparation,
+ });
+
+ g.setDefaultEdgeLabel(() => ({}));
+
+ const dataNodes = nodes(data);
+
+ for (const n of dataNodes) {
+ const id = nodeId(n);
+
+ g.setNode(nodeId(n), {
+ id,
+ label: typeof n.label === 'string' ? n.label : id,
+ width: nodeWidth,
+ height: nodeHeight,
+ ...(typeof n.label === 'object' ? n.label : null),
+ });
+
+ if (n.parent) {
+ g.setParent(id, n.parent);
+ }
+ }
+
+ const nodeEdges = edges(data);
+
+ for (const e of nodeEdges) {
+ const { source, target, label, ...rest } = e;
+ g.setEdge(
+ e.source,
+ e.target,
+ label
+ ? {
+ label: label,
+ labelpos: EdgeLabelPosition[edgeLabelPosition],
+ labeloffset: edgeLabelOffset,
+ width: edgeLabelWidth,
+ height: edgeLabelHeight,
+ ...rest,
+ }
+ : {}
+ );
+ }
+
+ if (filterNodes) {
+ g = g.filterNodes((nodeId) => filterNodes(nodeId, g));
+ }
+
+ dagre.layout(g);
+
+ return g;
+}
+
+/**
+ * Get all upstream predecessors ids for dagre nodeId
+ */
+export function dagreAncestors(
+ graph: dagre.graphlib.Graph,
+ nodeId: string,
+ maxDepth = Infinity,
+ currentDepth = 0
+): string[] {
+ if (currentDepth === maxDepth) {
+ return [];
+ }
+
+ const predecessors = graph.predecessors(nodeId) ?? [];
+ return [
+ ...predecessors,
+ ...predecessors.flatMap((pId) => dagreAncestors(graph, pId, maxDepth, currentDepth + 1)),
+ ];
+}
+
+/**
+ * Get all downstream descendants ids for dagre nodeId
+ */
+export function dagreDescendants(
+ graph: dagre.graphlib.Graph,
+ nodeId: string,
+ maxDepth = Infinity,
+ currentDepth = 0
+): string[] {
+ if (currentDepth === maxDepth) {
+ return [];
+ }
+
+ const successors = graph.successors(nodeId) ?? [];
+ return [
+ ...successors,
+ ...successors.flatMap((pId) => dagreDescendants(graph, pId, maxDepth, currentDepth + 1)),
+ ];
+}
diff --git a/packages/layerchart/src/lib/utils/graph/sankey.ts b/packages/layerchart/src/lib/utils/graph/sankey.ts
new file mode 100644
index 000000000..44f89a5b2
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/graph/sankey.ts
@@ -0,0 +1,88 @@
+import { csvParseRows } from 'd3-dsv';
+import type {
+ SankeyExtraProperties,
+ SankeyGraph,
+ SankeyLink,
+ SankeyNode,
+ SankeyNodeMinimal,
+} from 'd3-sankey';
+import type { hierarchy as d3Hierarchy } from 'd3-hierarchy';
+
+/**
+ * Convert CSV rows in format: 'source,target,value' to SankeyGraph
+ */
+export function sankeyGraphFromCsv(csv: string): SankeyGraph {
+ const links = csvParseRows(csv, (row) => {
+ const [source, target, rawValue] = row;
+ if (!source || !target) return null;
+ const num = rawValue ? +rawValue : NaN;
+ return {
+ source,
+ target,
+ value: isNaN(num) ? 1 : num,
+ };
+ });
+
+ return { nodes: sankeyNodesFromLinks(links), links };
+}
+
+/**
+ * Convert d3-hierarchy to graph (nodes/links)
+ */
+export function sankeyGraphFromHierarchy(hierarchy: ReturnType) {
+ return {
+ nodes: hierarchy.descendants(),
+ links: hierarchy.links().map((link) => ({ ...link, value: link.target.value })),
+ };
+}
+
+/**
+ * Create graph from node (and target node/links downward)
+ */
+export function sankeyGraphFromNode(node: SankeyNodeMinimal) {
+ const nodes: SankeyNode[] = [node];
+ const links: SankeyLink[] = [];
+
+ for (const link of node.sourceLinks ?? []) {
+ nodes.push(link.target);
+ links.push(link);
+
+ if (link.target.sourceLinks.length) {
+ const targetData = sankeyGraphFromNode(link.target);
+
+ // Only add new nodes
+ for (const node of targetData.nodes) {
+ if (!nodes.includes(node)) {
+ nodes.push(node);
+ }
+ }
+ // Only add new links
+ for (const link of targetData.links) {
+ if (!links.includes(link)) {
+ links.push(link);
+ }
+ }
+ }
+ }
+
+ return { nodes, links };
+}
+
+/**
+ * Get distinct nodes from link.source and link.target
+ */
+export function sankeyNodesFromLinks<
+ N extends SankeyExtraProperties,
+ L extends SankeyExtraProperties,
+>(links: Array>) {
+ const nodesByName = new Map();
+ for (const link of links) {
+ if (!nodesByName.has(link.source)) {
+ nodesByName.set(link.source, { name: link.source });
+ }
+ if (!nodesByName.has(link.target)) {
+ nodesByName.set(link.target, { name: link.target });
+ }
+ }
+ return Array.from(nodesByName.values());
+}
diff --git a/packages/layerchart/src/lib/utils/index.ts b/packages/layerchart/src/lib/utils/index.ts
index 817db2b70..91b5ff30b 100644
--- a/packages/layerchart/src/lib/utils/index.ts
+++ b/packages/layerchart/src/lib/utils/index.ts
@@ -1,11 +1,22 @@
+export { applyLanes } from './array.js';
+export * from './download.js';
export * from './canvas.js';
export * from './common.js';
+export * from './dataProp.js';
export * from './geo.js';
-export * from './graph.js';
export * from './hierarchy.js';
export * from './math.js';
export * from './path.js';
export * from './pivot.js';
+export * from './scales.svelte.js';
export * from './stack.js';
export * from './ticks.js';
+export * from './treemap.js';
export * from './threshold.js';
+export * from './rasterInterpolate.js';
+export * from './rasterBounds.js';
+export * from './stats.js';
+export * from './types.js';
+
+export * from './graph/dagre.js';
+export * from './graph/sankey.js';
diff --git a/packages/layerchart/src/lib/utils/key.svelte.ts b/packages/layerchart/src/lib/utils/key.svelte.ts
new file mode 100644
index 000000000..b4552813f
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/key.svelte.ts
@@ -0,0 +1,12 @@
+import { objectId } from '@layerstack/utils/object';
+
+// TODO: investigate if this is necessary with Svelte 5
+export function createKey(getValue: () => T) {
+ const value = $derived(getValue());
+ const key = $derived(value && typeof value === 'object' ? objectId(value) : value);
+ return {
+ get current() {
+ return key;
+ },
+ };
+}
diff --git a/packages/layerchart/src/lib/utils/linkUtils.ts b/packages/layerchart/src/lib/utils/linkUtils.ts
new file mode 100644
index 000000000..76a939d2a
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/linkUtils.ts
@@ -0,0 +1,423 @@
+import {
+ type CurveFactory,
+ curveLinear,
+ curveStep,
+ curveStepAfter,
+ curveStepBefore,
+ line as d3Line,
+ lineRadial,
+ linkRadial,
+} from 'd3-shape';
+
+export type LinkCoords = {
+ x: number;
+ y: number;
+};
+
+export type PresetLinkType = 'straight' | 'square' | 'beveled' | 'rounded' | 'swoop';
+
+export type LinkType = PresetLinkType | 'd3';
+
+export type LinkSweep = 'horizontal-vertical' | 'vertical-horizontal' | 'none';
+
+function isSamePoint(p1: LinkCoords, p2: LinkCoords): boolean {
+ return Math.abs(p1.x - p2.x) < 1e-6 && Math.abs(p1.y - p2.y) < 1e-6;
+}
+
+function createDirectPath(source: LinkCoords, target: LinkCoords): string {
+ if (isSamePoint(source, target)) return '';
+ return `M ${source.x} ${source.y} L ${target.x} ${target.y}`;
+}
+
+function isNearZero(value: number): boolean {
+ return Math.abs(value) < 1e-6;
+}
+
+type CreateLinkPathProps = {
+ source: LinkCoords;
+ target: LinkCoords;
+ radius: number;
+ sweep: LinkSweep;
+ dx: number;
+ dy: number;
+ /** Bend angle in degrees, used by 'swoop' type. Default 22.5. */
+ bend?: number;
+};
+
+function createSquarePath({ source, target, sweep }: CreateLinkPathProps): string {
+ if (sweep === 'horizontal-vertical') {
+ return `M ${source.x} ${source.y} L ${target.x} ${source.y} L ${target.x} ${target.y}`;
+ } else {
+ return `M ${source.x} ${source.y} L ${source.x} ${target.y} L ${target.x} ${target.y}`;
+ }
+}
+
+function createBeveledPath(opts: CreateLinkPathProps): string {
+ const { radius, dx, dy, source, target, sweep } = opts;
+ const effectiveRadius = Math.max(0, Math.min(radius, Math.abs(dx), Math.abs(dy)));
+
+ if (isNearZero(effectiveRadius)) {
+ return createSquarePath(opts);
+ }
+
+ const signX = Math.sign(dx);
+ const signY = Math.sign(dy);
+
+ if (sweep === 'horizontal-vertical') {
+ const pBeforeCorner = { x: target.x - effectiveRadius * signX, y: source.y };
+ const pAfterCorner = { x: target.x, y: source.y + effectiveRadius * signY };
+
+ return `M ${source.x} ${source.y} L ${pBeforeCorner.x} ${pBeforeCorner.y} L ${pAfterCorner.x} ${pAfterCorner.y} L ${target.x} ${target.y}`;
+ } else {
+ const pBeforeCorner = { x: source.x, y: target.y - effectiveRadius * signY };
+ const pAfterCorner = { x: source.x + effectiveRadius * signX, y: target.y };
+
+ return `M ${source.x} ${source.y} L ${pBeforeCorner.x} ${pBeforeCorner.y} L ${pAfterCorner.x} ${pAfterCorner.y} L ${target.x} ${target.y}`;
+ }
+}
+
+function createRoundedPath(opts: CreateLinkPathProps): string {
+ const { radius, dx, dy, source, target, sweep } = opts;
+ const effectiveRadius = Math.max(0, Math.min(radius, Math.abs(dx), Math.abs(dy)));
+
+ if (isNearZero(effectiveRadius)) {
+ return createSquarePath(opts);
+ }
+
+ const signX = Math.sign(dx);
+ const signY = Math.sign(dy);
+
+ if (sweep === 'horizontal-vertical') {
+ const pBeforeCorner = { x: target.x - effectiveRadius * signX, y: source.y };
+ const pAfterCorner = { x: target.x, y: source.y + effectiveRadius * signY };
+ const sweepFlag = signX * signY > 0 ? 1 : 0;
+
+ return `M ${source.x} ${source.y} L ${pBeforeCorner.x} ${pBeforeCorner.y} A ${effectiveRadius} ${effectiveRadius} 0 0 ${sweepFlag} ${pAfterCorner.x} ${pAfterCorner.y} L ${target.x} ${target.y}`;
+ } else {
+ const pBeforeCorner = { x: source.x, y: target.y - effectiveRadius * signY };
+ const pAfterCorner = { x: source.x + effectiveRadius * signX, y: target.y };
+ const sweepFlag = signX * signY > 0 ? 0 : 1;
+
+ return `M ${source.x} ${source.y} L ${pBeforeCorner.x} ${pBeforeCorner.y} A ${effectiveRadius} ${effectiveRadius} 0 0 ${sweepFlag} ${pAfterCorner.x} ${pAfterCorner.y} L ${target.x} ${target.y}`;
+ }
+}
+
+/**
+ * Swoop: circular arc between source and target. Equivalent to ObservablePlot's
+ * Arrow `bend` option — positive angle bends right (clockwise from source to
+ * target), negative bends left, 0 is a straight line.
+ */
+function createSwoopPath({ source, target, dx, dy, bend = 22.5 }: CreateLinkPathProps): string {
+ const chordLen = Math.hypot(dx, dy);
+ const bendRad = (bend * Math.PI) / 180;
+ if (Math.abs(bendRad) < 1e-6 || chordLen < 1e-6) {
+ return createDirectPath(source, target);
+ }
+ // Half-chord subtends `bend` at the arc center, so radius = chord / (2 * sin(bend))
+ const arcRadius = chordLen / (2 * Math.sin(Math.abs(bendRad)));
+ const largeArc = Math.abs(bend) > 90 ? 1 : 0;
+ const sweepFlag = bend > 0 ? 1 : 0;
+ return `M${source.x},${source.y}A${arcRadius},${arcRadius} 0 ${largeArc} ${sweepFlag} ${target.x},${target.y}`;
+}
+
+type PathStrategyMap = Record<
+ 'square' | 'beveled' | 'rounded' | 'swoop',
+ (props: CreateLinkPathProps) => string
+>;
+
+const pathStrategies: PathStrategyMap = {
+ square: createSquarePath,
+ beveled: createBeveledPath,
+ rounded: createRoundedPath,
+ swoop: createSwoopPath,
+};
+
+type GetLinkPresetPathProps = {
+ source: LinkCoords;
+ target: LinkCoords;
+ radius: number;
+ type: PresetLinkType;
+ sweep: LinkSweep;
+ /** Bend angle in degrees, used by 'swoop' type. Default 22.5. */
+ bend?: number;
+};
+
+export function getLinkPresetPath(opts: GetLinkPresetPathProps) {
+ const { source, target, type } = opts;
+ if (isSamePoint(source, target)) return '';
+ const dx = target.x - source.x;
+ const dy = target.y - source.y;
+
+ // straight line cases (swoop still bends even when axis-aligned)
+ if (type === 'straight' || (type !== 'swoop' && (isNearZero(dx) || isNearZero(dy)))) {
+ return createDirectPath(source, target);
+ }
+
+ return (pathStrategies[type] || pathStrategies.square)({ ...opts, dx, dy });
+}
+
+const FALLBACK_PATH = 'M0,0L0,0';
+
+type GetLinkD3PathProps = Omit & {
+ curve: CurveFactory;
+ /**
+ * Cartesian orientation hint for axis-dependent curves (d3 step variants step
+ * along x by default; for 'vertical' we step along y to match the natural flow).
+ */
+ orientation?: 'horizontal' | 'vertical';
+};
+
+export function getLinkD3Path({
+ source,
+ target,
+ sweep,
+ curve,
+ orientation = 'horizontal',
+}: GetLinkD3PathProps) {
+ const dx = target.x - source.x;
+ const dy = target.y - source.y;
+
+ // d3 step curves always step along x. For vertical orientation, emit a
+ // y-axis step manually so the step sits between parent/child along depth.
+ if (orientation === 'vertical' && sweep === 'none') {
+ const { x: sx, y: sy } = source;
+ const { x: tx, y: ty } = target;
+ if (curve === curveStep) {
+ const my = (sy + ty) / 2;
+ return `M${sx},${sy}L${sx},${my}L${tx},${my}L${tx},${ty}`;
+ }
+ if (curve === curveStepBefore) {
+ // Bump near source: sibling (x) changes first, then depth (y)
+ return `M${sx},${sy}L${tx},${sy}L${tx},${ty}`;
+ }
+ if (curve === curveStepAfter) {
+ // Bump near target: depth (y) changes first, then sibling (x)
+ return `M${sx},${sy}L${sx},${ty}L${tx},${ty}`;
+ }
+ }
+
+ const line = d3Line().curve(curve);
+ let points: [number, number][] = [];
+
+ const isAligned = isNearZero(dx) || isNearZero(dy);
+
+ if (sweep === 'none' || isAligned) {
+ points = [
+ [source.x, source.y],
+ [target.x, target.y],
+ ];
+ } else if (sweep === 'horizontal-vertical') {
+ points = [
+ [source.x, source.y],
+ [target.x, source.y],
+ [target.x, target.y],
+ ];
+ } else if (sweep === 'vertical-horizontal') {
+ points = [
+ [source.x, source.y],
+ [source.x, target.y],
+ [target.x, target.y],
+ ];
+ }
+
+ if (points.length === 2 && isNearZero(dx) && isNearZero(dx)) return FALLBACK_PATH;
+
+ const d = line(points);
+
+ if (!d || d.includes('NaN')) return FALLBACK_PATH;
+
+ return d;
+}
+
+// --- Radial variants --------------------------------------------------------
+// In radial mode, `source`/`target` carry polar coords: `x` = angle, `y` = radius.
+// Angles follow d3 tree convention (0 = up); visx's math subtracts PI/2 so 0 = +x axis.
+
+type RadialGeometry = {
+ sa: number;
+ sr: number;
+ ta: number;
+ tr: number;
+ sc: number;
+ ss: number;
+ tc: number;
+ ts: number;
+ sx: number;
+ sy: number;
+ tx: number;
+ ty: number;
+ sweepFlag: 0 | 1;
+};
+
+function radialGeometry(source: LinkCoords, target: LinkCoords): RadialGeometry {
+ const sa = source.x - Math.PI / 2;
+ const sr = source.y;
+ const ta = target.x - Math.PI / 2;
+ const tr = target.y;
+ const sc = Math.cos(sa);
+ const ss = Math.sin(sa);
+ const tc = Math.cos(ta);
+ const ts = Math.sin(ta);
+ const sweepFlag: 0 | 1 = Math.abs(ta - sa) > Math.PI ? (ta <= sa ? 1 : 0) : ta > sa ? 1 : 0;
+ return {
+ sa,
+ sr,
+ ta,
+ tr,
+ sc,
+ ss,
+ tc,
+ ts,
+ sx: sr * sc,
+ sy: sr * ss,
+ tx: tr * tc,
+ ty: tr * ts,
+ sweepFlag,
+ };
+}
+
+type GetLinkRadialPresetPathProps = {
+ source: LinkCoords;
+ target: LinkCoords;
+ type: PresetLinkType;
+ radius: number;
+ bend?: number;
+};
+
+export function getLinkRadialPresetPath({
+ source,
+ target,
+ type,
+ radius,
+ bend = 22.5,
+}: GetLinkRadialPresetPathProps): string {
+ const g = radialGeometry(source, target);
+ const { sr, ta, tr, sc, ss, tc, ts, sx, sy, tx, ty, sweepFlag } = g;
+
+ if (type === 'straight') {
+ return `M${sx},${sy}L${tx},${ty}`;
+ }
+
+ if (type === 'swoop') {
+ // Circular arc in cartesian space between the polar-converted endpoints.
+ const dx = tx - sx;
+ const dy = ty - sy;
+ const chordLen = Math.hypot(dx, dy);
+ const bendRad = (bend * Math.PI) / 180;
+ if (Math.abs(bendRad) < 1e-6 || chordLen < 1e-6) {
+ return `M${sx},${sy}L${tx},${ty}`;
+ }
+ const arcRadius = chordLen / (2 * Math.sin(Math.abs(bendRad)));
+ const largeArc = Math.abs(bend) > 90 ? 1 : 0;
+ const arcSweep = bend > 0 ? 1 : 0;
+ return `M${sx},${sy}A${arcRadius},${arcRadius} 0 ${largeArc} ${arcSweep} ${tx},${ty}`;
+ }
+
+ if (type === 'rounded') {
+ // visx LinkRadialCurve: cubic Bezier with rotated offset (percent controls tension)
+ const percent = 0.2;
+ const dx = tx - sx;
+ const dy = ty - sy;
+ const ix = percent * (dx + dy);
+ const iy = percent * (dy - dx);
+ return `M${sx},${sy}C${sx + ix},${sy + iy} ${tx + iy},${ty - ix} ${tx},${ty}`;
+ }
+
+ if (type === 'square') {
+ // Source at origin — degenerate arc, just radial to target
+ if (sr < 1e-6) return `M${sx},${sy}L${tx},${ty}`;
+ // Step at midpoint radius: radial + arc + radial
+ const mr = (sr + tr) / 2;
+ const p1x = mr * sc;
+ const p1y = mr * ss;
+ const p2x = mr * tc;
+ const p2y = mr * ts;
+ return `M${sx},${sy}L${p1x},${p1y}A${mr},${mr},0,0,${sweepFlag},${p2x},${p2y}L${tx},${ty}`;
+ }
+
+ // 'beveled': visx-style step with chord at source radius and chamfered corner
+ const cornerX = sr * tc;
+ const cornerY = sr * ts;
+ const chordDx = cornerX - sx;
+ const chordDy = cornerY - sy;
+ const chordLen = Math.hypot(chordDx, chordDy);
+
+ if (chordLen < 1e-6) {
+ // Source at origin — chord degenerates, just radial to target
+ return `M${sx},${sy}L${tx},${ty}`;
+ }
+
+ const radialLen = Math.abs(tr - sr) || 1;
+ const r = Math.max(0, Math.min(radius, chordLen, radialLen));
+ const cux = chordDx / chordLen;
+ const cuy = chordDy / chordLen;
+ const radialDir = Math.sign(tr - sr) || 1;
+
+ const p1x = cornerX - r * cux;
+ const p1y = cornerY - r * cuy;
+ const p2x = cornerX + radialDir * r * tc;
+ const p2y = cornerY + radialDir * r * ts;
+
+ return `M${sx},${sy}L${p1x},${p1y}L${p2x},${p2y}L${tx},${ty}`;
+}
+
+type GetLinkRadialD3PathProps = {
+ source: LinkCoords;
+ target: LinkCoords;
+ curve?: CurveFactory;
+};
+
+export function getLinkRadialD3Path({
+ source,
+ target,
+ curve,
+}: GetLinkRadialD3PathProps): string {
+ const g = radialGeometry(source, target);
+ const { sr, tr, sc, ss, tc, ts, sx, sy, tx, ty, sweepFlag } = g;
+
+ // Step curves render as polar arcs/radials rather than cartesian stairs.
+ // When source is at origin (root), degenerate to straight radial line.
+ if (curve === curveStepBefore || curve === curveStepAfter || curve === curveStep) {
+ if (sr < 1e-6) return `M${sx},${sy}L${tx},${ty}`;
+ }
+ if (curve === curveStepBefore) {
+ // arc at source radius, then radial to target
+ const ax = sr * tc;
+ const ay = sr * ts;
+ return `M${sx},${sy}A${sr},${sr},0,0,${sweepFlag},${ax},${ay}L${tx},${ty}`;
+ }
+ if (curve === curveStepAfter) {
+ // radial at source angle to target radius, then arc at target radius
+ const ax = tr * sc;
+ const ay = tr * ss;
+ return `M${sx},${sy}L${ax},${ay}A${tr},${tr},0,0,${sweepFlag},${tx},${ty}`;
+ }
+ if (curve === curveStep) {
+ // radial to mid-radius, arc at mid-radius, radial to target
+ const mr = (sr + tr) / 2;
+ const p1x = mr * sc;
+ const p1y = mr * ss;
+ const p2x = mr * tc;
+ const p2y = mr * ts;
+ return `M${sx},${sy}L${p1x},${p1y}A${mr},${mr},0,0,${sweepFlag},${p2x},${p2y}L${tx},${ty}`;
+ }
+
+ if (curve) {
+ // Other curves: apply in polar space via d3.lineRadial between the two nodes
+ const gen = lineRadial().curve(curve);
+ const d = gen([
+ [source.x, source.y],
+ [target.x, target.y],
+ ]);
+ return d ?? FALLBACK_PATH;
+ }
+
+ // Default: smooth radial curve via d3.linkRadial (visx LinkRadial)
+ const linkGen = linkRadial<
+ { source: LinkCoords; target: LinkCoords },
+ LinkCoords
+ >()
+ .angle((d) => d.x)
+ .radius((d) => d.y);
+ return linkGen({ source, target }) ?? FALLBACK_PATH;
+}
diff --git a/packages/layerchart/src/lib/utils/math.ts b/packages/layerchart/src/lib/utils/math.ts
index 6babcac62..9e48effac 100644
--- a/packages/layerchart/src/lib/utils/math.ts
+++ b/packages/layerchart/src/lib/utils/math.ts
@@ -44,6 +44,29 @@ export function cartesianToPolar(x: number, y: number) {
};
}
+/**
+ * Calculate the angle and length between two points
+ * @param point1 - First point
+ * @param point2 - Second point
+ * @returns Angle in degrees and length
+ */
+export function pointsToAngleAndLength(
+ point1: { x: number; y: number },
+ point2: { x: number; y: number }
+) {
+ const dx = point2.x - point1.x;
+ const dy = point2.y - point1.y;
+
+ const radians = Math.atan2(dy, dx);
+ const length = Math.sqrt(dx * dx + dy * dy);
+
+ return {
+ radians,
+ angle: radiansToDegrees(radians),
+ length,
+ };
+}
+
/** Convert celsius temperature to fahrenheit */
export function celsiusToFahrenheit(temperature: number) {
return temperature * (9 / 5) + 32;
diff --git a/packages/layerchart/src/lib/utils/motion.svelte.ts b/packages/layerchart/src/lib/utils/motion.svelte.ts
new file mode 100644
index 000000000..2a3736a88
--- /dev/null
+++ b/packages/layerchart/src/lib/utils/motion.svelte.ts
@@ -0,0 +1,396 @@
+import { untrack } from 'svelte';
+import { Spring, Tween } from 'svelte/motion';
+
+/**
+ * Spring motion configuration options
+ */
+export type SpringOptions = ConstructorParameters>[1];
+export type SpringSetOptions = Parameters<(typeof Spring)['prototype']['set']>[1];
+
+/**
+ * Tween motion configuration options
+ */
+export type TweenOptions = ConstructorParameters>[1];
+export type TweenSetOptions = Parameters<(typeof Tween)['prototype']['set']>[1];
+
+/**
+ * MotionNone is a non-animating state container that provides a compatible
+ * interface with Spring and Tween, but updates values immediately without animation.
+ * This gives us consistent state management whether animations are enabled or not.
+ */
+export type NoneOptions = ConstructorParameters>[1];
+export type NoneSetOptions = Parameters<(typeof MotionNone)['prototype']['set']>[1];
+
+/**
+ * Configuration object for Spring animations with additional type discriminator
+ */
+export type MotionSpringOption =
+ | ({
+ type: 'spring';
+ } & SpringOptions)
+ | 'spring';
+
+/**
+ * Configuration object for Tween animations with additional type discriminator
+ */
+export type MotionTweenOption =
+ | ({
+ type: 'tween';
+ } & TweenOptions)
+ | 'tween';
+
+/**
+ * Configuration object for non-animating state with additional type discriminator
+ */
+export type MotionNoneOption =
+ | {
+ type: 'none';
+ }
+ | 'none';
+
+/**
+ * Union type of all possible motion configuration options
+ */
+export type MotionOptions = MotionSpringOption | MotionTweenOption | MotionNoneOption;
+
+type IsDefault = K extends string ? (string extends K ? true : false) : never;
+
+/**
+ * Motion config that can be either a direct motion config or
+ * a map of property names to motion configs
+ */
+export type MotionProp =
+ IsDefault extends true ? MotionOptions : MotionOptions | { [prop in K]?: MotionOptions };
+
+/**
+ * Extended Spring class that adds a type discriminator to help with
+ * type narrowing in our motion system
+ */
+class MotionSpring extends Spring