plot icon indicating copy to clipboard operation
plot copied to clipboard

Arbitrary clip paths

Open mbostock opened this issue 3 years ago • 10 comments

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

mbostock avatar Nov 22 '22 15:11 mbostock

Maybe duplicate of #181.

mbostock avatar Nov 25 '22 21:11 mbostock

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?

espinielli avatar Dec 27 '22 09:12 espinielli

see https://github.com/observablehq/plot/discussions/1338

Fil avatar Mar 14 '23 19:03 Fil

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

Fil avatar Jun 04 '23 21:06 Fil

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?

mbostock avatar Jun 05 '23 13:06 mbostock

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.

mbostock avatar Jun 22 '23 17:06 mbostock

clip-path + path is still very poorly supported: https://observablehq.com/d/bf434fc5675c8f13

Fil avatar Jun 25 '23 16:06 Fil

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);
    });
}

Fil avatar Jul 02 '23 14:07 Fil

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 avatar Jan 11 '24 10:01 Fil

@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]
            }`;

chrispahm avatar Jan 11 '24 10:01 chrispahm

Maybe we could offer a Plot.geoClip render transform that you can pass GeoJSON and does this?

Image

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"})
  ]
})

mbostock avatar Nov 13 '24 22:11 mbostock

"the clip option is set to a GeoJSON object" would be fantastic

Fil avatar Nov 14 '24 09:11 Fil

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 😅

Fil avatar Nov 28 '24 19:11 Fil