Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/canvas-group-opacity.md
Original file line number Diff line number Diff line change
@@ -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 `<Group opacity={0.2}>` 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).
17 changes: 17 additions & 0 deletions .changeset/radial-tree-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'layerchart': minor
---

feat(Tree, Link, Connector): Add radial support

`Tree` now detects `<Chart radial>` 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 `<Chart>`.
6 changes: 5 additions & 1 deletion docs/src/content/components/Tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,8 @@ related: []

## Usage

:example{ name="basic" }
:example{ name="basic" showCode }

### Playground

:example{ name="playground" }
67 changes: 58 additions & 9 deletions docs/src/examples/catalog/Tree.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,79 @@
"components": [
{
"component": "Chart",
"lineNumber": 55,
"lineNumber": 25,
"line": "<Chart"
},
{
"component": "Tree",
"lineNumber": 67,
"lineNumber": 27,
"line": "<Tree"
},
{
"component": "Layer",
"lineNumber": 73,
"lineNumber": 26,
"line": "<Layer>"
},
{
"component": "Link",
"lineNumber": 30,
"line": "<Link"
},
{
"component": "Group",
"lineNumber": 38,
"line": "<Group"
},
{
"component": "Rect",
"lineNumber": 39,
"line": "<Rect"
},
{
"component": "Text",
"lineNumber": 48,
"line": "<Text"
}
]
},
{
"name": "playground",
"title": "playground",
"path": "/docs/components/Tree/playground",
"components": [
{
"component": "Chart",
"lineNumber": 60,
"line": "<Chart"
},
{
"component": "Tree",
"lineNumber": 75,
"line": "<Tree"
},
{
"component": "Layer",
"lineNumber": 81,
"line": "<Layer>"
},
{
"component": "Link",
"lineNumber": 85,
"line": "<Link"
},
{
"component": "Group",
"lineNumber": 88,
"lineNumber": 98,
"line": "<Group"
},
{
"component": "Rect",
"lineNumber": 107,
"lineNumber": 130,
"line": "<Rect"
},
{
"component": "Text",
"lineNumber": 118,
"lineNumber": 141,
"line": "<Text"
}
]
Expand Down Expand Up @@ -81,7 +123,14 @@
"example": "basic",
"component": "Tree",
"path": "/docs/components/Tree/basic",
"lineNumber": 67,
"lineNumber": 27,
"line": "<Tree"
},
{
"example": "playground",
"component": "Tree",
"path": "/docs/components/Tree/playground",
"lineNumber": 75,
"line": "<Tree"
},
{
Expand All @@ -92,5 +141,5 @@
"line": "<Tree"
}
],
"updatedAt": "2026-04-12T22:48:27.082Z"
}
"updatedAt": "2026-04-16T00:00:00.000Z"
}
2 changes: 1 addition & 1 deletion docs/src/examples/components/Connector/playground.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CurveMenuField>['value'] = $state(undefined);
let sweep: ConnectorSweep = $state('horizontal-vertical');
let radius = $state(60);
Expand Down
175 changes: 49 additions & 126 deletions docs/src/examples/components/Tree/basic.svelte
Original file line number Diff line number Diff line change
@@ -1,136 +1,59 @@
<script module lang="ts">
import { getFlare } from '$lib/data.remote';
let data = await getFlare();
</script>

<script lang="ts">
import type { ComponentProps } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { hierarchy as d3Hierarchy, type HierarchyNode } from 'd3-hierarchy';
import { curveBumpX } from 'd3-shape';

import { Chart, Group, Link, Layer, Rect, Text, Tree } from 'layerchart';
import TransformContextControls from '$lib/components/controls/TransformContextControls.svelte';
import TreeControls from '$lib/components/controls/TreeControls.svelte';
import { cls } from '@layerstack/tailwind';
import type { ConnectorSweep, ConnectorType } from '$lib/utils/connectorUtils.js';

let config = $state({
orientation: 'horizontal' as 'horizontal' | 'vertical',
layout: 'chart' as 'chart' | 'node',
type: 'd3' as ConnectorType,
sweep: 'none' as ConnectorSweep,
curve: curveBumpX,
radius: 60
<script>
import { Chart, Group, Layer, Link, Rect, Text, Tree } from 'layerchart';
import { hierarchy } from 'd3-hierarchy';

const data = hierarchy({
name: 'Root',
children: [
{
name: 'A',
children: [{ name: 'A1' }, { name: 'A2' }, { name: 'A3' }]
},
{
name: 'B',
children: [{ name: 'B1' }, { name: 'B2' }]
},
{ name: 'C' }
]
});
export { data };


let expandedNodeNames = $state(['flare']);
const hierarchy = $derived(
d3Hierarchy(data, (d) => (expandedNodeNames.includes(d.name) ? d.children : null))
);
// .sum((d) => d.value)
// .sort(sortFunc('value', 'desc'));
let selected = $state();

function getNodeKey(node: HierarchyNode<{ name: string }>) {
return node.data.name + node.depth;
}

const nodeWidth = 120;
const nodeWidth = 60;
const nodeHeight = 20;
const nodeSiblingGap = 20;
const nodeParentGap = 100;
const nodeSize = $derived(
config.orientation === 'horizontal'
? ([nodeHeight + nodeSiblingGap, nodeWidth + nodeParentGap] as [number, number])
: ([nodeWidth + nodeSiblingGap, nodeHeight + nodeParentGap] as [number, number])
);

export { data };
</script>

<TreeControls bind:config />

<Chart
padding={{ top: 24, left: nodeWidth / 2, right: nodeWidth / 2 }}
transform={{
mode: 'canvas',
motion: { type: 'tween', duration: 800, easing: cubicOut }
}}
clip
height={800}
>
{#snippet children()}
<TransformContextControls orientation="horizontal" class="-m-2" />

<Tree
{hierarchy}
orientation={config.orientation}
nodeSize={config.layout === 'node' ? nodeSize : undefined}
>
<Chart padding={{ top: 16, left: nodeWidth / 2, right: nodeWidth / 2 }} height={300}>
<Layer>
<Tree hierarchy={data} orientation="horizontal">
{#snippet children({ nodes, links })}
<Layer>
{#each links as link (getNodeKey(link.source) + '_' + getNodeKey(link.target))}
<Link
data={link}
orientation={config.orientation}
curve={config.curve}
type={config.type}
sweep={config.sweep}
radius={config.radius}
motion="tween"
class="stroke-surface-content opacity-20"
{#each links as link}
<Link data={link} orientation="horizontal" class="stroke-surface-content opacity-20" />
{/each}

{#each nodes as node}
<Group x={node.y - nodeWidth / 2} y={node.x - nodeHeight / 2}>
<Rect
width={nodeWidth}
height={nodeHeight}
class={node.data.children
? 'fill-surface-100 stroke-primary'
: 'fill-surface-100 stroke-secondary [stroke-dasharray:1]'}
rx={10}
/>
{/each}

{#each nodes as node (getNodeKey(node))}
<Group
x={(config.orientation === 'horizontal' ? node.y : node.x) - nodeWidth / 2}
y={(config.orientation === 'horizontal' ? node.x : node.y) - nodeHeight / 2}
motion="tween"
onclick={() => {
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')}
>
<Rect
width={nodeWidth}
height={nodeHeight}
class={cls(
'fill-surface-100',
node.data.children
? 'stroke-primary hover:stroke-2'
: 'stroke-secondary [stroke-dasharray:1]'
)}
rx={10}
/>
<Text
value={node.data.name}
x={nodeWidth / 2}
y={nodeHeight / 2}
dy={-2}
textAnchor="middle"
verticalAnchor="middle"
class={cls(
'text-xs pointer-events-none',
node.data.children ? 'fill-primary' : 'fill-secondary'
)}
/>
</Group>
{/each}
</Layer>
<Text
value={node.data.name}
x={nodeWidth / 2}
y={nodeHeight / 2}
dy={-2}
textAnchor="middle"
verticalAnchor="middle"
class="text-xs pointer-events-none {node.data.children
? 'fill-primary'
: 'fill-secondary'}"
/>
</Group>
{/each}
{/snippet}
</Tree>
{/snippet}
</Layer>
</Chart>
Loading
Loading