Newer
Older
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
import { line, curveLinear } from "d3-shape"
import { canvas } from "./elements.js"
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
.x((d) => d[0])
.y((d) => d[1])
.curve(curveLinear)
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 createPathElem = (d, width) => {
const pathGroupElem = document.createElementNS(SVG_URL, "path")
pathGroupElem.setAttribute("stroke-width", width)
pathGroupElem.setAttribute("d", d)
return pathGroupElem
}
///////////
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),
},
}
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 ensureSvgPath(pathElem, colour, id) {
if (pathElem == null) {
pathElem = SVG.create.group()
pathElem.setAttribute("stroke-width", MIN_STROKE_RADIUS * 2)
pathElem.setAttribute("stroke", colour)
pathElem.setAttribute("fill", "none")
pathElem.setAttribute("stroke-linecap", "round")
pathElem.setAttribute("pointer-events", "none")
canvas.appendChild(pathElem)
pathGroupElems.set(id, pathElem)
}
pathElem.innerHTML = ""
return pathElem
}
function getOrAddPathElem(pathElems, id) {
let pathElem = pathElems.get(id)
if (pathElem == null) {
pathElem = SVG.create.group()
pathElem.setAttribute("stroke", stroke_colour)
pathElem.setAttribute("stroke-width", MIN_STROKE_RADIUS * 2)
pathElem.setAttribute("fill", "none")
pathElem.setAttribute("pointer-events", "none")
HTML.canvas.appendChild(pathElem)
pathElems.set(id, pathElem)
}
pathElem.innerHTML = ""
return pathElem
}
const subpathElem = SVG.create.circle()
subpathElem.setAttribute("stroke", "none")
subpathElem.setAttribute("cx", subPath[0][0])
subpathElem.setAttribute("cy", subPath[0][1])
subpathElem.setAttribute("r", getStrokeRadius(subPath[0][2]))
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
return subpathElem
}
function generateSvgPath(subPath) {
const subpathElem = SVG.create.path()
subpathElem.setAttribute("d", smoothLine(subPath))
return subpathElem
}
function generateSvgForSubpath(subPath) {
return subPath.length === 1
? generateSvgPoint(subPath)
: generateSvgPath(subPath)
}
function isInvalidPoint(point) {
return !point || point[0] === undefined
}
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 generatePointsForPathElem(
pathElem,
pathID,
dataPoints,
erasureIntervals,
) {
const points = dataPoints
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
}
const nextPoint = points[i + 1]
const eraseIntervals = getEraseIntervalsForPointInPath(
erasureIntervals,
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, erasureIntervals)) {
appendToWholePath(subPath)
}
}
///////////
export const renderPath = (id, points, erasureIntervals) => {
const pathElem = getOrAddPathElem(pathGroupElems, id)
if (!points.length) {
return pathElem
}
generatePointsForPathElem(pathElem, id, points, erasureIntervals)
return pathElem
}
export const ___renderPath = (id, points) => {
points = points.filter(([x]) => x != null)
// Split up points into completely non-erased segments.
let segments = [[]]
for (const point of points) {
segments[segments.length - 1].push(point)
} else {
segments.push([])
}
segments = segments.filter((a) => a.length > 0)
let pathGroupElem = pathGroupElems.get(id)
if (segments.length == 0) {
if (pathGroupElem != null) {
canvas.removeChild(pathGroupElem)
pathGroupElems.delete(id)
}
return
pathGroupElem = ensureSvgPath(pathGroupElem, colour, id)
for (const subpath of segments) {
if (subpath.length == 1) {
} else {
// Further split up segments based on thickness.
let w = subpath_[0][2]
for (let i = 1; i < subpath_.length; i++) {
if (subpath_[i][2] != w) {
const d = smoothLine([...subpath_.splice(0, i), subpath_[0]])
pathGroupElem.appendChild(createPathElem(d, getStrokeRadius(w) * 2))
w = subpath_[0][2]
i = 1
}
pathGroupElem.appendChild(createPathElem(d, getStrokeRadius(w) * 2))
}
}
}
export const clear = () => {
pathGroupElems.clear()
canvas.innerHTML = ""
Iurii Maksymets
committed
// Necessary since buttons property is non standard on iOS versions < 13.2
function checkValidPointerEvent(e) {
return e.buttons & 1 || e.pointerType === "touch"
}
const dispatchPointerEvent = (name) => (e) => {
if (checkValidPointerEvent(e)) {
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
}