Skip to content

Commit

Permalink
clip: geojson (#2243)
Browse files Browse the repository at this point in the history
* clip: geojson
* All GeoJSON, with a centralized duck typing test
* clip: {type: "Sphere"} is equal to clip: "sphere"
* Add a path factory to the context
* switch/case
* promote "sphere" to {type:"Sphere"}; needs a unique shared object to avoid duplicating the clip-path
* Use a local sphere, simplify memoization
* inline memoizeGeo as an IIFE

---------

Co-authored-by: Mike Bostock <mbostock@gmail.com>
Fil and mbostock authored Nov 22, 2024
1 parent f889fd9 commit 9b53a85
Showing 20 changed files with 390 additions and 84 deletions.
2 changes: 1 addition & 1 deletion docs/features/marks.md
Original file line number Diff line number Diff line change
@@ -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) <VersionBadge version="0.6.7" />

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 <VersionBadge pr="2243" />, 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.)

5 changes: 4 additions & 1 deletion src/context.d.ts
Original file line number Diff line number Diff line change
@@ -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"];
}
4 changes: 3 additions & 1 deletion src/mark.d.ts
Original file line number Diff line number Diff line change
@@ -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,
18 changes: 2 additions & 16 deletions src/marks/geo.js
Original file line number Diff line number Diff line change
@@ -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};
31 changes: 23 additions & 8 deletions src/options.js
Original file line number Diff line number Diff line change
@@ -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;
}

9 changes: 7 additions & 2 deletions src/plot.js
Original file line number Diff line number Diff line change
@@ -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);
14 changes: 14 additions & 0 deletions src/projection.js
Original file line number Diff line number Diff line change
@@ -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));
}
});
}
}
52 changes: 29 additions & 23 deletions src/style.js
Original file line number Diff line number Diff line change
@@ -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) {
45 changes: 45 additions & 0 deletions test/output/contourVaporClip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions test/output/mandelbrotClip.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/output/rasterWalmartBarycentric.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/output/rasterWalmartBarycentricOpacity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/output/rasterWalmartRandomWalk.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions test/output/rasterWalmartWalkOpacity.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions test/output/usStateClipVoronoi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion test/plots/armadillo.ts
Original file line number Diff line number Diff line change
@@ -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()
]
});
}
32 changes: 32 additions & 0 deletions test/plots/heatmap.ts
Original file line number Diff line number Diff line change
@@ -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
})
]
});
}
42 changes: 42 additions & 0 deletions test/plots/raster-vapor.ts
Original file line number Diff line number Diff line change
@@ -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<any>("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);
11 changes: 2 additions & 9 deletions test/plots/raster-walmart.ts
Original file line number Diff line number Diff line change
@@ -7,19 +7,12 @@ async function rasterWalmart(options) {
d3.tsv<any>("data/walmarts.tsv", d3.autoType),
d3
.json<any>("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)]
});
}

22 changes: 16 additions & 6 deletions test/plots/us-state-capitals-voronoi.ts
Original file line number Diff line number Diff line change
@@ -36,37 +36,43 @@ export async function usStateCapitalsVoronoi() {
});
}

async function voronoiMap(centroid) {
async function voronoiMap(centroid, clipNation = false) {
const [nation, states] = await d3
.json<any>("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);
}

0 comments on commit 9b53a85

Please sign in to comment.