Skip to content

Commit 48da6b0

Browse files
Filmbostock
andauthored
affine fit (#1135)
* Fit a projection to a given domain closes #1125 * tweak style Co-authored-by: Mike Bostock <[email protected]>
1 parent 1bc1edb commit 48da6b0

File tree

5 files changed

+84
-6
lines changed

5 files changed

+84
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ If the **projection** option is specified as an object, the following additional
341341
* projection.**parallels** - the [standard parallels](https://github.com/d3/d3-geo/blob/main/README.md#conic_parallels) (for conic projections only)
342342
* projection.**precision** - the [sampling threshold](https://github.com/d3/d3-geo/blob/main/README.md#projection_precision)
343343
* projection.**rotate** - a two- or three- element array of Euler angles to rotate the sphere
344+
* projection.**domain** - a GeoJSON object to fit in the center of the frame
344345
* projection.**inset** - inset by the given amount in pixels when fitting to the frame (default zero)
345346
* projection.**insetLeft** - inset from the left edge of the frame (defaults to inset)
346347
* projection.**insetRight** - inset from the right edge of the frame (defaults to inset)

src/projection.js

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
geoAlbersUsa,
44
geoAzimuthalEqualArea,
55
geoAzimuthalEquidistant,
6+
geoPath,
67
geoConicConformal,
78
geoConicEqualArea,
89
geoConicEquidistant,
@@ -17,6 +18,7 @@ import {
1718
geoTransverseMercator
1819
} from "d3";
1920
import {isObject} from "./options.js";
21+
import {warn} from "./warnings.js";
2022

2123
export function Projection(
2224
{
@@ -32,6 +34,7 @@ export function Projection(
3234
if (projection == null) return;
3335
if (typeof projection.stream === "function") return projection; // d3 projection
3436
let options;
37+
let domain;
3538

3639
// If the projection was specified as an object with additional options,
3740
// extract those. The order of precedence for insetTop (and other insets) is:
@@ -41,6 +44,7 @@ export function Projection(
4144
let inset;
4245
({
4346
type: projection,
47+
domain,
4448
inset,
4549
insetTop = inset !== undefined ? inset : insetTop,
4650
insetRight = inset !== undefined ? inset : insetRight,
@@ -62,13 +66,31 @@ export function Projection(
6266
// The projection initializer might decide to not use a projection.
6367
if (projection == null) return;
6468

65-
// If there’s no need to translate, return the projection as-is for speed.
66-
// TODO Maybe scale to fit features here?
67-
const tx = marginLeft + insetLeft;
68-
const ty = marginTop + insetTop;
69-
if (tx === 0 && ty === 0) return projection;
69+
// If there’s no need to translate or scale, return the projection as-is for speed.
70+
let tx = marginLeft + insetLeft;
71+
let ty = marginTop + insetTop;
72+
if (tx === 0 && ty === 0 && domain == null) return projection;
73+
74+
// Otherwise wrap the projection stream with a suitable transform. If a domain
75+
// is specified, fit the projection to the frame. Otherwise, translate.
76+
if (domain) {
77+
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
78+
const kx = dx / (x1 - x0);
79+
const ky = dy / (y1 - y0);
80+
const k = Math.min(kx, ky);
81+
if (k > 0) {
82+
tx -= (k === kx ? x0 * k : ((x0 + x1) * k - dx) / 2);
83+
ty -= (k === ky ? y0 * k : ((y0 + y1) * k - dy) / 2);
84+
const {stream: affine} = geoTransform({
85+
point(x, y) {
86+
this.stream.point(x * k + tx, y * k + ty);
87+
}
88+
});
89+
return {stream: (s) => projection.stream(affine(s))};
90+
}
91+
warn(`The projection could not be fit to the specified domain. Using the default scale.`);
92+
}
7093

71-
// Otherwise wrap the projection stream with a translate transform.
7294
const {stream: translate} = geoTransform({
7395
point(x, y) {
7496
this.stream.point(x + tx, y + ty);

test/output/projectionFitAntarctica.svg

Lines changed: 35 additions & 0 deletions
Loading

test/plots/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export {default as penguinSpeciesIsland} from "./penguin-species-island.js";
172172
export {default as penguinSpeciesIslandRelative} from "./penguin-species-island-relative.js";
173173
export {default as penguinSpeciesIslandSex} from "./penguin-species-island-sex.js";
174174
export {default as polylinear} from "./polylinear.js";
175+
export {default as projectionFitAntarctica} from "./projection-fit-antarctica.js";
175176
export {default as randomBins} from "./random-bins.js";
176177
export {default as randomBinsXY} from "./random-bins-xy.js";
177178
export {default as randomQuantile} from "./random-quantile.js";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
4+
5+
export default async function () {
6+
const world = await d3.json("data/countries-50m.json");
7+
const land = feature(world, world.objects.land);
8+
const domain = feature(
9+
world,
10+
world.objects.countries.geometries.find((d) => d.properties.name === "Antarctica")
11+
);
12+
return Plot.plot({
13+
width: 600,
14+
height: 600,
15+
inset: 30,
16+
projection: {type: "azimuthal-equidistant", rotate: [0, 90], domain},
17+
marks: [Plot.geo(land, {fill: "#666"}), Plot.graticule(), Plot.geo(domain, {fill: "black"}), Plot.frame()]
18+
});
19+
}

0 commit comments

Comments
 (0)