javascriptcanvasdrawing

Drawn lines on Zoomed In Canvas Not Being Drawn In Correct Position


I am making a drawing feature on a canvas, but am stuck on the zoom part. Can anyone help me? When I am in a maximum zoomed out state, I am able to draw exactly where the mouse cursor is. However, when I zoom in, the lines I draw have an offset from where the mouse is. The formulae I am using for getting the arguments to my drawLines function's adjustedX and adjustedY values are:

const adjustedX = e.offsetX * viewportTransform.scale - viewportTransform.x;
const adjustedY = e.offsetY * viewportTransform.scale -viewportTransform.y;

Can someone help me fix this? I have attached all the code in the html file. It is HTML and vanilla JS.

function showLines() {
  console.log(lines)
}

function toggleDrawMode() {
  isDrawingMode = !isDrawingMode
}

const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
let lines = [];

const viewportTransform = {
  x: 0,
  y: 0,
  scale: 1
}

// From here on, everything we'll write will go below 👇
const drawRect = (x, y, width, height, color) => {
  ctx.fillStyle = color
  ctx.fillRect(x, y, width, height)
}


let isDrawingMode = true
const penWidth = 5
const penColor = 'red'

const drawPixels = (x, y) => {
  ctx.beginPath()

  ctx.arc(x, y, penWidth, 2 * Math.PI, false)
  ctx.fillStyle = penColor

  ctx.fill()
}

const erasePixels = (x, y) => {
  ctx.save()

  ctx.beginPath()
  ctx.arc(x, y, penWidth, 2 * Math.PI, false)

  ctx.globalCompositeOperation = 'destination-out'

  ctx.fillStyle = 'white'
  ctx.fill()
  ctx.restore()
}


const render = () => {

  // New code 👇
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
  // New Code 👆

  // drawRect(0, 0, 100, 100, 'red');
  // drawRect(200, 200, 100, 100, 'blue');
  // drawPixels(400, 400)

  // Ok, so we DO need to redraw the lines. That means we DO need to KEEP TRACK of them!
  // This drawing just needs absolute figures, the same as they were when the line was being made! The viewportTransform will automatically adjust shit here and there.
  lines.forEach((line, idx) => {

    // console.log(`letsdraw this line #${idx}`, line, `from the start pos (${previousXDrawing}, ${previousYDrawing})`);

    // drawPixels(line.x, line.y)
    if (line.isEraseLine) {
      eraseLine(line.x, line.y, line.previousX, line.previousY)
    } else {
      drawLine(line.x, line.y, line.previousX, line.previousY)
    }


    // TODO: bullshit most likely
    // previousXDrawing = line.x
    // previousYDrawing = line.y
  });

}


render()
// drawPixels(400, 400)
// lines.push({ x: 400, y: 400 })


// We need to keep track of our previous mouse position for later
let previousX = 0,
  previousY = 0;
let previousXDrawing = 0,
  previousYDrawing = 0;

const updatePanning = (e) => {
  const localX = e.clientX;
  const localY = e.clientY;

  viewportTransform.x += localX - previousX;
  viewportTransform.y += localY - previousY;

  previousX = localX;
  previousY = localY;
}

// If we are trying to zoom out to a level lesser than zoom level 1, then we will not do anything
const isZoomAllowed = (viewportTransform, deltaY) => {
  return viewportTransform.scale + deltaY * -0.01 >= 1
}

const updateZooming = (e) => {
  const oldScale = viewportTransform.scale;
  const oldX = viewportTransform.x;
  const oldY = viewportTransform.y;

  const localX = e.clientX;
  const localY = e.clientY;

  const previousScale = viewportTransform.scale;

  const newScale = viewportTransform.scale += e.deltaY * -0.01;

  const newX = localX - (localX - oldX) * (newScale / previousScale);
  const newY = localY - (localY - oldY) * (newScale / previousScale);

  viewportTransform.x = newX;
  viewportTransform.y = newY;
  viewportTransform.scale = newScale;
}


const drawLine = (x, y, previousPosX, previousPosY) => {
  ctx.beginPath()
  ctx.moveTo(previousPosX, previousPosY)
  ctx.lineTo(x, y)

  ctx.strokeStyle = penColor
  ctx.lineWidth = penWidth
  ctx.lineCap = 'round'

  ctx.stroke()
}

const eraseLine = (x, y, previousPosX, previousPosY) => {
  ctx.save()

  ctx.beginPath()
  ctx.moveTo(previousPosX, previousPosY)
  ctx.lineTo(x, y)

  ctx.globalCompositeOperation = 'destination-out'

  // ctx.strokeStyle = 'white' // NOT REALLY NEEDED. Destination out means you're just removing the effects of the pen, regardless of whatever colors they were.
  ctx.lineWidth = penWidth
  ctx.lineCap = 'round'

  ctx.stroke()
  ctx.restore()
}


const onMouseMove = (e) => {

  // Handle the case of drawing here. If drawing mode is on, then that should be handled.

  if (isDrawingMode) {



    // FINAL: yes this is the final solution! I have got it! Eureka!!!! yippieee
    // TODO: change this PART B
    const adjustedX = e.offsetX * viewportTransform.scale - viewportTransform.x;
    const adjustedY = e.offsetY * viewportTransform.scale - viewportTransform.y;

    // const adjustedX = (e.clientX + viewportTransform.x) * viewportTransform.scale ;
    // const adjustedY = (e.clientY + viewportTransform.y) * viewportTransform.scale ;

    console.log('X walay: ', e.offsetX, viewportTransform.scale, viewportTransform.x);
    console.log('Y walay: ', e.offsetY, viewportTransform.scale, viewportTransform.y);

    var bounding = canvas.getBoundingClientRect();
    var x = e.clientX - bounding.left;
    var y = e.clientY - bounding.top;




    const xToDraw = adjustedX
    const yToDraw = adjustedY

    if (e.shiftKey) {
      // erasePixels(xToDraw, yToDraw)
      eraseLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
      lines.push({
        x: xToDraw,
        y: yToDraw,
        previousX: previousXDrawing,
        previousY: previousYDrawing,
        isEraseLine: true
      }) // TODO: of course we somehow need to remember that this is a erased line. and handle it that way
    } else {

      // drawPixels(xToDraw, yToDraw)

      // drawPixels(400, 400)
      drawLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
      lines.push({
        x: xToDraw,
        y: yToDraw,
        previousX: previousXDrawing,
        previousY: previousYDrawing,
        isEraseLine: false
      })


    }
    // bogus attempt TODO: problem either here!
    previousXDrawing = adjustedX
    previousYDrawing = adjustedY

    console.log('on moving of mouse, these were the assigned prev x and y: ', previousXDrawing, previousYDrawing);



  } else {
    updatePanning(e)
    render()
  }

  // updatePanning(e)
  // render()

  // console.log(e)
}

const onMouseWheel = (e) => {

  if (isZoomAllowed(viewportTransform, e.deltaY)) {
    updateZooming(e)
    render()
  }

  // console.log(e)
}

canvas.addEventListener("wheel", onMouseWheel);


canvas.addEventListener("mousedown", (e) => {
  // This is needed for ensuring smooth panning
  previousX = e.clientX;
  previousY = e.clientY;

  // This is my attempt to make the drawLine logic work TODO: or problem is here
  if (isDrawingMode) {

    // TODO: change this PARTA
    const adjustedX = e.offsetX * viewportTransform.scale - viewportTransform.x;
    const adjustedY = e.offsetY * viewportTransform.scale - viewportTransform.y;

    // const adjustedX = (e.clientX + viewportTransform.x) * viewportTransform.scale;
    // const adjustedY = (e.clientY + viewportTransform.y) * viewportTransform.scale ;

    var bounding = canvas.getBoundingClientRect();
    var x = e.clientX - bounding.left;
    var y = e.clientY - bounding.top;

    previousXDrawing = adjustedX
    previousYDrawing = adjustedY

    // previousXDrawing = e.offsetX
    // previousYDrawing = e.offsetY

    // TODO: for some reason, over here e.offsetX is a lot. 
    // maybe we are using the wrong key for comparing x and y coordinates for drawing, and need something else
    console.log('in adding event listener, these were prev X and Y: ', previousXDrawing, previousYDrawing)
  }


  canvas.addEventListener("mousemove", onMouseMove);
})

canvas.addEventListener("mouseup", (e) => {
  canvas.removeEventListener("mousemove", onMouseMove);
})
<canvas width="500" height="500" id="canvas" style="border: 2px solid black; background-color: yellow;"></canvas>

<label for="">Drawing Mode</label>
<input type="checkbox" checked onchange="toggleDrawMode()">

<button onclick="showLines()">Show stats of lines</button>

https://playcode.io/2181300

For video demo of issue, see the below video: https://www.facebook.com/abdullahahmadaak/videos/560288413454083/?idorvanity=1094897594327615&notif_id=1733394446600296&notif_t=video_processed&ref=notif


Solution

  • The core of the solution is:

    // What you already had: resolve x,y in pixels relative to the canvas element
    const bounding = canvas.getBoundingClientRect();
    const x = e.clientX - bounding.left;
    const y = e.clientY - bounding.top;
        
    // Create a DOMPoint for the pixel coordinates
    const p = DOMPoint.fromPoint({ x, y });
    // Get the inverse of the transformation applied to the canvas context
    const t = ctx.getTransform().inverse();
    // Use it to calculate context coordinates for your pixel point
    const { x: adjustedX, y: adjustedY } = t.transformPoint(p);
    

    Here's it applied to your code.

    function showLines() {
      console.log(lines)
    }
    
    function toggleDrawMode() {
      isDrawingMode = !isDrawingMode
    }
    
    const canvas = document.getElementById('canvas')
    const ctx = canvas.getContext('2d')
    let lines = [];
    
    const viewportTransform = {
      x: 0,
      y: 0,
      scale: 1
    }
    
    // From here on, everything we'll write will go below 👇
    const drawRect = (x, y, width, height, color) => {
      ctx.fillStyle = color
      ctx.fillRect(x, y, width, height)
    }
    
    
    let isDrawingMode = true
    const penWidth = 5
    const penColor = 'red'
    
    const drawPixels = (x, y) => {
      ctx.beginPath()
    
      ctx.arc(x, y, penWidth, 2 * Math.PI, false)
      ctx.fillStyle = penColor
    
      ctx.fill()
    }
    
    const erasePixels = (x, y) => {
      ctx.save()
    
      ctx.beginPath()
      ctx.arc(x, y, penWidth, 2 * Math.PI, false)
    
      ctx.globalCompositeOperation = 'destination-out'
    
      ctx.fillStyle = 'white'
      ctx.fill()
      ctx.restore()
    }
    
    
    const render = () => {
    
      // New code 👇
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
      // New Code 👆
    
      // drawRect(0, 0, 100, 100, 'red');
      // drawRect(200, 200, 100, 100, 'blue');
      // drawPixels(400, 400)
    
      // Ok, so we DO need to redraw the lines. That means we DO need to KEEP TRACK of them!
      // This drawing just needs absolute figures, the same as they were when the line was being made! The viewportTransform will automatically adjust shit here and there.
      lines.forEach((line, idx) => {
    
        // console.log(`letsdraw this line #${idx}`, line, `from the start pos (${previousXDrawing}, ${previousYDrawing})`);
    
        // drawPixels(line.x, line.y)
        if (line.isEraseLine) {
          eraseLine(line.x, line.y, line.previousX, line.previousY)
        } else {
          drawLine(line.x, line.y, line.previousX, line.previousY)
        }
    
    
        // TODO: bullshit most likely
        // previousXDrawing = line.x
        // previousYDrawing = line.y
      });
    
    }
    
    
    render()
    // drawPixels(400, 400)
    // lines.push({ x: 400, y: 400 })
    
    
    // We need to keep track of our previous mouse position for later
    let previousX = 0,
      previousY = 0;
    let previousXDrawing = 0,
      previousYDrawing = 0;
    
    const updatePanning = (e) => {
      const localX = e.clientX;
      const localY = e.clientY;
    
      viewportTransform.x += localX - previousX;
      viewportTransform.y += localY - previousY;
    
      previousX = localX;
      previousY = localY;
    }
    
    // If we are trying to zoom out to a level lesser than zoom level 1, then we will not do anything
    const isZoomAllowed = (viewportTransform, deltaY) => {
      return viewportTransform.scale + deltaY * -0.01 >= 1
    }
    
    const updateZooming = (e) => {
      const oldScale = viewportTransform.scale;
      const oldX = viewportTransform.x;
      const oldY = viewportTransform.y;
    
      const localX = e.clientX;
      const localY = e.clientY;
    
      const previousScale = viewportTransform.scale;
    
      const newScale = viewportTransform.scale += e.deltaY * -0.01;
    
      const newX = localX - (localX - oldX) * (newScale / previousScale);
      const newY = localY - (localY - oldY) * (newScale / previousScale);
    
      viewportTransform.x = newX;
      viewportTransform.y = newY;
      viewportTransform.scale = newScale;
    }
    
    
    const drawLine = (x, y, previousPosX, previousPosY) => {
      ctx.beginPath()
      ctx.moveTo(previousPosX, previousPosY)
      ctx.lineTo(x, y)
    
      ctx.strokeStyle = penColor
      ctx.lineWidth = penWidth
      ctx.lineCap = 'round'
    
      ctx.stroke()
    }
    
    const eraseLine = (x, y, previousPosX, previousPosY) => {
      ctx.save()
    
      ctx.beginPath()
      ctx.moveTo(previousPosX, previousPosY)
      ctx.lineTo(x, y)
    
      ctx.globalCompositeOperation = 'destination-out'
    
      // ctx.strokeStyle = 'white' // NOT REALLY NEEDED. Destination out means you're just removing the effects of the pen, regardless of whatever colors they were.
      ctx.lineWidth = penWidth
      ctx.lineCap = 'round'
    
      ctx.stroke()
      ctx.restore()
    }
    
    
    const onMouseMove = (e) => {
    
      // Handle the case of drawing here. If drawing mode is on, then that should be handled.
    
      if (isDrawingMode) {
    
    
    
        // FINAL: yes this is the final solution! I have got it! Eureka!!!! yippieee
        // TODO: change this PART B
    
    
        console.log('X walay: ', e.offsetX, viewportTransform.scale, viewportTransform.x);
        console.log('Y walay: ', e.offsetY, viewportTransform.scale, viewportTransform.y);
    
        const bounding = canvas.getBoundingClientRect();
        const x = e.clientX - bounding.left;
        const y = e.clientY - bounding.top;
        const p = DOMPoint.fromPoint({ x, y });
        const t = ctx.getTransform().inverse();
        const { x: adjustedX, y: adjustedY } = t.transformPoint(p);
    
    
        const xToDraw = adjustedX
        const yToDraw = adjustedY
    
        if (e.shiftKey) {
          // erasePixels(xToDraw, yToDraw)
          eraseLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
          lines.push({
            x: xToDraw,
            y: yToDraw,
            previousX: previousXDrawing,
            previousY: previousYDrawing,
            isEraseLine: true
          }) // TODO: of course we somehow need to remember that this is a erased line. and handle it that way
        } else {
    
          // drawPixels(xToDraw, yToDraw)
    
          // drawPixels(400, 400)
          drawLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
          lines.push({
            x: xToDraw,
            y: yToDraw,
            previousX: previousXDrawing,
            previousY: previousYDrawing,
            isEraseLine: false
          })
    
    
        }
        // bogus attempt TODO: problem either here!
        previousXDrawing = adjustedX
        previousYDrawing = adjustedY
    
        console.log('on moving of mouse, these were the assigned prev x and y: ', previousXDrawing, previousYDrawing);
    
    
    
      } else {
        updatePanning(e)
        render()
      }
    
      // updatePanning(e)
      // render()
    
      // console.log(e)
    }
    
    const onMouseWheel = (e) => {
    
      if (isZoomAllowed(viewportTransform, e.deltaY)) {
        updateZooming(e)
        render()
      }
    
      // console.log(e)
    }
    
    canvas.addEventListener("wheel", onMouseWheel);
    
    
    canvas.addEventListener("mousedown", (e) => {
      // This is needed for ensuring smooth panning
      previousX = e.clientX;
      previousY = e.clientY;
    
      // This is my attempt to make the drawLine logic work TODO: or problem is here
      if (isDrawingMode) {
    
        // TODO: change this PARTA
        const adjustedX = e.offsetX * viewportTransform.scale - viewportTransform.x;
        const adjustedY = e.offsetY * viewportTransform.scale - viewportTransform.y;
    
        // const adjustedX = (e.clientX + viewportTransform.x) * viewportTransform.scale;
        // const adjustedY = (e.clientY + viewportTransform.y) * viewportTransform.scale ;
    
        var bounding = canvas.getBoundingClientRect();
        var x = e.clientX - bounding.left;
        var y = e.clientY - bounding.top;
    
        previousXDrawing = adjustedX
        previousYDrawing = adjustedY
    
        // previousXDrawing = e.offsetX
        // previousYDrawing = e.offsetY
    
        // TODO: for some reason, over here e.offsetX is a lot. 
        // maybe we are using the wrong key for comparing x and y coordinates for drawing, and need something else
        console.log('in adding event listener, these were prev X and Y: ', previousXDrawing, previousYDrawing)
      }
    
    
      canvas.addEventListener("mousemove", onMouseMove);
    })
    
    canvas.addEventListener("mouseup", (e) => {
      canvas.removeEventListener("mousemove", onMouseMove);
    })
    <canvas width="500" height="500" id="canvas" style="border: 2px solid black; background-color: yellow;"></canvas>
    
    <label for="">Drawing Mode</label>
    <input type="checkbox" checked onchange="toggleDrawMode()">
    
    <button onclick="showLines()">Show stats of lines</button>

    Note: there's a bug drawing a line from some coordinate to the start of the zoomed drawing. Since you posted quite some code I didn't feel like hunting that down. Hopefully you can find it yourself rather quickly.