Skip to content
Snippets Groups Projects
app.js 12 KiB
Newer Older
import { line, curveLinear } from "d3-shape"
import * as HTML from "./elements.js"
import { connect } from "./room.js"

const TEST_ROOM = "imperial"

const {
  computeErasureIntervals,
  combineErasureIntervals,
} = require("./erasure")
// TODO: switch to curve interpolation that respects mouse points based on velocity
const lineFn = line()
  .x((d) => d[0])
  .y((d) => d[1])
  .curve(curveLinear)

const tools = {
  PEN: "pen",
  ERASER: "eraser",
}

const STROKECOLOUR = "blue"
const STROKERADIUS = 2
const ERASERRADIUS = STROKERADIUS * 10
// function interpolatedCoordinate(start, end, length) {
//   const dx = end[0] - start[0]
//   const dy = end[1] - start[1]
//   const dist = getDistance(start, end)
//   const ratio = length / dist
//   return [start[0] + dx * ratio, start[1] + dy * ratio]
// }
Yuriy Maksymets's avatar
Yuriy Maksymets committed

function eraseAt(x, y, room) {
  let mousePos = [x, y]
  room.getPaths().forEach((points, pathID) => {
    const t = computeErasureIntervals(points, mousePos, ERASERRADIUS)
    if (!TEST_ERASE_INTERVAL[pathID]) TEST_ERASE_INTERVAL[pathID] = {}
    TEST_ERASE_INTERVAL[pathID] = combineErasureIntervals(
      TEST_ERASE_INTERVAL[pathID],
      t,
    )
    room.dispatchEvent(
      new CustomEvent("addOrUpdatePath", {
        detail: { id: pathID, points },
      }),
    )
    points.forEach((point) => {
Yuriy Maksymets's avatar
Yuriy Maksymets committed
      const distanceToPoint = getDistance(mousePos, point)
      if (distanceToPoint <= ERASERRADIUS) {
        // room.erasePoint(pathID, i)
        // let prev, next
        // if (i > 0) {
        //   prev = i - 1
        // }
        // if (i < points.length - 1) {
        //   next = i + 1
        // }
        // if (prev !== undefined) {
        //   const interpolatedPoint = interpolatedCoordinate(
        //     point,
        //     points[prev],
        //     ERASERRADIUS,
        //   )
        //   room.insertIntoPath(pathID, prev, interpolatedPoint)
        // }
        // if (next !== undefined) {
        //   const interpolatedPoint = interpolatedCoordinate(
        //     point,
        //     points[next],
        //     ERASERRADIUS,
        //   )
        //   room.insertIntoPath(pathID, next, interpolatedPoint)
        // }
Yuriy Maksymets's avatar
Yuriy Maksymets committed
const SVG_URL = "http://www.w3.org/2000/svg"

const SVG = {
  create: {
    circle: () => SVG.create.elem("circle"),
    group: () => SVG.create.elem("g"),
    path: () => SVG.create.elem("path"),
    elem: (elemName) => document.createElementNS(SVG_URL, elemName),
  },
}

Yuriy Maksymets's avatar
Yuriy Maksymets committed
let TEST_ERASE_INTERVAL = { 1: [[0.1, 0.2]] }
function erasurePoints(pointA, pointB, [start, fin]) {
  if (start >= fin) return
  if (start <= 0) start = 0
  if (fin >= 1) fin = 1

  const [xa, ya] = pointA
  const [xb, yb] = pointB

  const dx = xb - xa
  const dy = yb - ya

  const pointOnLine = (percent) => [xa + percent * dx, ya + percent * dy]
  return [start, fin].map(pointOnLine)
function getOrAddPathElem(pathElems, id) {
  let pathElem = pathElems.get(id)

  if (pathElem == null) {
Yuriy Maksymets's avatar
Yuriy Maksymets committed
    pathElem = SVG.create.group()

    pathElem.setAttribute("stroke", STROKECOLOUR)
    pathElem.setAttribute("stroke-width", STROKERADIUS * 2)
    pathElem.setAttribute("fill", "none")
    pathElem.setAttribute("pointer-events", "none")
    pathElem.setAttribute("marker-start", "url(#dot)")
    pathElem.setAttribute("marker-end", "url(#dot)")

    HTML.canvas.appendChild(pathElem)
    pathElems.set(id, pathElem)
function generateSvgPoint(subPath) {
  const subpathElem = SVG.create.circle()

  subpathElem.setAttribute("stroke", "none")
  subpathElem.setAttribute("fill", STROKECOLOUR)
  subpathElem.setAttribute("cx", subPath[0][0])
  subpathElem.setAttribute("cy", subPath[0][1])
  subpathElem.setAttribute("r", STROKERADIUS)

  return subpathElem
}
function generateSvgPath(subPath) {
  const subpathElem = SVG.create.path()
  subpathElem.setAttribute("d", lineFn(subPath))
  return subpathElem
}

function generateSvgForSubpath(subPath) {
  return subPath.length === 1
    ? generateSvgPoint(subPath)
    : generateSvgPath(subPath)
}

function isInvalidPoint(point) {
  return point[0] === undefined
}

function getEraseIntervals(pathID, pointID) {
  const eraseIntervalsForPath = TEST_ERASE_INTERVAL[pathID]
  if (!eraseIntervalsForPath) return undefined
  return eraseIntervalsForPath[pointID]
}

Yuriy Maksymets's avatar
Yuriy Maksymets committed
const POINT_ERASE_LIMIT = 0.0001

function pointWasErased(eraseIntervals) {
  return (
    eraseIntervals.length &&
    eraseIntervals[0] &&
    eraseIntervals[0][0] <= POINT_ERASE_LIMIT
  )
}

function needToDrawLastPoint(points, pathID) {
  if (points.length < 2) return true
  const penultimatePointIndex = points.length - 2
  const penPointEraseIntervals = getEraseIntervals(
    pathID,
    penultimatePointIndex,
  )

  if (
    penPointEraseIntervals &&
    penPointEraseIntervals.length &&
    penPointEraseIntervals.some(
      (interval) => interval[1] >= 1 - POINT_ERASE_LIMIT,
    )
  ) {
    return false
  }
  return true
}

function generatePointsForPathElem(pathElem, pathID, dataPoints) {
  const points = dataPoints
  document.getElementById("console").innerText = JSON.stringify(
    TEST_ERASE_INTERVAL,
  )
Yuriy Maksymets's avatar
Yuriy Maksymets committed
  const appendToWholePath = (subPath) => {
    if (!(subPath && subPath.length)) return
    const subpathElem = generateSvgForSubpath(subPath)
    pathElem.appendChild(subpathElem)
  }
Yuriy Maksymets's avatar
Yuriy Maksymets committed

  let subPath = []
  for (let i = 0; i < points.length; i++) {
    const point = points[i]
    if (isInvalidPoint(point)) {
Yuriy Maksymets's avatar
Yuriy Maksymets committed
    // Valid point inside a subpath
    const nextPoint = points[i + 1]
    const eraseIntervals = getEraseIntervals(pathID, i)
Yuriy Maksymets's avatar
Yuriy Maksymets committed
    if (!eraseIntervals || nextPoint === undefined) {
      subPath.push(point)
      continue
    }

    if (!pointWasErased(eraseIntervals) || subPath.length) {
      subPath.push(point)
    }

    for (const eraseInterval of eraseIntervals) {
      if (!eraseInterval) continue
      const erasedIntervalBounds = erasurePoints(
        point,
        nextPoint,
        eraseInterval,
      )
      if (!(erasedIntervalBounds && erasedIntervalBounds.length)) continue
      const [endOfDrawnSegment, startOfNewSegment] = erasedIntervalBounds

Yuriy Maksymets's avatar
Yuriy Maksymets committed
      if (eraseInterval[0] > POINT_ERASE_LIMIT) {
        subPath.push(endOfDrawnSegment)
      }

      appendToWholePath(subPath)
Yuriy Maksymets's avatar
Yuriy Maksymets committed
      if (eraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
        subPath = [startOfNewSegment]
      } else {
        subPath = []
      }
  if (needToDrawLastPoint(points, pathID)) {
    appendToWholePath(subPath)
  }
}

const addOrUpdatePathElem = (pathElems, id, points) => {
  const pathElem = getOrAddPathElem(pathElems, id)
  if (!points.length) {
    return pathElem
  }
  generatePointsForPathElem(pathElem, id, points)
  return pathElem
}
Yuriy Maksymets's avatar
Yuriy Maksymets committed
  return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
function setElemVisible(elem, visible = true) {
  if (!(elem && elem.style)) return
  elem.style.display = visible ? "block" : "none"
}
function showConnectedRoom(roomID) {
  HTML.connectedRoomID.textContent = roomID
  setElemVisible(HTML.connectedRoomInfoContainer)
}
function setBlankUIState() {
  while (HTML.canvas.children[1]) {
    HTML.canvas.removeChild(HTML.canvas.children[1])
  HTML.connectedPeers.innerHTML = "No peers are connected"
}

function handleRoomConnectClick() {
  const selectedRoomID = HTML.roomIDElem.value
  if (!selectedRoomID) return

  setBlankUIState()

  connectToARoom(selectedRoomID)
}

function handleRoomConnectionEstablished(room) {
  showConnectedRoom(room.name)

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

  room.addEventListener("userJoin", ({ detail: id }) => {
    if (HTML.connectedPeers.children.length == 0) {
    const peerElem = document.createElement("li")
    peerElem.innerHTML = id
    HTML.connectedPeers.appendChild(peerElem)
  })

  room.addEventListener("userLeave", ({ detail: id }) => {
    for (const peerElem of HTML.connectedPeers.children) {
      if (peerElem.innerHTML == id) {
        HTML.connectedPeers.removeChild(peerElem)
Giovanni Caruso's avatar
Giovanni Caruso committed
      }
    if (HTML.connectedPeers.children.length == 0) {
      HTML.connectedPeers.innerHTML = "No peers are connected"
    }
  const pathElems = new Map()

  room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
    addOrUpdatePathElem(pathElems, id, points)
Yuriy Maksymets's avatar
Yuriy Maksymets committed
  let currentTool = tools.ERASER
  const pathIDsByPointerID = new Map()
  const canvasOnPointerEnter = (e) => {
    if (~e.buttons & 1) {
    const mousePos = [e.offsetX, e.offsetY]
    if (currentTool == tools.PEN) {
      pathIDsByPointerID.set(e.pointerId, room.addPath(mousePos))
    } else if (currentTool == tools.ERASER) {
Yuriy Maksymets's avatar
Yuriy Maksymets committed
      eraseAt(mousePos[0], mousePos[1], room)
  const canvasOnPointerLeave = (e) => {
    pathIDsByPointerID.delete(e.pointerId)
  const canvasOnPointerMove = (e) => {
    if (~e.buttons & 1) {
    const mousePos = [e.offsetX, e.offsetY]
    if (currentTool == tools.PEN) {
      room.extendPath(pathIDsByPointerID.get(e.pointerId), mousePos)
    } else if (currentTool == tools.ERASER) {
      eraseAt(e.offsetX, e.offsetY, room)
  const peerButtonOnClick = () => {
    const peerID = HTML.peerIDElem.value
    if (peerID == "") {
      return
    }
    room.inviteUser(peerID)
    HTML.peerIDElem.value = ""
  const penButtonOnClick = () => {
    HTML.penButton.classList.add("selected")
    HTML.eraserButton.classList.remove("selected")
  const eraserButtonOnClick = () => {
    HTML.penButton.classList.remove("selected")
    HTML.eraserButton.classList.add("selected")
  HTML.canvas.addEventListener("pointerdown", canvasOnPointerEnter)
  HTML.canvas.addEventListener("pointerenter", canvasOnPointerEnter)
  HTML.canvas.addEventListener("pointerup", canvasOnPointerLeave)
  HTML.canvas.addEventListener("pointerleave", canvasOnPointerLeave)
  HTML.canvas.addEventListener("pointermove", canvasOnPointerMove)
  HTML.penButton.addEventListener("click", penButtonOnClick)
  HTML.eraserButton.addEventListener("click", eraserButtonOnClick)
  HTML.peerButton.addEventListener("click", peerButtonOnClick)
  HTML.roomConnectButton.removeEventListener("click", handleRoomConnectClick)
  const roomConnectButtonOnClick = () => {
    const selectedRoomID = HTML.roomIDElem.value
    if (!selectedRoomID || selectedRoomID == room.name) {
      return
    }
    HTML.canvas.removeEventListener("pointerdown", canvasOnPointerEnter)
    HTML.canvas.removeEventListener("pointerenter", canvasOnPointerEnter)
    HTML.canvas.removeEventListener("pointerup", canvasOnPointerLeave)
    HTML.canvas.removeEventListener("pointerleave", canvasOnPointerLeave)
    HTML.canvas.removeEventListener("pointermove", canvasOnPointerMove)
    HTML.peerButton.removeEventListener("click", peerButtonOnClick)
    HTML.penButton.removeEventListener("click", penButtonOnClick)
    HTML.eraserButton.removeEventListener("click", eraserButtonOnClick)
    HTML.roomConnectButton.removeEventListener(
      "click",
      roomConnectButtonOnClick,
    )
    handleRoomConnectClick()
  HTML.roomConnectButton.addEventListener("click", roomConnectButtonOnClick)
Yuriy Maksymets's avatar
Yuriy Maksymets committed

  let pid = room.addPath([100, 100])
Yuriy Maksymets's avatar
Yuriy Maksymets committed
  room.extendPath(pid, [800, 200])
  room.extendPath(pid, [100, 300])
  room.extendPath(pid, [800, 400])
  room.extendPath(pid, [800, 450])
  room.extendPath(pid, [800, 500])
  //   let pid = room.addPath([100, 100])
  //   room.extendPath(pid, [110, 105])
  //   room.extendPath(pid, [120, 100])
  //   room.extendPath(pid, [130, 105])
  //   room.extendPath(pid, [140, 100])
}

function handleRoomConnectionError(err) {
  alert(`Error connecting to a room:\n${err}`)
}

function connectToARoom(roomID) {
  connect(roomID)
    .then(handleRoomConnectionEstablished)
    .catch(handleRoomConnectionError)
HTML.canvas.addEventListener("touchmove", (e) => e.preventDefault())

HTML.roomConnectButton.addEventListener("click", handleRoomConnectClick, {
  once: true,
connectToARoom(TEST_ROOM)