Source: palm-reading/webcam.js

import { wait } from "../utils.js";
import { handleFortune } from "./script.js";

/**
 * The wrapper element that holds all the element anchored around the middle of
 * the screen, where the circle with the webcam video is.
 * @type {HTMLDivElement}
 */
const webcamWrapper = document.getElementById("webcam-wrapper");
/**
 * The currently displayed `.instructions` element.
 * @type {HTMLParagraphElement | null}
 */
let lastInstructionElem = null;
/**
 * Sets the message that appears under the webcam while it is analyzing the
 * palm. There will be a fade transition between the previous and new
 * instruction message.
 *
 * @param {string} instruction - The message to display. If an empty string,
 * it'll hide the instructions.
 */
function setInstructions(instruction) {
  if (lastInstructionElem) {
    lastInstructionElem.addEventListener("animationend", (e) => {
      e.currentTarget.remove();
    });
    lastInstructionElem.classList.remove("instructions-active");
  }
  if (instruction) {
    lastInstructionElem = Object.assign(document.createElement("p"), {
      textContent: instruction,
      className: "instructions instructions-active",
    });
    webcamWrapper.append(lastInstructionElem);
  } else {
    lastInstructionElem = null;
  }
}

/**
 * The `<video>` element that previews whatever is on the webcam.
 * @type {HTMLVideoElement}
 */
const video = document.getElementById("webcam-video");
/**
 * The button that requests for camera access.
 * @type {HTMLButtonElement}
 */
const requestBtn = document.getElementById("request-webcam");
/**
 * The heartbeat graph.
 * @type {SVGSVGElement}
 */
const ecgGraph = document.getElementById("ecg");
/**
 * A `<canvas>` that stores a snapshot of the webcam video.
 * @type {HTMLCanvasElement}
 */
const result = document.getElementById("result-palm");
/**
 * The `CanvasRenderingContext2D` for `result`.
 * @type {CanvasRenderingContext2D}
 */
const context = result.getContext("2d");
/**
 * The button for resetting the app and reading another hand.
 * @type {HTMLButtonElement}
 */
const readAnother = document.getElementById("read-another-hand");
/**
 * Whether the camera should be horizontally flipped (for front-facing cameras).
 * @type {boolean}
 */
let flipCamera = true;
/**
 * Handler for the "Begin" button that requests for camera access and turns on
 * the webcam.
 */
async function startCamera() {
  document.body.classList.remove("show-results");
  requestBtn.parentNode.style.display = "none";
  readAnother.style.display = "none";
  flipCamera = true;
  video.classList.add("flip");
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: "environment" },
    });
    video.srcObject = stream;
    const track = stream.getTracks()[0];
    // Firefox does not support getCapabilities
    if ("getCapabilities" in track) {
      const { facingMode } = track.getCapabilities();
      if (facingMode.includes("environment")) {
        video.classList.remove("flip");
        flipCamera = false;
      }
    }
  } catch (error) {
    requestBtn.parentNode.style.display = null;
  }
}
requestBtn.addEventListener("click", startCamera);
video.addEventListener("loadedmetadata", async () => {
  video.play();
  video.classList.add("video-on");
  ecgGraph.classList.add("ecg-active");
  ecgHistory.splice(0, ecgHistory.length);

  await wait(500);
  setInstructions("Please hold your hand over your camera.");
  await wait(2500);
  frameId = 0;
  paintEcg();
  setInstructions("Heartbeat detected.");
  await wait(3000);
  setInstructions("Keep your hand steady.");
  await wait(4000);
  video.pause();
  const size = Math.min(video.videoWidth, video.videoHeight);
  result.width = size;
  result.height = size;
  if (flipCamera) {
    context.translate(size, 0);
    context.scale(-1, 1);
  }
  if (video.videoWidth > video.videoHeight) {
    context.drawImage(video, -(video.videoWidth - video.videoHeight) / 2, 0);
  } else {
    context.drawImage(video, 0, -(video.videoHeight - video.videoWidth) / 2);
  }
  video.srcObject.getTracks()[0].stop();
  window.cancelAnimationFrame(frameId);
  frameId = null;
  video.classList.remove("video-on");
  ecgGraph.classList.remove("ecg-active");
  setInstructions("");
  readAnother.style.display = "block";
  handleFortune();
  document.body.classList.add("show-results");
});
readAnother.addEventListener("click", startCamera);

/**
 * Some points on an image of an ECG graph I found on Google Images that I
 * manually marked out in MS Paint. Used to form the piecewise linear `ecg`
 * polyline.
 * @type {Array<Array<number, number>>}
 */
const ecgPoints = [
  [-0.8, 0.2],
  [0.4, 1.2],
  [1.5, 0.2],
  [4.8, 0.2],
  [5.1, -0.8],
  [6.2, 13.9],
  [7.4, -4],
  [7.8, 0.2],
  [10, 0.8],
  [12, 4],
  [13, 4],
  [15, 0.5],
  [16.8, 1.1],
  [18, 0.2],
];
/**
 * Horizontal shift factor to be added to each point in `ecgPoints`. This is
 * because the image I got wasn't centered at the origin.
 * @type {number}
 */
const XSHIFT = 0.8;
/**
 * Vertical shift factor to be added to each point in `ecgPoints`.
 * @type {number}
 */
const YSHIFT = -0.2;
/**
 * The period of the heartbeat shape, in whatever units `ecgPoints` is in.
 * Increase to increase the spacing between heartbeats, but it's mostly a
 * guessing game.
 */
const PERIOD = 30;
/**
 * A periodic, piecewiese linear function that draws out the shape of an ECG.
 * The units used in this function are kind of weird, so some guesswork is
 * required to shape it into a nice-looking form for the heartbeat graph.
 *
 * @param {number} time - The x-value of the ECG graph.
 * @returns {number} The resulting y-value of the graph.
 */
function ecg(time) {
  time = time % PERIOD;
  const index = ecgPoints.findIndex(([x]) => x + XSHIFT > time);
  if (index === -1) {
    return 0;
  }
  const [left, right] = ecgPoints.slice(index - 1, index + 1);
  return (
    ((time - XSHIFT - left[0]) * (right[1] - left[1])) / (right[0] - left[0]) +
    left[1] +
    YSHIFT
  );
}

/**
 * The number of SVG units in width of the fake heartbeat graph.
 * @type {number}
 */
const ecgLength = 300;
/**
 * The FPS of the animation. `paintEcg` will try to keep the animation at this
 * rate, for displays that have slower or faster refresh rates.
 * @type {number}
 */
const FPS = 60;
/**
 * The `<path>` element that draws the ECG graph.
 * @type {SVGPathElement}
 */
const ecgPath = document.getElementById("ecg-path");
/**
 * A queue of points (new elements added to the beginning) representing the ECG
 * graph. Kept to a maximum of `ecgLength` items.
 * @type {number[]}
 */
const ecgHistory = [];
/**
 * Tracks how much time has been "simulated" by `paintEcg`. This is used for
 * refresh rate independence. For example, if there was a lag spike and real
 * time passes more than usual by the next animation frame, then `paintEcg`
 * might simulate two simulation "frames" in the same animation frame so the
 * animation doesn't slow down.
 * @type {number}
 */
let simTime = 0;
/**
 * The time when animation and simulation started.
 * @type {number}
 */
let startTime = Date.now();
/**
 * The ID returned by `window.requestAnimationFrame`, used to cancel it or
 * determine whether it is animating. `null` if `paintEcg` is not animating.
 * @type {number | null}
 */
let frameId = null;
/**
 * Draws the next frame of the fake heartbeat ECG graph animation. Once
 * `frameId` is set to `null`, the animation stops.
 *
 * This should be display refresh rate independent.
 */
function paintEcg() {
  if (frameId === null) {
    return;
  }
  // Correct for different monitor refresh rates
  const now = Date.now();
  const realTime = now - startTime;
  if (realTime - simTime > 500) {
    // If too much time passed
    simTime = 0;
    startTime = now;
  } else {
    while (simTime < realTime) {
      ecgHistory.unshift(
        -ecg(now / 30) * (Math.random() * 1 + 2.5) +
          15 +
          (Math.random() - 0.5) * 2
      );
      while (ecgHistory.length > ecgLength) {
        ecgHistory.pop();
      }
      ecgPath.setAttributeNS(
        null,
        "d",
        "M" + ecgHistory.map((pt, i) => `${i + 1} ${pt}`).join("L")
      );
      simTime += 1000 / FPS;
    }
  }
  frameId = window.requestAnimationFrame(paintEcg);
}