-
Yuriy Maksymets authoredYuriy Maksymets authored
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)