Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Arbitrary clip paths #1109

Closed
mbostock opened this issue Nov 22, 2022 · 13 comments · Fixed by #2243
Closed

Arbitrary clip paths #1109

mbostock opened this issue Nov 22, 2022 · 13 comments · Fixed by #2243
Labels
enhancement New feature or request geo Maps and projections

Comments

@mbostock
Copy link
Member

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 mbostock added the enhancement New feature or request label Nov 22, 2022
@mbostock
Copy link
Member Author

Maybe duplicate of #181.

@Fil Fil added the geo Maps and projections label Dec 1, 2022
@espinielli
Copy link
Contributor

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
Copy link
Contributor

Fil commented Mar 14, 2023

see #1338

@Fil
Copy link
Contributor

Fil commented Jun 4, 2023

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
Copy link
Member Author

mbostock commented Jun 5, 2023

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
Copy link
Member Author

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
Copy link
Contributor

Fil commented Jun 25, 2023

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

@Fil Fil mentioned this issue Jun 29, 2023
10 tasks
@Fil
Copy link
Contributor

Fil commented Jul 2, 2023

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
Copy link
Contributor

Fil commented Jan 11, 2024

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
Copy link

chrispahm commented Jan 11, 2024

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

@Fil Fil mentioned this issue Aug 4, 2024
@mbostock
Copy link
Member Author

mbostock commented Nov 13, 2024

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

@Fil
Copy link
Contributor

Fil commented Nov 14, 2024

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

@Fil Fil mentioned this issue Nov 21, 2024
@Fil Fil closed this as completed in #2243 Nov 22, 2024
@Fil
Copy link
Contributor

Fil commented Nov 28, 2024

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 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request geo Maps and projections
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants