Fabricjs zoom and pan to match vis-network fit - javascript

I am using fabricjs to allow users to draw objects in the background of a network displayed using vis-network. One of the features of vis-network is the fit() function, which zooms and pans the network so that it will neatly and entirely fit within the window. However, I need to zoom and pan the background (i.e. the fabricjs canvas) to match. I am finding it difficult to work out how to do this.
My code so far looks like this:
function myfit() {
let prevPos = network.getViewPosition()
let oldScale = network.getScale()
network.fit({
position: {x: 0, y: 0},
})
let newPos = network.getViewPosition()
let newScale = network.getScale()
panCanvas((prevPos.x - newPos.x) * oldScale, (prevPos.y - newPos.y) * oldScale)
zoomCanvas(newScale)
}
function zoomCanvas(zoom) {
canvas.zoomToPoint({x: canvas.getWidth() / 2, y: canvas.getHeight() / 2}, zoom)
}
function panCanvas(x, y) {
let zoom = network.getScale()
canvas.relativePan(new fabric.Point(x * zoom, y * zoom))
}
Both panCanvas and zoomCanvas work as they should, i.e. if the network is zoomed in or out and zoomCanvas called with the new zoom level (obtained from network.getScale()), the background objects are zoomed in or out to match (e..g if a fabric rect is overlaid on a network node, it stays overlaid after the zoom). panCanvas also works. However, if myfit() is used to fit the network in the window, and the final zoom level (newScale) is not 1 (which is the case if the whole network is too large to fit in the window, so vis-network reduces the zoom until it does), the fabric objects are displaced. It seems that some other formula for the amount of pan is needed.
The vis-network fit() function argument position: {x: 0, y: 0} centres the network in the middle. network.getViewPosition() returns the current central focus point of the view.
An example is shown below. In the first image 3, red and black fabric rectangle objects have been placed over two of the network nodes. Another node (node 13)is outside the view and not visible. Then myfit() is called and the result is the second image 4. The network has been shrunk and centred, and node 13 is not visible, but the fabric rectangles are no longer over their nodes, as they should be.

Well, I eventually worked it out. The rule is, always pan at zoom 1. That may mean that you have to record the current zoom, zoom to 1, pan, and then revert to the previous zoom level. The working version of myfit is:
function myfit() {
let prevPos = network.getViewPosition()
network.fit({
position: {x: 0, y: 0}, // fit to centre of canvas
})
let newPos = network.getViewPosition()
let newScale = network.getScale()
zoomCanvas(1.0)
panCanvas((prevPos.x - newPos.x), (prevPos.y - newPos.y), 1.0)
zoomCanvas(newScale)
}
function zoomCanvas(zoom) {
canvas.zoomToPoint({x: canvas.getWidth() / 2, y: canvas.getHeight() / 2}, zoom)
}
function panCanvas(x, y) {
let zoom = network.getScale()
canvas.relativePan(new fabric.Point(x * zoom, y * zoom))
}

Related

Get the real mouse location on a canvas with double context

I have been asked to get the mouse coordinates in a game made in html5 with canvas.
As a first test, try reading the mouse position with the function below. But this function only reads the mouse position taking into account the dimensions of the canvas.
What happens is that the game has a larger stage than the canvas and this function does not show me the real location of the character on the stage.
I was doing a search and noticed that "behind" the canvas exists on a map (.png) with pixel dimensions already established. The canvas works like the camera to see a portion of the map.
Will it be possible to adapt my function to read the dimensions of the map and then locate the actual coordinates of the player?
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext("2d");
canvas.addEventListener("click", function(e) {
var cRect = canvas.getBoundingClientRect();
var scaleX = canvas.width / cRect.width;
var scaleY = canvas.height / cRect.height;
var canvasX = Math.round((e.clientX - cRect.left) * scaleX);
var canvasY = Math.round((e.clientY - cRect.top) * scaleY);
console.log("X: "+canvasX+", Y: "+canvasY);
});
This function will only give me the position of the mouse based on the size of the canvas but the map is larger, I leave here an explanatory image.
I hope you have understood me. Thanks in advance.
World <=> View
To establish the vernacular, the terms used are
World: the coordinate system (in pixels) of world / playfield / (red box).
View: The coordinate system (in canvas pixels) of canvas / camera / (blue box).
As pointed out in the comments. You need the view origin. That is the coordinates that the top left of the canvas in world space.
You also need to know the view scale. That is the size of the canvas in relationship to the world.
Required information
const world = {width: 2048, height: 1024}; // Red box in pixels
const view = { // blue box
origin: {x: 500, y: 20}, // in world scale (pixels on world)
scale: {width: 1, height: 1}, // scale of pixels (from view to world)
}
Without this information you can not do the conversion. It must exist as it is required to render world content to the canvas.
Note that if the scales are 1 they may only be inferred in the canvas rendering system. If you can not find a scale then use 1.
Note This answer assumes there is no rotation of the view.
View => World
The following function will convert from view coordinates to world coordinates.
function viewToWorld(x, y) { // x,y pixel coordinates on canvas
return {
x: x * view.scale.width + view.origin.x,
y: y * view.scale.height + view.origin.y
}; // return x,y pixel coordinates in world
}
To use in a mouse event where the client is the canvas
function mouseEvent(event) {
// get world (red box) coords
const worldCoord = viewToWorld(event.clientX, event.clientY);
// normalize
worldCoord.x /= world.width;
worldCoord.y /= world.height;
}
World => View
You can reverse the conversion. That is move from world coordinates to view coordinates with the following functions.
function normalWorldToView(x, y) { // x,y normalized world coordinates
return {
x: (x * world.width - view.origin.x) / view.scale.width,
y: (y * world.height - view.origin.y) / view.scale.height
}; // return x,y pixel on canvas (view)
}
and in pixels
function worldToView(x, y) { // x,y world coordinates in pixels
return {
x: (x - view.origin.x) / view.scale.width,
y: (y - view.origin.y) / view.scale.height
}; // return x,y pixel on canvas (view)
}

How Can I Prevent Texture Bleeding When Using drawImage To Draw Multiple Images From A Tile Sheet, Sprite Sheet, Or Texture Atlas?

I am using the HTML5 canvas API to draw a tile map for a pixel art game. The rendered tile map is comprised of many smaller images that are cut out of a single source image called a tile sheet. I am using drawImage(src_img, sx, sy, sw, sh, dx, dy, dw, dh) to cut the individual tiles out of the source image and draw them onto the destination canvas. I am using setTransform(sx, 0, 0, sy, tx, ty) to apply scale and translation to the final rendered image.
The color "bleeding" issue I need to fix is caused by the sampler, which uses interpolation to blend colors during scale operations in order to make things not look pixelated. This is great for scaling digital photographs, but not for pixel art. While this doesn't do much visual damage to the centers of the tiles, the sampler is blending colors along the edges of adjacent tiles in the source image which creates unexpected colors in the rendered tile map. Instead of only using colors that fall within the source rectangle passed to drawImage, the sampler blends in colors from just outside of its boundaries causing what appear to be gaps between the tiles.
Below is my tile sheet's source image. Its actual size is 24x24 pixels, but I scaled it up to 96x96 pixels in GIMP so you could see it. I used the "Interpolation: None" setting on GIMP's scaling tool. As you can see there are no gaps or blurred borders around the individual tiles because the sampler did not interpolate the colors. The canvas API's sampler apparently does interpolate colors even when imageSmoothingEnabled is set to false.
Below is a section of the rendered tile map with imageSmoothingEnabled set to true. The left arrow points to some red bleeding into the bottom of the gray tile. This is because the red tile is directly below the gray tile in the tile sheet. The sampler is blending the red into the bottom edge of the gray tile.
The arrow on the right points to the right edge of the green tile. As you can see, no color is bleeding into it. This is because there is nothing to the right of the green tile in the source image and therefore nothing for the sampler to blend.
Below is the rendered tile map with imageSmoothingEnabled set to false. Depending on the scale and translation, texture bleeding still occurs. The left arrow is pointing to red bleeding in from the red tile in the source image. The visual damage is reduced, but still present.
The right arrow points to an issue with the far right green tile, which has a thin gray line bleeding in from the gray tile in the source image, which is to the left of the green tile.
The two images above were screen captured from Edge. Chrome and Firefox do a better job of hiding the bleeding. Edge seems to bleed on all sides, but Chrome and Firefox seem to only bleed on the right and bottom sides of the source rectangle.
If anyone knows how to fix this please let me know. People ask about this problem in a lot of forums and get work around answers like:
Pad your source tiles with border color so the sampler blends in the same color along the edges.
Put your source tiles in individual files so the sampler has nothing to sample past the borders.
Draw everything to an unscaled buffer canvas and then scale the buffer, ensuring that the sampler is blending in colors from adjacent tiles that are part of the final image, mitigating the visual damage.
Draw everything to the unscaled canvas and then scale it using CSS using image-rendering:pixelated, which basically works the same as the previous work around.
I would like to avoid work arounds, however if you know of another one, please post it. I want to know if there is a way to turn off sampling or interpolation or if there is any other way to stop texture bleeding that isn't one of the work arounds I listed.
Here is a fiddle showcasing the issue: https://jsfiddle.net/0rv1upjf/
You can see the same example on my Github Pages page: https://frankpoth.info/pages/javascript-projects/content/texture-bleeding/texture-bleeding.html
Update:
The problem arose due to floating point numbers being used when plotting pixels. The solution is to avoid floats and only draw on integers. Unfortunately, this means setTransform cannot be used efficiently because scaling generally results in floats, but I still managed to keep a good bit of math out of the tile rendering loop. Here's the code:
function drawRounded(source_image, context, scale) {
var offset_x = -OFFSET.x * scale + context.canvas.width * 0.5;
var offset_y = -OFFSET.y * scale + context.canvas.height * 0.5;
var map_height = (MAP_HEIGHT * scale)|0; // Similar to Math.trunc(MAP_HEIGHT * scale);
var map_width = (MAP_WIDTH * scale)|0;
var tile_size = TILE_SIZE * scale;
var rendered_tile_size = (tile_size + 1)|0; // Similar to Math.ceil(tile_size);
var map_index = 0; // Track the tile index in the map. This increases once per draw loop.
/* Loop through all tile positions in actual coordinate space so no additional calculations based on grid index are needed. */
for (var y = 0; y < map_height; y += tile_size) { // y first so we draw rows from top to bottom
for (var x = 0; x < map_width; x += tile_size) {
var frame = FRAMES[MAP[map_index]]; // The frame is the source location of the tile in the source_image.
// We have to keep the dx, dy truncation inside the loop to ensure the highest level of accuracy possible.
context.drawImage(source_image, frame.x, frame.y, TILE_SIZE, TILE_SIZE, (offset_x + x)|0, (offset_y + y)|0, rendered_tile_size, rendered_tile_size);
map_index ++;
}
}
}
I'm using Bitwise OR or the | operator to do my rounding. Bitwise Or returns a 1 in each bit position for which the corresponding bits of either or both operands are 1s. Bitwise operations will convert a float to an int. Using 0 as the right operand will match all the bits in the left operand and truncate the decimals. The downside to this is it only supports 32 bits, but I doubt I'll ever need more than 32 bits for my tile positions.
For example:
-10.5 | 0 == -10
10.1 | 0 == 10
10.5 | 0 == 10
In binary:
1010 | 0000 == 1010
This is a rounding issue.
There was already that question about this issue experienced on Safari browser when the context is translated to exactly n.5, Edge an IE are even worse and always bleed one way or an other, Chrome for macOs bleeds on n.5 too, but only when drawing an <img>, <canvas> are fine.
Least to say, that's a buggy area.
I didn't check the specs to know exactly what they should do, but there is an easy workaround.
Compute yourself the transformation of your coordinates so you can control exactly how they'll get rounded and ensure crisp pixels.
// First calculate the scaled translations
const scaled_offset_left = -OFFSET.x * scale + context.canvas.width * 0.5;
const scaled_offset_top = -OFFSET.y * scale + context.canvas.height * 0.5;
// when drawing each tile
const dest_x = Math.floor( scaled_offset_left + (x * scale) );
const dest_y = Math.floor( scaled_offset_top + (y * scale) );
const dest_size = Math.ceil( TILE_SIZE * scale );
context.drawImage( source_image,
frame.x, frame.y, TILE_SIZE, TILE_SIZE,
dest_x, dest_y, dest_size, dest_size,
);
/* This is the tile map. Each value is a frame index in the FRAMES array. Each frame tells drawImage where to blit the source from */
const MAP = [
0, 0, 0, 1, 1, 1, 1, 2, 2, 2,
0, 1, 0, 1, 2, 2, 1, 2, 3, 2,
0, 0, 0, 1, 1, 1, 1, 2, 2, 2,
3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
3, 4, 3, 4, 5, 5, 4, 5, 6, 5,
3, 4, 3, 4, 5, 5, 4, 5, 6, 5,
3, 3, 3, 4, 4, 4, 4, 5, 5, 5,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8,
6, 7, 6, 7, 8, 8, 7, 8, 0, 8,
6, 6, 6, 7, 7, 7, 7, 8, 8, 8
];
const TILE_SIZE = 8; // Each tile is 8x8 pixels
const MAP_HEIGHT = 80; // The map is 80 pixels tall by 80 pixels wide
const MAP_WIDTH = 80;
/* Each frame represents the source x, y coordinates of a tile in the source image. They are indexed according to the map values */
const FRAMES = [
{ x:0, y:0 }, // map value = 0
{ x:8, y:0 }, // map value = 1
{ x:16, y:0 }, // map value = 2
{ x:0, y:8 }, // etc.
{ x:8, y:8 },
{ x:16, y:8},
{ x:0, y:16},
{ x:8, y:16},
{ x:16, y:16}
];
/* These represent the state of the keyboard keys being used. false is up and true is down */
const KEYS = {
down: false,
left: false,
right: false,
scale_down: false, // the D key
scale_up: false, // the F key
up: false
}
/* This is the scroll offset. You can also think of it as the position of the red dot in the map. */
const OFFSET = {
x: MAP_WIDTH * 0.5,
y: MAP_HEIGHT * 0.5
}; // It starts out centered in the map.
const MAX_SCALE = 75; // Max scale is 75 times larger than the actual image size.
const MIN_SCALE = 0; // Texture bleeding seems to only occur on upscale, but min scale is 0 in case you want to try it.
var scale = 4.71; // some arbitrary number that will hopefully cause the issue in your browser
/* Get the canvas drawing context. */
var context = document.querySelector('canvas').getContext('2d', {
alpha: false,
desynchronized: true
});
/* The toggle button is the div */
var toggle = document.querySelector('div');
/* The source image is a 24x24 square with 9 tile images of various colors in it. */
var base_64_image_source = 'data:image/png;base64,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';
var source_image = new Image(); // This will be the source image
/* The keyboard event handler */
function keyDownUp(event) {
var state = event.type == 'keydown' ? true : false;
switch (event.keyCode) {
case 37:
KEYS.left = state;
break;
case 38:
KEYS.up = state;
break;
case 39:
KEYS.right = state;
break;
case 40:
KEYS.down = state;
break;
case 68:
KEYS.scale_down = state;
break;
case 70:
KEYS.scale_up = state;
}
}
/* This is the update and rendering loop. It handles input and draws the images. */
function loop() {
window.requestAnimationFrame(loop); // Perpetuate the loop
/* Prepare to move and scale the image with the keyboard input */
if (KEYS.left) OFFSET.x -= 0.5;
if (KEYS.right) OFFSET.x += 0.5;
if (KEYS.up) OFFSET.y -= 0.5;
if (KEYS.down) OFFSET.y += 0.5;
if (KEYS.scale_down) scale -= 0.5 * scale / MAX_SCALE;
if (KEYS.scale_up) scale += 0.5 * scale / MAX_SCALE;
/* Keep the scale size within a defined range */
if (scale > MAX_SCALE) scale = MAX_SCALE;
else if (scale < MIN_SCALE) scale = MIN_SCALE;
/* Clear the canvas to gray. */
context.setTransform(1, 0, 0, 1, 0, 0); // Set the transform back to the identity matrix
context.fillStyle = "#202830"; // Set the fill color to gray
context.fillRect(0, 0, context.canvas.width, context.canvas.height); // fill the entire canvas
/* [EDIT]
Don't set the transform, we will calculate it ourselves
// context.setTransform(scale, 0, 0, scale, -OFFSET.x * scale + context.canvas.width * 0.5, -OFFSET.y * scale + context.canvas.height * 0.5);
First step is calculating the scaled translation
*/
const scaled_offset_left = -OFFSET.x * scale + context.canvas.width * 0.5;
const scaled_offset_top = -OFFSET.y * scale + context.canvas.height * 0.5;
let map_index = 0; // Track the tile index in the map. This increases once per draw loop.
/* Loop through all tile positions in actual coordinate space so no additional calculations based on grid index are needed. */
for (let y = 0; y < MAP_HEIGHT; y += TILE_SIZE) { // y first so we draw rows from top to bottom
for (let x = 0; x < MAP_WIDTH; x += TILE_SIZE) {
const frame = FRAMES[MAP[map_index]]; // The frame is the source location of the tile in the source_image.
/* [EDIT]
We transform the coordinates ourselves
We can control a uniform rounding by using floor and ceil
*/
const dest_x = Math.floor( scaled_offset_left + (x * scale) );
const dest_y = Math.floor( scaled_offset_top + (y * scale) );
const dest_size = Math.ceil(TILE_SIZE * scale);
context.drawImage( source_image,
frame.x, frame.y, TILE_SIZE, TILE_SIZE,
dest_x, dest_y, dest_size, dest_size
);
map_index++;
}
}
/* Draw the red dot in the center of the screen. */
context.fillStyle = "#ff0000";
/* [EDIT]
Do the same kind of calculations for the "dot" if you don't want antialiasing
// const dot_x = Math.floor( scaled_offset_left + ((OFFSET.x - 0.5) * scale) );
// const dot_y = Math.floor( scaled_offset_top + ((OFFSET.y - 0.5) * scale) );
// const dot_size = Math.ceil( scale );
// context.fillRect( dot_x, dot_y, dot_size, dot_size ); // center on the dot
But if you do want antialiasing for the dot, then just set the transformation for this drawing
*/
context.setTransform(scale, 0, 0, scale, scaled_offset_left, scaled_offset_top);
context.fillRect( (OFFSET.x - 0.5), (OFFSET.y - 0.5), 1, 1 ); // center on the dot
var smoothing = context.imageSmoothingEnabled; // Get the current smoothing value because we are going to ignore it briefly.
/* Draw the source image in the top left corner for reference. */
context.setTransform(4, 0, 0, 4, 0, 0); // Zoom in on it so it's visible.
context.imageSmoothingEnabled = false; // Set smoothing to false so we get a crisp source image representation (the real source image is not scaled at all).
context.drawImage( source_image, 0, 0 );
context.imageSmoothingEnabled = smoothing; // Set smoothing back the way it was according to the toggle choice.
}
/* Turn image smoothing on and off when you press the toggle. */
function toggleSmoothing(event) {
context.imageSmoothingEnabled = !context.imageSmoothingEnabled;
if (context.imageSmoothingEnabled) toggle.innerText = 'Smoothing Enabled'; // Make sure the button has appropriate text in it.
else toggle.innerText = 'Smoothing Disabled';
}
/* The main loop will start after the source image is loaded to ensure there is something to draw. */
source_image.addEventListener('load', (event) => {
window.requestAnimationFrame(loop); // Start the loop
}, { once: true });
/* Add the toggle smoothing click handler to the div. */
toggle.addEventListener('click', toggleSmoothing);
/* Add keyboard input */
window.addEventListener('keydown', keyDownUp);
window.addEventListener('keyup', keyDownUp);
/* Resize the canvas. */
context.canvas.width = 480;
context.canvas.height = 480;
toggleSmoothing(); // Set imageSmoothingEnabled
/* Load the source image from the base64 string. */
source_image.setAttribute('src', base_64_image_source);
* {
box-sizing: border-box;
margin: 0;
overflow: hidden;
padding: 0;
user-select: none;
}
body,
html {
background-color: #202830;
color: #ffffff;
height: 100%;
width: 100%;
}
body {
align-items: center;
display: grid;
justify-items: center;
}
p {
max-width: 640px;
}
div {
border: #ffffff 2px solid;
bottom: 4px;
cursor: pointer;
padding: 8px;
position: fixed;
right: 4px
}
<div>Smoothing Disabled</div>
<p>Use the arrow keys to scroll and the D and F keys to scale. The source image is represented on the top left. Notice the vertical and horizontal lines that appear between tiles as you scroll and scale. They are the color of the tile's neighbor in the source
image. This may be due to color sampling that occurs during scaling. Click the toggle to set imageSmoothingEnabled on the drawing context.</p>
<canvas></canvas>
Note that to draw your "player" dot, you can either choose to do the same caulcations manually to avoid the blurring caused by antialiasing, or if you actually want that blurring, then you can simply set the transform only for this dot. In your position I would probably even make something modular like after a certain scale round, and below smoothen, but I'll let the reader do that implementation.

Accounting for Canvas Size Differences when Drawing on Image with Stored Coordinates

I'm struggling to find a method/strategy to handle drawing with stored coordinates and the variation in canvas dimensions across various devices and screen sizes for my web app.
Basically I want to display an image on the canvas. The user will mark two points on an area of image and the app records where these markers were placed. The idea is that the user will use the app every odd day, able to see where X amount of previous points were drawn and able to add two new ones to the area mentioned in places not already marked by previous markers. The canvas is currently set up for height = window.innerHeight and width = window.innerWidth/2.
My initial thought was recording the coordinates of each pair of points and retrieving them as required so they can be redrawn. But these coordinates don't match up if the canvas changes size, as discovered when I tested the web page on different devices. How can I record the previous coordinates and use them to mark the same area of the image regardless of canvas dimensions?
Use percentages! Example:
So lets say on Device 1 the canvas size is 150x200,
User puts marker on pixel 25x30. You can do some math to get the percentage.
And then you SAVE that percentage, not the location,
Example:
let userX = 25; //where the user placed a marker
let canvasWidth = 150;
//Use a calculator to verify :D
let percent = 100 / (canvasWidth / userX); //16.666%
And now that you have the percent you can set the marker's location based on that percent.
Example:
let markerX = (canvasWidth * percent) / 100; //24.999
canvasWidth = 400; //Lets change the canvas size!
markerX = (canvasWidth * percent) / 100; //66.664;
And voila :D just grab the canvas size and you can determine marker's location every time.
Virtual Canvas
You must define a virtual canvas. This is the ideal canvas with a predefined size, all coordinates are relative to this canvas. The center of this virtual canvas is coordinate 0,0
When a coordinate is entered it is converted to the virtual coordinates and stored. When rendered they are converted to the device screen coordinates.
Different devices have various aspect ratios, even a single device can be tilted which changes the aspect. That means that the virtual canvas will not exactly fit on all devices. The best you can do is ensure that the whole virtual canvas is visible without stretching it in x, or y directions. this is called scale to fit.
Scale to fit
To render to the device canvas you need to scale the coordinates so that the whole virtual canvas can fit. You use the canvas transform to apply the scaling.
To create the device scale matrix
const vWidth = 1920; // virtual canvas size
const vHeight = 1080;
function scaleToFitMatrix(dWidth, dHeight) {
const scale = Math.min(dWidth / vWidth, dHeight / vHeight);
return [scale, 0, 0, scale, dWidth / 2, dHeight / 2];
}
const scaleMatrix = scaleToFitMatrix(innerWidth, innerHeight);
Scale position not pixels
Point is defined as a position on the virtual canvas. However the transform will also scale the line widths, and feature sizes which you would not want on very low or high res devices.
To keep the same pixels size but still render in features in pixel sizes you use the inverse scale, and reset the transform just before you stroke as follows (4 pixel box centered over point)
const point = {x : 0, y : 0}; // center of virtual canvas
const point1 = {x : -vWidth / 2, y : -vHeight / 2}; // top left of virtual canvas
const point2 = {x : vWidth / 2, y : vHeight / 2}; // bottom right of virtual canvas
function drawPoint(ctx, matrix, vX, vY, pW, pH) { // vX, vY virtual coordinate
const invScale = 1 / matrix[0]; // to scale to pixel size
ctx.setTransform(...matrix);
ctx.lineWidth = 1; // width of line
ctx.beginPath();
ctx.rect(vX - pW * 0.5 * invScale, vY - pH * 0.5 * invScale, pW * invScale, pH * invScale);
ctx.setTransform(1,0,0,1,0,0); // reset transform for line width to be correct
ctx.fill();
ctx.stroke();
}
const ctx = canvas.getContext("2d");
drawPoint(ctx, scaleMatrix, point.x, point.y, 4, 4);
Transforming via CPU
To convert a point from the device coordinates to the virtual coordinates you need to apply the inverse matrix to that point. For example you get the pageX, pageY coordinates from a mouse, you convert using the scale matrix as follows
function pointToVirtual(matrix, point) {
point.x = (point.x - matrix[4]) / matrix[0];
point.y = (point.y - matrix[5]) / matrix[3];
return point;
}
To convert from virtual to device
function virtualToPoint(matrix, point) {
point.x = (point.x * matrix[0]) + matrix[4];
point.y = (point.y * matrix[3]) + matrix[5];
return point;
}
Check bounds
There may be an area above/below or left/right of the canvas that is outside the virtual canvas coordinates. To check if inside the virtual canvas call the following
function isInVritual(vPoint) {
return ! (vPoint.x < -vWidth / 2 ||
vPoint.y < -vHeight / 2 ||
vPoint.x >= vWidth / 2 ||
vPoint.y >= vHeight / 2);
}
const dPoint = {x: page.x, y: page.y}; // coordinate in device coords
if (isInVirtual(pointToVirtual(scaleMatrix,dPoint))) {
console.log("Point inside");
} else {
console.log("Point out of bounds.");
}
Extra points
The above assumes that the canvas is aligned to the screen.
Some devices will be zoomed (pinch scaled). You will need to check the device pixel scale for the best results.
It is best to set the virtual canvas size to the max screen resolution you expect.
Always work in virtual coordinates, only convert to device coordinates when you need to render.

Zoom in on a photo in canvas using javascript

I was searching for a couple of days how to solve this problem and I decided to ask here for the help.
The thing is, I made a canvas that is 640x480px and preloaded it with an image.
After I used the mouse to select the area that is going to be zoomed in (I used a draggable square, same type like if you would press mouse on windows desktop and select multiple icons) I changed the canvas to be 480x480px (since the zoom in part of the photo is a square), and within that new canvas I have displayed a new zoomed in part of that photo.
My question is: since I am doing all of this so I can zoom in on someones face so I can get a user to more precisely place dots on eyes and mouth (face recognition software like thing) how can I get real coordinates of these dots? In respect to an original image and original canvas that was 640x480px.
Everything is in pure javascript no jQuery, and without any js libraries
Thank you
The same way you'd convert between Fahrenheit and Celsius: decide on a reference point and adjust your scale. The reference point is easy: (0, 0) in the zoomed context is the upper left corner of the selected area in the original context. For the scale, convert the zoomed click point from pixels to percentages. A click at (120, 240) is a click at (25%, 50%). Then multiply that percentage by the size of the selected area and add the reference point offset.
// Assume the user selected in the 640x480 canvas a 223x223
// square whose upper left corner is (174, 36),
let zoomArea = {x: 174, y: 36, size: 223};
// and then clicked (120, 260) in the new 480x480 canvas.
let pointClicked = {x: 120, y: 260};
function getOriginalCoords(area, clicked) {
const ZOOMED_SIZE = 480;
// Get the coordinates of the clicked point in the zoomed
// area, on a scale of 0 to 1.
let clickedPercent = {
x: clicked.x / ZOOMED_SIZE,
y: clicked.y / ZOOMED_SIZE
};
return {
x: clickedPercent.x * area.size + area.x,
y: clickedPercent.y * area.size + area.y
};
}
console.log(getOriginalCoords(zoomArea, pointClicked));
At the end I did it this way
// get bounding rect of canvas
var rectangle = canvas.getBoundingClientRect();
// position of the point in respect to new 480x480 canvas
var xPositionZoom = e.clientX - crosshairOffSet - rectangle.left;
var yPositionZoom = e.clientY - crosshairOffSet - rectangle.top;
// position of the point in respect to original 640x480 canvas
var xPosition = rect.startX + (rect.w * (xPositionZoom / canvas.width));
var yPosition = rect.startY + (rect.h * (yPositionZoom / canvas.height));

How to zoom in and center on an object with fabricjs?

I would like to be able to click on an object, and have it zoomed to its boundingbox in the canvas viewport. How do I accomplish that? See http://jsfiddle.net/tinodb/qv989nzs/8/ for what I would like to get working.
Fabricjs' canvas has the zoomToPoint method (about which the docs say: Sets zoom level of this canvas instance, zoom centered around point), but that does not center to the given point, but it does work for zooming with scrolling. See http://jsfiddle.net/qv989nzs/
I tried several other approaches, like using canvas.setViewportTransform:
// centers a circle positioned at (200, 150)??
canvas.setViewportTransform([2, 0, 0, 2, -250, -150])
But I can't find the relation between the last two parameters to setViewportTransform and the position of the object.
(Btw, another problem is with the first example fiddle, that the zooming only works on the first click. Why is that?)
I found a way to do this, which is composed of:
canvas.setZoom(1) // reset zoom so pan actions work as expected
vpw = canvas.width / zoom
vph = canvas.height / zoom
x = (object.left - vpw / 2) // x is the location where the top left of the viewport should be
y = (object.top - vph / 2) // y idem
canvas.absolutePan({x:x, y:y})
canvas.setZoom(zoom)
See http://jsfiddle.net/tinodb/4Le8n5xd/ for a working example.
I was unable to get it to work with zoomToPoint and setViewportTransform (the latter of which does strange things, see for example http://jsfiddle.net/qv989nzs/9/ and click the blue circle; it is supposed to put the top left viewport at (25, 25), but it does not)
Here's an example of how to do it with setViewportTransform:
// first set the zoom, x, and y coordinates
var zoomLevel = 2;
var objectLeft = 250;
var objectTop = 150;
// then calculate the offset based on canvas size
var newLeft = (-objectLeft * zoomLevel) + canvas.width / 2;
var newTop = (-objectTop * zoomLevel) + canvas.height / 2;
// update the canvas viewport
canvas.setViewportTransform([zoomLevel, 0, 0, zoomLevel, newLeft, newTop]);

Categories

Resources