diff --git a/.changeset/brush-selection-constraints.md b/.changeset/brush-selection-constraints.md new file mode 100644 index 000000000..c5e607eef --- /dev/null +++ b/.changeset/brush-selection-constraints.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Brush): Add constraints to `BrushContext` / `BrushState`, similar to `TransformContext`'s `scaleExtent`/`translateExtent`/`constrain`. diff --git a/.changeset/chart-disable-text-selection.md b/.changeset/chart-disable-text-selection.md new file mode 100644 index 000000000..600cec6c0 --- /dev/null +++ b/.changeset/chart-disable-text-selection.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Chart): Disable text selection to prevent selection while dragging diff --git a/docs/src/content/components/Chart.md b/docs/src/content/components/Chart.md index 931313c61..dae7faedd 100644 --- a/docs/src/content/components/Chart.md +++ b/docs/src/content/components/Chart.md @@ -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 + +
+ +
+``` + +An individual selectable region can also just set `user-select: text` on itself (e.g. Tailwind's `select-text`), which overrides the inherited value. diff --git a/docs/src/content/guides/brush.md b/docs/src/content/guides/brush.md index 97d0117d0..05ba6e0f8 100644 --- a/docs/src/content/guides/brush.md +++ b/docs/src/content/guides/brush.md @@ -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 + + +``` + +:example{ component="BrushContext" name="max-window" } + +`minExtent` and `maxExtent` can be combined to keep the selection within a range: + +```svelte + +``` + +: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 + ({ + 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: diff --git a/docs/src/examples/catalog/BrushContext.json b/docs/src/examples/catalog/BrushContext.json index cb8a6a2d1..39e0e5dc9 100644 --- a/docs/src/examples/catalog/BrushContext.json +++ b/docs/src/examples/catalog/BrushContext.json @@ -215,6 +215,65 @@ } ] }, + { + "name": "max-window", + "title": "max window", + "path": "/docs/components/BrushContext/max-window", + "components": [ + { + "component": "Chart", + "lineNumber": 38, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 48, + "line": "" + }, + { + "component": "ChartClipPath", + "lineNumber": 51, + "line": "" + }, + { + "component": "Area", + "lineNumber": 52, + "line": "" + } + ] + }, + { + "name": "min-max-extent", + "title": "min max extent", + "path": "/docs/components/BrushContext/min-max-extent", + "components": [ + { + "component": "Chart", + "lineNumber": 30, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 45, + "line": "" + }, + { + "component": "Area", + "lineNumber": 47, + "line": "" + } + ] + }, { "name": "minimap", "title": "minimap", @@ -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": "" + }, + { + "component": "Axis", + "lineNumber": 52, + "line": "" + }, + { + "component": "Area", + "lineNumber": 54, + "line": "" + } + ] + }, { "name": "striped-background", "title": "striped background", @@ -552,5 +638,5 @@ } ], "usage": [], - "updatedAt": "2026-04-30T15:36:23.111Z" + "updatedAt": "2026-07-01T14:36:52.362Z" } \ No newline at end of file diff --git a/docs/src/examples/components/BrushContext/max-window.svelte b/docs/src/examples/components/BrushContext/max-window.svelte new file mode 100644 index 000000000..e5cd0a530 --- /dev/null +++ b/docs/src/examples/components/BrushContext/max-window.svelte @@ -0,0 +1,75 @@ + + + + +
+ Window: + {selectedDays != null ? `${selectedDays} days` : 'full range'} + — drag the overview to brush; capped at {maxDays} days +
+ + + + + + + + + + + + + + + (brushX = e.brush.x) + }} + padding={defaultChartPadding({ left: 25, bottom: 24 })} + height={60} + class="mt-2" +> + + + + + diff --git a/docs/src/examples/components/BrushContext/min-max-extent.svelte b/docs/src/examples/components/BrushContext/min-max-extent.svelte new file mode 100644 index 000000000..b69941b0a --- /dev/null +++ b/docs/src/examples/components/BrushContext/min-max-extent.svelte @@ -0,0 +1,49 @@ + + + + +
+ Selection: + {selectedDays != null ? `${selectedDays} days` : '—'} + + — try to drag smaller than {minDays} or larger than {maxDays} days + +
+ + (brushX = e.brush.x) + }} + height={260} +> + + + + + + diff --git a/docs/src/examples/components/BrushContext/snap-to-month.svelte b/docs/src/examples/components/BrushContext/snap-to-month.svelte new file mode 100644 index 000000000..b275c1864 --- /dev/null +++ b/docs/src/examples/components/BrushContext/snap-to-month.svelte @@ -0,0 +1,56 @@ + + + + +
+ {#if brushX?.[0] != null && brushX?.[1] != null} + Snapped: + {monthLabel(brushX[0])} – {monthLabel(brushX[1])} + {:else} + Drag to brush — edges snap to whole months + {/if} +
+ + ({ + 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} +> + + + + + + diff --git a/packages/layerchart/src/lib/components/BrushContext.svelte b/packages/layerchart/src/lib/components/BrushContext.svelte index 580a459fb..023fb638f 100644 --- a/packages/layerchart/src/lib/components/BrushContext.svelte +++ b/packages/layerchart/src/lib/components/BrushContext.svelte @@ -1,7 +1,12 @@