From fc51d161b0e41fef7399bcad02fd6de94f428b30 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 1 Jul 2026 12:03:58 -0400 Subject: [PATCH 1/2] feat(Brush): Add constraints to `BrushContext` / `BrushState`, similar to `TransformContext`'s `scaleExtent`/`translateExtent`/`constrain`. --- .changeset/brush-selection-constraints.md | 5 + docs/src/content/guides/brush.md | 43 ++++ docs/src/examples/catalog/BrushContext.json | 88 +++++++- .../components/BrushContext/max-window.svelte | 75 +++++++ .../BrushContext/min-max-extent.svelte | 49 ++++ .../BrushContext/snap-to-month.svelte | 56 +++++ .../src/lib/components/BrushContext.svelte | 57 ++++- .../src/lib/states/brush.svelte.test.ts | 135 +++++++++++ .../layerchart/src/lib/states/brush.svelte.ts | 210 +++++++++++++++++- 9 files changed, 714 insertions(+), 4 deletions(-) create mode 100644 .changeset/brush-selection-constraints.md create mode 100644 docs/src/examples/components/BrushContext/max-window.svelte create mode 100644 docs/src/examples/components/BrushContext/min-max-extent.svelte create mode 100644 docs/src/examples/components/BrushContext/snap-to-month.svelte 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/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 @@