Skip to content

Commit ccbef88

Browse files
committed
feat(Chart, BrushState): Add band scale (categorical) support for transform pan/zoom and brush selection. Uses range-rescaling pattern to smoothly zoom and pan categorical bar charts. Automatically constrains panning to data boundaries and prevents zooming out past initial view.
1 parent 42df576 commit ccbef88

14 files changed

Lines changed: 689 additions & 111 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat(Chart, BrushState): Add band scale (categorical) support for transform pan/zoom and brush selection. Uses range-rescaling pattern to smoothly zoom and pan categorical bar charts. Automatically constrains panning to data boundaries and prevents zooming out past initial view.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'layerchart': patch
3+
---
4+
5+
fix(scaleBandInvert): Account for range offset in band scale inversion. Previously assumed range started at 0, causing incorrect pixel-to-category mapping when the scale range was transformed.

docs/src/content/guides/brush.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,12 @@ An overview chart below the main chart can act as a navigation scrollbar. The ov
216216
| Visual brush | `brush` | [basic](/docs/components/BrushContext/basic) |
217217
| Brush-to-zoom | `brush={{ onBrushEnd: (e) => { ... e.brush.reset() } }}` | [integrated-brush](</docs/components/BrushContext/integrated-brush-(x-axis)>) |
218218
| Simplified chart zoom | `brush` on LineChart/AreaChart/etc. | [brush](/docs/components/LineChart/brush) |
219+
| Brush on categories | `brush` on BarChart with band scale | [brush-band](/docs/components/BarChart/brush-band) |
219220
| Minimap (focus+context) | `brush={{ x: xDomain, onChange: ... }}` | [minimap](/docs/components/BrushContext/minimap) |
220221
| Synced multi-chart | Shared `xDomain` state with `x` and `onChange` | [sync-brushes](/docs/components/BrushContext/synced-brushes) |
221222
| Programmatic control | `context.brush.move()`, `.reset()`, `.selectAll()` | [programmatic-control](/docs/components/BrushContext/programmatic-control) |
222223
| Brush + pan/zoom | `brush` + `transform={{ mode: 'domain' }}` | [brush-pan-zoom](/docs/components/LineChart/brush-pan-zoom) |
224+
| Brush + pan/zoom (band) | `brush` + `transform` on band scale | [brush-pan-zoom-band](/docs/components/BarChart/brush-pan-zoom-band) |
223225
| Overview + pan/zoom | `brush.x` synced to main chart's `context.xDomain` | [pan-zoom-with-overview](/docs/components/LineChart/pan-zoom-with-overview) |
224226
| Point selection | `brush={{ axis: 'both', onChange: ... }}` | [selection](/docs/components/BrushContext/selection) |
225227
| Custom styling | `brush={{ classes: { range: '...', handle: '...' } }}` | [simple-styling](/docs/components/BrushContext/simple-styling) |

docs/src/content/guides/transform.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ Zooming and panning modifies the chart's domain — the range of data values sho
2626

2727
:example{ component="BarChart" name="pan-zoom" }
2828

29+
For categorical (band scale) data, domain mode rescales the band range — bars grow proportionally as you zoom and pan smoothly across categories. Panning is automatically constrained to data boundaries:
30+
31+
:example{ component="BarChart" name="pan-zoom-band" }
32+
2933
A gradient-filled rectangle with full-chart grid axes, demonstrating both-axis zoom with `scaleExtent` up to 40x:
3034

3135
:example{ component="TransformContext" name="pan-zoom-axes" }
@@ -487,6 +491,7 @@ It supports placement (`'top-left'`, `'top-right'`, `'bottom-left'`, etc.), orie
487491
| Use case | Configuration | Example |
488492
| ---------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------- |
489493
| Pan/zoom a time series | `transform={{ mode: 'domain', axis: 'x' }}` | [pan-zoom](/docs/components/BarChart/pan-zoom) |
494+
| Pan/zoom categories | `transform={{ mode: 'domain', axis: 'x' }}` (band scale) | [pan-zoom-band](/docs/components/BarChart/pan-zoom-band) |
490495
| Pan/zoom both axes | `transform={{ mode: 'domain', scaleExtent: [1, 40] }}` | [pan-zoom-axes](/docs/components/TransformContext/pan-zoom-axes) |
491496
| Limit zoom depth | `scaleExtent: [1, 10]` | [pan-zoom-scale-extent](/docs/components/BarChart/pan-zoom-scale-extent) |
492497
| Keep data in view | `domainExtent: { x: { min: 'data', max: 'data' } }` | [pan-zoom-domain-extent](/docs/components/LineChart/pan-zoom-domain-extent) |
@@ -499,6 +504,7 @@ It supports placement (`'top-left'`, `'top-right'`, `'bottom-left'`, etc.), orie
499504
| Geo map zoom limits | `scaleExtent: [1, 8]` | [transform-canvas-scale-extent](/docs/components/GeoPath/transform-canvas-scale-extent) |
500505
| Globe pitch clamping | `constrain` with `Math.max(-90, ...)` | [transform-globe-constrain](/docs/components/GeoPath/transform-globe-constrain) |
501506
| Brush-to-zoom | `brush` + `transform={{ mode: 'domain' }}` | [brush-pan-zoom](/docs/components/LineChart/brush-pan-zoom) |
507+
| Brush-to-zoom (band) | `brush` + `transform` on band scale | [brush-pan-zoom-band](/docs/components/BarChart/brush-pan-zoom-band) |
502508
| Overview brush | Separate chart with `brush.x` synced to `context.xDomain` | [pan-zoom-with-overview](/docs/components/LineChart/pan-zoom-with-overview) |
503509
| Programmatic zoom only | `disablePointer: true` with `zoomTo()` calls | [basic](/docs/components/Pack/basic) |
504510
| Animated transforms | `motion: { type: 'tween', duration: 800 }` | [basic](/docs/components/Pack/basic) |
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script lang="ts">
2+
import { BarChart } from 'layerchart';
3+
import { getRandomInteger, usStateAbbreviations } from '$lib/utils/data.js';
4+
5+
const data = usStateAbbreviations.map((state) => ({ state, value: getRandomInteger(20, 100) }));
6+
export { data };
7+
</script>
8+
9+
<BarChart {data} x="state" y="value" brush height={300} />
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
import { BarChart } from 'layerchart';
3+
import { getRandomInteger, usStateAbbreviations } from '$lib/utils/data.js';
4+
5+
const data = usStateAbbreviations.map((state) => ({ state, value: getRandomInteger(20, 100) }));
6+
export { data };
7+
</script>
8+
9+
<BarChart
10+
{data}
11+
x="state"
12+
y="value"
13+
brush
14+
clip
15+
transform={{ mode: 'domain', axis: 'x', scaleExtent: [1, 10] }}
16+
height={300}
17+
/>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts">
2+
import { BarChart } from 'layerchart';
3+
import { getRandomInteger, usStateAbbreviations } from '$lib/utils/data.js';
4+
5+
const data = usStateAbbreviations.map((state) => ({ state, value: getRandomInteger(20, 100) }));
6+
export { data };
7+
</script>
8+
9+
<BarChart
10+
{data}
11+
x="state"
12+
y="value"
13+
clip
14+
transform={{ mode: 'domain', axis: 'x', scaleExtent: [1, 10] }}
15+
height={300}
16+
/>

docs/src/lib/utils/data.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,59 @@ export const wideData = [
136136
{ year: 2016, apples: 820, bananas: 560, cherries: 720, grapes: 400 }
137137
];
138138

139+
export const usStateAbbreviations = [
140+
'AL',
141+
'AK',
142+
'AZ',
143+
'AR',
144+
'CA',
145+
'CO',
146+
'CT',
147+
'DE',
148+
'FL',
149+
'GA',
150+
'HI',
151+
'ID',
152+
'IL',
153+
'IN',
154+
'IA',
155+
'KS',
156+
'KY',
157+
'LA',
158+
'ME',
159+
'MD',
160+
'MA',
161+
'MI',
162+
'MN',
163+
'MS',
164+
'MO',
165+
'MT',
166+
'NE',
167+
'NV',
168+
'NH',
169+
'NJ',
170+
'NM',
171+
'NY',
172+
'NC',
173+
'ND',
174+
'OH',
175+
'OK',
176+
'OR',
177+
'PA',
178+
'RI',
179+
'SC',
180+
'SD',
181+
'TN',
182+
'TX',
183+
'UT',
184+
'VT',
185+
'VA',
186+
'WA',
187+
'WV',
188+
'WI',
189+
'WY'
190+
];
191+
139192
export const longData = [
140193
{ year: 2019, basket: 1, fruit: 'apples', value: 3840 },
141194
{ year: 2019, basket: 1, fruit: 'bananas', value: 1920 },

packages/layerchart/src/lib/components/Chart.svelte

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
import { getObjectOrNull, type Accessor } from '$lib/utils/common.js';
99
import type { MotionProp } from '$lib/utils/motion.svelte.js';
10-
import { type AnyScale, type DomainType } from '$lib/utils/scales.svelte.js';
10+
import { type AnyScale, type DomainType, isScaleBand } from '$lib/utils/scales.svelte.js';
1111
import type {
1212
BaseRange,
1313
Nice,
@@ -22,7 +22,7 @@
2222
import { geoFitObjectTransform } from '$lib/utils/geo.js';
2323
import TransformContext from './TransformContext.svelte';
2424
import BrushContext from './BrushContext.svelte';
25-
import { type BrushDomainType, type BrushState } from '$lib/states/brush.svelte.js';
25+
import { type BrushDomainType, type BrushState, expandBandBrushDomain } from '$lib/states/brush.svelte.js';
2626
2727
import { setChartContext } from '$lib/contexts/chart.js';
2828
import { ChartState } from '$lib/states/chart.svelte.js';
@@ -833,6 +833,9 @@
833833
834834
const d0 = baseDomain[0] as unknown;
835835
const d1 = baseDomain[1] as unknown;
836+
837+
// Skip domain extent constraint for categorical scales (range clamping handles boundaries)
838+
if (typeof d0 === 'string') return axisTranslate;
836839
const isDate = d0 instanceof Date;
837840
const rawD0 = isDate ? (d0 as Date).getTime() : (d0 as number);
838841
const rawD1 = isDate ? (d1 as Date).getTime() : (d1 as number);
@@ -925,15 +928,52 @@
925928
};
926929
});
927930
928-
// Compose user-provided constrain with domainExtent constrain
931+
// Whether this is a band scale domain transform (affects scaleExtent and constrain defaults)
932+
const isBandDomainTransform = $derived(
933+
transform?.mode === 'domain' && (
934+
((transform.axis ?? 'both') !== 'y' && isScaleBand(chartState._xScaleProp)) ||
935+
((transform.axis ?? 'both') !== 'x' && isScaleBand(chartState._yScaleProp))
936+
)
937+
);
938+
939+
// For band scale domain transforms, enforce scaleExtent minimum of 1 (can't zoom out past initial view)
940+
const resolvedScaleExtent = $derived.by(() => {
941+
if (!isBandDomainTransform) return transform?.scaleExtent;
942+
const userExtent = transform?.scaleExtent;
943+
return [Math.max(1, userExtent?.[0] ?? 1), userExtent?.[1] ?? Infinity] as [number, number];
944+
});
945+
946+
// Default constrain for band scale domain transforms: prevent panning past data boundaries
947+
const bandScaleConstrain = $derived.by(() => {
948+
if (!isBandDomainTransform) return undefined;
949+
const xIsBand =
950+
(transform!.axis ?? 'both') !== 'y' && isScaleBand(chartState._xScaleProp);
951+
const yIsBand =
952+
(transform!.axis ?? 'both') !== 'x' && isScaleBand(chartState._yScaleProp);
953+
954+
return (t: { scale: number; translate: { x: number; y: number } }) => {
955+
let { scale, translate } = t;
956+
let tx = translate.x;
957+
let ty = translate.y;
958+
if (xIsBand) {
959+
// translate.x must be in [width * (1 - scale), 0]
960+
tx = Math.max(chartState.width * (1 - scale), Math.min(0, tx));
961+
}
962+
if (yIsBand) {
963+
ty = Math.max(chartState.height * (1 - scale), Math.min(0, ty));
964+
}
965+
return { scale, translate: { x: tx, y: ty } };
966+
};
967+
});
968+
969+
// Compose user-provided constrain with domainExtent constrain and band scale constrain
929970
const composedConstrain = $derived.by(() => {
930971
const userConstrain = transform?.constrain;
931-
if (!domainExtentConstrain && !userConstrain) return undefined;
932-
if (!domainExtentConstrain) return userConstrain;
933-
if (!userConstrain) return domainExtentConstrain;
934-
// Domain extent first, then user constrain
972+
const constrains = [bandScaleConstrain, domainExtentConstrain, userConstrain].filter(Boolean) as Array<(t: { scale: number; translate: { x: number; y: number } }) => { scale: number; translate: { x: number; y: number } }>;
973+
if (constrains.length === 0) return undefined;
974+
if (constrains.length === 1) return constrains[0];
935975
return (t: { scale: number; translate: { x: number; y: number } }) => {
936-
return userConstrain(domainExtentConstrain(t));
976+
return constrains.reduce((acc, fn) => fn(acc), t);
937977
};
938978
});
939979
@@ -954,8 +994,12 @@
954994
chartState.zoomToBrush(e.brush, userProps.axis ?? 'x');
955995
} else if (zoomOnBrush) {
956996
const axis = userProps.axis ?? 'x';
957-
if (axis === 'x' || axis === 'both') brushXDomain = e.brush.x;
958-
if (axis === 'y' || axis === 'both') brushYDomain = e.brush.y;
997+
if (axis === 'x' || axis === 'both') {
998+
brushXDomain = expandBandBrushDomain(e.brush.x, chartState._baseXDomain);
999+
}
1000+
if (axis === 'y' || axis === 'both') {
1001+
brushYDomain = expandBandBrushDomain(e.brush.y, chartState._baseYDomain);
1002+
}
9591003
}
9601004
userOnBrushEnd?.(e);
9611005
e.brush.reset();
@@ -993,14 +1037,15 @@
9931037
>
9941038
{#key chartState.isMounted}
9951039
<!-- svelte-ignore ownership_invalid_binding -->
996-
{@const { domainExtent: _de, constrain: _uc, apply: _apply, ...transformProps } = transform ?? {}}
1040+
{@const { domainExtent: _de, constrain: _uc, apply: _apply, scaleExtent: _se, ...transformProps } = transform ?? {}}
9971041
<TransformContext
9981042
bind:state={chartState.transformState}
9991043
mode={transform?.mode ?? 'none'}
10001044
initialTranslate={resolvedApply.translate ? initialTransform?.translate : undefined}
10011045
initialScale={resolvedApply.scale ? initialTransform?.scale : undefined}
10021046
{processTranslate}
10031047
{...transformProps}
1048+
scaleExtent={resolvedScaleExtent}
10041049
constrain={composedConstrain}
10051050
disablePointer={(brush === true || (typeof brush === 'object' && !brush.disabled)) || transform?.disablePointer}
10061051
{ondragstart}

0 commit comments

Comments
 (0)