From eacf6c99ab112628f420f35a627f1d655d24530b Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 15 Apr 2026 11:44:37 -0400 Subject: [PATCH 1/7] Improve Tree controls --- .../src/examples/components/Tree/basic.svelte | 5 +- .../components/controls/TreeControls.svelte | 48 ++++++++++++++----- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/src/examples/components/Tree/basic.svelte b/docs/src/examples/components/Tree/basic.svelte index 72c18bc92..4fe32230a 100644 --- a/docs/src/examples/components/Tree/basic.svelte +++ b/docs/src/examples/components/Tree/basic.svelte @@ -24,7 +24,6 @@ radius: 60 }); - let expandedNodeNames = $state(['flare']); const hierarchy = $derived( d3Hierarchy(data, (d) => (expandedNodeNames.includes(d.name) ? d.children : null)) @@ -53,7 +52,7 @@ {#snippet children()} - + - import { Field } from 'svelte-ux'; - import { ToggleGroup, ToggleOption, RangeField } from 'svelte-ux'; + import type { ComponentProps } from 'svelte'; + import { Field, ToggleGroup, ToggleOption, RangeField, MenuField } from 'svelte-ux'; + import CurveMenuField from '$lib/components/controls/fields/CurveMenuField.svelte'; import type { ConnectorSweep, ConnectorType } from '$lib/utils/connectorUtils.js'; - import type { CurveFactory } from 'd3-shape'; - import { curveBumpX, curveBumpY, curveStep, curveStepAfter, curveStepBefore } from 'd3-shape'; - import ConnectorControls from '$lib/components/controls/ConnectorControls.svelte'; interface Props { config: { @@ -12,15 +10,25 @@ layout: 'chart' | 'node'; type: ConnectorType; sweep: ConnectorSweep; - curve: CurveFactory; + curve: ComponentProps['value']; radius: number; }; } let { config = $bindable() }: Props = $props(); + + const typeOptions = ['straight', 'square', 'beveled', 'rounded', 'd3'].map((type) => ({ + label: type, + value: type + })); + + const sweepOptions = ['horizontal-vertical', 'vertical-horizontal', 'none'].map((sweep) => ({ + label: sweep, + value: sweep + })); -
+
Horizontal @@ -35,14 +43,28 @@
-
- + + + + {#if config.type === 'd3'} + + {/if} + {#if config.type === 'beveled' || config.type === 'rounded'} {/if} From 2c0a537909e5d5e7f350cff9eea33e69fcee85e9 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 15 Apr 2026 18:39:09 -0400 Subject: [PATCH 2/7] feat(Tree, Link, Connector): Add radial support --- .changeset/radial-tree-link.md | 17 ++ .../src/examples/components/Tree/basic.svelte | 59 ++++-- .../components/controls/TreeControls.svelte | 19 +- .../src/lib/components/Connector.svelte | 18 ++ .../layerchart/src/lib/components/Link.svelte | 9 + .../layerchart/src/lib/components/Tree.svelte | 10 +- .../src/lib/utils/connectorUtils.ts | 182 +++++++++++++++++- 7 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 .changeset/radial-tree-link.md diff --git a/.changeset/radial-tree-link.md b/.changeset/radial-tree-link.md new file mode 100644 index 000000000..6c88ce805 --- /dev/null +++ b/.changeset/radial-tree-link.md @@ -0,0 +1,17 @@ +--- +'layerchart': minor +--- + +feat(Tree, Link, Connector): Add radial support + +`Tree` now detects `` and lays out with `d3.tree().size([2π, min(width, height)/2])` plus radial separation. Nodes emit polar coords (`x` = angle, `y` = radius). + +`Connector` gains a `radial` prop (defaults to `ctx.radial`) that interprets `source`/`target` as polar and dispatches to new `getConnectorRadialPresetPath` / `getConnectorRadialD3Path` helpers. Radial behavior per connector `type`: + +- `straight` — straight cartesian line +- `square` — radial → arc at midR → radial +- `beveled` — chord at source radius with chamfered corner (controlled by `radius`) +- `rounded` — visx LinkRadialCurve Bezier +- `d3` — `d3.linkRadial` by default; with a `curve` prop, `curveStep` / `curveStepBefore` / `curveStepAfter` map to polar arcs/radials, other curves go through `d3.lineRadial` + +`Link` forwards `radial` to `Connector` automatically when inside a radial ``. diff --git a/docs/src/examples/components/Tree/basic.svelte b/docs/src/examples/components/Tree/basic.svelte index 4fe32230a..d90582c83 100644 --- a/docs/src/examples/components/Tree/basic.svelte +++ b/docs/src/examples/components/Tree/basic.svelte @@ -16,7 +16,7 @@ import type { ConnectorSweep, ConnectorType } from '$lib/utils/connectorUtils.js'; let config = $state({ - orientation: 'horizontal' as 'horizontal' | 'vertical', + orientation: 'horizontal' as 'horizontal' | 'vertical' | 'radial', layout: 'chart' as 'chart' | 'node', type: 'd3' as ConnectorType, sweep: 'none' as ConnectorSweep, @@ -52,7 +52,10 @@ {#snippet children({ nodes, links })} - {#each links as link (getNodeKey(link.source) + '_' + getNodeKey(link.target))} - - {/each} + + {#each links as link (getNodeKey(link.source) + '_' + getNodeKey(link.target))} + + {/each} - {#each nodes as node (getNodeKey(node))} - { if (expandedNodeNames.includes(node.data.name)) { expandedNodeNames = expandedNodeNames.filter((name) => name !== node.data.name); @@ -128,6 +144,7 @@ /> {/each} + {/snippet} diff --git a/docs/src/lib/components/controls/TreeControls.svelte b/docs/src/lib/components/controls/TreeControls.svelte index bfc5178b8..fb5df4453 100644 --- a/docs/src/lib/components/controls/TreeControls.svelte +++ b/docs/src/lib/components/controls/TreeControls.svelte @@ -6,7 +6,7 @@ interface Props { config: { - orientation: 'horizontal' | 'vertical'; + orientation: 'horizontal' | 'vertical' | 'radial'; layout: 'chart' | 'node'; type: ConnectorType; sweep: ConnectorSweep; @@ -33,6 +33,7 @@ Horizontal Vertical + Radial @@ -53,14 +54,6 @@ classes={{ menuIcon: 'hidden' }} /> - - {#if config.type === 'd3'} {/if} @@ -68,4 +61,12 @@ {#if config.type === 'beveled' || config.type === 'rounded'} {/if} + +
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/connectorUtils.ts b/packages/layerchart/src/lib/utils/connectorUtils.ts index 553d8d3a0..89f9e4220 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,174 @@ 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') { + // 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. + 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; +} From 9a5d15818b5e5b2a98429e2fd8dfd9981a07fb79 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 16 Apr 2026 09:30:16 -0400 Subject: [PATCH 3/7] fix(canvas): Compose globalAlpha multiplicatively so Group opacity propagates to children --- .changeset/canvas-group-opacity.md | 9 +++ ...nherited-globalAlpha--Group-opacity--1.png | Bin 0 -> 2721 bytes ...nherited-globalAlpha--Group-opacity--2.png | Bin 0 -> 2721 bytes .../src/lib/utils/canvas.svelte.test.ts | 70 ++++++++++++++++++ packages/layerchart/src/lib/utils/canvas.ts | 7 +- 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 .changeset/canvas-group-opacity.md create mode 100644 packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--1.png create mode 100644 packages/layerchart/src/lib/utils/__screenshots__/canvas.svelte.test.ts/renderPathData-composes-element-opacity-with-inherited-globalAlpha--Group-opacity--2.png diff --git a/.changeset/canvas-group-opacity.md b/.changeset/canvas-group-opacity.md new file mode 100644 index 000000000..e7bd6e521 --- /dev/null +++ b/.changeset/canvas-group-opacity.md @@ -0,0 +1,9 @@ +--- +'layerchart': patch +--- + +fix(canvas): Compose globalAlpha multiplicatively so Group opacity propagates to children + +Canvas `renderPathData` was overwriting `ctx.globalAlpha` with absolute values for element opacity, fill opacity, and stroke opacity. This meant a parent `` had no effect on child marks rendered on canvas — the child's own opacity (defaulting to 1) would replace the inherited value. + +Now all three sites multiply against the current `globalAlpha`, which correctly composes with ancestor Group opacity set via `save()`/`restore()` scoping. Also removes double-application of element `opacity` inside the fill/stroke blocks (it's already applied at the element level). 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 0000000000000000000000000000000000000000..6054086f9d0f3e0b33071c33aac6da738c605b6c GIT binary patch literal 2721 zcmeAS@N?(olHy`uVBq!ia0y~yVDx2RV7kD;1QeOwv~@BA16P=*i(^Q|oHuuU!-S(5 z+%9^bVTt*-bo+Jg{W5V7TIKgG@5CgHE$2@C4QjjL@zaCH5fBgD7qe$+${}q3$_Eee1?U8V}*ne*Mq5FGwu9MZcb+6`5 zP9EQf^sQGP+T^_ddp4|n(b@I$H(XVGzWrQo>4}^F9u{x2+LH7CyI{1e*0%cU0=vE5 zvSE3jx3e!_YqwvAea+2p^8NlR&$IvNmuKJL_y1Lm+46rsKmYia%aFY}{`usWNw+6h zt+|x;zJ5nxtZZ1;`{$nvpYPhWmT&c^3Fp$cS*&%OFhR}+2ZzSh{~ zltxTGUS_xXY9jBvYd?3~eRYqSpZnX2+Pe4LIeGV(>kADVT&s$nKmKO>A^$#K!o+zs zdG|M7OfN^FOz-s@&{JV+3@-1pHI8j@~M}6I%s3N`u3}WsPlre*YYhd{Z#z>Ojx^c)iRJr z=N#GGml7@O_h={c=Bp2X%=>!B?so3i)29ndO&gq}9iDHtERB}!OYl5gaqrb-PdSSf zs-%wk;tlPVG+vmj9hxc9k!!40pdgJ`8 z`RCi$@>NGxJ~xh*{ik&i8b9?xI~@;|G|aybj-KC6=1w#3Guz)k&ADT75h!*Zn(sUZ z)VE*j-hm5Y?Y|e9YXQ?--=m$i$HUs?FM;A=o$L9x*4wPUfFj`ukO>Ub=g)Jjb>}9Yx&CJv~z2UExgk|Sjo(>o$EaR+(Q577nT>lxNQ05rOdAqzFMnx zyHVlMFd0n{qq$+UC>SjsN9zX5FMrt?82o+Jg{W5V7TIKgG@5CgHE$2@C4QjjL@zaCH5fBgD7qe$+${}q3$_Eee1?U8V}*ne*Mq5FGwu9MZcb+6`5 zP9EQf^sQGP+T^_ddp4|n(b@I$H(XVGzWrQo>4}^F9u{x2+LH7CyI{1e*0%cU0=vE5 zvSE3jx3e!_YqwvAea+2p^8NlR&$IvNmuKJL_y1Lm+46rsKmYia%aFY}{`usWNw+6h zt+|x;zJ5nxtZZ1;`{$nvpYPhWmT&c^3Fp$cS*&%OFhR}+2ZzSh{~ zltxTGUS_xXY9jBvYd?3~eRYqSpZnX2+Pe4LIeGV(>kADVT&s$nKmKO>A^$#K!o+zs zdG|M7OfN^FOz-s@&{JV+3@-1pHI8j@~M}6I%s3N`u3}WsPlre*YYhd{Z#z>Ojx^c)iRJr z=N#GGml7@O_h={c=Bp2X%=>!B?so3i)29ndO&gq}9iDHtERB}!OYl5gaqrb-PdSSf zs-%wk;tlPVG+vmj9hxc9k!!40pdgJ`8 z`RCi$@>NGxJ~xh*{ik&i8b9?xI~@;|G|aybj-KC6=1w#3Guz)k&ADT75h!*Zn(sUZ z)VE*j-hm5Y?Y|e9YXQ?--=m$i$HUs?FM;A=o$L9x*4wPUfFj`ukO>Ub=g)Jjb>}9Yx&CJv~z2UExgk|Sjo(>o$EaR+(Q577nT>lxNQ05rOdAqzFMnx zyHVlMFd0n{qq$+UC>SjsN9zX5FMrt?82 { 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 = From b952af9e4068bf13a437a3a24f2541f152c9d646 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 16 Apr 2026 10:04:52 -0400 Subject: [PATCH 4/7] Rename "basic" example to "playground", improve playground, and add new basic --- docs/src/content/components/Tree.md | 6 +- docs/src/examples/catalog/Tree.json | 67 +++++- .../src/examples/components/Tree/basic.svelte | 191 +++++------------- .../components/Tree/playground.svelte | 160 +++++++++++++++ .../components/controls/TreeControls.svelte | 12 ++ 5 files changed, 284 insertions(+), 152 deletions(-) create mode 100644 docs/src/examples/components/Tree/playground.svelte diff --git a/docs/src/content/components/Tree.md b/docs/src/content/components/Tree.md index 0cf8cdf8b..a8ab8c159 100644 --- a/docs/src/content/components/Tree.md +++ b/docs/src/content/components/Tree.md @@ -7,4 +7,8 @@ related: [] ## Usage -:example{ name="basic" } +:example{ name="basic" showCode } + +### Playground + +:example{ name="playground" } diff --git a/docs/src/examples/catalog/Tree.json b/docs/src/examples/catalog/Tree.json index 8319f72ae..1b2ec54dd 100644 --- a/docs/src/examples/catalog/Tree.json +++ b/docs/src/examples/catalog/Tree.json @@ -8,37 +8,79 @@ "components": [ { "component": "Chart", - "lineNumber": 55, + "lineNumber": 25, "line": "" }, { "component": "Link", + "lineNumber": 30, + "line": "" + }, + { + "component": "Link", + "lineNumber": 85, "line": " - import { getFlare } from '$lib/data.remote'; - let data = await getFlare(); - - - - - - - {#snippet children()} - - - + + + {#snippet children({ nodes, links })} - - - {#each links as link (getNodeKey(link.source) + '_' + getNodeKey(link.target))} - - {/each} - - {#each nodes as node (getNodeKey(node))} - {@const nodeX = - config.orientation === 'radial' - ? node.y * Math.sin(node.x) - : config.orientation === 'horizontal' - ? node.y - : node.x} - {@const nodeY = - config.orientation === 'radial' - ? -node.y * Math.cos(node.x) - : config.orientation === 'horizontal' - ? node.x - : node.y} - { - if (expandedNodeNames.includes(node.data.name)) { - expandedNodeNames = expandedNodeNames.filter((name) => name !== node.data.name); - } else { - expandedNodeNames = [...expandedNodeNames, node.data.name]; - } - selected = node; - - // transform.zoomTo({ - // x: orientation === 'horizontal' ? selected.y : selected.x, - // y: orientation === 'horizontal' ? selected.x : selected.y, - // }); - }} - class={cls(node.data.children && 'cursor-pointer')} - > - - - - {/each} + {#each links as link} + + {/each} + + {#each nodes as node} + + + - + {/each} {/snippet} - {/snippet} + diff --git a/docs/src/examples/components/Tree/playground.svelte b/docs/src/examples/components/Tree/playground.svelte new file mode 100644 index 000000000..8d7cac691 --- /dev/null +++ b/docs/src/examples/components/Tree/playground.svelte @@ -0,0 +1,160 @@ + + + + + + + + {#snippet children()} + + + + {#snippet children({ nodes, links })} + + + + {#each links as link (getNodeKey(link.source) + '_' + getNodeKey(link.target))} + + {/each} + + + {#each nodes as node (getNodeKey(node))} + {@const nodeX = + config.orientation === 'radial' + ? node.y * Math.sin(node.x) + : config.orientation === 'horizontal' + ? node.y + : node.x} + {@const nodeY = + config.orientation === 'radial' + ? -node.y * Math.cos(node.x) + : config.orientation === 'horizontal' + ? node.x + : node.y} + { + if (expandedNodeNames.includes(node.data.name)) { + expandedNodeNames = expandedNodeNames.filter((name) => name !== node.data.name); + } else { + expandedNodeNames = [...expandedNodeNames, node.data.name]; + } + selected = node; + + // transform.zoomTo({ + // x: orientation === 'horizontal' ? selected.y : selected.x, + // y: orientation === 'horizontal' ? selected.x : selected.y, + // }); + }} + class={cls(node.data.children && 'cursor-pointer')} + > + + + + {/each} + + + {/snippet} + + {/snippet} + diff --git a/docs/src/lib/components/controls/TreeControls.svelte b/docs/src/lib/components/controls/TreeControls.svelte index fb5df4453..52426d210 100644 --- a/docs/src/lib/components/controls/TreeControls.svelte +++ b/docs/src/lib/components/controls/TreeControls.svelte @@ -12,6 +12,9 @@ sweep: ConnectorSweep; curve: ComponentProps['value']; radius: number; + siblingGap: number; + parentGap: number; + angularSpacing: number; }; } @@ -70,3 +73,12 @@ classes={{ menuIcon: 'hidden' }} />
+ +
+ + {#if config.orientation === 'radial'} + + {:else} + + {/if} +
From be5df295c998e3f19fc4f16e7b275f00eec65d66 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 16 Apr 2026 10:05:29 -0400 Subject: [PATCH 5/7] Show error message when an example fails to render --- docs/src/lib/components/Example.svelte | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/src/lib/components/Example.svelte b/docs/src/lib/components/Example.svelte index 2ec4980d2..12e10c8cb 100644 --- a/docs/src/lib/components/Example.svelte +++ b/docs/src/lib/components/Example.svelte @@ -224,6 +224,15 @@ {#if isVisible} + {#snippet failed(error, reset)} +
+
+ Example error{#if component && name}: {component}/{name}{/if} +
+
{error}
+ +
+ {/snippet} {#snippet pending()}
Loading... From 05412b933c8dd58485ad513c254d8d19e0896656 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 16 Apr 2026 12:28:52 -0400 Subject: [PATCH 6/7] improve root node links for step curves --- packages/layerchart/src/lib/utils/connectorUtils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/layerchart/src/lib/utils/connectorUtils.ts b/packages/layerchart/src/lib/utils/connectorUtils.ts index 89f9e4220..3f7adf25d 100644 --- a/packages/layerchart/src/lib/utils/connectorUtils.ts +++ b/packages/layerchart/src/lib/utils/connectorUtils.ts @@ -254,6 +254,8 @@ export function getConnectorRadialPresetPath({ } 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; @@ -304,6 +306,10 @@ export function getConnectorRadialD3Path({ 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; From b49e6d741e9a7c5505cef4383bbb22047185ccd8 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 16 Apr 2026 13:13:34 -0400 Subject: [PATCH 7/7] Improve tree playground example --- .../components/Connector/playground.svelte | 2 +- .../components/Tree/playground.svelte | 59 ++++++++++++++----- .../controls/ConnectorControls.svelte | 4 +- .../components/controls/TreeControls.svelte | 46 +++++++++++++-- docs/src/lib/data.remote.ts | 6 ++ .../data/examples/hierarchy/simple-tree.json | 36 +++++++++++ 6 files changed, 130 insertions(+), 23 deletions(-) create mode 100644 docs/static/data/examples/hierarchy/simple-tree.json diff --git a/docs/src/examples/components/Connector/playground.svelte b/docs/src/examples/components/Connector/playground.svelte index c887aaef3..c774c87cc 100644 --- a/docs/src/examples/components/Connector/playground.svelte +++ b/docs/src/examples/components/Connector/playground.svelte @@ -10,7 +10,7 @@ let source = $state({ x: 300, y: 150 }); let target = $state({ x: 500, y: 300 }); - let type: ConnectorType = $state('rounded'); + let type: ConnectorType = $state('d3'); let curve: ComponentProps['value'] = $state(undefined); let sweep: ConnectorSweep = $state('horizontal-vertical'); let radius = $state(60); diff --git a/docs/src/examples/components/Tree/playground.svelte b/docs/src/examples/components/Tree/playground.svelte index 8d7cac691..e8f3e2a23 100644 --- a/docs/src/examples/components/Tree/playground.svelte +++ b/docs/src/examples/components/Tree/playground.svelte @@ -1,6 +1,6 @@ - + + {@const isRoot = node.depth === 0} + {@const isExpanded = expandedNodeNames.includes(node.data.name)} diff --git a/docs/src/lib/components/controls/ConnectorControls.svelte b/docs/src/lib/components/controls/ConnectorControls.svelte index 06b997d8d..a73b99803 100644 --- a/docs/src/lib/components/controls/ConnectorControls.svelte +++ b/docs/src/lib/components/controls/ConnectorControls.svelte @@ -12,13 +12,13 @@ } let { - type = $bindable('rounded' as ConnectorType), + type = $bindable('d3' as ConnectorType), curve = $bindable(undefined as ComponentProps['value']), sweep = $bindable('horizontal-vertical' as ConnectorSweep), radius = $bindable(60) }: Props = $props(); - const typeOptions = ['straight', 'square', 'beveled', 'rounded', 'd3'].map((type) => ({ + const typeOptions = ['d3', 'straight', 'square', 'beveled', 'rounded'].map((type) => ({ label: type, value: type })); diff --git a/docs/src/lib/components/controls/TreeControls.svelte b/docs/src/lib/components/controls/TreeControls.svelte index 52426d210..f3f14b83e 100644 --- a/docs/src/lib/components/controls/TreeControls.svelte +++ b/docs/src/lib/components/controls/TreeControls.svelte @@ -5,6 +5,8 @@ import type { ConnectorSweep, ConnectorType } from '$lib/utils/connectorUtils.js'; interface Props { + dataset?: string; + datasetOptions?: { label: string; value: string }[]; config: { orientation: 'horizontal' | 'vertical' | 'radial'; layout: 'chart' | 'node'; @@ -18,9 +20,9 @@ }; } - let { config = $bindable() }: Props = $props(); + let { dataset = $bindable(), datasetOptions, config = $bindable() }: Props = $props(); - const typeOptions = ['straight', 'square', 'beveled', 'rounded', 'd3'].map((type) => ({ + const typeOptions = ['d3', 'straight', 'square', 'beveled', 'rounded'].map((type) => ({ label: type, value: type })); @@ -31,7 +33,21 @@ })); -
+
+ {#if datasetOptions} + + {/if} + Horizontal @@ -75,10 +91,28 @@
- + {#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" } + ] + } + ] +}