Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions src/dimensions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {hasProjection} from "./projection.js";
import {projectionFitRatio} from "./projection.js";
import {isOrdinalScale} from "./scales.js";
import {offset} from "./style.js";

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

// Compute the outer dimensions of the plot. If the top and bottom margins are
// specified explicitly, adjust the automatic height accordingly.
// specified explicitly, adjust the automatic height accordingly. If a
// projection is specified, base the computation on the projection’s fit
// ratio.
let {
width = 640,
height = autoHeight(scales, geometry || hasProjection(options)) +
Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
height = autoHeightProjection(
scales,
projectionFitRatio(options, geometry),
width - (marginLeft + marginRight),
marginTop + marginBottom // actual marginY, for projections
) ?? autoHeight(scales, Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault))
} = options;

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

function autoHeight({y, fy, fx}, geometry) {
function autoHeight({y, fy, fx}, paddingY) {
const nfy = fy ? fy.scale.domain().length : 1;
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : geometry ? 17 : 1;
return !!(y || fy || geometry) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60 + paddingY;
}

function autoHeightProjection({fy, fx}, fitRatio, width, marginY) {
if (fitRatio > 0) {
return (
Math.ceil(
Math.max(
50,
Math.min(1200, (width * (fx ? fx.scale.bandwidth() : 1) * fitRatio) / (fy ? fy.scale.bandwidth() : 1))
)
) + marginY
);
}
}
56 changes: 32 additions & 24 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
import {constant, isObject} from "./options.js";
import {warn} from "./warnings.js";

const pi = Math.PI;
const tau = 2 * pi;
const golden = 1.618;

export function Projection(
{
projection,
Expand Down Expand Up @@ -76,6 +80,7 @@ export function Projection(
let transform;

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

return {stream: (s) => projection.stream(transform.stream(clip(s)))};
}

export function hasProjection({projection} = {}) {
if (projection == null) return false;
if (typeof projection.stream === "function") return true; // d3 projection
if (isObject(projection)) ({type: projection} = projection);
if (typeof projection !== "function") projection = namedProjection(projection);
return projection != null;
return {stream: (s) => projection.stream(transform.stream(clip(s))), ratio};
}

const pi = Math.PI;
const tau = 2 * pi;

function namedProjection(projection) {
switch (`${projection}`.toLowerCase()) {
case "albers-usa":
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673);
return scaleProjection(geoAlbersUsa, 0.7463, 0.4673, 610 / 975);
case "albers":
return conicProjection(geoAlbers, 0.7463, 0.4673);
return conicProjection(geoAlbers, 0.7463, 0.4673, 610 / 975);
case "azimuthal-equal-area":
return scaleProjection(geoAzimuthalEqualArea, 4, 4);
return scaleProjection(geoAzimuthalEqualArea, 4, 4, 1);
case "azimuthal-equidistant":
return scaleProjection(geoAzimuthalEquidistant, tau, tau);
return scaleProjection(geoAzimuthalEquidistant, tau, tau, 1);
case "conic-conformal":
return conicProjection(geoConicConformal, tau, tau);
case "conic-equal-area":
return conicProjection(geoConicEqualArea, 6.1702, 2.9781);
case "conic-equidistant":
return conicProjection(geoConicEquidistant, 7.312, 3.6282);
case "equal-earth":
return scaleProjection(geoEqualEarth, 5.4133, 2.6347);
return scaleProjection(geoEqualEarth, 5.4133, 2.6347, 0.4867);
case "equirectangular":
return scaleProjection(geoEquirectangular, tau, pi);
return scaleProjection(geoEquirectangular, tau, pi, 0.5);
case "gnomonic":
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
return scaleProjection(geoGnomonic, 3.4641, 3.4641, 1);
case "identity":
return identity;
case "reflect-y":
return reflectY;
case "mercator":
return scaleProjection(geoMercator, tau, tau);
return scaleProjection(geoMercator, tau, tau, 1);
case "orthographic":
return scaleProjection(geoOrthographic, 2, 2);
return scaleProjection(geoOrthographic, 2, 2, 1);
case "stereographic":
return scaleProjection(geoStereographic, 2, 2);
return scaleProjection(geoStereographic, 2, 2, 1);
case "transverse-mercator":
return scaleProjection(geoTransverseMercator, tau, tau);
return scaleProjection(geoTransverseMercator, tau, tau, 1);
default:
throw new Error(`unknown projection type: ${projection}`);
}
Expand All @@ -165,14 +160,15 @@ function maybePostClip(clip, x1, y1, x2, y2) {
}
}

function scaleProjection(createProjection, kx, ky) {
function scaleProjection(createProjection, kx, ky, ratio) {
return ({width, height, rotate, precision = 0.15, clip}) => {
const projection = createProjection();
if (precision != null) projection.precision?.(precision);
if (rotate != null) projection.rotate?.(rotate);
if (typeof clip === "number") projection.clipAngle?.(clip);
projection.scale(Math.min(width / kx, height / ky));
projection.translate([width / 2, height / 2]);
if (ratio > 0) projection.ratio = ratio;
return projection;
};
}
Expand Down Expand Up @@ -218,3 +214,15 @@ export function applyProjection(values, projection) {
stream.point(x[i], y[i]);
}
}

// When a projection is specified, try to determine a good value for the
// projection’s height, if it is a named projection. When we don’t have a way to
// know, the golden ratio is our best guess.
export function projectionFitRatio({projection} = {}, geometry) {
projection = Projection(
{projection},
{width: 100, height: 300, marginLeft: 0, marginRight: 0, marginTop: 0, marginBottom: 0}
);
if (projection == null) return geometry ? golden - 1 : 0;
return projection.ratio ?? golden - 1;
}
23 changes: 23 additions & 0 deletions test/output/projectionHeightAlbers.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions test/output/projectionHeightEqualEarth.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions test/output/projectionHeightMercator.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions test/output/projectionHeightOrthographic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions test/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ export {default as projectionFitBertin1953} from "./projection-fit-bertin1953.js
export {default as projectionFitConic} from "./projection-fit-conic.js";
export {default as projectionFitIdentity} from "./projection-fit-identity.js";
export {default as projectionFitUsAlbers} from "./projection-fit-us-albers.js";
export {default as projectionHeightAlbers} from "./projection-height-albers.js";
export {default as projectionHeightEqualEarth} from "./projection-height-equal-earth.js";
export {default as projectionHeightMercator} from "./projection-height-mercator.js";
export {default as projectionHeightOrthographic} from "./projection-height-orthographic.js";
export {default as randomBins} from "./random-bins.js";
export {default as randomBinsXY} from "./random-bins-xy.js";
export {default as randomQuantile} from "./random-quantile.js";
Expand Down
2 changes: 2 additions & 0 deletions test/plots/projection-fit-conic.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export default async function () {
const world = await d3.json("data/countries-110m.json");
const land = feature(world, world.objects.land);
return Plot.plot({
width: 640,
height: 400,
projection: {
type: "conic-equal-area",
parallels: [-42, -5],
Expand Down
21 changes: 21 additions & 0 deletions test/plots/projection-height-albers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {mesh} from "topojson-client";

export default async function () {
const [conus, countymesh] = await d3
.json("data/us-counties-10m.json")
.then((us) => [mesh(us, us.objects.states, (a, b) => a === b), mesh(us, us.objects.counties, (a, b) => a !== b)]);
return Plot.plot({
width: 975,
// expected height: 610,
projection: {
type: "albers-usa"
},
marks: [
Plot.geo(conus, {strokeWidth: 1.5}),
Plot.geo(countymesh, {strokeOpacity: 0.1}),
Plot.frame({stroke: "red", strokeDasharray: 4})
]
});
}
18 changes: 18 additions & 0 deletions test/plots/projection-height-equal-earth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {feature} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-110m.json");
const land = feature(world, world.objects.land);
return Plot.plot({
facet: {data: [0, 1], x: [0, 1]},
projection: "equal-earth",
marks: [
Plot.geo(land, {fill: "currentColor"}),
Plot.graticule(),
Plot.sphere(),
Plot.frame({stroke: "red", strokeDasharray: 4})
]
});
}
19 changes: 19 additions & 0 deletions test/plots/projection-height-mercator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {feature} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-110m.json");
const land = feature(world, world.objects.land);
return Plot.plot({
width: 400,
facet: {data: [0, 1], y: [0, 1]},
projection: "mercator",
marks: [
Plot.geo(land, {fill: "currentColor"}),
Plot.graticule(),
Plot.sphere(),
Plot.frame({stroke: "red", strokeDasharray: 4})
]
});
}
18 changes: 18 additions & 0 deletions test/plots/projection-height-orthographic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {feature} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-110m.json");
const land = feature(world, world.objects.land);
return Plot.plot({
facet: {data: [0, 1, 2, 3], x: (d) => d % 2, y: (d) => d >> 1},
projection: "orthographic",
marks: [
Plot.geo(land, {fill: "currentColor"}),
Plot.graticule(),
Plot.sphere(),
Plot.frame({stroke: "red", strokeDasharray: 4})
]
});
}