Skip to content

Commit 558bcdc

Browse files
Filmbostock
andauthored
simpler projection autoHeight (#1166)
* autoHeight the height for projections (including the null projection when Plot.geo is used) is computed for a target frame with an aspect ratio that defaults to the golden ratio. When using facets, the computation takes into account the number of rows and columns, with similar limits to what total size is acceptable (1260px). The projection's preferred aspect ratio is adapted for a few named projections. For example "equal-earth" and "equirectangular" are wider than tall, "mercator" and a few azimuthal projections default to a square. closes #1136 supersedes #1162 * determine the width of a facet from the (default) value of paddingInner=0.1, paddingOuter=0, instead of relying on the fx scale still being in the intermediate state where its range is [0,1]. There's a 2px discrepancy in the tests, which is probably coming from some rounding. * refactor: use the ratio implied by tx and ty * aspectRatio * auto height based on width * Update src/dimensions.js Co-authored-by: Philippe Rivière <[email protected]> * fix auto height when geometry-less projection * fix default height for custom projections Co-authored-by: Mike Bostock <[email protected]>
1 parent c6a49aa commit 558bcdc

15 files changed

+413
-44
lines changed

src/dimensions.js

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {hasProjection} from "./projection.js";
1+
import {projectionAspectRatio} from "./projection.js";
22
import {isOrdinalScale} from "./scales.js";
33
import {offset} from "./style.js";
44

@@ -52,8 +52,13 @@ export function Dimensions(scales, geometry, axes, options = {}) {
5252
// specified explicitly, adjust the automatic height accordingly.
5353
let {
5454
width = 640,
55-
height = autoHeight(scales, geometry || hasProjection(options)) +
56-
Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
55+
height = autoHeight(scales, geometry, options, {
56+
width,
57+
marginTopDefault,
58+
marginBottomDefault,
59+
marginRightDefault,
60+
marginLeftDefault
61+
}) + Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
5762
} = options;
5863

5964
// Coerce the width and height.
@@ -74,8 +79,23 @@ export function Dimensions(scales, geometry, axes, options = {}) {
7479
};
7580
}
7681

77-
function autoHeight({y, fy, fx}, geometry) {
82+
function autoHeight(
83+
{y, fy, fx},
84+
geometry,
85+
{projection},
86+
{width, marginTopDefault, marginBottomDefault, marginRightDefault, marginLeftDefault}
87+
) {
7888
const nfy = fy ? fy.scale.domain().length : 1;
79-
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : geometry ? 17 : 1;
80-
return !!(y || fy || geometry) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
89+
90+
// If a projection is specified, use its natural aspect ratio (if known).
91+
const ar = projectionAspectRatio(projection, geometry);
92+
if (ar) {
93+
const nfx = fx ? fx.scale.domain().length : 1;
94+
const far = ((1.1 * nfy - 0.1) / (1.1 * nfx - 0.1)) * ar; // 0.1 is default facet padding
95+
const lar = Math.max(0.1, Math.min(10, far)); // clamp the aspect ratio to a “reasonable” value
96+
return Math.round((width - marginLeftDefault - marginRightDefault) * lar + marginTopDefault + marginBottomDefault);
97+
}
98+
99+
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
100+
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
81101
}

src/projection.js

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {
2020
import {constant, isObject} from "./options.js";
2121
import {warn} from "./warnings.js";
2222

23+
const pi = Math.PI;
24+
const tau = 2 * pi;
25+
const defaultAspectRatio = 0.618;
26+
2327
export function Projection(
2428
{
2529
projection,
@@ -58,7 +62,7 @@ export function Projection(
5862
}
5963

6064
// For named projections, retrieve the corresponding projection initializer.
61-
if (typeof projection !== "function") projection = namedProjection(projection);
65+
if (typeof projection !== "function") ({type: projection} = namedProjection(projection));
6266

6367
// Compute the frame dimensions and invoke the projection initializer.
6468
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
@@ -104,17 +108,6 @@ export function Projection(
104108
return {stream: (s) => projection.stream(transform.stream(clip(s)))};
105109
}
106110

107-
export function hasProjection({projection} = {}) {
108-
if (projection == null) return false;
109-
if (typeof projection.stream === "function") return true; // d3 projection
110-
if (isObject(projection)) ({type: projection} = projection);
111-
if (typeof projection !== "function") projection = namedProjection(projection);
112-
return projection != null;
113-
}
114-
115-
const pi = Math.PI;
116-
const tau = 2 * pi;
117-
118111
function namedProjection(projection) {
119112
switch (`${projection}`.toLowerCase()) {
120113
case "albers-usa":
@@ -138,9 +131,9 @@ function namedProjection(projection) {
138131
case "gnomonic":
139132
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
140133
case "identity":
141-
return identity;
134+
return {type: identity};
142135
case "reflect-y":
143-
return reflectY;
136+
return {type: reflectY};
144137
case "mercator":
145138
return scaleProjection(geoMercator, tau, tau);
146139
case "orthographic":
@@ -166,14 +159,35 @@ function maybePostClip(clip, x1, y1, x2, y2) {
166159
}
167160

168161
function scaleProjection(createProjection, kx, ky) {
169-
return ({width, height, rotate, precision = 0.15, clip}) => {
170-
const projection = createProjection();
171-
if (precision != null) projection.precision?.(precision);
172-
if (rotate != null) projection.rotate?.(rotate);
173-
if (typeof clip === "number") projection.clipAngle?.(clip);
174-
projection.scale(Math.min(width / kx, height / ky));
175-
projection.translate([width / 2, height / 2]);
176-
return projection;
162+
return {
163+
type: ({width, height, rotate, precision = 0.15, clip}) => {
164+
const projection = createProjection();
165+
if (precision != null) projection.precision?.(precision);
166+
if (rotate != null) projection.rotate?.(rotate);
167+
if (typeof clip === "number") projection.clipAngle?.(clip);
168+
projection.scale(Math.min(width / kx, height / ky));
169+
projection.translate([width / 2, height / 2]);
170+
return projection;
171+
},
172+
aspectRatio: ky / kx
173+
};
174+
}
175+
176+
function conicProjection(createProjection, kx, ky) {
177+
const {type, aspectRatio} = scaleProjection(createProjection, kx, ky);
178+
return {
179+
type: (options) => {
180+
const {parallels, domain, width, height} = options;
181+
const projection = type(options);
182+
if (parallels != null) {
183+
projection.parallels(parallels);
184+
if (domain === undefined) {
185+
projection.fitSize([width, height], {type: "Sphere"});
186+
}
187+
}
188+
return projection;
189+
},
190+
aspectRatio
177191
};
178192
}
179193

@@ -187,21 +201,6 @@ const reflectY = constant(
187201
})
188202
);
189203

190-
function conicProjection(createProjection, kx, ky) {
191-
createProjection = scaleProjection(createProjection, kx, ky);
192-
return (options) => {
193-
const {parallels, domain, width, height} = options;
194-
const projection = createProjection(options);
195-
if (parallels != null) {
196-
projection.parallels(parallels);
197-
if (domain === undefined) {
198-
projection.fitSize([width, height], {type: "Sphere"});
199-
}
200-
}
201-
return projection;
202-
};
203-
}
204-
205204
// Applies a point-wise projection to the given paired x and y channels.
206205
// Note: mutates values!
207206
export function maybeProject(cx, cy, channels, values, context) {
@@ -233,3 +232,21 @@ function project(cx, cy, values, projection) {
233232
stream.point(x[i], y[i]);
234233
}
235234
}
235+
236+
// When a named projection is specified, we can use its natural aspect ratio to
237+
// determine a good value for the projection’s height based on the desired
238+
// width. When we don’t have a way to know, the golden ratio is our best guess.
239+
// Due to a circular dependency (we need to know the height before we can
240+
// construct the projection), we have to test the raw projection option rather
241+
// than the materialized projection; therefore we must be extremely careful that
242+
// the logic of this function exactly matches Projection above!
243+
export function projectionAspectRatio(projection, geometry) {
244+
if (typeof projection?.stream === "function") return defaultAspectRatio;
245+
if (isObject(projection)) projection = projection.type;
246+
if (projection == null) return geometry ? defaultAspectRatio : undefined;
247+
if (typeof projection !== "function") {
248+
const {aspectRatio} = namedProjection(projection);
249+
if (aspectRatio) return aspectRatio;
250+
}
251+
return defaultAspectRatio;
252+
}

test/output/projectionHeightAlbers.svg

Lines changed: 23 additions & 0 deletions
Loading

test/output/projectionHeightEqualEarth.svg

Lines changed: 48 additions & 0 deletions
Loading

test/output/projectionHeightGeometry.svg

Lines changed: 36 additions & 0 deletions
Loading

test/output/projectionHeightMercator.svg

Lines changed: 48 additions & 0 deletions
Loading

test/output/projectionHeightOrthographic.svg

Lines changed: 80 additions & 0 deletions
Loading

test/plots/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ export {default as projectionFitBertin1953} from "./projection-fit-bertin1953.js
182182
export {default as projectionFitConic} from "./projection-fit-conic.js";
183183
export {default as projectionFitIdentity} from "./projection-fit-identity.js";
184184
export {default as projectionFitUsAlbers} from "./projection-fit-us-albers.js";
185+
export {default as projectionHeightAlbers} from "./projection-height-albers.js";
186+
export {default as projectionHeightEqualEarth} from "./projection-height-equal-earth.js";
187+
export {default as projectionHeightGeometry} from "./projection-height-geometry.js";
188+
export {default as projectionHeightMercator} from "./projection-height-mercator.js";
189+
export {default as projectionHeightOrthographic} from "./projection-height-orthographic.js";
185190
export {default as randomBins} from "./random-bins.js";
186191
export {default as randomBinsXY} from "./random-bins-xy.js";
187192
export {default as randomQuantile} from "./random-quantile.js";

test/plots/projection-fit-conic.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export default async function () {
66
const world = await d3.json("data/countries-110m.json");
77
const land = feature(world, world.objects.land);
88
return Plot.plot({
9+
width: 640,
10+
height: 400,
911
projection: {
1012
type: "conic-equal-area",
1113
parallels: [-42, -5],
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 {mesh} from "topojson-client";
4+
5+
export default async function () {
6+
const [conus, countymesh] = await d3
7+
.json("data/us-counties-10m.json")
8+
.then((us) => [mesh(us, us.objects.states, (a, b) => a === b), mesh(us, us.objects.counties, (a, b) => a !== b)]);
9+
return Plot.plot({
10+
projection: {
11+
type: "albers-usa"
12+
},
13+
marks: [
14+
Plot.geo(conus, {strokeWidth: 1.5}),
15+
Plot.geo(countymesh, {strokeOpacity: 0.1}),
16+
Plot.frame({stroke: "red", strokeDasharray: 4})
17+
]
18+
});
19+
}

0 commit comments

Comments
 (0)