Arbitrary clip paths
It’d be nice to support arbitrary clip paths. I can implement one by wrapping a mark like so:
function clip(mark, renderClip) {
return {
initialize(facets, channels) {
return mark.initialize(facets, channels);
},
filter(index, channels, values) {
return mark.filter(index, channels, values);
},
render(facet, scales, values, dimensions, context) {
const fragment = document.createDocumentFragment();
const svg = fragment.appendChild(mark.render(facet, scales, values, dimensions, context));
const clipPath = fragment.appendChild(renderClip(facet, scales, values, dimensions, context));
svg.setAttribute("clip-path", "url(#clip)");
clipPath.setAttribute("id", "clip");
return fragment;
}
};
}
That should probably extend Mark, though? And it should generate a unique identifier rather than using “clip”.
Then I could have a function that creates a clipPath element like so:
function renderClip(facet, scales) {
const clipPath = htl.svg`<clipPath>`;
for (const {a, b} of abDisplayabilityCoordinates.filter(d => d.displayable)) {
clipPath.appendChild(htl.svg`<rect
x=${scales.x(a - 0.0025)}
y=${scales.y(b + 0.0025)}
width=${scales.x(a + 0.0025) - scales.x(a - 0.0025) + 1}
height=${scales.y(b - 0.0025) - scales.y(b + 0.0025) + 1}
/>`);
}
return clipPath;
}
Ref. https://observablehq.com/@mjbo/oklab-named-colors-wheel
Maybe duplicate of #181.
I have tried to add clip-path to Plot.image() but it seems not handled by applyIndirectStyles() (that is what my browsing of the code brings me to...)
I would like to clip an image to a geo path:
Plot.image(italy, {
x: (d) => d.properties.lon,
y: (d) => d.properties.lat,
width: (d) => d.properties.width,
height: (d) => d.properties.height,
preserveAspectRatio: "none",
src: (d) => d.properties.flag,
clipPath: (d) => `url(#iso-${d.id})`,
title: (d) => d.id
})
I have some tinkering going on here: https://observablehq.com/d/a8fe9e54cf07cb7a
Any thoughts?
see https://github.com/observablehq/plot/discussions/1338
Here's a snippet that uses the new render transform:
marks: [
Plot.geo(perimetro_mexico, {
render: (i, s, v, d, c, next) =>
svg`<clipPath id="x">${next(i, s, v, d, c).children[0]}` // create the clipPath "x"
}),
Plot.raster(data, {
x: "longitude",
y: "latitude",
fill: "banda_interes",
interpolate: Plot.interpolatorRandomWalk(),
render: (i, s, v, d, c, next) =>
svg`<g clip-path="url(#x)">${next(i, s, v, d, c)}` // reference "x" as clip-path
}),
see https://observablehq.com/d/d5d3052622043025 & https://observablehq.com/@fil/diy-live-map-of-air-quality-in-the-us
That’s really nice @Fil. I bet we could package that up into something reusable. Perhaps a clip transform where you supply a geometry channel, and it uses the geo mark under the hood?
We should consider using CSS clip-path instead of SVG, since it is now widely supported and much more convenient since you don’t need a globally unique identifier.
clip-path + path is still very poorly supported: https://observablehq.com/d/bf434fc5675c8f13
Chrome doesn't seem to support view-box + polygon(coords)—which works under Safari and Firefox.
Chromium bug reference: https://bugs.chromium.org/p/chromium/issues/detail?id=694218
Until this bug is resolved, we probably have to follow the classic route of adding a clipPath with a unique id and url(). An alternative possibility is to wrap the element we want to clip in a SVG element; but it seems more trouble than necessary, and only works for rectangles:
function applyClip(selection, channels) {
if (!channels) return;
const {x1: X1, y1: Y1, x2: X2, y2: Y2} = channels;
return selection
.each(function (i) {
const g = this.ownerDocument.createElementNS(namespaces.svg, "svg");
const x = Math.min(X1[i], X2[i]);
const y = Math.min(Y1[i], Y2[i]);
const w = Math.abs(X1[i] - X2[i]);
const h = Math.abs(Y1[i] - Y2[i]);
g.setAttribute("viewBox", `${x} ${y} ${w} ${h}`);
g.setAttribute("x", `${x}`);
g.setAttribute("y", `${y}`);
g.setAttribute("width", `${w}`);
g.setAttribute("height", `${h}`);
g.setAttribute("overflow", "hidden");
this.replaceWith(g);
g.appendChild(this);
});
}
The Chrome bug has been fixed in 119 https://chromestatus.com/feature/5068167415595008 https://developer.chrome.com/blog/new-in-chrome-119
The tests in https://observablehq.com/@fil/clip-path-and-basic-shapes-1109 seem to work in all major browsers now, so we could use style="clip-path: view-box path('${path}')".
@Fil just helped me with an issue applying clip paths to a faceted Ridgeline plot: https://observablehq.com/@chrispahm/ridgeline-plot-with-average-values
Here we used a style to cancel the transform property from the clipPath elements (which is added by the facet system), in order to get the position right:
return svg`<clipPath id=${encodeURI(i.fy)} style="transform: none">${
next(i, s, v, d, c).children[0]
}`;
Maybe we could offer a Plot.geoClip render transform that you can pass GeoJSON and does this?
Plot.plot({
projection: "albers",
color: {scheme: "YlGnBu"},
marks: [
Plot.density(walmarts, {
x: "longitude",
y: "latitude",
bandwidth: 10,
fill: "density",
render(index, scales, values, dimensions, context, next) {
const {document} = context;
const g = next(index, scales, values, dimensions, context);
const clipPath = document.createElementNS("http://www.w3.org/2000/svg", "clipPath");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const geoPath = d3.geoPath(context.projection);
path.setAttribute("d", geoPath(nation));
clipPath.setAttribute("id", "clip");
clipPath.appendChild(path);
context.ownerSVGElement.appendChild(clipPath);
g.setAttribute("clip-path", "url(#clip)");
return g;
}
}),
Plot.geo(statemesh, {strokeOpacity: 0.3}),
Plot.geo(nation),
Plot.dot(walmarts, {x: "longitude", y: "latitude", r: 1, fill: "currentColor"})
]
})
E.g.,
Plot.plot({
projection: "albers",
color: {scheme: "YlGnBu"},
marks: [
Plot.density(walmarts, Plot.geoClip(nation, {x: "longitude", y: "latitude", bandwidth: 10, fill: "density"})),
Plot.geo(statemesh, {strokeOpacity: 0.3}),
Plot.geo(nation),
Plot.dot(walmarts, {x: "longitude", y: "latitude", r: 1, fill: "currentColor"})
]
})
Alternatively, maybe this is what happens when the clip option is set to a GeoJSON object?
Plot.plot({
projection: "albers",
color: {scheme: "YlGnBu"},
marks: [
Plot.density(walmarts, {x: "longitude", y: "latitude", bandwidth: 10, fill: "density", clip: nation}),
Plot.geo(statemesh, {strokeOpacity: 0.3}),
Plot.geo(nation),
Plot.dot(walmarts, {x: "longitude", y: "latitude", r: 1, fill: "currentColor"})
]
})
"the clip option is set to a GeoJSON object" would be fantastic
As @jwolondon remarks in https://observablehq.com/@fil/multiscale-density-spatial-interpolator#comment-451a923b320875f1, the technique with view-box doesn't work in Safari when the page is scaled 😠 However, that's not what we're doing in #2243, which works fine 😅