// 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 * as humanhash from "humanhash" import jdenticon from "jdenticon" const DEFAULT_ROOM = "imperial" const MIN_PRESSURE_FACTOR = 0.1 const MAX_PRESSURE_FACTOR = 1.5 // 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 } let room = null const humanHasher = new humanhash() 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") }) room.addEventListener("undoDisabled", () => { HTML.fastUndoButton.classList.add("disabled") HTML.undoButton.classList.add("disabled") }) } const tryRoomConnect = async (roomID) => { return await connect(roomID) .then(onRoomConnect) .catch((err) => alert(`Error connecting to a room:\n${err}`)) } const pathIDsByPointerID = new Map() 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", () => room != null && room.fastUndo(), ) HTML.undoButton.addEventListener("click", () => room != null && room.undo()) 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 }) => { 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 }) => { pathIDsByPointerID.delete(e.pointerId) }) 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) { room.extendPath(pathIDsByPointerID.get(e.pointerId), [ ...mousePos, toolSelection.getStrokeRadius() * getPressureFactor(e.pressure), toolSelection.getStrokeColour(), ]) } else if (currentTool == toolSelection.Tools.ERASER) { eraseEverythingAtPosition( mousePos[0], mousePos[1], toolSelection.getEraseRadius(), room, ) } }) window.addEventListener("unload", () => { if (room) { room.disconnect() } }) tryRoomConnect(DEFAULT_ROOM)