Skip to content

Commit 796f066

Browse files
committed
feat(Chart): In projection mode, scaleExtent and translateExtent are now interpreted as relative values. scaleExtent: [0.5, 8] means 0.5x to 8x of the fitted projection scale. translateExtent is offset from the initial fitted position in pixels.
1 parent cca8196 commit 796f066

7 files changed

Lines changed: 136 additions & 25 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): In projection mode, `scaleExtent` and `translateExtent` are now interpreted as relative values (like d3-zoom). `scaleExtent: [0.5, 8]` means 0.5x to 8x of the fitted projection scale. `translateExtent` is offset from the initial fitted position in pixels.

docs/src/content/guides/transform.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@ Updates the geo projection based on transform interactions. The projection is re
7171

7272
Which projection properties are updated is controlled by the `apply` option and auto-detected from the projection type:
7373

74-
| Projection type | Default `apply` | Behavior |
75-
| ----------------------------------------------- | ---------------------------------------------------- | ----------------------- |
76-
| Flat maps (`geoMercator`, `geoAlbersUsa`, etc.) | `{ translate: true, scale: true, rotation: false }` | Drag pans, scroll zooms |
77-
| Globe projections (`geoOrthographic`, etc.) | `{ rotation: true, scale: false, translate: false }` | Drag rotates the globe |
74+
| Projection type | Default `apply` | Behavior |
75+
| ----------------------------------------------- | --------------------------------------------------- | -------------------------- |
76+
| Flat maps (`geoMercator`, `geoAlbersUsa`, etc.) | `{ translate: true, scale: true, rotation: false }` | Drag pans, scroll zooms |
77+
| Globe projections (`geoOrthographic`, etc.) | `{ rotation: true, scale: true, translate: false }` | Drag rotates, scroll zooms |
7878

7979
Auto-detection uses the projection's `clipAngle` — projections with a default clip angle (like orthographic) are treated as globes. Override with explicit `apply` values when needed.
8080

@@ -100,12 +100,12 @@ When `fitGeojson` is provided and translate mode is active, the initial translat
100100

101101
:example{ component="GeoPath" name="translucent-globe" }
102102

103-
To enable zoom on a globe alongside rotation:
103+
Scroll zoom works on globes by default. To disable it, override with `apply`:
104104

105105
```svelte
106106
<Chart
107107
geo={{ projection: geoOrthographic, fitGeojson: countries }}
108-
transform={{ mode: 'projection', apply: { rotation: true, scale: true } }}
108+
transform={{ mode: 'projection', apply: { rotation: true, scale: false } }}
109109
/>
110110
```
111111

@@ -286,6 +286,8 @@ This works identically for geo canvas transforms:
286286

287287
:example{ component="GeoPath" name="transform-canvas-scale-extent" }
288288

289+
In `projection` mode, `scaleExtent` is interpreted as **relative to the initial fitted scale**. For example, `[0.5, 8]` means 0.5x to 8x of the projection's fitted scale — not absolute pixel values.
290+
289291
### `domainExtent` — constrain in data space
290292

291293
For `mode: 'domain'` charts, `domainExtent` lets you express constraints in data units rather than pixel/transform space. This is useful when you want to say things like "don't pan before January 2020" or "always show at least 7 days."
@@ -387,12 +389,14 @@ Constrains panning to a bounding box in pixel coordinates `[[minX, minY], [maxX,
387389

388390
For `domain` mode, prefer `domainExtent` which lets you express bounds in data units.
389391

392+
In `projection` mode with flat maps, `translateExtent` defines pan bounds **relative to the initial fitted position**, and the allowed range scales with zoom level. For globe projections using rotation, values are passed through as degrees (yaw/pitch).
393+
390394
### How constraints compose
391395

392396
When multiple constraint options are provided, they are applied in order:
393397

394-
1. `scaleExtent` — clamps scale
395-
2. `translateExtent` — clamps translate
398+
1. `scaleExtent` — clamps scale (relative multipliers in projection mode)
399+
2. `translateExtent` — clamps translate (zoom-aware bounds in projection mode)
396400
3. `domainExtent` — clamps in domain space (converted to a `constrain` function internally)
397401
4. `constrain` — final custom adjustment
398402

@@ -498,13 +502,13 @@ It supports placement (`'top-left'`, `'top-right'`, `'bottom-left'`, etc.), orie
498502
| Minimum visible range | `domainExtent: { x: { minRange: 7 * 86400000 } }` | [pan-zoom-domain-extent](/docs/components/LineChart/pan-zoom-domain-extent) |
499503
| Pan/zoom a map (CSS) | `transform={{ mode: 'canvas', scrollMode: 'scale' }}` | [transform-canvas](/docs/components/GeoPath/transform-canvas) |
500504
| Pan/zoom a map (geo) | `transform={{ mode: 'projection', scrollMode: 'scale' }}` | [transform-projection](/docs/components/GeoPath/transform-projection) |
501-
| World map (CSS) | Canvas mode + world countries | [transform-world-canvas](/docs/components/GeoPath/transform-world-canvas) |
502-
| World map (geo) | Projection mode + world countries | [transform-world-projection](/docs/components/GeoPath/transform-world-projection) |
505+
| World map (CSS) | Canvas mode + world countries | [transform-world-canvas](/docs/components/GeoPath/transform-world-canvas) |
506+
| World map (geo) | Projection mode + world countries | [transform-world-projection](/docs/components/GeoPath/transform-world-projection) |
503507
| Globe rotation | `transform={{ mode: 'projection' }}` (auto-detected) | [translucent-globe](/docs/components/GeoPath/translucent-globe) |
504508
| Geo map zoom limits | `scaleExtent: [1, 8]` | [transform-canvas-scale-extent](/docs/components/GeoPath/transform-canvas-scale-extent) |
505509
| Globe pitch clamping | `constrain` with `Math.max(-90, ...)` | [transform-globe-constrain](/docs/components/GeoPath/transform-globe-constrain) |
506510
| 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) |
511+
| Brush-to-zoom (band) | `brush` + `transform` on band scale | [brush-pan-zoom-band](/docs/components/BarChart/brush-pan-zoom-band) |
508512
| Overview brush | Separate chart with `brush.x` synced to `context.xDomain` | [pan-zoom-with-overview](/docs/components/LineChart/pan-zoom-with-overview) |
509513
| Programmatic zoom only | `disablePointer: true` with `zoomTo()` calls | [basic](/docs/components/Pack/basic) |
510514
| Animated transforms | `motion: { type: 'tween', duration: 800 }` | [basic](/docs/components/Pack/basic) |

docs/src/examples/components/TransformContext/pan-zoom-axes.svelte

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@
2525
transform={{
2626
mode: 'domain',
2727
scaleExtent: [1, 40],
28-
domainExtent: {
29-
x: { min: -100, max: domainSize + 100 },
30-
y: { min: -100, max: domainSize + 100 }
31-
},
28+
// TODO: Disabled as domainExtent currently doesn't work well with inverted domains and zoom to cursor.
29+
// domainExtent: {
30+
// x: { min: -100, max: domainSize + 100 },
31+
// y: { min: -100, max: domainSize + 100 }
32+
// },
3233
motion: { type: 'spring' }
3334
}}
3435
height={500}

docs/src/lib/components/controls/TransformContextControls.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@
162162
class="text-surface-content"
163163
>
164164
<svelte:fragment slot="selection" let:value>
165-
<Icon data={value?.icon ?? LucideChevronDown} />
165+
{#key value}
166+
<Icon data={value?.icon ?? LucideChevronDown} />
167+
{/key}
166168
</svelte:fragment>
167169
</MenuButton>
168170
</Tooltip>

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -936,13 +936,65 @@
936936
)
937937
);
938938
939-
// For band scale domain transforms, enforce scaleExtent minimum of 1 (can't zoom out past initial view)
939+
// For projection mode, scaleExtent is relative to the initial fitted scale (like d3-zoom).
940+
// e.g. [0.5, 8] means 0.5x to 8x of the fitted projection scale.
941+
// For band scale domain transforms, enforce minimum of 1 (can't zoom out past initial view).
940942
const resolvedScaleExtent = $derived.by(() => {
943+
if (transform?.mode === 'projection' && transform?.scaleExtent && initialTransform) {
944+
const baseScale = initialTransform.scale;
945+
return [
946+
transform.scaleExtent[0] * baseScale,
947+
transform.scaleExtent[1] * baseScale,
948+
] as [number, number];
949+
}
941950
if (!isBandDomainTransform) return transform?.scaleExtent;
942951
const userExtent = transform?.scaleExtent;
943952
return [Math.max(1, userExtent?.[0] ?? 1), userExtent?.[1] ?? Infinity] as [number, number];
944953
});
945954
955+
// For projection mode with flat projections, translateExtent defines the pannable world bounds
956+
// at the initial (1x) zoom level, similar to d3-zoom. The allowed translate range scales with
957+
// the zoom ratio so you can pan more when zoomed in.
958+
// For rotation mode (globes), translateExtent is passed through as degrees (yaw/pitch).
959+
const resolvedTranslateExtent = $derived.by(() => {
960+
if (transform?.mode === 'projection' && transform?.translateExtent) {
961+
if (resolvedApply.rotation) {
962+
// Rotation mode: values are degrees (yaw/pitch), pass through as-is
963+
return transform.translateExtent;
964+
}
965+
// Flat projection translate mode: handled via projectionTranslateConstrain below
966+
return undefined;
967+
}
968+
return transform?.translateExtent;
969+
});
970+
971+
// For flat projection mode, implement d3-zoom-style translate constraining:
972+
// The viewport (at current zoom) must overlap with the translateExtent world bounds.
973+
// As zoom increases, the allowed translate range grows proportionally.
974+
const projectionTranslateConstrain = $derived.by(() => {
975+
if (transform?.mode !== 'projection' || !transform?.translateExtent || !initialTransform || resolvedApply.rotation) {
976+
return undefined;
977+
}
978+
979+
const baseScale = initialTransform.scale;
980+
const baseTranslate = initialTransform.translate;
981+
const [[x0, y0], [x1, y1]] = transform.translateExtent;
982+
983+
return (t: { scale: number; translate: { x: number; y: number } }) => {
984+
let { scale, translate } = t;
985+
// Zoom ratio relative to fitted scale
986+
const k = scale / baseScale;
987+
988+
// Allowed translate range scales with zoom ratio
989+
translate = {
990+
x: Math.max(baseTranslate.x + x0 * k, Math.min(baseTranslate.x + x1 * k, translate.x)),
991+
y: Math.max(baseTranslate.y + y0 * k, Math.min(baseTranslate.y + y1 * k, translate.y)),
992+
};
993+
994+
return { scale, translate };
995+
};
996+
});
997+
946998
// Default constrain for band scale domain transforms: prevent panning past data boundaries
947999
const bandScaleConstrain = $derived.by(() => {
9481000
if (!isBandDomainTransform) return undefined;
@@ -969,7 +1021,7 @@
9691021
// Compose user-provided constrain with domainExtent constrain and band scale constrain
9701022
const composedConstrain = $derived.by(() => {
9711023
const userConstrain = transform?.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 } }>;
1024+
const constrains = [bandScaleConstrain, domainExtentConstrain, projectionTranslateConstrain, userConstrain].filter(Boolean) as Array<(t: { scale: number; translate: { x: number; y: number } }) => { scale: number; translate: { x: number; y: number } }>;
9731025
if (constrains.length === 0) return undefined;
9741026
if (constrains.length === 1) return constrains[0];
9751027
return (t: { scale: number; translate: { x: number; y: number } }) => {
@@ -1037,7 +1089,7 @@
10371089
>
10381090
{#key chartState.isMounted}
10391091
<!-- svelte-ignore ownership_invalid_binding -->
1040-
{@const { domainExtent: _de, constrain: _uc, apply: _apply, scaleExtent: _se, ...transformProps } = transform ?? {}}
1092+
{@const { domainExtent: _de, constrain: _uc, apply: _apply, scaleExtent: _se, translateExtent: _te, ...transformProps } = transform ?? {}}
10411093
<TransformContext
10421094
bind:state={chartState.transformState}
10431095
mode={transform?.mode ?? 'none'}
@@ -1046,6 +1098,7 @@
10461098
{processTranslate}
10471099
{...transformProps}
10481100
scaleExtent={resolvedScaleExtent}
1101+
translateExtent={resolvedTranslateExtent}
10491102
constrain={composedConstrain}
10501103
disablePointer={(brush === true || (typeof brush === 'object' && !brush.disabled)) || transform?.disablePointer}
10511104
{ondragstart}

packages/layerchart/src/lib/components/TransformContext.svelte.test.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('TransformContext', () => {
1414
height: 300,
1515
geo: {
1616
projection: geoMercator,
17-
fitGeojson: { type: 'Sphere' } as GeoJSON.GeoJsonObject,
17+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
1818
},
1919
transform: {
2020
mode: 'projection' as const,
@@ -36,7 +36,7 @@ describe('TransformContext', () => {
3636
// Switch to orthographic (globe)
3737
chartProps.geo = {
3838
projection: geoOrthographic,
39-
fitGeojson: { type: 'Sphere' } as GeoJSON.GeoJsonObject,
39+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
4040
};
4141
await tick();
4242

@@ -53,7 +53,7 @@ describe('TransformContext', () => {
5353
height: 300,
5454
geo: {
5555
projection: geoOrthographic,
56-
fitGeojson: { type: 'Sphere' } as GeoJSON.GeoJsonObject,
56+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
5757
},
5858
transform: {
5959
mode: 'projection' as const,
@@ -75,7 +75,7 @@ describe('TransformContext', () => {
7575
// Switch to Mercator (flat)
7676
chartProps.geo = {
7777
projection: geoMercator,
78-
fitGeojson: { type: 'Sphere' } as GeoJSON.GeoJsonObject,
78+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
7979
};
8080
await tick();
8181

@@ -93,7 +93,7 @@ describe('TransformContext', () => {
9393
height: 300,
9494
geo: {
9595
projection: geoOrthographic,
96-
fitGeojson: { type: 'Sphere' } as GeoJSON.GeoJsonObject,
96+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
9797
},
9898
transform: {
9999
mode: 'projection' as const,
@@ -122,6 +122,50 @@ describe('TransformContext', () => {
122122
expect(chartContext.geo.projection?.scale()).toBeCloseTo(initialScale * 2, 0);
123123
});
124124

125+
it('should interpret scaleExtent as relative multipliers in projection mode', async () => {
126+
let chartContext: any;
127+
128+
render(TransformTestHarness, {
129+
chartProps: {
130+
height: 300,
131+
geo: {
132+
projection: geoMercator,
133+
fitGeojson: { type: 'Sphere' } as unknown as GeoJSON.GeoJsonObject,
134+
},
135+
transform: {
136+
mode: 'projection' as const,
137+
scrollMode: 'scale' as const,
138+
scaleExtent: [0.5, 2] as [number, number],
139+
},
140+
},
141+
oncontext: (ctx: any) => {
142+
chartContext = ctx;
143+
},
144+
});
145+
146+
await vi.waitFor(() => expect(chartContext).toBeDefined());
147+
148+
const initialScale = chartContext.transform.scale;
149+
expect(initialScale).toBeGreaterThan(10); // Mercator fitSize scale is typically large
150+
151+
// Try to zoom way beyond 2x — should be clamped to 2x initial
152+
chartContext.transform.setScale(initialScale * 5, { instant: true });
153+
await tick();
154+
155+
await vi.waitFor(() => {
156+
// Should be clamped to ~2x the initial scale
157+
expect(chartContext.transform.scale).toBeCloseTo(initialScale * 2, 0);
158+
});
159+
160+
// Try to zoom below 0.5x — should be clamped to 0.5x initial
161+
chartContext.transform.setScale(initialScale * 0.1, { instant: true });
162+
await tick();
163+
164+
await vi.waitFor(() => {
165+
expect(chartContext.transform.scale).toBeCloseTo(initialScale * 0.5, 0);
166+
});
167+
});
168+
125169
it('should sync disablePointer reactively', async () => {
126170
let chartContext: any;
127171

packages/layerchart/src/lib/states/transform.svelte.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ export class TransformState {
190190
private _applyTranslate(x: number, y: number, deltaX: number, deltaY: number) {
191191
if (this.processTranslate) return this.processTranslate(x, y, deltaX, deltaY);
192192
if (this.mode === 'domain') {
193-
// Negate deltaY because screen Y (top→bottom) is inverted vs data Y (bottom→top)
193+
// Negate deltaY because screen Y (top→bottom) is inverted vs data Y (bottom→top).
194+
// This works for both normal and reversed Y domains because _computeTransformDomain
195+
// uses signed range, which naturally handles the reversal.
194196
if (this.axis === 'x') return { x: x + deltaX, y: 0 };
195197
if (this.axis === 'y') return { x: 0, y: y - deltaY };
196198
return { x: x + deltaX, y: y - deltaY };

0 commit comments

Comments
 (0)