diff --git a/src/dimensions.js b/src/dimensions.js index 43b24b8b3a..cb7a7f13a1 100644 --- a/src/dimensions.js +++ b/src/dimensions.js @@ -94,7 +94,8 @@ function autoHeight( ) { const nfy = fy ? fy.scale.domain().length : 1; - // If a projection is specified, use its natural aspect ratio (if known). + // If a projection is specified, compute an aspect ratio based on the domain, + // defaulting to the projection’s natural aspect ratio (if known). const ar = projectionAspectRatio(projection); if (ar) { const nfx = fx ? fx.scale.domain().length : 1; diff --git a/src/projection.js b/src/projection.js index 26afac4241..30df2ae88e 100644 --- a/src/projection.js +++ b/src/projection.js @@ -167,8 +167,10 @@ function scaleProjection(createProjection, kx, ky) { 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 (width != null) { + projection.scale(Math.min(width / kx, height / ky)); + projection.translate([width / 2, height / 2]); + } return projection; }, aspectRatio: ky / kx @@ -183,7 +185,7 @@ function conicProjection(createProjection, kx, ky) { const projection = type(options); if (parallels != null) { projection.parallels(parallels); - if (domain === undefined) { + if (domain === undefined && width != null) { projection.fitSize([width, height], {type: "Sphere"}); } } @@ -234,16 +236,25 @@ export function hasProjection({projection} = {}) { return projection != null; } -// 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 createProjection above! +// When a projection is specified, we can use its 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 createProjection above! export function projectionAspectRatio(projection) { if (typeof projection?.stream === "function") return defaultAspectRatio; - if (isObject(projection)) projection = projection.type; + if (isObject(projection)) { + let domain, options; + ({domain, type: projection, ...options} = projection); + if (domain != null && projection != null) { + const type = typeof projection === "string" ? namedProjection(projection).type : projection; + const [[x0, y0], [x1, y1]] = geoPath(type({...options, width: 100, height: 100})).bounds(domain); + const r = (y1 - y0) / (x1 - x0); + return r && isFinite(r) ? (r < 0.2 ? 0.2 : r > 5 ? 5 : r) : defaultAspectRatio; + } + } if (projection == null) return; if (typeof projection !== "function") { const {aspectRatio} = namedProjection(projection); diff --git a/test/output/geoText.svg b/test/output/geoText.svg index bb2ecf855a..a38224101f 100644 --- a/test/output/geoText.svg +++ b/test/output/geoText.svg @@ -1,4 +1,4 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Kingston upon Thames - Croydon - Bromley - Hounslow - Ealing - Havering - Hillingdon - Harrow - Brent - Barnet - Lambeth - Southwark - Lewisham - Greenwich - Bexley - Enfield - Waltham Forest - Redbridge - Sutton - Richmond upon Thames - Merton - Wandsworth - Hammersmith and Fulham - Kensington and Chelsea - Westminster - Camden - Tower Hamlets - Islington - Hackney - Haringey - Newham - Barking and Dagenham - City of London + Kingston upon Thames + Croydon + Bromley + Hounslow + Ealing + Havering + Hillingdon + Harrow + Brent + Barnet + Lambeth + Southwark + Lewisham + Greenwich + Bexley + Enfield + Waltham Forest + Redbridge + Sutton + Richmond upon Thames + Merton + Wandsworth + Hammersmith and Fulham + Kensington and Chelsea + Westminster + Camden + Tower Hamlets + Islington + Hackney + Haringey + Newham + Barking and Dagenham + City of London \ No newline at end of file diff --git a/test/output/geoTip.svg b/test/output/geoTip.svg index 1bbaa91313..e729853b92 100644 --- a/test/output/geoTip.svg +++ b/test/output/geoTip.svg @@ -1,4 +1,4 @@ - + - 2001 + 2001 - 2011 + 2011 - 2021 + 2021 - year + year - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/geoTipCentroid.svg b/test/output/geoTipCentroid.svg index 1bbaa91313..e729853b92 100644 --- a/test/output/geoTipCentroid.svg +++ b/test/output/geoTipCentroid.svg @@ -1,4 +1,4 @@ - + - 2001 + 2001 - 2011 + 2011 - 2021 + 2021 - year + year - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/geoTipGeoCentroid.svg b/test/output/geoTipGeoCentroid.svg index 1bbaa91313..e729853b92 100644 --- a/test/output/geoTipGeoCentroid.svg +++ b/test/output/geoTipGeoCentroid.svg @@ -1,4 +1,4 @@ - + - 2001 + 2001 - 2011 + 2011 - 2021 + 2021 - year + year - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/geoTipXY.svg b/test/output/geoTipXY.svg index 1bbaa91313..e729853b92 100644 --- a/test/output/geoTipXY.svg +++ b/test/output/geoTipXY.svg @@ -1,4 +1,4 @@ - + - 2001 + 2001 - 2011 + 2011 - 2021 + 2021 - year + year - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/output/projectionDomainRatioME.svg b/test/output/projectionDomainRatioME.svg new file mode 100644 index 0000000000..e4b3660a78 --- /dev/null +++ b/test/output/projectionDomainRatioME.svg @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionDomainRatioMN.svg b/test/output/projectionDomainRatioMN.svg new file mode 100644 index 0000000000..fdc56c3bca --- /dev/null +++ b/test/output/projectionDomainRatioMN.svg @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionDomainRatioNC.svg b/test/output/projectionDomainRatioNC.svg new file mode 100644 index 0000000000..20956f1010 --- /dev/null +++ b/test/output/projectionDomainRatioNC.svg @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionDomainRatioNCManual.svg b/test/output/projectionDomainRatioNCManual.svg new file mode 100644 index 0000000000..9dd03d1128 --- /dev/null +++ b/test/output/projectionDomainRatioNCManual.svg @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionHeightDegenerate.svg b/test/output/projectionHeightDegenerate.svg new file mode 100644 index 0000000000..2e333cf1fc --- /dev/null +++ b/test/output/projectionHeightDegenerate.svg @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/output/projectionHeightGeometryDomain.svg b/test/output/projectionHeightGeometryDomain.svg new file mode 100644 index 0000000000..f479157ce2 --- /dev/null +++ b/test/output/projectionHeightGeometryDomain.svg @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/test/plots/index.ts b/test/plots/index.ts index b7af3ca8cd..f510057789 100644 --- a/test/plots/index.ts +++ b/test/plots/index.ts @@ -228,6 +228,7 @@ export * from "./population-by-latitude.js"; export * from "./population-by-longitude.js"; export * from "./projection-bleed-edges.js"; export * from "./projection-bleed-edges2.js"; +export * from "./projection-domain-ratio.js"; export * from "./projection-clip-angle-frame.js"; export * from "./projection-clip-angle.js"; export * from "./projection-clip-berghaus.js"; diff --git a/test/plots/projection-domain-ratio.ts b/test/plots/projection-domain-ratio.ts new file mode 100644 index 0000000000..3d1651f004 --- /dev/null +++ b/test/plots/projection-domain-ratio.ts @@ -0,0 +1,41 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {feature, mesh} from "topojson-client"; + +type Prj = {type: Plot.ProjectionName | (() => any); parallels?: [number, number]; rotate?: [number, number]}; + +async function stateMap(id: string, prj: Prj) { + const us = await d3.json("data/us-counties-10m.json"); + const state = feature(us, us.objects.states).features.find((d) => d.id === id); + const counties = mesh( + us, + {type: "GeometryCollection", geometries: us.objects.counties.geometries.filter((d) => d.id.startsWith(id))}, + (a, b) => a != b + ); + return Plot.plot({ + projection: {...prj, domain: state}, + marks: [Plot.geo(counties, {strokeOpacity: 0.2}), Plot.geo(state)] + }); +} + +export async function projectionDomainRatioME() { + return stateMap("23", {type: "transverse-mercator", rotate: [68 + 30 / 60, -43 - 40 / 60]}); +} + +export async function projectionDomainRatioMN() { + return stateMap("27", {type: "conic-conformal", parallels: [45 + 37 / 60, 47 + 3 / 60], rotate: [94 + 15 / 60, 0]}); +} + +export async function projectionDomainRatioNC() { + return stateMap("37", {type: "conic-conformal", parallels: [34 + 20 / 60, 36 + 10 / 60], rotate: [79, 0]}); +} + +export async function projectionDomainRatioNCManual() { + return stateMap("37", { + type: () => + d3 + .geoConicConformal() + .parallels([34 + 20 / 60, 36 + 10 / 60]) + .rotate([79, 0]) + }); +} diff --git a/test/plots/projection-height-geometry.ts b/test/plots/projection-height-geometry.ts index 0b90067a66..1fdd5937c2 100644 --- a/test/plots/projection-height-geometry.ts +++ b/test/plots/projection-height-geometry.ts @@ -1,14 +1,15 @@ import * as Plot from "@observablehq/plot"; +const shape = { + type: "LineString", + coordinates: Array.from({length: 201}, (_, i) => { + const angle = (i / 100) * Math.PI; + const r = (i % 2) + 5; + return [300 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)]; + }) +} as const; + export async function projectionHeightGeometry() { - const shape = { - type: "LineString", - coordinates: Array.from({length: 201}, (_, i) => { - const angle = (i / 100) * Math.PI; - const r = (i % 2) + 5; - return [300 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)]; - }) - } as const; return Plot.plot({ facet: {data: [0, 1], y: [0, 1]}, projection: "identity", @@ -16,15 +17,24 @@ export async function projectionHeightGeometry() { }); } +export async function projectionHeightDegenerate() { + return Plot.plot({ + style: "border: #777 1px solid;", + projection: "mercator", + height: 400, + inset: 199.5, + marks: [Plot.graticule(), Plot.sphere()] + }); +} + +export async function projectionHeightGeometryDomain() { + return Plot.plot({ + projection: {type: "identity", domain: shape}, + marks: [Plot.geo(shape), Plot.frame({stroke: "red", strokeDasharray: 4})] + }); +} + export async function projectionHeightGeometryNull() { - const shape = { - type: "LineString", - coordinates: Array.from({length: 201}, (_, i) => { - const angle = (i / 100) * Math.PI; - const r = (i % 2) + 5; - return [300 + 30 * r * Math.cos(angle), 185 + 30 * r * Math.sin(angle)]; - }) - } as const; return Plot.plot({ aspectRatio: true, width: 400,