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 * 8 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] } function eraseAt(x, y, room) { let mousePos = [x, y] room.getPaths().forEach((points, pathID) => { points.forEach((point, i) => { 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) } } }) }) } 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), }, } let TEST_ERASE_INTERVAL = { 0: [[0.1, 0.3], [0.4, 0.8]] } 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) } const addOrUpdatePathElem = (pathElems, id, points) => { let pathElem = pathElems.get(id) if (pathElem == null) { 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) } pathElem.innerHTML = "" if (points.length == 0) { return pathElem } // Push a fake path split to generate the last path points.push([-1, -1, false]) let subPath = [] let toAdd = undefined for (let i = 0; i < points.length; i++) { const point = points[i] if (toAdd) { subPath.push(toAdd) toAdd = undefined } if (point[0] === undefined) { continue } if (point[2] === false) { // End of subpath if (subPath.length == 1) { 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) pathElem.appendChild(subpathElem) } else if (subPath.length > 0) { const subpathElem = SVG.create.path() subpathElem.setAttribute("d", lineFn(subPath)) pathElem.appendChild(subpathElem) } subPath = [] continue } // Valid point inside a subpath subPath.push(point) const eraseIntervals = TEST_ERASE_INTERVAL[i] if (!eraseIntervals) continue for (const eraseInterval of eraseIntervals) { if (eraseInterval) { if (toAdd) { subPath.push(toAdd) toAdd = undefined } if (i === points.length - 1) { continue } const nextPoint = points[i + 1] const [start, fin] = erasurePoints( point, nextPoint, eraseInterval[0], eraseInterval[1], ) subPath.push(start) const subpathElem = SVG.create.path() subpathElem.setAttribute("d", lineFn(subPath)) pathElem.appendChild(subpathElem) subPath = [] toAdd = fin } } } return pathElem } const getDistance = (a, b) => { 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) { HTML.connectedPeers.innerHTML = "" } 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) } } 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) }) let currentTool = tools.ERASER const pathIDsByPointerID = new Map() const canvasOnPointerEnter = (e) => { if (~e.buttons & 1) { return } const mousePos = [e.offsetX, e.offsetY] if (currentTool == tools.PEN) { pathIDsByPointerID.set(e.pointerId, room.addPath(mousePos)) } else if (currentTool == tools.ERASER) { eraseAt(mousePos[0], mousePos[1], room) } } const canvasOnPointerLeave = (e) => { pathIDsByPointerID.delete(e.pointerId) } const canvasOnPointerMove = (e) => { if (~e.buttons & 1) { return } const mousePos = [e.offsetX, e.offsetY] if (currentTool == tools.PEN) { room.extendPath(pathIDsByPointerID.get(e.pointerId), mousePos) } else if (currentTool == tools.ERASER) { room.getPaths().forEach((points, pathID) => { const t = computeErasureIntervals(points, mousePos, ERASERRADIUS) TEST_ERASE_INTERVAL = combineErasureIntervals(TEST_ERASE_INTERVAL, t) room.dispatchEvent( new CustomEvent("addOrUpdatePath", { detail: { id: pathID, points }, }), ) points.forEach((point, i) => { if (getDistance(mousePos, point) <= ERASERRADIUS) { room.erasePoint(pathID, i) } }) }) } } const peerButtonOnClick = () => { const peerID = HTML.peerIDElem.value if (peerID == "") { return } room.inviteUser(peerID) HTML.peerIDElem.value = "" } const penButtonOnClick = () => { currentTool = tools.PEN HTML.penButton.classList.add("selected") HTML.eraserButton.classList.remove("selected") } const eraserButtonOnClick = () => { currentTool = tools.ERASER 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, ) room.disconnect() room = null handleRoomConnectClick() } HTML.roomConnectButton.addEventListener("click", roomConnectButtonOnClick) let pid = room.addPath([100, 100]) room.extendPath(pid, [150, 150]) room.extendPath(pid, [100, 200]) } 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)