Skip to content
Merged
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
32 changes: 26 additions & 6 deletions src/dimensions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {hasProjection} from "./projection.js";
import {projectionAspectRatio} from "./projection.js";
import {isOrdinalScale} from "./scales.js";
import {offset} from "./style.js";

Expand Down Expand Up @@ -52,8 +52,13 @@ export function Dimensions(scales, geometry, axes, options = {}) {
// specified explicitly, adjust the automatic height accordingly.
let {
width = 640,
height = autoHeight(scales, geometry || hasProjection(options)) +
Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
height = autoHeight(scales, geometry, options, {
width,
marginTopDefault,
marginBottomDefault,
marginRightDefault,
marginLeftDefault
}) + Math.max(0, marginTop - marginTopDefault + marginBottom - marginBottomDefault)
} = options;

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

function autoHeight({y, fy, fx}, geometry) {
function autoHeight(
{y, fy, fx},
geometry,
{projection},
{width, marginTopDefault, marginBottomDefault, marginRightDefault, marginLeftDefault}
) {
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;

// If a projection is specified, use its natural aspect ratio (if known).
const ar = projectionAspectRatio(projection, geometry);
if (ar) {
const nfx = fx ? fx.scale.domain().length : 1;
const far = ((1.1 * nfy - 0.1) / (1.1 * nfx - 0.1)) * ar; // 0.1 is default facet padding
const lar = Math.max(0.1, Math.min(10, far)); // clamp the aspect ratio to a “reasonable” value
return Math.round((width - marginLeftDefault - marginRightDefault) * lar + marginTopDefault + marginBottomDefault);
}

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;
}
91 changes: 54 additions & 37 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 defaultAspectRatio = 0.618;

export function Projection(
{
projection,
Expand Down Expand Up @@ -58,7 +62,7 @@ export function Projection(
}

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

// Compute the frame dimensions and invoke the projection initializer.
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
Expand Down Expand Up @@ -104,17 +108,6 @@ 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;
}

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

function namedProjection(projection) {
switch (`${projection}`.toLowerCase()) {
case "albers-usa":
Expand All @@ -138,9 +131,9 @@ function namedProjection(projection) {
case "gnomonic":
return scaleProjection(geoGnomonic, 3.4641, 3.4641);
case "identity":
return identity;
return {type: identity};
case "reflect-y":
return reflectY;
return {type: reflectY};
case "mercator":
return scaleProjection(geoMercator, tau, tau);
case "orthographic":
Expand All @@ -166,14 +159,35 @@ function maybePostClip(clip, x1, y1, x2, y2) {
}

function scaleProjection(createProjection, kx, ky) {
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]);
return projection;
return {
type: ({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]);
return projection;
},
aspectRatio: ky / kx
};
}

function conicProjection(createProjection, kx, ky) {
const {type, aspectRatio} = scaleProjection(createProjection, kx, ky);
return {
type: (options) => {
const {parallels, domain, width, height} = options;
const projection = type(options);
if (parallels != null) {
projection.parallels(parallels);
if (domain === undefined) {
projection.fitSize([width, height], {type: "Sphere"});
}
}
return projection;
},
aspectRatio
};
}

Expand All @@ -187,21 +201,6 @@ const reflectY = constant(
})
);

function conicProjection(createProjection, kx, ky) {
createProjection = scaleProjection(createProjection, kx, ky);
return (options) => {
const {parallels, domain, width, height} = options;
const projection = createProjection(options);
if (parallels != null) {
projection.parallels(parallels);
if (domain === undefined) {
projection.fitSize([width, height], {type: "Sphere"});
}
}
return projection;
};
}

// Applies a point-wise projection to the given paired x and y channels.
export function maybeApplyProjection(cx, cy, channels, values, projection) {
const x = channels[cx] && channels[cx].scale === "x";
Expand Down Expand Up @@ -232,3 +231,21 @@ function applyProjection(cx, cy, values, projection) {
stream.point(x[i], y[i]);
}
}

// When a named projection is specified, we can use its natural aspect ratio to
// determine a good value for the projection’s height based on the desired
// width. When we don’t have a way to know, the golden ratio is our best guess.
// Due to a circular dependency (we need to know the height before we can
// construct the projection), we have to test the raw projection option rather
// than the materialized projection; therefore we must be extremely careful that
// the logic of this function exactly matches Projection above!
export function projectionAspectRatio(projection, geometry) {
if (typeof projection?.stream === "function") return defaultAspectRatio;
if (isObject(projection)) projection = projection.type;
if (projection == null) return geometry ? defaultAspectRatio : undefined;
if (typeof projection !== "function") {
const {aspectRatio} = namedProjection(projection);
if (aspectRatio) return aspectRatio;
}
return defaultAspectRatio;
}
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.
36 changes: 36 additions & 0 deletions test/output/projectionHeightGeometry.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.
5 changes: 5 additions & 0 deletions test/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ 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 projectionHeightGeometry} from "./projection-height-geometry.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
19 changes: 19 additions & 0 deletions test/plots/projection-height-albers.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 {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({
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})
]
});
}
17 changes: 17 additions & 0 deletions test/plots/projection-height-geometry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as Plot from "@observablehq/plot";

export default async function () {
const shape = {
type: "LineString",
coordinates: Array.from({length: 201}, (_, i) => {
const angle = (i / 100) * Math.PI;
const r = (i % 2) + 5;
return [340 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)];
})
};
return Plot.plot({
facet: {data: [0, 1], y: [0, 1]},
projection: null,
marks: [Plot.geo(shape), Plot.frame({stroke: "red", strokeDasharray: 4})]
});
}
18 changes: 18 additions & 0 deletions test/plots/projection-height-mercator.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], 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})
]
});
}
2 changes: 1 addition & 1 deletion test/plots/us-county-choropleth.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default async function () {
label: "Unemployment (%)"
},
marks: [
Plot.geo(counties, {fill: (d) => unemployment.get(d.id), title: (d) => d.properties.name}), // TODO string accessors for properties, id
Plot.geo(counties, {fill: (d) => unemployment.get(d.id), title: (d) => d.properties.name}),
Plot.geo(statemesh, {stroke: "white"})
]
});
Expand Down