Skip to content

Commit 8f676ef

Browse files
committed
feat: Support pre-projected topologies in GeoLegend via referenceScale
1 parent 85076f1 commit 8f676ef

File tree

4 files changed

+64
-14
lines changed

4 files changed

+64
-14
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'layerchart': minor
3+
---
4+
5+
feat: Support pre-projected topologies in `GeoLegend` via `referenceScale`
6+
7+
Add a `referenceScale` prop to `GeoLegend` for charts that render pre-projected data with `geoIdentity` (e.g. `us-atlas`'s `counties-albers-10m` / `states-albers-10m`, pre-projected with `geoAlbersUsa().scale(1300)`). When provided, pixels-per-distance is derived from the chart's fit scale and the supplied base scale, bypassing the `projection.invert` + `geoDistance` path which only works for real lon/lat projections. The `GeoPath` bubble-map example now renders a correct scale bar.

docs/src/content/components/GeoLegend.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,15 @@ Use `labelPlacement="top"` to place labels above the bar so two legends with dif
4646
## Reference point
4747

4848
The pixels-per-unit ratio is measured at a single reference point on the projection — by default, the center of the chart's plot area (`[width / 2, height / 2]`). For conformal projections like Mercator where scale varies with latitude, the legend is only accurate at that reference point. Pass an explicit `referencePoint` in chart pixel coordinates to anchor the measurement elsewhere (e.g. near the legend itself, or over your region of interest).
49+
50+
## Pre-projected data (`geoIdentity`)
51+
52+
When rendering a topology whose coordinates have already been run through a projection (e.g. `us-atlas`'s `counties-albers-10m` / `states-albers-10m`), the chart uses `geoIdentity` as its projection. In that case `projection.invert` returns topology pixel coordinates rather than `[lon, lat]`, so the default distance calculation (which relies on `geoDistance` on inverted points) cannot work.
53+
54+
Pass `referenceScale` with the scale of the original projection used to pre-project the data to opt into a direct calculation that combines the chart's fit scale with the known base scale. For the `us-atlas` albers topologies, that value is `1300`:
55+
56+
```svelte
57+
<GeoLegend units="mi" referenceScale={1300} />
58+
```
59+
60+
See the [`GeoPath` bubble map example](/docs/components/GeoPath#bubble-map) for a full setup.

docs/src/examples/components/GeoPath/bubble-map.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@
145145

146146
<CircleLegend title="Population" tickFormat="metric" placement="bottom-right" />
147147

148-
<GeoLegend units="mi" placement="bottom-left" class="m-2" />
148+
<GeoLegend units="mi" referenceScale={1300} placement="bottom-left" class="m-2" />
149149

150150
<Tooltip.Root>
151151
{#snippet children({ data })}

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

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@
8484
*/
8585
referencePoint?: [number, number];
8686
87+
/**
88+
* Scale of the projection originally used to pre-project the data, for
89+
* charts that render pre-projected topologies via `geoIdentity`. For
90+
* example, the `us-atlas` `counties-albers-10m` / `states-albers-10m`
91+
* topologies are pre-projected with `geoAlbersUsa().scale(1300)`, so pass
92+
* `referenceScale={1300}`. When provided, pixels-per-distance is derived
93+
* directly from the chart's `geoIdentity` fit scale and this reference
94+
* scale, bypassing the `projection.invert` + `geoDistance` path which
95+
* does not work for pre-projected data.
96+
*/
97+
referenceScale?: number;
98+
8799
/**
88100
* The placement of the legend.
89101
*/
@@ -141,6 +153,7 @@
141153
height = 4,
142154
title = '',
143155
referencePoint,
156+
referenceScale,
144157
placement,
145158
color = 'currentColor',
146159
classes = {},
@@ -163,19 +176,37 @@
163176
// `null` if no projection or invert is unavailable (or numerically degenerate).
164177
const pixelsPerUnit = $derived.by(() => {
165178
const projection = ctx.geo?.projection;
166-
if (!projection || typeof projection.invert !== 'function') return null;
167-
168-
const refPx: [number, number] = referencePoint ?? [ctx.width / 2, ctx.height / 2];
169-
const a = projection.invert(refPx);
170-
const b = projection.invert([refPx[0] + 1, refPx[1]]);
171-
if (!a || !b) return null;
172-
if (!Number.isFinite(a[0]) || !Number.isFinite(b[0])) return null;
173-
174-
const radiansPerPx = geoDistance(a, b);
175-
if (!Number.isFinite(radiansPerPx) || radiansPerPx === 0) return null;
176-
177-
const unitsPerPx = radiansPerPx * earthRadius;
178-
let pxPerUnit = 1 / unitsPerPx;
179+
if (!projection) return null;
180+
181+
let pxPerUnit: number;
182+
183+
if (referenceScale != null) {
184+
// Pre-projected data path (e.g. `geoIdentity` + us-atlas
185+
// `counties-albers-10m`): `projection.invert` returns topology pixel
186+
// coordinates, not lon/lat, so `geoDistance` can't be used. Instead,
187+
// combine the chart's fit scale with the known base projection scale:
188+
// topology units per chart px = 1 / fitScale
189+
// radians per topology unit = 1 / referenceScale
190+
// units (mi/km) per radian = earthRadius
191+
// => px per unit = (fitScale * referenceScale) / earthRadius
192+
const fitScale = typeof projection.scale === 'function' ? projection.scale() : null;
193+
if (fitScale == null || !Number.isFinite(fitScale) || fitScale === 0) return null;
194+
pxPerUnit = (fitScale * referenceScale) / earthRadius;
195+
} else {
196+
if (typeof projection.invert !== 'function') return null;
197+
198+
const refPx: [number, number] = referencePoint ?? [ctx.width / 2, ctx.height / 2];
199+
const a = projection.invert(refPx);
200+
const b = projection.invert([refPx[0] + 1, refPx[1]]);
201+
if (!a || !b) return null;
202+
if (!Number.isFinite(a[0]) || !Number.isFinite(b[0])) return null;
203+
204+
const radiansPerPx = geoDistance(a, b);
205+
if (!Number.isFinite(radiansPerPx) || radiansPerPx === 0) return null;
206+
207+
const unitsPerPx = radiansPerPx * earthRadius;
208+
pxPerUnit = 1 / unitsPerPx;
209+
}
179210
180211
// In `canvas` transform mode the projection itself is not re-scaled — the
181212
// rendered output is visually scaled by `ctx.transform.scale`, so we need

0 commit comments

Comments
 (0)