The introduction
Before I met an interview machine test, is to use canvas to draw shapes, and support zooming, drag functions. Now I have a moment to share how I did it step by step. It’s a good idea to take a look at the Canvas API, canvas API shuttle, before reading this.
Begin to write
Let’s write out the container Dom and the style HTML
<div id="chart-wrap" class="chart-wrap"></div>
Copy the code
css
html.body {
margin: 0;
height: 100%;
overflow: hidden;
}
.chart-wrap {
height: calc(100% - 40px);
margin: 20px;
box-shadow: 0 0 3px orange;
}
Copy the code
First draw a shape
Create a class called Chart that initializes the canvas in the constructor, writes the function to draw the shape, and renders the canvas. The code is as follows:
class chart {
// Initial constructor
constructor(params) {
var wrapDomStyle = getComputedStyle(params.el);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
// Create canvas canvas
this.El = document.createElement('canvas');
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext('2d');
params.el.appendChild(this.El);
}
// Draw a circle
drawCircle(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.arc(data.x, data.y, data.r, 0.2 * Math.PI);
this.ctx.fill();
}
// Add shapes
push(data) {
this.drawCircle(data); }}// Build the chart object
var chartObj = new chart( { el: document.getElementById('chart-wrap')});// Draw a circle
chartObj.push({
fillStyle: 'pink'.x: 400.y: 300.r: 50
});
Copy the code
Create an object, pass in the container Dom, initialize a canvas from constructor into the div# chartObj Dom, and assign the created instance to the variable chartObj.
Draw a circle by calling the class’s push method.
Click here to see the code effect
Draw many, many types of shapes
If you want to draw other graphs, you need to add type judgment. The above code is modified as follows:
class chart {
// Initial constructor
constructor(params) {
var wrapDomStyle = getComputedStyle(params.el);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
// Create canvas canvas
this.El = document.createElement('canvas');
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext('2d');
params.el.appendChild(this.El);
}
// Draw a circle
drawCircle(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.arc(data.x, data.y, data.r, 0.2 * Math.PI);
this.ctx.fill();
}
// _____________ add draw line method ____________
drawLine(data) {
var arr = data.data.concat()
var ctx = ctx || this.ctx;
ctx.beginPath()
ctx.moveTo(arr.shift(), arr.shift())
ctx.lineWidth = data.lineWidth || 1
do{
ctx.lineTo(arr.shift(), arr.shift());
} while (arr.length)
ctx.stroke();
}
// ___________ add the draw rectangle method ______________
drawRect(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.fillRect(... data.data); }// ___________ add a method to determine type drawing _____________
draw(item) {
switch(item.type){
case 'line':
this.drawLine(item)
break;
case 'rect':
this.drawRect(item)
break;
case 'circle':
this.drawCircle(item)
break; }}// Add shapes
push(data) {
this.draw(data); // ____________ modify call draw method ____________}}// Build the chart object
var chartObj = new chart( { el: document.getElementById('chart-wrap')});// Draw a circle
chartObj.push({
type: 'circle'.// ____________ adds a type __________________
fillStyle: 'pink'.x: 400.y: 300.r: 50
});
// ___________ add drawing lines __________
chartObj.push({
type: 'line'.lineWidth: 4.data: [100.90.200.90.250.200.400.200]})// ___________ add draw rectangle __________
chartObj.push({
type: 'rect'.fillStyle: "#0f00ff".data: [350.400.100.100]})Copy the code
A method and data to draw a rectangle (drawRect), a drawLine (drawLine), and a function to determine the type of render (draw) have been added.
Click here to see the code effect
Add zoom
Adding zooming requires clarifying a few things first.
Scaling Canvas provides two types of methods that can be implemented, one to scale on the current scale and one to scale on the underlying canvas.
Matrix changes are not limited to scaling, but you can change the scaling value without changing other parameters
Scale based on current scaling: scale() scales the current drawing to larger or smaller, transform() replaces the current transformation matrix of the drawing;
This means that the canvas was originally 1, and the first time you magnify it 2 times, it becomes 2, and the second time you magnify it 2 times, it becomes 4
Scale on the underlying canvas: setTransform() resets the current transformation to the identity matrix. Then run transform().
This means that the canvas was originally 1, and the first time it was enlarged by 2 times, it became 2, and the second time it was enlarged by 2 times, it was still 2, because it was reset to 1 and then enlarged again
Here I use setTransform() to scale the canvas
The first step:
Since scaling is required, the current scaling value must be saved by constructor as an argument, as well as saving data under push() and render() to redraw all data
constructor() {
// Since canvas is drawn based on state, that is, after setting the scale value, the elements drawn will be drawn according to the scale multiple, so we need to save each drawn object.
this.data = [];
this.scale = 1; // The default scaling value is 1
}
// Add shapes
push(data) {
// Add the save data operation to the push method
this.data.push(data);
}
// Render the entire graphics canvas
render() {
this.El.width = this.width
this.data.forEach(item= > {
this.draw(item)
})
}
Copy the code
The second step:
Because the mouse wheel controls when zooming, it is added to listen for the wheel event, and it is added when the mouse is in the canvas, so it does not need to listen for the wheel event when it is not in the canvas.
constructor() {
// Add a scroll wheel to determine the event
this.addScaleFunc();
}
// Add the zoom function to register and remove the MouseWhell event
addScaleFunc() {
this.El.addEventListener('mouseenter'.this.addMouseWhell);
this.El.addEventListener('mouseleave'.this.removeMouseWhell);
}
// Add the mousewhell event
addMouseWhell = () = > {
document.addEventListener('mousewheel'.this.scrollFunc, {passive: false});
}
// Remove the mousewhell event
removeMouseWhell = () = > {
document.removeEventListener('mousewheel'.this.scrollFunc, {passive: false});
}
Copy the code
Step 3:
After the wheel event listening is complete, it is time to call the concrete zoom implementation code
constructor() {
// Scale the data to be used by the implementation
this.maxScale = 3; // Maximum scaling value
this.minScale = 1; // Minimum zoom value
this.step = 0.1; / / zoom ratio
this.offsetX = 0; // Canvas X offset value
this.offsetY = 0; // The canvas's Y offset
}
// Scale to calculate
scrollFunc = (e) = > {
// Prevent default events (external container prevents scrolling while scaling)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0) {this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY
this.scale += this.step
} else {
this.offsetX += this.scale <= this.minScale ? 0 : offsetX
this.offsetY += this.scale <= this.minScale ? 0 : offsetY
this.scale -= this.step
}
this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
this.render()
}
}
// Add Settings to zoom in the type judgement render method
draw() {
this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
}
Copy the code
Preview the above code effect
Explanation:
The first step is easy to understand the second step, more troublesome is the third step, the following is to explain the third part of the specific zoom implementation.
Cut down your code
scrollFunc = (e) = > {
// Prevent default events (external container prevents scrolling while scaling)
e.preventDefault();
if(e.wheelDelta){
e.wheelDelta > 0 ? this.scale += this.step : this.scale -= this.step
this.render()
}
}
Copy the code
All it takes is a few lines to scale. Determine if E.Wey delta scrolls up or down, increasing or decreasing the size of this.scale, and finally call Render () to redraw the current canvas.
E.preventdefault () doesn’t have to be explained; everyone knows it addresses default behavior. It is important to explain that {passive: false} must be added to the third argument of the event listener when scrollFunc() is called (the default is {passive: true}), otherwise the default rolling event will not be blocked.
You can comment out the rest of the code in scrollFunc in the demo and see that scaling works, but instead of scaling based on mouse position, it always scales at (0,0) the canvas. So the canvas will be offset to the lower right when zoomed in, so the left and up offset corrections are needed to make zooming look like zooming at mouse position.
Modify the code above as follows:
scrollFunc = (e) = > {
// Prevent default events (external container prevents scrolling while scaling)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0) {this.offsetX -= offsetX
this.offsetY -= offsetY
this.scale += this.step
} else {
this.offsetX += offsetX
this.offsetY += offsetY
this.scale -= this.step
}
this.render()
}
}
Copy the code
X, y are the distance between the mouse and the original origin of the canvas, offsetX, offsetY are the offset of this scaling, and then judge whether to zoom in or out to increase or decrease the offset of the overall canvas.
The offset is calculated as follows: mouse distance from the original point (x, Y) divided by the scale value this.scale multiplied by the scale this.step.
Explanation: Because it is usedsetTransform()
, so each zoom in or zoom out is based on the original canvas size, so you need to divide by the zoom value to find the distance between the mouse and the original point based on the original zoom.
Explanation: If usedscale()
Instead of dividing by the scale value, the current scale value multiplied by the scale value equals the actual scale value
Finally, to improve the zoom function, add the maximum zoom value this.maxScale and the minimum zoom value this.minScale limit, complete the code as follows:
// Scale to calculate
scrollFunc = (e) = > {
// Prevent default events (external container prevents scrolling while scaling)
e.preventDefault();
if(e.wheelDelta){
var x = e.offsetX - this.offsetX
var y = e.offsetY - this.offsetY
var offsetX = (x / this.scale) * this.step
var offsetY = (y / this.scale) * this.step
if(e.wheelDelta > 0) {this.offsetX -= this.scale >= this.maxScale ? 0 : offsetX
this.offsetY -= this.scale >= this.maxScale ? 0 : offsetY
this.scale += this.step
} else {
this.offsetX += this.scale <= this.minScale ? 0 : offsetX
this.offsetY += this.scale <= this.minScale ? 0 : offsetY
this.scale -= this.step
}
this.scale = Math.min(this.maxScale, Math.max(this.scale, this.minScale))
this.render()
}
}
Copy the code
This is done, just call this.render(). In this.render we call this.draw, which calls the setTransform method, which sets the changed scale and offset value to the canvas.
this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
Copy the code
Add drag and drop to the canvas effect
First of all, let’s clarify the dragging steps: mouse down => mouse move => mouse release
Mousedown: we use the mousedown event, and then register the mouse movement event in the down event
Mouse movement: We use the Mousemove event to concretely move the canvas in the mouse movement event
Mouse Release: We use the Mouseup event to remove the mouse movement event from the Mouse release event
The specific code is as follows:
constructor(params) {
this.wrapDom = params.el;
this.addDragFunc();
}
// Add drag-and-drop function, decide when to register to remove drag-and-drop function
addDragFunc() {
this.El.addEventListener('mousedown'.this.addMouseMove);
document.addEventListener('mouseup'.this.removeMouseMove);
}
// Add mouse movement function to get save current click coordinates
addMouseMove = (e) = > {
this.targetX = e.offsetX
this.targetY = e.offsetY
this.mousedownOriginX = this.offsetX;
this.mousedownOriginY = this.offsetY;
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}// Remove the mouse movement event
removeMouseMove = () = > {
this.wrapDom.style.cursor = ' '
this.El.removeEventListener('mousemove'.this.moveCanvasFunc, false)
this.El.removeEventListener('mousemove'.this.moveShapeFunc, false)}// Move the canvas
moveCanvasFunc = (e) = > {
// Get the maximum removable width
var maxMoveX = this.El.width / 2;
var maxMoveY = this.El.height / 2;
var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
this.offsetX = Math.abs(offsetX) > maxMoveX ? this.offsetX : offsetX
this.offsetY = Math.abs(offsetY) > maxMoveY ? this.offsetY : offsetY
this.render()
}
Copy the code
The above code effect demonstration
The rest of the code is pretty simple, so here’s a detailed explanation of what addMouseMove() and moveCanvasFunc() do.
The addMouseMove function uses targetX, targetY saves the coordinates of mouse clicks, mousedownOriginX, mousedownOriginX saves the overall offset of the canvas at mouse clicks.
Then calculate the overall offset after moving in moveCanvasFunc function. The code in moveCanvasFunc function can be simplified as follows:
moveCanvasFunc = (e) = > {
var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
this.render()
}
Copy the code
The rest of the code limits the maximum offset and finally calls this.render()
In general, dragging and pulling the canvas is a little bit easier than scaling, and again this will end with a call to this.render(), which will call the this.draw function, which will call the setTransform method, which will change the scaling value, And the offset value is set to the canvas.
this.ctx.setTransform(this.scale,0.0.this.scale, this.offsetX, this.offsetY);
Copy the code
Drag and drop shapes on the canvas
If you want to drag a shape on the canvas, you need to determine whether the mouse click position is in the shape, and because of the hierarchy, you can only control the shape of the top layer.
Therefore, we need to write the judgment method of whether the mouse is inside the shape when it is pressed down. Here, we only write the judgment method of rectangle, circle and line segment.
Because the drag function has been implemented in the implementation of the drag function, now only need to change the addMouseMove function and add the shape movement function, as well as three judgment methods.
The overall code is as follows:
// Add mouse movement function to get save current click coordinates
addMouseMove = (e) = > {
this.targetX = e.offsetX
this.targetY = e.offsetY
this.mousedownOriginX = this.offsetX;
this.mousedownOriginY = this.offsetY;
var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;
this.activeShape = null
this.data.forEach(item= > {
switch(item.type){
case 'rect':
this.isInnerRect(... item.data, x, y) && (this.activeShape = item)
break;
case 'circle':
this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
break;
case 'line':
var lineNumber = item.data.length / 2 - 1
var flag = false
for(let i = 0; i < lineNumber; i++){
let index = i*2;
flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
if(flag){
this.activeShape = item
break; }}}})if(!this.activeShape){
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}else {
this.wrapDom.style.cursor = 'all-scroll'
this.shapedOldX = null
this.shapedOldY = null
this.El.addEventListener('mousemove'.this.moveShapeFunc, false)}}// Move the shape
moveShapeFunc = (e) = > {
var moveX = e.offsetX - (this.shapedOldX || this.targetX);
var moveY = e.offsetY - (this.shapedOldY || this.targetY);
moveX /= this.scale
moveY /= this.scale
switch(this.activeShape.type){
case 'rect':
let x = this.activeShape.data[0]
let y = this.activeShape.data[1]
let width = this.activeShape.data[2]
let height = this.activeShape.data[3]
this.activeShape.data = [x + moveX, y + moveY, width, height]
break;
case 'circle':
this.activeShape.x += moveX
this.activeShape.y += moveY
break;
case 'line':
var item = this.activeShape;
var lineNumber = item.data.length / 2
for(let i = 0; i < lineNumber; i++){
let index = i*2;
item.data[index] += moveX
item.data[index + 1] += moveY
}
}
this.shapedOldX = e.offsetX
this.shapedOldY = e.offsetY
this.render()
}
// Check whether the box is inside the rectangle
isInnerRect(x0, y0, width, height, x, y) {
return x0 <= x && y0 <= y && (x0 + width) >= x && (y0 + height) >= y
}
// Check if it is inside the circle
isInnerCircle(x0, y0, r, x, y) {
return Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2) < =Math.pow(r, 2)}// Check if it is on a path
isInnerPath(x0, y0, x1, y1, x, y, lineWidth) {
var a1pow = Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2);
var a1 = Math.sqrt(a1pow, 2)
var a2pow = Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)
var a2 = Math.sqrt(a2pow, 2)
var a3pow = Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2)
var a3 = Math.sqrt(a3pow, 2)
var r = lineWidth / 2
var ab = (a1pow - a2pow + a3pow) / (2 * a3)var ab = (a1pow - a2pow + a3pow) / (2 * a3)
var h = Math.sqrt(a1pow - Math.pow(ab, 2), 2)
var ad = Math.sqrt(Math.pow(a3, 2) + Math.pow(r, 2))
return h <= r && a1 <= ad && a2 <= ad
}
Copy the code
The above code effect demonstration
This code adds an action in addMouseMove to determine if it is inside the shape.
var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;
this.activeShape = null
this.data.forEach(item= > {
switch(item.type){
case 'rect':
this.isInnerRect(... item.data, x, y) && (this.activeShape = item)
break;
case 'circle':
this.isInnerCircle(item.x, item.y, item.r, x, y) && (this.activeShape = item)
break;
case 'line':
var lineNumber = item.data.length / 2 - 1
var flag = false
for(let i = 0; i < lineNumber; i++){
let index = i*2;
flag = this.isInnerPath(item.data[index], item.data[index+1], item.data[index+2], item.data[index+3], x, y, item.lineWidth || 1)
if(flag){
this.activeShape = item
break; }}}})Copy the code
Obtain x and Y coordinates from the origin of the canvas based on the original zoom state according to the mouse position, and call different methods according to different types to determine whether the canvas is in the current shape.
It then registers the event of dragging a canvas or a shape based on whether it is inside the shape
if(!this.activeShape){
this.wrapDom.style.cursor = 'grabbing'
this.El.addEventListener('mousemove'.this.moveCanvasFunc, false)}else {
this.wrapDom.style.cursor = 'all-scroll'
this.shapedOldX = null
this.shapedOldY = null
this.El.addEventListener('mousemove'.this.moveShapeFunc, false)}Copy the code
If inside the shape, modify the shape position parameter and call this.render() to re-render the canvas
// Move the shape
moveShapeFunc = (e) = > {
var moveX = e.offsetX - (this.shapedOldX || this.targetX);
var moveY = e.offsetY - (this.shapedOldY || this.targetY);
moveX /= this.scale
moveY /= this.scale
switch(this.activeShape.type){
case 'rect':
let x = this.activeShape.data[0]
let y = this.activeShape.data[1]
let width = this.activeShape.data[2]
let height = this.activeShape.data[3]
this.activeShape.data = [x + moveX, y + moveY, width, height]
break;
case 'circle':
this.activeShape.x += moveX
this.activeShape.y += moveY
break;
case 'line':
var item = this.activeShape;
var lineNumber = item.data.length / 2
for(let i = 0; i < lineNumber; i++){
let index = i*2;
item.data[index] += moveX
item.data[index + 1] += moveY
}
}
this.shapedOldX = e.offsetX
this.shapedOldY = e.offsetY
this.render()
}
Copy the code
Moving the shape also gets the amount of movement of the canvas based on the original scale (you can see above except this.scale), moveX, moveY, and adds the amount of movement to the position coordinates of the selected shape.
Save the current offset this.shapedOldX, this.shapedOldY for the next event.
Determine if it is inside the shape
1. Determine whether it is in a rectangular box according to the x and y coordinates currently calculated, determine whether it is smaller than the x and y coordinates of the rectangle, and determine whether it is larger than the lower-right coordinates of the rectangle (x + width) and (y + height).
According to the x and y coordinates, calculate the distance from the center of the circle. If the distance is less than or equal to the radius of the circle, it indicates that the circle is inside the circle.
Assume that line AB is 90 and click C to determine whether AC or BC is greater than AD. If so,C must not be in the line segment, and the vertical distance CH between C and AB must be less than or equal to half of the line segment width.
Only a single line segment can be judged. If multiple connection lines are not judged accurately, there will be extra parts at the connection that cannot be judged. The diagram below:
This is a line segment of width 90, and the red area can be identified by the above method, but the part that the arrow points to cannot be identified.
It is also left out here because if the Angle between line segments is less than 90deg, the default shape will be:
You can see the miterLimit attribute, lineJoin attribute and lineCap attribute. These attributes have a great influence on line segments. Here we only demonstrate the judgment of single line segment in default state.
conclusion
OK, the above has finished the requirements described at the beginning, interested friends can change the example in the Demo to modify the parameters to see the effect.
If you have any questions or omissions, please correct them. Thank you.