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

0 commit comments

Comments
 (0)