Skip to content

projection.clip option #1151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Dec 6, 2022
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,12 +341,21 @@ If the **projection** option is specified as an object, the following additional
* projection.**parallels** - the [standard parallels](https://github.com/d3/d3-geo/blob/main/README.md#conic_parallels) (for conic projections only)
* projection.**precision** - the [sampling threshold](https://github.com/d3/d3-geo/blob/main/README.md#projection_precision)
* projection.**rotate** - a two- or three- element array of Euler angles to rotate the sphere
* projection.**domain** - a GeoJSON object to fit in the center of the frame
* projection.**domain** - a GeoJSON object to fit in the center of the (inset) frame
* projection.**inset** - inset by the given amount in pixels when fitting to the frame (default zero)
* projection.**insetLeft** - inset from the left edge of the frame (defaults to inset)
* projection.**insetRight** - inset from the right edge of the frame (defaults to inset)
* projection.**insetTop** - inset from the top edge of the frame (defaults to inset)
* projection.**insetBottom** - inset from the bottom edge of the frame (defaults to inset)
* projection.**clip** - the projection clipping method

The following projection clipping methods are supported for projection.**clip**:

* *frame* or true (default) - clip to the extent of the frame (including margins but not insets)
* a number - clip to a great circle of the given radius in degrees centered around the origin
* null or false - do not clip

Whereas the mark.**clip** option is implemented using SVG clipping, the projection.**clip** option affects the generated geometry and typically produces smaller SVG output.

### Color options

Expand Down
51 changes: 35 additions & 16 deletions src/projection.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
geoAlbersUsa,
geoAzimuthalEqualArea,
geoAzimuthalEquidistant,
geoClipRectangle,
geoConicConformal,
geoConicEqualArea,
geoConicEquidistant,
Expand Down Expand Up @@ -34,6 +35,7 @@ export function Projection(
if (typeof projection.stream === "function") return projection; // d3 projection
let options;
let domain;
let clip = "frame";

// If the projection was specified as an object with additional options,
// extract those. The order of precedence for insetTop (and other insets) is:
Expand All @@ -49,6 +51,7 @@ export function Projection(
insetRight = inset !== undefined ? inset : insetRight,
insetBottom = inset !== undefined ? inset : insetBottom,
insetLeft = inset !== undefined ? inset : insetLeft,
clip = clip,
...options
} = projection);
if (projection == null) return;
Expand All @@ -61,40 +64,44 @@ export function Projection(
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
const dx = width - marginLeft - marginRight - insetLeft - insetRight;
const dy = height - marginTop - marginBottom - insetTop - insetBottom;
projection = projection?.({width: dx, height: dy, ...options});
projection = projection?.({width: dx, height: dy, clip, ...options});

// The projection initializer might decide to not use a projection.
if (projection == null) return;
clip = maybePostClip(clip, marginLeft, marginTop, width - marginRight, height - marginBottom);

// If there’s no need to transform, return the projection as-is for speed.
// Translate the origin to the top-left corner, respecting margins and insets.
let tx = marginLeft + insetLeft;
let ty = marginTop + insetTop;
if (tx === 0 && ty === 0 && domain == null) return projection;
let transform;

// Otherwise wrap the projection stream with a suitable transform. If a domain
// is specified, fit the projection to the frame. Otherwise, translate.
if (domain) {
// If a domain is specified, fit the projection to the frame.
if (domain != null) {
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
const k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
if (k > 0) {
tx -= (k * (x0 + x1) - dx) / 2;
ty -= (k * (y0 + y1) - dy) / 2;
const {stream: affine} = geoTransform({
transform = geoTransform({
point(x, y) {
this.stream.point(x * k + tx, y * k + ty);
}
});
return {stream: (s) => projection.stream(affine(s))};
} else {
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
}
warn(`The projection could not be fit to the specified domain. Using the default scale.`);
}

const {stream: translate} = geoTransform({
point(x, y) {
this.stream.point(x + tx, y + ty);
}
});
return {stream: (s) => projection.stream(translate(s))};
transform ??=
tx === 0 && ty === 0
? identity()
: geoTransform({
point(x, y) {
this.stream.point(x + tx, y + ty);
}
});

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

export function hasProjection({projection} = {}) {
Expand Down Expand Up @@ -147,11 +154,23 @@ function namedProjection(projection) {
}
}

function maybePostClip(clip, x1, y1, x2, y2) {
if (clip === false || clip == null || typeof clip === "number") return (s) => s;
if (clip === true) clip = "frame";
switch (`${clip}`.toLowerCase()) {
case "frame":
return geoClipRectangle(x1, y1, x2, y2);
default:
throw new Error(`unknown projection clip type: ${clip}`);
}
}

function scaleProjection(createProjection, kx, ky) {
return ({width, height, rotate, precision = 0.15}) => {
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;
Expand Down
4 changes: 4 additions & 0 deletions test/data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ https://covid19.healthdata.org/
World Atlas TopoJSON 2.0.2
https://github.com/topojson/world-atlas

## countries-110m.json
World Atlas TopoJSON 2.0.2
https://github.com/topojson/world-atlas

## d3-survey-2015.json
D3 Community Survey, 2015
https://github.com/enjalot/d3surveys
Expand Down
1 change: 1 addition & 0 deletions test/data/countries-110m.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions test/output/armadillo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 0 additions & 42 deletions test/output/bertin1953Facets.svg

This file was deleted.

22 changes: 22 additions & 0 deletions test/output/projectionBleedEdges.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions test/output/projectionBleedEdges2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions test/output/projectionClipAngle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions test/output/projectionClipAngleFrame.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions test/output/projectionClipBerghaus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 6 additions & 10 deletions test/output/projectionFitAntarctica.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/projectionFitBertin1953.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/projectionFitConic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/output/usCountyChoropleth.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion test/output/usStateCapitals.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion test/plots/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export {default as availability} from "./availability.js";
export {default as ballotStatusRace} from "./ballot-status-race.js";
export {default as bandClip} from "./band-clip.js";
export {default as beckerBarley} from "./becker-barley.js";
export {default as bertin1953Facets} from "./bertin1953-facets.js";
export {default as binStrings} from "./bin-strings.js";
export {default as binTimestamps} from "./bin-timestamps.js";
export {default as boxplot} from "./boxplot.js";
Expand Down Expand Up @@ -171,7 +170,13 @@ export {default as penguinSpeciesIsland} from "./penguin-species-island.js";
export {default as penguinSpeciesIslandRelative} from "./penguin-species-island-relative.js";
export {default as penguinSpeciesIslandSex} from "./penguin-species-island-sex.js";
export {default as polylinear} from "./polylinear.js";
export {default as projectionBleedEdges} from "./projection-bleed-edges.js";
export {default as projectionBleedEdges2} from "./projection-bleed-edges2.js";
export {default as projectionClipAngle} from "./projection-clip-angle.js";
export {default as projectionClipAngleFrame} from "./projection-clip-angle-frame.js";
export {default as projectionClipBerghaus} from "./projection-clip-berghaus.js";
export {default as projectionFitAntarctica} from "./projection-fit-antarctica.js";
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";
Expand Down
21 changes: 21 additions & 0 deletions test/plots/projection-bleed-edges.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 {feature} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-50m.json");
const domain = feature(world, world.objects.land);
const width = 600;
return Plot.plot({
width,
height: width,
projection: {
type: "azimuthal-equal-area",
rotate: [45, -90],
domain: {type: "Sphere"},
clip: 31,
inset: -width * (Math.SQRT1_2 - 0.5) // extend to corners instead of edges
},
marks: [Plot.geo(domain, {fill: "#ccc", stroke: "currentColor"}), Plot.graticule()]
});
}
21 changes: 21 additions & 0 deletions test/plots/projection-bleed-edges2.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 {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: 600,
height: 600,
facet: {x: [1, 2], data: [1, 2]},
projection: {
type: "azimuthal-equidistant",
rotate: [90, -90],
domain: d3.geoCircle().center([0, 90]).radius(85)(),
clip: "frame",
inset: -185
},
marks: [Plot.graticule(), Plot.geo(land, {fill: "#ccc", stroke: "currentColor"}), Plot.frame({stroke: "white"})]
});
}
14 changes: 14 additions & 0 deletions test/plots/projection-clip-angle-frame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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-50m.json");
const domain = feature(world, world.objects.land);
return Plot.plot({
width: 600,
height: 600,
projection: {type: "azimuthal-equidistant", clip: 40, inset: -20, rotate: [0, -90], domain: {type: "Sphere"}},
marks: [Plot.graticule(), Plot.geo(domain, {fill: "currentColor"}), Plot.sphere()]
});
}
14 changes: 14 additions & 0 deletions test/plots/projection-clip-angle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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-50m.json");
const domain = feature(world, world.objects.land);
return Plot.plot({
width: 600,
height: 600,
projection: {type: "azimuthal-equidistant", clip: 30, rotate: [0, 90], domain: {type: "Sphere"}},
marks: [Plot.graticule(), Plot.geo(domain, {fill: "currentColor"}), Plot.sphere()]
});
}
15 changes: 15 additions & 0 deletions test/plots/projection-clip-berghaus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {geoBerghaus} from "d3-geo-projection";
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: 600,
height: 600,
projection: {type: geoBerghaus, domain: {type: "Sphere"}},
marks: [Plot.graticule({clip: "sphere"}), Plot.geo(land, {fill: "currentColor", clip: "sphere"}), Plot.sphere()]
});
}
17 changes: 15 additions & 2 deletions test/plots/projection-fit-antarctica.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,20 @@ export default async function () {
width: 600,
height: 600,
inset: 30,
projection: {type: "azimuthal-equidistant", rotate: [0, 90], domain},
marks: [Plot.graticule({clip: "frame"}), Plot.geo(domain, {clip: "frame", fill: "currentColor"}), Plot.frame()]
style: "overflow: visible;",
projection: {
type: "azimuthal-equidistant",
rotate: [0, 90],
domain
},
marks: [
Plot.graticule(),
Plot.geo(domain, {fill: "currentColor"}),
Plot.frame(),
// Since we’re using the default clip: "frame" for the projection, these
// marks should not be rendered; the projected point is outside the frame.
Plot.dot({length: 1}, {x: -90, y: -63}),
Plot.text({length: 1}, {x: -90, y: -63, text: ["Do not render"]})
]
});
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {geoBertin1953} from "d3-geo-projection";
import {feature} from "topojson-client";
import {merge} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-50m.json");
const land = feature(world, world.objects.land);
const world = await d3.json("data/countries-110m.json");
const land = merge(
world,
world.objects.countries.geometries.filter((d) => d.properties.name !== "Antarctica")
);
return Plot.plot({
width: 960,
height: 302,
marginRight: 44,
marginLeft: 0,
facet: {data: [1, 2], x: ["a", "b"]},
projection: ({width, height}) => geoBertin1953().fitSize([width, height], {type: "Sphere"}),
marks: [Plot.frame({stroke: "red"}), Plot.geo(land, {fill: "currentColor"}), Plot.sphere({strokeWidth: 0.5})],
projection: {type: geoBertin1953, domain: land},
marks: [Plot.frame({stroke: "red"}), Plot.geo(land, {fill: "currentColor"})],
style: "border: solid 1px blue"
});
}
2 changes: 1 addition & 1 deletion test/plots/projection-fit-conic.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as d3 from "d3";
import {feature} from "topojson-client";

export default async function () {
const world = await d3.json("data/countries-50m.json");
const world = await d3.json("data/countries-110m.json");
const land = feature(world, world.objects.land);
return Plot.plot({
projection: {
Expand Down