1. What is Canvas?

The Canvas API provides a way to draw graphics using JavaScript and HTML’s < Canvas > element. It can be used for animation, game graphics, data visualization, image editing, and real-time video processing.

The Canvas API focuses primarily on 2D graphics. The WebGL API, which also uses the canvas> element, is used to draw hardware-accelerated 2D and 3D graphics. All major browsers now support it.

When the Electron process monitoring tool Electron -re was used to draw dynamic line charts on Canvas to show the CPU/Memory usage changes of the process.

This time we use the Canvas base drawing API to drag and scale a rectangle element.

Second, pre-knowledge

pixel

Some concepts about screen pixels:

  • Physical Pixels (DP)

    Physical pixels are also called device pixels. We often hear about the resolution of mobile phones and physical pixels. For example, the physical resolution of iPhone 7 is 750 * 1334. The screen is made up of pixels, meaning 750 pixels horizontally and 1334 vertically.

  • Device independent Pixel (DIP)

    The Iphone4 has a physical resolution of 640 * 980, while the 3gs has a physical resolution of 320 * 480. If we draw an image with a real layout width of 320px, On the iphone4, only half of the content was there, and the rest was blank. To avoid this problem, we introduced logical pixels, which were set to 320px on both phones for easy drawing.

  • Device Pixel ratio (DPR)

    The above device-independent pixels are basically for the convenience of calculation. We unified the logical pixels of the device, but the physical pixels represented by each logical pixel are not determined. In order to determine the relationship between physical pixels and logical pixels without scaling, we introduced the concept of device Pixel ratio (DPR) : Device pixel ratio = device pixel/logical pixel (DPR = DP/DIP).

Canvas width and CSS width

Canvas The default size of the Canvas is 300 pixels x 150 pixels (width x height, pixels in px). However, you can customize the Canvas size using the HTML height and width attributes.

Example:

<canvas width="600" height="300" style="width: 300px; height: 150px"></canvas>
Copy the code
  • Width and height in style respectively represent the width and height occupied by the Canvas element on the interface, that is, the width and height in style, that is, device-independent pixels (CSS logical pixels).
  • Width and height in the attribute represent the width and height of the actual pixel of Canvas, and the image drawn by Canvas is a bitmap, that is, a physical pixel (one bitmap pixel corresponds to one physical pixel).

In the case of device DPR = 2, if the width and height of Canvas are the same as the width and height of Css, that is to say, the number of physical pixels in Canvas is smaller and cannot correspond to a single physical pixel on the screen. Canvas will draw the graph and then enlarge it hard to fill the Canvas, so the graph will be blurred.

This problem does not occur in the case of device DPR = 1, where a Canvas pixel corresponds to both a physical pixel and a CSS pixel.

Resolve drawing blur

Principle: Make Canvas pixel and screen physical pixel one-to-one correspondence.

Steps:

  • Let’s start by making the canvas wider than the screen’s physical pixel width and height
  • Scale the canvas to display the graphics to normal size
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio; // Suppose DPR is 2
// Get the width and height of the CSS
const {width: cssWidth, height: cssHeight} = canvas.getBoundingClientRect();
// According to DPR, enlarge the canvas pixel so that 1 Canvas pixel is equal to 1 physical pixel
canvas.width = dpr * cssWidth;
canvas.height = dpr * cssHeight;
// As the canvas expands, so does the canvas coordinate system. If the original coordinate system is used, the drawing content will shrink, so the drawing scale needs to be enlarged
ctx.scale(dpr,dpr);
Copy the code

Coordinate system in Canvas

The coordinate system in 2D drawing environment is the same as the window coordinate system by default. It takes the upper left corner of canvas as the origin of the coordinate, and it is positive along the x axis and positive along the y axis. The unit of canvas coordinate is “px”.

Iii. Key steps

1. Call and interaction modes

// Create an artboard
const drawer = new Drawer('#drawer');
// Create the geometry
const rect = new DragableAndScalableRect({
  x: 500.// The x coordinates of the center point of the graph, not the upper-left coordinates
  y: 300.// Graph center point y coordinates, not the upper left corner coordinates
  width: 200.// Graphic width
  height: 200.// Graph height
  minWidth: 20.// Minimum width
  minHeight: 20.// Minimum height
  cornerWidth: 20 // The width of a small rectangle with four corners for scaling
});
// Add the geometry to the artboard
drawer.addPolygon(rect);
Copy the code

From the above, we adopted an object-oriented approach and created several classes for separating responsibilities:

  • The Drawer: Drawing class is used to add graphics, call drawing methods, listen for window changes to redraw, respond to mouse events, and more.
  • DrawHelper: The auxiliary drawing class is used to perform lattice drawing, get the position of the mouse in the Canvas coordinate system, clear rectangular areas, check whether the graphics parameters are valid, and so on.
  • Polygon: Geometric graphics class that provides basic and template methods, some of which require concrete graphics subclasses to implement.
  • DragableAndScalableRect: Drags-and-scales classes are implemented in this class to update the coordinates of individual graphics, calculate geometric positions, specify logic for receipt and destruction, determine whether a point is inside the graphics, etc.

2. Create the artboard class

1) duties

  • Add graphics.
  • Call the graph drawing method.
  • Listen for window changes to redraw.
  • Respond to mouse events.

2) Function realization point

  • In the constructor, we obtain the Canvas element through the CSS selector, and set the width and height of the Canvas. At the same time, we use the resize method to scale the Canvas to make the screen pixels correspond to the Canvas pixels one by one.
  • Multiple graphic elements can be added to an artboard and stored in an array of Polygons, to which the Canvas’s drawing context is bound. Graphics can also be removed from the artboard, and the corresponding CTX is unbound from the element.
  • The Render method of the artboard is used to draw all elements of the canvas, calling the Draw method of all Polygon objects for specific content drawing.
  • The sketchpad class needs to bind mouse press, move, and lift events. When the mouse is down, traverse the polygons array to select the first clicked object and determine the current polygon object’s event response. In this case, there are only two events: zoom and drag. If there is a polygon object that needs to respond during mouse movement, the polygon object will be sent the current mouse coordinate information in real time for coordinate calculation.
/* -------------- canvas canvas class -------------- */
  class Drawer {
    constructor(selector) {
      this.polygons = [];
      this.me = document.querySelector(selector);
      this.ctx = null;
      this.target = null; // Single point operation target

      this.me.onmousedown = this.onMouseDown;
      this.me.onmouseup = this.onMouseUp;
      this.me.onmousemove = this.onMouseMove;

      if (this.me.getContext) {
        this.ctx = this.me.getContext('2d');
        this.resize();
      } else {
        throw new Error('canvas context:2d is not available! '); }}onResize() {
      this.resize();
      this.clear();
      this.render();
    }

    resize() {
      const rect = this.me.getBoundingClientRect();
      this.me.width = rect.width * window.devicePixelRatio;
      this.me.height = rect.height * window.devicePixelRatio;
      this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
    }

    clear() {
      const rect = this.me.getBoundingClientRect();
      this.ctx.clearRect(0.0.this.me.width, this.me.height);
    }

    render() {
      this.polygons.forEach(polygon= > polygon.draw());
    }

    addPolygon(polygon) {
      this.polygons.push(polygon);
      polygon.attach(this.ctx);
      polygon.draw();
    }

    removePolygon(polygon) {
      const index = this.polygons.indexOf(polygon);
      if(index ! = = -1) {
        this.polygons[index].destroy();
        this.polygons[index].detach();
        this.polygons.splice(index, 1);
      }
    }

    onMouseDown = (event) = > {
      const point = DrawHelper.getMousePosition(this.me, event);
      for (let i = 0; i < this.polygons.length; i++) {
        if (this.polygons[i].isInCornerPath(point) && this.polygons[i].scalable) {
          this.polygons[i].scaleStart(point);
          this.target = this.polygons[i];
          break;
        }
        if (this.polygons[i].isInPath(point) && this.polygons[i].dragable) {
          this.polygons[i].dragStart(point);
          this.target = this.polygons[i];
          break;
        }
      }
    }

    onMouseMove = (event) = > {
      const point = DrawHelper.getMousePosition(this.me, event);
      if (!this.target) return;
      switch (this.target.status) {
        case 'draging':
          this.target.drag(point)
          break;
        case 'scaling':
          this.target.scale(point)
          break;
        default:
          break;
      }
    }

    onMouseUp = (event) = > {
      const point = DrawHelper.getMousePosition(this.me, event);
      if (!this.target) return;
      switch (this.target.status) {
        case 'draging':
          this.target.dragEnd(point)
          break;
        case 'scaling':
          this.target.scaleEnd(point)
          break;
        default:
          break;
      }
      this.target = null; }}Copy the code

3. Create a draw helper class

1) duties

  • Perform a lattice drawing.
  • Clear the rectangular area.
  • Check whether the graph parameters are valid.

2) Function realization point

  • drawPointsMethod will get a bitmap array, using Canvascontext.beginPathMethod starts drawing,Ctx. moveTo moves to a certain point.ctx.lineToConnecting points are line segments,ctx.stokeDraw paths. Note: In this example, rectangles are drawn without using the built-in API directlystrokeRect(x, y, width, height), and directly use this method to draw line segments.
  • getMousePositionIt is used to obtain the position information of the mouse relative to the canvas, that is, the coordinates in the canvas. Note that CSS pixels need to be scaled by the canvas:rect.top * (canvas.height / rect.height).
  • ClearRect clears a rectangular region with specified coordinates and width and height.
  • CheckGeometry checks whether the graph parameters are valid, and the parameters must be positive.
/* -------------- draw helper class -------------- */
  class DrawHelper {
    // Perform the drawing
    static drawPoints(ctx, points) {
      const firstPoint = points[0];
      ctx.strokeStyle = 'black';
      ctx.beginPath();

      ctx.moveTo(firstPoint.x, firstPoint.y);
      points.forEach(point= > {
        ctx.lineTo(point.x, point.y);
      });

      ctx.lineTo(firstPoint.x, firstPoint.y);
      ctx.stroke();
    }

    // Get the mouse position
    static getMousePosition(canvas, event) {
      const rect = canvas.getBoundingClientRect();
      const x = event.clientX - rect.left * (canvas.width / rect.width);
      const y = event.clientY - rect.top * (canvas.height / rect.height);

      return {x, y};
    }

    // Clear the rectangle area
    static clearRect(ctx, x, y, width, height) {
      ctx.clearRect(x, y, width, height);
    }

    // Check the parameters geometry
    static checkGeometry(geometry) {
      const keys = Object.keys(geometry);
      for (let i = 0; i < keys.length; i++) {
        if (geometry[keys[i]] < 0) {
          throw new Error(`geometry: value of ${keys[i]}is no less than 0! `); }}return geometry;
    }

    static drawRect(){}}Copy the code

4. Create a generic geometry class

1) duties

  • Provides template methods and properties for subclasses.
  • Provides an external interface that the artboard class calls in response to user actions.

2) Function realization point

  • ScaleStart starts to scale.
  • Scale is zooming.
  • ScaleEnd Scaling ends.
  • DragStart Starts to drag.
  • Drag Dragging.
  • DragEnd The drag ends.
/* -------------- geometry class -------------- */
  class Polygon {
    dragable=false
    scalable=false
    status='pending'
    prePoint=null
    constructor() {
      this.ctx = null;
    }

    draw() {}
    destroy() {}

    attach(ctx) {
      this.ctx = ctx;
    }
    detach() {
      this.ctx = null;
    }

    isInPath(point) { return false }
    isInCornerPath(point) { return false }

    scaleStart(point) {
      this.status = 'scaling';
      this.prePoint = point;
    }
    scale(point) {
      this.destroy();
      this.update(point);
      this.draw();
    }
    scaleEnd(point) {
      this.status = 'pending';
      this.destroy();
      this.update(point);
      this.draw();
      this.prePoint = null;
    }

    dragStart(point) {
      this.status = 'draging';
      this.prePoint = point;
    }
    drag(point) {
      this.destroy();
      this.update(point);
      this.draw();
    }
    dragEnd(point) {
      this.status = 'pending';
      this.destroy();
      this.update(point);
      this.draw();
      this.prePoint = null; }}Copy the code

5. Create drag and stretch rectangle classes

1) duties

  • Update of graph coordinates
  • Geometric position calculation
  • Concrete logical implementations of draw and destroy
  • Determines whether a point is in the graph response region

2) Function realization point

  • IsInPath determines whether a coordinate point is within the four vertices of the current rectangle.
  • IsInCornerPath Determines whether a coordinate point is within four rectangles of the four scalable vertices of the current rectangle.
  • The draw method calls DrawHelper to draw on the canvas an array of lattice coordinates that make up the various parts of the current figure.
  • Destroy locally clears the canvas based on the matrix coordinates and width and height. Note that you need to adjust the width and height of the incoming canvas so that the actual clearing area is larger than the rectangle area; otherwise, some of the rectangle’s edges may remain on the canvas.
  • updateWhenDragingUpdate the matrix coordinates of the graph as you drag. First, prePoint, that is, the last trigger coordinate point, is recorded, and then the current coordinate point and prePoint are subtracted to obtain the distance difference. Finally will be the current graphCoordinate plus differenceYou get the center position of the latest graph, while the width and height of the graph remain the same.
  • updateWhenScalingWhen scaling, update the matrix coordinates of the graph. First, we need to record the prePoint, which is the last trigger coordinate point, and then subtract the current coordinate point from the prePointThe distance difference. Note that the coordinate algorithm of the graph is different from that of dragging. When zooming, coordinate points X and y need to be adjusted as well as the width and height of the graph.
    • Note that when scaling, check whether the minimum critical points of width and height are reached. If so, no further coordinate updates are made.
    • The displacement of x and y coordinates is half of the distance difference obtained before, because width and height are also changing.
    • The calculation of width and height is also different for each vertex.
      • Upper left: The distance difference is generated for both width and heightNegative gainThe effect. You can imagine that we started fromTop left to bottom rightWhen you pull, both width and heightnarrowThe direction from the top left to the bottom right is the direction of the coordinate systemPositive direction.
      • Upper right: The distance difference is generated for widthIs the gainEffect, on heightNegative gainThe effect.
      • Bottom right: The distance difference is generated for both width and heightIs the gainThe effect.
      • Bottom left: The distance difference is generated for widthNegative gainEffect, on heightIs the gainThe effect.
  • GetPoints calculates the vertex coordinates of the graph according to the graph attributes x, Y, width, height and so on.
class DragableAndScalableRect extends Polygon {
    minWidth = 0
    minHeight = 0
    constructor(geometry) {
      super(a);this.geometry = DrawHelper.checkGeometry(geometry);
      this.minWidth = 'minWidth' in geometry ? geometry.minWidth : this.minWidth;
      this.minHeight = 'minHeight' in geometry ? geometry.minHeight : this.minHeight;
      this.points = this.getPoints();
      this.cornerPoint = null;
      this.dragable = true;
      this.scalable = true;
    }

    // Determine if the click position is inside the graph
    isInPath(point, geometry) {
      const {x, y, width, height} = geometry || this.geometry;
      return (point.x>= x - width/2) &&
             (point.x <= x + width/2) &&
             (point.y>= y - height/2) &&
             (point.y <= y + height/2);
    }

    // Determine if the click position is in the four corners
    isInCornerPath(point) {
      const [rectPoints, ...cornerPoints] = this.points;
      const {cornerWidth} = this.geometry;
      for (let i = 0; i < rectPoints.length; i++) {
        if (
          this.isInPath( point, {... rectPoints[i],width: cornerWidth, height: cornerWidth})
          ) {
            this.cornerPoint = i;
            return true; }}this.cornerPoint = null;
      return false;
    }

    // Draw the graph according to the lattice
    draw() {
      this.points.forEach(pointArray= > {
        if (Array.isArray(pointArray)) {
          DrawHelper.drawPoints(this.ctx, pointArray); }}); }// Destroy the graph
    destroy() {
      const {width, height, cornerWidth} = this.geometry;
      const [rectPoints, ...cornerPoints] = this.points;
      const leftTopPoint = rectPoints[0];
      DrawHelper.clearRect(this.ctx, leftTopPoint.x - 1, leftTopPoint.y - 1, width + 2, height + 2);
      cornerPoints.forEach((cPoint) = > {
        DrawHelper.clearRect(this.ctx, cPoint[0].x - 1, cPoint[0].y - 1, cornerWidth + 2, cornerWidth + 2);
      });
    }

    updateWhenDraging(point) {
      const {prePoint} = this;
      this.geometry.x = this.geometry.x + (point.x - prePoint.x);
      this.geometry.y = this.geometry.y + (point.y - prePoint.y);
      this.points = this.getPoints();
      this.prePoint = point;
    }

    updateWhenScaling(point) {
      const {prePoint} = this;
      const xDistance = (point.x - prePoint.x);
      const yDistance = (point.y - prePoint.y);
      constnewGeometry = {... this.geometry};switch (this.cornerPoint) {
        case 0:
          newGeometry.x = this.geometry.x + (xDistance) / 2;
          newGeometry.y = this.geometry.y + (yDistance) / 2;
          newGeometry.width = this.geometry.width - (xDistance);
          newGeometry.height = this.geometry.height - (yDistance);
          break;
        case 1:
          newGeometry.x = this.geometry.x + (xDistance) / 2;
          newGeometry.y = this.geometry.y + (yDistance) / 2;
          newGeometry.width = this.geometry.width + (xDistance);
          newGeometry.height = this.geometry.height - (yDistance);
          break;
        case 2:
          newGeometry.x = this.geometry.x + (xDistance) / 2;
          newGeometry.y = this.geometry.y + (yDistance) / 2;
          newGeometry.width = this.geometry.width + (xDistance);
          newGeometry.height = this.geometry.height + (yDistance);
          break;
        case 3:
          newGeometry.x = this.geometry.x + (xDistance) / 2;
          newGeometry.y = this.geometry.y + (yDistance) / 2;
          newGeometry.width = this.geometry.width - (xDistance);
          newGeometry.height = this.geometry.height + (yDistance);
          break;
        default:
          return;
      }

      if (
        newGeometry.width < this.minWidth ||
        newGeometry.height < this.minHeight
      ) {
        return;
      }
      this.geometry = newGeometry;
      this.points = this.getPoints();
      this.prePoint = point;
    }

    // Update the lattice coordinates in real time
    update(point) {
      switch (this.status) {
        case 'draging':
          this.updateWhenDraging(point);
          break;
        case 'scaling':
          this.updateWhenScaling(point);
          break;
        default:
          break; }}// Get the four corners of the rectangle
    getPointFromGeometry(x, y, width, height) {
      return {
        leftTopPoint: {
          x: x - width / 2.y: y - height / 2
        },
        rightTopPoint: {
          x: x + width / 2.y: y - height / 2
        },
        leftBottomPoint: {
          x: x - width / 2.y: y + height / 2
        },
        rightBottomPoint: {
          x: x + width / 2.y: y + height / 2}}; }// Get the geometry lattice
    getPoints() {
      const {x, y, width, height, cornerWidth} = this.geometry;
      const rectPosition = this.getPointFromGeometry(x, y, width, height);
      const leftTopPoint = rectPosition.leftTopPoint;
      const rightTopPoint = rectPosition.rightTopPoint;
      const leftBottomPoint = rectPosition.leftBottomPoint;
      const rightBottomPoint = rectPosition.rightBottomPoint;

      const leftTopRectPosition = this.getPointFromGeometry(leftTopPoint.x, leftTopPoint.y, cornerWidth, cornerWidth);
      const rightTopRectPosition = this.getPointFromGeometry(rightTopPoint.x, rightTopPoint.y, cornerWidth, cornerWidth);
      const rightBottomRectPosition = this.getPointFromGeometry(rightBottomPoint.x, rightBottomPoint.y, cornerWidth, cornerWidth);
      const leftBottomRectPosition = this.getPointFromGeometry(leftBottomPoint.x, leftBottomPoint.y, cornerWidth, cornerWidth);

      const leftTopRect = [
        leftTopRectPosition.leftTopPoint,
        leftTopRectPosition.rightTopPoint,
        leftTopRectPosition.rightBottomPoint,
        leftTopRectPosition.leftBottomPoint
      ];
      const rightTopRect = [
        rightTopRectPosition.leftTopPoint,
        rightTopRectPosition.rightTopPoint,
        rightTopRectPosition.rightBottomPoint,
        rightTopRectPosition.leftBottomPoint
      ];
      const rightBottomRect = [
        rightBottomRectPosition.leftTopPoint,
        rightBottomRectPosition.rightTopPoint,
        rightBottomRectPosition.rightBottomPoint,
        rightBottomRectPosition.leftBottomPoint
      ];
      const leftBottomRect = [
        leftBottomRectPosition.leftTopPoint,
        leftBottomRectPosition.rightTopPoint,
        leftBottomRectPosition.rightBottomPoint,
        leftBottomRectPosition.leftBottomPoint
      ];

      return[ [ leftTopPoint, rightTopPoint, rightBottomPoint, leftBottomPoint ], leftTopRect, rightTopRect, rightBottomRect, leftBottomRect ]; }}Copy the code

6. Handle window size changes

Listen for the window resize event, and then call the canvas’s onResize method to clear the canvas and redraw the various graphics objects that have been added to the canvas. Further consideration is the use of throttling and chattering functions for performance optimization.

window.onresize = () = > {
    drawer.onResize();
}
Copy the code

Four, complete source code

> > making address

Five, the conclusion

Canvas adds unlimited graphics operation capability and creativity to the front end. There are many application scenarios, such as image editing, animation rendering, 3D scene with WebGL, video frame processing, game development and so on. Very interesting things, energy and interest can be in-depth study.