Skip to content
Snippets Groups Projects
app.js 11.74 KiB
// Room connection and synchronisation.
// Translate local canvas input events to draw messages in light of current tool
// selections and send to the room.
// Get back room updates and invoke the local canvas renderer.

import * as canvas from "./canvas.js"
import * as HTML from "./elements.js"
import { computeErasureIntervals, combineErasureIntervals } from "./erasure.js"
import { connect } from "./room.js"
import * as toolSelection from "./tool-selection.js"
import recognizeFromPoints, { Shapes } from "./shapes.js"
import * as humanhash from "humanhash"
import jdenticon from "jdenticon"

const DEFAULT_ROOM = "imperial"

const MIN_PRESSURE_FACTOR = 0.1
const MAX_PRESSURE_FACTOR = 1.5

const UNDO_RATE = 24
let undoInterval = null

let room = null

const humanHasher = new humanhash()

const PREDICTED_POINT_COLOR = "#00000044"
const LAST_RECOGNIZED_PATH_ID = "LSP"

const pathIDsByPointerID = new Map()

// This is a quadratic such that:
// - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
// - getPressureFactor(0.5) = 1.0
// - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR
// For sensible results, maintain that:
// - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0
// - 1.0 <= MAX_PRESSURE_FACTOR
// For intuitive results, maintain that:
// - MAX_PRESSURE_FACTOR <= ~2.0
const getPressureFactor = (pressure) => {
  const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4
  const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4
  const c = MIN_PRESSURE_FACTOR
  return a * pressure ** 2 + b * pressure + c
}

function selectedRadiusPoint(x, y, color, pressure = 0) {
  return [
    x,
    y,
    toolSelection.getStrokeRadius() * getPressureFactor(pressure),
    color,
  ]
}

function faintPredictionPoint(x, y, pressure = 0) {
  return selectedRadiusPoint(x, y, PREDICTED_POINT_COLOR, pressure)
}

function selectedColorAndRadiusPoint(x, y, pressure = 0) {
  return selectedRadiusPoint(x, y, toolSelection.getStrokeColour(), pressure)
}

function eraseEverythingAtPosition(x, y, radius, room) {
  const mousePos = [x, y]
  room.getPaths().forEach((points, pathID) => {
    const prevPathIntervals =
      (room.erasureIntervals || { [pathID]: {} })[pathID] || {}
    const newPathIntervals = computeErasureIntervals(
      points,
      mousePos,
      radius,
      prevPathIntervals,
    )

    const erasureIntervalsForPath = combineErasureIntervals(
      prevPathIntervals,
      newPathIntervals,
    )

    Object.keys(erasureIntervalsForPath).forEach((pointID) =>
      room.extendErasureIntervals(
        pathID,
        pointID,
        erasureIntervalsForPath[pointID],
      ),
    )
  })
}

const onRoomConnect = (room_) => {
  room = room_

  HTML.connectedRoomID.textContent = room.name

  HTML.userIDElem.value = room.ownID || ""
  room.addEventListener("allocateOwnID", ({ detail: id }) => {
    HTML.userIDElem.value = id
  })

  // Create user account
  const userID = HTML.userIDElem.value

  const avatarImage = document.createElement("svg")
  avatarImage.innerHTML = jdenticon.toSvg(userID, 40)
  avatarImage.className = "avatar"

  const userAccount = document.createElement("div")
  userAccount.innerHTML = humanHasher.humanize(userID, 2)

  HTML.userInfo.innerHTML = ""
  HTML.userInfo.appendChild(avatarImage)
  HTML.userInfo.appendChild(userAccount)

  room.addEventListener("userJoin", ({ detail: id }) => {
    if (HTML.connectedPeers.children.length == 0) {
      HTML.connectedPeers.innerHTML = ""
    }

    getOrInsertPeerById(id)
    updateOverallStatusIcon()
  })

  room.addEventListener("userLeave", ({ detail: id }) => {
    HTML.connectedPeers.removeChild(getOrInsertPeerById(id))

    if (HTML.connectedPeers.children.length == 0) {
      HTML.connectedPeers.innerHTML = "No peers are connected"
    }

    updateOverallStatusIcon()
  })

  room.addEventListener("userConnection", ({ detail: { id, quality } }) => {
    const high = "/quality-high.svg"
    const medium = "/quality-medium.svg"
    const low = "/quality-low.svg"

    const peer = getOrInsertPeerById(id).children[1]
    if (quality < 0.33) {
      if (!peer.src.includes(high)) {
        peer.src = high
      }
    } else if (quality < 0.66) {
      if (!peer.src.includes(medium)) {
        peer.src = medium
      }
    } else {
      if (!peer.src.includes(low)) {
        peer.src = low
      }
    }
  })

  room.addEventListener("weSyncedWithPeer", ({ detail: id }) => {
    getOrInsertPeerById(id).children[2].className = "peer-status upload synced"
    updateOverallStatusIcon()
  })

  room.addEventListener("waitingForSyncStep", ({ detail: id }) => {
    getOrInsertPeerById(id).children[3].className =
      "peer-status download negotiating"
    updateOverallStatusIcon()
  })

  room.addEventListener("peerSyncedWithUs", ({ detail: id }) => {
    getOrInsertPeerById(id).children[3].className =
      "peer-status download synced"
    updateOverallStatusIcon()
  })

  room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
    canvas.renderPath(id, points, room.erasureIntervals[id] || [])
  })

  room.addEventListener(
    "removedIntervalsChange",
    ({ detail: { id, intervals, points } }) => {
      room.erasureIntervals[id] = combineErasureIntervals(
        room.erasureIntervals[id] || {},
        intervals,
      )
      canvas.renderPath(id, points, room.erasureIntervals[id] || [])
    },
  )

  room.addEventListener("undoEnabled", () => {
    HTML.fastUndoButton.classList.remove("disabled")
    HTML.undoButton.classList.remove("disabled")
  })
}

function getRecognizedShapePoints(points) {
  const recognizedShape = recognizeFromPoints(points)
  if (!recognizedShape.shape) return undefined

  switch (recognizedShape.shape) {
    case Shapes.line: {
      const p1 = recognizedShape.firstPoint
      const p2 = recognizedShape.lastPoint
      return [p1, p2]
    }
    case Shapes.rectangle: {
      return recognizedShape.boundingPoints
    }
  }

  return undefined
}

function drawIfRecognized(points, callback, notRecCallback) {
  const recognizedPoints = getRecognizedShapePoints(points)
  if (recognizedPoints) {
    callback && callback(recognizedPoints)
  } else {
    notRecCallback && notRecCallback()
  }
}

function clearRecognizedUpcoming() {
  canvas.renderPath(LAST_RECOGNIZED_PATH_ID, [], [])
}

function drawRecognizedUpcoming(points) {
  drawIfRecognized(
    points,
    (recognizedPoints) =>
      canvas.renderPath(
        LAST_RECOGNIZED_PATH_ID,
        recognizedPoints.map((point) =>
          faintPredictionPoint(point[0], point[1]),
        ),
        [],
      ),
    clearRecognizedUpcoming,
  )
}

function drawRecognized(pathID, points) {
  drawIfRecognized(points, (newPoints) =>
    room.replacePath(
      pathID,
      newPoints.map((point) => selectedColorAndRadiusPoint(point[0], point[1])),
    ),
  )
  clearRecognizedUpcoming()
}

const tryRoomConnect = async (roomID) => {
  return await connect(roomID)
    .then(onRoomConnect)
    .catch((err) => alert(`Error connecting to a room:\n${err}`))
}

HTML.peerButton.addEventListener("click", () => {
  const peerID = HTML.peerIDElem.value
  if (room == null || peerID == "") {
    return
  }
  room.inviteUser(peerID)
  HTML.peerIDElem.value = ""
})

const onRoomJoinEnter = () => {
  const selectedRoomID = HTML.roomIDElem.value
  if (!selectedRoomID || selectedRoomID == room.name) {
    return
  }

  if (room != null) {
    room.disconnect()
    room = null
  }

  canvas.clear()
  HTML.connectedPeers.innerHTML = "No peers are connected"
  HTML.fastUndoButton.classList.add("disabled")
  HTML.undoButton.classList.add("disabled")
  tryRoomConnect(selectedRoomID)
}

HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)

HTML.fastUndoButton.addEventListener("click", () => {
  if (room == null) return

  room.fastUndo()

  if (!room.canUndo()) {
    HTML.fastUndoButton.classList.add("disabled")
    HTML.undoButton.classList.add("disabled")
  }
})

HTML.undoButton.addEventListener("mouseup", () => {
  clearInterval(undoInterval)
})

HTML.undoButton.addEventListener("mouseleave", () => {
  clearInterval(undoInterval)
})

HTML.undoButton.addEventListener("mousedown", () => {
  undoInterval = setInterval(function() {
    if (room == null) return

    room.undo()

    if (!room.canUndo()) {
      HTML.fastUndoButton.classList.add("disabled")
      HTML.undoButton.classList.add("disabled")
    }
  }, 1000 / UNDO_RATE)
})

HTML.roomIDElem.addEventListener("keydown", (event) => {
  if (event.key == "Enter") {
    event.target.blur()

    onRoomJoinEnter()
  }
})

const getOrInsertPeerById = (id) => {
  for (const peerElem of HTML.connectedPeers.children) {
    const peerId = peerElem.children[4].id
    if (peerId == id) {
      return peerElem
    }
  }

  const peerElem = document.createElement("li")

  const avatarImage = document.createElement("svg")
  avatarImage.innerHTML = jdenticon.toSvg(id, 50)
  avatarImage.className = "avatar"

  const quality = document.createElement("img")
  quality.src = "/quality-low.svg"
  quality.alt = "Peer quality icon"
  quality.className = "peer-quality"

  const ourStatus = document.createElement("div")
  ourStatus.className = "peer-status upload unsynced"

  const theirStatus = document.createElement("div")
  theirStatus.className = "peer-status download unsynced"
  const peerId = document.createElement("div")
  peerId.style.marginLeft = "5px"

  peerId.id = id

  peerId.innerHTML = humanHasher.humanize(id, 2)

  peerElem.appendChild(avatarImage)
  peerElem.appendChild(quality)
  peerElem.appendChild(ourStatus)
  peerElem.appendChild(theirStatus)
  peerElem.appendChild(peerId)

  HTML.connectedPeers.appendChild(peerElem)

  return peerElem
}

const updateOverallStatusIcon = () => {
  for (const peerElem of HTML.connectedPeers.children) {
    if (
      !peerElem.children[2].classList.contains("synced") ||
      !peerElem.children[3].classList.contains("synced")
    ) {
      HTML.overallStatusIcon.className = "synchronising"
      HTML.overallStatusIconImage.src = "synchronising.svg"
      return
    }
  }
  HTML.overallStatusIcon.className = "synchronised"
  HTML.overallStatusIconImage.src = "synchronised.svg"
}

canvas.input.addEventListener("strokestart", ({ detail: e }) => {
  e.preventDefault()
  clearRecognizedUpcoming()
  if (room == null) {
    return
  }
  const currentTool = toolSelection.getTool()
  const mousePos = [e.offsetX, e.offsetY]
  if (currentTool == toolSelection.Tools.PEN) {
    pathIDsByPointerID.set(
      e.pointerId,
      room.addPath([
        ...mousePos,
        toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
        toolSelection.getStrokeColour(),
      ]),
    )
  } else if (currentTool == toolSelection.Tools.ERASER) {
    eraseEverythingAtPosition(
      mousePos[0],
      mousePos[1],
      toolSelection.getEraseRadius(),
      room,
    )
  }
})

canvas.input.addEventListener("strokeend", ({ detail: e }) => {
  const { pressure, pointerId } = e
  const pathID = pathIDsByPointerID.get(pointerId)
  if (toolSelection.isRecognitionModeSet()) {
    drawRecognized(pathID, room.getPoints(pathID), pressure)
  }
  pathIDsByPointerID.delete(pointerId)
  clearRecognizedUpcoming()
})
canvas.input.addEventListener("strokemove", ({ detail: e }) => {
  if (room == null) {
    return
  }
  const currentTool = toolSelection.getTool()
  const mousePos = [e.offsetX, e.offsetY]
  if (currentTool == toolSelection.Tools.PEN) {
    const pathID = pathIDsByPointerID.get(e.pointerId)
    room.extendPath(
      pathID,
      selectedColorAndRadiusPoint(...mousePos, e.pressure),
    )
    if (toolSelection.isRecognitionModeSet()) {
      drawRecognizedUpcoming(room.getPoints(pathID), e.pressure)
    }
  } else if (currentTool == toolSelection.Tools.ERASER) {
    eraseEverythingAtPosition(
      mousePos[0],
      mousePos[1],
      toolSelection.getEraseRadius(),
      room,
    )
  }
})

window.addEventListener("unload", () => {
  if (room) {
    room.disconnect()
  }
})

tryRoomConnect(DEFAULT_ROOM)