Source: drawing.js

/** @module drawing */

import * as consts from './consts.js';
import * as linalg from './linalg.js';
import * as menus from './menus.js';

/**
 * get the machine drawing canvas
 * @returns the canvas object on which the machine is drawn
 */
export function get_canvas() {
  return document.getElementById('machine_drawing');
}

/**
 * get the position of the mouseclick event wrt canvas
 * @param {Object} e
 * @returns {Array<float>} x and y position of the mouseclick wrt canvas
 */
export function event_position_on_canvas(e) {
  const rect = get_canvas().getBoundingClientRect();
  const x = (e.clientX - rect.left)*window.devicePixelRatio;
  const y = (e.clientY - rect.top)*window.devicePixelRatio;
  return [x, y];
}

function canvas_size() {
  const rect = get_canvas().getBoundingClientRect();
  return [rect.right*window.devicePixelRatio, rect.bottom*window.devicePixelRatio];
}

/**
 * get the position of the mouseclick event wrt canvas
 * @param {Array<float>} canvas_pt - the [x, y] position wrt canvas
 * @returns {Array<float>} x and y position wrt window
 */
export function canvas_px_to_window_px(canvas_pt) {
  const rect = get_canvas().getBoundingClientRect();
  return linalg.add([rect.left, rect.top], linalg.scale(1/window.devicePixelRatio, canvas_pt));
}

/**
 * computes an appropriate text size to display the label
 * @param {int} textbox_width - width of textbox in pxiels
 * @param {string} text - the text message to display
 * @returns {int} fontsize in pixels
 */
function text_size_heuristic(textbox_width, text) {
  const default_size = consts.TEXT_SIZING_CONSTS.b+
                       Math.exp(consts.TEXT_SIZING_CONSTS.k*text.length+consts.TEXT_SIZING_CONSTS.a);
  return textbox_width/consts.DEFAULT_VERTEX_RADIUS*default_size;
}

/**
 * draw text on the canvas
 * @param {string} text - the text you want to draw on the screen
 * @param {Array<float>} pos - the position wrt canvas
 * @param {float} size - font size
 * @param {string} text_align - choice from {'center', 'left', 'right'}
 * @param {Array<string>} color_map - an array of colors the same length as the text coding the color of each char
 */
export function draw_text(text, pos, size, color_map, text_align='center') {
  const ctx = get_canvas().getContext('2d');
  ctx.font = `${size}px Sans-Serif`;
  ctx.textAlign = text_align;
  ctx.textBaseline = 'middle';
  if (!color_map) {
    ctx.strokeStyle = 'white'; // draw white outline around text
    ctx.lineWidth = 8;  // Set width for white outline around text
    ctx.strokeText(text, ...pos);
    ctx.fillStyle = 'black';
    ctx.fillText(text, ...pos);
    ctx.strokeStyle = consts.DEFAULT_INPUT_COLOR;
  } else {  // we want to control the individual character color
    for (let i = 0; i < text.length; ++i) {
      ctx.fillStyle = color_map[i];
      ctx.fillText(text.charAt(i), pos[0]+i*consts.DEFAULT_VIZ_SIZE, pos[1]);
      ctx.fillStyle = consts.DEFAULT_INPUT_COLOR;
    }
    ctx.strokeStyle = consts.DEFAULT_INPUT_COLOR;
    ctx.fillStyle = consts.DEFAULT_INPUT_COLOR;  // reset to default fill style
  }
}

/**
 * draw a circle on the current canvas
 * @param {int} x - x position from left wrt canvas
 * @param {int} y - y position from top wrt canvas
 * @param {int} r - radius of the circle
 * @param {boolean} highlighted - if true, fill the circle with color
 * @param {float} thickness - line width
 */
export function draw_circle(x, y, r, highlighted=false, thickness=1) {
  const ctx = get_canvas().getContext('2d');
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2*Math.PI);
  ctx.lineWidth = Math.round(thickness);
  ctx.stroke();
  if (highlighted) {
    ctx.fillStyle = consts.HIGHLIGHTED_VERTEX_COLOR;
    ctx.fill();
    ctx.fillStyle = 'black';  // reset to default for text and other drawings
  }
}

/**
 * draws a smaller concentric circle within the vertex
 * @param {Object} vertex - the vertex object in which we want to draw a circle
 */
export function draw_final_circle(vertex) {
  draw_circle(vertex.x, vertex.y, vertex.r*consts.FINAL_CIRCLE_SIZE);
}

/**
 * given the name of the vertex, grab the vertex from graph and draw it on screen
 * @param {Object} vertex - the vertex we want to draw
 */
export function draw_vertex(vertex) {
  // draw the circle
  draw_circle(vertex.x, vertex.y, vertex.r, vertex.highlighted);
  let text = vertex.name;
  if (menus.is_Moore()) {
    text += ` / ${vertex.moore_output}`;
  }
  // find an appropriate text size and draw the text inside the vertex
  const text_size = text_size_heuristic(vertex.r, text);
  draw_text(text, [vertex.x, vertex.y], text_size);
  if (vertex.is_start) {  // it is the starting vertex
    const tip1 = [vertex.x-vertex.r, vertex.y],
      tip2 = linalg.sub(tip1, linalg.scale(consts.START_TRIANGLE_SCALE, [vertex.r, vertex.r])),
      tip3 = linalg.sub(tip1, linalg.scale(consts.START_TRIANGLE_SCALE, [vertex.r, -vertex.r]));
    draw_triangle(tip1, tip2, tip3);
  }
  if (vertex.is_final) {
    draw_final_circle(vertex);
  }
}

/**
 * draw a triangle with three tips provided
 * @param {Array<float>} tip1
 * @param {Array<float>} tip2
 * @param {Array<float>} tip3
 */
export function draw_triangle(tip1, tip2, tip3) {
  const ctx = get_canvas().getContext('2d');
  ctx.beginPath();
  ctx.moveTo(...tip1);
  ctx.lineTo(...tip2);
  ctx.lineTo(...tip3);
  ctx.fill();
}

/**
 * draw an curved array with start, end and a mid
 * @param {Array<float>} start - where to begin
 * @param {Array<float>} end - where to end
 * @param {Array<float>} mid - control point for quadratic bezier curve
 * @param {string} edge_text - the text to display on the edge
 * @param {float} text_size - the size of the text
 */
// eslint-disable-next-line no-unused-vars
export function draw_arrow(start, end, mid, edge_text, text_size) {
  if (!mid) {
    mid = linalg.scale(1/2, linalg.add(start, end));
  }  // find mid if DNE
  const start_to_mid = linalg.sub(mid, start), mid_to_end = linalg.sub(end, mid), start_to_end = linalg.sub(end, start);
  const v1_on_v2 = linalg.proj(start_to_mid, start_to_end);
  const ortho_comp = linalg.scale(consts.EDGE_CURVATURE, linalg.sub(start_to_mid, v1_on_v2));
  const ctx = get_canvas().getContext('2d');
  ctx.beginPath();
  ctx.moveTo(...start);
  // we boost the curve by the orthogonal component of v1 wrt v2
  // ctx.quadraticCurveTo(...linalg.add(mid, ortho_comp), ...end);
  drawSplit(start, end, mid);
  ctx.stroke();
  const arrow_tip = linalg.normalize(linalg.sub(mid_to_end, ortho_comp), consts.ARROW_LENGTH);
  const normal_to_tip = linalg.normalize(linalg.normal_vec(arrow_tip), consts.ARROW_WIDTH/2);  // half the total width
  const tip1 = end,
    tip2 = linalg.add(linalg.sub(end, arrow_tip), normal_to_tip),
    tip3 = linalg.sub(linalg.sub(end, arrow_tip), normal_to_tip);
  draw_triangle(tip1, tip2, tip3);
}

/**
 * draw a split arrow with start, end and a mid
 * do not draw points if the distance from point to mid is less than radius
 * @param {Array<float>} start - where to begin
 * @param {Array<float>} end - where to end
 * @param {Array<float>} mid - control point for quadratic bezier curve
 */

// eslint-disable-next-line no-unused-vars
function drawSplit(start, end, mid) {
  // alert(end);
  // alert(linalg.scale(t**2, end));
  // console.log(start);
  const iterations = 20;
  const ctx = get_canvas().getContext('2d');
  ctx.lineWidth = 1;
  for (let i = 0; i <= iterations; i++) {
    let t = i/iterations;
    let point = linalg.add(linalg.scale((1-t)**2, start), linalg.add(linalg.scale(2*(1-t)*t, mid), linalg.scale(t**2, end)));
    //alert(point);
    //if (linalg.vec_len(linalg.sub(point, mid)) > radius)
    //if the x distance is less than estimated_pixels / 2 OR the y distance is less than radius, we dont draw
    ctx.lineTo(...point);
    ctx.stroke();
  }
}

/**
 * checks if (x, y) wrt canvas is inside vertex v
 * @param {Object} graph - the graph of interest
 * @param {float} x - x position
 * @param {float} y - y position
 * @param {string} v - name of the vertex
 * @returns {boolean} whether (x, y) is in v
 */
export function in_vertex(graph, x, y, v) {
  const vertex = graph[v];
  const diff = [x-vertex.x, y-vertex.y];
  return linalg.vec_len(diff) < vertex.r;
}

/**
 * detects if the current click is inside a vertex
 * @param {Object} graph - the graph of interest
 * @param {float} x - x position wrt canvas
 * @param {float} y - y position wrt canvas
 * @returns {string} returns the first vertex in the graph that contains (x, y), null otherwise
 */
export function in_any_vertex(graph, x, y) {
  for (const v of Object.keys(graph)) {
    if (in_vertex(graph, x, y, v)) {
      return v;
    }
  }
  return null;
}

/**
 * detects if the current click is inside edge text
 * @param {Object} graph - the graph of interest
 * @param {float} x - x position wrt canvas
 * @param {float} y - y position wrt canvas
 * @returns {Object} returns the first edge in the graph that contains (x, y), null otherwise
 */
export function in_edge_text(graph, x, y) {
  for (const vertex of Object.values(graph)) {
    for (const edge of vertex.out) {
      const [, , mid] = compute_edge_geometry(graph, edge);
      const diff = [x-mid[0], y-mid[1]];
      if (linalg.vec_len(diff) < consts.EDGE_TEXT_SACALING*vertex.r) {  // click within a certain radius
        return edge;
      }
    }
  }
  return null;
}

/**
 * computes the geometric start and end of the edge wrt canvas
 * @param {Object} graph - the graph containing the edge
 * @param {Object} edge - the edge we want to compute the start and end of
 * @returns {Array<Object>} [start, end], both 2d vectors
 */
export function compute_edge_start_end(graph, edge) {
  const {from, to} = edge;
  const s = graph[from], t = graph[to];
  let start, end;
  if (from === to) {
    const {angle1, angle2} = edge;  // additioanl attributes storing the start and end angle
    start = [s.x+s.r*Math.cos(angle1), s.y+s.r*Math.sin(angle1)];
    end = [t.x+t.r*Math.cos(angle2), t.y+t.r*Math.sin(angle2)];
  } else {
    const from_to = [t.x-s.x, t.y-s.y];
    const inner_vec = linalg.normalize(from_to, s.r);
    start = [s.x+inner_vec[0], s.y+inner_vec[1]];
    end = [t.x-inner_vec[0], t.y-inner_vec[1]];
  }
  return [start, end];
}

/**
 * computes the geometric start, end, and quadratic bezier curve control
 * @param {Object} graph - the graph containing the edge
 * @param {Object} edge - the edge we want to compute the start and end and mid of
 * @returns {Array<Object>} [start, end, mid], all 2d vectors
 */
export function compute_edge_geometry(graph, edge) {
  const {from, to, a1, a2} = edge;
  const s = graph[from];
  const [start, end] = compute_edge_start_end(graph, edge);
  // construct the two basis vectors
  const v1 = linalg.sub(end, start);
  let v2 = linalg.normalize(linalg.normal_vec(v1), s.r);
  let mid_vec = linalg.linear_comb(v1, v2, a1, a2);
  let mid = linalg.add(start, mid_vec);
  if (from === to && in_vertex(graph, ...mid, from)) {  // if edge falls inside the from vertex
    v2 = [-v2[0], -v2[1]];  // flip the second basis vector temporarily
    mid_vec = linalg.linear_comb(v1, v2, a1, a2);
    mid = [start[0]+mid_vec[0], start[1]+mid_vec[1]];
    edge.a2 = -edge.a2;  // also change the internal direction to make the flip permanent
  }
  return [start, end, mid];
}

/**
 * draws the edge object on the canvas
 * @param {Object} graph - the graph containing the edge
 * @param {Object} edge - the edge object you want to draw
 * @param {float} text_size - the font size of the transition label
 */
export function draw_edge(graph, edge, text_size) {
  let {transition, pop_symbol, push_symbol, move, mealy_output} = edge;
  const [start, end, mid] = compute_edge_geometry(graph, edge);
  let edge_text = transition;  // vanilla NFA only uses transition
  draw_arrow(start, end, mid, edge_text, text_size);

  if (menus.is_PDA()) {  // append pop and push if we have PDA
    edge_text += ','+pop_symbol+consts.ARROW_SYMBOL+push_symbol;
  } else if (menus.is_Turing()) {  // append push and left/right if we have turing
    edge_text += consts.ARROW_SYMBOL+push_symbol+','+move;
  } else if (menus.is_Mealy()) {
    edge_text += ' / ' + mealy_output;
  }
  draw_text(edge_text, mid, text_size);
}

/* load the asset before it is drawn */
const normal_trash = new Image();
normal_trash.src = '../assets/icon-trash.svg';
const red_trash = new Image();
red_trash.src = '../assets/icon-hover-trash.svg';
let trash = normal_trash;
let trash_inited = false;

export function over_trash(e) {
  const [x, y] = event_position_on_canvas(e);
  const trash_dims = { X: 1830, Y: 740, Width: 50, Height: 50 };
  const [canvas_width, canvas_height] = canvas_size();
  if (
    x >= canvas_width - (trash.width + 30) &&
    x <= canvas_width - (trash.width + 30) + trash_dims.Width &&
    y >= canvas_height - (trash.height + 30) &&
    y <= canvas_height - (trash.width + 30) + trash_dims.Height
  ) {
    trash = red_trash;
    draw_trash();
    return true;
  } else {
    trash = normal_trash;
    draw_trash();
    return false;
  }
}

export function draw_trash() {
  const canvas = get_canvas();
  const ctx = canvas.getContext('2d');
  if (!trash_inited) {
    trash.onload = () => {
      const [canvas_width, canvas_height] = canvas_size();  // duplicate logic here because canvas size may change
      const x = canvas_width - (trash.width + 30);
      const y = canvas_height - (trash.height + 30);
      ctx.drawImage(trash, x, y);
      canvas.addEventListener('mousemove', over_trash);  // constantly check if mouse is over trash
      trash_inited = true;
    };
  } else {
    const [canvas_width, canvas_height] = canvas_size();
    const x = canvas_width - (trash.width + 30);
    const y = canvas_height - (trash.height + 30);
    ctx.drawImage(trash, x, y);
  }
}

/**
 * draw the entire graph on the canvas
 * @param {Object} graph - the graph object to draw on the canvas
 */
export function draw(graph) {
  const canvas = get_canvas();
  canvas.width = window.innerWidth*window.devicePixelRatio;
  canvas.height = window.innerHeight*window.devicePixelRatio;
  for (const vertex of Object.values(graph)) {
    draw_vertex(vertex);
    for (const edge of vertex.out) {
      draw_edge(graph, edge, consts.EDGE_TEXT_SACALING*vertex.r);
    }
  }
  draw_trash();
}

/**
 * Computes the total size taken by the graph drawing on the canvas
 * @param {Object} graph - target graph
 * @returns {Array<Array<float>>} - [[xmin, ymin], [xmax, ymax]] enclosing the graph
 */
function compute_machine_drawing_size(graph) {
  const canvas = get_canvas();
  let min_x = canvas.width, min_y = canvas.height, max_x = 0, max_y = 0;
  for (const vertex of Object.values(graph)) {
    min_x = Math.min(min_x, vertex.x-2*vertex.r);
    min_y = Math.min(min_y, vertex.y-2*vertex.r);
    max_x = Math.max(max_x, vertex.x+2*vertex.r);
    max_y = Math.max(max_y, vertex.y+2*vertex.r);
    for (const edge of vertex.out) {
      const [, , mid] = compute_edge_geometry(graph, edge);
      const pxl_per_letter = consts.PIXEL_PER_SIZE_1_LETTER*consts.EDGE_TEXT_SACALING*vertex.r;
      // 2 for delimiters btw transition and pop/push symbols
      const text_length = edge.transition.length + edge.push_symbol.length + edge.pop_symbol.length + 2;
      const text_pxls = text_length*pxl_per_letter;
      min_x = Math.min(min_x, mid[0]-text_pxls/2);
      min_y = Math.min(min_y, mid[1]-2*pxl_per_letter);
      max_x = Math.max(max_x, mid[0]+text_pxls/2);
      max_y = Math.max(max_y, mid[1]+2*pxl_per_letter);
    }
  }
  return [[min_x, min_y], [max_x, max_y]];
}

/**
 * grab the current graph and download onto user's computer
 * @param {Object} graph - the graph object whose drawing is to be downloaded
 */
export function save_as_png(graph) {
  const [top_left, bottom_right] = compute_machine_drawing_size(graph);
  if (top_left[0] >= bottom_right[0] || top_left[1] >= bottom_right[1]) {
    alert('The graph is empty. Nothing to save.');
    return;
  }
  const width = bottom_right[0] - top_left[0], height = bottom_right[1] - top_left[1];
  const new_canvas = document.createElement('canvas');  // make a new canvas
  new_canvas.width = width, new_canvas.height = height;  // resize
  const new_ctx = new_canvas.getContext('2d');
  // copy the current canvas onto the new canvas
  new_ctx.drawImage(get_canvas(), top_left[0], top_left[1], width, height, 0, 0, width, height);
  const link = document.createElement('a');
  link.href = new_canvas.toDataURL('image/png');
  // GB date for sortability
  link.download = (new Date()).toLocaleString('en-GB').replace(' ', '')+'_machine.png';
  link.click();
}

/**
 * remove old highlited vertexes and mark current vertexes as highlited
 * @param {Object} graph
 * @param {Iterable<string>} cur_states - vertex names
 */
export function highlight_states(graph, cur_states) {
  for (const vertex of Object.values(graph)) {  // eliminate all highlights
    vertex.highlighted = false;
  }
  for (const v of cur_states) {  // highlight only those we want to highlight
    graph[v].highlighted = true;
  }
  draw(graph);
}

/**
 * displays the input_str and highlight the character being processed at this step
 * @param {string} input_str - the machine input that is currently being run
 * @param {int} index - index of the input_str the machine is currently consuming
 */
export function viz_NFA_input(input_str, index) {
  const canvas = get_canvas();
  const pos = [canvas.width*consts.INPUT_VIZ_WIDTH_R, canvas.height*consts.INPUT_VIZ_HEIGHT_R];
  const color_map = [];
  for (let i = 0; i < input_str.length; ++i) {
    if (i < index) {
      color_map.push(consts.READ_INPUT_COLOR);
    } else if (i === index) {
      color_map.push(consts.CUR_INPUT_COLOR);
    } else {
      color_map.push(consts.DEFAULT_INPUT_COLOR);
    }
  }
  draw_text(input_str, pos, consts.DEFAULT_VIZ_SIZE, color_map);
}

/**
 * displays the relavant section of the Turing Machine tape as an overlay
 * @param {Map<int, string>} tape - tape contents indexed by position. Using map due to potentially neg index
 * @param {int} tape_idx - the current tape head position
 */
export function viz_TM_tape(tape, tape_idx) {
  const canvas = get_canvas();
  const pos = [canvas.width*consts.INPUT_VIZ_WIDTH_R, canvas.height*consts.INPUT_VIZ_HEIGHT_R];
  const color_map = [consts.DEFAULT_INPUT_COLOR];
  const tape_start = tape_idx-consts.TAPE_VIEW_RADIUS;
  const tape_end = tape_idx+consts.TAPE_VIEW_RADIUS;
  const tape_arr = [consts.TAPE_LEFT_ARROW];
  for (let i = tape_start; i <= tape_end; ++i) {
    if (i < tape_idx) {
      color_map.push(consts.DEFAULT_INPUT_COLOR);
    } else if (i === tape_idx) {
      color_map.push(consts.CUR_INPUT_COLOR);
    } else {
      color_map.push(consts.DEFAULT_INPUT_COLOR);
    }
    if (i in tape) {
      tape_arr.push(tape[i]);
    } else {
      tape_arr.push(consts.EMPTY_TAPE);
    }
  }
  color_map.push(consts.DEFAULT_INPUT_COLOR);
  tape_arr.push(consts.TAPE_RIGHT_ARROW);
  draw_text(tape_arr.join(''), pos, consts.DEFAULT_VIZ_SIZE, color_map);
}

/**
 * Displays each compute configuration near the vertex the non-deterministic branch is at
 * @param {Object} graph
 * @param {Map<string, Array<string>>} PDA_configs - set of PDA configurations. Using map to avoid duplicates
 *                                                   the key is the serialized configuration as a string
 */
export function viz_PDA_configs(graph, PDA_configs) {
  const vertex_to_info = new Map();  // map vertex name to the info to be displayed
  for (const [v, stack, remaining_input] of PDA_configs.values()) {
    const stack_str = stack.join('');
    const input_str = [...remaining_input].reverse().join('');  // remaining_input is in reverse order for speed
    const text = `${input_str};${stack_str}`;
    if (vertex_to_info.has(v)) {  // separate multiple configurations with a pipe
      vertex_to_info.set(v, `${vertex_to_info.get(v)}|${text}`);
    } else {
      vertex_to_info.set(v, text);
    }
  }
  for (const [v, text] of vertex_to_info.entries()) {
    const vertex = graph[v];
    const pos = [vertex.x+vertex.r, vertex.y+vertex.r];
    const color_map = new Array(text.length).fill(consts.PDA_CONF_COLOR);
    draw_text(text, pos, consts.DEFAULT_VIZ_SIZE, color_map);
  }
}