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
19 changes: 19 additions & 0 deletions .changeset/add-dodge-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'layerchart': minor
---

feat(Dodge): Add Dodge component for deterministic non-overlapping layout

A new composition component (similar to `ForceSimulation`) that packs items along one axis to avoid overlaps. Modeled after [Observable Plot's `dodge` transform](https://observablehq.com/plot/transforms/dodge):

- `axis`: `'x'` or `'y'` — which axis to dodge along (default `'y'`)
- `anchor`: `'top'`/`'middle'`/`'bottom'` (for `axis='y'`) or `'left'`/`'middle'`/`'right'` (for `axis='x'`) — controls which edge items grow away from
- `padding`: minimum px gap between items
- `r`: collision radius per item (constant or accessor). When omitted, falls back to the chart's `r` accessor / `rScale` (matching `Points`), then to a default of `5`.
- `position`: override the anchor-axis pixel accessor (defaults to chart's `xGet`/`yGet`)

Yields each item's computed pixel `x`/`y` (and original `index`) via the children snippet, so you can render with any primitive (`Circle`, `Text`, etc.).

Also includes a `rowHeight` mode that switches from circular to row-based rectangular packing — useful for text labels where circular collision would produce unnecessarily large vertical gaps. The pure `dodge()` algorithm is exported from `Dodge.shared.svelte.ts` for direct use.

Algorithm modeled after Observable Plot / SveltePlot: maintains an interval tree of placed items keyed by anchor-axis extent, queries it for items in the new item's collision zone, and builds candidate dodge-axis positions from circle-tangency math. Currently implemented as a linear-scan tracker with the same API; can be swapped for a real interval tree without API changes if profiling demands it.
5 changes: 5 additions & 0 deletions .changeset/image-pointer-events-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Image): Stop disabling pointer events by default
23 changes: 23 additions & 0 deletions .changeset/primitives-inherit-chart-accessors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'layerchart': minor
---

feat(Circle, Text): Inherit chart accessors by default in data mode

`<Circle>` and `<Text>` now fall back to the chart's `x`/`y`/`r` accessors (via `xGet`/`yGet`/`rGet`) when the corresponding position prop is omitted — matching how `<Points>` and the new `<Dodge>` work. This lets the chart be the single source of truth for `x`/`y`/`r` and removes the boilerplate of repeating those props on every primitive:

```svelte
<!-- Before -->
<Chart {data} x="date" y="value" r="size" rRange={[2, 10]}>
<Circle {data} cx="date" cy="value" r="size" />
</Chart>

<!-- After -->
<Chart {data} x="date" y="value" r="size" rRange={[2, 10]}>
<Circle {data} />
</Chart>
```

`Circle` and `Text` also now enter data mode when `data` is explicitly passed (in addition to the existing trigger when `cx`/`cy`/`x`/`y` are data-driven), so the implicit-accessor pattern works without needing to pass redundant string accessors just to trigger iteration.

Behavior is unchanged whenever any position prop is set explicitly — the hardcoded defaults (0/0/1) only apply when neither prop nor chart-level config is present. All existing usages in the docs pass explicit position props, so this is purely additive.
17 changes: 17 additions & 0 deletions .changeset/text-fontsize-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'layerchart': minor
---

feat(Text): Add `fontSize` prop with auto-derived `capHeight`

Adds a typed `fontSize?: number | string` prop on `<Text>` (number = pixels, string passes through). When set, `capHeight` defaults to `fontSize * 0.71` instead of the legacy `'0.71em'` — so per-item scaled labels with `verticalAnchor="middle"` align to a common visual baseline without an explicit `capHeight` override.

Previously, `getPixelValue` resolved `'0.71em'` against a hard-coded 16px, so vertical centering was only correct for ~16px text. Larger labels sat too low, smaller ones too high — visible on text-driven beeswarms or any caller scaling labels per-element.

```svelte
<!-- Before: needed both font-size and capHeight to center correctly -->
<Text font-size={r * 1.4} capHeight="{r * 1.4 * 0.71}px" verticalAnchor="middle" ... />

<!-- After: one prop, centering handled automatically -->
<Text fontSize={r * 1.4} verticalAnchor="middle" ... />
```
211 changes: 211 additions & 0 deletions bundle-analyzer/bundle-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,12 @@ export const scenarios: Scenario[] = [
description: 'Circle packing layout',
imports: ['Chart', 'Svg', 'Pack', 'Circle', 'Text'],
},
{
name: 'dodge',
group: 'Hierarchy',
description: 'Dodge non-overlapping packing (deterministic alternative to ForceSimulation)',
imports: ['Chart', 'Svg', 'Dodge', 'Circle'],
},

// --- Graph / network ---
{
Expand Down Expand Up @@ -1731,6 +1737,210 @@ export const scenarios: Scenario[] = [
layers: { GeoEdgeFade: 'canvas' },
},

// Layer-agnostic primitive (Blur — has all three layer variants).
{
name: 'Blur',
group: 'Components',
description: 'Standalone Blur (agnostic) — baseline',
imports: ['Blur'],
},
{
name: 'Blur.svg',
group: 'Components',
description: 'Standalone Blur from `layerchart/svg`',
imports: ['Blur'],
layers: { Blur: 'svg' },
},
{
name: 'Blur.canvas',
group: 'Components',
description: 'Standalone Blur from `layerchart/canvas`',
imports: ['Blur'],
layers: { Blur: 'canvas' },
},
{
name: 'Blur.html',
group: 'Components',
description: 'Standalone Blur from `layerchart/html`',
imports: ['Blur'],
layers: { Blur: 'html' },
},

// Ribbon (svg + canvas only).
{
name: 'Ribbon',
group: 'Components',
description: 'Standalone Ribbon (agnostic) — baseline',
imports: ['Ribbon'],
},
{
name: 'Ribbon.svg',
group: 'Components',
description: 'Standalone Ribbon from `layerchart/svg`',
imports: ['Ribbon'],
layers: { Ribbon: 'svg' },
},
{
name: 'Ribbon.canvas',
group: 'Components',
description: 'Standalone Ribbon from `layerchart/canvas`',
imports: ['Ribbon'],
layers: { Ribbon: 'canvas' },
},

// Layout components (single agnostic file each — they don't render their
// own marks; consumers compose primitives in the children snippet).
{
name: 'Dodge',
group: 'Components',
description: 'Standalone Dodge — baseline',
imports: ['Dodge'],
},
{
name: 'ForceSimulation',
group: 'Components',
description: 'Standalone ForceSimulation — baseline',
imports: ['ForceSimulation'],
},
{
name: 'Pack',
group: 'Components',
description: 'Standalone Pack — baseline',
imports: ['Pack'],
},
{
name: 'Tree',
group: 'Components',
description: 'Standalone Tree — baseline',
imports: ['Tree'],
},
{
name: 'Treemap',
group: 'Components',
description: 'Standalone Treemap — baseline',
imports: ['Treemap'],
},
{
name: 'Partition',
group: 'Components',
description: 'Standalone Partition — baseline',
imports: ['Partition'],
},
{
name: 'Chord',
group: 'Components',
description: 'Standalone Chord — baseline',
imports: ['Chord'],
},
{
name: 'Dagre',
group: 'Components',
description: 'Standalone Dagre — baseline',
imports: ['Dagre'],
},
{
name: 'Sankey',
group: 'Components',
description: 'Standalone Sankey — baseline',
imports: ['Sankey'],
},

// Geo helpers (no per-layer rendering).
{
name: 'GeoLegend',
group: 'Components',
description: 'Standalone GeoLegend — baseline',
imports: ['GeoLegend'],
},
{
name: 'GeoProjection',
group: 'Components',
description: 'Standalone GeoProjection — baseline',
imports: ['GeoProjection'],
},
{
name: 'GeoRaster',
group: 'Components',
description: 'Standalone GeoRaster — baseline',
imports: ['GeoRaster'],
},
{
name: 'GeoVisible',
group: 'Components',
description: 'Standalone GeoVisible — baseline',
imports: ['GeoVisible'],
},

// Interaction / context wrappers.
{
name: 'Tooltip',
group: 'Components',
description: 'Standalone Tooltip — baseline',
imports: ['Tooltip'],
},
{
name: 'BrushContext',
group: 'Components',
description: 'Standalone BrushContext — baseline',
imports: ['BrushContext'],
},
{
name: 'TransformContext',
group: 'Components',
description: 'Standalone TransformContext — baseline',
imports: ['TransformContext'],
},
{
name: 'MotionPath',
group: 'Components',
description: 'Standalone MotionPath — baseline',
imports: ['MotionPath'],
},

// Utility / decoration components.
{
name: 'Layer',
group: 'Components',
description: 'Standalone Layer — baseline',
imports: ['Layer'],
},
{
name: 'Legend',
group: 'Components',
description: 'Standalone Legend — baseline',
imports: ['Legend'],
},
{
name: 'CircleLegend',
group: 'Components',
description: 'Standalone CircleLegend — baseline',
imports: ['CircleLegend'],
},
{
name: 'ColorRamp',
group: 'Components',
description: 'Standalone ColorRamp — baseline',
imports: ['ColorRamp'],
},
{
name: 'Bounds',
group: 'Components',
description: 'Standalone Bounds — baseline',
imports: ['Bounds'],
},
{
name: 'Point',
group: 'Components',
description: 'Standalone Point — baseline',
imports: ['Point'],
},
{
name: 'WebGL',
group: 'Components',
description: 'Standalone WebGL — baseline',
imports: ['WebGL'],
},

// --- Worst case ---
{
name: 'all',
Expand Down Expand Up @@ -1776,6 +1986,7 @@ const INDIVIDUAL_COMPONENTS: string[] = [
'Contour',
'Dagre',
'Density',
'Dodge',
'Ellipse',
'ForceSimulation',
'Frame',
Expand Down
78 changes: 78 additions & 0 deletions docs/src/content/components/Dodge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
description: Layout transform that packs items along one axis to avoid overlaps, deterministically.
category: layout
layers: [svg, canvas, html]
related: [ForceSimulation, Points, Circle]
---

The **Dodge** component repositions items along one axis so they don't overlap, given their positions on the other axis. Unlike [`ForceSimulation`](/docs/components/ForceSimulation), it's deterministic — the same input always produces the same layout — and faster to compute. Modeled after [Observable Plot's `dodge` transform](https://observablehq.com/plot/transforms/dodge).

It's a non-rendering composition component: pass it your data and a few layout props, and it yields each item's computed pixel `x`/`y` (and resolved `r`) via the children snippet for you to render however you want — typically with `<Circle>`, but `<Image>`, `<Rect>`, or `<Text>` work too.

## Algorithm

The packing uses a greedy O(n log n) interval-tree-based algorithm: for each item in input order, the candidate positions along the dodge axis are computed from the tangency equation against any horizontally-overlapping placed items, and the candidate closest to the anchor is chosen. Layout is **stable for a given input** — there's no animation or jitter.

## Axis & anchor

`axis` selects which dimension Dodge computes. Items are anchored on the _other_ axis (their natural data position) and stacked along the dodge axis to avoid overlaps.

`anchor` selects which edge items grow away from along the dodge axis:

| `axis` | Valid `anchor` values | Default |
| ------ | ------------------------------- | ---------- |
| `'x'` | `'left'`, `'middle'`, `'right'` | `'left'` |
| `'y'` | `'top'`, `'middle'`, `'bottom'` | `'bottom'` |

:example{ name="anchor" }

A classic 1-D beeswarm is `axis="y"` + `anchor="middle"` — items spread symmetrically around the chart's vertical center.

:example{ name="beeswarm" }

## Sizing (`r`)

`r` is the per-item collision radius (constant or accessor). When omitted, Dodge falls back to the chart's `r` accessor / `rScale`. This lets the `<Chart>` declare `r="propertyName"` once and have Dodge pick it up automatically.

Because the algorithm processes items in **input order** and greedily picks the candidate closest to the anchor, the order you pass `data` directly shapes the result:

- **Unsorted** — placement reflects whatever order the data arrived in (e.g. by date), often producing a noisier-looking stack.
- **Largest first** — big items anchor at the baseline; smaller items nestle into the gaps. Produces the cleanest "skyline" silhouette and is the common choice for variable-radius beeswarms.
- **Smallest first** — small items get the prime baseline real estate; later items get pushed outward by every prior placement, so large items end up far from the anchor.

The example below lets you toggle the sort order to see this directly:

:example{ name="variable-radius" }

Any mark works inside the snippet — drive a `<Text>` font size from the resolved `r` to scale labels alongside the dodge radius.

:example{ name="text-beeswarm" }

## Rectangular packing (`rx` / `ry`)

Circular packing produces unnecessarily large vertical gaps when an item is much wider than tall — typical for text labels and Gantt-style time bars. Provide `rx` and `ry` (per-axis half-extents) instead of `r` to switch to axis-aligned rectangular collision: items snap to fixed-height rows along the dodge axis, with collision checked along the anchor axis.

| `axis` | Anchor-axis half-extent | Dodge-axis half-extent (row size) |
| ------ | ----------------------- | --------------------------------- |
| `'y'` | `rx` (typically per-item, e.g. `labelWidth/2`) | `ry` (typically a constant) — row spacing is `2 * ry` |
| `'x'` | `ry` (per-item) | `rx` (constant) — column spacing is `2 * rx` |

```svelte
<Dodge axis="y" rx={(d) => labelWidth(d) / 2} ry={8} />
```

:example{ name="timeline" }

## Sub-region dodging (`baseline`)

By default, Dodge anchors items to a chart edge (or center). Pass `baseline` to anchor against an arbitrary pixel coordinate along the dodge axis — for example, a band scale's `bandLeft + bandwidth/2` so each group dodges around its own column center, or a horizontal divider so labels stack above (`anchor="bottom"`) and below (`anchor="top"`) a shared baseline.

This works by combining multiple `<Dodge>` instances, each with its own `baseline` and `position` accessor. Output positions are already in chart coordinates, so the snippet doesn't need to translate them.

:example{ name="grouped-vertical" }

## Time-range lanes (Gantt-style)

For events with start/end ranges, pass each item's pixel midpoint as `position` and half its pixel width as `rx` (the anchor-axis half-extent). With `ry` set to half the desired lane height, Dodge packs each event into the lowest non-overlapping lane.

:example{ name="duration-bars-dense-lanes" }
4 changes: 2 additions & 2 deletions docs/src/content/components/ForceSimulation.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
description: Layout components which positions nodes using physics-based forces, simulating attraction, repulsion, and link constraints to create an intuitive, collision-free network visualization.
category: layout
layers: [svg, canvas]
related: []
layers: [svg, canvas, html]
related: [Dodge]
---

## Usage
Expand Down
Loading
Loading