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
5 changes: 5 additions & 0 deletions .changeset/brush-selection-constraints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(Brush): Add constraints to `BrushContext` / `BrushState`, similar to `TransformContext`'s `scaleExtent`/`translateExtent`/`constrain`.
5 changes: 5 additions & 0 deletions .changeset/chart-disable-text-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Chart): Disable text selection to prevent selection while dragging
15 changes: 15 additions & 0 deletions docs/src/content/components/Chart.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,18 @@ order: 1
::note
Features: Adds support for x and y baselines (always show 0, etc)
::

## Text selection

Charts are treated as interactive widgets: `user-select: none` is applied to the root container (`.lc-root-container`) so dragging to brush, pan, or zoom never selects axis labels or surrounding page text. Since `user-select` inherits, this covers the whole chart.

To re-enable selection where you need it, set the `--lc-user-select` custom property to `text` — on the chart, a wrapping element, or a specific subtree:

```svelte
<!-- Re-enable for a chart (or any descendants under a wrapper) -->
<div style="--lc-user-select: text">
<Chart {data} ... />
</div>
```

An individual selectable region can also just set `user-select: text` on itself (e.g. Tailwind's `select-text`), which overrides the inherited value.
43 changes: 43 additions & 0 deletions docs/src/content/guides/brush.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,49 @@ Size in pixels of the invisible drag handles on the edges of the selection. Defa
- **Double-click on the selection** — resets the brush (`reset()`)
- **Double-click on a handle** — extends that edge to the domain boundary

## Constraints

Limit the selection size with `minExtent` / `maxExtent` (`{ x?, y? }`). For continuous scales the value is in domain units — e.g. milliseconds for a time scale — and for band/point scales it's the number of categories. The edge you aren't dragging is held fixed, so creating or resizing past the limit pulls the moving edge back rather than snapping the whole selection.

A common use is a "focus + context" view where the detail chart should never show more than a fixed window, no matter how much data is loaded — here the brush is capped at 90 days:

```svelte
<!-- Never show more than 90 days at once -->
<Chart {data} brush={{ maxExtent: { x: 90 * 24 * 60 * 60 * 1000 } }}>
```

:example{ component="BrushContext" name="max-window" }

`minExtent` and `maxExtent` can be combined to keep the selection within a range:

```svelte
<Chart {data} brush={{ minExtent: { x: 30 * DAY }, maxExtent: { x: 180 * DAY } }}>
```

:example{ component="BrushContext" name="min-max-extent" }

For anything the extents can't express, pass a `constrain` function. It receives the candidate `{ x, y }` selection and returns a corrected one, and runs after `min/maxExtent` on every update (create, resize, move, and programmatic changes) — for example, snapping edges to month boundaries:

```svelte
<Chart
{data}
brush={{
constrain: ({ x, y }) => ({
x: [timeMonth.floor(x[0]), timeMonth.ceil(x[1])],
y
})
}}
/>
```

::note
Snapping can round an edge _past_ the first/last data point, but by default the selection is kept within the domain (`constrainToDomain`, on by default), so `constrain` output is clamped back to the edge automatically. Set `constrainToDomain: false` to opt out and allow the selection to extend beyond the domain.
::

:example{ component="BrushContext" name="snap-to-month" }

This mirrors the `scaleExtent` / `translateExtent` / `constrain` options on [transform](/docs/guides/transform). Because the limits are enforced inside the brush state, the selection never momentarily holds an out-of-range value.

## Styling

Customize the brush appearance with the `classes` prop:
Expand Down
88 changes: 87 additions & 1 deletion docs/src/examples/catalog/BrushContext.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,65 @@
}
]
},
{
"name": "max-window",
"title": "max window",
"path": "/docs/components/BrushContext/max-window",
"components": [
{
"component": "Chart",
"lineNumber": 38,
"line": "<Chart"
},
{
"component": "Layer",
"lineNumber": 47,
"line": "<Layer>"
},
{
"component": "Axis",
"lineNumber": 48,
"line": "<Axis placement=\"left\" grid rule />"
},
{
"component": "ChartClipPath",
"lineNumber": 51,
"line": "<ChartClipPath>"
},
{
"component": "Area",
"lineNumber": 52,
"line": "<Area line={{ class: 'stroke-2 stroke-primary' }} class=\"fill-primary/20\" />"
}
]
},
{
"name": "min-max-extent",
"title": "min max extent",
"path": "/docs/components/BrushContext/min-max-extent",
"components": [
{
"component": "Chart",
"lineNumber": 30,
"line": "<Chart"
},
{
"component": "Layer",
"lineNumber": 44,
"line": "<Layer>"
},
{
"component": "Axis",
"lineNumber": 45,
"line": "<Axis placement=\"left\" grid rule />"
},
{
"component": "Area",
"lineNumber": 47,
"line": "<Area line={{ class: 'stroke-2 stroke-primary' }} class=\"fill-primary/20\" />"
}
]
},
{
"name": "minimap",
"title": "minimap",
Expand Down Expand Up @@ -439,6 +498,33 @@
}
]
},
{
"name": "snap-to-month",
"title": "snap to month",
"path": "/docs/components/BrushContext/snap-to-month",
"components": [
{
"component": "Chart",
"lineNumber": 30,
"line": "<Chart"
},
{
"component": "Layer",
"lineNumber": 51,
"line": "<Layer>"
},
{
"component": "Axis",
"lineNumber": 52,
"line": "<Axis placement=\"left\" grid rule />"
},
{
"component": "Area",
"lineNumber": 54,
"line": "<Area line={{ class: 'stroke-2 stroke-primary' }} class=\"fill-primary/20\" />"
}
]
},
{
"name": "striped-background",
"title": "striped background",
Expand Down Expand Up @@ -552,5 +638,5 @@
}
],
"usage": [],
"updatedAt": "2026-04-30T15:36:23.111Z"
"updatedAt": "2026-07-01T14:36:52.362Z"
}
75 changes: 75 additions & 0 deletions docs/src/examples/components/BrushContext/max-window.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script module lang="ts">
import { getAppleStock } from '$lib/data.remote';
const data = await getAppleStock();
</script>

<script lang="ts">
import {
Area,
Axis,
Chart,
ChartClipPath,
Layer,
defaultChartPadding,
type DomainType
} from 'layerchart';

export { data };

const DAY = 24 * 60 * 60 * 1000;
const maxDays = 90;

// Live brush selection — also drives the detail chart's visible range
let brushX = $state<DomainType>([null, null]);

const selectedDays = $derived(
brushX?.[0] != null && brushX?.[1] != null ? Math.round((+brushX[1] - +brushX[0]) / DAY) : null
);
</script>

<div class="mb-2 text-sm">
Window:
<span class="font-semibold">{selectedDays != null ? `${selectedDays} days` : 'full range'}</span>
<span class="text-surface-content/50">— drag the overview to brush; capped at {maxDays} days</span
>
</div>

<!-- Detail — zooms to the (capped) brush selection -->
<Chart
{data}
x="date"
y="value"
xDomain={brushX}
yDomain={[0, null]}
padding={defaultChartPadding({ left: 25, bottom: 24 })}
height={220}
>
<Layer>
<Axis placement="left" grid rule />
<Axis placement="bottom" rule />
<!-- Clip the area/line to the plot so the zoomed range doesn't overflow the axes -->
<ChartClipPath>
<Area line={{ class: 'stroke-2 stroke-primary' }} class="fill-primary/20" />
</ChartClipPath>
</Layer>
</Chart>

<!-- Overview + brush (never wider than `maxDays`) -->
<Chart
{data}
x="date"
y="value"
brush={{
maxExtent: { x: maxDays * DAY },
x: brushX as any,
onChange: (e) => (brushX = e.brush.x)
}}
padding={defaultChartPadding({ left: 25, bottom: 24 })}
height={60}
class="mt-2"
>
<Layer>
<Axis placement="bottom" rule />
<Area class="fill-surface-content/10" />
</Layer>
</Chart>
49 changes: 49 additions & 0 deletions docs/src/examples/components/BrushContext/min-max-extent.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script module lang="ts">
import { getAppleStock } from '$lib/data.remote';
const data = await getAppleStock();
</script>

<script lang="ts">
import { Area, Axis, Chart, Layer, defaultChartPadding, type DomainType } from 'layerchart';

export { data };

const DAY = 24 * 60 * 60 * 1000;
const minDays = 30;
const maxDays = 180;

let brushX = $state<DomainType>([null, null]);

const selectedDays = $derived(
brushX?.[0] != null && brushX?.[1] != null ? Math.round((+brushX[1] - +brushX[0]) / DAY) : null
);
</script>

<div class="mb-2 text-sm">
Selection:
<span class="font-semibold">{selectedDays != null ? `${selectedDays} days` : '—'}</span>
<span class="text-surface-content/50">
— try to drag smaller than {minDays} or larger than {maxDays} days
</span>
</div>

<Chart
{data}
x="date"
y="value"
yDomain={[0, null]}
padding={defaultChartPadding({ left: 25, bottom: 24 })}
brush={{
minExtent: { x: minDays * DAY },
maxExtent: { x: maxDays * DAY },
x: brushX as any,
onChange: (e) => (brushX = e.brush.x)
}}
height={260}
>
<Layer>
<Axis placement="left" grid rule />
<Axis placement="bottom" rule />
<Area line={{ class: 'stroke-2 stroke-primary' }} class="fill-primary/20" />
</Layer>
</Chart>
56 changes: 56 additions & 0 deletions docs/src/examples/components/BrushContext/snap-to-month.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script module lang="ts">
import { getAppleStock } from '$lib/data.remote';
const data = await getAppleStock();
</script>

<script lang="ts">
import { Area, Axis, Chart, Layer, defaultChartPadding, type DomainType } from 'layerchart';
import { timeMonth } from 'd3-time';

export { data };

let brushX = $state<DomainType>([null, null]);

function monthLabel(value: number | string | Date | null | undefined) {
return value instanceof Date
? value.toLocaleDateString(undefined, { year: 'numeric', month: 'short' })
: '';
}
</script>

<div class="mb-2 text-sm">
{#if brushX?.[0] != null && brushX?.[1] != null}
Snapped:
<span class="font-semibold">{monthLabel(brushX[0])} – {monthLabel(brushX[1])}</span>
{:else}
<span class="text-surface-content/50">Drag to brush — edges snap to whole months</span>
{/if}
</div>

<Chart
{data}
x="date"
y="value"
yDomain={[0, null]}
padding={defaultChartPadding({ left: 25, bottom: 24 })}
brush={{
// Snap edges to whole months. `timeMonth.ceil` can round past the last data point, but
// `constrainToDomain` (on by default) clamps the result back inside the chart.
constrain: ({ x, y }) => ({
x:
x[0] != null && x[1] != null
? [timeMonth.floor(x[0] as Date), timeMonth.ceil(x[1] as Date)]
: x,
y
}),
x: brushX as any,
onChange: (e) => (brushX = e.brush.x)
}}
height={260}
>
<Layer>
<Axis placement="left" grid rule />
<Axis placement="bottom" rule />
<Area line={{ class: 'stroke-2 stroke-primary' }} class="fill-primary/20" />
</Layer>
</Chart>
Loading
Loading