From 9b488b4d15d114e0c33576c155a407d9a52a8218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 21 Nov 2024 20:00:50 +0100 Subject: [PATCH 01/14] clip: geojson --- src/mark.d.ts | 4 +- src/marks/area.js | 2 +- src/marks/arrow.js | 2 +- src/marks/bar.js | 2 +- src/marks/contour.js | 2 +- src/marks/delaunay.js | 6 +- src/marks/density.js | 2 +- src/marks/dot.js | 2 +- src/marks/frame.js | 2 +- src/marks/geo.js | 21 +---- src/marks/hexgrid.js | 2 +- src/marks/image.js | 2 +- src/marks/line.js | 2 +- src/marks/linearRegression.js | 2 +- src/marks/link.js | 2 +- src/marks/raster.js | 2 +- src/marks/rect.js | 2 +- src/marks/rule.js | 4 +- src/marks/text.js | 2 +- src/marks/tick.js | 2 +- src/marks/tip.js | 2 +- src/marks/vector.js | 2 +- src/marks/waffle.js | 2 +- src/options.js | 8 +- src/projection.js | 14 ++++ src/style.js | 60 ++++++++------ test/output/contourVaporClip.svg | 45 ++++++++++ test/output/mandelbrotClip.svg | 66 +++++++++++++++ test/output/rasterWalmartBarycentric.svg | 8 +- .../rasterWalmartBarycentricOpacity.svg | 8 +- test/output/rasterWalmartRandomWalk.svg | 8 +- test/output/rasterWalmartWalkOpacity.svg | 8 +- test/output/usStateClipVoronoi.svg | 83 +++++++++++++++++++ test/plots/heatmap.ts | 32 +++++++ test/plots/raster-vapor.ts | 42 ++++++++++ test/plots/raster-walmart.ts | 11 +-- test/plots/us-state-capitals-voronoi.ts | 22 +++-- 37 files changed, 386 insertions(+), 102 deletions(-) create mode 100644 test/output/contourVaporClip.svg create mode 100644 test/output/mandelbrotClip.svg create mode 100644 test/output/usStateClipVoronoi.svg diff --git a/src/mark.d.ts b/src/mark.d.ts index 4e5a60cbed..2f2f24f8bf 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -1,3 +1,4 @@ +import {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"; @@ -295,11 +296,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 with a (multi-) polygon 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/area.js b/src/marks/area.js index 628088445a..d706fa09ab 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -46,7 +46,7 @@ export class Area extends Mark { render(index, scales, channels, dimensions, context) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales, 0, 0) .call((g) => g diff --git a/src/marks/arrow.js b/src/marks/arrow.js index 7d1bdaf217..1ca8065995 100644 --- a/src/marks/arrow.js +++ b/src/marks/arrow.js @@ -62,7 +62,7 @@ export class Arrow extends Mark { const wingScale = headLength / 1.5; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/bar.js b/src/marks/bar.js index 686c018460..b4d127d83a 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -25,7 +25,7 @@ export class AbstractBar extends Mark { const w = this._width(scales, channels, dimensions); const h = this._height(scales, channels, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(this._transform, this, scales) .call((g) => g diff --git a/src/marks/contour.js b/src/marks/contour.js index 6d692d7d91..13b6be017d 100644 --- a/src/marks/contour.js +++ b/src/marks/contour.js @@ -91,7 +91,7 @@ export class Contour extends AbstractRaster { const {geometry: G} = channels; const path = geoPath(); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => { g.selectAll() diff --git a/src/marks/delaunay.js b/src/marks/delaunay.js index 57a927d22e..dd776e7e45 100644 --- a/src/marks/delaunay.js +++ b/src/marks/delaunay.js @@ -125,7 +125,7 @@ class DelaunayLink extends Mark { } return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call( Z @@ -175,7 +175,7 @@ class AbstractDelaunayMark extends Mark { } return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call( Z @@ -249,7 +249,7 @@ class Voronoi extends Mark { const {x, y} = scales; const {x: X, y: Y, cells: C} = channels; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => { g.selectAll() diff --git a/src/marks/density.js b/src/marks/density.js index 2dd3cdf227..d069a6aa11 100644 --- a/src/marks/density.js +++ b/src/marks/density.js @@ -50,7 +50,7 @@ export class Density extends Mark { const {contours} = channels; const path = geoPath(); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {}) .call((g) => g diff --git a/src/marks/dot.js b/src/marks/dot.js index 58e529def2..4e4aa356d7 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -79,7 +79,7 @@ export class Dot extends Mark { const size = R ? undefined : r * r * Math.PI; if (negative(r)) index = []; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/frame.js b/src/marks/frame.js index eca4084d26..663ba1969b 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -37,7 +37,7 @@ export class Frame extends Mark { const y2 = height - marginBottom - insetBottom; return create(anchor ? "svg:line" : rx1y1 || rx1y2 || rx2y1 || rx2y2 ? "svg:path" : "svg:rect", context) .datum(0) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyDirectStyles, this) .call(applyChannelStyles, this, channels) .call(applyTransform, this, {}) diff --git a/src/marks/geo.js b/src/marks/geo.js index 854e3dcef5..b3d1d190ae 100644 --- a/src/marks/geo.js +++ b/src/marks/geo.js @@ -1,8 +1,9 @@ -import {geoGraticule10, geoPath, geoTransform} from "d3"; +import {geoGraticule10, geoPath} from "d3"; import {create} from "../context.js"; import {negative, positive} from "../defined.js"; import {Mark} from "../mark.js"; import {identity, maybeNumberChannel} from "../options.js"; +import {xyProjection} from "../projection.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; import {centroid} from "../transforms/centroid.js"; import {withDefaultSort} from "./dot.js"; @@ -35,12 +36,12 @@ 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 = geoPath(context.projection ?? xyProjection(scales)); const {r} = this; if (negative(r)) index = []; else if (r !== undefined) path.pointRadius(r); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => { g.selectAll() @@ -55,20 +56,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/marks/hexgrid.js b/src/marks/hexgrid.js index ecc4355a7a..f2abc023ab 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -46,7 +46,7 @@ export class Hexgrid extends Mark { } return create("svg:g", context) .datum(0) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {}, offset + ox, offset + oy) .call((g) => g.append("path").call(applyDirectStyles, this).call(applyChannelStyles, this, channels).attr("d", d)) .node(); diff --git a/src/marks/image.js b/src/marks/image.js index a0b49d599d..9288c118db 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -82,7 +82,7 @@ export class Image extends Mark { const {r, width, height, rotate} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/line.js b/src/marks/line.js index 35038ab7ca..bd5a4207d3 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -53,7 +53,7 @@ export class Line extends Mark { const {x: X, y: Y} = channels; const {curve} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index 517c96c9b7..d550b19045 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -40,7 +40,7 @@ class LinearRegression extends Mark { const {x: X, y: Y, z: Z} = channels; const {ci} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/link.js b/src/marks/link.js index 9bac4f0a4c..b44fd429e6 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -40,7 +40,7 @@ export class Link extends Mark { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; const {curve} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/raster.js b/src/marks/raster.js index 2d5662ede7..6d422bed13 100644 --- a/src/marks/raster.js +++ b/src/marks/raster.js @@ -154,7 +154,7 @@ export class Raster extends AbstractRaster { context2d.putImageData(image, 0, 0); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/rect.js b/src/marks/rect.js index aea82c42aa..ffea89ca05 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -41,7 +41,7 @@ export class Rect extends Mark { const bx = x?.bandwidth ? x.bandwidth() : 0; const by = y?.bandwidth ? y.bandwidth() : 0; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {}, 0, 0) .call((g) => g diff --git a/src/marks/rule.js b/src/marks/rule.js index 8fc091dfe3..3faed66c20 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -35,7 +35,7 @@ export class RuleX extends Mark { const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetTop, insetBottom} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x}, offset, 0) .call((g) => g @@ -85,7 +85,7 @@ export class RuleY extends Mark { const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetLeft, insetRight} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {y: Y && y}, 0, offset) .call((g) => g diff --git a/src/marks/text.js b/src/marks/text.js index a243c5a1c1..c83ccf50b5 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -95,7 +95,7 @@ export class Text extends Mark { const {rotate} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyIndirectTextStyles, this, T, dimensions) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => diff --git a/src/marks/tick.js b/src/marks/tick.js index 42ef1b56d6..a9a57eba7e 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -17,7 +17,7 @@ class AbstractTick extends Mark { } render(index, scales, channels, dimensions, context) { return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(this._transform, this, scales) .call((g) => g diff --git a/src/marks/tip.js b/src/marks/tip.js index bfb9d04cb2..968c618b78 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -130,7 +130,7 @@ export class Tip extends Mark { // We don’t call applyChannelStyles because we only use the channels to // derive the content of the tip, not its aesthetics. const g = create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyIndirectTextStyles, this) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => diff --git a/src/marks/vector.js b/src/marks/vector.js index c2420f869e..f8a9ccf916 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -92,7 +92,7 @@ export class Vector extends Mark { const {length, rotate, anchor, shape, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 4a95d38ec6..23745be45b 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -76,7 +76,7 @@ function waffleRender(y) { if (ry != null) basePatternRect.setAttribute("ry", ry); return create("svg:g", context) - .call(applyIndirectStyles, this, dimensions, context) + .call(applyIndirectStyles, this, scales, dimensions, context) .call(this._transform, this, scales) .call((g) => g diff --git a/src/options.js b/src/options.js index 144fd3b37b..3f3d31ac5f 100644 --- a/src/options.js +++ b/src/options.js @@ -602,12 +602,14 @@ 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 ( + clip != null && + !["FeatureCollection", "Feature", "Polygon", "GeometryCollection", "MultiPolygon"].includes(clip.type) + ) + clip = keyword(clip, "clip", ["frame", "sphere"]); return clip; } 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..1e7727dc27 100644 --- a/src/style.js +++ b/src/style.js @@ -4,6 +4,7 @@ import {defined, nonempty} from "./defined.js"; import {formatDefault} from "./format.js"; import {isNone, isNoneish, isRound, maybeColorChannel, maybeNumberChannel} from "./options.js"; import {keyof, number, string} from "./options.js"; +import {xyProjection} from "./projection.js"; import {warn} from "./warnings.js"; export const offset = (typeof window !== "undefined" ? window.devicePixelRatio > 1 : typeof it === "undefined") ? 0 : 0.5; // prettier-ignore @@ -303,27 +304,22 @@ export function* groupIndex(I, position, mark, channels) { } // Note: may mutate selection.node! -function applyClip(selection, mark, dimensions, context) { +function applyClip(selection, mark, scales, 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 === "sphere") clipUrl = getProjectionClip(context); + else if (clip?.type) clipUrl = getGeoClip(clip, scales)(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,15 +352,27 @@ 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"})); -}); +function memoizeGeo(clip) { + const geoClips = new WeakMap(); + return (geo, scales) => { + if (!geoClips.has(geo)) geoClips.set(geo, clip(geo, scales)); + return geoClips.get(geo); + }; +} + +const getGeoClip = memoizeGeo((geo, scales) => + memoizeClip((clipPath, context) => { + const {projection} = context; + if (!projection && geo.type === "Sphere") throw new Error(`clipping to the sphere requires a projection`); + clipPath.append("path").attr("d", geoPath(projection ?? xyProjection(scales))(geo)); + }) +); + +const getProjectionClip = getGeoClip({type: "Sphere"}); // Note: may mutate selection.node! -export function applyIndirectStyles(selection, mark, dimensions, context) { - applyClip(selection, mark, dimensions, context); +export function applyIndirectStyles(selection, mark, scales, dimensions, context) { + applyClip(selection, mark, scales, dimensions, context); applyAttr(selection, "class", mark.className); applyAttr(selection, "fill", mark.fill); applyAttr(selection, "fill-opacity", mark.fillOpacity); 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/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); } From e2527d3ad0181d85d02c0d3514a5802599bea161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 21 Nov 2024 20:06:12 +0100 Subject: [PATCH 02/14] document --- docs/features/marks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index 7e163907e6..afa3a6abea 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* or a GeoJSON object with a polygonal geometry , then the mark will be clipped to the projected geometry (_e.g._, the front hemisphere when using the orthographic projection). A [geographic projection](./projections.md) is required for the sphere. 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.) From 3d9234dd656dba4809d265be7a476ff1953b1dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 21 Nov 2024 21:25:04 +0100 Subject: [PATCH 03/14] Update src/mark.d.ts Co-authored-by: Mike Bostock --- src/mark.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mark.d.ts b/src/mark.d.ts index 2f2f24f8bf..a1d73ff5df 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -1,4 +1,4 @@ -import {GeoPermissibleObjects} from "d3"; +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"; From b263074a0b7f9c49b1f16a7ae830ba92f280d97f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 10:37:08 +0100 Subject: [PATCH 04/14] All GeoJSON, with a centralized duck typing test --- src/options.js | 48 +++++++++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/options.js b/src/options.js index 3f3d31ac5f..c9ca501a3a 100644 --- a/src/options.js +++ b/src/options.js @@ -169,24 +169,38 @@ export function dataify(data) { export function arrayify(values) { if (values == null || isArray(values)) return values; if (isArrowVector(values)) return maybeTypedArrowify(values); - switch (values.type) { - case "FeatureCollection": - return values.features; - case "GeometryCollection": - return values.geometries; - case "Feature": - case "LineString": - case "MultiLineString": - case "MultiPoint": - case "MultiPolygon": - case "Point": - case "Polygon": - case "Sphere": - return [values]; + 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) { + return ( + typeof x?.type === "string" && + [ + "FeatureCollection", + "GeometryCollection", + "Feature", + "LineString", + "MultiLineString", + "MultiPoint", + "MultiPolygon", + "Point", + "Polygon", + "Sphere" + ].includes(x.type) + ); +} + // An optimization of type.from(values, f): if the given values are already an // instanceof the desired array type, the faster values.map method is used. export function map(values, f, type = Array) { @@ -605,11 +619,7 @@ export function maybeNamed(things) { export function maybeClip(clip) { if (clip === true) clip = "frame"; else if (clip === false) clip = null; - else if ( - clip != null && - !["FeatureCollection", "Feature", "Polygon", "GeometryCollection", "MultiPolygon"].includes(clip.type) - ) - clip = keyword(clip, "clip", ["frame", "sphere"]); + else if (clip != null && !isGeoJSON(clip)) clip = keyword(clip, "clip", ["frame", "sphere"]); return clip; } From 63c8b5308a4a825545da142327e7d5f6c8187b49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 10:42:49 +0100 Subject: [PATCH 05/14] clip: {type: "Sphere"} is equal to clip: "sphere" tested in armadillo, by having two marks with these different clip values sharing the same clip-path --- src/options.js | 4 +++- test/plots/armadillo.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/options.js b/src/options.js index c9ca501a3a..e512cecafb 100644 --- a/src/options.js +++ b/src/options.js @@ -619,7 +619,9 @@ export function maybeNamed(things) { export function maybeClip(clip) { if (clip === true) clip = "frame"; else if (clip === false) clip = null; - else if (clip != null && !isGeoJSON(clip)) clip = keyword(clip, "clip", ["frame", "sphere"]); + else if (isGeoJSON(clip)) { + if (clip.type === "Sphere") clip = "sphere"; + } else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]); return clip; } 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() + ] }); } From a67c5a9bdac9beb914c16d577086a257f06aee02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 11:27:47 +0100 Subject: [PATCH 06/14] Add a path factory to the context --- src/context.d.ts | 5 ++++- src/marks/area.js | 2 +- src/marks/arrow.js | 2 +- src/marks/bar.js | 2 +- src/marks/contour.js | 2 +- src/marks/delaunay.js | 6 +++--- src/marks/density.js | 2 +- src/marks/dot.js | 2 +- src/marks/frame.js | 2 +- src/marks/geo.js | 7 +++---- src/marks/hexgrid.js | 2 +- src/marks/image.js | 2 +- src/marks/line.js | 2 +- src/marks/linearRegression.js | 2 +- src/marks/link.js | 2 +- src/marks/raster.js | 2 +- src/marks/rect.js | 2 +- src/marks/rule.js | 4 ++-- src/marks/text.js | 2 +- src/marks/tick.js | 2 +- src/marks/tip.js | 2 +- src/marks/vector.js | 2 +- src/marks/waffle.js | 2 +- src/plot.js | 9 +++++++-- src/style.js | 19 +++++++------------ 25 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/context.d.ts b/src/context.d.ts index ce2c3568d8..4786488972 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/marks/area.js b/src/marks/area.js index d706fa09ab..628088445a 100644 --- a/src/marks/area.js +++ b/src/marks/area.js @@ -46,7 +46,7 @@ export class Area extends Mark { render(index, scales, channels, dimensions, context) { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales, 0, 0) .call((g) => g diff --git a/src/marks/arrow.js b/src/marks/arrow.js index 1ca8065995..7d1bdaf217 100644 --- a/src/marks/arrow.js +++ b/src/marks/arrow.js @@ -62,7 +62,7 @@ export class Arrow extends Mark { const wingScale = headLength / 1.5; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/bar.js b/src/marks/bar.js index b4d127d83a..686c018460 100644 --- a/src/marks/bar.js +++ b/src/marks/bar.js @@ -25,7 +25,7 @@ export class AbstractBar extends Mark { const w = this._width(scales, channels, dimensions); const h = this._height(scales, channels, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(this._transform, this, scales) .call((g) => g diff --git a/src/marks/contour.js b/src/marks/contour.js index 13b6be017d..6d692d7d91 100644 --- a/src/marks/contour.js +++ b/src/marks/contour.js @@ -91,7 +91,7 @@ export class Contour extends AbstractRaster { const {geometry: G} = channels; const path = geoPath(); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => { g.selectAll() diff --git a/src/marks/delaunay.js b/src/marks/delaunay.js index dd776e7e45..57a927d22e 100644 --- a/src/marks/delaunay.js +++ b/src/marks/delaunay.js @@ -125,7 +125,7 @@ class DelaunayLink extends Mark { } return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call( Z @@ -175,7 +175,7 @@ class AbstractDelaunayMark extends Mark { } return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call( Z @@ -249,7 +249,7 @@ class Voronoi extends Mark { const {x, y} = scales; const {x: X, y: Y, cells: C} = channels; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => { g.selectAll() diff --git a/src/marks/density.js b/src/marks/density.js index d069a6aa11..2dd3cdf227 100644 --- a/src/marks/density.js +++ b/src/marks/density.js @@ -50,7 +50,7 @@ export class Density extends Mark { const {contours} = channels; const path = geoPath(); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {}) .call((g) => g diff --git a/src/marks/dot.js b/src/marks/dot.js index 4e4aa356d7..58e529def2 100644 --- a/src/marks/dot.js +++ b/src/marks/dot.js @@ -79,7 +79,7 @@ export class Dot extends Mark { const size = R ? undefined : r * r * Math.PI; if (negative(r)) index = []; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/frame.js b/src/marks/frame.js index 663ba1969b..eca4084d26 100644 --- a/src/marks/frame.js +++ b/src/marks/frame.js @@ -37,7 +37,7 @@ export class Frame extends Mark { const y2 = height - marginBottom - insetBottom; return create(anchor ? "svg:line" : rx1y1 || rx1y2 || rx2y1 || rx2y2 ? "svg:path" : "svg:rect", context) .datum(0) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyDirectStyles, this) .call(applyChannelStyles, this, channels) .call(applyTransform, this, {}) diff --git a/src/marks/geo.js b/src/marks/geo.js index b3d1d190ae..60252dd447 100644 --- a/src/marks/geo.js +++ b/src/marks/geo.js @@ -1,9 +1,8 @@ -import {geoGraticule10, geoPath} from "d3"; +import {geoGraticule10} from "d3"; import {create} from "../context.js"; import {negative, positive} from "../defined.js"; import {Mark} from "../mark.js"; import {identity, maybeNumberChannel} from "../options.js"; -import {xyProjection} from "../projection.js"; import {applyChannelStyles, applyDirectStyles, applyIndirectStyles, applyTransform} from "../style.js"; import {centroid} from "../transforms/centroid.js"; import {withDefaultSort} from "./dot.js"; @@ -36,12 +35,12 @@ export class Geo extends Mark { } render(index, scales, channels, dimensions, context) { const {geometry: G, r: R} = channels; - const path = geoPath(context.projection ?? xyProjection(scales)); + const path = context.path(); const {r} = this; if (negative(r)) index = []; else if (r !== undefined) path.pointRadius(r); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => { g.selectAll() diff --git a/src/marks/hexgrid.js b/src/marks/hexgrid.js index f2abc023ab..ecc4355a7a 100644 --- a/src/marks/hexgrid.js +++ b/src/marks/hexgrid.js @@ -46,7 +46,7 @@ export class Hexgrid extends Mark { } return create("svg:g", context) .datum(0) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {}, offset + ox, offset + oy) .call((g) => g.append("path").call(applyDirectStyles, this).call(applyChannelStyles, this, channels).attr("d", d)) .node(); diff --git a/src/marks/image.js b/src/marks/image.js index 9288c118db..a0b49d599d 100644 --- a/src/marks/image.js +++ b/src/marks/image.js @@ -82,7 +82,7 @@ export class Image extends Mark { const {r, width, height, rotate} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/line.js b/src/marks/line.js index bd5a4207d3..35038ab7ca 100644 --- a/src/marks/line.js +++ b/src/marks/line.js @@ -53,7 +53,7 @@ export class Line extends Mark { const {x: X, y: Y} = channels; const {curve} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/linearRegression.js b/src/marks/linearRegression.js index d550b19045..517c96c9b7 100644 --- a/src/marks/linearRegression.js +++ b/src/marks/linearRegression.js @@ -40,7 +40,7 @@ class LinearRegression extends Mark { const {x: X, y: Y, z: Z} = channels; const {ci} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/link.js b/src/marks/link.js index b44fd429e6..9bac4f0a4c 100644 --- a/src/marks/link.js +++ b/src/marks/link.js @@ -40,7 +40,7 @@ export class Link extends Mark { const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels; const {curve} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/raster.js b/src/marks/raster.js index 6d422bed13..2d5662ede7 100644 --- a/src/marks/raster.js +++ b/src/marks/raster.js @@ -154,7 +154,7 @@ export class Raster extends AbstractRaster { context2d.putImageData(image, 0, 0); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, scales) .call((g) => g diff --git a/src/marks/rect.js b/src/marks/rect.js index ffea89ca05..aea82c42aa 100644 --- a/src/marks/rect.js +++ b/src/marks/rect.js @@ -41,7 +41,7 @@ export class Rect extends Mark { const bx = x?.bandwidth ? x.bandwidth() : 0; const by = y?.bandwidth ? y.bandwidth() : 0; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {}, 0, 0) .call((g) => g diff --git a/src/marks/rule.js b/src/marks/rule.js index 3faed66c20..8fc091dfe3 100644 --- a/src/marks/rule.js +++ b/src/marks/rule.js @@ -35,7 +35,7 @@ export class RuleX extends Mark { const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetTop, insetBottom} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x}, offset, 0) .call((g) => g @@ -85,7 +85,7 @@ export class RuleY extends Mark { const {width, height, marginTop, marginRight, marginLeft, marginBottom} = dimensions; const {insetLeft, insetRight} = this; return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {y: Y && y}, 0, offset) .call((g) => g diff --git a/src/marks/text.js b/src/marks/text.js index c83ccf50b5..a243c5a1c1 100644 --- a/src/marks/text.js +++ b/src/marks/text.js @@ -95,7 +95,7 @@ export class Text extends Mark { const {rotate} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyIndirectTextStyles, this, T, dimensions) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => diff --git a/src/marks/tick.js b/src/marks/tick.js index a9a57eba7e..42ef1b56d6 100644 --- a/src/marks/tick.js +++ b/src/marks/tick.js @@ -17,7 +17,7 @@ class AbstractTick extends Mark { } render(index, scales, channels, dimensions, context) { return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(this._transform, this, scales) .call((g) => g diff --git a/src/marks/tip.js b/src/marks/tip.js index 968c618b78..bfb9d04cb2 100644 --- a/src/marks/tip.js +++ b/src/marks/tip.js @@ -130,7 +130,7 @@ export class Tip extends Mark { // We don’t call applyChannelStyles because we only use the channels to // derive the content of the tip, not its aesthetics. const g = create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyIndirectTextStyles, this) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => diff --git a/src/marks/vector.js b/src/marks/vector.js index f8a9ccf916..c2420f869e 100644 --- a/src/marks/vector.js +++ b/src/marks/vector.js @@ -92,7 +92,7 @@ export class Vector extends Mark { const {length, rotate, anchor, shape, r} = this; const [cx, cy] = applyFrameAnchor(this, dimensions); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(applyTransform, this, {x: X && x, y: Y && y}) .call((g) => g diff --git a/src/marks/waffle.js b/src/marks/waffle.js index 867e380404..1e848bcc7e 100644 --- a/src/marks/waffle.js +++ b/src/marks/waffle.js @@ -120,7 +120,7 @@ function waffleRender({render, ...options}) { if (ry != null) basePatternRect.setAttribute("ry", ry); return create("svg:g", context) - .call(applyIndirectStyles, this, scales, dimensions, context) + .call(applyIndirectStyles, this, dimensions, context) .call(this._transform, this, scales) .call((g) => g 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/style.js b/src/style.js index 1e7727dc27..95fa4f5ee9 100644 --- a/src/style.js +++ b/src/style.js @@ -1,10 +1,9 @@ -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"; import {isNone, isNoneish, isRound, maybeColorChannel, maybeNumberChannel} from "./options.js"; import {keyof, number, string} from "./options.js"; -import {xyProjection} from "./projection.js"; import {warn} from "./warnings.js"; export const offset = (typeof window !== "undefined" ? window.devicePixelRatio > 1 : typeof it === "undefined") ? 0 : 0.5; // prettier-ignore @@ -304,7 +303,7 @@ export function* groupIndex(I, position, mark, channels) { } // Note: may mutate selection.node! -function applyClip(selection, mark, scales, dimensions, context) { +function applyClip(selection, mark, dimensions, context) { let clipUrl; const {clip = context.clip} = mark; if (clip === "frame") { @@ -318,7 +317,7 @@ function applyClip(selection, mark, scales, dimensions, context) { }); clipUrl = getFrameClip(context, dimensions); } else if (clip === "sphere") clipUrl = getProjectionClip(context); - else if (clip?.type) clipUrl = getGeoClip(clip, scales)(context); + else if (clip?.type) 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 @@ -360,19 +359,15 @@ function memoizeGeo(clip) { }; } -const getGeoClip = memoizeGeo((geo, scales) => - memoizeClip((clipPath, context) => { - const {projection} = context; - if (!projection && geo.type === "Sphere") throw new Error(`clipping to the sphere requires a projection`); - clipPath.append("path").attr("d", geoPath(projection ?? xyProjection(scales))(geo)); - }) +const getGeoClip = memoizeGeo((geo) => + memoizeClip((clipPath, context) => clipPath.append("path").attr("d", context.path()(geo))) ); const getProjectionClip = getGeoClip({type: "Sphere"}); // Note: may mutate selection.node! -export function applyIndirectStyles(selection, mark, scales, dimensions, context) { - applyClip(selection, mark, scales, dimensions, context); +export function applyIndirectStyles(selection, mark, dimensions, context) { + applyClip(selection, mark, dimensions, context); applyAttr(selection, "class", mark.className); applyAttr(selection, "fill", mark.fill); applyAttr(selection, "fill-opacity", mark.fillOpacity); From c3af2940692c0ef1de6eb409a99e14d34a6b20c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 18:44:43 +0100 Subject: [PATCH 07/14] Update docs/features/marks.md Co-authored-by: Mike Bostock --- docs/features/marks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/marks.md b/docs/features/marks.md index afa3a6abea..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* or a GeoJSON object with a polygonal geometry , then the mark will be clipped to the projected geometry (_e.g._, the front hemisphere when using the orthographic projection). A [geographic projection](./projections.md) is required for the sphere. +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.) From e5927795d32762f26ac56f9beab025234c4e7ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 18:45:03 +0100 Subject: [PATCH 08/14] Update src/context.d.ts Co-authored-by: Mike Bostock --- src/context.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context.d.ts b/src/context.d.ts index 4786488972..53a1c01fee 100644 --- a/src/context.d.ts +++ b/src/context.d.ts @@ -19,7 +19,7 @@ export interface Context { projection?: GeoStreamWrapper; /** A function to draw GeoJSON with the current projection, if any, otherwise with the x and y scales. */ - path?: () => GeoPath; + path: () => GeoPath; /** The default clip for all marks. */ clip?: MarkOptions["clip"]; From 928bf0b94601b3e2177c94bfc8071d3533825376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 18:45:19 +0100 Subject: [PATCH 09/14] Update src/mark.d.ts Co-authored-by: Mike Bostock --- src/mark.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mark.d.ts b/src/mark.d.ts index cc7ceccb78..43e61da15f 100644 --- a/src/mark.d.ts +++ b/src/mark.d.ts @@ -297,7 +297,7 @@ 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 with a (multi-) polygon geometry + * - geojson - a GeoJSON object, typically with polygonal geometry * - null or false - do not clip * * The *sphere* clip option requires a geographic projection. From e23ed5210978ed6702016458f6ce1ecc8780ff68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 19:08:09 +0100 Subject: [PATCH 10/14] switch/case I wonder if we should still test for a type string or if doing the 10 tests is fast enough anyway --- src/options.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/options.js b/src/options.js index e512cecafb..5cb2fc063f 100644 --- a/src/options.js +++ b/src/options.js @@ -184,21 +184,21 @@ export function arrayify(values) { // Duck typing test for GeoJSON function isGeoJSON(x) { - return ( - typeof x?.type === "string" && - [ - "FeatureCollection", - "GeometryCollection", - "Feature", - "LineString", - "MultiLineString", - "MultiPoint", - "MultiPolygon", - "Point", - "Polygon", - "Sphere" - ].includes(x.type) - ); + switch (x?.type) { + case "FeatureCollection": + case "GeometryCollection": + case "Feature": + case "LineString": + case "MultiLineString": + case "MultiPoint": + case "MultiPolygon": + case "Point": + case "Polygon": + case "Sphere": + return true; + default: + return false; + } } // An optimization of type.from(values, f): if the given values are already an From 3533dd7759fe0aa1be8d5a8db4d0ba2a15e6e4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 19:46:17 +0100 Subject: [PATCH 11/14] promote "sphere" to {type:"Sphere"}; needs a unique shared object to avoid duplicating the clip-path --- src/options.js | 10 ++++++++-- src/style.js | 5 +---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/options.js b/src/options.js index 5cb2fc063f..7f71a5a415 100644 --- a/src/options.js +++ b/src/options.js @@ -616,12 +616,18 @@ export function maybeNamed(things) { return isIterable(things) ? named(things) : things; } +// A shared Sphere object coalesces all sphere clips. +const sphere = {type: "Sphere"}; + export function maybeClip(clip) { if (clip === true) clip = "frame"; else if (clip === false) clip = null; else if (isGeoJSON(clip)) { - if (clip.type === "Sphere") clip = "sphere"; - } else if (clip != null) clip = keyword(clip, "clip", ["frame", "sphere"]); + if (clip.type === "Sphere") clip = sphere; + } else if (clip != null) { + clip = keyword(clip, "clip", ["frame", "sphere"]); + if (clip === "sphere") clip = sphere; + } return clip; } diff --git a/src/style.js b/src/style.js index 95fa4f5ee9..219a64e330 100644 --- a/src/style.js +++ b/src/style.js @@ -316,8 +316,7 @@ function applyClip(selection, mark, dimensions, context) { selection.node = () => this; // Note: mutation! }); clipUrl = getFrameClip(context, dimensions); - } else if (clip === "sphere") clipUrl = getProjectionClip(context); - else if (clip?.type) clipUrl = getGeoClip(clip)(context); + } else if (clip?.type) 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 @@ -363,8 +362,6 @@ const getGeoClip = memoizeGeo((geo) => memoizeClip((clipPath, context) => clipPath.append("path").attr("d", context.path()(geo))) ); -const getProjectionClip = getGeoClip({type: "Sphere"}); - // Note: may mutate selection.node! export function applyIndirectStyles(selection, mark, dimensions, context) { applyClip(selection, mark, dimensions, context); From 31e2e33e1cef44afb7e12669d5df3107cae21bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 22:00:21 +0100 Subject: [PATCH 12/14] Update src/style.js Co-authored-by: Mike Bostock --- src/style.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/style.js b/src/style.js index 219a64e330..97e53714fa 100644 --- a/src/style.js +++ b/src/style.js @@ -316,7 +316,9 @@ function applyClip(selection, mark, dimensions, context) { selection.node = () => this; // Note: mutation! }); clipUrl = getFrameClip(context, dimensions); - } else if (clip?.type) clipUrl = getGeoClip(clip)(context); + } 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 From d5c7af4c72270bc56887550c10c60f8d57eb038d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 22:36:52 +0100 Subject: [PATCH 13/14] Use a local sphere, simplify memoization --- src/options.js | 9 ++------- src/style.js | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/options.js b/src/options.js index 7f71a5a415..d11bee1bb4 100644 --- a/src/options.js +++ b/src/options.js @@ -616,17 +616,12 @@ export function maybeNamed(things) { return isIterable(things) ? named(things) : things; } -// A shared Sphere object coalesces all sphere clips. -const sphere = {type: "Sphere"}; - export function maybeClip(clip) { if (clip === true) clip = "frame"; else if (clip === false) clip = null; - else if (isGeoJSON(clip)) { - if (clip.type === "Sphere") clip = sphere; - } else if (clip != null) { + else if (!isGeoJSON(clip) && clip != null) { clip = keyword(clip, "clip", ["frame", "sphere"]); - if (clip === "sphere") clip = sphere; + if (clip === "sphere") clip = {type: "Sphere"}; } return clip; } diff --git a/src/style.js b/src/style.js index 97e53714fa..2308b9d601 100644 --- a/src/style.js +++ b/src/style.js @@ -317,7 +317,7 @@ function applyClip(selection, mark, dimensions, context) { }); clipUrl = getFrameClip(context, dimensions); } else if (clip) { - clipUrl = getGeoClip(clip)(context); + clipUrl = getGeoClip(clip, context); } // Here we’re careful to apply the ARIA attributes to the outer G element when @@ -352,17 +352,23 @@ const getFrameClip = memoizeClip((clipPath, context, dimensions) => { .attr("height", height - marginTop - marginBottom); }); -function memoizeGeo(clip) { - const geoClips = new WeakMap(); - return (geo, scales) => { - if (!geoClips.has(geo)) geoClips.set(geo, clip(geo, scales)); - return geoClips.get(geo); +function memoizeGeo() { + 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; }; } -const getGeoClip = memoizeGeo((geo) => - memoizeClip((clipPath, context) => clipPath.append("path").attr("d", context.path()(geo))) -); +const getGeoClip = memoizeGeo(); // Note: may mutate selection.node! export function applyIndirectStyles(selection, mark, dimensions, context) { From 872f641f8391c88ccae704bdfe4420a0955f180c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Fri, 22 Nov 2024 22:55:31 +0100 Subject: [PATCH 14/14] inline memoizeGeo as an IIFE --- src/style.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/style.js b/src/style.js index 2308b9d601..f123ef3b6a 100644 --- a/src/style.js +++ b/src/style.js @@ -352,7 +352,7 @@ const getFrameClip = memoizeClip((clipPath, context, dimensions) => { .attr("height", height - marginTop - marginBottom); }); -function memoizeGeo() { +const getGeoClip = (function () { const cache = new WeakMap(); const sphere = {type: "Sphere"}; return (geo, context) => { @@ -366,9 +366,7 @@ function memoizeGeo() { } return url; }; -} - -const getGeoClip = memoizeGeo(); +})(); // Note: may mutate selection.node! export function applyIndirectStyles(selection, mark, dimensions, context) {