mrm-canvas.ts

// Copyright (c) 2022 Tobias Briones. All rights reserved.
// SPDX-License-Identifier: BSD-3-Clause
// This file is part of https://github.com/mathsoftware/engineer
//
// This file is also available at https://github.com/repsymo/2dp-repsymo-solver
// under a different license.

import { TreeNode, newTreeNode } from './mrm';

export const parentElId = 'solutionTreeParent';

export abstract class MrmCanvas {
  public padding: number;
  private canvasEl: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;

  protected constructor() {
    this.padding = 0;
  }

  get width() {
    return this.canvasEl.width;
  }

  get height() {
    return this.canvasEl.height;
  }

  init(canvasEl: HTMLCanvasElement) {
    this.canvasEl = canvasEl;
    this.ctx = this.canvasEl.getContext('2d');
    this.updateCanvasSize();
    this.update();
  }

  render() {
    this.update();
    this.draw(this.ctx);
  }

  private updateCanvasSize() {
    const parentEl = document.getElementById(parentElId);
    this.canvasEl.width = parentEl.offsetWidth - this.padding;
    this.canvasEl.height = parentEl.offsetHeight - this.padding;
  }

  protected abstract update();

  protected abstract draw(ctx: CanvasRenderingContext2D);
}

// You should delete the @ts-ignore comments //

export class SolutionTreeCanvas extends MrmCanvas {
  private readonly axesCanvas: TreeAxesCanvas;
  public rootNode: TreeNode;
  private radiusPx: number;

  constructor() {
    super();
    this.axesCanvas = new TreeAxesCanvas();
    this.rootNode = newTreeNode();
  }

  init(canvasEl) {
    super.init(canvasEl);
    this.axesCanvas.init(canvasEl);
  }

  render() {
    super.render();
    this.axesCanvas.render();
  }

  protected update() {
    this.radiusPx = this.axesCanvas.cellSize / 4;
  }

  protected draw(ctx) {
    // @ts-ignore
    const memoization = new Set<string>();

    this.drawNode(ctx, this.rootNode, memoization);
  }

  // @ts-ignore
  private drawNode(ctx: CanvasRenderingContext2D, node: TreeNode, memoization: Set<string>) {
    const point2d = { x: node.decisionYear, y: node.machineAge };
    const point2dStr = JSON.stringify(point2d);
    const hasNeverBeenDrawn = !memoization.has(point2dStr);

    if (hasNeverBeenDrawn) {
      this.drawNodeLines(ctx, node, memoization);
    }
    this.drawNodeCircle(ctx, node);
    this.drawNodeContent(ctx, node);
    memoization.add(point2dStr);
  }

  // @ts-ignore
  private drawNodeLines(ctx: CanvasRenderingContext2D, node: TreeNode, memoization: Set<string>) {
    const padding = TreeAxesCanvas.AXIS_LABEL_SIZE_PX;
    const { x, y } = this.getNodeCP(node);
    const isNodeNext = (next: TreeNode) => node.machineAge === 1 && next.machineAge === 1;
    const isNodeBelow = (next: TreeNode) => node.machineAge < next.machineAge;

    const drawLineTo = (next: TreeNode) => {
      const nextX = (next.decisionYear * this.axesCanvas.cellSize) + padding;
      const nextY = this.height - (next.machineAge * this.axesCanvas.cellSize) - padding;
      ctx.beginPath();
      ctx.moveTo(x, y);
      ctx.lineTo(nextX, nextY);
      ctx.stroke();
    };

    /**
     * Computes the rectangle triangle given by this node's (x, y) and the next
     * node's (nextX, nextY) points.
     */
    const triangle = (next: TreeNode) => {
      const nextCP = this.getNodeCP(next);
      const nextX = nextCP.x;
      const nextY = nextCP.y;
      const triangleX = nextX - x;
      const triangleY = Math.abs(nextY - y);
      const hypotenuse = getHypotenuse(triangleX, triangleY);
      return { triangleX, triangleY, hypotenuse };
    };

    const drawUpRightLabel = (next: TreeNode, label: string) => {
      const { triangleX, triangleY, hypotenuse } = triangle(next);
      const labelX = x + (triangleX * this.radiusPx / hypotenuse);
      const labelY = y - (triangleY * this.radiusPx / hypotenuse) - 8;
      ctx.fillText(label, labelX, labelY);
    };

    const drawDownRightLabel = (next: TreeNode, label: string) => {
      const { triangleX, triangleY, hypotenuse } = triangle(next);
      const labelX = x + (triangleX * this.radiusPx / hypotenuse) - 4;
      const labelY = y + (triangleY * this.radiusPx / hypotenuse) + 16;
      ctx.fillText(label, labelX, labelY);
    };

    const drawRightLabel = (next: TreeNode, label: string) => {
      const { triangleX, triangleY, hypotenuse } = triangle(next);
      const labelX = x + (triangleX * this.radiusPx / hypotenuse) + 4;
      const labelY = y + (triangleY * this.radiusPx / hypotenuse) + 16;
      ctx.fillText(label, labelX, labelY);
    };

    const drawLabelTo = (next: TreeNode, label: string) => {
      ctx.font = '12px Poppins';
      ctx.textAlign = 'center';
      ctx.fillStyle = 'black';

      if (isNodeBelow(next)) {
        drawUpRightLabel(next, label);
      }
      else if (isNodeNext(next)) {
        drawRightLabel(next, label);
      }
      else {
        drawDownRightLabel(next, label);
      }
    };

    if (node.k) {
      drawLineTo(node.k);
      drawLabelTo(node.k, 'K');
      this.drawNode(ctx, node.k, memoization); // Recursive call
    }
    if (node.r) {
      drawLineTo(node.r);
      drawLabelTo(node.r, 'R');
      this.drawNode(ctx, node.r, memoization); // Recursive call
    }
  }

  private drawNodeCircle(ctx: CanvasRenderingContext2D, node: TreeNode) {
    const { x, y } = this.getNodeCP(node);
    ctx.beginPath();
    ctx.arc(x, y, this.radiusPx, 0, 2 * Math.PI);
    ctx.fillStyle = 'white';
    ctx.fill();
    ctx.stroke();
  }

  private drawNodeContent(ctx: CanvasRenderingContext2D, node: TreeNode) {
    ctx.font = '24px Poppins';
    ctx.textAlign = 'center';
    ctx.fillStyle = 'black';
    const txt = String(node.machineAge);
    const txtMetrics = ctx.measureText(txt);
    const txtHeight = txtMetrics.actualBoundingBoxAscent + txtMetrics.actualBoundingBoxDescent;
    const { x, y } = this.getNodeCP(node);
    ctx.fillText(txt, x, y + txtHeight / 2);
  }

  private getNodeCP(node: TreeNode) {
    return {
      x: (node.decisionYear * this.axesCanvas.cellSize) + TreeAxesCanvas.AXIS_LABEL_SIZE_PX,
      y: this.height - (node.machineAge * this.axesCanvas.cellSize) - TreeAxesCanvas.AXIS_LABEL_SIZE_PX
    };
  }
}

export class TreeAxesCanvas extends MrmCanvas {
  public static readonly AXIS_LABEL_SIZE_PX = 24;
  public maxAbscissa: number;
  public maxOrdinate: number;
  private cellSizePx: number;

  constructor() {
    super();
    this.padding = TreeAxesCanvas.AXIS_LABEL_SIZE_PX;
    this.maxAbscissa = 5;
    this.maxOrdinate = 8;
  }

  get cellSize() {
    return this.cellSizePx;
  }

  protected update() {
    this.cellSizePx = this.width / (this.maxAbscissa + 1);
  }

  protected draw(ctx) {
    ctx.font = '12px Poppins';
    ctx.fillStyle = 'black';
    ctx.strokeStyle = 'black';

    ctx.moveTo(this.padding, 0);
    ctx.lineTo(this.padding, this.height - this.padding);
    ctx.lineTo(this.width, this.height - this.padding);
    ctx.lineWidth = 1;
    ctx.stroke();
    this.drawXLabels(ctx);
    this.drawYLabels(ctx);
  }

  private drawXLabels(ctx) {
    ctx.textAlign = 'center';

    for (let i = 0; i <= this.maxAbscissa; i++) {
      const x = (i * this.cellSizePx) + this.padding;
      ctx.fillText(String(i), x, this.height);
    }
  }

  private drawYLabels(ctx) {
    ctx.textAlign = 'start';

    for (let i = 1; i <= this.maxOrdinate; i++) {
      const y = this.height - (i * this.cellSizePx) - this.padding;
      ctx.fillText(String(i), 0, y);
    }
  }
}

function getHypotenuse(triangleX: number, triangleY: number) {
  return Math.sqrt(Math.pow(triangleX, 2) + Math.pow(triangleY, 2));
}