Canvas drawing

The following implementations are in the GitHub repository 👉 electronic-screenshot, demo

Arrows to draw

It’s basically a trig function calculation, as shown in the figure:

/** PI/6 */
const ARROW_ANGLE = Math.PI / 6;

export function drawArrow(
  ctx: CanvasRenderingContext2D,
  startPoint: Point,
  endPoint: Point,
  width: number,
  fillStyle: CanvasFillStrokeStyles['fillStyle']
) {
  const [x1, y1] = startPoint;
  const [x2, y2] = endPoint;
  const alpha = Math.atan((y1 - y2) / (x1 - x2));
  /** BD length */ when EA points overlap
  const minArrowHeight = Math.abs(
    (x2 - x1) / (Math.cos(alpha) * Math.cos(ARROW_ANGLE))
  );
  /** BD actual length */
  const arrowHeight = Math.min(minArrowHeight, 6 + width * 2);
  const d = x2 < x1 ? -1 : 1;
  const [x3, y3] = [
    x2 - Math.cos(alpha - ARROW_ANGLE) * arrowHeight * d,
    y2 - Math.sin(alpha - ARROW_ANGLE) * arrowHeight * d,
  ];
  const [x4, y4] = [
    x2 - Math.cos(alpha + ARROW_ANGLE) * arrowHeight * d,
    y2 - Math.sin(alpha + ARROW_ANGLE) * arrowHeight * d,
  ];
  const [xa, ya] = [(x4 - x3) / 3, (y4 - y3) / 3];
  const [x5, y5] = [x3 + xa, y3 + ya];
  const [x6, y6] = [x4 - xa, y4 - ya];
  const paths: Array<Point> = [
    [x1, y1],
    [x5, y5],
    [x3, y3],
    [x2, y2],
    [x4, y4],
    [x6, y6],
  ];
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  paths.slice(1).forEach((point) = >ctx.lineTo(... point)); ctx.closePath(); ctx.fillStyle = fillStyle; ctx.fill(); }Copy the code

Effect of the brush

If only multiple points are connected in straight lines, when the collection points are not dense enough, uneven lines will appear, as shown in the figure below:

If you’ve ever used Photoshop, you know how powerful Bezier curves are. Here we used the bezier curves to make them smooth.

To draw a quadratic Bezier curve, you need a starting point, a control point, and an end point. When only two points are directly connected, more than three points are taken three points each time, the second point is the control point, the middle point of the second point and the third point is the end point; Then repeat the process with the control point and midpoint of this step and the next point, until there is only one point left, directly connected.

export function drawCurve(
  ctx: CanvasRenderingContext2D,
  path: Array<Point>,
  lineWidth: number,
  strokeStyle: CanvasFillStrokeStyles['strokeStyle']
) {
  if (path.length < 2) return;
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = strokeStyle;
  ctx.lineCap = 'round';
  ctx.beginPath();
  let startPoint = path[0]; ctx.moveTo(... startPoint);for (let i = 1; i < path.length - 1; i++) {
    /** controlPoint, nextPoint */
    const [[cx, cy], [nx, ny]] = path.slice(i, i + 2);
    /** endPoint */
    const [ex, ey] = [cx + (nx - cx) / 2, cy + (ny - cy) / 2]; ctx.quadraticCurveTo(cx, cy, ex, ey); startPoint = [ex, ey]; } ctx.lineTo(... path.slice(-1) [0]);
  ctx.stroke();
  ctx.closePath();
}
Copy the code

The elliptical draw

Here we draw the inscribed ellipse of a rectangle controlled by two diagonal points (startPoint/endPoint). Since the Canvas only has the arc() method to draw a perfect circle, you have to scale the canvas first.

export function drawEllipse(
  ctx: CanvasRenderingContext2D,
  startPoint: Point,
  endPoint: Point,
  lineWidth: number,
  strokeStyle: CanvasFillStrokeStyles['strokeStyle']
) {
  const [[x1, y1], [x2, y2]] = [startPoint, endPoint];
  const [r1, r2] = [x1 - x2, y1 - y2].map((n) = > Math.abs(n / 2));
  const [x0, y0] = [(x1 + x2) / 2, (y1 + y2) / 2];
  const r = Math.max(r1, r2);
  const [rx, ry] = [r1 / r, r2 / r];
  ctx.save();
  ctx.scale(rx, ry);
  ctx.beginPath();
  ctx.arc(x0 / rx, y0 / ry, r, 0.2 * Math.PI);
  ctx.closePath();
  ctx.restore();
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = strokeStyle;
  ctx.stroke();
}
Copy the code

Picture Mosaic

The basic idea is to divide the canvas into a checkerboard, calculate the average color of each checkerboard, and set the average color of all pixels of the checkerboard.

First calculate the pixel color of each Mosaic square:

/** size: Mosaic size */
export function createMosaicData(ctx: CanvasRenderingContext2D, size: number) {
  const { width, height } = ctx.canvas;
  // Get the number of horizontal and vertical squares
  const [wl, hl] = [Math.ceil(width / size), Math.ceil(height / size)];
  // Canvas raw pixel data
  const data = ctx.getImageData(0.0, width, height).data;
  // Generate a new WL * HL pixel data
  const md = new Uint8ClampedArray(wl * hl * 4);
  for (let i = 0; i < wl * hl; i++) {
    const sy = Math.floor(i / wl);
    const sx = i - sy * wl;
    let [sumR, sumG, sumB, total] = [0.0.0.0];
    // Calculate the average color in each square of the original canvas
    for (let y = sy * size; y < Math.min((sy + 1) * size, height); y++) {
      const stratY = y * width;
      for (let x = sx * size; x < Math.min((sx + 1) * size, width); x++) {
        const sIndex = (stratY + x) * 4;
        (sumR += data[sIndex]),
          (sumG += data[sIndex + 1]),
          (sumB += data[sIndex + 2]),
          total++;
      }
    }
    [md[i * 4], md[i * 4 + 1], md[i * 4 + 2], md[i * 4 + 3]] = [
      sumR / total,
      sumG / total,
      sumB / total,
      255,]; }return md;
}
Copy the code

If you set size to 10, you can get the pixel data of a canvas shrunk by 10 times:

const size = 10;
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
const [wl, hl] = [Math.ceil(width / size), Math.ceil(height / size)];
ctx.putImageData(new ImageData(createMosaicData(ctx, size), wl, hl), 0.0);
Copy the code

Finally, we only need to draw the scaled pixel information on the canvas in equal proportions to obtain the Mosaic effect of the original canvas:

export function mosaicCnavas(ctx: CanvasRenderingContext2D, size: number) {
  const { width, height } = ctx.canvas;
  const md = createMosaicData(ctx, size);
  const [wl, hl] = [Math.ceil(width / size), Math.ceil(height / size)];
  const newData = new Uint8ClampedArray(width * height * 4);
  for (let y = 0; y < hl; y++) {
    const [startY, endY] = [y * size, Math.min((y + 1) * size, height)];
    for (let x = 0; x < wl; x++) {
      const [startX, endX] = [x * size, Math.min((x + 1) * size, width)];
      const index = (y * wl + x) * 4;
      const [R, G, B, A] = [md[index], md[index + 1], md[index + 2].255];
      // Set all points within the box to the average color
      for (let y0 = startY; y0 < endY; y0++) {
        for (let x0 = startX; x0 < endX; x0++) {
          const nIndex = (y0 * width + x0) * 4;
          (newData[nIndex] = R),
            (newData[nIndex + 1] = G),
            (newData[nIndex + 2] = B),
            (newData[nIndex + 3] = A);
        }
      }
    }
  }
  ctx.putImageData(new ImageData(newData, width, height), 0.0);
}
Copy the code

If partial areas need to be mosaicated, the areas need to be mosaicated need to be calculated. The following is illustrated by brush drawing.

First we will get a brush path and brushWidth brushWidth. Here we will Mosaic all the path points with radius r=brushWidth/2 of the circular region.

const createDrawMosaicLayerData = (
  width: number,
  height: number,
  path: Array<Point>,
  r: number
) = >
  path.reduce((data, [x0, y0]) = > {
    const [startX, endX] = [Math.max(0, x0 - r), Math.min(x0 + r, width)];
    const [startY, endY] = [Math.max(0, y0 - r), Math.min(y0 + r, height)];
    for (let y = startY; y < endY; y++) {
      for (let x = startX; x < endX; x++) {
        if ((x - x0) ** 2 + (y - y0) ** 2 < r ** 2) {
          data[y * width + x] = true; }}}return data;
  }, <Array<boolean>>Array(width * height).fill(false));
Copy the code

Using the above method, we get a Boolean array of width * height to indicate whether each point on the canvas needs processing.

export function drawMosaic(
  ctx: CanvasRenderingContext2D,
  path: Array<Point>,
  size: number,
  brushWidth: number,
  data: Uint8ClampedArray
) {
  const { height, width } = ctx.canvas;
  const drawData = createDrawMosaicLayerData(
    width,
    height,
    path,
    brushWidth / 2
  );
  const [wl, hl] = [Math.ceil(width / size), Math.ceil(height / size)];
  /** Element picture pixel data */
  const originalData = ctx.getImageData(0.0, width, height).data;
  /** Pixel data to be processed */
  const newData = new Uint8ClampedArray(width * height * 4);
  for (let y = 0; y < hl; y++) {
    const [startY, endY] = [y * size, Math.min((y + 1) * size, height)];
    for (let x = 0; x < wl; x++) {
      const [startX, endX] = [x * size, Math.min((x + 1) * size, width)];
      const index = (y * wl + x) * 4;
      const [R, G, B, A] = [data[index], data[index + 1], data[index + 2].255];
      for (let y0 = startY; y0 < endY; y0++) {
        for (let x0 = startX; x0 < endX; x0++) {
          const dIndex = y0 * width + x0;
          const nIndex = dIndex * 4;
          DrawData [dIndex] is used to determine whether to assign the original RGBA value or the Mosaic RGB value
          if (drawData[dIndex]) {
            newData[nIndex] = R;
            newData[nIndex + 1] = G;
            newData[nIndex + 2] = B;
            newData[nIndex + 3] = A;
          } else {
            newData[nIndex] = originalData[nIndex];
            newData[nIndex + 1] = originalData[nIndex + 1];
            newData[nIndex + 2] = originalData[nIndex + 2];
            newData[nIndex + 3] = originalData[nIndex + 3];
          }
        }
      }
    }
  }
  ctx.putImageData(new ImageData(newData, width, height), 0.0);
}
Copy the code


TODO: There are still shortcomings in the calculation of the above drawing area. For example, if the distance between waypoints is too large (>2r), there will be incoherent drawing, and the solution direction is still Bezier curve 🌺

Reference Documents:

  • How does canvas advanced draw smooth curves
  • MDN CanvasRenderingContext2D.quadraticCurveTo()
  • MDN ImageData
  • MDN CanvasRenderingContext2D.putImageData
  • Modern JavaScript tutorial – ArrayBuffer, binary array

github.com/canvascat/n…