I have square that follows my cursor.
Its border top is red to see if the rotation is right.
I'm trying to rotate it depending on mouse movement angle. Like if mouse goes 45deg top right then square must rotate by 45deg.
The problem is that when I move my mouse slowly the square starts to rotate like crazy. But if I move my mouse fast enough square rotates pretty smooth.
Actually it's just a part of my task that I'm trying to accomplish. My whole task is to make custom circle cursor that stretches when mouse moving. The idea I'm trying to implement:
rotate circle by mouse movement angle and then just scaleX it to make stretching effect. But I cannot do it because of problem I described above. I need my follower to rotate smoothly when mouse speed is slow.
class Cursor {
constructor() {
this.prevX = null;
this.prevY = null;
this.curX = null;
this.curY = null;
this.angle = null;
this.container = document.querySelector(".cursor");
this.follower = this.container.querySelector(".cursor-follower");
document.addEventListener("mousemove", (event) => {
this.curX = event.clientX;
this.curY = event.clientY;
});
this.position();
}
position(timestamp) {
this.follower.style.top = `${this.curY}px`;
this.follower.style.left = `${this.curX}px`;
this.angle = Math.atan2(this.curY - this.prevY, this.curX - this.prevX) * 180/Math.PI;
console.log(this.angle + 90);
this.follower.style.transform = `rotateZ(${this.angle + 90}deg)`;
this.prevX = this.curX;
this.prevY = this.curY;
requestAnimationFrame(this.position.bind(this));
}
}
const cursor = new Cursor();
.cursor-follower {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
pointer-events: none;
user-select: none;
width: 76px;
height: 76px;
margin: -38px;
border: 1.5px solid #000;
border-top: 1.5px solid red;
}
<div class="cursor">
<div class="cursor-follower"></div>
</div>
Following the cursor tangent smoothly isn't as simple as it first feels. In modern browsers mousemove event fires nearby at the frame rate (typically 60 FPS). When the mouse is moving slowly, the cursor moves only a pixel or two between the events. When calculating the angle, vertical + horizontal move of 1px resolves to 45deg. Then there's another problem, the event firing rate is not consistent, during the mouse is moving, event firing rate can drop to 30 FPS or even to 24 FPS, which actually helps to get more accurate angle, but makes the scale calculations heavily inaccurate (your real task seems to need scale calculations too).
One solution is to use CSS Transitions to make the animation smoother. However, adding a transition makes the angle calculations much more complex, because the jumps between negative and positive angles Math.atan2 returns when crossing PI will become visible when using transition.
Here's a sample code of how to use transition to make the cursor follower smoother.
class Follower {
// Default options
threshold = 4;
smoothness = 10;
stretchRate = 100;
stretchMax = 100;
stretchSlow = 100;
baseAngle = Math.PI / 2;
// Class initialization
initialized = false;
// Listens mousemove event
static moveCursor (e) {
if (Follower.active) {
Follower.prototype.crsrMove.call(Follower.active, e);
}
}
static active = null;
// Adds/removes mousemove listener
static init () {
if (this.initialized) {
document.removeEventListener('mousemove', this.moveCursor);
if (this.active) {
this.active.cursor.classList.add('hidden');
}
} else {
document.addEventListener('mousemove', this.moveCursor);
}
this.initialized = !this.initialized;
}
// Base values of instances
x = -1000;
y = -1000;
angle = 0;
restoreTimer = -1;
stamp = 0;
speed = [0];
// Prototype properties
constructor (selector) {
this.cursor = document.querySelector(selector);
this.restore = this.restore.bind(this);
}
// Activates a new cursor
activate (options = {}) {
// Remove the old cursor
if (Follower.active) {
Follower.active.cursor.classList.add('hidden');
Follower.active.cursor.classList.remove('cursor', 'transitioned');
}
// Set the new cursor
Object.assign(this, options);
this.setCss = this.cursor.style.setProperty.bind(this.cursor.style);
this.cursor.classList.remove('hidden');
this.cHW = this.cursor.offsetWidth / 2;
this.cHH = this.cursor.offsetHeight / 2;
this.setCss('--smoothness', this.smoothness / 100 + 's');
this.cursor.classList.add('cursor');
setTimeout(() => this.cursor.classList.add('transitioned'), 0); // Snap to the current angle
this.crsrMove({
clientX: this.x,
clientY: this.y
});
Follower.active = this;
return this;
}
// Moves the cursor with effects
crsrMove (e) {
clearTimeout(this.restoreTimer); // Cancel reset timer
const PI = Math.PI,
pi = PI / 2,
x = e.clientX,
y = e.clientY,
dX = x - this.x,
dY = y - this.y,
dist = Math.hypot(dX, dY);
let rad = this.angle + this.baseAngle,
dTime = e.timeStamp - this.stamp,
len = this.speed.length,
sSum = this.speed.reduce((a, s) => a += s),
speed = dTime
? ((1000 / dTime) * dist + sSum) / len
: this.speed[len - 1], // Old speed when dTime = 0
scale = Math.min(
this.stretchMax / 100,
Math.max(speed / (500 - this.stretchRate || 1),
this.stretchSlow / 100
)
);
// Update base values and rotation angle
if (isNaN(dTime)) {
scale = this.scale;
} // Prevents a snap of a new cursor
if (len > 5) {
this.speed.length = 1;
}
// Update angle only when mouse has moved enough from the previous update
if (dist > this.threshold) {
let angle = Math.atan2(dY, dX),
dAngle = angle - this.angle,
adAngle = Math.abs(dAngle),
cw = 0;
// Smoothen small angles
if (adAngle < PI / 90) {
angle += dAngle * 0.5;
}
// Crossing ±PI angles
if (adAngle >= 3 * pi) {
cw = -Math.sign(dAngle) * Math.sign(dX); // Rotation direction: -1 = CW, 1 = CCW
angle += cw * 2 * PI - dAngle; // Restores the current position with negated angle
// Update transform matrix without transition & rendering
this.cursor.classList.remove('transitioned');
this.setCss('--angle', `${angle + this.baseAngle}rad`);
this.cursor.offsetWidth; // Matrix isn't updated without layout recalculation
this.cursor.classList.add('transitioned');
adAngle = 0; // The angle was handled, prevent further adjusts
}
// Orthogonal mouse turns
if (adAngle >= pi && adAngle < 3 * pi) {
this.cursor.classList.remove('transitioned');
setTimeout(() => this.cursor.classList.add('transitioned'), 0);
}
rad = angle + this.baseAngle;
this.x = x;
this.y = y;
this.angle = angle;
}
this.scale = scale;
this.stamp = e.timeStamp;
this.speed.push(speed);
// Transform the cursor
this.setCss('--angle', `${rad}rad`);
this.setCss('--scale', `${scale}`);
this.setCss('--tleft', `${x - this.cHW}px`);
this.setCss('--ttop', `${y - this.cHH}px`);
// Reset the cursor when mouse stops
this.restoreTimer = setTimeout(this.restore, this.smoothness + 100, x, y);
}
// Returns the position parameters of the cursor
position () {
const {x, y, angle, scale, speed} = this;
return {x, y, angle, scale, speed};
}
// Restores the cursor
restore (x, y) {
this.state = 0;
this.setCss('--scale', 1);
this.scale = 1;
this.speed = [0];
this.x = x;
this.y = y;
}
}
Follower.init();
const crsr = new Follower('.crsr').activate();
body {
margin: 0px;
}
.crsr {
width: 76px;
height: 76px;
border: 2px solid #000;
border-radius: 0%;
text-align: center;
font-size: 20px;
}
.cursor {
position: fixed;
cursor: default;
user-select: none;
left: var(--tleft);
top: var(--ttop);
transform: rotate(var(--angle)) scaleY(var(--scale));
}
.transitioned {
transition: transform var(--smoothness) linear;
}
.hidden {
display: none;
}
<div class="crsr hidden">A</div>
The basic idea of the code is to wait until the mouse has moved enough pixels (threshold) to calculate the angle. The "mad circle" effect is tackled by setting the angle to the same position, but at the negated angle when crossing PI. This change is made invisibly between the renderings.
CSS variables are used for the actual values in transform, this allows to change a single parameter of the transform functions at the time, you don't have to rewrite the entire rule. setCss method is just syntactic sugar, it makes the code a little bit shorter.
The current parameters are showing a rectangle follower as it is in your question. Setting ex. stretchMax = 300 and stretchSlow = 125 and adding 50% border radius to CSS might be near to what you finally need. stretchRate defines the stretch related to the speed of the mouse. If the slow motion is still not smooth enough for your purposes, you can create a better algorithm to // Smoothen small angles section (in crsrMove method). You can play with the parameters at jsFiddle.
Try like this
class Cursor {
constructor() {
this.prevX = null;
this.prevY = null;
this.curX = null;
this.curY = null;
this.angle = null;
this.container = document.querySelector(".cursor");
this.follower = this.container.querySelector(".cursor-follower");
document.addEventListener("mousemove", (event) => {
this.curX = event.clientX;
this.curY = event.clientY;
});
this.position();
}
position(timestamp) {
this.follower.style.top = `${this.curY}px`;
this.follower.style.left = `${this.curX}px`;
if (this.curY !== this.prevY && this.curX !== this.prevX) {
this.angle = Math.atan2(this.curY - this.prevY, this.curX - this.prevX) * 180/Math.PI;
}
console.log(this.angle + 90);
this.follower.style.transform = `rotateZ(${this.angle + 90}deg)`;
this.prevX = this.curX;
this.prevY = this.curY;
requestAnimationFrame(this.position.bind(this));
}
}
const cursor = new Cursor();
Related
Its basicly an image, but I want to add some points with dropdowns, and its like 15 points, ajusting it with px would be very time consuming, I wonder if there is any other way around it. Thanks
<div id="map">
<div id="slide">
<img id="mapimg" src="https://i.imgur.com/NLS1KX0.jpg">
<div id="point">
<i id="pointIcon" class="fa-solid fa-location-dot"></i>
</div>
</div>
</div>
$(document).ready(function (){
var scroll_zoom = new ScrollZoom($('#map'),5,0.5)
})
//The parameters are:
//
//container: The wrapper of the element to be zoomed. The script will look for the first child of the container and apply the transforms to it.
//max_scale: The maximum scale (4 = 400% zoom)
//factor: The zoom-speed (1 = +100% zoom per mouse wheel tick)
function ScrollZoom(container,max_scale,factor){
var target = container.children().first()
var size = {w:target.width(),h:target.height()}
var pos = {x:0,y:0}
var scale = 1
var zoom_target = {x:0,y:0}
var zoom_point = {x:0,y:0}
var curr_tranform = target.css('transition')
var last_mouse_position = { x:0, y:0 }
var drag_started = 0
target.css('transform-origin','0 0')
target.on("mousewheel DOMMouseScroll",scrolled)
target.on('mousemove', moved)
target.on('mousedown', function() {
drag_started = 1;
target.css({'cursor':'move', 'transition': 'transform 0s'});
/* Save mouse position */
last_mouse_position = { x: event.pageX, y: event.pageY};
});
target.on('mouseup mouseout', function() {
drag_started = 0;
target.css({'cursor':'default', 'transition': curr_tranform});
});
function scrolled(e){
var offset = container.offset()
zoom_point.x = e.pageX - offset.left
zoom_point.y = e.pageY - offset.top
e.preventDefault();
var delta = e.delta || e.originalEvent.wheelDelta;
if (delta === undefined) {
//we are on firefox
delta = e.originalEvent.detail;
}
delta = Math.max(-1,Math.min(1,delta)) // cap the delta to [-1,1] for cross browser consistency
// determine the point on where the slide is zoomed in
zoom_target.x = (zoom_point.x - pos.x)/scale
zoom_target.y = (zoom_point.y - pos.y)/scale
// apply zoom
scale += delta * factor * scale
scale = Math.max(1,Math.min(max_scale,scale))
// calculate x and y based on zoom
pos.x = -zoom_target.x * scale + zoom_point.x
pos.y = -zoom_target.y * scale + zoom_point.y
update()
}
function moved(event){
if(drag_started == 1) {
var current_mouse_position = { x: event.pageX, y: event.pageY};
var change_x = current_mouse_position.x - last_mouse_position.x;
var change_y = current_mouse_position.y - last_mouse_position.y;
/* Save mouse position */
last_mouse_position = current_mouse_position;
//Add the position change
pos.x += change_x;
pos.y += change_y;
update()
}
}
function update(){
// Make sure the slide stays in its container area when zooming out
if(pos.x>0)
pos.x = 0
if(pos.x+size.w*scale<size.w)
pos.x = -size.w*(scale-1)
if(pos.y>0)
pos.y = 0
if(pos.y+size.h*scale<size.h)
pos.y = -size.h*(scale-1)
target.css('transform','translate('+(pos.x)+'px,'+(pos.y)+'px) scale('+scale+','+scale+')')
}
}
I tried using this, but the image is very long, and it would take to much time, I wonder if there is any easy way to do it.
#point {
position: relative;
overflow: hidden;
margin-left: 338px;
margin-top: -243px;
width: 25px;
height: 25px;
}
I tried using this, but the image is very long, and it would take to much time, I wonder if there is any easy way to do it.
#point {
position: relative;
overflow: hidden;
margin-left: 338px;
margin-top: -243px;
width: 25px;
height: 25px;
}
So I'm tyring to create a specific pointer trail effect. I'm pasting the code that I'm taking as an example below. The problem is that the trail is dotted, but I'm trying to make a line. I'm trying to recreate the trail that you can see on this site: [1]: https://argor-heraeus.com/
Example of the code that I'm using:
// dots is an array of Dot objects,
// mouse is an object used to track the X and Y position
// of the mouse, set with a mousemove event listener below
var dots = [],
mouse = {
x: 0,
y: 0
};
// The Dot object used to scaffold the dots
var Dot = function() {
this.x = 0;
this.y = 0;
this.node = (function() {
var n = document.createElement("div");
n.className = "trail";
document.body.appendChild(n);
return n;
}());
};
// The Dot.prototype.draw() method sets the position of
// the object's <div> node
Dot.prototype.draw = function() {
this.node.style.left = this.x + "px";
this.node.style.top = this.y + "px";
};
// Creates the Dot objects, populates the dots array
for (var i = 0; i < 12; i++) {
var d = new Dot();
dots.push(d);
}
// This is the screen redraw function
function draw() {
// Make sure the mouse position is set everytime
// draw() is called.
var x = mouse.x,
y = mouse.y;
// This loop is where all the 90s magic happens
dots.forEach(function(dot, index, dots) {
var nextDot = dots[index + 1] || dots[0];
dot.x = x;
dot.y = y;
dot.draw();
x += (nextDot.x - dot.x) * .6;
y += (nextDot.y - dot.y) * .6;
});
}
addEventListener("mousemove", function(event) {
//event.preventDefault();
mouse.x = event.pageX;
mouse.y = event.pageY;
});
// animate() calls draw() then recursively calls itself
// everytime the screen repaints via requestAnimationFrame().
function animate() {
draw();
requestAnimationFrame(animate);
}
// And get it started by calling animate().
animate();
body {
background-color: black;
}
.trail {
/* className for the trail elements */
position: absolute;
height: 6px;
width: 6px;
border-radius: 3px;
background: gold;
}
I'm playing with margins (left,right) , with width, but I can't "stick them" together. Anyone got an idea how I could make a clean line?
So I changed this in the given code:
x += (nextDot.x - dot.x) * **0.05**;
y += (nextDot.y - dot.y) * **0.05**;
And added more dots, 80, to be precise.
I also added a more smooth transition with this CSS rules
-webkit-transition:90ms;
transition:90ms;
pointer-events: none;
I'm creating a canvas with an overlay div to add markers on click and I want markers to change position when I pan zoom the canvas or resize the window. I'm using https://github.com/timmywil/panzoom to pan zoom.
The problem is when I convert mouse position to canvas coordinates it worked correctly but when I convert it back to screen position to render markers on overlay div, the result is not as same as initialized mouse position and recalculate marker's position on resize also not correct.
This canvas is fullscreen with no scroll.
width = 823; height = 411;
scale = 2; panX = 60; panY = 10;
mouse.pageX = 467; mouse.pageY = 144;
// {x: 475, y: 184} correct coords when I use ctx.drawImage(..) to test
canvasCoords = getCanvasCoords(mouse.pageX, mouse.pageY, scale);
// {x: 417, y: 124}
screenCoords = toScreenCoords(canvasCoords.x, canvasCoords.y, scale, panX, panY);
------------------------------
but with scale = 1; it worked correctly.
// convert mouse position to canvas coordinates
getCanvasCoords(pageX: number, pageY: number, scale: number) {
var rect = this.pdfInfo.canvas.getBoundingClientRect();
let x = (pageX - rect.left + this.scrollElement.scrollTop) / scale;
let y = (pageY - rect.top + this.scrollElement.scrollLeft) / scale;
return {
x: Number.parseInt(x.toFixed(0)),
y: Number.parseInt(y.toFixed(0)),
};
}
// convert canvas coords to screen coords
toScreenCoords(
x: number,
y: number,
scale: number
) {
var rect = this.pdfInfo.canvas.getBoundingClientRect();
let wx =
x * scale + rect.left - this.scrollElement.scrollTop / scale;
let wy =
y * scale + rect.top - this.scrollElement.scrollLeft / scale;
return {
x: Number.parseInt(wx.toFixed(0)),
y: Number.parseInt(wy.toFixed(0)),
};
}
getNewPos(x, oldV, newV) {
return (x * oldV) / newV;
}
// update screen coords with new screen width and height
onResize(old, new) {
this.screenCoordList.forEach(el => {
el.x = getNewPos(el.x, old.width, new.width);
el.y = getNewPos(el.y, old.height, new.height);
})
}
How to get it worked with scale and pan? if you know any library can do the job please recommend, thank you.
Here's a code snippet that seems to be working, you can probably adapt it for your purposes.
What I used was:
function toCanvasCoords(pageX, pageY, scale) {
var rect = canvas.getBoundingClientRect();
let x = (pageX - rect.left) / scale;
let y = (pageY - rect.top) / scale;
return toPoint(x, y);
}
and
function toScreenCoords(x, y, scale) {
var rect = canvas.getBoundingClientRect();
let wx = x * scale + rect.left + scrollElement.scrollLeft;
let wy = y * scale + rect.top + scrollElement.scrollTop;
return toPoint(wx, wy);
}
I'm just getting the mouse position from the window object. I'm may be mistaken, but I think this is why scrollLeft and scrollTop don't appear in toCanvasCoords (since the position is relative to the client area of the window itself, the scroll doesn't come into it). But then when you transform back, you have to take it into account.
This ultimately just returns the mouse position relative to the window (which was the input), so it's not really necessary to do the whole transformation in a roundabout way if you just want to attach an element to the mouse pointer. But transforming back is useful if you want to have something attached to a certain point on the canvas image (say, a to feature on the map) - which I'm guessing is something that you're going for, since you said that you want to render markers on an overlay div.
In the code snippet bellow, the red circle is drawn on the canvas itself at the location returned by toCanvasCoords; you'll notice that it scales together with the background.
I didn't use an overlay div covering the entire map, I just placed a couple of small divs on top using absolute positioning. The black triangle is a div (#tracker) that basically tracks the mouse; it is placed at the result of toScreenCoords. It serves as a way to check if the transformations work correctly. It's an independent element, so it doesn't scale with the image.
The red triangle is another such div (#feature), and demonstrates the aforementioned "attach to feature" idea. Suppose the background is a something like a map, and suppose you want to attach a "map pin" icon to something on it, like to a particular intersection; you can take that location on the map (which is a fixed value), and pass it to toScreenCoords. In the code snippet below, I've aligned it with a corner of a square on the background, so that you can track it visually as you change scale and/or scroll. (After you click "Run code snippet", you can click "Full page", and then resize the window to get the scroll bars).
Now, depending on what exactly is going on in your code, you may have tweak a few things, but hopefully, this will help you. If you run into problems, make use of console.log and/or place some debug elements on the page that will display values live for you (e.g. mouse position, client rectangle, etc.), so that you can examine values. And take things one step at the time - e.g. first get the scale to work, but ignore scrolling, then try to get scrolling to work, but keep the scale at 1, etc.
const canvas = document.getElementById('canvas');
const context = canvas.getContext("2d");
const tracker = document.getElementById('tracker');
const feature = document.getElementById('feature');
const slider = document.getElementById("scale-slider");
const scaleDisplay = document.getElementById("scale-display");
const scrollElement = document.querySelector('html');
const bgImage = new Image();
bgImage.src = "https://i.stack.imgur.com/yxtqw.jpg"
var bgImageLoaded = false;
bgImage.onload = () => { bgImageLoaded = true; };
var mousePosition = toPoint(0, 0);
var scale = 1;
function updateMousePosition(evt) {
mousePosition = toPoint(evt.clientX, evt.clientY);
}
function getScale(evt) {
scale = evt.target.value;
scaleDisplay.textContent = scale;
}
function toCanvasCoords(pageX, pageY, scale) {
var rect = canvas.getBoundingClientRect();
let x = (pageX - rect.left) / scale;
let y = (pageY - rect.top) / scale;
return toPoint(x, y);
}
function toScreenCoords(x, y, scale) {
var rect = canvas.getBoundingClientRect();
let wx = x * scale + rect.left + scrollElement.scrollLeft;
let wy = y * scale + rect.top + scrollElement.scrollTop;
return toPoint(wx, wy);
}
function toPoint(x, y) {
return { x: x, y: y }
}
function roundPoint(point) {
return {
x: Math.round(point.x),
y: Math.round(point.y)
}
}
function update() {
context.clearRect(0, 0, 500, 500);
context.save();
context.scale(scale, scale);
if (bgImageLoaded)
context.drawImage(bgImage, 0, 0);
const canvasCoords = toCanvasCoords(mousePosition.x, mousePosition.y, scale);
drawTarget(canvasCoords);
const trackerCoords = toScreenCoords(canvasCoords.x, canvasCoords.y, scale);
updateTrackerLocation(trackerCoords);
updateFeatureLocation()
context.restore();
requestAnimationFrame(update);
}
function drawTarget(location) {
context.fillStyle = "rgba(255, 128, 128, 0.8)";
context.beginPath();
context.arc(location.x, location.y, 8.5, 0, 2*Math.PI);
context.fill();
}
function updateTrackerLocation(location) {
const canvasRectangle = offsetRectangle(canvas.getBoundingClientRect(),
scrollElement.scrollLeft, scrollElement.scrollTop);
if (rectContains(canvasRectangle, location)) {
tracker.style.left = location.x + 'px';
tracker.style.top = location.y + 'px';
}
}
function updateFeatureLocation() {
// suppose the background is a map, and suppose there's a feature of interest
// (e.g. a road intersection) that you want to place the #feature div over
// (I roughly aligned it with a corner of a square).
const featureLoc = toScreenCoords(84, 85, scale);
feature.style.left = featureLoc.x + 'px';
feature.style.top = featureLoc.y + 'px';
}
function offsetRectangle(rect, offsetX, offsetY) {
// copying an object via the spread syntax or
// using Object.assign() doesn't work for some reason
const result = JSON.parse(JSON.stringify(rect));
result.left += offsetX;
result.right += offsetX;
result.top += offsetY;
result.bottom += offsetY;
result.x = result.left;
result.y = result.top;
return result;
}
function rectContains(rect, point) {
const inHorizontalRange = rect.left <= point.x && point.x <= rect.right;
const inVerticalRange = rect.top <= point.y && point.y <= rect.bottom;
return inHorizontalRange && inVerticalRange;
}
window.addEventListener('mousemove', (e) => updateMousePosition(e), false);
slider.addEventListener('input', (e) => getScale(e), false);
requestAnimationFrame(update);
#canvas {
border: 1px solid gray;
}
#tracker, #feature {
position: absolute;
left: 0;
top: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 10px solid black;
transform: translate(-4px, 0);
}
#feature {
border-bottom: 10px solid red;
}
<div>
<label for="scale-slider">Scale:</label>
<input type="range" id="scale-slider" name="scale-slider" min="0.5" max="2" step="0.02" value="1">
<span id="scale-display">1</span>
</div>
<canvas id="canvas" width="500" height="500"></canvas>
<div id="tracker"></div>
<div id="feature"></div>
P.S. Don't do Number.parseInt(x.toFixed(0)); generally, work with floating point for as long as possible to minimize accumulation of errors, and only convert to int at the last minute. I've included the roundPoint function that rounds the (x, y) coordinates of a point to the nearest integer (via Math.round), but ended up not needing to use it at all.
Note: The image below is used as the background in the code snippet, to serve as a reference point for scaling; it is included here just so that it is hosted on Stack Exchange's imgur.com account, so that the code is not referencing a (potentially volatile) 3rd-pary source.
I have a case where I want to draw 3 arc lines and erase them.
First Arc CA should be drawn progressively and then it should be erased progressively. Then arc AB should be drawn and erased and then arc BC should do the same. And then repeat.
My approach:
Using canvas and JS:
I started with canvas, but the anti-aliasing does not effect here. So I thought may be SVG will be better.
var currentEndAngle = 0;
var currentStartAngle = 0;
var currentColor = 'black';
var lineRadius = 300;
var lineWidth = 5;
setInterval(draw, 5);
function draw() {
var can = document.getElementById('canvas1'); // GET LE CANVAS
var canvas = document.getElementById("canvas1");
var context = canvas.getContext("2d");
var x = canvas.width / 2;
var y = canvas.height / 2;
var radius;
var width;
var startAngle = currentStartAngle * Math.PI;
var endAngle = (currentEndAngle) * Math.PI;
currentStartAngle = currentEndAngle - 0.01;
currentEndAngle = currentEndAngle + 0.01;
if (Math.floor(currentStartAngle / 2) % 2) {
currentColor = "white";
radius = lineRadius - 1;
width = lineWidth + 3;
} else {
currentColor = "black";
radius = lineRadius;
width = lineWidth;
}
var counterClockwise = false;
context.beginPath();
context.arc(x, y, radius, startAngle, endAngle, counterClockwise);
context.lineWidth = width;
// line color
context.strokeStyle = currentColor;
context.stroke();
/************************************************/
}
body {
text-align: center;
background: blue;
}
#canvas1 {
width: 500px;
height: 500px;
margin: 0 auto;
}
<canvas id="canvas1" width="700" height="700"></canvas>
Using SVG and CSS
The SVG approach looks smoother. But I don't understand how I can modify the dasharray, dashoffset and radius of circle to get 3 arcs animating.
circle {
fill: transparent;
stroke: black;
stroke-width: 2;
stroke-dasharray: 250;
stroke-dashoffset: 0;
animation: rotate 5s linear infinite;
}
#keyframes rotate {
0% {
stroke-dashoffset: 500;
}
100% {
stroke-dashoffset: 0;
}
}
<svg height="400" width="400">
<circle cx="100" cy="100" r="40" />
</svg>
So if anyone can help me extend the code or give guidance on how I can create three arcs from the svg circle and how the dasharray, dashoffset and radius should be set?
In case you have a better solution then the above 2 approaches then please let me know.
I have also tried to use the drawsvg plugin from GSAP and I guess that might be easier but I am not allowed to use the 'drawsvg' plugin for my project.
For the canvas version, as stated in comments, your antialiasing problem is that you are redrawing over and over on the same pixels.
To avoid this, clear your whole canvas every frame and redraw everything.
For your requested animation, you would have to store both your start angle and your end angle. Then you'll increment one after the other, while swithing when you've passed the division size threshold.
Here is an annotated snippet that will make things more clear I hope.
// settings
var divisions = 3;
var duration = 3000; // in ms
var canvas = document.getElementById("canvas1");
var context = canvas.getContext("2d");
var x = canvas.width / 2;
var y = canvas.height / 2;
var radius = (canvas.width / 7) * 2;
context.lineWidth = 4;
// init
var currentSplit = 0;
var splitAngle = (Math.PI * 2) / divisions;
var splitTime = (duration / (divisions*2)); // how much time per split per end
var angles = [0,0]; // here we store both start and end angle
var current = 0;
var startTime = performance.now();
draw();
function draw(currentTime) {
// first convert the elapsed time to an angle
var timedAngle = ((currentTime - startTime) / splitTime) * splitAngle;
// set the current end to this timed angle + the current position on the circle
angles[current] = timedAngle + (splitAngle * currentSplit);
if (timedAngle >= splitAngle) { // one split is done for this end
// it should not go farther than the threshold
angles[current] = (splitAngle * (currentSplit + 1));
current = +(!current) // switch which end should move
startTime = currentTime; // reset the timer
if(!current){ // we go back to the start
currentSplit = (currentSplit + 1) % divisions; // increment our split index
}
}
if(angles[1] > Math.PI*2){ // we finished one complete revolution
angles[0] = angles[1] = current = 0; // reset everything
}
// at every frame we clear everything
context.clearRect(0, 0, canvas.width, canvas.height);
// and redraw
context.beginPath();
context.arc(x, y, radius, angles[0], angles[1], true);
context.stroke();
requestAnimationFrame(draw); // loop at screen refresh rate
}
body {
text-align: center;
}
#canvas1 {
width: 250px;
height: 150px;
}
<canvas id="canvas1" width="500" height="300"></canvas>
You don't really want to modify stroke-dashoffset, because that just shifts the dash patter around the circle.
You have to modify the dash array values anyway, so you might as well just do it all by animating the values in the dash array.
Your circle has radius 40, so the circumference is 251.33. Meaning that each of your three arc has a length of 83.78.
For each of the three stages, we grow the "on" part of the dash from 0 to 83.78. Then we shrink it back down again, while simultaneously growing the previous gap from 83.78 to 167.55. That is so that the tail gets pushed around to the end.
That works for the first two steps, but since the dash pattern starts and ends at the 3 o'clock position (and doesn't wrap through that point), we have to do the tail push for the last stage by using an extra empty dash pair at the start. We grow the gap on that one from 0 to 83.78 instead.
circle {
fill: transparent;
stroke: black;
stroke-width: 2;
animation: rotate 5s linear infinite;
}
#keyframes rotate {
0% { stroke-dasharray: 0 0 0 83.78 0 83.78 0 83.78; }
16.7% { stroke-dasharray: 0 0 0 83.78 83.78 0 0 83.78; }
33.3% { stroke-dasharray: 0 0 0 167.55 0 0 0 83.78; }
50% { stroke-dasharray: 0 0 0 83.78 0 83.78 83.78 0; }
66.6% { stroke-dasharray: 0 0 0 83.78 0 167.55 0 0; }
83.3% { stroke-dasharray: 0 0 83.78 0 0 83.78 0 83.78; }
100% { stroke-dasharray: 0 83.78 0 0 0 83.78 0 83.78; }
}
<svg height="400" width="400">
<circle cx="100" cy="100" r="40" />
</svg>
Javascript extends HTML
Canvas, (or CSS, HTML, SVG) combined with javascript always wins out over CSS, SVG, HTML alone because Javascript is far more adaptable. HTML, CSS and SVG are declarative languages, while JavaScript is a fully functional imperative language that can do anything any other programing language can do.
You use javascript to add to the HTML, CSS, SVG functionality, effectively declaring new behaviour for these languages.
Once you have defined the Javascript functionality you can forget about the javascript and use the HTML, CSS, or SVG calling upon the new behaviours as needed.
In this case all elements with the class name "segmentedProgress" will become an animated progress. You can set up as many properties as you like to control the behaviour and add them to the element's data attribute.
eg
<div class="segmentedProgress"></div>
<!-- showing defaults as above element will be setup -->
<div class="segmentedProgress"
data-angle-steps = 3 <!-- number of segments. (integers only) -->
data-speed = 1000 <!-- Time per segment in ms -->
data-easing = "1.2" <!-- easing power -->
data-line-width = "0.1" <!-- as fraction of radius -->
data-radial-size = "0.33" <!-- as fraction of shortest dimension -->
data-color = "black" <!-- colour of line -->
></div>
As long as the Javascript has been included the progress will automatically appear on the page for each element that is correctly configured. If you have your server setup to recognise page content dependencies then the above is all you need to do to add the behaviour to the page as the server will add what is needed to make it run.
The javascript
It does not take much javascript to implement. You find all the elements that have the appropriate class name and add them to an array of progress items. Then animate them as needed.
document.addEventListener("load", function(){
var elements = [...document.body.querySelectorAll(".segmentedProgress")];
if(elements.length === 0){ // exit if nothing found
return;
}
// singleton to isolate from onload
(function(){
const error = 0.01; // Math too perfect causes zero len arc to draw nothing. Error makes sure there is always some length in the drawn arc
const items = []; // array of progress items
// each progress item defaults
var defaults = {
angleSteps : 3, // number of segments. (integers only)
speed : 1000, // Time per segment in ms
easing : 1.2, // easing power where 1 = no easing 2 = normal quadratic easing 1/2= inverse quadratic easing
lineWidth : 0.1, // as fraction of radius
radialSize : 0.33,// as fraction of shortest dimension
color : "black", // colour of line
complete : false, // not used
resize () { // resize the canvas and set size dependent vars
this.bounds = this.element.getBoundingClientRect();
this.w = this.canvas.width = this.bounds.width;
this.h = this.canvas.height = this.bounds.height;
this.canvas.style.top = (this.bounds.top + scrollY) + "px";
this.canvas.style.left = (this.bounds.left + scrollX) + "px";
this.pos = { x : this.w / 2, y : this.h / 2}; // position of circle
this.radius = Math.min(this.w, this.h) * this.radialSize; // radius of circle
// set canvas state constants
this.ctx.lineCap = "round";
},
update (time) { // updates and renders
var segStart, segProgress, pp, ctx, ang;
ctx = this.ctx; // alias to this.ctx
// clear the canvas
ctx.clearRect(0, 0, this.w, this.h);
// get current selment angle
ang = Math.PI * 2 / this.angleSteps, // Radians per segment
// set the time at the correct speed
time /= this.speed;
// get the segment start position in radians
segStart = Math.floor(time % this.angleSteps) * ang;
// get the unit progress of this stage doubled for grow and shrink stages
var segProgress = (time % 1) * 2;
var pp = segProgress % 1; // pp partial progress
pp = (pp ** this.easing) / ((pp ** this.easing) + (1 - pp) ** this.easing); // add some easing
ctx.beginPath();
// first half of progress is growth
if(segProgress <= 1){
ctx.arc(this.pos.x, this.pos.y, this.radius, segStart, segStart + pp * ang + error);
}else{
// second half of progress is shrink
ctx.arc(this.pos.x, this.pos.y, this.radius, segStart + pp * ang - error, segStart + ang);
}
ctx.strokeStyle = this.color;
ctx.lineWidth = this.radius * this.lineWidth;
ctx.stroke();
}
}
// create prgress item for each found element
elements.forEach(element => {
var pItem = {...defaults}; // progress item
pItem.element = element;
// get any element setting that overwrite the defaults
Object.keys(defaults).forEach(key => {
if(typeof defaults[key] !== "function"){
if(element.dataset[key] !== undefined){
pItem[key] = element.dataset[key];
if(! isNaN(element.dataset[key])){
pItem[key] = Number(pItem[key]);
}
}
}
});
pItem.canvas = document.createElement("canvas");
pItem.ctx = pItem.canvas.getContext("2d");
pItem.canvas.style.position = "absolute";
pItem.resize();
items.push(pItem);
element.appendChild(pItem.canvas);
});
elements.length = 0; // let go of elements
// change size on resize
window.addEventListener("resize", () =>{
items.forEach(pItem => pItem.resize());
});
// start the animation
requestAnimationFrame(update);
// main update loop
function update (time) {
items.forEach(pItem => {
pItem.update(time);
});
requestAnimationFrame(update);
}
}());
}());
As a demo
//document.addEventListener("load",()=>{
;(function(){
var elements = [...document.body.querySelectorAll(".segmentedProgress")];
if (elements.length === 0) { return }
(function () {
const error = 0.001; // Math too perfect causes zero len arc to draw nothing. Error makes sure there is always some length in the drawn arc
const items = []; // array of progress items
var defaults = {
angleSteps : 3, // number of segments. (integers only)
speed : 1000, // Time per segment in ms
easing : 1.2, // easing power where 1 = no easing 2 = normal quadratic easing 1/2= inverse quadratic easing
lineWidth : 0.1, // as fraction of radius
radialSize : 0.33,// as fraction of shortest dimension
color : "black", // colour of line
complete : false, // not used
resize () { // resize the canvas and set size dependent vars
this.bounds = this.element.getBoundingClientRect();
this.w = this.canvas.width = this.bounds.width;
this.h = this.canvas.height = this.bounds.height;
this.canvas.style.top = (this.bounds.top + scrollY) + "px";
this.canvas.style.left = (this.bounds.left + scrollX) + "px";
this.pos = { x : this.w / 2, y : this.h / 2}; // position of circle
this.radius = Math.min(this.w, this.h) * this.radialSize; // radius of circle
this.ctx.lineCap = "round";
},
update (time) { // updates and renders
var segStart, segProgress, pp, ctx, ang;
ctx = this.ctx; // alias to this.ctx
ctx.clearRect(0, 0, this.w, this.h);
ang = Math.PI * 2 / this.angleSteps, // Radians per segment
time /= this.speed;
segStart = Math.floor(time % this.angleSteps) * ang;
var segProgress = (time % 1) * 2;
var pp = segProgress % 1; // pp partial progress
// babel can not handle the following line even though most
// browsers can
// pp = (pp ** this.easing) / ((pp ** this.easing) + (1 - pp) ** this.easing); // add some easing
// to cover babel error
pp = Math.pow(pp,this.easing) / (Math.pow(pp,this.easing) + Math.pow(1 - pp, this.easing)); // add some easing
ctx.beginPath();
if(segProgress <= 1){
ctx.arc(this.pos.x, this.pos.y, this.radius, segStart, segStart + pp * ang + error);
}else{
ctx.arc(this.pos.x, this.pos.y, this.radius, segStart + pp * ang - error, segStart + ang);
}
ctx.strokeStyle = this.color;
ctx.lineWidth = this.radius * this.lineWidth;
ctx.stroke();
}
}
elements.forEach(element => {
var pItem = {...defaults}; // progress item
pItem.element = element;
Object.keys(defaults).forEach(key => {
if(typeof defaults[key] !== "function"){
if(element.dataset[key] !== undefined){
pItem[key] = element.dataset[key];
if(! isNaN(element.dataset[key])){
pItem[key] = Number(pItem[key]);
}
}
}
});
pItem.canvas = document.createElement("canvas");
pItem.ctx = pItem.canvas.getContext("2d");
pItem.canvas.style.position = "absolute";
pItem.resize();
items.push(pItem);
element.appendChild(pItem.canvas);
});
elements.length = 0;
window.addEventListener("resize", () =>{ items.forEach(pItem => pItem.resize()) });
requestAnimationFrame(update);
function update (time) {
items.forEach(pItem => { pItem.update(time) });
requestAnimationFrame(update);
}
}());
}());
.segmentedProgress {
width : 100px;
height : 100px;
}
.big {
width : 200px;
height : 200px;
}
.large {
width : 512px;
height : 512px;
background : #4AF;
}
4 segment fast.
<div class="segmentedProgress" data-color="red" data-speed ="250" data-line-width="0.3" data-angle-steps=4 ></div>
Default Progress
<div class="segmentedProgress" ></div>
Big progress
<div class="big segmentedProgress" data-color="blue" data-speed ="2500" data-line-width="0.3" data-angle-steps=2 ></div>
60 Seconds two overlap
<div class="large segmentedProgress" data-color="white" data-speed ="1000" data-line-width="0.02" data-angle-steps=60 >
<div class="large segmentedProgress" data-color="white" data-speed ="1000" data-line-width="0.02" data-angle-steps=2 data-radial-size = "0.34">
</div>
I want to be able to zoom in on the point under the mouse in an HTML 5 canvas, like zooming on Google Maps. How can I achieve that?
The better solution is to simply move the position of the viewport based on the change in the zoom. The zoom point is simply the point in the old zoom and the new zoom that you want to remain the same. Which is to say the viewport pre-zoomed and the viewport post-zoomed have the same zoompoint relative to the viewport. Given that we're scaling relative to the origin. You can adjust the viewport position accordingly:
scalechange = newscale - oldscale;
offsetX = -(zoomPointX * scalechange);
offsetY = -(zoomPointY * scalechange);
So really you can just pan over down and to the right when you zoom in, by a factor of how much you zoomed in, relative to the point you zoomed at.
Finally solved it:
const zoomIntensity = 0.2;
const canvas = document.getElementById("canvas");
let context = canvas.getContext("2d");
const width = 600;
const height = 200;
let scale = 1;
let originx = 0;
let originy = 0;
let visibleWidth = width;
let visibleHeight = height;
function draw(){
// Clear screen to white.
context.fillStyle = "white";
context.fillRect(originx, originy, width/scale, height/scale);
// Draw the black square.
context.fillStyle = "black";
context.fillRect(50, 50, 100, 100);
// Schedule the redraw for the next display refresh.
window.requestAnimationFrame(draw);
}
// Begin the animation loop.
draw();
canvas.onwheel = function (event){
event.preventDefault();
// Get mouse offset.
const mousex = event.clientX - canvas.offsetLeft;
const mousey = event.clientY - canvas.offsetTop;
// Normalize mouse wheel movement to +1 or -1 to avoid unusual jumps.
const wheel = event.deltaY < 0 ? 1 : -1;
// Compute zoom factor.
const zoom = Math.exp(wheel * zoomIntensity);
// Translate so the visible origin is at the context's origin.
context.translate(originx, originy);
// Compute the new visible origin. Originally the mouse is at a
// distance mouse/scale from the corner, we want the point under
// the mouse to remain in the same place after the zoom, but this
// is at mouse/new_scale away from the corner. Therefore we need to
// shift the origin (coordinates of the corner) to account for this.
originx -= mousex/(scale*zoom) - mousex/scale;
originy -= mousey/(scale*zoom) - mousey/scale;
// Scale it (centered around the origin due to the translate above).
context.scale(zoom, zoom);
// Offset the visible origin to it's proper position.
context.translate(-originx, -originy);
// Update scale and others.
scale *= zoom;
visibleWidth = width / scale;
visibleHeight = height / scale;
}
<canvas id="canvas" width="600" height="200"></canvas>
The key, as #Tatarize pointed out, is to compute the axis position such that the zoom point (mouse pointer) remains in the same place after the zoom.
Originally the mouse is at a distance mouse/scale from the corner, we want the point under the mouse to remain in the same place after the zoom, but this is at mouse/new_scale away from the corner. Therefore we need to shift the origin (coordinates of the corner) to account for this.
originx -= mousex/(scale*zoom) - mousex/scale;
originy -= mousey/(scale*zoom) - mousey/scale;
scale *= zoom
The remaining code then needs to apply the scaling and translate to the draw context so it's origin coincides with the canvas corner.
This is actually a very difficult problem (mathematically), and I'm working on the same thing almost. I asked a similar question on Stackoverflow but got no response, but posted in DocType (StackOverflow for HTML/CSS) and got a response. Check it out http://doctype.com/javascript-image-zoom-css3-transforms-calculate-origin-example
I'm in the middle of building a jQuery plugin that does this (Google Maps style zoom using CSS3 Transforms). I've got the zoom to mouse cursor bit working fine, still trying to figure out how to allow the user to drag the canvas around like you can do in Google Maps. When I get it working I'll post code here, but check out above link for the mouse-zoom-to-point part.
I didn't realise there was scale and translate methods on Canvas context, you can achieve the same thing using CSS3 eg. using jQuery:
$('div.canvasContainer > canvas')
.css('transform', 'scale(1) translate(0px, 0px)');
Make sure you set the CSS3 transform-origin to 0, 0 (transform-origin: 0 0). Using CSS3 transform allows you to zoom in on anything, just make sure the container DIV is set to overflow: hidden to stop the zoomed edges spilling out of the sides.
Whether you use CSS3 transforms, or canvas' own scale and translate methods is up to you, but check the above link for the calculations.
Update: Meh! I'll just post the code here rather than get you to follow a link:
$(document).ready(function()
{
var scale = 1; // scale of the image
var xLast = 0; // last x location on the screen
var yLast = 0; // last y location on the screen
var xImage = 0; // last x location on the image
var yImage = 0; // last y location on the image
// if mousewheel is moved
$("#mosaicContainer").mousewheel(function(e, delta)
{
// find current location on screen
var xScreen = e.pageX - $(this).offset().left;
var yScreen = e.pageY - $(this).offset().top;
// find current location on the image at the current scale
xImage = xImage + ((xScreen - xLast) / scale);
yImage = yImage + ((yScreen - yLast) / scale);
// determine the new scale
if (delta > 0)
{
scale *= 2;
}
else
{
scale /= 2;
}
scale = scale < 1 ? 1 : (scale > 64 ? 64 : scale);
// determine the location on the screen at the new scale
var xNew = (xScreen - xImage) / scale;
var yNew = (yScreen - yImage) / scale;
// save the current screen location
xLast = xScreen;
yLast = yScreen;
// redraw
$(this).find('div').css('transform', 'scale(' + scale + ')' + 'translate(' + xNew + 'px, ' + yNew + 'px' + ')')
.css('transform-origin', xImage + 'px ' + yImage + 'px')
return false;
});
});
You will of course need to adapt it to use the canvas scale and translate methods.
I like Tatarize's answer, but I'll provide an alternative. This is a trivial linear algebra problem, and the method I present works well with pan, zoom, skew, etc. That is, it works well if your image is already transformed.
When a matrix is scaled, the scale is at point (0, 0). So, if you have an image and scale it by a factor of 2, the bottom-right point will double in both the x and y directions (using the convention that [0, 0] is the top-left of the image).
If instead you would like to zoom the image about the center, then a solution is as follows: (1) translate the image such that its center is at (0, 0); (2) scale the image by x and y factors; (3) translate the image back. i.e.
myMatrix
.translate(image.width / 2, image.height / 2) // 3
.scale(xFactor, yFactor) // 2
.translate(-image.width / 2, -image.height / 2); // 1
More abstractly, the same strategy works for any point. If, for example, you want to scale the image at a point P:
myMatrix
.translate(P.x, P.y)
.scale(xFactor, yFactor)
.translate(-P.x, -P.y);
And lastly, if the image is already transformed in some manner (for example, if it's rotated, skewed, translated, or scaled), then the current transformation needs to be preserved. Specifically, the transform defined above needs to be post-multiplied (or right-multiplied) by the current transform.
myMatrix
.translate(P.x, P.y)
.scale(xFactor, yFactor)
.translate(-P.x, -P.y)
.multiply(myMatrix);
There you have it. Here's a plunk that shows this in action. Scroll with the mousewheel on the dots and you'll see that they consistently stay put. (Tested in Chrome only.) http://plnkr.co/edit/3aqsWHPLlSXJ9JCcJzgH?p=preview
I ran into this problem using c++, which I probably shouldn't have had i just used OpenGL matrices to begin with...anyways, if you're using a control whose origin is the top left corner, and you want pan/zoom like google maps, here's the layout (using allegro as my event handler):
// initialize
double originx = 0; // or whatever its base offset is
double originy = 0; // or whatever its base offset is
double zoom = 1;
.
.
.
main(){
// ...set up your window with whatever
// tool you want, load resources, etc
.
.
.
while (running){
/* Pan */
/* Left button scrolls. */
if (mouse == 1) {
// get the translation (in window coordinates)
double scroll_x = event.mouse.dx; // (x2-x1)
double scroll_y = event.mouse.dy; // (y2-y1)
// Translate the origin of the element (in window coordinates)
originx += scroll_x;
originy += scroll_y;
}
/* Zoom */
/* Mouse wheel zooms */
if (event.mouse.dz!=0){
// Get the position of the mouse with respect to
// the origin of the map (or image or whatever).
// Let us call these the map coordinates
double mouse_x = event.mouse.x - originx;
double mouse_y = event.mouse.y - originy;
lastzoom = zoom;
// your zoom function
zoom += event.mouse.dz * 0.3 * zoom;
// Get the position of the mouse
// in map coordinates after scaling
double newx = mouse_x * (zoom/lastzoom);
double newy = mouse_y * (zoom/lastzoom);
// reverse the translation caused by scaling
originx += mouse_x - newx;
originy += mouse_y - newy;
}
}
}
.
.
.
draw(originx,originy,zoom){
// NOTE:The following is pseudocode
// the point is that this method applies so long as
// your object scales around its top-left corner
// when you multiply it by zoom without applying a translation.
// draw your object by first scaling...
object.width = object.width * zoom;
object.height = object.height * zoom;
// then translating...
object.X = originx;
object.Y = originy;
}
Here's my solution for a center-oriented image:
var MIN_SCALE = 1;
var MAX_SCALE = 5;
var scale = MIN_SCALE;
var offsetX = 0;
var offsetY = 0;
var $image = $('#myImage');
var $container = $('#container');
var areaWidth = $container.width();
var areaHeight = $container.height();
$container.on('wheel', function(event) {
event.preventDefault();
var clientX = event.originalEvent.pageX - $container.offset().left;
var clientY = event.originalEvent.pageY - $container.offset().top;
var nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale - event.originalEvent.deltaY / 100));
var percentXInCurrentBox = clientX / areaWidth;
var percentYInCurrentBox = clientY / areaHeight;
var currentBoxWidth = areaWidth / scale;
var currentBoxHeight = areaHeight / scale;
var nextBoxWidth = areaWidth / nextScale;
var nextBoxHeight = areaHeight / nextScale;
var deltaX = (nextBoxWidth - currentBoxWidth) * (percentXInCurrentBox - 0.5);
var deltaY = (nextBoxHeight - currentBoxHeight) * (percentYInCurrentBox - 0.5);
var nextOffsetX = offsetX - deltaX;
var nextOffsetY = offsetY - deltaY;
$image.css({
transform : 'scale(' + nextScale + ')',
left : -1 * nextOffsetX * nextScale,
right : nextOffsetX * nextScale,
top : -1 * nextOffsetY * nextScale,
bottom : nextOffsetY * nextScale
});
offsetX = nextOffsetX;
offsetY = nextOffsetY;
scale = nextScale;
});
body {
background-color: orange;
}
#container {
margin: 30px;
width: 500px;
height: 500px;
background-color: white;
position: relative;
overflow: hidden;
}
img {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
max-width: 100%;
max-height: 100%;
margin: auto;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<div id="container">
<img id="myImage" src="https://via.placeholder.com/300">
</div>
Here's an alternate way to do it that uses setTransform() instead of scale() and translate(). Everything is stored in the same object. The canvas is assumed to be at 0,0 on the page, otherwise you'll need to subtract its position from the page coords.
this.zoomIn = function (pageX, pageY) {
var zoomFactor = 1.1;
this.scale = this.scale * zoomFactor;
this.lastTranslation = {
x: pageX - (pageX - this.lastTranslation.x) * zoomFactor,
y: pageY - (pageY - this.lastTranslation.y) * zoomFactor
};
this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
this.lastTranslation.x,
this.lastTranslation.y);
};
this.zoomOut = function (pageX, pageY) {
var zoomFactor = 1.1;
this.scale = this.scale / zoomFactor;
this.lastTranslation = {
x: pageX - (pageX - this.lastTranslation.x) / zoomFactor,
y: pageY - (pageY - this.lastTranslation.y) / zoomFactor
};
this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
this.lastTranslation.x,
this.lastTranslation.y);
};
Accompanying code to handle panning:
this.startPan = function (pageX, pageY) {
this.startTranslation = {
x: pageX - this.lastTranslation.x,
y: pageY - this.lastTranslation.y
};
};
this.continuePan = function (pageX, pageY) {
var newTranslation = {x: pageX - this.startTranslation.x,
y: pageY - this.startTranslation.y};
this.canvasContext.setTransform(this.scale, 0, 0, this.scale,
newTranslation.x, newTranslation.y);
};
this.endPan = function (pageX, pageY) {
this.lastTranslation = {
x: pageX - this.startTranslation.x,
y: pageY - this.startTranslation.y
};
};
To derive the answer yourself, consider that the same page coordinates need to match the same canvas coordinates before and after the zoom. Then you can do some algebra starting from this equation:
(pageCoords - translation) / scale = canvasCoords
if(wheel > 0) {
this.scale *= 1.1;
this.offsetX -= (mouseX - this.offsetX) * (1.1 - 1);
this.offsetY -= (mouseY - this.offsetY) * (1.1 - 1);
}
else {
this.scale *= 1/1.1;
this.offsetX -= (mouseX - this.offsetX) * (1/1.1 - 1);
this.offsetY -= (mouseY - this.offsetY) * (1/1.1 - 1);
}
I want to put here some information for those, who do separately drawing of picture and moving -zooming it.
This may be useful when you want to store zooms and position of viewport.
Here is drawer:
function redraw_ctx(){
self.ctx.clearRect(0,0,canvas_width, canvas_height)
self.ctx.save()
self.ctx.scale(self.data.zoom, self.data.zoom) //
self.ctx.translate(self.data.position.left, self.data.position.top) // position second
// Here We draw useful scene My task - image:
self.ctx.drawImage(self.img ,0,0) // position 0,0 - we already prepared
self.ctx.restore(); // Restore!!!
}
Notice scale MUST be first.
And here is zoomer:
function zoom(zf, px, py){
// zf - is a zoom factor, which in my case was one of (0.1, -0.1)
// px, py coordinates - is point within canvas
// eg. px = evt.clientX - canvas.offset().left
// py = evt.clientY - canvas.offset().top
var z = self.data.zoom;
var x = self.data.position.left;
var y = self.data.position.top;
var nz = z + zf; // getting new zoom
var K = (z*z + z*zf) // putting some magic
var nx = x - ( (px*zf) / K );
var ny = y - ( (py*zf) / K);
self.data.position.left = nx; // renew positions
self.data.position.top = ny;
self.data.zoom = nz; // ... and zoom
self.redraw_ctx(); // redraw context
}
and, of course, we would need a dragger:
this.my_cont.mousemove(function(evt){
if (is_drag){
var cur_pos = {x: evt.clientX - off.left,
y: evt.clientY - off.top}
var diff = {x: cur_pos.x - old_pos.x,
y: cur_pos.y - old_pos.y}
self.data.position.left += (diff.x / self.data.zoom); // we want to move the point of cursor strictly
self.data.position.top += (diff.y / self.data.zoom);
old_pos = cur_pos;
self.redraw_ctx();
}
})
Here's a code implementation of #tatarize's answer, using PIXI.js. I have a viewport looking at part of a very big image (e.g. google maps style).
$canvasContainer.on('wheel', function (ev) {
var scaleDelta = 0.02;
var currentScale = imageContainer.scale.x;
var nextScale = currentScale + scaleDelta;
var offsetX = -(mousePosOnImage.x * scaleDelta);
var offsetY = -(mousePosOnImage.y * scaleDelta);
imageContainer.position.x += offsetX;
imageContainer.position.y += offsetY;
imageContainer.scale.set(nextScale);
renderer.render(stage);
});
$canvasContainer is my html container.
imageContainer is my PIXI container that has the image in it.
mousePosOnImage is the mouse position relative to the entire image (not just the view port).
Here's how I got the mouse position:
imageContainer.on('mousemove', _.bind(function(ev) {
mousePosOnImage = ev.data.getLocalPosition(imageContainer);
mousePosOnViewport.x = ev.data.originalEvent.offsetX;
mousePosOnViewport.y = ev.data.originalEvent.offsetY;
},self));
You need to get the point in world space (opposed to screen space) before and after zooming, and then translate by the delta.
mouse_world_position = to_world_position(mouse_screen_position);
zoom();
mouse_world_position_new = to_world_position(mouse_screen_position);
translation += mouse_world_position_new - mouse_world_position;
Mouse position is in screen space, so you have to transform it to world space.
Simple transforming should be similar to this:
world_position = screen_position / scale - translation
One important thing... if you have something like:
body {
zoom: 0.9;
}
You need make the equivilent thing in canvas:
canvas {
zoom: 1.1;
}
Here is my solution:
// helpers
const diffPoints = (p1, p2) => {
return {
x: p1.x - p2.x,
y: p1.y - p2.y,
};
};
const addPoints = (p1, p2) => {
return {
x: p1.x + p2.x,
y: p1.y + p2.y,
};
};
function scalePoint(p1, scale) {
return { x: p1.x / scale, y: p1.y / scale };
}
// constants
const ORIGIN = Object.freeze({ x: 0, y: 0 });
const SQUARE_SIZE = 20;
const ZOOM_SENSITIVITY = 500; // bigger for lower zoom per scroll
const MAX_SCALE = 50;
const MIN_SCALE = 0.1;
// dom
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");
const debugDiv = document.getElementById("debug");
// "props"
const initialScale = 0.75;
const initialOffset = { x: 10, y: 20 };
// "state"
let mousePos = ORIGIN;
let lastMousePos = ORIGIN;
let offset = initialOffset;
let scale = initialScale;
// when setting up canvas, set width/height to devicePixelRation times normal
const { devicePixelRatio = 1 } = window;
context.canvas.width = context.canvas.width * devicePixelRatio;
context.canvas.height = context.canvas.height * devicePixelRatio;
function draw() {
window.requestAnimationFrame(draw);
// clear canvas
context.canvas.width = context.canvas.width;
// transform coordinates - scale multiplied by devicePixelRatio
context.scale(scale * devicePixelRatio, scale * devicePixelRatio);
context.translate(offset.x, offset.y);
// draw
context.fillRect(200 + -SQUARE_SIZE / 2, 50 + -SQUARE_SIZE / 2, SQUARE_SIZE, SQUARE_SIZE);
// debugging
context.beginPath();
context.moveTo(0, 0);
context.lineTo(0, 50);
context.moveTo(0, 0);
context.lineTo(50, 0);
context.stroke();
// debugDiv.innerText = `scale: ${scale}
// mouse: ${JSON.stringify(mousePos)}
// offset: ${JSON.stringify(offset)}
// `;
}
// calculate mouse position on canvas relative to top left canvas point on page
function calculateMouse(event, canvas) {
const viewportMousePos = { x: event.pageX, y: event.pageY };
const boundingRect = canvas.getBoundingClientRect();
const topLeftCanvasPos = { x: boundingRect.left, y: boundingRect.top };
return diffPoints(viewportMousePos, topLeftCanvasPos);
}
// zoom
function handleWheel(event) {
event.preventDefault();
// update mouse position
const newMousePos = calculateMouse(event, canvas);
lastMousePos = mousePos;
mousePos = newMousePos;
// calculate new scale/zoom
const zoom = 1 - event.deltaY / ZOOM_SENSITIVITY;
const newScale = scale * zoom;
if (MIN_SCALE > newScale || newScale > MAX_SCALE) {
return;
}
// offset the canvas such that the point under the mouse doesn't move
const lastMouse = scalePoint(mousePos, scale);
const newMouse = scalePoint(mousePos, newScale);
const mouseOffset = diffPoints(lastMouse, newMouse);
offset = diffPoints(offset, mouseOffset);
scale = newScale;
}
canvas.addEventListener("wheel", handleWheel);
// panning
const mouseMove = (event) => {
// update mouse position
const newMousePos = calculateMouse(event, canvas);
lastMousePos = mousePos;
mousePos = newMousePos;
const mouseDiff = scalePoint(diffPoints(mousePos, lastMousePos), scale);
offset = addPoints(offset, mouseDiff);
};
const mouseUp = () => {
document.removeEventListener("mousemove", mouseMove);
document.removeEventListener("mouseup", mouseUp);
};
const startPan = (event) => {
document.addEventListener("mousemove", mouseMove);
document.addEventListener("mouseup", mouseUp);
// set initial mouse position in case user hasn't moved mouse yet
mousePos = calculateMouse(event, canvas);
};
canvas.addEventListener("mousedown", startPan);
// repeatedly redraw
window.requestAnimationFrame(draw);
#canvas {
/*set fixed width and height for what you actually want in css!*/
/*should be the same as what's passed into canvas element*/
width: 500px;
height: 150px;
position: fixed;
border: 2px solid black;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<!--still need width and height here, same as css-->
<canvas id="canvas" width="500" height="150"></canvas>
<div id="debug"></div>
<script type="module" src="pan_zoom.js"></script>
</body>
</html>
you can use scrollto(x,y) function to handle the position of scrollbar right to the point that you need to be showed after zooming.for finding the position of mouse use event.clientX and event.clientY.
this will help you
Here's an approach I use for tighter control over how things are drawn
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var scale = 1;
var xO = 0;
var yO = 0;
draw();
function draw(){
// Clear screen
ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight);
// Original coordinates
const xData = 50, yData = 50, wData = 100, hData = 100;
// Transformed coordinates
const x = xData * scale + xO,
y = yData * scale + yO,
w = wData * scale,
h = hData * scale;
// Draw transformed positions
ctx.fillStyle = "black";
ctx.fillRect(x,y,w,h);
}
canvas.onwheel = function (e){
e.preventDefault();
const r = canvas.getBoundingClientRect(),
xNode = e.pageX - r.left,
yNode = e.pageY - r.top;
const newScale = scale * Math.exp(-Math.sign(e.deltaY) * 0.2),
scaleFactor = newScale/scale;
xO = xNode - scaleFactor * (xNode - xO);
yO = yNode - scaleFactor * (yNode - yO);
scale = newScale;
draw();
}
<canvas id="canvas" width="600" height="200"></canvas>
Adding an answer that worked for me in C# & WPF:
double zoom = scroll > 0 ? 1.2 : (1/1.2);
var CursorPosCanvas = e.GetPosition(Canvas);
pan.X += -(CursorPosCanvas.X - Canvas.RenderSize.Width / 2.0 - pan.X) * (zoom - 1.0);
pan.Y += -(CursorPosCanvas.Y - Canvas.RenderSize.Height / 2.0 - pan.Y) * (zoom - 1.0);
transform.ScaleX *= zoom;
transform.ScaleY *= zoom;