Skip to content

Commit 68bbcc8

Browse files
committed
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
1 parent 680966f commit 68bbcc8

14 files changed

+390
-36
lines changed

src/dimensions.js

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

@@ -49,11 +49,17 @@ export function Dimensions(scales, geometry, axes, options = {}) {
4949
marginLeft = +marginLeft;
5050

5151
// Compute the outer dimensions of the plot. If the top and bottom margins are
52-
// specified explicitly, adjust the automatic height accordingly.
52+
// specified explicitly, adjust the automatic height accordingly. If a
53+
// projection is specified, base the computation on the projection’s default
54+
// fit ratio.
5355
let {
5456
width = 640,
55-
height = autoHeight(scales, geometry || hasProjection(options)) +
56-
Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
57+
height = (autoHeightProjection(
58+
scales,
59+
projectionFitRatio(options, geometry),
60+
640 - marginLeftDefault - marginRightDefault,
61+
marginTopDefault + marginBottomDefault
62+
) ?? autoHeight(scales)) + Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
5763
} = options;
5864

5965
// Coerce the width and height.
@@ -74,8 +80,19 @@ export function Dimensions(scales, geometry, axes, options = {}) {
7480
};
7581
}
7682

77-
function autoHeight({y, fy, fx}, geometry) {
83+
function autoHeight({y, fy, fx}) {
7884
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;
85+
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
86+
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
87+
}
88+
89+
function autoHeightProjection({fy, fx}, ratio, width, marginY) {
90+
if (ratio > 0) {
91+
return Math.round(
92+
Math.max(
93+
140,
94+
Math.min(1260, (width * (fx ? fx.scale.bandwidth() : 1) * ratio) / (fy ? fy.scale.bandwidth() : 1))
95+
) + marginY
96+
);
97+
}
8198
}

src/projection.js

Lines changed: 34 additions & 29 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 golden = 1.618;
26+
2327
export function Projection(
2428
{
2529
projection,
@@ -57,8 +61,8 @@ export function Projection(
5761
if (projection == null) return;
5862
}
5963

60-
// For named projections, retrieve the corresponding projection initializer.
61-
if (typeof projection !== "function") projection = namedProjection(projection);
64+
// For named projections, retrieve the corresponding projection initializer and default aspect ratio.
65+
if (typeof projection !== "function") ({projection} = namedProjection(projection));
6266

6367
// Compute the frame dimensions and invoke the projection initializer.
6468
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
@@ -104,51 +108,40 @@ 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":
121-
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673);
114+
return {projection: scaleProjection(geoAlbersUsa, 0.7463, 0.4673), ratio: 610 / 975};
122115
case "albers":
123-
return conicProjection(geoAlbers, 0.7463, 0.4673);
116+
return {projection: conicProjection(geoAlbers, 0.7463, 0.4673), ratio: 610 / 975};
124117
case "azimuthal-equal-area":
125-
return scaleProjection(geoAzimuthalEqualArea, 4, 4);
118+
return {projection: scaleProjection(geoAzimuthalEqualArea, 4, 4), ratio: 1};
126119
case "azimuthal-equidistant":
127-
return scaleProjection(geoAzimuthalEquidistant, tau, tau);
120+
return {projection: scaleProjection(geoAzimuthalEquidistant, tau, tau), ratio: 1};
128121
case "conic-conformal":
129-
return conicProjection(geoConicConformal, tau, tau);
122+
return {projection: conicProjection(geoConicConformal, tau, tau)};
130123
case "conic-equal-area":
131-
return conicProjection(geoConicEqualArea, 6.1702, 2.9781);
124+
return {projection: conicProjection(geoConicEqualArea, 6.1702, 2.9781)};
132125
case "conic-equidistant":
133-
return conicProjection(geoConicEquidistant, 7.312, 3.6282);
126+
return {projection: conicProjection(geoConicEquidistant, 7.312, 3.6282)};
134127
case "equal-earth":
135-
return scaleProjection(geoEqualEarth, 5.4133, 2.6347);
128+
return {projection: scaleProjection(geoEqualEarth, 5.4133, 2.6347), ratio: 0.4867};
136129
case "equirectangular":
137-
return scaleProjection(geoEquirectangular, tau, pi);
130+
return {projection: scaleProjection(geoEquirectangular, tau, pi), ratio: 0.5};
138131
case "gnomonic":
139-
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
132+
return {projection: scaleProjection(geoGnomonic, 3.4641, 3.4641)};
140133
case "identity":
141-
return identity;
134+
return {projection: identity};
142135
case "reflect-y":
143-
return reflectY;
136+
return {projection: reflectY};
144137
case "mercator":
145-
return scaleProjection(geoMercator, tau, tau);
138+
return {projection: scaleProjection(geoMercator, tau, tau), ratio: 1};
146139
case "orthographic":
147-
return scaleProjection(geoOrthographic, 2, 2);
140+
return {projection: scaleProjection(geoOrthographic, 2, 2), ratio: 1};
148141
case "stereographic":
149-
return scaleProjection(geoStereographic, 2, 2);
142+
return {projection: scaleProjection(geoStereographic, 2, 2)};
150143
case "transverse-mercator":
151-
return scaleProjection(geoTransverseMercator, tau, tau);
144+
return {projection: scaleProjection(geoTransverseMercator, tau, tau), ratio: 1};
152145
default:
153146
throw new Error(`unknown projection type: ${projection}`);
154147
}
@@ -232,3 +225,15 @@ function applyProjection(cx, cy, values, projection) {
232225
stream.point(x[i], y[i]);
233226
}
234227
}
228+
229+
// When a projection is specified, try to determine a good value for the
230+
// projection’s height, if it is a named projection. When we don’t have a way to
231+
// know, the golden ratio is our best guess.
232+
export function projectionFitRatio({projection} = {}, geometry) {
233+
if (isObject(projection)) projection = projection.type;
234+
if (typeof projection === "string") {
235+
const {ratio} = namedProjection(projection);
236+
if (ratio) return ratio;
237+
}
238+
return geometry ? golden - 1 : 0;
239+
}

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)