Tips for calculating the canvas-relative mouse position on a CSS 3D transformed canvas

Recently decided to challenge myself by experimenting with drawing on 3D transformed canvases. After some trial and error, I managed to get it partially working.

const m4 = twgl.m4;

[...document.querySelectorAll('canvas')].forEach((canvas) => {
  const ctx = canvas.getContext('2d');
  let count = 0;

  canvas.addEventListener('mousemove', (e) => {
    const pos = getElementRelativeMousePosition(e, canvas);
    ctx.fillStyle = hsl((count++ % 10) / 10, 1, 0.5);
    ctx.fillRect(pos.x - 1, pos.y - 1, 3, 3);
  });
});

function getElementRelativeMousePosition(e, elem) {
  const pos = convertPointFromPageToNode(elem, e.pageX, e.pageY); 
  
  return {
    x: pos[0],
    y: pos[1],
  };
}

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}

function convertPointFromPageToNode(elem, pageX, pageY) {
  const mat = m4.inverse(getTransformationMatrix(elem));
  return m4.transformPoint(mat, [pageX, pageY, 0]);
};

function getTransformationMatrix(elem) {
  let matrix = m4.identity();
  let currentElem = elem;

  while (currentElem !== undefined && 
         currentElem !== currentElem.ownerDocument.documentElement) {
    const style = window.getComputedStyle(currentElem);
    const localMatrix = parseMatrix(style.transform);
    matrix = m4.multiply(localMatrix, matrix);
    currentElem = currentElem.parentElement;
  }

  const w = elem.offsetWidth;
  const h = elem.offsetHeight;
  let i = 4;
  let left = +Infinity;
  let top = +Infinity;
  for (let i = 0; i < 4; ++i) {
    const p = m4.transformPoint(matrix, [w * (i & 1), h * ((i & 2) >> 1), 0]);
    left = Math.min(p[0], left);
    top = Math.min(p[1], top);
  }
  const rect = elem.getBoundingClientRect()
  document.querySelector('p').textContent =
    `${w}x${h}`;
  matrix =  m4.multiply(m4.translation([
     window.pageXOffset + rect.left - left, 
     window.pageYOffset + rect.top - top,
     0]), matrix);
  return matrix;
}


function parseMatrix(str) {
  if (str.startsWith('matrix3d(')) {
    return str.substring(9, str.length - 1).split(',').map(v => parseFloat(v.trim()));
  } else if (str.startsWith('matrix(')) {
    const m = str.substring(7, str.length - 1).split(',').map(v => parseFloat(v.trim()));
    return [
      m[0], m[1], 0, 0,
      m[2], m[3], 0, 0,
      0, 0, 1, 0,
      m[4], m[5], 0, 1,
    ]
  } else if (str == 'none') {
    return m4.identity();
  }
  throw new Error('unknown format');
}
canvas { 
  display: block;
  background: yellow;
  transform: scale(0.75);
}
#c1 {
  margin: 20px;
  background: red;
  transform: translateX(-50px);
  display: inline-block;
}
#c2 {
  margin: 20px;
  background: green;
  transform: rotate(45deg);
  display: inline-block;
}
#c3 {
  margin: 20px;
  background: blue;
  display: inline-block;
}

#c4 {
  position: absolute;
  top: 0;
  background: cyan;
  transform: translateX(-250px) rotate(55deg);
  display: inline-block;
}
#c5 {
  background: magenta;
  transform: translate(50px);
  display: inline-block;
}
#c6 {
  margin: 20px;
  background: pink;
  transform: rotate(45deg) rotateX(45deg);  
  display: inline-block;
}
<p>
foo
</p>
<div id="c1">
  <div id="c2">
    <div id="c3">
      <canvas></canvas>
    </div>
  </div>
</div>
<div id="c4">
  <div id="c5">
    <div id="c6">
      <canvas></canvas>
    </div>
  </div>
</div>
<script src="https://twgljs.org/dist/4.x/twgl-full.min.js"></script>

The initial code seemed to be functioning properly. When moving the cursor over the yellow canvas elements, the drawing was consistent. However, upon adding a 3D transformation, the functionality broke. Update the CSS for '#c6' as follows:

    #c6 {
      margin: 20px;
      background: pink;
      transform: rotate(45deg) rotateX(45deg);  
      display: inline-block;
    }

After this change, attempting to draw on the right yellow canvas results in inaccuracies.

const m4 = twgl.m4;

[...document.querySelectorAll('canvas')].forEach((canvas) => {
  const ctx = canvas.getContext('2d');
  let count = 0;

  canvas.addEventListener('mousemove', (e) => {
    const pos = getElementRelativeMousePosition(e, canvas);
    ctx.fillStyle = hsl((count++ % 10) / 10, 1, 0.5);
    ctx.fillRect(pos.x - 1, pos.y - 1, 3, 3);
  });
});

function getElementRelativeMousePosition(e, elem) {
  const pos = convertPointFromPageToNode(elem, e.pageX, e.pageY); 
  
  return {
    x: pos[0],
    y: pos[1],
  };
}

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}

function convertPointFromPageToNode(elem, pageX, pageY) {
  const mat = m4.inverse(getTransformationMatrix(elem));
  return m4.transformPoint(mat, [pageX, pageY, 0]);
};

function getTransformationMatrix(elem) {
  let matrix = m4.identity();
  let currentElem = elem;

  while (currentElem !== undefined && 
         currentElem !== currentElem.ownerDocument.documentElement) {
    const style = window.getComputedStyle(currentElem);
    const localMatrix = parseMatrix(style.transform);
    matrix = m4.multiply(localMatrix, matrix);
    currentElem = currentElem.parentElement;
  }

  const w = elem.offsetWidth;
  const h = elem.offsetHeight;
  let i = 4;
  let left = +Infinity;
  let top = +Infinity;
  for (let i = 0; i < 4; ++i) {
    const p = m4.transformPoint(matrix, [w * (i & 1), h * ((i & 2) >> 1), 0]);
    left = Math.min(p[0], left);
    top = Math.min(p[1], top);
  }
  const rect = elem.getBoundingClientRect()
  document.querySelector('p').textContent =
    `${w}x${h}`;
  matrix =  m4.multiply(m4.translation([
     window.pageXOffset + rect.left - left, 
     window.pageYOffset + rect.top - top,
     0]), matrix);
  return matrix;
}


function parseMatrix(str) {
  if (str.startsWith('matrix3d(')) {
    return str.substring(9, str.length - 1).split(',').map(v => parseFloat(v.trim()));
  } else if (str.startsWith('matrix(')) {
    const m = str.substring(7, str.length - 1).split(',').map(v => parseFloat(v.trim()));
    return [
      m[0], m[1], 0, 0,
      m[2], m[3], 0, 0,
      0, 0, 1, 0,
      m[4], m[5], 0, 1,
    ]
  } else if (str == 'none') {
    return m4.identity();
  }
  throw new Error('unknown format');
}
canvas { 
  display: block;
  background: yellow;
  transform: scale(0.75);
}
#c1 {
  margin: 20px;
  background: red;
  transform: translateX(-50px);
  display: inline-block;
}
#c2 {
  margin: 20px;
  background: green;
  transform: rotate(45deg);
  display: inline-block;
}
#c3 {
  margin: 20px;
  background: blue;
  display: inline-block;
}

#c4 {
  position: absolute;
  top: 0;
  background: cyan;
  transform: translateX(-250px) rotate(55deg);
  display: inline-block;
}
#c5 {
  background: magenta;
  transform: translate(50px);
  display: inline-block;
}
#c6 {
  margin: 20px;
  background: pink;
  transform: rotate(45deg) rotateX(45deg);  
  display: inline-block;
}
<p>
foo
</p>
<div id="c1">
  <div id="c2">
    <div id="c3">
      <canvas></canvas>
    </div>
  </div>
</div>
<div id="c4">
  <div id="c5">
    <div id="c6">
      <canvas></canvas>
    </div>
  </div>
</div>
<script src="https://twgljs.org/dist/4.x/twgl-full.min.js"></script>

Trying to figure out what might be causing this issue. Any insights or suggestions are welcome!

Answer №1

Note: This response complements the findings that the original poster already discovered on their own.

To achieve the desired functionality, you can utilize the MouseEvent constructor.

By passing the clientX and clientY properties (or pageX & pageY) inside the constructor and then dispatching this composed event to your target, it will set the offsetX and offsetY properties relative to the target element.

A converter can be created which takes advantage of the synchronous nature of dispatchEvent:

const init_pos = { x: 50, y: 50};
const relative_pos = {};
const canvas = document.querySelector('canvas');

canvas.addEventListener('mousemove', e => {
  relative_pos.x = e.offsetX;
  relative_pos.y = e.offsetY;
}, {once: true});

canvas.dispatchEvent(new MouseEvent('mousemove', {
  clientX: init_pos.x,
  clientY: init_pos.y
}));
// log synchronously
console.log(relative_pos);
canvas { 
  display: block;
  background: yellow;
  transform: scale(0.75);
}
#c4 {
  position: absolute;
  top: 0;
  background: cyan;
  transform: translateX(-250px) rotate(55deg);
  display: inline-block;
}
#c5 {
  background: magenta;
  transform: translate(50px);
  display: inline-block;
}
#c6 {
  background: pink;
  transform: rotate(45deg);
  display: inline-block;
}
<div id="c4">
  <div id="c5">
    <div id="c6">
      <canvas></canvas>
    </div>
  </div>
</div>

Building upon the example in the provided answer, a single object can be used to maintain the global Event's position. The relative positions of the canvases can be obtained at each frame within a requestAnimationFrame loop.
If only the visible face should handle the events, the elements need to react to pointer-events for accurate matching with document.elementFromPoint(x, y).

// stores the last event's position
const pos = {
  x: 0,
  y: 0
};
const canvases = document.querySelectorAll('canvas');
// Global "real" MouseEvent handler
document.body.onmousemove = (e) => {
  pos.x = e.clientX;
  pos.y = e.clientY;
};

canvases.forEach(canvas => {
  const ctx = canvas.getContext('2d');
  let count = 0;
  canvas.addEventListener('mousemove', draw);
  function draw(e) {
    // avoid firing on real Events
    if (e.cancelable) return;
    
    const x = e.offsetX * canvas.width / canvas.clientWidth;
    const y = e.offsetY * canvas.height / canvas.clientHeight;
    
    if (x < 0 || x > canvas.width || y < 0 || y > canvas.height) {
      return;
    }
    
    ctx.fillStyle = hsl((count++ % 10) / 10, 1, 0.5);
    ctx.fillRect(x - 1, y - 1, 3, 3);
  }
});

anim();

function anim() {
  requestAnimationFrame(anim);

  // determine front element for painting
  const front_elem = single_face.checked && document.elementFromPoint(pos.x, pos.y);

  // at each frame
  canvases.forEach(c => {
    if (!front_elem || c === front_elem) {
      // generate composed event synchronously within rAF callback
      c.dispatchEvent(
        new MouseEvent('mousemove', {
          clientX: pos.x,
          clientY: pos.y
        })
      );
    }
  });
}

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}
.scene {
  width: 200px;
  height: 200px;
  perspective: 600px;
}

.cube {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  animation-duration: 16s;
  animation-name: rotate;
  animation-iteration-count: infinite;
  animation-timing-function: linear;
  pointer-events: none; /* mouse events not required */
}

#single_face:checked+.scene .cube {
  pointer-events: all; /* specify front face interactions */
}
label,#single_face {float: right}
@keyframes rotate {
  from {
    transform: translateZ(-100px) rotateX( 0deg) rotateY( 0deg);
  }
  to {
    transform: translateZ(-100px) rotateX(360deg) rotateY(720deg);
  }
}

.cube__face {
  position: absolute;
  width: 200px;
  height: 200px;
  display: block;
}

.cube__face--front {
  background: rgba(255, 0, 0, 0.2);
  transform: rotateY( 0deg) translateZ(100px);
}

.cube__face--right {
  background: rgba(0, 255, 0, 0.2);
  transform: rotateY( 90deg) translateZ(100px);
}

.cube__face--back {
  background: rgba(0, 0, 255, 0.2);
  transform: rotateY(180deg) translateZ(100px);
}

.cube__face--left {
  background: rgba(255, 255, 0, 0.2);
  transform: rotateY(-90deg) translateZ(100px);
}

.cube__face--top {
  background: rgba(0, 255, 255, 0.2);
  transform: rotateX( 90deg) translateZ(100px);
}

.cube__face--bottom {
  background: rgba(255, 0, 255, 0.2);
  transform: rotateX(-90deg) translateZ(100px);
}
<label>Draw on a single face</label><input type="checkbox" id="single_face">
<div class="scene">
  <div class="cube">
    <canvas class="cube__face cube__face--front"></canvas>
    <canvas class="cube__face cube__face--back"></canvas>
    <canvas class="cube__face cube__face--right"></canvas>
    <canvas class="cube__face cube__face--left"></canvas>
    <canvas class="cube__face cube__face--top"></canvas>
    <canvas class="cube__face cube__face--bottom"></canvas>
  </div>
</div>
<pre id="debug"></pre>

Answer №2

Alas ... the answer remains elusive, but it appears that event.offsetX and event.offsetY are meant to hold this value despite MDN suggesting they are not yet standardized.

Testing reveals functionality in Chrome and Firefox, though Safari presents inconsistencies in certain scenarios. Regrettably, offsetX and offsetY are absent in touch events. While pointer events support these properties, Safari lacks compatibility as of May 2019.

[...document.querySelectorAll('canvas')].forEach((canvas) => {
  const ctx = canvas.getContext('2d');
  let count = 0;

  canvas.addEventListener('mousemove', (e) => {
    const pos = {
      x: e.offsetX * ctx.canvas.width / ctx.canvas.clientWidth,
      y: e.offsetY * ctx.canvas.height / ctx.canvas.clientHeight,
    };
    ctx.fillStyle = hsl((count++ % 10) / 10, 1, 0.5);
    ctx.fillRect(pos.x - 1, pos.y - 1, 3, 3);
  });
});

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}
canvas { 
  display: block;
  background: yellow;
  transform: scale(0.75);
}
#c1 {
  margin: 20px;
  background: red;
  transform: translateX(-50px);
  display: inline-block;
}
#c2 {
  margin: 20px;
  background: green;
  transform: rotate(45deg);
  display: inline-block;
}
#c3 {
  margin: 20px;
  background: blue;
  display: inline-block;
}

#c4 {
  position: absolute;
  top: 0;
  background: cyan;
  transform: translateX(-250px) rotate(55deg);
  display: inline-block;
}
#c5 {
  background: magenta;
  transform: translate(50px);
  display: inline-block;
}
#c6 {
  background: pink;
  transform: rotate(45deg) rotateX(45deg);  /* changed */
  display: inline-block;
}
<p>
foo
</p>
<div id="c1">
  <div id="c2">
    <div id="c3">
      <canvas></canvas>
    </div>
  </div>
</div>
<div id="c4">
  <div id="c5">
    <div id="c6">
      <canvas></canvas>
    </div>
  </div>
</div>

Unfortunately, the challenge persists when we require a canvas's relative position outside of an event context. In the following example, our aim is to continue drawing under the mouse pointer even when it remains stationary.

[...document.querySelectorAll('canvas')].forEach((canvas) => {
  const ctx = canvas.getContext('2d');
  ctx.canvas.width  = ctx.canvas.clientWidth;
  ctx.canvas.height = ctx.canvas.clientHeight;
  let count = 0;

  function draw(e, radius = 1) {
    const pos = {
      x: e.offsetX * ctx.canvas.width / ctx.canvas.clientWidth,
      y: e.offsetY * ctx.canvas.height / ctx.canvas.clientHeight,
    };
    document.querySelector('#debug').textContent = count;
    ctx.beginPath();
    ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
    ctx.fillStyle = hsl((count++ % 100) / 100, 1, 0.5);
    ctx.fill();
  }

  function preventDefault(e) {
    e.preventDefault();
  }

  if (window.PointerEvent) {
    canvas.addEventListener('pointermove', (e) => {
      draw(e, Math.max(Math.max(e.width, e.height) / 2, 1));
    });
    canvas.addEventListener('touchstart', preventDefault, {passive: false});
    canvas.addEventListener('touchmove', preventDefault, {passive: false});
  } else {
    canvas.addEventListener('mousemove', draw);
    canvas.addEventListener('mousedown', preventDefault);
  }
});

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}
.scene {
  width: 200px;
  height: 200px;
  perspective: 600px;
}

.cube {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  animation-duration: 16s;
  animation-name: rotate;
  animation-iteration-count: infinite;
  animation-timing-function: linear;
}

@keyframes rotate {
  from { transform: translateZ(-100px) rotateX(  0deg) rotateY(  0deg); }
  to   { transform: translateZ(-100px) rotateX(360deg) rotateY(720deg); }
}

.cube__face {
  position: absolute;
  width: 200px;
  height: 200px;
  display: block;
}

.cube__face--front  { background: rgba(255, 0, 0, 0.2); transform: rotateY(  0deg) translateZ(100px); }
.cube__face--right  { background: rgba(0, 255, 0, 0.2); transform: rotateY( 90deg) translateZ(100px); }
.cube__face--back   { background: rgba(0, 0, 255, 0.2); transform: rotateY(180deg) translateZ(100px); }
.cube__face--left   { background: rgba(255, 255, 0, 0.2); transform: rotateY(-90deg) translateZ(100px); }
.cube__face--top    { background: rgba(0, 255, 255, 0.2); transform: rotateX( 90deg) translateZ(100px); }
.cube__face--bottom { background: rgba(255, 0, 255, 0.2); transform: rotateX(-90deg) translateZ(100px); }
<div class="scene">
  <div class="cube">
    <canvas class="cube__face cube__face--front"></canvas>
    <canvas class="cube__face cube__face--back"></canvas>
    <canvas class="cube__face cube__face--right"></canvas>
    <canvas class="cube__face cube__face--left"></canvas>
    <canvas class="cube__face cube__face--top"></canvas>
    <canvas class="cube__face cube__face--bottom"></canvas>
  </div>
</div>
<pre id="debug"></pre>

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

What is the best way to set values for the outcomes of a jQuery/AJAX post?

When sending data to a php page using jquery, I aim to receive the response from the page and store it in php variables on the original page without displaying results in the HTML or appending a DIV. Here's an example of how this can be achieved: Sam ...

Can using the jQuery.clone method impact other functions?

Assuming $dom is a jQuery element, can the following line be safely removed if a is not utilized thereafter? var a = $dom.clone(true,true); When using $dom.clone(false,false), it appears that there are no side effects. I believe this method doesn't ...

preserving the status of checkboxes based on an array of binary values

I am trying to figure out how to restore the state of checkboxes in an ORACLE APEX tabular form. The selection is made in the first column using the APEX row selector f01. After saving the checkbox state in a collection and then transferring it to an arra ...

Incorporate content from a single HTML file into a different one

Hello, I am working with two HTML documents - let's call them index.html and index2.html for this example. I am looking to embed all the code within the body tag of index2.html into a section within index.html. Is there a way to create this connectio ...

When clicking on a dropdown option, the OnClick event does not seem

When selecting an option from the dropdown, it should retrieve a value from the database and display it in a text field. I implemented a click function which worked in Firefox but not in Google Chrome. HTML: <select id="classi" name="classi"> & ...

Ways to ensure a form label is accessible via keyboard navigation

My HTML5 form collects user information and processes it upon submission. With the help of the jQuery Validation Plugin, input validation is performed. In case of errors, an error-message-container div is displayed at the top of the screen, listing all err ...

Determining when props are updated in Vue.js 2 when passing an object as a prop

Let's say there is an array feedsArray, with a sample value like this: this.feedsArray = [ { id: 1, type: 'Comment', value: 'How are you today ?' }, { id: 2, type: 'Meet', name: 'Daily ...

The issue at hand is that the headers cannot be redefined once they have already been sent

Hello, I have tried numerous solutions but have not been able to resolve the "Can't set headers after they are sent" error in the following simple code. I have spent several days working on this and would greatly appreciate any input you may have. ap ...

Combine multiple jQuery click functions into a single function

One issue I am facing on my website is that there are multiple modules, each of which can be disabled by the user. The problem lies in the fact that I have a separate script for each module. Hence, my query: How can I combine these scripts into one (perhap ...

Learn the ins and outs of Ember.js with our comprehensive tutorial and guide. Discover solutions to common issues such as DS

Learning Ember has been a challenging experience for me. The guide I am using seems to be lacking important information that I need. I keep encountering two specific errors: Uncaught Error: Ember.State has been moved into a plugin: https://github.com/e ...

Dimming the background of my page as the Loader makes its grand entrance

Currently, I am in the process of developing a filtering system for my content. The setup involves displaying a loader in the center of the screen whenever a filter option is clicked, followed by sorting and displaying the results using JQuery. I have a v ...

Discover the impact of images.google.com on enhancing your image gallery

I am currently working on creating a website that features an image gallery. When a user clicks on a thumbnail, I want a hidden div to slide out and display more information about the image, possibly even including a slideshow. After extensive searching o ...

Sharing Data Across Multiple Windows in Angular 2 Using a Singleton List

My multiplayer game involves adding new players to a single game room lobby, which is essentially a list of current players. How can I update this list for all connected players when new ones join? I implemented a service and included it in the providers ...

Displaying a background image in Laravel using the storage directory

Can anyone offer some guidance on how to load a background image from storage? I have tried using the following code with normal img src, but it doesn't seem to work with background images. background-image: url({{ Storage::get("{$post->image}") } ...

The resizing of the window does not trigger any changes in the jQuery functions

Here is a snippet of jQuery code that executes one function when the window size is large (>=1024) and another when it is resized to be small. Although the console.logs behave as expected on resize, the functions themselves do not change. This means that ...

Hyperlink -> Boundary

Our homepage features various elements as part of the menu. Each element consists of a picture, title, and small description. The issue is that these menu items always display a border when the mouse hovers over them, despite attempts to hide it. You can ...

Unable to retrieve information obtained from MongoDB

After successfully retrieving all data from the "topics" collection using find() with cursor and foreach, I am encountering an issue. When I attempt to assign the fetched information to a variable named "data" and send it back to the page, it consistently ...

Developing HTML5 animation by utilizing sprite sheets

Struggling to create an engaging canvas animation with the image provided in the link below. Please take a look at https://i.stack.imgur.com/Pv2sI.jpg I'm attempting to run this sprite sheet image for a normal animation, but unfortunately, I seem to ...

Unique Floating Bullet Icons Ascending When Enlarged

I'm currently trying to use a star image as a custom bullet point on an <li> element. However, I'm facing the issue where the bottom of the image aligns with the font's baseline, causing the image to be pushed upwards. Is there any sol ...

Is it feasible to generate a realistic arrow using CSS and then rotate it?

Can a fully functional arrow be created using CSS alone? Here is the method I used to create just the arrowhead: http://jsfiddle.net/2fsoz6ye/ #demo:after { content: ' '; height: 0; position: absolute; width: 0; border: 10p ...