Synchronizing d3-zoom between on and off-screen canvas


I’m working with a fairly large GeoJSON dataset that I use as a 2D map of the world.

Due to performance issues, I’ve resorted to using OffscreenCanvas for rendering all shapes that fit within the current viewport with a padding on all sides equal to viewport width/height.

    this.projection = d3.geoIdentity().reflectY(true);
    this.path = d3.geoPath(this.projection).context(this.offScreenCtx);
  renderOntoOffscreen() {
    if (!this.transform) return;
    this.offScreenCtx.translate(this.transform.x*this.transform.k, this.transform.y*this.transform.k);
    this.offScreenCtx.strokeStyle = "#000";
    this.offScreenCtx.lineWidth = 0.5;

    let shapesDrawn = 0;
    const shapes = this.provincesData.features;

    const viewportLeft = -this.transform.x - window.innerWidth;
    const viewportRight = -this.transform.x + window.innerWidth*2;
    const viewportTop = -this.transform.y - window.innerHeight;
    const viewportBottom = -this.transform.y + window.innerHeight*2
    for (let shape of shapes) {

      const maximumX =!.bounds[1][0]
      const maximumY =!.bounds[1][1]
      const minimumX =!.bounds[0][0]
      const minimumY =!.bounds[0][1]
      if(maximumX > viewportRight) continue
      if(minimumX < viewportLeft) continue

    //logic for rendering shapes
  renderOntoMain() {
    let canvasData = this.offScreenCtx.getImageData(
      -this.transform.x + window.innerWidth,
      -this.transform.y + window.innerHeight,
    this.ctx.putImageData(canvasData, 0, 0);

However, the current solution has issues.
The problem boils down to this line:

    this.offScreenCtx.translate(this.transform.x*this.transform.k, this.transform.y*this.transform.k);

When I pan and zoom on my canvas, the panning gets out of sync. Here’s what happens:

  1. During the pan: Everything looks smooth because I’m just copying from an offscreen canvas.
  2. When the zoom ends: The offscreen canvas is re-rendered.
  3. Re-rendering problem: The re-render function (renderOntoOffscreen ) applies a translation (translate ) that seems to double the amount I panned.

For example, if I pan 20 pixels to the right, it jumps to 40 pixels when the offscreen canvas is updated.

With the translate() removed the panning works fine, but then the canvas will cut off at whatever point offScreenCanvas’s width ends (which I cannot increase as that would break the upper limit)

My questions are as follows:

  1. Can you help me figure out why this double panning is happening and how to fix it?
  2. Mostly unrelated to the main post, is there a way to set the custom zoom origin of d3-zoom so that it’d recognize the full width/height of the geojson, as opposed to just the part that’s rendered?

Could the reason be that you haven’t accounted for devicePixelRatio? Can you share a live example that demonstrates the problem?

Thank you for your suggestion
I managed to figure it out eventually.

The problem, as I suspected, boiled down to me using persistent d3-zoom transform x/y coordinates for both translating the big offscreen canvas, and for picking out particular sections of it for copying into the visible onscreen canvas

Resetting the transform’s xy coordinates to 0 after every pan and not using context.restore() on offscreen canvas, which meant that every new translate is now relative to the previous one solved my big issue, and left some smaller ones

This is a crude representation of where I’m at right now, and you should quickly be able to see what my current issues are:

  1. As mentioned in the first post, I’ve still not found a way to set the origin of the zoom to the center of the map. Currently, as you can see, the zoom keeps being drawn to the upper left corner of the map, presumably because d3 bases its numbers on the viewport size

  2. Upon zooming in either direction, the “double pan” effect comes back, likely because a combination of translate + scale puts the offscreen renderer out of sync with the onscreen renderer that’s based solely on the xy coordinates

It’s possible to “fix” this by just multiplying the x/y coordinates of the upper corner of the portion of offscreenCanvas that’s copied onto main (note the commented out lines that multiply this.transform.x with this.persistentTransform.k), but this also results in the panning changing its speed depending on the zoom level

Again thank you for your earlier response and I’d appreciate any further guidance.

To be honest, I would do away with most of the implementation and just call drawImage() instead. Here is an example:

You can pass it a canvas as well, and you can also specify the source rect to be drawn.

Thank you so much for your help. I think the fragment with applying transformations to the zoom transform and copying the respective part of the offscreen canvas is what I needed to solve my zooming issues

I really don’t want to abuse your kindness to much, so feel free to not respond further; however, with your latest suggestion put in place I’m still struggling to figure out a way to combine it with my offscreen canvas renderer in a way where transforms on both are in sync and don’t cause the double pan.

Again, to summarize the problem:

  const onZoom = ({transform}) => {
    const dx = transform.applyX(0);
    const dy = transform.applyY(0);
    const dw = transform.applyX(imgWidth);
    const dh = transform.applyY(imgHeight);
    ctx.clearRect(0, 0, width, height);
    ctx.drawImage(image, dx, dy, dw - dx, dh - dy);

This is the fragment of code you wrote, that is meant to run on each pan. In my implementation, it copies from a fragment of the offscreen canvas defined by the dx/dy coordinates of the upper corner and draws it on the on-screen canvas

However, as I mentioned in the first post, given how large the map I’m working with is, every time the user pans, I have to run context.scale() and translate() with current zoom transform to make sure that the map doesn’t become too blurry and the regions that were previously not rendered due to going out of the limits of the canvas now do.

This means that in my offscreen renderer I have to call context.translate() to move the renderer origin in accordance with current transform… but then I’m back to the issue of ‘double panning’

^a video showcasing the problem

Two things that may apply (based on the code you shared earlier):

  • consider the transform “owned” by d3.zoom and always retrieve it from the selection (preferably via d3.zoomTransform(selection))
  • make sure you don’t run multiple concurrent updates (i.e. either discard a running update or wait for it to complete before dispatching a new one with the then current transform)