Skip to content

Commit e87ebe5

Browse files
committed
projection autoheight
1 parent d70fd8b commit e87ebe5

12 files changed

+339
-31
lines changed

src/dimensions.js

Lines changed: 26 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 fit
54+
// 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+
width - (marginLeft + marginRight),
61+
marginTop + marginBottom // actual marginY, for projections
62+
) ?? autoHeight(scales, Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault))
5763
} = options;
5864

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

77-
function autoHeight({y, fy, fx}, geometry) {
83+
function autoHeight({y, fy, fx}, paddingY) {
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 + paddingY;
87+
}
88+
89+
function autoHeightProjection({fy, fx}, fitRatio, width, marginY) {
90+
if (fitRatio > 0) {
91+
return (
92+
Math.ceil(
93+
Math.max(
94+
50,
95+
Math.min(1200, (width * (fx ? fx.scale.bandwidth() : 1) * fitRatio) / (fy ? fy.scale.bandwidth() : 1))
96+
)
97+
) + marginY
98+
);
99+
}
81100
}

src/projection.js

Lines changed: 32 additions & 24 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,
@@ -76,6 +80,7 @@ export function Projection(
7680
let transform;
7781

7882
// If a domain is specified, fit the projection to the frame.
83+
let ratio = projection.ratio;
7984
if (domain != null) {
8085
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
8186
const k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
@@ -87,6 +92,7 @@ export function Projection(
8792
this.stream.point(x * k + tx, y * k + ty);
8893
}
8994
});
95+
ratio = Math.max(0.5, Math.min(golden, (y1 - y0) / (x1 - x0)));
9096
} else {
9197
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
9298
}
@@ -101,54 +107,43 @@ export function Projection(
101107
}
102108
});
103109

104-
return {stream: (s) => projection.stream(transform.stream(clip(s)))};
105-
}
106-
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;
110+
return {stream: (s) => projection.stream(transform.stream(clip(s))), ratio};
113111
}
114112

115-
const pi = Math.PI;
116-
const tau = 2 * pi;
117-
118113
function namedProjection(projection) {
119114
switch (`${projection}`.toLowerCase()) {
120115
case "albers-usa":
121-
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673);
116+
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673, 610 / 975);
122117
case "albers":
123-
return conicProjection(geoAlbers, 0.7463, 0.4673);
118+
return conicProjection(geoAlbers, 0.7463, 0.4673, 610 / 975);
124119
case "azimuthal-equal-area":
125-
return scaleProjection(geoAzimuthalEqualArea, 4, 4);
120+
return scaleProjection(geoAzimuthalEqualArea, 4, 4, 1);
126121
case "azimuthal-equidistant":
127-
return scaleProjection(geoAzimuthalEquidistant, tau, tau);
122+
return scaleProjection(geoAzimuthalEquidistant, tau, tau, 1);
128123
case "conic-conformal":
129124
return conicProjection(geoConicConformal, tau, tau);
130125
case "conic-equal-area":
131126
return conicProjection(geoConicEqualArea, 6.1702, 2.9781);
132127
case "conic-equidistant":
133128
return conicProjection(geoConicEquidistant, 7.312, 3.6282);
134129
case "equal-earth":
135-
return scaleProjection(geoEqualEarth, 5.4133, 2.6347);
130+
return scaleProjection(geoEqualEarth, 5.4133, 2.6347, 0.4867);
136131
case "equirectangular":
137-
return scaleProjection(geoEquirectangular, tau, pi);
132+
return scaleProjection(geoEquirectangular, tau, pi, 0.5);
138133
case "gnomonic":
139-
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
134+
return scaleProjection(geoGnomonic, 3.4641, 3.4641, 1);
140135
case "identity":
141136
return identity;
142137
case "reflect-y":
143138
return reflectY;
144139
case "mercator":
145-
return scaleProjection(geoMercator, tau, tau);
140+
return scaleProjection(geoMercator, tau, tau, 1);
146141
case "orthographic":
147-
return scaleProjection(geoOrthographic, 2, 2);
142+
return scaleProjection(geoOrthographic, 2, 2, 1);
148143
case "stereographic":
149-
return scaleProjection(geoStereographic, 2, 2);
144+
return scaleProjection(geoStereographic, 2, 2, 1);
150145
case "transverse-mercator":
151-
return scaleProjection(geoTransverseMercator, tau, tau);
146+
return scaleProjection(geoTransverseMercator, tau, tau, 1);
152147
default:
153148
throw new Error(`unknown projection type: ${projection}`);
154149
}
@@ -165,14 +160,15 @@ function maybePostClip(clip, x1, y1, x2, y2) {
165160
}
166161
}
167162

168-
function scaleProjection(createProjection, kx, ky) {
163+
function scaleProjection(createProjection, kx, ky, ratio) {
169164
return ({width, height, rotate, precision = 0.15, clip}) => {
170165
const projection = createProjection();
171166
if (precision != null) projection.precision?.(precision);
172167
if (rotate != null) projection.rotate?.(rotate);
173168
if (typeof clip === "number") projection.clipAngle?.(clip);
174169
projection.scale(Math.min(width / kx, height / ky));
175170
projection.translate([width / 2, height / 2]);
171+
if (ratio > 0) projection.ratio = ratio;
176172
return projection;
177173
};
178174
}
@@ -218,3 +214,15 @@ export function applyProjection(values, projection) {
218214
stream.point(x[i], y[i]);
219215
}
220216
}
217+
218+
// When a projection is specified, try to determine a good value for the
219+
// projection’s height, if it is a named projection. When we don’t have a way to
220+
// know, the golden ratio is our best guess.
221+
export function projectionFitRatio({projection} = {}, geometry) {
222+
projection = Projection(
223+
{projection},
224+
{width: 100, height: 300, marginLeft: 0, marginRight: 0, marginTop: 0, marginBottom: 0}
225+
);
226+
if (projection == null) return geometry ? golden - 1 : 0;
227+
return projection.ratio ?? golden - 1;
228+
}

test/output/projectionHeightAlbers.svg

Lines changed: 23 additions & 0 deletions
Loading

test/output/projectionHeightEqualEarth.svg

Lines changed: 48 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ export {default as projectionFitBertin1953} from "./projection-fit-bertin1953.js
181181
export {default as projectionFitConic} from "./projection-fit-conic.js";
182182
export {default as projectionFitIdentity} from "./projection-fit-identity.js";
183183
export {default as projectionFitUsAlbers} from "./projection-fit-us-albers.js";
184+
export {default as projectionHeightAlbers} from "./projection-height-albers.js";
185+
export {default as projectionHeightEqualEarth} from "./projection-height-equal-earth.js";
186+
export {default as projectionHeightMercator} from "./projection-height-mercator.js";
187+
export {default as projectionHeightOrthographic} from "./projection-height-orthographic.js";
184188
export {default as randomBins} from "./random-bins.js";
185189
export {default as randomBinsXY} from "./random-bins-xy.js";
186190
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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
width: 975,
11+
// expected height: 610,
12+
projection: {
13+
type: "albers-usa"
14+
},
15+
marks: [
16+
Plot.geo(conus, {strokeWidth: 1.5}),
17+
Plot.geo(countymesh, {strokeOpacity: 0.1}),
18+
Plot.frame({stroke: "red", strokeDasharray: 4})
19+
]
20+
});
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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-110m.json");
7+
const land = feature(world, world.objects.land);
8+
return Plot.plot({
9+
facet: {data: [0, 1], x: [0, 1]},
10+
projection: "equal-earth",
11+
marks: [
12+
Plot.geo(land, {fill: "currentColor"}),
13+
Plot.graticule(),
14+
Plot.sphere(),
15+
Plot.frame({stroke: "red", strokeDasharray: 4})
16+
]
17+
});
18+
}

0 commit comments

Comments
 (0)