Related
Running into an interesting canvas bug: after translating a canvas, the pixels appear blurry on Safari but not Chrome.
I've tried just about every image-rendering and imageSmoothing trick to no avail.
Here's a codepen where I've been able to reproduce the issue: https://codepen.io/plillian/pen/RwQegyR
Is this a just Safari bug? Or is there a way to force nearest neighbor in Safari as well?
Yes this is a Safari bug, you may want to let them know about it. For what it's worth, it's still an issue in the latest Technology Preview (Safari 15.4, WebKit 17614.1.14.10.6) where it's not even able to render every frame on time and will just "blink".
As for a workaround, the only one I can think of would be to do this all on the canvas directly, you can easily make this resizing of an ImageData by first converting it to an ImageBitmap and use drawImage().
Though to implement the scrolling behavior we'll have a bit of work to do.
One way is to use a placeholder <div> and make it act as-if we did transform our <canvas>. This way we can still use the native scrolling behavior and simply update the arguments to drawImage().
We then can stick the canvas on the top left corner of the viewport, and set it to the size of the viewport, overcoming the issue of possibly having a too big canvas.
(async () => {
const SIZE = 1024;
const X = -208.97398878415459;
const Y = 47.03519866364394;
const scale = 80;
const viewport = document.getElementById('viewport');
const wrapper = document.getElementById('wrapper');
const placeholder = document.getElementById('placeholder');
const canvas = document.getElementById('cvs');
placeholder.style.width = SIZE + 'px';
placeholder.style.height = SIZE + 'px';
const c = canvas.getContext('2d');
const pixels = new Uint8ClampedArray(4 * SIZE * SIZE);
for (let xi = 0; xi < SIZE; xi++) {
for (let yi = 0; yi < SIZE; yi++) {
const idx = (xi + yi * SIZE) * 4;
pixels[idx] = (xi << 6) % 255;
pixels[idx + 1] = (yi << 6) % 255;
pixels[idx + 3] = 255;
}
}
const pixelData = new ImageData(pixels, SIZE, SIZE);
// Convert to an ImageBitmap for ease of resizing and cropping
const bmp = await createImageBitmap(pixelData);
// We resize the canvas bitmap based on the size of the viewport
// While respecting the actual dPR (gimme crisp pixels!)
// Thanks to gman for the reminder of how to suppport all early impl.
// https://stackoverflow.com/a/65435847/3702797
const observer = new ResizeObserver(([entry]) => {
let width;
let height;
const dPR = devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
} else if (entry.contentBoxSize) {
if ( entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize * dPR;
height = entry.contentBoxSize[0].blockSize * dPR;
} else {
width = entry.contentBoxSize.inlineSize * dPR;
height = entry.contentBoxSize.blockSize * dPR;
}
} else {
width = entry.contentRect.width * dPR;
height = entry.contentRect.height * dPR;
}
canvas.width = width;
canvas.height = height;
canvas.style.width = (width / dPR) + 'px';
canvas.style.height = (height / dPR) + 'px';
c.scale(dPR, dPR);
c.imageSmoothingEnabled = false;
});
// observe the scrollbox size changes
try {
observer.observe(viewport, { box: 'device-pixel-content-box' });
}
catch(err) {
observer.observe(viewport, { box: 'content-box' });
}
function getDrawImageArgs(nodetranslate) {
const { width, height } = canvas;
const { scrollLeft, scrollTop } = viewport;
const mat = new DOMMatrix(nodetranslate).inverse();
const source = mat.transformPoint({ x: scrollLeft, y: scrollTop });
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
return [source.x, source.y, sourceWidth, sourceHeight, 0, 0, canvas.width * scale, canvas.height * scale];
}
function animate() {
const nodetranslate = `translate3D(${X}px, ${Y}px, 0px) scale(${scale})`;
wrapper.style.transform = nodetranslate;
c.clearRect(0, 0, canvas.width, canvas.height);
c.drawImage(bmp, ...getDrawImageArgs(nodetranslate));
requestAnimationFrame(animate);
}
animate();
})().catch(console.error)
body { margin: 0 }
#viewport {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: auto;
}
.sticker {
position: sticky;
top: 0;
left: 0;
height: 0px;
width: 0px;
overflow: visible;
line-height: 0;
z-index: 1;
}
canvas {
position: absolute;
}
#wrapper {
transform-origin: 0 0;
position: absolute;
}
#placeholder {
display: inline-block;
}
<script>
// Because Safari wouldn't be Safari without all its little bugs...
// See https://stackoverflow.com/a/35503829/3702797
(()=>{if(function(){const e=document.createElement("canvas").getContext("2d");e.fillRect(0,0,40,40),e.drawImage(e.canvas,-40,-40,80,80,50,50,20,20);var a=e.getImageData(50,50,30,30),r=new Uint32Array(a.data.buffer),n=(e,t)=>r[t*a.width+e];return[[9,9],[20,9],[9,20],[20,20]].some(([e,t])=>0!==n(e,t))||[[10,10],[19,10],[10,19],[19,19]].some(([e,t])=>0===n(e,t))}()){const e=CanvasRenderingContext2D.prototype,i=e.drawImage;i?e.drawImage=function(e,t,a){if(!(9===arguments.length))return i.apply(this,[...arguments]);var r,n=function(e,t,a,r,n,i,o,h,m){var{width:s,height:d}=function(t){var e=e=>{e=globalThis[e];return e&&t instanceof e};{if(e("HTMLImageElement"))return{width:t.naturalWidth,height:t.naturalHeight};if(e("HTMLVideoElement"))return{width:t.videoWidth,height:t.videoHeight};if(e("SVGImageElement"))throw new TypeError("SVGImageElement isn't yet supported as source image.","UnsupportedError");return e("HTMLCanvasElement")||e("ImageBitmap")?t:void 0}}(e);r<0&&(t+=r,r=Math.abs(r));n<0&&(a+=n,n=Math.abs(n));h<0&&(i+=h,h=Math.abs(h));m<0&&(o+=m,m=Math.abs(m));var g=Math.max(t,0),u=Math.min(t+r,s),s=Math.max(a,0),d=Math.min(a+n,d),r=h/r,n=m/n;return[e,g,s,u-g,d-s,t<0?i-t*r:i,a<0?o-a*n:o,(u-g)*r,(d-s)*n]}(...arguments);return r=n,[3,4,7,8].some(e=>!r[e])?void 0:i.apply(this,n)}:console.error("This script requires a basic implementation of drawImage")}})();
</script>
<div id="viewport">
<div class="sticker">
<!-- <canvas> isn't a void element, it must have a closing tag -->
<!-- We place it in a "sticky" element, outside of the one that gets transformed -->
<canvas id="cvs"></canvas>
</div>
<div id="wrapper">
<div id="placeholder"><!--
We'll use it as an easy way to measure what part of the canvas we should draw
based on the current scroll position.
--></div>
<div>
</div>
You can inspect the <canvas> element and see it's actually only as big as the viewport and not some 81920x81920px.
Running into an interesting canvas bug: after translating a canvas, the pixels appear blurry on Safari but not Chrome.
I've tried just about every image-rendering and imageSmoothing trick to no avail.
Here's a codepen where I've been able to reproduce the issue: https://codepen.io/plillian/pen/RwQegyR
Is this a just Safari bug? Or is there a way to force nearest neighbor in Safari as well?
Yes this is a Safari bug, you may want to let them know about it. For what it's worth, it's still an issue in the latest Technology Preview (Safari 15.4, WebKit 17614.1.14.10.6) where it's not even able to render every frame on time and will just "blink".
As for a workaround, the only one I can think of would be to do this all on the canvas directly, you can easily make this resizing of an ImageData by first converting it to an ImageBitmap and use drawImage().
Though to implement the scrolling behavior we'll have a bit of work to do.
One way is to use a placeholder <div> and make it act as-if we did transform our <canvas>. This way we can still use the native scrolling behavior and simply update the arguments to drawImage().
We then can stick the canvas on the top left corner of the viewport, and set it to the size of the viewport, overcoming the issue of possibly having a too big canvas.
(async () => {
const SIZE = 1024;
const X = -208.97398878415459;
const Y = 47.03519866364394;
const scale = 80;
const viewport = document.getElementById('viewport');
const wrapper = document.getElementById('wrapper');
const placeholder = document.getElementById('placeholder');
const canvas = document.getElementById('cvs');
placeholder.style.width = SIZE + 'px';
placeholder.style.height = SIZE + 'px';
const c = canvas.getContext('2d');
const pixels = new Uint8ClampedArray(4 * SIZE * SIZE);
for (let xi = 0; xi < SIZE; xi++) {
for (let yi = 0; yi < SIZE; yi++) {
const idx = (xi + yi * SIZE) * 4;
pixels[idx] = (xi << 6) % 255;
pixels[idx + 1] = (yi << 6) % 255;
pixels[idx + 3] = 255;
}
}
const pixelData = new ImageData(pixels, SIZE, SIZE);
// Convert to an ImageBitmap for ease of resizing and cropping
const bmp = await createImageBitmap(pixelData);
// We resize the canvas bitmap based on the size of the viewport
// While respecting the actual dPR (gimme crisp pixels!)
// Thanks to gman for the reminder of how to suppport all early impl.
// https://stackoverflow.com/a/65435847/3702797
const observer = new ResizeObserver(([entry]) => {
let width;
let height;
const dPR = devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
} else if (entry.contentBoxSize) {
if ( entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize * dPR;
height = entry.contentBoxSize[0].blockSize * dPR;
} else {
width = entry.contentBoxSize.inlineSize * dPR;
height = entry.contentBoxSize.blockSize * dPR;
}
} else {
width = entry.contentRect.width * dPR;
height = entry.contentRect.height * dPR;
}
canvas.width = width;
canvas.height = height;
canvas.style.width = (width / dPR) + 'px';
canvas.style.height = (height / dPR) + 'px';
c.scale(dPR, dPR);
c.imageSmoothingEnabled = false;
});
// observe the scrollbox size changes
try {
observer.observe(viewport, { box: 'device-pixel-content-box' });
}
catch(err) {
observer.observe(viewport, { box: 'content-box' });
}
function getDrawImageArgs(nodetranslate) {
const { width, height } = canvas;
const { scrollLeft, scrollTop } = viewport;
const mat = new DOMMatrix(nodetranslate).inverse();
const source = mat.transformPoint({ x: scrollLeft, y: scrollTop });
const sourceWidth = canvas.width;
const sourceHeight = canvas.height;
return [source.x, source.y, sourceWidth, sourceHeight, 0, 0, canvas.width * scale, canvas.height * scale];
}
function animate() {
const nodetranslate = `translate3D(${X}px, ${Y}px, 0px) scale(${scale})`;
wrapper.style.transform = nodetranslate;
c.clearRect(0, 0, canvas.width, canvas.height);
c.drawImage(bmp, ...getDrawImageArgs(nodetranslate));
requestAnimationFrame(animate);
}
animate();
})().catch(console.error)
body { margin: 0 }
#viewport {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
overflow: auto;
}
.sticker {
position: sticky;
top: 0;
left: 0;
height: 0px;
width: 0px;
overflow: visible;
line-height: 0;
z-index: 1;
}
canvas {
position: absolute;
}
#wrapper {
transform-origin: 0 0;
position: absolute;
}
#placeholder {
display: inline-block;
}
<script>
// Because Safari wouldn't be Safari without all its little bugs...
// See https://stackoverflow.com/a/35503829/3702797
(()=>{if(function(){const e=document.createElement("canvas").getContext("2d");e.fillRect(0,0,40,40),e.drawImage(e.canvas,-40,-40,80,80,50,50,20,20);var a=e.getImageData(50,50,30,30),r=new Uint32Array(a.data.buffer),n=(e,t)=>r[t*a.width+e];return[[9,9],[20,9],[9,20],[20,20]].some(([e,t])=>0!==n(e,t))||[[10,10],[19,10],[10,19],[19,19]].some(([e,t])=>0===n(e,t))}()){const e=CanvasRenderingContext2D.prototype,i=e.drawImage;i?e.drawImage=function(e,t,a){if(!(9===arguments.length))return i.apply(this,[...arguments]);var r,n=function(e,t,a,r,n,i,o,h,m){var{width:s,height:d}=function(t){var e=e=>{e=globalThis[e];return e&&t instanceof e};{if(e("HTMLImageElement"))return{width:t.naturalWidth,height:t.naturalHeight};if(e("HTMLVideoElement"))return{width:t.videoWidth,height:t.videoHeight};if(e("SVGImageElement"))throw new TypeError("SVGImageElement isn't yet supported as source image.","UnsupportedError");return e("HTMLCanvasElement")||e("ImageBitmap")?t:void 0}}(e);r<0&&(t+=r,r=Math.abs(r));n<0&&(a+=n,n=Math.abs(n));h<0&&(i+=h,h=Math.abs(h));m<0&&(o+=m,m=Math.abs(m));var g=Math.max(t,0),u=Math.min(t+r,s),s=Math.max(a,0),d=Math.min(a+n,d),r=h/r,n=m/n;return[e,g,s,u-g,d-s,t<0?i-t*r:i,a<0?o-a*n:o,(u-g)*r,(d-s)*n]}(...arguments);return r=n,[3,4,7,8].some(e=>!r[e])?void 0:i.apply(this,n)}:console.error("This script requires a basic implementation of drawImage")}})();
</script>
<div id="viewport">
<div class="sticker">
<!-- <canvas> isn't a void element, it must have a closing tag -->
<!-- We place it in a "sticky" element, outside of the one that gets transformed -->
<canvas id="cvs"></canvas>
</div>
<div id="wrapper">
<div id="placeholder"><!--
We'll use it as an easy way to measure what part of the canvas we should draw
based on the current scroll position.
--></div>
<div>
</div>
You can inspect the <canvas> element and see it's actually only as big as the viewport and not some 81920x81920px.
I have found this script online which i though would help me to zoom in on an image, but zoom towards the mouse position. However when the mouse is positioned to the left this isn't the case. I feel like there is a simple change I need to make however I can't find it!
window.addEventListener('load', () => {
new Vue({}).$mount('#app');
});
Vue.component('test', {
template: '#template',
data() {
return {
zoomMin: 1,
zoomMax: 7,
dragEventX: null,
dragEventY: null,
touchEvent: null,
zoomPointX: 0,
zoomPointY: 0,
zoomScale: 1,
zoomStyle: null,
frame: 1,
speed: 1,
zoom: 1,
}
},
mounted() {
this.$refs.image.addEventListener('wheel', this.onWheel);
},
methods: {
onWheel($event) {
$event.preventDefault();
let direction = Math.sign($event.deltaY);
let n = this.zoomScale - direction / (6 / this.speed);
this.setZoomScale($event.clientX, $event.clientY, n)
},
setZoomScale(clientX, clientY, n) {
const bounding = this.$refs.image.getBoundingClientRect();
let mouseLeft = clientX - bounding.left,
mouseTop = clientY - bounding.top,
zoomPointX = this.zoomPointX || 0,
zoomPointY = this.zoomPointY || 0;
let leftPoint = (mouseLeft - zoomPointX) / this.zoomScale,
topPoint = (mouseTop - zoomPointY) / this.zoomScale;
this.zoomScale = Math.min(Math.max(n, this.zoomMin), this.zoomMax);
let leftZoom = -leftPoint * this.zoomScale + mouseLeft,
topZoom = -topPoint * this.zoomScale + mouseTop;
this.setZoomPoint(leftZoom, topZoom);
},
setZoomPoint(leftZoom, topZoom) {
let left = leftZoom || this.zoomPointX || 0,
top = topZoom || this.zoomPointY || 0,
leftOffset = this.$el.clientWidth * (this.zoomScale - 1),
topOffset = this.$el.clientHeight * (this.zoomScale - 1);
this.zoomPointX = Math.min(Math.max(left, -leftOffset), 0);
this.zoomPointY = Math.min(Math.max(top, -topOffset), 0);
this.setZoomStyle();
},
setZoomStyle() {
this.zoomStyle = {
transform: `translate(${this.zoomPointX}px, ${this.zoomPointY}px) scale(${this.zoomScale})`
};
},
}
});
#app {
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 80%;
height: 80%;
overflow: hidden;
border: 1px solid #000;
}
.container img {
width: 100%;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<test></test>
</div>
<script type="text/x-template" id="template">
<div class="container">
<img ref="image" :style="zoomStyle" src="https://s3-eu-west-1.amazonaws.com/crash.net/visordown.com/field/image/2020_YAM_YZF1000R1_EU_DPBMC_STA_001-70516%20copy.jpg"></img>
</div>
</script>
You do not zoom towards the mouse on the right either; in fact, if you put your mouse in the top-left corner you zoom in to the center of the image. Besides that I can at least identify that this.zoomPointX = Math.min(Math.max(left, -leftOffset), 0); only allows your translate to go left, and thus only allow zooming towards the right of the center line.
Some debugging
Let's start with taking a step back and figuring out what we are doing here. The final styling is a translate, followed by a scale. The scale happens around the center of what you are seeing on screen right then, and the translate that happens before that is meant to move the image so the point around which you want to zoom is in the middle.
To properly debug this we need a better understanding of what the code is doing, so I first added some debug markers and disabled the zoom styling, so we can visualise what is happening without it zooming all over the place.
window.addEventListener('load', () => {
new Vue({}).$mount('#app');
});
Vue.component('test', {
template: '#template',
data() {
return {
zoomMin: 1,
zoomMax: 7,
dragEventX: null,
dragEventY: null,
touchEvent: null,
zoomPointX: 0,
zoomPointY: 0,
zoomScale: 1,
zoomStyle: null,
frame: 1,
speed: 1,
zoom: 1,
// Debugging
imageHeight: 0,
imageWidth: 0,
}
},
computed: {
markerStyle() {
return {
top: `${this.imageHeight / 2 - this.zoomPointY}px`,
left: `${this.imageWidth / 2 - this.zoomPointX}px`,
};
},
boundaryMarkerStyle() {
const middleY = this.imageHeight / 2 - this.zoomPointY;
const middleX = this.imageWidth / 2 - this.zoomPointX;
const height = this.imageHeight / this.zoomScale;
const width = this.imageWidth / this.zoomScale;
return {
top: `${middleY - height / 2}px`,
left: `${middleX - width / 2}px`,
width: `${width}px`,
height: `${height}px`,
};
},
},
mounted() {
// Moved the listener to the container so we can overlay something over the image
this.$refs.container.addEventListener("wheel", this.onWheel);
// Temporary for debugging; I could also determine it dynamically and should if we
// we want to be able to resize
this.$refs.image.addEventListener("load", () => {
const bounding = this.$refs.image.getBoundingClientRect();
this.imageHeight = bounding.height;
this.imageWidth = bounding.width;
});
},
methods: {
onWheel($event) {
$event.preventDefault();
let direction = Math.sign($event.deltaY);
let n = this.zoomScale - direction / (6 / this.speed);
this.setZoomScale($event.clientX, $event.clientY, n)
},
setZoomScale(clientX, clientY, n) {
const bounding = this.$refs.image.getBoundingClientRect();
// mouseLeft and mouseTop represent the pixel within the container we are targeting
const mouseLeft = clientX - bounding.left;
const mouseTop = clientY - bounding.top;
// zoomPointX and Y represent what point we were zooming towards
const zoomPointX = this.zoomPointX || 0;
const zoomPointY = this.zoomPointY || 0;
// This attempts to modify the point we are targeting based on
// what we are zooming towards before and what we are zooming towards now
// zoomPointX represents something that is calculated with a different zoomScale, so this
// presumably calculates bogus
const leftPoint = (mouseLeft - zoomPointX) / this.zoomScale;
const topPoint = (mouseTop - zoomPointY) / this.zoomScale;
// This normalizes the zoom so we can't zoom out past the full image and not past 7 times the current image
this.zoomScale = Math.min(Math.max(n, this.zoomMin), this.zoomMax);
// This should represent the point we are zooming towards (I think?)
const leftZoom = -leftPoint * this.zoomScale + mouseLeft;
const topZoom = -topPoint * this.zoomScale + mouseTop;
// This function breaks its promise to set only the zoom scale and also sets the zoom point. :(
this.setZoomPoint(leftZoom, topZoom);
},
setZoomPoint(leftZoom, topZoom) {
const left = leftZoom || this.zoomPointX || 0;
const top = topZoom || this.zoomPointY || 0;
const leftOffset = this.$el.clientWidth * (this.zoomScale - 1);
const topOffset = this.$el.clientHeight * (this.zoomScale - 1);
this.zoomPointX = Math.min(Math.max(left, -leftOffset), 0);
this.zoomPointY = Math.min(Math.max(top, -topOffset), 0);
this.setZoomStyle();
},
setZoomStyle() {
// this.zoomStyle = {
// transform: `translate(${this.zoomPointX}px, ${this.zoomPointY}px) scale(${this.zoomScale})`
// };
},
}
});
#app {
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 80%;
height: 80%;
overflow: hidden;
border: 1px solid #000;
position: relative;
}
.container img {
width: 100%;
}
.marker {
position: absolute;
border: 8px solid red;
z-index: 9999;
}
.boundary-marker {
position: absolute;
border: 2px dashed red;
z-index: 9999;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<test></test>
</div>
<script type="text/x-template" id="template">
<div class="container" ref="container">
<img ref="image" :style="zoomStyle" src="https://s3-eu-west-1.amazonaws.com/crash.net/visordown.com/field/image/2020_YAM_YZF1000R1_EU_DPBMC_STA_001-70516%20copy.jpg"></img>
<div class="marker" :style="markerStyle"></div>
<div class="boundary-marker" :style="boundaryMarkerStyle"></div>
</div>
</script>
Fixing the center point
First of all, lets disable the functionality to clamp the zoom point, as we know it to be broken.
this.zoomPointX = left;
this.zoomPointY = top;
Then lets focus on getting the center point right. To get this right, we need to actually determine which pixel of the original image we are targeting, taking into account we might have zoomed in already! We need to keep in mind that the translate function and the scale function always are on the original image.
We can determine the part of the image we are currently viewing with zoomPointX, zoomPointY and zoomScale. (hint: we did that for the debug marker already) zoomPointX and zoomPointY do not really represent a point we zoom towards, but more the translation we made, so I have renamed them to translateX and translateY for convenience.
The x coordinate of the pixel in the original picture we can currently see on the left side of the screen is calculated by finding the x coordinate of the middle point on the original image, then subtracting half of our viewport from it:
const leftSideX = (this.imageWidth / 2 - this.translateX) - (this.imageWidth / this.zoomScale / 2);
The number of pixels from the left side of our viewport to the point, as it would be on the original picture can be calculated by multiplying the amount of pixels this viewport represents with the percentage of pixels from the left border we are
const offsetX = (this.imageWidth / this.zoomScale) * (mouseLeft / this.imageWidth);
And then we get our translateX by calculating from the middle of the image again.
this.translateX = -(leftSideX + offsetX - (this.imageWidth / 2));
window.addEventListener('load', () => {
new Vue({}).$mount('#app');
});
Vue.component('test', {
template: '#template',
data() {
return {
zoomMin: 1,
zoomMax: 7,
dragEventX: null,
dragEventY: null,
touchEvent: null,
translateX: 0,
translateY: 0,
zoomScale: 1,
zoomStyle: null,
frame: 1,
speed: 1,
zoom: 1,
// Debugging
imageHeight: 0,
imageWidth: 0,
}
},
computed: {
markerStyle() {
return {
top: `${this.imageHeight / 2 - this.translateY}px`,
left: `${this.imageWidth / 2 - this.translateX}px`,
};
},
boundaryMarkerStyle() {
const middleY = this.imageHeight / 2 - this.translateY;
const middleX = this.imageWidth / 2 - this.translateX;
const height = this.imageHeight / this.zoomScale;
const width = this.imageWidth / this.zoomScale;
return {
top: `${middleY - height / 2}px`,
left: `${middleX - width / 2}px`,
width: `${width}px`,
height: `${height}px`,
};
},
},
mounted() {
// Moved the listener to the container so we can overlay something over the image
this.$refs.container.addEventListener("wheel", this.onWheel);
// Temporary for debugging; I could also determine it dynamically and should if we
// we want to be able to resize
this.$refs.image.addEventListener("load", () => {
const bounding = this.$refs.image.getBoundingClientRect();
this.imageHeight = bounding.height;
this.imageWidth = bounding.width;
});
},
methods: {
onWheel($event) {
$event.preventDefault();
const direction = Math.sign($event.deltaY);
const scale = this.zoomScale - direction / (6 / this.speed);
this.setZoomScale($event.clientX, $event.clientY, scale);
},
setZoomScale(clientX, clientY, scale) {
const bounding = this.$refs.image.getBoundingClientRect();
// mouseLeft and mouseTop represent the pixel within the container we are targeting
const mouseLeft = clientX - bounding.left;
const mouseTop = clientY - bounding.top;
// translateX and Y represent the translation towards the point we are zooming towards
const leftSideX =
this.imageWidth / 2 -
this.translateX -
this.imageWidth / this.zoomScale / 2;
const offsetX =
(this.imageWidth / this.zoomScale) * (mouseLeft / this.imageWidth);
this.translateX = -(leftSideX + offsetX - this.imageWidth / 2);
const leftSideY =
this.imageHeight / 2 -
this.translateY -
this.imageHeight / this.zoomScale / 2;
const offsetY =
(this.imageHeight / this.zoomScale) * (mouseTop / this.imageHeight);
this.translateY = -(leftSideY + offsetY - this.imageHeight / 2);
// This normalizes the zoom so we can't zoom out past the full image and not past 7 times the current image
this.zoomScale = Math.min(Math.max(scale, this.zoomMin), this.zoomMax);
},
}
});
#app {
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 80%;
height: 80%;
overflow: hidden;
border: 1px solid #000;
position: relative;
}
.container img {
width: 100%;
}
.marker {
position: absolute;
border: 8px solid red;
z-index: 9999;
}
.boundary-marker {
position: absolute;
border: 2px dashed red;
z-index: 9999;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<test></test>
</div>
<script type="text/x-template" id="template">
<div class="container" ref="container">
<img ref="image" :style="zoomStyle" src="https://s3-eu-west-1.amazonaws.com/crash.net/visordown.com/field/image/2020_YAM_YZF1000R1_EU_DPBMC_STA_001-70516%20copy.jpg"></img>
<div class="marker" :style="markerStyle"></div>
<div class="boundary-marker" :style="boundaryMarkerStyle"></div>
</div>
</script>
Fixing the bounds and doing cleanup
The original min-max function that we removed earlier was, I think meant to prevent you from zooming to a point where you see white on the outside of the image (ala what did happen in the original when you zoomed on the bottom right).
We can do this by clamping the value of translateX and translateY to an imaginary rectangle that is half the width/half the height from each of the relevant edges. We start by determining the height/width of our viewport (hint: we already calculated this for the marker). The center is (0, 0), while the edges are variations of (+/- imageWidth / 2, +/- imageHeight / 2).
Afterwards, we just need to clamp the value.
const viewportHeight = this.imageHeight / this.zoomScale;
const viewportWidth = this.imageWidth / this.zoomScale;
const exclusionViewportY = (this.imageHeight / 2) - (viewportHeight / 2);
const exclusionViewportX = (this.imageWidth / 2) - (viewportWidth / 2);
this.translateX = Math.min(Math.max(this.translateX, -exclusionViewportX), exclusionViewportX);
this.translateY = Math.min(Math.max(this.translateY, -exclusionViewportY), exclusionViewportY);
Finally, I took the liberty to move zoomStyle to a computed property. It saves on having to call functions in methods that have nothing to do with that method like you did. I renamed the main function to better represent what it does. I also added a beforeDestroy lifecycle hook, because your code currently leaks memory as the event handler is not removed.
window.addEventListener('load', () => {
new Vue({}).$mount('#app');
});
Vue.component('test', {
template: '#template',
data() {
return {
zoomMin: 1,
zoomMax: 7,
dragEventX: null,
dragEventY: null,
touchEvent: null,
translateX: 0,
translateY: 0,
zoomScale: 1,
frame: 1,
speed: 1,
zoom: 1,
};
},
computed: {
zoomStyle() {
return {
transform: `translate(${this.translateX}px, ${this.translateY}px) scale(${this.zoomScale})`,
};
},
},
mounted() {
// Moved the listener to the container so we can overlay something over the image
this.$refs.container.addEventListener("wheel", this.onWheel);
},
methods: {
onWheel($event) {
$event.preventDefault();
const direction = Math.sign($event.deltaY);
const scale = this.zoomScale - direction / (6 / this.speed);
this.calculateZoom($event.clientX, $event.clientY, scale);
},
calculateZoom(clientX, clientY, scale) {
const bounding = this.$refs.image.getBoundingClientRect();
// mouseLeft and mouseTop represent the pixel within the container we are targeting
const mouseLeft = clientX - bounding.left;
const mouseTop = clientY - bounding.top;
// translateX and Y represent the translation towards the point we are zooming towards
const leftSideX =
bounding.width / 2 -
this.translateX -
bounding.width / this.zoomScale / 2;
const offsetX =
(bounding.width / this.zoomScale) * (mouseLeft / bounding.width);
this.translateX = -(leftSideX + offsetX - bounding.width / 2);
const leftSideY =
bounding.height / 2 -
this.translateY -
bounding.height / this.zoomScale / 2;
const offsetY =
(bounding.height / this.zoomScale) * (mouseTop / bounding.height);
this.translateY = -(leftSideY + offsetY - bounding.height / 2);
// This normalizes the zoom so we can't zoom out past the full image and not past 7 times the current image
this.zoomScale = Math.min(Math.max(scale, this.zoomMin), this.zoomMax);
// Finally, we clamp the center point so we always stay within the image
const viewportHeight = bounding.height / this.zoomScale;
const viewportWidth = bounding.width / this.zoomScale;
const exclusionViewportY = bounding.height / 2 - viewportHeight / 2;
const exclusionViewportX = bounding.width / 2 - viewportWidth / 2;
this.translateX = Math.min(
Math.max(this.translateX, -exclusionViewportX),
exclusionViewportX
);
this.translateY = Math.min(
Math.max(this.translateY, -exclusionViewportY),
exclusionViewportY
);
},
}
});
#app {
display: flex;
justify-content: center;
align-items: center;
}
.container {
width: 80%;
height: 80%;
overflow: hidden;
border: 1px solid #000;
position: relative;
padding: 0;
margin: 0;
line-height: 0;
}
.container img {
width: 100%;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
<test></test>
</div>
<script type="text/x-template" id="template">
<div class="container" ref="container">
<img ref="image" :style="zoomStyle" src="https://s3-eu-west-1.amazonaws.com/crash.net/visordown.com/field/image/2020_YAM_YZF1000R1_EU_DPBMC_STA_001-70516%20copy.jpg"></img>
</div>
</script>
The Goal :
The idea is to create an element grid (image gallery for exemple) that would infinitely loop on itself scrolling on two axes.
There should be no holes nor too much randomness (avoid having the same element randomly falling aside from itself). And this no matter how many element there is in the first place (it seems easy to infinite loop through a grid of 16 (4*4) elements, not that much over 17 (17*1). (My guess is that any prime number of elements is by definition a pain to make a grid of).
So I actually found a wonderful working exemple :
http://www.benstockley.com/
It's actually really close (probably better) than what I was imagining. Now it's using canvas and i tried looking at the javascript and it's a 30000 minified lines long script so I really can't read any core logic behind it.
Math side / Problem solving :
This is the logic and theory behind the problem, the math involved and the mindset.
How the program should process the list of elements so we have no holes, infinite grid, best repartion of the elements over all the axes.
My guess is that it somehow has to be procedural. I'm not sure if we should create grids or loop through the list on every axes (kind of like sudoku ? i don't know);
Pratical side / UI / UX :
Any advice on the technologies involved, pieces of code. I'm guessing it classic DOM is out of the way and that somehow canvas or 2D webgl will be mandatory. But I would love to hear any advice on this side.
Besides all the elements grid processing. The UI and UX involved in exploring a 2D infinite or vast layout in DOM or renderer is somehow not classical. The best technologies or advice on doing this are welcome.
Exemples :
I would welcome any working exemple that somewhat share an aspect of this problem.
I've got a fiddle that's set up to arrange your 2d grid.
It functions by using horizontal and vertical "step sizes". So, moving one step right in the grid advances the horizontal step size in the list. Moving one step down advances the vertical step size in the list (and they accumulate).
We allow the advances in the list to loop back to zero when the end is reached.
It likely makes sense to use a horizontal step size of 1 (so a row of your grid will maintain your list order). For the vertical step size, you want an integer that shares no common divisors with the list length. Though it's no guarantee, I used the (rounded) square root of the list length as something that will work in lots of cases.
I'll reproduce the fiddle here:
var list = ['red','green','blue','cyan','orange','yellow','pink'];
var hstep = 1;
var vstep = Math.ceil(Math.sqrt(list.length));
function getListItem(x,y) {
var index = x * hstep + y * vstep;
return list[index % list.length];
}
var elementSize = 30;
var gutterSize = 10;
function getOffset(x,y) {
return [10 + (elementSize + gutterSize) * x, 10 + (elementSize + gutterSize) * y];
}
var frame = $('.frame');
function drawElement(x,y) {
var listItem = getListItem(x,y);
var offsets = getOffset(x,y);
var element = $('<div></div>').addClass('element').css({
left: offsets[0] + 'px',
top: offsets[1] + 'px',
'background-color': listItem
});
frame.append(element);
}
function drawElements() {
var x = 0, y = 0;
while (10 + (elementSize + gutterSize) * x < frame.width()) {
while (10 + (elementSize + gutterSize) * y < frame.height()) {
drawElement(x,y);
y++;
}
y = 0;
x++;
}
}
drawElements();
.frame {
border: 2px solid black;
margin: 40px auto;
height: 300px;
width: 300px;
position: relative;
overflow: hidden;
}
.frame .element {
position: absolute;
width: 30px;
height: 30px;
}
.buttons {
position: absolute;
top: 0px;
width: 100%;
}
.buttons button {
position: absolute;
width: 30px;
height: 30px;
padding: 5px;
}
button.up {top: 0px; left: 46%;}
button.down {top: 355px; left: 46%;}
button.left {top: 160px; left: 15px;}
button.right {top: 160px; right: 15px;}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div class="frame">
</div>
<div class="buttons">
<button class="up">↑</button>
<button class="down">↓</button>
<button class="left">←</button>
<button class="right">→</button>
</div>
You can see I've left some simple buttons to implement movement, but they are not functional yet. If you wanted to continue implementation along the lines of what I've done here, you could render your elements to a certain range beyond the visible frame, then implement some sort of animated repositioning. The renderElements function here only renders what is visible, so you can use something like that and not get stuck in rendering infinite elements, even though there's no theoretical limit to how far you can "scroll".
#arbuthnott I edited your code to implement the exploration via decrementing relativeX and relativeY variables. Also I inserted an "origin" div (1x1 px, overflow visible). This DOM element will represent the X and Y origin. I'm not sure it's essential but it's really convenient.
Now my function currently remove all elements and reinsert all elements on each update (every 500ms for now).
The idear would be to find a way to compare which elements I need versus which one already exists.
Maybe storing existing elements into an array, and compare the array with the "query" array. Than see just the elements that are missing.
This is the idear, not sure about the implementation (I suck at handling arrays).
https://jsfiddle.net/bnv6mumd/64/
var sources = ['red','green','blue','cyan','orange','yellow','pink','purple'];
var frame = $('.frame'),
origin = $('.origin');
var fWidth = 600,
fHeight = 300,
srcTotal = sources.length,
srcSquare = Math.ceil(Math.sqrt(srcTotal)),
rX = 0,
rY = 0;
var gridSize = 30,
gutterSize = 5,
elementSize = gridSize - gutterSize;
function getSourceItem(x,y) {
var index = x + y * srcSquare;
return sources[Math.abs(index) % srcTotal];
}
function getOffset(x,y) {
return [gridSize * x,gridSize * y];
}
function drawElement(x,y) {
var sourceItem = getSourceItem(x,y);
var offsets = getOffset(x,y);
var element = $('<div></div>').addClass('element').css({
left: offsets[0] + 'px',
top: offsets[1] + 'px',
'background-color': sourceItem,
});
origin.append(element);
}
function init() {
var x = 0, y = 0;
while ( gridSize * x < fWidth) {
while ( gridSize * y < fHeight) {
drawElement(x,y);
y++;
}
y = 0;
x++;
}
}
function updateElements() {
origin.empty();
var x = -Math.trunc(rX / gridSize) -1, y = - Math.trunc(rY / gridSize) -1;
while ( gridSize * x + rX < fWidth) {
while ( gridSize * y + rY < fHeight) {
drawElement(x,y);
y++;
}
y = -Math.ceil(rY / gridSize);
x++;
}
}
function animate() {
rX -= 5;
rY -= 5;
origin.css({left: rX, top: rY})
updateElements();
console.log("relative X : " + rX + " | relative Y : " + rY);
}
setInterval(animate, 500)
init();
.frame {
border: 2px solid black;
margin: 40px auto;
height: 300px;
width: 600px;
position: relative;
overflow: hidden;
}
.origin {
height: 1px;
width: 1px;
position: absolute;
overflow: visible;
}
.frame .element {
position: absolute;
width: 25px;
height: 25px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div class="frame">
<div class="origin" style="top:0;left:0;"></div>
</div>
This is my final snippet version (i will start to work on real implementation specific to my case now).
I think I optimized in a decent way DOM operations, code structure etc (I am very well open to suggestions though).
I now only update the elements that needs to be updated (click near the frame to show overflow)
https://jsfiddle.net/bnv6mumd/81/
var sources = ['red', 'green', 'blue', 'cyan', 'orange', 'yellow', 'pink', 'purple'];
var frame = $('.frame'),
origin = $('.origin');
var srcTotal = sources.length,
srcSquare = Math.round(Math.sqrt(srcTotal)),
fWidth = 200,
fHeight = 200,
cellSize = 50,
gutterSize = 20,
gridSize = [Math.floor(fWidth / cellSize) + 1, Math.floor(fHeight / cellSize) + 1],
aX = 0, // Absolute/Applied Coordinates
aY = 0,
rX = 0, // Relative/frame Coordinates
rY = 0;
function getSrcItem(x, y) {
var index = x + y * srcSquare;
return sources[Math.abs(index) % srcTotal];
}
function getOffset(x, y) {
return [cellSize * x, cellSize * y];
}
function getY() {
return Math.floor(-rY / cellSize);
}
function getX() {
return Math.floor(-rX / cellSize);
}
function drawElement(x, y) {
var srcItem = getSrcItem(x, y),
offsets = getOffset(x, y),
element = $('<div></div>').addClass('element').css({
left: offsets[0] + 'px',
top: offsets[1] + 'px',
'background-color': srcItem,
}).attr({
"X": x,
"Y": y
});
origin.append(element);
}
function drawCol(x, y) {
var maxY = y + gridSize[1];
while (y <= maxY + 1) {
drawElement(x - 1, y - 1);
y++;
}
}
function drawLign(x, y) {
var maxX = x + gridSize[0];
while (x <= maxX + 1) {
drawElement(x - 1, y - 1);
x++;
}
}
function drawGrid() {
origin.empty();
var x = getX(),
y = getY(),
maxX = x + gridSize[0],
maxY = y + gridSize[1];
while (y <= maxY + 1) {
drawLign(x, y);
x = getX();
y++;
}
}
function updateX(x, y, diffX, diffY) {
if (Math.sign(diffX) == -1) {
drawCol(aX - 1, y);
$('[x=' + (aX + gridSize[0]) + ']').remove();
aX--;
} else if (Math.sign(diffY) == 1) {
drawCol(aX + gridSize[0] + 2, y);
$('[x=' + (aX - 1) + ']').remove();
aX++;
}
}
function updateY(x, y, diffX, diffY) {
if (Math.sign(diffY) == -1) {
drawLign(x, aY - 1);
$('[y=' + (aY + gridSize[0]) + ']').remove();
aY--;
} else if (Math.sign(diffY) == 1) {
drawLign(x, aY + gridSize[0] + 2);
$('[y=' + (aY - 1) + ']').remove();
aY++;
}
}
function animate() {
rX += 1;
rY += 1;
origin.css({
left: rX,
top: rY
});
var x = getX(),
y = getY(),
diffX = x - aX,
diffY = y - aY;
if (diffX) {
updateX(x, y, diffX, diffY)
};
if (diffY) {
updateY(x, y, diffX, diffY)
};
requestAnimationFrame(animate);
}
$('body').click(function() {
$(frame).toggleClass("overflow");
})
drawGrid();
animate();
.frame {
border: 2px solid black;
margin: 100px auto;
height: 200px;
width: 200px;
position: relative;
}
.overflow{
overflow:hidden;
}
.origin {
height: 1px;
width: 1px;
position: absolute;
overflow: visible;
}
.frame .element {
position: absolute;
width: 30px;
height: 30px;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div class="frame overflow">
<div class="origin" style="top:0;left:0;"></div>
</div>
This question already has answers here:
How do I get the coordinates of a mouse click on a canvas element? [duplicate]
(22 answers)
Closed 3 years ago.
First, I know this question has been asked many times. However, the answers provided are not consistent and a variety of methods are used to get the mouse position. A few examples:
Method 1:
canvas.onmousemove = function (event) { // this object refers to canvas object
Mouse = {
x: event.pageX - this.offsetLeft,
y: event.pageY - this.offsetTop
}
}
Method 2:
function getMousePos(canvas, evt) {
var rect = canvas.getBoundingClientRect();
return {
x: evt.clientX - rect.left,
y: evt.clientY - rect.top
};
}
Method 3:
var findPos = function(obj) {
var curleft = curtop = 0;
if (obj.offsetParent) {
do {
curleft += obj.offsetLeft;
curtop += obj.offsetTop;
} while (obj = obj.offsetParent);
}
return { x : curleft, y : curtop };
};
Method 4:
var x;
var y;
if (e.pageX || e.pageY)
{
x = e.pageX;
y = e.pageY;
}
else {
x = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
y = e.clientY + document.body.scrollTop + document.documentElement.scrollTop;
}
x -= gCanvasElement.offsetLeft;
y -= gCanvasElement.offsetTop;
and so on.
What I am curious is which method is the most modern in terms of browser support and convenience in getting the mouse position in a canvas. Or is it those kind of things that have marginal impact and any of the above is a good choice? (Yes I realize the codes above are not exactly the same)
This seems to work. I think this is basically what K3N said.
function getRelativeMousePosition(event, target) {
target = target || event.target;
var rect = target.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
function getStyleSize(style, propName) {
return parseInt(style.getPropertyValue(propName));
}
// assumes target or event.target is canvas
function getCanvasRelativeMousePosition(event, target) {
target = target || event.target;
var pos = getRelativeMousePosition(event, target);
// you can remove this if padding is 0.
// I hope this always returns "px"
var style = window.getComputedStyle(target);
var nonContentWidthLeft = getStyleSize(style, "padding-left") +
getStyleSize(style, "border-left");
var nonContentWidthTop = getStyleSize(style, "padding-top") +
getStyleSize(style, "border-top");
var nonContentWidthRight = getStyleSize(style, "padding-right") +
getStyleSize(style, "border-right");
var nonContentWidthBottom = getStyleSize(style, "padding-bottom") +
getStyleSize(style, "border-bottom");
var rect = target.getBoundingClientRect();
var contentDisplayWidth = rect.width - nonContentWidthLeft - nonContentWidthRight;
var contentDisplayHeight = rect.height - nonContentWidthTop - nonContentWidthBottom;
pos.x = (pos.x - nonContentWidthLeft) * target.width / contentDisplayWidth;
pos.y = (pos.y - nonContentWidthTop ) * target.height / contentDisplayHeight;
return pos;
}
If you run the sample below and move the mouse over the blue area it will draw under the cursor. The border (black), padding (red), width, and height are all set to non-pixel values values. The blue area is the actual canvas pixels. The canvas's resolution is not set so it's 300x150 regardless of the size it's stretched to.
Move the mouse over the blue area and it will draw a pixel under it.
var canvas = document.querySelector("canvas");
var ctx = canvas.getContext("2d");
function clearCanvas() {
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
clearCanvas();
var posNode = document.createTextNode("");
document.querySelector("#position").appendChild(posNode);
function getRelativeMousePosition(event, target) {
target = target || event.target;
var rect = target.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
function getStyleSize(style, propName) {
return parseInt(style.getPropertyValue(propName));
}
// assumes target or event.target is canvas
function getCanvasRelativeMousePosition(event, target) {
target = target || event.target;
var pos = getRelativeMousePosition(event, target);
// you can remove this if padding is 0.
// I hope this always returns "px"
var style = window.getComputedStyle(target);
var nonContentWidthLeft = getStyleSize(style, "padding-left") +
getStyleSize(style, "border-left");
var nonContentWidthTop = getStyleSize(style, "padding-top") +
getStyleSize(style, "border-top");
var nonContentWidthRight = getStyleSize(style, "padding-right") +
getStyleSize(style, "border-right");
var nonContentWidthBottom = getStyleSize(style, "padding-bottom") +
getStyleSize(style, "border-bottom");
var rect = target.getBoundingClientRect();
var contentDisplayWidth = rect.width - nonContentWidthLeft - nonContentWidthRight;
var contentDisplayHeight = rect.height - nonContentWidthTop - nonContentWidthBottom;
pos.x = (pos.x - nonContentWidthLeft) * target.width / contentDisplayWidth;
pos.y = (pos.y - nonContentWidthTop ) * target.height / contentDisplayHeight;
return pos;
}
function handleMouseEvent(event) {
var pos = getCanvasRelativeMousePosition(event);
posNode.nodeValue = JSON.stringify(pos, null, 2);
ctx.fillStyle = "white";
ctx.fillRect(pos.x | 0, pos.y | 0, 1, 1);
}
canvas.addEventListener('mousemove', handleMouseEvent);
canvas.addEventListener('click', clearCanvas);
* {
box-sizing: border-box;
cursor: crosshair;
}
html, body {
width: 100%;
height: 100%;
color: white;
}
.outer {
background-color: green;
display: flex;
display: -webkit-flex;
-webkit-justify-content: center;
-webkit-align-content: center;
-webkit-align-items: center;
justify-content: center;
align-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.inner {
border: 1em solid black;
background-color: red;
padding: 1.5em;
width: 90%;
height: 90%;
}
#position {
position: absolute;
left: 1em;
top: 1em;
z-index: 2;
pointer-events: none;
}
<div class="outer">
<canvas class="inner"></canvas>
</div>
<pre id="position"></pre>
So, best advice?, always have the border and padding of a canvas be 0 if unless you want to go through all these steps. If the border and padding are zero you can just canvas.clientWidth and canvas.clientHeight for contentDisplayWidth and contentDisplayHeight in the example below and all the nonContextXXX values become 0.
function getRelativeMousePosition(event, target) {
target = target || event.target;
var rect = target.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
// assumes target or event.target is canvas
function getNoPaddingNoBorderCanvasRelativeMousePosition(event, target) {
target = target || event.target;
var pos = getRelativeMousePosition(event, target);
pos.x = pos.x * target.width / canvas.clientWidth;
pos.y = pos.y * target.height / canvas.clientHeight;
return pos;
}
var canvas = document.querySelector("canvas");
var ctx = canvas.getContext("2d");
function clearCanvas() {
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
clearCanvas();
var posNode = document.createTextNode("");
document.querySelector("#position").appendChild(posNode);
function getRelativeMousePosition(event, target) {
target = target || event.target;
var rect = target.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
}
// assumes target or event.target is canvas
function getNoPaddingNoBorderCanvasRelativeMousePosition(event, target) {
target = target || event.target;
var pos = getRelativeMousePosition(event, target);
pos.x = pos.x * target.width / canvas.clientWidth;
pos.y = pos.y * target.height / canvas.clientHeight;
return pos;
}
function handleMouseEvent(event) {
var pos = getNoPaddingNoBorderCanvasRelativeMousePosition(event);
posNode.nodeValue = JSON.stringify(pos, null, 2);
ctx.fillStyle = "white";
ctx.fillRect(pos.x | 0, pos.y | 0, 1, 1);
}
canvas.addEventListener('mousemove', handleMouseEvent);
canvas.addEventListener('click', clearCanvas);
* {
box-sizing: border-box;
cursor: crosshair;
}
html, body {
width: 100%;
height: 100%;
color: white;
}
.outer {
background-color: green;
display: flex;
display: -webkit-flex;
-webkit-justify-content: center;
-webkit-align-content: center;
-webkit-align-items: center;
justify-content: center;
align-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.inner {
background-color: red;
width: 90%;
height: 80%;
display: block;
}
#position {
position: absolute;
left: 1em;
top: 1em;
z-index: 2;
pointer-events: none;
}
<div class="outer">
<canvas class="inner"></canvas>
</div>
<pre id="position"></pre>
You target canvas, so you target only recent browsers.
So you can forget about the pageX stuff of Method 4.
Method 1 fails in case of nested canvas.
Method 3 is just like Method 2, but slower since you do it by hand.
-->> The way to go is option 2.
Now since you worry about performances, you don't want to call to the DOM
on each mouse move : cache the boundingRect left and top inside some var/property.
If your page allows scrolling, do not forget to handle the 'scroll' event
and to re-compute the bounding rect on scroll.
The coordinates are provided in css pixels : If you scale the Canvas with css,
be sure its border is 0 and use offsetWidth and offsetHeight to compute correct
position. Since you will want to cache also those values for performances and avoid too many globals, code will look like :
var mouse = { x:0, y:0, down:false };
function setupMouse() {
var rect = cv.getBoundingClientRect();
var rectLeft = rect.left;
var rectTop = rect.top;
var cssScaleX = cv.width / cv.offsetWidth;
var cssScaleY = cv.height / cv.offsetHeight;
function handleMouseEvent(e) {
mouse.x = (e.clientX - rectLeft) * cssScaleX;
mouse.y = (e.clientY - rectTop) * cssScaleY;
}
window.addEventListener('mousedown', function (e) {
mouse.down = true;
handleMouseEvent(e);
});
window.addEventListener('mouseup', function (e) {
mouse.down = false;
handleMouseEvent(e);
});
window.addEventListener('mouseout', function (e) {
mouse.down = false;
handleMouseEvent(e);
});
window.addEventListener('mousemove', handleMouseEvent );
};
Last word : performance testing an event handler is, to say the least, questionable, unless you can ensure that the very same moves/clicks are performed during each test. There no way to handle things faster than in the code above. Well, you might save 2 muls if you are sure canvas isn't css scaled, but anyway as of now the browser overhead for input handling is so big that it won't change a thing.
I would recommend use of getBoundingClientRect().
When the browser do a re-flow/update this method returns the position (relative to view-port) of the element.
It's widely supported cross-browser so there is really no reason not to use it IMO. Though, if you need backward-compatibility to support old browsers you should use a different method.
However, there are a few of things you need to be aware of when using this method:
Element CSS padding affects the position relative to canvas if > 0.
Element CSS border width affects the position relative to canvas if > 0.
The resulting object is static, ie. it is not updated even if for example view-port changed before you use the object.
You need to add the widths of those, top and left, to your position manually. These are included by the method which means you need to compensate for this to get the position to be relative to canvas.
If you don't use border and/or padding it's straightforward. But when you do you either need to add the absolute widths of those in pixels, or if they are unknown or dynamic, you would need to mess with getComputedStyle method and getPropertyValue to get those (these gives the size always in pixels even if the original definition of the border/padding was in a different unit).
These are values you can cache for the most part if used, unless border and padding is also changing but in most use-cases this is not the case.
You clarified that performance isn't the issue and you are correct in doing so as none of these methods you list are the real bottlenecks (but of course fully possible to measure if you know how to do performance testing). Which method you use becomes in essence a personal taste rather than a performance issue as the bottleneck lays in pushing the events themselves through the event chain.
But the most "modern" (if we define modern as newer and more convenient) is getBoundingClientRect() and avoiding border/padding on the element makes it a breeze to use.