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()
export const MIN_STROKE_RADIUS = 0.1
export const MAX_STROKE_RADIUS = 3.9
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
const MAX_POINT_DISTANCE = 5
const MAX_PRESSURE_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_PRESSURE_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_PRESSURE_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_PRESSURE_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
}
const getStrokeRadius = (pressure) => {
return MIN_STROKE_RADIUS + pressure * (MAX_STROKE_RADIUS - MIN_STROKE_RADIUS)
}
export const input = new EventTarget()
const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
function 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,
])
function ensurePathGroupElem(id) {
let groupElem = pathGroupElems.get(id)
if (groupElem == null) {
groupElem = createSvgElem("g")
groupElem.setAttribute("stroke", stroke_colour)
groupElem.setAttribute("fill", "none")
groupElem.setAttribute("pointer-events", "none")
HTML.canvas.appendChild(groupElem)
pathGroupElems.set(id, groupElem)
groupElem.innerHTML = ""
return groupElem
function renderPoint(point, colour) {
const circleElem = createSvgElem("circle")
circleElem.setAttribute("stroke", "none")
circleElem.setAttribute("fill", colour)
circleElem.setAttribute("cx", point[0])
circleElem.setAttribute("cy", point[1])
circleElem.setAttribute("r", getStrokeRadius(point[2]))
function renderSubpath(subpath) {
if (subpath.length == 1) {
return renderPoint(subpath[0])
}
const pathElem = createSvgElem("path")
pathElem.setAttribute("stroke-width", getStrokeRadius(subpath[0][2]) * 2)
pathElem.setAttribute("d", smoothLine(subpath))
return pathElem
function isValidPoint(point) {
return point != null && point[0] != null
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
}
function 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
function pointWasErased(eraseIntervals) {
return (
eraseIntervals.length &&
eraseIntervals[0] &&
eraseIntervals[0][0] <= POINT_ERASE_LIMIT
)
}
function 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
}
function 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,
)
if (!eraseIntervals || nextPoint === undefined) {
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
function 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 function setStrokeColour(colour) {
stroke_colour = colour
}
export function getStrokeColour() {
return stroke_colour
}