Skip to content

Commit daffeb2

Browse files
Filmbostock
andauthored
projection.clip option (#1151)
* add 110m for faster tests * projection.clipAngle * always clipExtent(frame) * projection fit & clip tests * two techniques for bleed-edges maps * Update README * Update README * Update README * fix tests (clipAngle => clip) * fix warning Co-authored-by: Mike Bostock <[email protected]>
1 parent bdae7d1 commit daffeb2

25 files changed

+357
-83
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,12 +341,21 @@ If the **projection** option is specified as an object, the following additional
341341
* projection.**parallels** - the [standard parallels](https://github.com/d3/d3-geo/blob/main/README.md#conic_parallels) (for conic projections only)
342342
* projection.**precision** - the [sampling threshold](https://github.com/d3/d3-geo/blob/main/README.md#projection_precision)
343343
* projection.**rotate** - a two- or three- element array of Euler angles to rotate the sphere
344-
* projection.**domain** - a GeoJSON object to fit in the center of the frame
344+
* projection.**domain** - a GeoJSON object to fit in the center of the (inset) frame
345345
* projection.**inset** - inset by the given amount in pixels when fitting to the frame (default zero)
346346
* projection.**insetLeft** - inset from the left edge of the frame (defaults to inset)
347347
* projection.**insetRight** - inset from the right edge of the frame (defaults to inset)
348348
* projection.**insetTop** - inset from the top edge of the frame (defaults to inset)
349349
* projection.**insetBottom** - inset from the bottom edge of the frame (defaults to inset)
350+
* projection.**clip** - the projection clipping method
351+
352+
The following projection clipping methods are supported for projection.**clip**:
353+
354+
* *frame* or true (default) - clip to the extent of the frame (including margins but not insets)
355+
* a number - clip to a great circle of the given radius in degrees centered around the origin
356+
* null or false - do not clip
357+
358+
Whereas the mark.**clip** option is implemented using SVG clipping, the projection.**clip** option affects the generated geometry and typically produces smaller SVG output.
350359

351360
### Color options
352361

src/projection.js

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
geoAlbersUsa,
44
geoAzimuthalEqualArea,
55
geoAzimuthalEquidistant,
6+
geoClipRectangle,
67
geoConicConformal,
78
geoConicEqualArea,
89
geoConicEquidistant,
@@ -34,6 +35,7 @@ export function Projection(
3435
if (typeof projection.stream === "function") return projection; // d3 projection
3536
let options;
3637
let domain;
38+
let clip = "frame";
3739

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

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

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

74-
// Otherwise wrap the projection stream with a suitable transform. If a domain
75-
// is specified, fit the projection to the frame. Otherwise, translate.
76-
if (domain) {
78+
// If a domain is specified, fit the projection to the frame.
79+
if (domain != null) {
7780
const [[x0, y0], [x1, y1]] = geoPath(projection).bounds(domain);
7881
const k = Math.min(dx / (x1 - x0), dy / (y1 - y0));
7982
if (k > 0) {
8083
tx -= (k * (x0 + x1) - dx) / 2;
8184
ty -= (k * (y0 + y1) - dy) / 2;
82-
const {stream: affine} = geoTransform({
85+
transform = geoTransform({
8386
point(x, y) {
8487
this.stream.point(x * k + tx, y * k + ty);
8588
}
8689
});
87-
return {stream: (s) => projection.stream(affine(s))};
90+
} else {
91+
warn(`Warning: the projection could not be fit to the specified domain; using the default scale.`);
8892
}
89-
warn(`The projection could not be fit to the specified domain. Using the default scale.`);
9093
}
9194

92-
const {stream: translate} = geoTransform({
93-
point(x, y) {
94-
this.stream.point(x + tx, y + ty);
95-
}
96-
});
97-
return {stream: (s) => projection.stream(translate(s))};
95+
transform ??=
96+
tx === 0 && ty === 0
97+
? identity()
98+
: geoTransform({
99+
point(x, y) {
100+
this.stream.point(x + tx, y + ty);
101+
}
102+
});
103+
104+
return {stream: (s) => projection.stream(transform.stream(clip(s)))};
98105
}
99106

100107
export function hasProjection({projection} = {}) {
@@ -147,11 +154,23 @@ function namedProjection(projection) {
147154
}
148155
}
149156

157+
function maybePostClip(clip, x1, y1, x2, y2) {
158+
if (clip === false || clip == null || typeof clip === "number") return (s) => s;
159+
if (clip === true) clip = "frame";
160+
switch (`${clip}`.toLowerCase()) {
161+
case "frame":
162+
return geoClipRectangle(x1, y1, x2, y2);
163+
default:
164+
throw new Error(`unknown projection clip type: ${clip}`);
165+
}
166+
}
167+
150168
function scaleProjection(createProjection, kx, ky) {
151-
return ({width, height, rotate, precision = 0.15}) => {
169+
return ({width, height, rotate, precision = 0.15, clip}) => {
152170
const projection = createProjection();
153171
if (precision != null) projection.precision?.(precision);
154172
if (rotate != null) projection.rotate?.(rotate);
173+
if (typeof clip === "number") projection.clipAngle?.(clip);
155174
projection.scale(Math.min(width / kx, height / ky));
156175
projection.translate([width / 2, height / 2]);
157176
return projection;

test/data/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ https://covid19.healthdata.org/
3535
World Atlas TopoJSON 2.0.2
3636
https://github.com/topojson/world-atlas
3737

38+
## countries-110m.json
39+
World Atlas TopoJSON 2.0.2
40+
https://github.com/topojson/world-atlas
41+
3842
## d3-survey-2015.json
3943
D3 Community Survey, 2015
4044
https://github.com/enjalot/d3surveys

test/data/countries-110m.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

test/output/armadillo.svg

Lines changed: 2 additions & 2 deletions
Loading

test/output/bertin1953Facets.svg

Lines changed: 0 additions & 42 deletions
This file was deleted.

test/output/projectionBleedEdges.svg

Lines changed: 22 additions & 0 deletions
Loading

test/output/projectionBleedEdges2.svg

Lines changed: 42 additions & 0 deletions
Loading

test/output/projectionClipAngle.svg

Lines changed: 25 additions & 0 deletions
Loading

test/output/projectionClipAngleFrame.svg

Lines changed: 25 additions & 0 deletions
Loading

test/output/projectionClipBerghaus.svg

Lines changed: 31 additions & 0 deletions
Loading

test/output/projectionFitAntarctica.svg

Lines changed: 6 additions & 10 deletions
Loading

test/output/projectionFitBertin1953.svg

Lines changed: 36 additions & 0 deletions
Loading

test/output/projectionFitConic.svg

Lines changed: 1 addition & 1 deletion
Loading

test/output/usCountyChoropleth.html

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

test/output/usStateCapitals.svg

Lines changed: 1 addition & 1 deletion
Loading

test/plots/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export {default as availability} from "./availability.js";
2626
export {default as ballotStatusRace} from "./ballot-status-race.js";
2727
export {default as bandClip} from "./band-clip.js";
2828
export {default as beckerBarley} from "./becker-barley.js";
29-
export {default as bertin1953Facets} from "./bertin1953-facets.js";
3029
export {default as binStrings} from "./bin-strings.js";
3130
export {default as binTimestamps} from "./bin-timestamps.js";
3231
export {default as boxplot} from "./boxplot.js";
@@ -171,7 +170,13 @@ export {default as penguinSpeciesIsland} from "./penguin-species-island.js";
171170
export {default as penguinSpeciesIslandRelative} from "./penguin-species-island-relative.js";
172171
export {default as penguinSpeciesIslandSex} from "./penguin-species-island-sex.js";
173172
export {default as polylinear} from "./polylinear.js";
173+
export {default as projectionBleedEdges} from "./projection-bleed-edges.js";
174+
export {default as projectionBleedEdges2} from "./projection-bleed-edges2.js";
175+
export {default as projectionClipAngle} from "./projection-clip-angle.js";
176+
export {default as projectionClipAngleFrame} from "./projection-clip-angle-frame.js";
177+
export {default as projectionClipBerghaus} from "./projection-clip-berghaus.js";
174178
export {default as projectionFitAntarctica} from "./projection-fit-antarctica.js";
179+
export {default as projectionFitBertin1953} from "./projection-fit-bertin1953.js";
175180
export {default as projectionFitConic} from "./projection-fit-conic.js";
176181
export {default as projectionFitIdentity} from "./projection-fit-identity.js";
177182
export {default as projectionFitUsAlbers} from "./projection-fit-us-albers.js";

test/plots/projection-bleed-edges.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
4+
5+
export default async function () {
6+
const world = await d3.json("data/countries-50m.json");
7+
const domain = feature(world, world.objects.land);
8+
const width = 600;
9+
return Plot.plot({
10+
width,
11+
height: width,
12+
projection: {
13+
type: "azimuthal-equal-area",
14+
rotate: [45, -90],
15+
domain: {type: "Sphere"},
16+
clip: 31,
17+
inset: -width * (Math.SQRT1_2 - 0.5) // extend to corners instead of edges
18+
},
19+
marks: [Plot.geo(domain, {fill: "#ccc", stroke: "currentColor"}), Plot.graticule()]
20+
});
21+
}

test/plots/projection-bleed-edges2.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
4+
5+
export default async function () {
6+
const world = await d3.json("data/countries-110m.json");
7+
const land = feature(world, world.objects.land);
8+
return Plot.plot({
9+
width: 600,
10+
height: 600,
11+
facet: {x: [1, 2], data: [1, 2]},
12+
projection: {
13+
type: "azimuthal-equidistant",
14+
rotate: [90, -90],
15+
domain: d3.geoCircle().center([0, 90]).radius(85)(),
16+
clip: "frame",
17+
inset: -185
18+
},
19+
marks: [Plot.graticule(), Plot.geo(land, {fill: "#ccc", stroke: "currentColor"}), Plot.frame({stroke: "white"})]
20+
});
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
4+
5+
export default async function () {
6+
const world = await d3.json("data/countries-50m.json");
7+
const domain = feature(world, world.objects.land);
8+
return Plot.plot({
9+
width: 600,
10+
height: 600,
11+
projection: {type: "azimuthal-equidistant", clip: 40, inset: -20, rotate: [0, -90], domain: {type: "Sphere"}},
12+
marks: [Plot.graticule(), Plot.geo(domain, {fill: "currentColor"}), Plot.sphere()]
13+
});
14+
}

test/plots/projection-clip-angle.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
4+
5+
export default async function () {
6+
const world = await d3.json("data/countries-50m.json");
7+
const domain = feature(world, world.objects.land);
8+
return Plot.plot({
9+
width: 600,
10+
height: 600,
11+
projection: {type: "azimuthal-equidistant", clip: 30, rotate: [0, 90], domain: {type: "Sphere"}},
12+
marks: [Plot.graticule(), Plot.geo(domain, {fill: "currentColor"}), Plot.sphere()]
13+
});
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Plot from "@observablehq/plot";
2+
import * as d3 from "d3";
3+
import {geoBerghaus} from "d3-geo-projection";
4+
import {feature} from "topojson-client";
5+
6+
export default async function () {
7+
const world = await d3.json("data/countries-110m.json");
8+
const land = feature(world, world.objects.land);
9+
return Plot.plot({
10+
width: 600,
11+
height: 600,
12+
projection: {type: geoBerghaus, domain: {type: "Sphere"}},
13+
marks: [Plot.graticule({clip: "sphere"}), Plot.geo(land, {fill: "currentColor", clip: "sphere"}), Plot.sphere()]
14+
});
15+
}

test/plots/projection-fit-antarctica.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,20 @@ export default async function () {
1212
width: 600,
1313
height: 600,
1414
inset: 30,
15-
projection: {type: "azimuthal-equidistant", rotate: [0, 90], domain},
16-
marks: [Plot.graticule({clip: "frame"}), Plot.geo(domain, {clip: "frame", fill: "currentColor"}), Plot.frame()]
15+
style: "overflow: visible;",
16+
projection: {
17+
type: "azimuthal-equidistant",
18+
rotate: [0, 90],
19+
domain
20+
},
21+
marks: [
22+
Plot.graticule(),
23+
Plot.geo(domain, {fill: "currentColor"}),
24+
Plot.frame(),
25+
// Since we’re using the default clip: "frame" for the projection, these
26+
// marks should not be rendered; the projected point is outside the frame.
27+
Plot.dot({length: 1}, {x: -90, y: -63}),
28+
Plot.text({length: 1}, {x: -90, y: -63, text: ["Do not render"]})
29+
]
1730
});
1831
}
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,22 @@
11
import * as Plot from "@observablehq/plot";
22
import * as d3 from "d3";
33
import {geoBertin1953} from "d3-geo-projection";
4-
import {feature} from "topojson-client";
4+
import {merge} from "topojson-client";
55

66
export default async function () {
7-
const world = await d3.json("data/countries-50m.json");
8-
const land = feature(world, world.objects.land);
7+
const world = await d3.json("data/countries-110m.json");
8+
const land = merge(
9+
world,
10+
world.objects.countries.geometries.filter((d) => d.properties.name !== "Antarctica")
11+
);
912
return Plot.plot({
1013
width: 960,
1114
height: 302,
1215
marginRight: 44,
1316
marginLeft: 0,
1417
facet: {data: [1, 2], x: ["a", "b"]},
15-
projection: ({width, height}) => geoBertin1953().fitSize([width, height], {type: "Sphere"}),
16-
marks: [Plot.frame({stroke: "red"}), Plot.geo(land, {fill: "currentColor"}), Plot.sphere({strokeWidth: 0.5})],
18+
projection: {type: geoBertin1953, domain: land},
19+
marks: [Plot.frame({stroke: "red"}), Plot.geo(land, {fill: "currentColor"})],
1720
style: "border: solid 1px blue"
1821
});
1922
}

test/plots/projection-fit-conic.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as d3 from "d3";
33
import {feature} from "topojson-client";
44

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

0 commit comments

Comments
 (0)