Skip to content

projection.clip: sphere, frame, angle #1150

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

Closed
wants to merge 5 commits into from
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,13 @@ If the **projection** option is specified as an object, the following additional
* 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.**clipAngle** - restricts the sphere of an azimuthal projection to a circle of the specified radius (in degrees)
* projection.**clip** - the projection clip method

The following projection clip methods are supported:
* _null_ - disable projection clipping
* **frame** - every path generated by a geo mark (including graticule and sphere) is clipped to the frame’s dimensions (default); the underlying technique is more performant than the mark-level clip option
* **sphere** - every path generated by the geo mark (except the sphere mark) receives a clip-path attribute linked to the sphere path; this is needed for some extended projections whose projected sphere is not convex, such as the star-shaped Berghaus projection.

### Color options

Expand Down
1 change: 1 addition & 0 deletions src/marks/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class Geo extends Mark {
withDefaultSort(options),
defaults
);
if (data?.length === 1 && data[0]?.type === "Sphere") this.isSphere = true; // TODO: is there a cleaner way to pass this up to applyIndirectStyles?
this.r = cr;
}
render(index, scales, channels, dimensions, context) {
Expand Down
52 changes: 38 additions & 14 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 All @@ -16,6 +17,7 @@ import {
geoTransform,
geoTransverseMercator
} from "d3";
import {maybeClip} from "./style.js";
import {constant, isObject} from "./options.js";
import {warn} from "./warnings.js";

Expand All @@ -34,6 +36,7 @@ export function Projection(
if (typeof projection.stream === "function") return projection; // d3 projection
let options;
let domain;
let clip;

// 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 +52,7 @@ export function Projection(
insetRight = inset !== undefined ? inset : insetRight,
insetBottom = inset !== undefined ? inset : insetBottom,
insetLeft = inset !== undefined ? inset : insetLeft,
clip,
...options
} = projection);
if (projection == null) return;
Expand All @@ -66,14 +70,29 @@ export function Projection(
// The projection initializer might decide to not use a projection.
if (projection == null) return;

// If there’s no need to transform, return the projection as-is for speed.
let tx = marginLeft + insetLeft;
let ty = marginTop + insetTop;
if (tx === 0 && ty === 0 && domain == null) return projection;
// Post-clip is handled downstream after scale & translate. The intent to clip
// to sphere can be specified by the projection definition, or by the
// projection itself. By default we clip to frame, to avoid bleeding out.
clip = maybeClip(clip ?? projection.clip ?? "frame");
let clipSphere = false;
switch (clip) {
case "frame":
clip = geoClipRectangle(marginLeft, marginTop, width - marginRight, height - marginBottom);
break;
case "sphere":
clipSphere = true;
// eslint-disable-next-line no-fallthrough
default:
clip = (s) => s;
}

// Otherwise wrap the projection stream with a suitable transform. If a domain
// is specified, fit the projection to the frame. Otherwise, translate.
if (domain) {
// is specified, or a clipAngle has been requested, fit the projection to the
// frame. Otherwise, translate if necessary.
let tx = marginLeft + insetLeft;
let ty = marginTop + insetTop;
if (options?.clipAngle != null && domain === undefined) domain = {type: "Sphere"};
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) {
Expand All @@ -84,17 +103,21 @@ export function Projection(
this.stream.point(x * k + tx, y * k + ty);
}
});
return {stream: (s) => projection.stream(affine(s))};
return {stream: (s) => projection.stream(affine(clip(s))), clipSphere};
}
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))};
const translate =
tx === 0 && ty === 0
? (s) => s
: geoTransform({
point(x, y) {
this.stream.point(x + tx, y + ty);
}
}).stream;

return {stream: (s) => projection.stream(translate(clip(s))), clipSphere};
}

export function hasProjection({projection} = {}) {
Expand Down Expand Up @@ -148,10 +171,11 @@ function namedProjection(projection) {
}

function scaleProjection(createProjection, kx, ky) {
return ({width, height, rotate, precision = 0.15}) => {
return ({width, height, rotate, precision = 0.15, clipAngle}) => {
const projection = createProjection();
if (precision != null) projection.precision?.(precision);
if (rotate != null) projection.rotate?.(rotate);
if (clipAngle != null) projection.clipAngle?.(clipAngle);
projection.scale(Math.min(width / kx, height / ky));
projection.translate([width / 2, height / 2]);
return projection;
Expand Down
36 changes: 17 additions & 19 deletions src/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,24 +323,23 @@ export function applyIndirectStyles(selection, mark, scales, dimensions, context
applyAttr(selection, "shape-rendering", mark.shapeRendering);
applyAttr(selection, "paint-order", mark.paintOrder);
applyAttr(selection, "pointer-events", mark.pointerEvents);
switch (mark.clip) {
case "frame": {
const {x, y} = scales;
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
const id = getClipId();
selection
.attr("clip-path", `url(#${id})`)
.append("clipPath")
.attr("id", id)
.append("rect")
.attr("x", marginLeft - (x?.bandwidth ? x.bandwidth() / 2 : 0))
.attr("y", marginTop - (y?.bandwidth ? y.bandwidth() / 2 : 0))
.attr("width", width - marginRight - marginLeft)
.attr("height", height - marginTop - marginBottom);
break;
}
case "sphere": {
const {projection} = context;
if (mark.clip === "frame") {
const {x, y} = scales;
const {width, height, marginLeft, marginRight, marginTop, marginBottom} = dimensions;
const id = getClipId();
selection
.attr("clip-path", `url(#${id})`)
.append("clipPath")
.attr("id", id)
.append("rect")
.attr("x", marginLeft - (x?.bandwidth ? x.bandwidth() / 2 : 0))
.attr("y", marginTop - (y?.bandwidth ? y.bandwidth() / 2 : 0))
.attr("width", width - marginRight - marginLeft)
.attr("height", height - marginTop - marginBottom);
} else {
const projection = context?.projection;
const clipSphere = projection?.clipSphere;
if (mark.clip === "sphere" || (clipSphere && !mark.isSphere)) {
if (!projection) throw new Error(`the "sphere" clip option requires a projection`);
const id = getClipId();
selection
Expand All @@ -349,7 +348,6 @@ export function applyIndirectStyles(selection, mark, scales, dimensions, context
.attr("id", id)
.append("path")
.attr("d", geoPath(projection)({type: "Sphere"}));
break;
}
}
}
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.

10 changes: 5 additions & 5 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.

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.
4 changes: 2 additions & 2 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.
4 changes: 2 additions & 2 deletions test/plots/armadillo.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default async function () {
width: 960,
height: 548,
margin: 1,
projection: ({width, height}) => geoArmadillo().precision(0.2).fitSize([width, height], {type: "Sphere"}),
marks: [Plot.geo(land, {clip: "sphere", fill: "currentColor"}), Plot.graticule({clip: "sphere"}), Plot.sphere()]
projection: {type: () => geoArmadillo().precision(0.2), clip: "sphere", domain: {type: "Sphere"}},
marks: [Plot.geo(land, {fill: "currentColor"}), Plot.graticule(), Plot.sphere()]
});
}
5 changes: 4 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,11 @@ 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 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
24 changes: 24 additions & 0 deletions test/plots/projection-clip-angle-frame.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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 domain = feature(
world,
world.objects.countries.geometries.find((d) => d.properties.name === "Antarctica")
);
return Plot.plot({
width: 600,
height: 600,
projection: {
type: "azimuthal-equidistant",
clipAngle: 30,
domain: {type: "Sphere"},
inset: -20,
clip: "frame",
rotate: [0, 90]
},
marks: [Plot.graticule(), Plot.geo(domain, {fill: "currentColor"}), Plot.sphere()]
});
}
17 changes: 17 additions & 0 deletions test/plots/projection-clip-angle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 domain = feature(
world,
world.objects.countries.geometries.find((d) => d.properties.name === "Antarctica")
);
return Plot.plot({
width: 600,
height: 600,
projection: {type: "azimuthal-equidistant", clipAngle: 30, rotate: [0, 90]},
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, clip: "sphere", domain: {type: "Sphere"}},
marks: [Plot.graticule(), Plot.geo(land, {fill: "currentColor"}), Plot.sphere()]
});
}
2 changes: 1 addition & 1 deletion test/plots/projection-fit-antarctica.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 domain = feature(
world,
world.objects.countries.geometries.find((d) => d.properties.name === "Antarctica")
Expand Down
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