|
84 | 84 | */ |
85 | 85 | referencePoint?: [number, number]; |
86 | 86 |
|
| 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 | +
|
87 | 99 | /** |
88 | 100 | * The placement of the legend. |
89 | 101 | */ |
|
141 | 153 | height = 4, |
142 | 154 | title = '', |
143 | 155 | referencePoint, |
| 156 | + referenceScale, |
144 | 157 | placement, |
145 | 158 | color = 'currentColor', |
146 | 159 | classes = {}, |
|
163 | 176 | // `null` if no projection or invert is unavailable (or numerically degenerate). |
164 | 177 | const pixelsPerUnit = $derived.by(() => { |
165 | 178 | 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 | + } |
179 | 210 |
|
180 | 211 | // In `canvas` transform mode the projection itself is not re-scaled — the |
181 | 212 | // rendered output is visually scaled by `ctx.transform.scale`, so we need |
|
0 commit comments