From 6d51cb5c5bf765eb1f3372a53cd9a247d23f86f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 11 Jul 2024 13:51:43 +0200 Subject: [PATCH 1/3] Determine the default height when the projection domain is set closes #2063 --- src/dimensions.js | 3 +- src/projection.js | 19 +- test/output/geoText.svg | 134 +++++------ test/output/geoTip.svg | 208 +++++++++--------- test/output/geoTipCentroid.svg | 208 +++++++++--------- test/output/geoTipGeoCentroid.svg | 208 +++++++++--------- test/output/geoTipXY.svg | 208 +++++++++--------- test/output/projectionDomainRatioME.svg | 22 ++ test/output/projectionDomainRatioMN.svg | 22 ++ test/output/projectionDomainRatioNC.svg | 22 ++ .../output/projectionHeightGeometryDomain.svg | 20 ++ test/plots/index.ts | 1 + test/plots/projection-domain-ratio.ts | 31 +++ test/plots/projection-height-geometry.ts | 32 +-- 14 files changed, 634 insertions(+), 504 deletions(-) create mode 100644 test/output/projectionDomainRatioME.svg create mode 100644 test/output/projectionDomainRatioMN.svg create mode 100644 test/output/projectionDomainRatioNC.svg create mode 100644 test/output/projectionHeightGeometryDomain.svg create mode 100644 test/plots/projection-domain-ratio.ts 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..a51e0ee88f 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 && height) { + 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 && height) { projection.fitSize([width, height], {type: "Sphere"}); } } @@ -243,7 +245,16 @@ export function hasProjection({projection} = {}) { // 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} = namedProjection(projection); + const [[x0, y0], [x1, y1]] = geoPath(type(options)).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/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 7b99e1ada1..808622ed6e 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..e9738480e4 --- /dev/null +++ b/test/plots/projection-domain-ratio.ts @@ -0,0 +1,31 @@ +import * as Plot from "@observablehq/plot"; +import * as d3 from "d3"; +import {feature, mesh} from "topojson-client"; + +type Prj = {type: Plot.ProjectionName; 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]}); +} diff --git a/test/plots/projection-height-geometry.ts b/test/plots/projection-height-geometry.ts index 0b90067a66..c487ac720b 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,14 @@ export async function projectionHeightGeometry() { }); } +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, From 17328e4fd3039a14cc076c5cc45d8cbc15b5a95d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 11 Jul 2024 16:30:51 +0200 Subject: [PATCH 2/3] Only scale if passed a width (height being always passed with width, there's no need to check both) the case where width and height are undefined is when we are testing the projection's aspect ratio with a given domain Note that the path in the SVG is broken (full of NaN) when projection.scale() === 0. This seems to be a secondary issue. --- src/projection.js | 4 ++-- test/output/projectionHeightDegenerate.svg | 22 ++++++++++++++++++++++ test/plots/projection-height-geometry.ts | 10 ++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 test/output/projectionHeightDegenerate.svg diff --git a/src/projection.js b/src/projection.js index a51e0ee88f..a8f2cf6790 100644 --- a/src/projection.js +++ b/src/projection.js @@ -167,7 +167,7 @@ function scaleProjection(createProjection, kx, ky) { if (precision != null) projection.precision?.(precision); if (rotate != null) projection.rotate?.(rotate); if (typeof clip === "number") projection.clipAngle?.(clip); - if (width && height) { + if (width != null) { projection.scale(Math.min(width / kx, height / ky)); projection.translate([width / 2, height / 2]); } @@ -185,7 +185,7 @@ function conicProjection(createProjection, kx, ky) { const projection = type(options); if (parallels != null) { projection.parallels(parallels); - if (domain === undefined && width && height) { + if (domain === undefined && width != null) { projection.fitSize([width, height], {type: "Sphere"}); } } 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/plots/projection-height-geometry.ts b/test/plots/projection-height-geometry.ts index c487ac720b..1fdd5937c2 100644 --- a/test/plots/projection-height-geometry.ts +++ b/test/plots/projection-height-geometry.ts @@ -17,6 +17,16 @@ 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}, From cb8367a05d02328048b4b5f4e7ce8e763a946bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Wed, 24 Jul 2024 15:05:09 +0200 Subject: [PATCH 3/3] adding the case where the projection is given by a {type: function, domain} --- src/projection.js | 18 +++++++-------- test/output/projectionDomainRatioNCManual.svg | 22 +++++++++++++++++++ test/plots/projection-domain-ratio.ts | 12 +++++++++- 3 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 test/output/projectionDomainRatioNCManual.svg diff --git a/src/projection.js b/src/projection.js index a8f2cf6790..30df2ae88e 100644 --- a/src/projection.js +++ b/src/projection.js @@ -236,21 +236,21 @@ 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)) { let domain, options; ({domain, type: projection, ...options} = projection); if (domain != null && projection != null) { - const {type} = namedProjection(projection); - const [[x0, y0], [x1, y1]] = geoPath(type(options)).bounds(domain); + 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; } 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/plots/projection-domain-ratio.ts b/test/plots/projection-domain-ratio.ts index e9738480e4..3d1651f004 100644 --- a/test/plots/projection-domain-ratio.ts +++ b/test/plots/projection-domain-ratio.ts @@ -2,7 +2,7 @@ import * as Plot from "@observablehq/plot"; import * as d3 from "d3"; import {feature, mesh} from "topojson-client"; -type Prj = {type: Plot.ProjectionName; parallels?: [number, number]; rotate?: [number, number]}; +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"); @@ -29,3 +29,13 @@ export async function projectionDomainRatioMN() { 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]) + }); +}