Scaling mouse drawings elements on a html canvas - javascript

Is there any way to scale drawing elements on a canvas? I'm creating an application where then user can place points on a canvas, but when I try to resize the browser window all the elements disappear.
My initial tentative was to calculate the screen difference before and after the resizing. After I get the percentages, I just sat the scale on the canvas and place the coordinates that was saved from the first time I drew on the canvas, but still doesn't work, if the points appear on the canvas, it is on the same place without scaling. Can someone give me little line of thought?
private calculateCanvasScreenDifference(previousSize, guestScreen) {
return ((controlScreen - currentSize) * 100) / controlScreen;
}
let difWidthPercent = Math.abs(this.calculateCanvasScreenDifference(canvasPreviousWidth, canvasWidth) * 0.01);
let difHeightPercent = Math.abs(this.calculateCanvasScreenDifference(canvasPreviousHeight, canvasHeight) * 0.01);
let scaleX = ((Math.abs(difWidthPercent) <= 1) ? 1.00 - difWidthPercent : difWidthPercent - 1.00);
let scaleY = ((Math.abs(difHeightPercent) <= 1) ? 1.00 - difHeightPercent : difHeightPercent - 1.00);
this.cx.scale(Number(scaleX), Number(scaleY));
...
...
// then start recreating the drawing that was previous saved on an array of object(x, y values)
this.cx.beginPath();
this.cx.arc(coord.x, coord.y, 7, 0, Math.PI * 2, true);
this.cx.stroke();

Keep track of your canvas width and start with a scale factor of 1.
let originalWidth = canvas.width;
let scale = 1;
On resize calculate the new scale factor. And update tracked canvas size.
let scale = newWidth / originalWidth;
originalWidth = newWidth;
Use the scale factor for all drawing at all times. e.g.
context.arc(coord.x * scale, coord.y * scale, radius, 0, Math.PI*2, false);
Note: This approach assumes the original and new canvas sizes are proportional. If not then you will need to track width and height, and calculate separate x and y scale factors.

Related

HTML Canvas coordinate systems and rendering process

I'm playing with drawing on html canvas and I'm little confused of how different coordinate systems actually works. What I have learned so far is that there are more coordinate systems:
canvas coordinate system
css coordinate system
physical (display) coordinate system
So when I draw a line using CanvasRenderingContext2D
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(3, 1);
ctx.lineTo(3, 5);
ctx.stroke();
before drawing pixels to the display, the path needs to be
scaled according to the ctx transformation matrix (if any)
scaled according to the ratio between css canvas element dimensions (canvas.style.width and canvas.style.height) and canvas drawing dimensions (canvas.width and canvas.height)
scaled according to the window.devicePixelRatio (hi-res displays)
Now when I want to draw a crisp line, I found that there are two things to fight with. The first one is that canvas uses antialiasing. So when I draw a line of thikness 1 at integer coordinates, it will be blurred.
To fix this, it needs to be shifted by 0.5 pixels
ctx.moveTo(3.5, 1);
ctx.lineTo(3.5, 5);
The second thing to consider is window.devicePixelRatio. It is used to map logical css pixels to physical pixels. The snadard way how to adapt canvas to hi-res devices is to scale to the ratio
const ratio = window.devicePixelRatio || 1;
const clientBoundingRectangle = canvas.getBoundingClientRect();
canvas.width = clientBoundingRectangle.width * ratio;
canvas.height = clientBoundingRectangle.height * ratio;
const ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
My question is, how is the solution of the antialiasing problem related to the scaling for the hi-res displays?
Let's say my display is hi-res and window.devicePixelRatio is 2.0. When I apply context scaling to adapt canvas to the hi-res display and want to draw the line with thickness of 1, can I just ignore the context scale and draw
ctx.moveTo(3.5, 1);
ctx.lineTo(3.5, 5);
which is in this case effectively
ctx.moveTo(7, 2);
ctx.lineTo(7, 10);
or do I have to consider the scaling ratio and use something like
ctx.moveTo(3.75, 1);
ctx.lineTo(3.75, 5);
to get the crisp line?
Antialiasing can occur both in the rendering on the canvas bitmap buffer, at the time you draw to it, and at the time it's displayed on the monitor, by CSS.
The 0.5px offset for straight lines works only for line widths that are odd integers. As you hinted to, it's so that the stroke, that can only be aligned to the center of the path, and thus will spread inside and outside of the actual path by half the line width, falls on full pixel coordinates. For a comprehensive explanation, see this previous answer of mine.
Scaling the canvas buffer to the monitor's pixel ratio works because on high-res devices, multiple physical dots will be used to cover a single px area. This allows to have more details e.g in texts, or other vector graphics. However, for bitmaps this means the browser has to "pretend" it was bigger in the first place. For instance a 100x100 image, rendered on a 2x monitor will have to be rendered as if it was a 200x200 image to have the same size as on a 1x monitor. During that scaling, the browser may yet again use antialiasing, or another scaling algorithm to "create" the missing pixels.
By directly scaling up the canvas by the pixel ratio, and scaling it down through CSS, we end up with an original bitmap that's the size it will be rendered, and there is no need for CSS to scale anything anymore.
But now, your canvas context is scaled by this pixel ratio too, and if we go back to our straight lines, still assuming a 2x monitor, the 0.5px offset now actually becomes a 1px offset, which is useless. A lineWidth of 1 will actually generate a 2px stroke, which doesn't need any offset.
So no, don't ignore the scaling when offsetting your context for straight lines.
But the best is probably to not use that offset trick at all, and instead use rect() calls and fill() if you want your lines to fit perfectly on pixels.
const canvas = document.querySelector("canvas");
// devicePixelRatio may not be accurate, see below
setCanvasSize(canvas);
function draw() {
const dPR = devicePixelRatio;
const ctx = canvas.getContext("2d");
// scale() with weird zoom levels may produce antialiasing
// So one might prefer to do the scaling of all coords manually:
const lineWidth = Math.round(1 * dPR);
const cellSize = Math.round(10 * dPR);
for (let x = cellSize; x < canvas.width; x += cellSize) {
ctx.rect(x, 0, lineWidth, canvas.height);
}
for (let y = cellSize; y < canvas.height; y += cellSize) {
ctx.rect(0, y, canvas.width, lineWidth);
}
ctx.fill();
}
function setCanvasSize(canvas) {
// We resize the canvas bitmap based on the size of the viewport
// while respecting the actual dPR
// Thanks to gman for the reminder of how to suppport all early impl.
// https://stackoverflow.com/a/65435847/3702797
const observer = new ResizeObserver(([entry]) => {
let width;
let height;
const dPR = devicePixelRatio;
if (entry.devicePixelContentBoxSize) {
width = entry.devicePixelContentBoxSize[0].inlineSize;
height = entry.devicePixelContentBoxSize[0].blockSize;
} else if (entry.contentBoxSize) {
if ( entry.contentBoxSize[0]) {
width = entry.contentBoxSize[0].inlineSize * dPR;
height = entry.contentBoxSize[0].blockSize * dPR;
} else {
width = entry.contentBoxSize.inlineSize * dPR;
height = entry.contentBoxSize.blockSize * dPR;
}
} else {
width = entry.contentRect.width * dPR;
height = entry.contentRect.height * dPR;
}
canvas.width = width;
canvas.height = height;
canvas.style.width = (width / dPR) + 'px';
canvas.style.height = (height / dPR) + 'px';
// we need to redraw
draw();
});
// observe the scrollbox size changes
try {
observer.observe(canvas, { box: 'device-pixel-content-box' });
}
catch(err) {
observer.observe(canvas, { box: 'content-box' });
}
}
canvas { width: 300px; height: 150px; }
<canvas></canvas>
Preventing anti-aliasing requires that the pixels of the canvas, which is a raster image, are aligned with the pixels of the screen, which can be done by multiplying the canvas size by the devicePixelRatio, while using the CSS size to hold the canvas to its original size:
canvas.width = pixelSize * window.devicePixelRatio;
canvas.height = pixelSize * window.devicePixelRatio;
canvas.style.width = pixelSize + 'px';
canvas.style.height = pixelSize + 'px';
You can then use scale on the context, so that the drawn images won't be shrunk by higher devicePixelRatios. Here I am rounding so that lines can be crisp on ratios that are not whole numbers:
let roundedScale = Math.round(window.devicePixelRatio);
context.scale(roundedScale, roundedScale);
The example then draws a vertical line from the center top of one pixel to the center top of another:
context.moveTo(100.5, 10);
context.lineTo(100.5, 190);
One thing to keep in mind is zooming. If you zoom in on the example, it will become anti-aliased as the browser scales up the raster image. If you then click run on the example again, it will become crisp again (on most browsers). This is because most browsers update the devicePixelRatio to include any zooming. If you are rendering in an animation loop while they are zooming, the rounding could cause some flickering.

D3 v4 invert function

I am trying to project a JPG basemap onto an Orthographic projection using the inverse projection. I have been able to get it working in v3 of D3, but I am having an issue in v4 of D3. For some reason, v4 gives me the edge of the source image as the background (rather than the black background I have specified). Are there any known issues with the inverse projection in v4 or any fixes for this?
D3 v4 JSBin Link
<title>Final Project</title>
<style>
canvas {
background-color: black;
}
</style>
<body>
<div id="canvas-image-orthographic"></div>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>
// Canvas element width and height
var width = 960,
height = 500;
// Append the canvas element to the container div
var div = d3.select('#canvas-image-orthographic'),
canvas = div.append('canvas')
.attr('width', width)
.attr('height', height);
// Get the 2D context of the canvas instance
var context = canvas.node().getContext('2d');
// Create and configure the Equirectangular projection
var equirectangular = d3.geoEquirectangular()
.scale(width / (2 * Math.PI))
.translate([width / 2, height / 2]);
// Create and configure the Orthographic projection
var orthographic = d3.geoOrthographic()
.scale(Math.sqrt(2) * height / Math.PI)
.translate([width / 2, height / 2])
.clipAngle(90);
// Create the image element
var image = new Image(width, height);
image.crossOrigin = "Anonymous";
image.onload = onLoad;
image.src = 'https://tatornator12.github.io/classes/final-project/32908689360_24792ca036_k.jpg';
// Copy the image to the canvas context
function onLoad() {
// Copy the image to the canvas area
context.drawImage(image, 0, 0, image.width, image.height);
// Reads the source image data from the canvas context
var sourceData = context.getImageData(0, 0, image.width, image.height).data;
// Creates an empty target image and gets its data
var target = context.createImageData(image.width, image.height),
targetData = target.data;
// Iterate in the target image
for (var x = 0, w = image.width; x < w; x += 1) {
for (var y = 0, h = image.height; y < h; y += 1) {
// Compute the geographic coordinates of the current pixel
var coords = orthographic.invert([x, y]);
// Source and target image indices
var targetIndex,
sourceIndex,
pixels;
// Check if the inverse projection is defined
if ((!isNaN(coords[0])) && (!isNaN(coords[1]))) {
// Compute the source pixel coordinates
pixels = equirectangular(coords);
// Compute the index of the red channel
sourceIndex = 4 * (Math.floor(pixels[0]) + w * Math.floor(pixels[1]));
sourceIndex = sourceIndex - (sourceIndex % 4);
targetIndex = 4 * (x + w * y);
targetIndex = targetIndex - (targetIndex % 4);
// Copy the red, green, blue and alpha channels
targetData[targetIndex] = sourceData[sourceIndex];
targetData[targetIndex + 1] = sourceData[sourceIndex + 1];
targetData[targetIndex + 2] = sourceData[sourceIndex + 2];
targetData[targetIndex + 3] = sourceData[sourceIndex + 3];
}
}
}
// Clear the canvas element and copy the target image
context.clearRect(0, 0, image.width, image.height);
context.putImageData(target, 0, 0);
}
</script>
The problem is that the invert function is not one to one. There are two ways that I'm aware of that can solve the problem. One, calculate the area of the disc that makes up the projection and skip pixels that are outside of that radius. Or two (which I use below), calculate the forward projection of your coordinates and see if they match the x,y coordinates that you started with:
if (
(Math.abs(x - orthographic(coords)[0]) < 0.5 ) &&
(Math.abs(y - orthographic(coords)[1]) < 0.5 )
)
Essentially this asks is [x,y] equal to projection(projection.invert([x,y])). By ensuring that this statement is equal (or near equal) then the pixel is indeed in the projection disc. This is needed as multiple svg points can represent a given lat long but projection() returns only the one you want.
There is a tolerance factor there for rounding errors in the code block above, as long as the forward projection is within half a pixel of the original x,y coordinate it'll be drawn (which appears to work pretty well):
I've got an updated bin here (click run, I unchecked auto run).
Naturally this is the more computationally involved process when compared to calculating the radius of the projection disc (but that method is limited to projections that project to a disc).
This question's two answers might be able to explain further - they cover both approaches.

Graphical representation of speed on canvas

I have the following code:
https://jsfiddle.net/8o3sn9mh/21/
var
canvas = document.getElementById('c'),
ctx = canvas.getContext('2d');
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
thrust = height * 0.000001;
maxVelocity = height * 0.00067;
velocity = height * 0.00019;
velocityInterval = setInterval(function(){
velocity+= thrust;
if(velocity > maxVelocity) velocity = maxVelocity;
ctx.fillRect(1, height / 2, width * (velocity / maxVelocity), height / 30);
}, 1);
explanation: I have a canvas which adapts to the user's window. I am trying to represent speed progress upon the canvas with the following starting parameters:
velocity, Max velocity(speed limit) and thrust which accelerates the velocity every millisecond.
the bar starts accelerating from a certain velocity(0.00019) and when you reach full speed the bar's width is exactly the canvas' width. it works fine but as you can see the graph bar starts at a certain x point which is not 0x, because i decided that the starting velocity shall be quite fast.
how can I start the bar at 0x and still be accurate with the speed progress?
here is how it should look like(of course the logic i was talking about is not included here):
https://jsfiddle.net/8o3sn9mh/33/
You can re-map the value between 0 and max.
Javascript has no native build in function for that.
But you can take one from p5 library for example which is:
Math.map = function (value, istart, istop, ostart, ostop) {
return ostart + (ostop - ostart) * ((value - istart) / (istop - istart));
}
Or follow this discussion on SO.
This function re-maps a value to a new range.
So in your case you'd define
var mapStart = width* ((height * 0.00019)/maxVelocity);
and map the (velocity,width) calculation to be between 0 and max. width
var mappedValue = Math.map((width * (velocity / maxVelocity)), mapStart, width, 0, width);
ctx.fillRect(1, height / 2, mappedValue, height / 30);
The fiddle.

Responsive canvas with fixed line width

I'm drawing a line chart with canvas. The chart is responsive, but the line has to have a fixed width.
I made it responsive with css
#myCanvas{
width: 80%;
}
,so the stroke is scaled.
The only solution I have found is to get the value of the lineWidth with the proportion between the width attribute of the canvas and its real width.
To apply it, I clear and draw the canvas on resize.
<canvas id="myCanvas" width="510" height="210"></canvas>
<script type="text/javascript">
var c = document.getElementById("myCanvas");
var ctx = c.getContext("2d");
function draw(){
var canvasattrwidth = $('#myCanvas').attr('width');
var canvasrealwidth = $('#myCanvas').width();
// n sets the line width
var n = 4;
var widthStroke = n * (canvasattrwidth / canvasrealwidth) ;
ctx.lineWidth = widthStroke;
ctx.beginPath();
ctx.moveTo( 0 , 10 );
ctx.lineTo( 200 , 100 );
ctx.stroke();
}
$(window).on('resize', function(){
ctx.clearRect(0, 0, c.width, c.height);
draw();
});
draw();
</script>
This is my first canvas and I think there is an easier way to made the lineWidth fixed (not to clear and draw everytime on resize), but I cannot find it.
There is a question with the similar problem
html5 canvas prevent linewidth scaling
but with the method scale(), so I cannot use that solution.
There is no way to get a real world dimension of details for the canvas such as millimeters or inches so you will have to do it in pixels.
As the canvas resolution decreases the pixel width of a line needs to decrease as well. The limiting property of line width is a pixel. Rendering a line narrower than a pixel will only approximate the appearance of a narrower line by reducing the opacity (this is done automatically)
You need to define the line width in terms of the lowest resolution you will expect, within reason of course and adjust that width as the canvas resolution changes in relation to this selected ideal resolution.
If you are scaling the chart by different amounts in the x and y directions you will have to use the ctx.scale or ctx.setTransform methods. As you say you do not want to do this I will assume that your scaling is always with a square aspect.
So we can pick the lowest acceptable resolution. Say 512 pixels for either width or height of the canvas and select the lineWidth in pixels for that resolution.
Thus we can create two constants
const NATIVE_RES = 512; // the minimum resolution we reasonably expect
const LINE_WIDTH = 1; // pixel width of the line at that resolution
// Note I Capitalize constants, This is non standard in Javascript
Then to calculate the actual line width is simply the actual canvas.width divided by the NATIVE_RES then multiply that result by the LINE_WIDTH.
var actualLineWidth = LINE_WIDTH * (canvas.width / NATIVE_RES);
ctx.lineWidth = actualLineWidth;
You may want to limit that size to the smallest canvas dimension. You can do that with Math.min or you can limit it in the largest dimension with Math.max
For min dimention.
var actualLineWidth = LINE_WIDTH * (Math.min(canvas.width, canvas.height) / NATIVE_RES);
ctx.lineWidth = actualLineWidth;
For max dimension
var actualLineWidth = LINE_WIDTH * (Math.max(canvas.width, canvas.height) / NATIVE_RES);
ctx.lineWidth = actualLineWidth;
You could also consider the diagonal as the adjusting factor that would incorporate the best of both x and y dimensions.
// get the diagonal resolution
var diagonalRes = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height)
var actualLineWidth = LINE_WIDTH * (diagonalRes / NATIVE_RES);
ctx.lineWidth = actualLineWidth;
And finally you may wish to limit the lower range of the line to stop strange artifacts when the line gets smaller than 1 pixel.
Set lower limit using the diagonal
var diagonalRes = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height)
var actualLineWidth = Math.max(1, LINE_WIDTH * (diagonalRes / NATIVE_RES));
ctx.lineWidth = actualLineWidth;
This will create a responsive line width that will not go under 1 pixel if the canvas diagonal resolution goes under 512.
The method you use is up to you. Try them out a see what you like best. The NATIVE_RES I picked "512" is also arbitrary and can be what ever you wish. You will just have to experiment with the values and method to see which you like best.
If your scaling aspect is changing then there is a completely different technique to solve that problem which I will leave for another question.
Hope this has helped.

Image jumping at the beginning of scrolling

I wrote some code to zoom in my image, but when I scroll at the very beginning this picture jumps a little. How to fix the problem?
Full page view.
Editor view.
HTML
<canvas id="canvas"></canvas>
JS
function draw(scroll) {
scroll = (window.scrollY || window.pageYOffset) / (document.body.clientHeight - window.innerHeight) * 3000;
canvas.setAttribute('width', window.innerWidth);
canvas.setAttribute('height', window.innerHeight);
//The main formula that draws and zooms the picture
drawImageProp(ctx, forest, 0, (-scroll * 3.9) / 4, canvas.width, canvas.height + (scroll * 3.9) / 2);
}
Not a bug fix
I had a look at the Codepen example and it does jump at the top (sometimes). I have a fix for you but I did not have the time to locate the source of your code problem. I did notice that the jump involved a aspect change so it must be in the scaling that your error is. (look out for negatives)
GPU is a better clipper
Also your code is actually doing unnecessary work, because you are calculating the image clipping region. Canvas context does the clipping for you and is especially good at clipping images. Even though you provide the clip area the image will still go through clip as that is part of the render pipeline. The only time you should be concerned about the clipped display of an image is whether or not any part of the image is visible so that you don't send a draw call, and it only really matters if you are pushing the image render count (ie game sprite counts 500+)
Code example
Anyway I digress. Below is my code. You can add the checks and balances. (argument vetting, scaling max min, etc).
Calling function.
// get a normalised scale 0-1 from the scroll postion
var scale = (window.scrollY || window.pageYOffset) / (document.body.clientHeight - window.innerHeight);
// call the draw function
// scale 0-1 where 0 is min scale and 1 is max scale (min max determined in function
// X and y offset are clamped but are ranged
// 0 - image.width and 0 - image.height
// where 0,0 shows top left and width,height show bottom right
drawImage(ctx, forest, scale, xOffset, yOffset);
The function.
The comments should cover what you need to know. You will notice that all I am concerned with is how big the image should be and where the top left corner will be. The GPU will do the clipping for you, and will not cost you processing time (even for unaccelerated displays). I personally like to work with normalised values 0-1, it is a little extra work but my brain likes the simplicity, it also reduces the need for magic numbers (magics number are a sign that code is not adaptable) . Function will work for any size display and any size image. Oh and I like divide rather than multiply, (a bad coding habit that comes from a good math habit) replacing the / 2 and needed brackets with * 0.5 will make it more readable.
function drawImage(ctx, img, scale, x, y){
const MAX_SCALE = 4;
const MIN_SCALE = 1;
var w = canvas.width; // set vars just for source clarity
var h = canvas.height;
var iw = img.width;
var ih = img.height;
var fit = Math.max(w / iw, h / ih); // get the scale to fill the avalible display area
// Scale is a normalised value from 0-1 as input arg Convert to range
scale = (MAX_SCALE - MIN_SCALE) * scale + MIN_SCALE;
var idw = iw * fit * scale; // get image total display size;
var idh = ih * fit * scale;
x /= iw; // normalise offsets
y /= ih; //
x = - (idw - w) * x; // transform offsets to display coords
y = - (idh - h) * y;
x = Math.min( 0, Math.max( - (idw - w), x) ); // clamp image to display area
y = Math.min( 0, Math.max( - (idh - h), y) );
// use set transform to scale and translate
ctx.setTransform(scale, 0, 0, scale, idw / 2 + x, idh / 2 + y);
// display the image to fit;
ctx.drawImage(img, ( - iw / 2 ) * fit, (- ih / 2 ) * fit);
// restore transform.
ctx.setTransform(1, 0, 0, 1, 0, 0)
}
Sorry I did not solve the problem directly, but hopefully this will help you redesign your approch.
I recently added a similar answer involving zooming and panning (and rotation) with the mouse which you may be interested in How to pan the canvas? Its a bit messy still "note to self (my clean it up)" and has no bounds clamping. But shows how to set a zoom origin, and convert from screen space to world space. (find where a screen pixel is on a pan/scale/rotated display).
Good luck with your project.

Categories