Skip to content

Commit 9b53a85

Browse files
Filmbostock
andauthored
clip: geojson (#2243)
* clip: geojson * All GeoJSON, with a centralized duck typing test * clip: {type: "Sphere"} is equal to clip: "sphere" * Add a path factory to the context * switch/case * promote "sphere" to {type:"Sphere"}; needs a unique shared object to avoid duplicating the clip-path * Use a local sphere, simplify memoization * inline memoizeGeo as an IIFE --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent f889fd9 commit 9b53a85

20 files changed

+390
-84
lines changed

docs/features/marks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ All marks support the following style options:
493493
* **clip** - whether and how to clip the mark
494494
* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md) <VersionBadge version="0.6.7" />
495495

496-
If the **clip** option is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions; if the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, then a [geographic projection](./projections.md) is required and the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection).
496+
If the **clip** option is *frame* (or equivalently true), the mark is clipped to the frame’s dimensions. If the **clip** option is null (or equivalently false), the mark is not clipped. If the **clip** option is *sphere*, the mark will be clipped to the projected sphere (_e.g._, the front hemisphere when using the orthographic projection); a [geographic projection](./projections.md) is required in this case. Lastly if the **clip** option is a GeoJSON object <VersionBadge pr="2243" />, the mark will be clipped to the projected geometry.
497497

498498
If the **tip** option is true, a [tip mark](../marks/tip.md) with the [pointer transform](../interactions/pointer.md) will be derived from this mark and placed atop all other marks, offering details on demand. If the **tip** option is set to an options object, these options will be passed to the derived tip mark. If the **tip** option (or, if an object, its **pointer** option) is set to *x*, *y*, or *xy*, [pointerX](../interactions/pointer.md#pointerX), [pointerY](../interactions/pointer.md#pointerY), or [pointer](../interactions/pointer.md#pointer) will be used, respectively; otherwise the pointing mode will be chosen automatically. (If the **tip** mark option is truthy, the **title** channel is no longer applied using an SVG title element as this would conflict with the tip mark.)
499499

src/context.d.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type {GeoStreamWrapper} from "d3";
1+
import type {GeoPath, GeoStreamWrapper} from "d3";
22
import type {MarkOptions} from "./mark.js";
33

44
/** Additional rendering context provided to marks and initializers. */
@@ -18,6 +18,9 @@ export interface Context {
1818
/** The current projection, if any. */
1919
projection?: GeoStreamWrapper;
2020

21+
/** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */
22+
path: () => GeoPath;
23+
2124
/** The default clip for all marks. */
2225
clip?: MarkOptions["clip"];
2326
}

src/mark.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type {GeoPermissibleObjects} from "d3";
12
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
23
import type {Context} from "./context.js";
34
import type {Dimensions} from "./dimensions.js";
@@ -296,11 +297,12 @@ export interface MarkOptions {
296297
*
297298
* - *frame* or true - clip to the plot’s frame (inner area)
298299
* - *sphere* - clip to the projected sphere (*e.g.*, front hemisphere)
300+
* - geojson - a GeoJSON object, typically with polygonal geometry
299301
* - null or false - do not clip
300302
*
301303
* The *sphere* clip option requires a geographic projection.
302304
*/
303-
clip?: "frame" | "sphere" | boolean | null;
305+
clip?: "frame" | "sphere" | GeoPermissibleObjects | boolean | null;
304306

305307
/**
306308
* The horizontal offset in pixels; a constant option. On low-density screens,

src/marks/geo.js

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {geoGraticule10, geoPath, geoTransform} from "d3";
1+
import {geoGraticule10} from "d3";
22
import {create} from "../context.js";
33
import {negative, positive} from "../defined.js";
44
import {Mark} from "../mark.js";
@@ -35,7 +35,7 @@ export class Geo extends Mark {
3535
}
3636
render(index, scales, channels, dimensions, context) {
3737
const {geometry: G, r: R} = channels;
38-
const path = geoPath(context.projection ?? scaleProjection(scales));
38+
const path = context.path();
3939
const {r} = this;
4040
if (negative(r)) index = [];
4141
else if (r !== undefined) path.pointRadius(r);
@@ -55,20 +55,6 @@ export class Geo extends Mark {
5555
}
5656
}
5757

58-
// If no projection is specified, default to a projection that passes points
59-
// through the x and y scales, if any.
60-
function scaleProjection({x: X, y: Y}) {
61-
if (X || Y) {
62-
X ??= (x) => x;
63-
Y ??= (y) => y;
64-
return geoTransform({
65-
point(x, y) {
66-
this.stream.point(X(x), Y(y));
67-
}
68-
});
69-
}
70-
}
71-
7258
export function geo(data, options = {}) {
7359
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
7460
else if (options.geometry === undefined) options = {...options, geometry: identity};

src/options.js

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,24 @@ export function dataify(data) {
169169
export function arrayify(values) {
170170
if (values == null || isArray(values)) return values;
171171
if (isArrowVector(values)) return maybeTypedArrowify(values);
172-
switch (values.type) {
172+
if (isGeoJSON(values)) {
173+
switch (values.type) {
174+
case "FeatureCollection":
175+
return values.features;
176+
case "GeometryCollection":
177+
return values.geometries;
178+
default:
179+
return [values];
180+
}
181+
}
182+
return Array.from(values);
183+
}
184+
185+
// Duck typing test for GeoJSON
186+
function isGeoJSON(x) {
187+
switch (x?.type) {
173188
case "FeatureCollection":
174-
return values.features;
175189
case "GeometryCollection":
176-
return values.geometries;
177190
case "Feature":
178191
case "LineString":
179192
case "MultiLineString":
@@ -182,9 +195,10 @@ export function arrayify(values) {
182195
case "Point":
183196
case "Polygon":
184197
case "Sphere":
185-
return [values];
198+
return true;
199+
default:
200+
return false;
186201
}
187-
return Array.from(values);
188202
}
189203

190204
// An optimization of type.from(values, f): if the given values are already an
@@ -602,12 +616,13 @@ export function maybeNamed(things) {
602616
return isIterable(things) ? named(things) : things;
603617
}
604618

605-
// TODO Accept other types of clips (paths, urls, x, y, other marks…)?
606-
// https://github.com/observablehq/plot/issues/181
607619
export function maybeClip(clip) {
608620
if (clip === true) clip = "frame";
609621
else if (clip === false) clip = null;
610-
else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]);
622+
else if (!isGeoJSON(clip) && clip != null) {
623+
clip = keyword(clip, "clip", ["frame", "sphere"]);
624+
if (clip === "sphere") clip = {type: "Sphere"};
625+
}
611626
return clip;
612627
}
613628

src/plot.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {creator, select} from "d3";
1+
import {creator, geoPath, select} from "d3";
22
import {createChannel, inferChannelScale} from "./channel.js";
33
import {createContext} from "./context.js";
44
import {createDimensions} from "./dimensions.js";
@@ -11,7 +11,7 @@ import {frame} from "./marks/frame.js";
1111
import {tip} from "./marks/tip.js";
1212
import {isColor, isIterable, isNone, isScaleOptions} from "./options.js";
1313
import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js";
14-
import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
14+
import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js";
1515
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
1616
import {innerDimensions, outerDimensions} from "./scales.js";
1717
import {isPosition, registry as scaleRegistry} from "./scales/index.js";
@@ -236,6 +236,11 @@ export function plot(options = {}) {
236236
facetTranslate = facetTranslator(fx, fy, dimensions);
237237
}
238238

239+
// A path generator for marks that want to draw GeoJSON.
240+
context.path = function () {
241+
return geoPath(this.projection ?? xyProjection(scales));
242+
};
243+
239244
// Compute value objects, applying scales and projection as needed.
240245
for (const [mark, state] of stateByMark) {
241246
state.values = mark.scale(state.channels, scales, context);

src/projection.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,17 @@ export function getGeometryChannels(channel) {
296296
for (const object of channel.value) geoStream(object, sink);
297297
return [x, y];
298298
}
299+
300+
// If no projection is specified, default to a projection that passes points
301+
// through the x and y scales, if any.
302+
export function xyProjection({x: X, y: Y}) {
303+
if (X || Y) {
304+
X ??= (x) => x;
305+
Y ??= (y) => y;
306+
return geoTransform({
307+
point(x, y) {
308+
this.stream.point(X(x), Y(y));
309+
}
310+
});
311+
}
312+
}

src/style.js

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {geoPath, group, namespaces, select} from "d3";
1+
import {group, namespaces, select} from "d3";
22
import {create} from "./context.js";
33
import {defined, nonempty} from "./defined.js";
44
import {formatDefault} from "./format.js";
@@ -306,24 +306,20 @@ export function* groupIndex(I, position, mark, channels) {
306306
function applyClip(selection, mark, dimensions, context) {
307307
let clipUrl;
308308
const {clip = context.clip} = mark;
309-
switch (clip) {
310-
case "frame": {
311-
// Wrap the G element with another (untransformed) G element, applying the
312-
// clip to the parent G element so that the clip path is not affected by
313-
// the mark’s transform. To simplify the adoption of this fix, mutate the
314-
// passed-in selection.node to return the parent G element.
315-
selection = create("svg:g", context).each(function () {
316-
this.appendChild(selection.node());
317-
selection.node = () => this; // Note: mutation!
318-
});
319-
clipUrl = getFrameClip(context, dimensions);
320-
break;
321-
}
322-
case "sphere": {
323-
clipUrl = getProjectionClip(context);
324-
break;
325-
}
309+
if (clip === "frame") {
310+
// Wrap the G element with another (untransformed) G element, applying the
311+
// clip to the parent G element so that the clip path is not affected by
312+
// the mark’s transform. To simplify the adoption of this fix, mutate the
313+
// passed-in selection.node to return the parent G element.
314+
selection = create("svg:g", context).each(function () {
315+
this.appendChild(selection.node());
316+
selection.node = () => this; // Note: mutation!
317+
});
318+
clipUrl = getFrameClip(context, dimensions);
319+
} else if (clip) {
320+
clipUrl = getGeoClip(clip, context);
326321
}
322+
327323
// Here we’re careful to apply the ARIA attributes to the outer G element when
328324
// clipping is applied, and to apply the ARIA attributes before any other
329325
// attributes (for readability).
@@ -356,11 +352,21 @@ const getFrameClip = memoizeClip((clipPath, context, dimensions) => {
356352
.attr("height", height - marginTop - marginBottom);
357353
});
358354

359-
const getProjectionClip = memoizeClip((clipPath, context) => {
360-
const {projection} = context;
361-
if (!projection) throw new Error(`the "sphere" clip option requires a projection`);
362-
clipPath.append("path").attr("d", geoPath(projection)({type: "Sphere"}));
363-
});
355+
const getGeoClip = (function () {
356+
const cache = new WeakMap();
357+
const sphere = {type: "Sphere"};
358+
return (geo, context) => {
359+
let c, url;
360+
if (!(c = cache.get(context))) cache.set(context, (c = new WeakMap()));
361+
if (geo.type === "Sphere") geo = sphere; // coalesce all spheres.
362+
if (!(url = c.get(geo))) {
363+
const id = getClipId();
364+
select(context.ownerSVGElement).append("clipPath").attr("id", id).append("path").attr("d", context.path()(geo));
365+
c.set(geo, (url = `url(#${id})`));
366+
}
367+
return url;
368+
};
369+
})();
364370

365371
// Note: may mutate selection.node!
366372
export function applyIndirectStyles(selection, mark, dimensions, context) {

test/output/contourVaporClip.svg

Lines changed: 45 additions & 0 deletions
Loading

test/output/mandelbrotClip.svg

Lines changed: 66 additions & 0 deletions
Loading

test/output/rasterWalmartBarycentric.svg

Lines changed: 4 additions & 4 deletions
Loading

test/output/rasterWalmartBarycentricOpacity.svg

Lines changed: 4 additions & 4 deletions
Loading

test/output/rasterWalmartRandomWalk.svg

Lines changed: 4 additions & 4 deletions
Loading

test/output/rasterWalmartWalkOpacity.svg

Lines changed: 4 additions & 4 deletions
Loading

test/output/usStateClipVoronoi.svg

Lines changed: 83 additions & 0 deletions
Loading

test/plots/armadillo.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export async function armadillo() {
1111
height: 548,
1212
margin: 1,
1313
projection: ({width, height}) => geoArmadillo().precision(0.2).fitSize([width, height], {type: "Sphere"}),
14-
marks: [Plot.geo(land, {clip: "sphere", fill: "currentColor"}), Plot.graticule({clip: "sphere"}), Plot.sphere()]
14+
marks: [
15+
Plot.geo(land, {clip: "sphere", fill: "currentColor"}),
16+
Plot.graticule({clip: {type: "Sphere"}}),
17+
Plot.sphere()
18+
]
1519
});
1620
}

test/plots/heatmap.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,35 @@ export function mandelbrot() {
180180
]
181181
});
182182
}
183+
184+
export function mandelbrotClip() {
185+
return Plot.plot({
186+
height: 500,
187+
clip: {
188+
type: "Polygon",
189+
coordinates: [
190+
[
191+
[-2, 0],
192+
[0, 1.5],
193+
[1, 0],
194+
[0, -1.5],
195+
[-2, 0]
196+
]
197+
]
198+
},
199+
marks: [
200+
Plot.raster({
201+
fill: (x, y) => {
202+
for (let n = 0, zr = 0, zi = 0; n < 80; ++n) {
203+
[zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y];
204+
if (zr * zr + zi * zi > 4) return n;
205+
}
206+
},
207+
x1: -2,
208+
y1: -1.164,
209+
x2: 1,
210+
y2: 1.164
211+
})
212+
]
213+
});
214+
}

test/plots/raster-vapor.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as Plot from "@observablehq/plot";
22
import * as d3 from "d3";
3+
import {feature} from "topojson-client";
34

45
async function vapor() {
56
return d3
@@ -61,6 +62,47 @@ export async function contourVapor() {
6162
});
6263
}
6364

65+
export async function contourVaporClip() {
66+
const [world, data] = await Promise.all([d3.json<any>("data/countries-50m.json"), vapor()]);
67+
const land = feature(world, world.objects.land);
68+
return Plot.plot({
69+
width: 960,
70+
projection: {type: "orthographic", rotate: [0, -90]},
71+
color: {scheme: "blues"},
72+
marks: [
73+
Plot.sphere({fill: "#eee"}),
74+
Plot.raster(data, {
75+
fill: Plot.identity,
76+
interpolate: "random-walk",
77+
width: 360,
78+
height: 180,
79+
x1: -180,
80+
y1: 90,
81+
x2: 180,
82+
y2: -90,
83+
blur: 1,
84+
pixelSize: 3,
85+
clip: land
86+
}),
87+
Plot.contour(data, {
88+
value: Plot.identity,
89+
width: 360,
90+
height: 180,
91+
x1: -180,
92+
y1: 90,
93+
x2: 180,
94+
y2: -90,
95+
blur: 0.5,
96+
stroke: "black",
97+
strokeWidth: 0.5,
98+
clip: land
99+
}),
100+
Plot.geo(land, {stroke: "black"}),
101+
Plot.sphere({stroke: "black"})
102+
]
103+
});
104+
}
105+
64106
export async function rasterVaporPeters() {
65107
const radians = Math.PI / 180;
66108
const sin = (y) => Math.sin(y * radians);

test/plots/raster-walmart.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,12 @@ async function rasterWalmart(options) {
77
d3.tsv<any>("data/walmarts.tsv", d3.autoType),
88
d3
99
.json<any>("data/us-counties-10m.json")
10-
.then((us) => [
11-
feature(us, us.objects.nation.geometries[0]).geometry.coordinates[0][0],
12-
mesh(us, us.objects.states, (a, b) => a !== b)
13-
])
10+
.then((us) => [feature(us, us.objects.nation.geometries[0]), mesh(us, us.objects.states, (a, b) => a !== b)])
1411
]);
1512
return Plot.plot({
1613
projection: "albers",
1714
color: {scheme: "spectral"},
18-
marks: [
19-
Plot.raster(walmarts, {x: "longitude", y: "latitude", ...options}),
20-
Plot.geo({type: "Polygon", coordinates: [d3.reverse(outline) as number[][]]}, {fill: "white"}),
21-
Plot.geo(statemesh)
22-
]
15+
marks: [Plot.raster(walmarts, {x: "longitude", y: "latitude", ...options, clip: outline}), Plot.geo(statemesh)]
2316
});
2417
}
2518

0 commit comments

Comments
 (0)