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] // } 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) => { 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 = { 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) { 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 = "" return 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] } 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, ) const appendToWholePath = (subPath) => { if (!(subPath && subPath.length)) return const subpathElem = generateSvgForSubpath(subPath) pathElem.appendChild(subpathElem) } let subPath = [] for (let i = 0; i < points.length; i++) { const point = points[i] if (isInvalidPoint(point)) { continue } // Valid point inside a subpath const nextPoint = points[i + 1] const eraseIntervals = getEraseIntervals(pathID, i) 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 if (eraseInterval[0] > POINT_ERASE_LIMIT) { subPath.push(endOfDrawnSegment) } appendToWholePath(subPath) 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 } 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) { eraseAt(e.offsetX, e.offsetY, room) } } 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, [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)