Skip to content
Snippets Groups Projects
Commit d9bfc44e authored by Yuriy Maksymets's avatar Yuriy Maksymets
Browse files

Merge branch 'master' into shape-recognition

parents 8537e50b 269d4a22
No related branches found
No related tags found
No related merge requests found
......@@ -5,7 +5,7 @@
"node": true,
"jest": true
},
"extends": "eslint:recommended",
"extends": ["eslint:recommended", "prettier"],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
......
import {
computeErasureIntervals,
combineErasureIntervals,
spreadErasureIntervals,
flattenErasureIntervals,
} from "../src/erasure.js"
describe("erasure intervals", () => {
it("computes simple erasure intervals", () => {
const points = [
[0, 0],
[100, 100],
]
const erasureCenter = [50, 50]
const erasureRadius = 25 * Math.SQRT2
const erasureIntervals = computeErasureIntervals(
points,
erasureCenter,
erasureRadius,
)
expect(erasureIntervals).toStrictEqual({ 0: [[0.25, 0.75]] })
})
it("computes complex erasure intervals", () => {
const points = [
[0, 0],
[100, 100],
[0, 200],
]
const erasureCenter = [100, 100]
const erasureRadius = 25 * Math.SQRT2
const erasureIntervals = computeErasureIntervals(
points,
erasureCenter,
erasureRadius,
)
expect(erasureIntervals).toStrictEqual({ 0: [[0.75, 1]], 1: [[0, 0.25]] })
})
it("computes erasure intervals when point projection is not on the segment", () => {
const points = [
[800, 400],
[800, 450],
[800, 500],
]
const erasureCenter = [800, 432]
const erasureRadius = 20 //* Math.SQRT2
const erasureIntervals = computeErasureIntervals(
points,
erasureCenter,
erasureRadius,
)
expect(erasureIntervals).toStrictEqual({ 0: [[0.24, 1]], 1: [[0, 0.04]] })
})
it("computes erasure intervals ???", () => {
const points = [
[100, 100],
[1100, 100],
]
const erasureCenter = [448, 86]
const erasureRadius = 100
const erasureIntervals = computeErasureIntervals(
points,
erasureCenter,
erasureRadius,
)
expect(erasureIntervals).toStrictEqual({
0: [[0.2489848496441075, 0.4470151503558925]],
})
})
it("combines distinct intervals", () => {
const i1 = { 0: [[0.1, 0.6]] }
const i2 = { 0: [[0.7, 0.8]] }
const combined = combineErasureIntervals(i1, i2)
const expected = {
0: [
[0.1, 0.6],
[0.7, 0.8],
],
}
expect(combined).toStrictEqual(expected)
})
it("combines overlapping intervals", () => {
const i1 = { 0: [[0.1, 0.6]] }
const i2 = { 0: [[0.5, 0.8]] }
const combined = combineErasureIntervals(i1, i2)
const expected = { 0: [[0.1, 0.8]] }
expect(combined).toStrictEqual(expected)
})
it("combines overlapping inside intervals", () => {
const i1 = { 0: [[0.1, 0.6]] }
const i2 = { 0: [[0.2, 0.3]] }
const combined = combineErasureIntervals(i1, i2)
const expected = { 0: [[0.1, 0.6]] }
expect(combined).toStrictEqual(expected)
})
it("spreads flattened intervals", () => {
const il = [
[0.1, 1.25],
[1.5, 2.0],
[7.5, 7.75],
]
const spread = spreadErasureIntervals(il)
const expected = {
0: [[0.1, 1.0]],
1: [
[0.0, 0.25],
[0.5, 1.0],
],
7: [[0.5, 0.75]],
}
expect(spread).toStrictEqual(expected)
})
it("spreads singulatity intervals", () => {
const il = [
[0.0, 0.0],
[3.5, 3.5],
[99.0, 99.0],
]
const spread = spreadErasureIntervals(il)
const expected = { 0: [[0.0, 0.0]], 3: [[0.5, 0.5]], 99: [[0.0, 0.0]] }
expect(spread).toStrictEqual(expected)
})
it("flattens spread intervals", () => {
const is = {
0: [[0.1, 1.0]],
1: [
[0.0, 0.3],
[0.4, 1.0],
],
7: [[0.35, 0.75]],
}
const flattened = flattenErasureIntervals(is)
const expected = [
[0.1, 1.0],
[1.0, 1.3],
[1.4, 2.0],
[7.35, 7.75],
]
expect(flattened).toStrictEqual(expected)
})
})
const sum = (a, b) => a + b
describe("number adding", () => {
describe("number summation", () => {
it("adds 1 + 2 to equal 3", () => {
expect(sum(1, 2)).toBe(3)
})
})
})
describe("server", () => {
it("should work", () => {})
})
This diff is collapsed.
<svg viewBox="-10 -10 30 30" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10"/>
</svg>
\ No newline at end of file
......@@ -13,16 +13,14 @@
/>
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker
.register("service-worker.js")
.then(
(registration) =>
console.log(
`Service worker registered on scope ${registration.scope}`,
),
(reason) =>
console.log(`Service worker failed to register ~ ${reason}`),
)
navigator.serviceWorker.register("service-worker.js").then(
(registration) =>
console.log(
`Service worker registered on scope ${registration.scope}`,
),
(reason) =>
console.log(`Service worker failed to register ~ ${reason}`),
)
}
</script>
</head>
......
......@@ -10,6 +10,7 @@ const CACHE_NAME = "APP-V0"
const FILES_TO_CACHE = [
"/index.html",
"/styles.css",
"/cursor.svg",
"/js/app.js",
"/js/queue.js",
"/logo.png",
......
......@@ -3,6 +3,10 @@ body {
height: 100%;
}
#console {
color: white;
}
body {
background-color: black;
margin: 0;
......
......@@ -5,11 +5,12 @@
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"
const TEST_ROOM = "imperial"
const DEFAULT_ROOM = "imperial"
const MIN_PRESSURE_FACTOR = 0.1
const MAX_PRESSURE_FACTOR = 1.5
......@@ -32,6 +33,33 @@ const getPressureFactor = (pressure) => {
let room = null
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_
......@@ -101,9 +129,20 @@ const onRoomConnect = (room_) => {
})
room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
canvas.renderPath(id, points)
canvas.renderPath(id, points, room.erasureIntervals)
drawRecognized(points)
})
room.addEventListener(
"removedIntervalsChange",
({ detail: { id, intervals, points } }) => {
room.erasureIntervals[id] = combineErasureIntervals(
room.erasureIntervals[id] || {},
intervals,
)
canvas.renderPath(id, points, room.erasureIntervals)
},
)
}
const mp = (x, y) => [x, y, 1, "black", true]
......@@ -221,25 +260,6 @@ const updateOverallStatusIcon = () => {
HTML.overallStatusIconImage.src = "synchronised.svg"
}
const getDistance = (a, b) => {
return Math.sqrt(
(a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]),
)
}
const erasePoint = ([x, y]) => {
if (room == null) {
return
}
room.getPaths().forEach((points, pathID) => {
points.forEach((point, i) => {
if (getDistance([x, y], point) <= toolSelection.getEraseRadius()) {
room.erasePoint(pathID, i)
}
})
})
}
canvas.input.addEventListener("strokestart", ({ detail: e }) => {
if (room == null) {
return
......@@ -256,7 +276,12 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => {
]),
)
} else if (currentTool == toolSelection.Tools.ERASER) {
erasePoint(mousePos)
eraseEverythingAtPosition(
mousePos[0],
mousePos[1],
toolSelection.getEraseRadius(),
room,
)
}
})
......@@ -277,8 +302,19 @@ canvas.input.addEventListener("strokemove", ({ detail: e }) => {
toolSelection.getStrokeColour(),
])
} else if (currentTool == toolSelection.Tools.ERASER) {
erasePoint(mousePos)
eraseEverythingAtPosition(
mousePos[0],
mousePos[1],
toolSelection.getEraseRadius(),
room,
)
}
})
window.addEventListener("unload", () => {
if (room) {
room.disconnect()
}
})
tryRoomConnect(TEST_ROOM)
tryRoomConnect(DEFAULT_ROOM)
......@@ -2,17 +2,22 @@
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
import { line, curveBasis } from "d3-shape"
import { line, curveCatmullRom } from "d3-shape"
import { canvas } from "./elements.js"
const SVG_URL = "http://www.w3.org/2000/svg"
// TODO: switch to curve interpolation that respects mouse points based on velocity
const lineFn = line()
import * as HTML from "./elements.js"
// TODO: look at paper.js which has path.smooth() and curve.getPart()
// TODO: look at snap.svg which has path.getTotalLength(), path.subpath() and Snap.closestPoint()
// TODO: look at curve interpolation that respects mouse points based on velocity
const curve = curveCatmullRom.alpha(1.0)
const smoothLine = line()
.x((d) => d[0])
.y((d) => d[1])
.curve(curveBasis)
.curve(curve)
const pathGroupElems = new Map()
......@@ -39,7 +44,6 @@ const smoothPath = ([...path]) => {
path[i - 1][1] + (dy / segmentsToSplit) * j,
path[i - 1][2] + (dw / segmentsToSplit) * j,
path[i - 1][3],
path[i - 1][4],
])
}
path.splice(i, 0, ...newPoints)
......@@ -59,7 +63,6 @@ const smoothPath = ([...path]) => {
path[i - 1][1] + (dy / segmentsToSplit) * j,
path[i - 1][2] + (dw / segmentsToSplit) * j,
path[i - 1][3],
path[i - 1][4],
])
}
path.splice(i, 0, ...newPoints)
......@@ -70,76 +73,195 @@ const smoothPath = ([...path]) => {
export const input = new EventTarget()
const createPathElem = (d, width) => {
const pathGroupElem = document.createElementNS(SVG_URL, "path")
pathGroupElem.setAttribute("stroke-width", width)
pathGroupElem.setAttribute("d", d)
return pathGroupElem
const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
const erasurePoints = (point0, point1, [start, fin]) => {
if (start >= fin) return
if (start <= 0) start = 0
if (fin >= 1) fin = 1
const dx = point1[0] - point0[0]
const dy = point1[1] - point0[1]
const dw = point1[2] - point0[2]
return [start, fin].map((fraction) => [
point0[0] + fraction * dx,
point0[1] + fraction * dy,
point0[2] + fraction * dw,
point0[3],
])
}
export const renderPath = (id, points) => {
points = points.filter(([x]) => x != null)
let colour = ""
// Split up points into completely non-erased segments.
let segments = [[]]
for (const point of points) {
colour = point[3]
if (point[4] != false) {
segments[segments.length - 1].push(point)
} else {
segments.push([])
}
const ensurePathGroupElem = (id) => {
let groupElem = pathGroupElems.get(id)
if (groupElem == null) {
groupElem = createSvgElem("g")
groupElem.setAttribute("fill", "none")
groupElem.setAttribute("stroke-linecap", "round")
groupElem.setAttribute("pointer-events", "none")
HTML.canvas.appendChild(groupElem)
pathGroupElems.set(id, groupElem)
}
segments = segments.filter((a) => a.length > 0)
let pathGroupElem = pathGroupElems.get(id)
groupElem.innerHTML = ""
if (segments.length == 0) {
if (pathGroupElem != null) {
canvas.removeChild(pathGroupElem)
pathGroupElems.delete(id)
}
return
return groupElem
}
const renderPoint = (point) => {
const circleElem = createSvgElem("circle")
circleElem.setAttribute("stroke", "none")
circleElem.setAttribute("fill", point[3])
circleElem.setAttribute("cx", point[0])
circleElem.setAttribute("cy", point[1])
circleElem.setAttribute("r", point[2])
return circleElem
}
const renderSubpath = (subpath) => {
if (subpath.length == 1) {
return renderPoint(subpath[0])
}
const pathElem = createSvgElem("path")
pathElem.setAttribute("stroke", subpath[0][3])
pathElem.setAttribute("stroke-width", subpath[0][2] * 2)
pathElem.setAttribute("d", smoothLine(subpath))
return pathElem
}
const isValidPoint = (point) => {
return point != null && point[0] != null
}
const getEraseIntervalsForPointInPath = (intervals, pathID, pointID) => {
if (!intervals) return undefined
const eraseIntervalsForPath = intervals[pathID]
if (!eraseIntervalsForPath) return undefined
return eraseIntervalsForPath[pointID]
}
const POINT_ERASE_LIMIT = 0.0001
const pointWasErased = (eraseIntervals) => {
return (
eraseIntervals.length &&
eraseIntervals[0] &&
eraseIntervals[0][0] <= POINT_ERASE_LIMIT
)
}
const needToDrawLastPoint = (points, pathID, eraseIntervals) => {
if (points.length < 2) return true
const penultimatePointIndex = points.length - 2
const penPointEraseIntervals = getEraseIntervalsForPointInPath(
eraseIntervals,
pathID,
penultimatePointIndex,
)
if (
penPointEraseIntervals &&
penPointEraseIntervals.length &&
penPointEraseIntervals.some(
(interval) => interval[1] >= 1 - POINT_ERASE_LIMIT,
)
) {
return false
}
return true
}
if (pathGroupElem == null) {
pathGroupElem = document.createElementNS(SVG_URL, "g")
pathGroupElem.setAttribute("stroke", colour)
pathGroupElem.setAttribute("fill", "none")
pathGroupElem.setAttribute("stroke-linecap", "round")
pathGroupElem.setAttribute("pointer-events", "none")
canvas.appendChild(pathGroupElem)
pathGroupElems.set(id, pathGroupElem)
const applyErasureIntervals = (pathID, points, erasureIntervals) => {
if (points.length == 0) {
return []
}
pathGroupElem.innerHTML = ""
for (const subpath of segments) {
if (subpath.length == 1) {
const circleElem = document.createElementNS(SVG_URL, "circle")
circleElem.setAttribute("stroke", "none")
circleElem.setAttribute("fill", colour)
circleElem.setAttribute("cx", subpath[0][0])
circleElem.setAttribute("cy", subpath[0][1])
circleElem.setAttribute("r", subpath[0][2])
pathGroupElem.appendChild(circleElem)
} else {
// Further split up segments based on thickness.
const subpath_ = smoothPath(subpath)
let w = subpath_[0][2]
for (let i = 1; i < subpath_.length; i++) {
if (subpath_[i][2] != w) {
const d = lineFn([...subpath_.splice(0, i), subpath_[0]])
pathGroupElem.appendChild(createPathElem(d, w * 2))
w = subpath_[0][2]
i = 1
}
const subpaths = []
let subpath = []
for (let i = 0; i < points.length; i++) {
const point = points[i]
if (!isValidPoint(point)) {
continue
}
const nextPoint = points[i + 1]
const eraseIntervals = getEraseIntervalsForPointInPath(
erasureIntervals,
pathID,
i,
)
if (!eraseIntervals) {
subpath.push(point)
continue
} else if (nextPoint === undefined) {
if (JSON.stringify(eraseIntervals) != "[[0,0]]") 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)
}
const d = lineFn(subpath_)
pathGroupElem.appendChild(createPathElem(d, w * 2))
subpaths.push(subpath.slice())
if (eraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
subpath = [startOfNewSegment]
} else {
subpath = []
}
}
}
if (needToDrawLastPoint(points, pathID, erasureIntervals)) {
subpaths.push(subpath.slice())
}
return subpaths.filter((subpath) => subpath.length > 0)
}
export const splitOnPressures = ([...path]) => {
const subpaths = []
let w = path[0][2]
for (let i = 1; i < path.length; i++) {
if (path[i][2] != w) {
subpaths.push([...path.splice(0, i), path[0]])
w = path[0][2]
i = 1
}
}
subpaths.push(path)
return subpaths
}
export const renderPath = (id, points, erasureIntervals) => {
let subpaths = applyErasureIntervals(id, points, erasureIntervals)
subpaths = subpaths.map(smoothPath)
subpaths = subpaths.flatMap(splitOnPressures)
const subpathElems = subpaths.map((subpath) => renderSubpath(subpath))
const pathGroupElem = ensurePathGroupElem(id)
subpathElems.forEach((subpathElem) => pathGroupElem.appendChild(subpathElem))
}
export const clear = () => {
......@@ -148,12 +270,12 @@ export const clear = () => {
}
// Necessary since buttons property is non standard on iOS versions < 13.2
function checkValidPointerEvent(e) {
const isValidPointerEvent = (e) => {
return e.buttons & 1 || e.pointerType === "touch"
}
const dispatchPointerEvent = (name) => (e) => {
if (checkValidPointerEvent(e)) {
if (isValidPointerEvent(e)) {
input.dispatchEvent(new CustomEvent(name, { detail: e }))
}
}
......
export default class AbstractConnection extends EventTarget {
constructor(options) {
super()
this.options = options
}
/*
Supported events:
- roomJoined => ()
- roomLeft => ()
- channelOpened => ({detail: uid})
- channelError => ({detail: uid})
- channelClosed => ({detail: uid})
- messageReceived => ({detail: {uid, channel, message}})
*/
getUserID() {
// => int
}
getPeerHandle(/*uid*/) {
// => opaque
}
getPeerFootprint(/*uid*/) {
// => Promise => int
}
send(/*uid, channel, message*/) {
// => void
}
broadcast(/*channel, message*/) {
// => void
}
terminatePeer(/*uid*/) {
// => void
}
destructor() {
// => void
}
}
import AbstractConnection from "./Connection.js"
import LioWebRTC from "liowebrtc"
export default class WebRTCConnection extends AbstractConnection {
constructor(options) {
super(options)
this.webrtc = new LioWebRTC({
url: this.options.url,
dataOnly: true,
constraints: {
minPeers: this.options.mesh.minPeers,
maxPeers: this.options.mesh.maxPeers,
},
})
this.webrtc.on("ready", () => {
this.webrtc.joinRoom(this.options.room)
})
this.webrtc.on("joinedRoom", () => {
this.dispatchEvent(new CustomEvent("roomJoined"))
})
this.webrtc.on("leftRoom", () => {
this.dispatchEvent(new CustomEvent("roomLeft"))
})
this.webrtc.on("channelOpen", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelOpened", { detail: peer.id }))
})
this.webrtc.on("channelError", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelError", { detail: peer.id }))
})
this.webrtc.on("channelClose", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelClosed", { detail: peer.id }))
})
this.webrtc.on("receivedPeerData", (channel, message, peer) => {
// Message could have been forwarded but interface only needs to know about directly connected peers
this.dispatchEvent(
new CustomEvent("messageReceived", {
detail: {
uid: peer.forwardedBy ? peer.forwardedBy.id : peer.id,
channel,
message,
},
}),
)
})
}
getUserID() {
return this.webrtc.getMyId()
}
getPeerHandle(uid) {
return this.webrtc.getPeerById(uid)
}
getPeerFootprint(uid) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return Promise.reject()
return new Promise(function(resolve, reject) {
peer.getStats(null).then((stats) => {
let footprint = -1
stats.forEach((report) => {
if (
report.type == "candidate-pair" &&
report.bytesSent > 0 &&
report.bytesReceived > 0 &&
report.writable
) {
footprint = Math.max(footprint, report.bytesReceived)
}
})
if (footprint != -1) {
resolve(footprint)
} else {
reject()
}
})
})
}
send(uid, channel, message) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return
this.webrtc.whisper(peer, channel, message)
}
broadcast(channel, message) {
this.webrtc.shout(channel, message)
}
terminatePeer(uid) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return
peer.end()
}
destructor() {
this.webrtc.quit()
}
}
function sqr(x) {
return x ** 2
}
function hypotenuseSquared(a, b) {
return sqr(a) + sqr(b)
}
function distanceSquared([x0, y0], [x1, y1]) {
return hypotenuseSquared(x0 - x1, y0 - y1)
}
function distance(point0, point1) {
return Math.sqrt(distanceSquared(point0, point1))
}
function cap01(x) {
return Math.max(0, Math.min(1, x))
}
function distToSegmentSquared(lineStart, lineEnd, point) {
const l2 = distanceSquared(lineStart, lineEnd)
if (l2 === 0) return distanceSquared(point, lineStart)
let t =
((point[0] - lineStart[0]) * (lineEnd[0] - lineStart[0]) +
(point[1] - lineStart[1]) * (lineEnd[1] - lineStart[1])) /
l2
t = cap01(t)
return distanceSquared(point, [
lineStart[0] + t * (lineEnd[0] - lineStart[0]),
lineStart[1] + t * (lineEnd[1] - lineStart[1]),
])
}
function interpolate([x0, y0], [x1, y1], t) {
return [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]
}
function project([x1, y1], [x2, y2], [x3, y3]) {
const x21 = x2 - x1,
y21 = y2 - y1
const x31 = x3 - x1,
y31 = y3 - y1
return (x31 * x21 + y31 * y21) / (x21 * x21 + y21 * y21)
}
function erasureInterval(lineStart, lineEnd, erasureCenter, erasureRadius) {
if (!(lineStart && erasureCenter)) return undefined
if (!lineEnd) {
const dist2ToSingularity = distanceSquared(erasureCenter, lineStart)
return dist2ToSingularity <= erasureRadius ** 2 ? [0, 0] : undefined
}
const distToSegment2 = distToSegmentSquared(lineStart, lineEnd, erasureCenter)
if (erasureRadius ** 2 < distToSegment2) return undefined
const lineLength = distance(lineStart, lineEnd)
if (lineLength === 0) {
return distToSegment2 <= erasureRadius ** 2 ? [0, 1] : undefined
}
const projT = project(lineStart, lineEnd, erasureCenter)
const projectionPoint = interpolate(lineStart, lineEnd, projT)
const d2 = distance(erasureCenter, projectionPoint)
const halfLength = Math.sqrt(Math.abs(sqr(erasureRadius) - sqr(d2)))
if (halfLength === 0) return undefined
let touchFromStartDist = distance(lineStart, projectionPoint)
if (projT < 0) touchFromStartDist = -touchFromStartDist
const touchBeginFromStarDist = touchFromStartDist - halfLength
const touchEndFromStarDist = touchFromStartDist + halfLength
return [
cap01(touchBeginFromStarDist / lineLength),
cap01(touchEndFromStarDist / lineLength),
]
}
export function computeErasureIntervals(points, erasureCenter, erasureRadius) {
return points
.map((point, i) => ({ point, i }))
.reduce((acc, { point, i }) => {
const interval = erasureInterval(
point,
points[i + 1],
erasureCenter,
erasureRadius,
)
if (!interval) return acc
return {
...acc,
[i]: [interval],
}
}, {})
}
function overlaps([s1, e1], [, e2]) {
return s1 <= e2 && s1 <= e1
}
function mergeIntervals(...intervals) {
if (!intervals.length) return []
const sorted = intervals.sort(([a], [b]) => a - b)
const stack = [sorted[0]]
sorted.forEach((x) => {
const top = stack[stack.length - 1]
if (overlaps(x, top)) {
if (x[1] > top[1]) top[1] = x[1]
} else {
stack.push(x)
}
})
return stack
}
export function combineErasureIntervals(i1, i2) {
const _i1 = { ...i1 }
Object.keys(i1).forEach((key) => {
if (i2[key]) {
_i1[key] = mergeIntervals(...i1[key], ...i2[key])
}
})
return { ...i2, ..._i1 }
}
export function spreadErasureIntervals(intervals) {
const spread = {}
intervals.forEach(([l, u]) => {
if (u > l) {
for (let li = Math.floor(l); li < u; li++) {
const list = spread[li] || []
const [il, iu] = [Math.max(li, l), Math.min(li + 1, u)]
list.push([il % 1, iu == li + 1 ? 1 : iu % 1])
spread[li] = list
}
} else {
spread[Math.floor(l)] = [[l % 1, u % 1]]
}
})
return spread
}
export function flattenErasureIntervals(intervals) {
const flatten = []
for (const idx in intervals) {
if (intervals[idx])
flatten.push(
intervals[idx].map(([l, u]) => [parseInt(idx) + l, parseInt(idx) + u]),
)
}
return flatten.flat()
}
......@@ -46,10 +46,10 @@ self.onmessage = (event) => {
let message = event.data.message.message
if (event.data.message.length > 1) {
let messages = buffer[event.data.peer.id]
let messages = buffer[event.data.uid]
if (!messages) {
messages = {}
buffer[event.data.peer.id] = messages
buffer[event.data.uid] = messages
}
let slices = messages[event.data.message.uuid]
......
......@@ -4,12 +4,19 @@ import yMap from "y-map"
import yMemory from "y-memory"
import Y from "yjs"
import yWebrtc from "./y-webrtc/index.js"
import yP2PMesh from "./y-p2p-mesh.js"
import WebRTCConnection from "./connection/WebRTC.js"
yMemory(Y)
yMap(Y)
yArray(Y)
yWebrtc(Y)
yP2PMesh(Y)
import {
combineErasureIntervals,
spreadErasureIntervals,
flattenErasureIntervals,
} from "./erasure.js"
class Room extends EventTarget {
constructor(name) {
......@@ -17,6 +24,7 @@ class Room extends EventTarget {
this.name = name
this._y = null
this.ownID = null
this.erasureIntervals = {}
}
disconnect() {
......@@ -25,47 +33,64 @@ class Room extends EventTarget {
addPath([x, y, w, colour]) {
const id = uuidv4()
this._y.share.strokeAdd.set(id, Y.Array).push([[x, y, w, colour]])
this.shared.strokePoints.set(id, Y.Array).push([[x, y, w, colour]])
return id
}
extendPath(id, [x, y, w, colour]) {
this._y.share.strokeAdd.get(id).push([[x, y, w, colour]])
this.shared.strokePoints.get(id).push([[x, y, w, colour]])
}
extendErasureIntervals(pathID, pointID, newIntervals) {
const self = this
// eslint-disable-next-line require-yield
this._y.db.requestTransaction(function* requestTransaction() {
const prevJSON = self.shared.eraseIntervals.get(pathID) || "[]"
const pathIntervals = JSON.parse(prevJSON)
const combinedIntervals = combineErasureIntervals(
[pathIntervals],
[flattenErasureIntervals({ [pointID]: newIntervals })],
)[0]
const postJSON = JSON.stringify(combinedIntervals)
if (prevJSON == postJSON) {
return
}
self.shared.eraseIntervals.set(pathID, postJSON)
})
}
getPaths() {
const paths = new Map()
for (const id of this._y.share.strokeAdd.keys()) {
for (const id of this.shared.strokePoints.keys()) {
paths.set(id, this._generatePath(id))
}
return paths
}
erasePoint(id, idx) {
let eraseSet = this._y.share.strokeErase.get(id)
if (!eraseSet) {
eraseSet = this._y.share.strokeErase.set(id, Y.Map)
}
eraseSet.set(idx.toString(), true)
get shared() {
return this._y.share
}
// Generate an array of points [x, y, exist] by merging the path's add and erase sets
_generatePath(id) {
const addSet = this._y.share.strokeAdd.get(id)
const points = this.shared.strokePoints.get(id)
if (addSet === undefined) {
return []
}
if (!points) return []
return points.toArray()
}
_generateRemovedIntervals(id) {
const intervals = this.shared.eraseIntervals.get(id)
const eraseSet = this._y.share.strokeErase.get(id) || { get: () => false }
if (!intervals) return []
return addSet
.toArray()
.map((p = [], i) => [p[0], p[1], p[2], p[3], !eraseSet.get(i.toString())])
return spreadErasureIntervals(JSON.parse(intervals))
}
inviteUser(id) {
......@@ -78,9 +103,14 @@ class Room extends EventTarget {
name: "memory",
},
connector: {
name: "webrtc",
name: "p2p-mesh",
connection: WebRTCConnection,
url: "/",
room: this.name,
mesh: {
minPeers: 4,
maxPeers: 8,
},
handshake: {
initial: 100,
interval: 500,
......@@ -125,37 +155,43 @@ class Room extends EventTarget {
},
},
share: {
strokeAdd: "Map",
strokeErase: "Map",
strokePoints: "Map",
eraseIntervals: "Map",
},
})
this._y.share.strokeAdd.observe((lineEvent) => {
const dispatchRemovedIntervalsEvent = (lineEvent) => {
const id = lineEvent.name
const intervals = this._generateRemovedIntervals(id)
const points = this._generatePath(id)
const detail = { id, intervals, points }
this.dispatchEvent(
new CustomEvent("removedIntervalsChange", {
detail,
}),
)
}
const dispatchPathUpdateEvent = (lineEvent) => {
const id = lineEvent.name
const points = this._generatePath(id)
const detail = { id, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
}
this.shared.strokePoints.observe((lineEvent) => {
if (lineEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
dispatchPathUpdateEvent(lineEvent)
lineEvent.value.observe((pointEvent) => {
if (pointEvent.type == "insert") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
dispatchPathUpdateEvent(lineEvent)
}
})
}
})
this._y.share.strokeErase.observe((lineEvent) => {
if (lineEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
lineEvent.value.observe((pointEvent) => {
if (pointEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
}
})
}
this.shared.eraseIntervals.observe((lineEvent) => {
dispatchRemovedIntervalsEvent(lineEvent)
})
}
}
......
......@@ -9,7 +9,7 @@ let tool = Tools.PEN
let strokeColour = "#0000ff"
let strokeRadius = 5
// TODO: The erase radius should also be selectable.
const ERASE_RADIUS = 10
const ERASE_RADIUS = 20
export const getTool = () => tool
export const getStrokeColour = () => strokeColour
......
/* global Y */
"use strict"
import LioWebRTC from "liowebrtc"
function extend(Y) {
class WebRTC extends Y.AbstractConnector {
class P2PMesh extends Y.AbstractConnector {
constructor(y, options) {
if (options === undefined) {
throw new Error("Options must not be undefined!")
......@@ -12,21 +10,20 @@ function extend(Y) {
options.role = "slave"
super(y, options)
this.webrtcOptions = options
this.webrtcOptions.handshake = this.webrtcOptions.handshake || {}
this.webrtcOptions.handshake.initial =
this.webrtcOptions.handshake.initial || 100
this.webrtcOptions.handshake.interval =
this.webrtcOptions.handshake.interval || 500
this.webrtcOptions.heartbeat = this.webrtcOptions.heartbeat || {}
this.webrtcOptions.heartbeat.interval =
this.webrtcOptions.heartbeat.interval || 500
this.webrtcOptions.heartbeat.minimum =
this.webrtcOptions.heartbeat.minimum || 1000
this.webrtcOptions.heartbeat.timeout =
this.webrtcOptions.heartbeat.timeout || 10000
this.options = options
this.options.mesh = this.options.mesh || {}
this.options.mesh.minPeers = this.options.mesh.minPeers || 4
this.options.mesh.maxPeers = this.options.mesh.maxPeers || 8
this.options.handshake = this.options.handshake || {}
this.options.handshake.initial = this.options.handshake.initial || 100
this.options.handshake.interval = this.options.handshake.interval || 500
this.options.heartbeat = this.options.heartbeat || {}
this.options.heartbeat.interval = this.options.heartbeat.interval || 500
this.options.heartbeat.minimum = this.options.heartbeat.minimum || 1000
this.options.heartbeat.timeout = this.options.heartbeat.timeout || 10000
this.queue = new Worker("js/queue.js")
this.queue.onmessage = (event) => {
......@@ -36,46 +33,43 @@ function extend(Y) {
const { uid, channel, message } = event.data
// y-js db transactions can send messages after a peer has disconnected
if (
(channel == "y-js" && !this.peers.has(uid)) ||
!this.webrtc.getPeerById(uid)
) {
if (channel == "y-js" && !this.peers.has(uid)) {
return
}
this.webrtc.whisper(this.webrtc.getPeerById(uid), channel, message)
this.connection.send(uid, channel, message)
} else if (method == "broadcast") {
const { channel, message } = event.data
return this.webrtc.shout(channel, message)
return this.connection.broadcast(channel, message)
} else if (method == "received") {
const { type, message, peer } = event.data
const { uid, channel, message } = event.data
if (type === "tw-ml") {
if (channel === "tw-ml") {
// Handshakes can only be sent and received directly
if (message === "tw") {
// Response message in the handshake
this.queue.postMessage({
method: "send",
uid: peer.id,
uid,
channel: "tw-ml",
message: "ml",
})
} else if (message == "ml") {
// Handshake completed
this.checkAndInsertPeer(peer.id)
this.checkAndInsertPeer(uid)
}
} else {
this.checkAndInsertPeer(peer.id)
this.checkAndInsertPeer(uid)
if (type === "y-js") {
this.checkAndInsertPeer(peer.id)
if (channel === "y-js") {
this.checkAndInsertPeer(uid)
if (message.type === "sync done") {
this.raiseUserEvent("peerSyncedWithUs", { user: peer.id })
this.raiseUserEvent("peerSyncedWithUs", { user: uid })
}
this.receiveMessage(peer.id, message)
this.receiveMessage(uid, message)
}
}
}
......@@ -84,97 +78,85 @@ function extend(Y) {
if (options.onUserEvent) {
this.onUserEvent(options.onUserEvent)
}
this.initialiseConnection()
window.addEventListener("unload", () => {
this.y.destroy()
})
this.initialiseConnection()
}
initialiseConnection() {
this.webrtc = new LioWebRTC({
url: this.webrtcOptions.url,
dataOnly: true,
constraints: {
minPeers: 4,
maxPeers: 8,
},
})
this.peers = new Map()
this.webrtc.on("ready", () => {
this.webrtc.joinRoom(this.webrtcOptions.room)
})
this.connection = new this.options.connection(this.options)
this.webrtc.on("joinedRoom", () => {
this.connection.addEventListener("roomJoined", () => {
this.checkAndEnsureUser()
})
this.webrtc.on("leftRoom", () => {
this.connection.addEventListener("roomLeft", () => {
console.log("TODO: LEFT ROOM")
})
this.webrtc.on("channelError", (a, b, c, d) =>
console.log("TODO: CHANNEL ERROR", a, b, c, d),
)
this.webrtc.on("channelOpen", (dataChannel, peer) => {
this.connection.addEventListener("channelOpened", ({ detail: uid }) => {
this.checkAndEnsureUser()
// Start a handshake to ensure both sides are able to use the channel
function handshake(peer) {
const _peer = this.webrtc.getPeerById(peer.id)
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer) {
return
}
if (this.peers.has(peer.id)) {
if (this.peers.has(uid)) {
return
}
// Initial message in the handshake
this.queue.postMessage({
method: "send",
uid: peer.id,
uid,
channel: "tw-ml",
message: "tw",
})
setTimeout(
handshake.bind(this, peer),
this.webrtcOptions.handshake.interval,
this.options.handshake.interval,
)
}
setTimeout(
handshake.bind(this, peer),
this.webrtcOptions.handshake.initial,
handshake.bind(this, this.connection.getPeerHandle(uid)),
this.options.handshake.initial,
)
})
this.webrtc.on("receivedPeerData", (type, message, peer) => {
this.checkAndEnsureUser()
// Message could have been forwarded but yjs only needs to know about directly connected peers
this.queue.postMessage({
method: "received",
type,
message,
peer: { id: peer.forwardedBy ? peer.forwardedBy.id : peer.id },
})
})
this.connection.addEventListener("channelError", ({ detail: uid }) =>
console.log("TODO: CHANNEL ERROR", uid),
)
this.webrtc.on("channelClose", (dataChannel, peer) => {
this.connection.addEventListener("channelClosed", ({ detail: uid }) => {
this.checkAndEnsureUser()
this.checkAndRemovePeer(peer.id)
this.checkAndRemovePeer(uid)
})
this.connection.addEventListener(
"messageReceived",
({ detail: { uid, channel, message } }) => {
this.checkAndEnsureUser()
this.queue.postMessage({
method: "received",
uid,
channel,
message,
})
},
)
}
// Ensure that y-js is up to date on the user's id
checkAndEnsureUser() {
const id = this.webrtc.getMyId()
const id = this.connection.getUserID()
if (this.y.db.userId === id) {
return
......@@ -192,13 +174,18 @@ function extend(Y) {
}
const health = {
lastStatsResolved: true,
lastReceivedBytes: 0,
lastReceivedTimestamp: Date.now(),
lastFootprintResolved: true,
lastFootprint: 0,
lastFootprintTimestamp: Date.now(),
}
health.cb = setInterval(
this.heartbeat.bind(this, this.webrtc.getPeerById(uid), health),
this.webrtcOptions.heartbeat.interval,
this.heartbeat.bind(
this,
uid,
this.connection.getPeerHandle(uid),
health,
),
this.options.heartbeat.interval,
)
this.peers.set(uid, health)
......@@ -206,75 +193,59 @@ function extend(Y) {
this.userJoined(uid, "master")
}
heartbeat(peer, health) {
const _peer = this.webrtc.getPeerById(peer.id)
heartbeat(uid, peer, health) {
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer || !this.peers.has(peer.id)) {
if (!_peer || _peer !== peer || !this.peers.has(uid)) {
clearInterval(health.cb)
return
}
if (!health.lastStatsResolved) {
return peer.end(true)
if (!health.lastFootprintResolved) {
return this.connection.terminatePeer(uid)
}
health.lastStatsResolved = false
health.lastFootprintResolved = false
const self = this
peer.getStats(null).then((stats) => {
health.lastStatsResolved = true
let disconnect = true
stats.forEach((report) => {
if (
report.type == "candidate-pair" &&
report.bytesSent > 0 &&
report.bytesReceived > 0 &&
report.writable
) {
const timeSinceLastReceived =
Date.now() - health.lastReceivedTimestamp
if (report.bytesReceived != health.lastReceivedBytes) {
health.lastReceivedBytes = report.bytesReceived
health.lastReceivedTimestamp = Date.now()
} else if (
timeSinceLastReceived > self.webrtcOptions.heartbeat.timeout
) {
return
} else if (
timeSinceLastReceived > self.webrtcOptions.heartbeat.interval
) {
self.queue.postMessage({
method: "send",
uid: peer.id,
channel: "heartbeat",
})
}
this.raiseUserEvent("userConnection", {
id: peer.id,
quality:
1.0 -
(self.webrtcOptions.heartbeat.timeout -
Math.max(
timeSinceLastReceived,
self.webrtcOptions.heartbeat.minimum,
)) /
(self.webrtcOptions.heartbeat.timeout -
self.webrtcOptions.heartbeat.minimum),
this.connection
.getPeerFootprint(uid)
.then((footprint) => {
health.lastFootprintResolved = true
const timeSinceLastFootprint =
Date.now() - health.lastFootprintTimestamp
if (footprint != health.lastFootprint) {
health.lastFootprint = footprint
health.lastFootprintTimestamp = Date.now()
} else if (timeSinceLastFootprint > self.options.heartbeat.timeout) {
return this.connection.terminatePeer(uid)
} else if (timeSinceLastFootprint > self.options.heartbeat.interval) {
self.queue.postMessage({
method: "send",
uid,
channel: "heartbeat",
})
disconnect = false
}
})
if (disconnect) {
peer.end(true)
}
})
this.raiseUserEvent("userConnection", {
id: uid,
quality:
1.0 -
(self.options.heartbeat.timeout -
Math.max(
timeSinceLastFootprint,
self.options.heartbeat.minimum,
)) /
(self.options.heartbeat.timeout -
self.options.heartbeat.minimum),
})
})
.catch(() => {
return this.connection.terminatePeer(uid)
})
}
// Ensure that y-js knows that the peer has left
......@@ -295,7 +266,7 @@ function extend(Y) {
disconnect() {
this.queue.terminate()
this.webrtc.quit()
this.connection.destructor()
super.disconnect()
}
......@@ -321,7 +292,7 @@ function extend(Y) {
this.raiseUserEvent("weSyncedWithPeer", { user: uid })
}
this.queue.postMessage({ method: "send", channel: "y-js", uid, message })
this.queue.postMessage({ method: "send", uid, channel: "y-js", message })
}
broadcast(message) {
......@@ -333,7 +304,7 @@ function extend(Y) {
}
}
Y.extend("webrtc", WebRTC)
Y.extend("p2p-mesh", P2PMesh)
}
export default extend
......
The MIT License (MIT)
Copyright (c) 2014 Kevin Jahns <kevin.jahns@rwth-aachen.de>.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment