observablehq / plot

A concise API for exploratory data visualization implementing a layered grammar of graphics
https://observablehq.com/plot/
ISC License
4.36k stars 176 forks source link

Arbitrary clip paths #1109

Open mbostock opened 1 year ago

mbostock commented 1 year ago

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 commented 1 year ago

Maybe duplicate of #181.

espinielli commented 1 year ago

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?

Fil commented 1 year ago

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

Fil commented 1 year ago

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

mbostock commented 1 year ago

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 commented 1 year ago

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.

Fil commented 1 year ago

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

Fil commented 1 year ago

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 commented 9 months ago

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}')".

chrispahm commented 9 months ago

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