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

0 commit comments

Comments
 (0)