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 @@ + + + + + −1.0 + −0.8 + −0.6 + −0.4 + −0.2 + 0.0 + 0.2 + 0.4 + 0.6 + 0.8 + 1.0 + + + + −2.0 + −1.5 + −1.0 + −0.5 + 0.0 + 0.5 + 1.0 + + + + + + + + \ 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); }