Newer
Older
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
import { line, curveBasis } from "d3-shape"
const SVG_URL = "http://www.w3.org/2000/svg"
import * as HTML from "./elements.js"
// TODO: switch to curve interpolation that respects mouse points based on velocity
const pathGroupElems = new Map()
let strokeColour = "blue"
let strokeRadius = 1
export const MIN_PRESSURE = 0.1
export const MAX_PRESSURE = 1.0
const MAX_RADIUS_DELTA = 0.05
// Interpolate a path so that:
// - The distance between two adjacent points is capped at MAX_POINT_DISTANCE.
// - The pressure delta between two adjacent points is capped at
// MAX_RADIUS_DELTA
// If paths are too choppy, try decreasing these constants.
const smoothPath = ([...path]) => {
// Apply MAX_POINT_DISTANCE.
for (let i = 1; i < path.length; i++) {
const dx = path[i][0] - path[i - 1][0]
const dy = path[i][1] - path[i - 1][1]
const dw = path[i][2] - path[i - 1][2]
const distance = Math.hypot(dx, dy)
const segmentsToSplit = Math.ceil(distance / MAX_POINT_DISTANCE)
const newPoints = []
for (let j = 1; j < segmentsToSplit; j++) {
newPoints.push([
path[i - 1][0] + (dx / segmentsToSplit) * j,
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)
i += newPoints.length
}
// Apply MAX_RADIUS_DELTA.
for (let i = 1; i < path.length; i++) {
const dx = path[i][0] - path[i - 1][0]
const dy = path[i][1] - path[i - 1][1]
const dw = path[i][2] - path[i - 1][2]
const segmentsToSplit = Math.ceil(dw / MAX_RADIUS_DELTA)
const newPoints = []
for (let j = 1; j < segmentsToSplit; j++) {
newPoints.push([
path[i - 1][0] + (dx / segmentsToSplit) * j,
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)
i += newPoints.length
}
return path
}
export const input = new EventTarget()
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],
true,
])
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)
groupElem.innerHTML = ""
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])
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
}
const applyErasureIntervals = (pathID, points, erasureIntervals) => {
if (points.length == 0) {
return []
for (let i = 0; i < points.length; i++) {
const point = points[i]
continue
}
const nextPoint = points[i + 1]
const eraseIntervals = getEraseIntervalsForPointInPath(
erasureIntervals,
pathID,
i,
)
Moritz Langenstein
committed
if (!eraseIntervals) {
Moritz Langenstein
committed
} 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)
subpaths.push(subpath.slice())
if (eraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
subpath = [startOfNewSegment]
}
}
}
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))
pathGroupElems.clear()
canvas.innerHTML = ""
Iurii Maksymets
committed
// Necessary since buttons property is non standard on iOS versions < 13.2
const isValidPointerEvent = (e) => {
Iurii Maksymets
committed
return e.buttons & 1 || e.pointerType === "touch"
}
const dispatchPointerEvent = (name) => (e) => {
if (isValidPointerEvent(e)) {
Iurii Maksymets
committed
input.dispatchEvent(new CustomEvent(name, { detail: e }))
Iurii Maksymets
committed
}
// Note that the PointerEvent is passed as the detail in these 'stroke events'.
canvas.addEventListener("pointerdown", dispatchPointerEvent("strokestart"))
canvas.addEventListener("pointerenter", dispatchPointerEvent("strokestart"))
canvas.addEventListener("pointerup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
canvas.addEventListener("touchmove", (e) => e.preventDefault())
export const setStrokeColour = (colour) => {
strokeColour = colour
export const getStrokeColour = () => {
return strokeColour
export const setStrokeRadius = (radius) => {
strokeRadius = radius
const calculateStrokeRadius = (pressure, radius) => {
return radius * (MIN_PRESSURE + pressure * (MAX_PRESSURE - MIN_PRESSURE))
export const getStrokeRadius = (pressure) => {
return calculateStrokeRadius(pressure, strokeRadius)