+
+ {#if datasetOptions}
+
+ {/if}
+
Horizontal
Vertical
+ Radial
@@ -35,15 +63,56 @@
-
-
+
+ {#if config.type === 'd3'}
+
+ {/if}
+
{#if config.type === 'beveled' || config.type === 'rounded'}
{/if}
+
+
+
+
+
+
+ {#if config.orientation === 'radial'}
+
+ {:else}
+
+ {/if}
diff --git a/docs/src/lib/data.remote.ts b/docs/src/lib/data.remote.ts
index c7022ba76..a4e90c485 100644
--- a/docs/src/lib/data.remote.ts
+++ b/docs/src/lib/data.remote.ts
@@ -125,6 +125,12 @@ export const getFlare = prerender(async () => {
return data;
});
+export const getSimpleTree = prerender(async () => {
+ const { fetch } = getRequestEvent();
+ const data = await fetch('/data/examples/hierarchy/simple-tree.json').then((r) => r.json());
+ return data;
+});
+
export const getUsSenators = prerender(async () => {
const { fetch } = getRequestEvent();
const data = (await fetch('/data/examples/us-senators.csv').then(async (r) =>
diff --git a/docs/static/data/examples/hierarchy/simple-tree.json b/docs/static/data/examples/hierarchy/simple-tree.json
new file mode 100644
index 000000000..b5b0dff12
--- /dev/null
+++ b/docs/static/data/examples/hierarchy/simple-tree.json
@@ -0,0 +1,36 @@
+{
+ "name": "R",
+ "children": [
+ {
+ "name": "A",
+ "children": [
+ { "name": "A1" },
+ { "name": "A2" },
+ { "name": "A3" },
+ {
+ "name": "C",
+ "children": [
+ { "name": "C1" },
+ {
+ "name": "D",
+ "children": [
+ { "name": "D1" },
+ { "name": "D2" },
+ { "name": "D3" }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ { "name": "Z" },
+ {
+ "name": "B",
+ "children": [
+ { "name": "B1" },
+ { "name": "B2" },
+ { "name": "B3" }
+ ]
+ }
+ ]
+}
diff --git a/packages/layerchart/src/lib/components/Connector.svelte b/packages/layerchart/src/lib/components/Connector.svelte
index 832bdbc63..207b0c26c 100644
--- a/packages/layerchart/src/lib/components/Connector.svelte
+++ b/packages/layerchart/src/lib/components/Connector.svelte
@@ -46,6 +46,12 @@
* @default `d3.curveLinear`
*/
curve?: CurveFactory;
+
+ /**
+ * Interpret `source`/`target` as polar coordinates (`x` = angle, `y` = radius)
+ * and render the path in radial space. Defaults to `ctx.radial` when unset.
+ */
+ radial?: boolean;
} & PathPropsWithoutHTML;
export type ConnectorProps = ConnectorPropsWithoutHTML &
@@ -57,10 +63,13 @@
import {
getConnectorD3Path,
getConnectorPresetPath,
+ getConnectorRadialD3Path,
+ getConnectorRadialPresetPath,
type ConnectorCoords,
type ConnectorSweep,
type ConnectorType,
} from '$lib/utils/connectorUtils.js';
+ import { getChartContext } from '$lib/contexts/chart.js';
import Path, { type PathProps, type PathPropsWithoutHTML } from './Path.svelte';
import type { Without } from '$lib/utils/types.js';
import { createId } from '$lib/utils/createId.js';
@@ -82,6 +91,7 @@
type = 'rounded',
radius = 20,
curve = curveLinear,
+ radial: radialProp,
pathRef = $bindable(),
pathData: pathDataProp,
marker,
@@ -92,6 +102,9 @@
...restProps
}: ConnectorProps = $props();
+ const ctx = getChartContext();
+ const radial = $derived(radialProp ?? ctx.radial ?? false);
+
const sweep = $derived.by(() => {
if (sweepProp) return sweepProp;
if (type === 'd3') return 'none';
@@ -116,6 +129,11 @@
const pathData = $derived.by(() => {
if (pathDataProp) return pathDataProp;
+ if (radial) {
+ return type === 'd3'
+ ? getConnectorRadialD3Path({ source, target, curve })
+ : getConnectorRadialPresetPath({ source, target, type, radius });
+ }
if (type === 'd3') {
return getConnectorD3Path({
source,
diff --git a/packages/layerchart/src/lib/components/Link.svelte b/packages/layerchart/src/lib/components/Link.svelte
index 3b88987d0..837149d96 100644
--- a/packages/layerchart/src/lib/components/Link.svelte
+++ b/packages/layerchart/src/lib/components/Link.svelte
@@ -82,6 +82,9 @@ TODO:
*/
import Connector, { type ConnectorProps } from './Connector.svelte';
import { extractLayerProps } from '$lib/utils/attributes.js';
+ import { getChartContext } from '$lib/contexts/chart.js';
+
+ const ctx = getChartContext();
let {
data,
@@ -95,6 +98,7 @@ TODO:
explicitCoords,
type = 'd3',
sweep = 'none',
+ radius = 20,
...restProps
}: LinkProps = $props();
@@ -125,12 +129,14 @@ TODO:
const xAccessor = $derived.by(() => {
if (xProp) return xProp;
if (sankey) return (d: any) => (d.isSource ? d.node.x1 : d.node.x0);
+ if (ctx.radial) return (d: any) => d.x;
return (d: any) => (orientation === 'horizontal' ? d.y : d.x);
});
const yAccessor = $derived.by(() => {
if (yProp) return yProp;
if (sankey) return (d: any) => d.y;
+ if (ctx.radial) return (d: any) => d.y;
return (d: any) => (orientation === 'horizontal' ? d.x : d.y);
});
@@ -165,6 +171,7 @@ TODO:
return FALLBACK_COORDS;
}
});
+
diff --git a/packages/layerchart/src/lib/components/Tree.svelte b/packages/layerchart/src/lib/components/Tree.svelte
index 59b77a057..1d1905494 100644
--- a/packages/layerchart/src/lib/components/Tree.svelte
+++ b/packages/layerchart/src/lib/components/Tree.svelte
@@ -49,9 +49,13 @@
const ctx = getChartContext();
const treeData = $derived.by(() => {
- const _tree = d3Tree
().size(
- orientation === 'horizontal' ? [ctx.height, ctx.width] : [ctx.width, ctx.height]
- );
+ const _tree = ctx.radial
+ ? d3Tree()
+ .size([2 * Math.PI, Math.min(ctx.width, ctx.height) / 2])
+ .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth)
+ : d3Tree().size(
+ orientation === 'horizontal' ? [ctx.height, ctx.width] : [ctx.width, ctx.height]
+ );
if (nodeSize) {
_tree.nodeSize(nodeSize);
diff --git a/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--1.png b/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--1.png
new file mode 100644
index 000000000..6054086f9
Binary files /dev/null and b/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--1.png differ
diff --git a/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--2.png b/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--2.png
new file mode 100644
index 000000000..6054086f9
Binary files /dev/null and b/packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--2.png differ
diff --git a/packages/layerchart/src/lib/utils/canvas.svelte.test.ts b/packages/layerchart/src/lib/utils/canvas.svelte.test.ts
index b9a8fb04d..1ef2ec437 100644
--- a/packages/layerchart/src/lib/utils/canvas.svelte.test.ts
+++ b/packages/layerchart/src/lib/utils/canvas.svelte.test.ts
@@ -386,6 +386,76 @@ describe('renderPathData', () => {
expect(globalAlphaValues[0]).toBeCloseTo(0.4);
});
+ it('composes fill opacity with inherited globalAlpha (Group opacity)', () => {
+ // Simulate a parent Group setting globalAlpha to 0.5
+ ctx.globalAlpha = 0.5;
+
+ const globalAlphaValues: number[] = [];
+ const originalFill = ctx.fill.bind(ctx);
+ vi.spyOn(ctx, 'fill').mockImplementation(function (this: CanvasRenderingContext2D, ...args: any) {
+ globalAlphaValues.push(ctx.globalAlpha);
+ originalFill(...args);
+ });
+
+ renderPathData(ctx, 'M0,0 L100,0 L100,100 Z', {
+ styles: { fill: 'red', fillOpacity: '1', opacity: '1', stroke: 'none' },
+ });
+
+ // Should be 0.5 (inherited) * 1 * 1 = 0.5, not overwritten to 1
+ expect(globalAlphaValues[0]).toBeCloseTo(0.5);
+ });
+
+ it('composes stroke opacity with inherited globalAlpha (Group opacity)', () => {
+ ctx.globalAlpha = 0.5;
+
+ const globalAlphaValues: number[] = [];
+ const originalStroke = ctx.stroke.bind(ctx);
+ vi.spyOn(ctx, 'stroke').mockImplementation(function (this: CanvasRenderingContext2D) {
+ globalAlphaValues.push(ctx.globalAlpha);
+ originalStroke();
+ });
+
+ renderPathData(ctx, 'M0,0 L100,0', {
+ styles: {
+ fill: 'none',
+ stroke: 'black',
+ strokeOpacity: '0.4',
+ opacity: '1',
+ strokeWidth: '2',
+ },
+ });
+
+ // Should be 0.5 (inherited) * 0.4 = 0.2
+ expect(globalAlphaValues[0]).toBeCloseTo(0.2);
+ });
+
+ it('composes element opacity with inherited globalAlpha (Group opacity)', () => {
+ ctx.globalAlpha = 0.5;
+
+ const globalAlphaValues: number[] = [];
+ const originalFill = ctx.fill.bind(ctx);
+ vi.spyOn(ctx, 'fill').mockImplementation(function (this: CanvasRenderingContext2D, ...args: any) {
+ globalAlphaValues.push(ctx.globalAlpha);
+ originalFill(...args);
+ });
+
+ renderPathData(ctx, 'M0,0 L100,0 L100,100 Z', {
+ styles: { fill: 'red', fillOpacity: '1', opacity: '0.6', stroke: 'none' },
+ });
+
+ // Should be 0.5 (inherited) * 0.6 (element opacity) * 1 (fillOpacity) = 0.3
+ expect(globalAlphaValues[0]).toBeCloseTo(0.3);
+ });
+
+ it('restores globalAlpha after rendering with inherited alpha', () => {
+ ctx.globalAlpha = 0.5;
+
+ renderPathData(ctx, 'M0,0 L100,0 L100,100 Z', plainFill('red'));
+
+ // globalAlpha should be restored to inherited value after rendering
+ expect(ctx.globalAlpha).toBeCloseTo(0.5);
+ });
+
it('respects paintOrder stroke (stroke before fill)', () => {
const callOrder: string[] = [];
vi.spyOn(ctx, 'fill').mockImplementation(() => {
diff --git a/packages/layerchart/src/lib/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts
index 1979f2524..5e42961a0 100644
--- a/packages/layerchart/src/lib/utils/canvas.ts
+++ b/packages/layerchart/src/lib/utils/canvas.ts
@@ -223,7 +223,7 @@ function render(
resolvedStyles?.paintOrder === 'stroke' ? ['stroke', 'fill'] : ['fill', 'stroke'];
if (resolvedStyles?.opacity) {
- ctx.globalAlpha = Number(resolvedStyles?.opacity);
+ ctx.globalAlpha *= Number(resolvedStyles?.opacity);
}
// font/text properties can be expensive to set (not sure why), so only apply if needed (renderText())
@@ -279,8 +279,7 @@ function render(
const currentGlobalAlpha = ctx.globalAlpha;
const fillOpacity = Number(resolvedStyles?.fillOpacity);
- const opacity = Number(resolvedStyles?.opacity);
- ctx.globalAlpha = fillOpacity * opacity;
+ ctx.globalAlpha *= isNaN(fillOpacity) ? 1 : fillOpacity;
ctx.fillStyle = fill;
render.fill(ctx);
@@ -302,7 +301,7 @@ function render(
const strokeOpacity = Number(resolvedStyles?.strokeOpacity);
const opacity = Number(resolvedStyles?.opacity);
if (!isNaN(strokeOpacity) && strokeOpacity !== 1) {
- ctx.globalAlpha = strokeOpacity * (isNaN(opacity) ? 1 : opacity);
+ ctx.globalAlpha *= strokeOpacity;
}
ctx.lineWidth =
diff --git a/packages/layerchart/src/lib/utils/connectorUtils.ts b/packages/layerchart/src/lib/utils/connectorUtils.ts
index 553d8d3a0..3f7adf25d 100644
--- a/packages/layerchart/src/lib/utils/connectorUtils.ts
+++ b/packages/layerchart/src/lib/utils/connectorUtils.ts
@@ -1,4 +1,13 @@
-import { type CurveFactory, line as d3Line, curveLinear } from 'd3-shape';
+import {
+ type CurveFactory,
+ curveLinear,
+ curveStep,
+ curveStepAfter,
+ curveStepBefore,
+ line as d3Line,
+ lineRadial,
+ linkRadial,
+} from 'd3-shape';
export type ConnectorCoords = {
x: number;
@@ -165,3 +174,180 @@ export function getConnectorD3Path({ source, target, sweep, curve }: GetConnecto
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: ConnectorCoords, target: ConnectorCoords): 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 GetConnectorRadialPresetPathProps = {
+ source: ConnectorCoords;
+ target: ConnectorCoords;
+ type: PresetConnectorType;
+ radius: number;
+};
+
+export function getConnectorRadialPresetPath({
+ source,
+ target,
+ type,
+ radius,
+}: GetConnectorRadialPresetPathProps): 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 === '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 GetConnectorRadialD3PathProps = {
+ source: ConnectorCoords;
+ target: ConnectorCoords;
+ curve?: CurveFactory;
+};
+
+export function getConnectorRadialD3Path({
+ source,
+ target,
+ curve,
+}: GetConnectorRadialD3PathProps): 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: ConnectorCoords; target: ConnectorCoords },
+ ConnectorCoords
+ >()
+ .angle((d) => d.x)
+ .radius((d) => d.y);
+ return linkGen({ source, target }) ?? FALLBACK_PATH;
+}