diff --git a/docs/features/marks.md b/docs/features/marks.md
index 7e163907e6..52754bf443 100644
--- a/docs/features/marks.md
+++ b/docs/features/marks.md
@@ -493,7 +493,7 @@ All marks support the following style options:
* **clip** - whether and how to clip the mark
* **tip** - whether to generate an implicit [pointer](../interactions/pointer.md) [tip](../marks/tip.md)
-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).
+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 , the mark will be clipped to the projected geometry.
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.)
diff --git a/src/context.d.ts b/src/context.d.ts
index ce2c3568d8..53a1c01fee 100644
--- a/src/context.d.ts
+++ b/src/context.d.ts
@@ -1,4 +1,4 @@
-import type {GeoStreamWrapper} from "d3";
+import type {GeoPath, GeoStreamWrapper} from "d3";
import type {MarkOptions} from "./mark.js";
/** Additional rendering context provided to marks and initializers. */
@@ -18,6 +18,9 @@ export interface Context {
/** The current projection, if any. */
projection?: GeoStreamWrapper;
+ /** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */
+ path: () => GeoPath;
+
/** The default clip for all marks. */
clip?: MarkOptions["clip"];
}
diff --git a/src/mark.d.ts b/src/mark.d.ts
index c40b1a1a56..43e61da15f 100644
--- a/src/mark.d.ts
+++ b/src/mark.d.ts
@@ -1,3 +1,4 @@
+import type {GeoPermissibleObjects} from "d3";
import type {Channel, ChannelDomainSort, ChannelValue, ChannelValues, ChannelValueSpec} from "./channel.js";
import type {Context} from "./context.js";
import type {Dimensions} from "./dimensions.js";
@@ -296,11 +297,12 @@ export interface MarkOptions {
*
* - *frame* or true - clip to the plot’s frame (inner area)
* - *sphere* - clip to the projected sphere (*e.g.*, front hemisphere)
+ * - geojson - a GeoJSON object, typically with polygonal geometry
* - null or false - do not clip
*
* The *sphere* clip option requires a geographic projection.
*/
- clip?: "frame" | "sphere" | boolean | null;
+ clip?: "frame" | "sphere" | GeoPermissibleObjects | boolean | null;
/**
* The horizontal offset in pixels; a constant option. On low-density screens,
diff --git a/src/marks/geo.js b/src/marks/geo.js
index 854e3dcef5..60252dd447 100644
--- a/src/marks/geo.js
+++ b/src/marks/geo.js
@@ -1,4 +1,4 @@
-import {geoGraticule10, geoPath, geoTransform} from "d3";
+import {geoGraticule10} from "d3";
import {create} from "../context.js";
import {negative, positive} from "../defined.js";
import {Mark} from "../mark.js";
@@ -35,7 +35,7 @@ export class Geo extends Mark {
}
render(index, scales, channels, dimensions, context) {
const {geometry: G, r: R} = channels;
- const path = geoPath(context.projection ?? scaleProjection(scales));
+ const path = context.path();
const {r} = this;
if (negative(r)) index = [];
else if (r !== undefined) path.pointRadius(r);
@@ -55,20 +55,6 @@ export class Geo extends Mark {
}
}
-// If no projection is specified, default to a projection that passes points
-// through the x and y scales, if any.
-function scaleProjection({x: X, y: Y}) {
- if (X || Y) {
- X ??= (x) => x;
- Y ??= (y) => y;
- return geoTransform({
- point(x, y) {
- this.stream.point(X(x), Y(y));
- }
- });
- }
-}
-
export function geo(data, options = {}) {
if (options.tip && options.x === undefined && options.y === undefined) options = centroid(options);
else if (options.geometry === undefined) options = {...options, geometry: identity};
diff --git a/src/options.js b/src/options.js
index 144fd3b37b..d11bee1bb4 100644
--- a/src/options.js
+++ b/src/options.js
@@ -169,11 +169,24 @@ export function dataify(data) {
export function arrayify(values) {
if (values == null || isArray(values)) return values;
if (isArrowVector(values)) return maybeTypedArrowify(values);
- switch (values.type) {
+ if (isGeoJSON(values)) {
+ switch (values.type) {
+ case "FeatureCollection":
+ return values.features;
+ case "GeometryCollection":
+ return values.geometries;
+ default:
+ return [values];
+ }
+ }
+ return Array.from(values);
+}
+
+// Duck typing test for GeoJSON
+function isGeoJSON(x) {
+ switch (x?.type) {
case "FeatureCollection":
- return values.features;
case "GeometryCollection":
- return values.geometries;
case "Feature":
case "LineString":
case "MultiLineString":
@@ -182,9 +195,10 @@ export function arrayify(values) {
case "Point":
case "Polygon":
case "Sphere":
- return [values];
+ return true;
+ default:
+ return false;
}
- return Array.from(values);
}
// An optimization of type.from(values, f): if the given values are already an
@@ -602,12 +616,13 @@ export function maybeNamed(things) {
return isIterable(things) ? named(things) : things;
}
-// TODO Accept other types of clips (paths, urls, x, y, other marks…)?
-// https://github.com/observablehq/plot/issues/181
export function maybeClip(clip) {
if (clip === true) clip = "frame";
else if (clip === false) clip = null;
- else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]);
+ else if (!isGeoJSON(clip) && clip != null) {
+ clip = keyword(clip, "clip", ["frame", "sphere"]);
+ if (clip === "sphere") clip = {type: "Sphere"};
+ }
return clip;
}
diff --git a/src/plot.js b/src/plot.js
index 829c4624a4..d92aca0587 100644
--- a/src/plot.js
+++ b/src/plot.js
@@ -1,4 +1,4 @@
-import {creator, select} from "d3";
+import {creator, geoPath, select} from "d3";
import {createChannel, inferChannelScale} from "./channel.js";
import {createContext} from "./context.js";
import {createDimensions} from "./dimensions.js";
@@ -11,7 +11,7 @@ import {frame} from "./marks/frame.js";
import {tip} from "./marks/tip.js";
import {isColor, isIterable, isNone, isScaleOptions} from "./options.js";
import {dataify, lengthof, map, yes, maybeIntervalTransform, subarray} from "./options.js";
-import {createProjection, getGeometryChannels, hasProjection} from "./projection.js";
+import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js";
import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js";
import {innerDimensions, outerDimensions} from "./scales.js";
import {isPosition, registry as scaleRegistry} from "./scales/index.js";
@@ -236,6 +236,11 @@ export function plot(options = {}) {
facetTranslate = facetTranslator(fx, fy, dimensions);
}
+ // A path generator for marks that want to draw GeoJSON.
+ context.path = function () {
+ return geoPath(this.projection ?? xyProjection(scales));
+ };
+
// Compute value objects, applying scales and projection as needed.
for (const [mark, state] of stateByMark) {
state.values = mark.scale(state.channels, scales, context);
diff --git a/src/projection.js b/src/projection.js
index 30df2ae88e..20e011101a 100644
--- a/src/projection.js
+++ b/src/projection.js
@@ -296,3 +296,17 @@ export function getGeometryChannels(channel) {
for (const object of channel.value) geoStream(object, sink);
return [x, y];
}
+
+// If no projection is specified, default to a projection that passes points
+// through the x and y scales, if any.
+export function xyProjection({x: X, y: Y}) {
+ if (X || Y) {
+ X ??= (x) => x;
+ Y ??= (y) => y;
+ return geoTransform({
+ point(x, y) {
+ this.stream.point(X(x), Y(y));
+ }
+ });
+ }
+}
diff --git a/src/style.js b/src/style.js
index 94e4742ee0..f123ef3b6a 100644
--- a/src/style.js
+++ b/src/style.js
@@ -1,4 +1,4 @@
-import {geoPath, group, namespaces, select} from "d3";
+import {group, namespaces, select} from "d3";
import {create} from "./context.js";
import {defined, nonempty} from "./defined.js";
import {formatDefault} from "./format.js";
@@ -306,24 +306,20 @@ export function* groupIndex(I, position, mark, channels) {
function applyClip(selection, mark, dimensions, context) {
let clipUrl;
const {clip = context.clip} = mark;
- switch (clip) {
- case "frame": {
- // Wrap the G element with another (untransformed) G element, applying the
- // clip to the parent G element so that the clip path is not affected by
- // the mark’s transform. To simplify the adoption of this fix, mutate the
- // passed-in selection.node to return the parent G element.
- selection = create("svg:g", context).each(function () {
- this.appendChild(selection.node());
- selection.node = () => this; // Note: mutation!
- });
- clipUrl = getFrameClip(context, dimensions);
- break;
- }
- case "sphere": {
- clipUrl = getProjectionClip(context);
- break;
- }
+ if (clip === "frame") {
+ // Wrap the G element with another (untransformed) G element, applying the
+ // clip to the parent G element so that the clip path is not affected by
+ // the mark’s transform. To simplify the adoption of this fix, mutate the
+ // passed-in selection.node to return the parent G element.
+ selection = create("svg:g", context).each(function () {
+ this.appendChild(selection.node());
+ selection.node = () => this; // Note: mutation!
+ });
+ clipUrl = getFrameClip(context, dimensions);
+ } else if (clip) {
+ clipUrl = getGeoClip(clip, context);
}
+
// Here we’re careful to apply the ARIA attributes to the outer G element when
// clipping is applied, and to apply the ARIA attributes before any other
// attributes (for readability).
@@ -356,11 +352,21 @@ const getFrameClip = memoizeClip((clipPath, context, dimensions) => {
.attr("height", height - marginTop - marginBottom);
});
-const getProjectionClip = memoizeClip((clipPath, context) => {
- const {projection} = context;
- if (!projection) throw new Error(`the "sphere" clip option requires a projection`);
- clipPath.append("path").attr("d", geoPath(projection)({type: "Sphere"}));
-});
+const getGeoClip = (function () {
+ const cache = new WeakMap();
+ const sphere = {type: "Sphere"};
+ return (geo, context) => {
+ let c, url;
+ if (!(c = cache.get(context))) cache.set(context, (c = new WeakMap()));
+ if (geo.type === "Sphere") geo = sphere; // coalesce all spheres.
+ if (!(url = c.get(geo))) {
+ const id = getClipId();
+ select(context.ownerSVGElement).append("clipPath").attr("id", id).append("path").attr("d", context.path()(geo));
+ c.set(geo, (url = `url(#${id})`));
+ }
+ return url;
+ };
+})();
// Note: may mutate selection.node!
export function applyIndirectStyles(selection, mark, dimensions, context) {
diff --git a/test/output/contourVaporClip.svg b/test/output/contourVaporClip.svg
new file mode 100644
index 0000000000..3505e28a71
--- /dev/null
+++ b/test/output/contourVaporClip.svg
@@ -0,0 +1,45 @@
+
\ No newline at end of file
diff --git a/test/output/mandelbrotClip.svg b/test/output/mandelbrotClip.svg
new file mode 100644
index 0000000000..2d7082c601
--- /dev/null
+++ b/test/output/mandelbrotClip.svg
@@ -0,0 +1,66 @@
+
\ No newline at end of file
diff --git a/test/output/rasterWalmartBarycentric.svg b/test/output/rasterWalmartBarycentric.svg
index 518ce041f5..00607eb342 100644
--- a/test/output/rasterWalmartBarycentric.svg
+++ b/test/output/rasterWalmartBarycentric.svg
@@ -13,12 +13,12 @@
white-space: pre;
}
-
+
+
+
+
-
-
-
diff --git a/test/output/rasterWalmartBarycentricOpacity.svg b/test/output/rasterWalmartBarycentricOpacity.svg
index c922759c9e..e5d3527f6c 100644
--- a/test/output/rasterWalmartBarycentricOpacity.svg
+++ b/test/output/rasterWalmartBarycentricOpacity.svg
@@ -13,12 +13,12 @@
white-space: pre;
}
-
+
+
+
+
-
-
-
diff --git a/test/output/rasterWalmartRandomWalk.svg b/test/output/rasterWalmartRandomWalk.svg
index 6587b20d54..eb295cdc71 100644
--- a/test/output/rasterWalmartRandomWalk.svg
+++ b/test/output/rasterWalmartRandomWalk.svg
@@ -13,12 +13,12 @@
white-space: pre;
}
-
+
+
+
+
-
-
-
diff --git a/test/output/rasterWalmartWalkOpacity.svg b/test/output/rasterWalmartWalkOpacity.svg
index 074c9df0dc..1b4bc5c89c 100644
--- a/test/output/rasterWalmartWalkOpacity.svg
+++ b/test/output/rasterWalmartWalkOpacity.svg
@@ -13,12 +13,12 @@
white-space: pre;
}
-
+
+
+
+
-
-
-
diff --git a/test/output/usStateClipVoronoi.svg b/test/output/usStateClipVoronoi.svg
new file mode 100644
index 0000000000..8e659749c9
--- /dev/null
+++ b/test/output/usStateClipVoronoi.svg
@@ -0,0 +1,83 @@
+
\ No newline at end of file
diff --git a/test/plots/armadillo.ts b/test/plots/armadillo.ts
index c5de2b5029..c9e3d52a3d 100644
--- a/test/plots/armadillo.ts
+++ b/test/plots/armadillo.ts
@@ -11,6 +11,10 @@ export async function armadillo() {
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()]
+ marks: [
+ Plot.geo(land, {clip: "sphere", fill: "currentColor"}),
+ Plot.graticule({clip: {type: "Sphere"}}),
+ Plot.sphere()
+ ]
});
}
diff --git a/test/plots/heatmap.ts b/test/plots/heatmap.ts
index 396d229cd4..e92e08db62 100644
--- a/test/plots/heatmap.ts
+++ b/test/plots/heatmap.ts
@@ -180,3 +180,35 @@ export function mandelbrot() {
]
});
}
+
+export function mandelbrotClip() {
+ return Plot.plot({
+ height: 500,
+ clip: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-2, 0],
+ [0, 1.5],
+ [1, 0],
+ [0, -1.5],
+ [-2, 0]
+ ]
+ ]
+ },
+ marks: [
+ Plot.raster({
+ fill: (x, y) => {
+ for (let n = 0, zr = 0, zi = 0; n < 80; ++n) {
+ [zr, zi] = [zr * zr - zi * zi + x, 2 * zr * zi + y];
+ if (zr * zr + zi * zi > 4) return n;
+ }
+ },
+ x1: -2,
+ y1: -1.164,
+ x2: 1,
+ y2: 1.164
+ })
+ ]
+ });
+}
diff --git a/test/plots/raster-vapor.ts b/test/plots/raster-vapor.ts
index abfe1d7674..fc760b24c7 100644
--- a/test/plots/raster-vapor.ts
+++ b/test/plots/raster-vapor.ts
@@ -1,5 +1,6 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
+import {feature} from "topojson-client";
async function vapor() {
return d3
@@ -61,6 +62,47 @@ export async function contourVapor() {
});
}
+export async function contourVaporClip() {
+ const [world, data] = await Promise.all([d3.json("data/countries-50m.json"), vapor()]);
+ const land = feature(world, world.objects.land);
+ return Plot.plot({
+ width: 960,
+ projection: {type: "orthographic", rotate: [0, -90]},
+ color: {scheme: "blues"},
+ marks: [
+ Plot.sphere({fill: "#eee"}),
+ Plot.raster(data, {
+ fill: Plot.identity,
+ interpolate: "random-walk",
+ width: 360,
+ height: 180,
+ x1: -180,
+ y1: 90,
+ x2: 180,
+ y2: -90,
+ blur: 1,
+ pixelSize: 3,
+ clip: land
+ }),
+ Plot.contour(data, {
+ value: Plot.identity,
+ width: 360,
+ height: 180,
+ x1: -180,
+ y1: 90,
+ x2: 180,
+ y2: -90,
+ blur: 0.5,
+ stroke: "black",
+ strokeWidth: 0.5,
+ clip: land
+ }),
+ Plot.geo(land, {stroke: "black"}),
+ Plot.sphere({stroke: "black"})
+ ]
+ });
+}
+
export async function rasterVaporPeters() {
const radians = Math.PI / 180;
const sin = (y) => Math.sin(y * radians);
diff --git a/test/plots/raster-walmart.ts b/test/plots/raster-walmart.ts
index e9073c7939..58d653ea63 100644
--- a/test/plots/raster-walmart.ts
+++ b/test/plots/raster-walmart.ts
@@ -7,19 +7,12 @@ async function rasterWalmart(options) {
d3.tsv("data/walmarts.tsv", d3.autoType),
d3
.json("data/us-counties-10m.json")
- .then((us) => [
- feature(us, us.objects.nation.geometries[0]).geometry.coordinates[0][0],
- mesh(us, us.objects.states, (a, b) => a !== b)
- ])
+ .then((us) => [feature(us, us.objects.nation.geometries[0]), mesh(us, us.objects.states, (a, b) => a !== b)])
]);
return Plot.plot({
projection: "albers",
color: {scheme: "spectral"},
- marks: [
- Plot.raster(walmarts, {x: "longitude", y: "latitude", ...options}),
- Plot.geo({type: "Polygon", coordinates: [d3.reverse(outline) as number[][]]}, {fill: "white"}),
- Plot.geo(statemesh)
- ]
+ marks: [Plot.raster(walmarts, {x: "longitude", y: "latitude", ...options, clip: outline}), Plot.geo(statemesh)]
});
}
diff --git a/test/plots/us-state-capitals-voronoi.ts b/test/plots/us-state-capitals-voronoi.ts
index a2481cfa07..927c202b2f 100644
--- a/test/plots/us-state-capitals-voronoi.ts
+++ b/test/plots/us-state-capitals-voronoi.ts
@@ -36,37 +36,43 @@ export async function usStateCapitalsVoronoi() {
});
}
-async function voronoiMap(centroid) {
+async function voronoiMap(centroid, clipNation = false) {
const [nation, states] = await d3
.json("data/us-counties-10m.json")
.then((us) => [feature(us, us.objects.nation), feature(us, us.objects.states)]);
+
+ const clip = clipNation ? nation : "sphere";
return Plot.plot({
width: 640,
height: 640,
margin: 1,
projection: ({width, height}) =>
- d3.geoAzimuthalEqualArea().rotate([96, -40]).clipAngle(24).fitSize([width, height], {type: "Sphere"}),
+ d3
+ .geoAzimuthalEqualArea()
+ .rotate([96, -40])
+ .clipAngle(24)
+ .fitSize([width, height], clipNation ? nation : {type: "Sphere"}),
marks: [
Plot.geo(nation, {fill: "currentColor", fillOpacity: 0.2}),
Plot.dot(states.features, centroid({r: 2.5, fill: "currentColor"})),
- Plot.voronoiMesh(states.features, centroid({clip: "sphere"})),
+ Plot.voronoiMesh(states.features, centroid({clip})),
Plot.voronoi(
states.features,
Plot.pointer(
centroid({
x: "longitude",
y: "latitude",
- clip: "sphere",
title: "state",
stroke: "red",
fill: "red",
fillOpacity: 0.4,
pointerEvents: "all",
- maxRadius: Infinity
+ maxRadius: Infinity,
+ clip
})
)
),
- Plot.sphere({strokeWidth: 2})
+ clipNation ? Plot.geo(nation, {strokeWidth: 1}) : Plot.sphere({strokeWidth: 2})
]
});
}
@@ -75,6 +81,10 @@ export async function usStateCentroidVoronoi() {
return voronoiMap(Plot.centroid);
}
+export async function usStateClipVoronoi() {
+ return voronoiMap(Plot.centroid, true);
+}
+
export async function usStateGeoCentroidVoronoi() {
return voronoiMap(Plot.geoCentroid);
}