Newer
Older
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
Moritz Langenstein
committed
import { line, curveCatmullRom, curveLinear } from "d3-shape"
const SVG_URL = "http://www.w3.org/2000/svg"
import * as HTML from "./elements.js"
const curve = curveCatmullRom.alpha(1.0)
Moritz Langenstein
committed
const straightLine = line()
.x((d) => d[0])
.y((d) => d[1])
.curve(curveLinear)
const pathGroupElems = new Map()
const MAX_RADIUS_DELTA = 0.05
// - The radius delta between two adjacent points is capped at
Moritz Langenstein
committed
// MAX_RADIUS_DELTA.
// If paths are too choppy, try decreasing these constants.
const smoothPath = ([...path]) => {
// 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.splice(i, 0, ...newPoints)
i += newPoints.length
}
return path
}
export const input = new EventTarget()
const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
const interpolate = (point0, point1, fraction) => [
point0[0] + fraction * (point1[0] - point0[0]),
point0[1] + fraction * (point1[1] - point0[1]),
point0[2] + fraction * (point1[2] - point0[2]),
point0[3],
]
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)
Moritz Langenstein
committed
const renderSubpath = (subpath, pathSmooth) => {
const pathElem = createSvgElem("path")
pathElem.setAttribute("stroke", subpath[0][3])
pathElem.setAttribute("stroke-width", subpath[0][2] * 2)
Moritz Langenstein
committed
pathElem.setAttribute(
"d",
pathSmooth ? smoothLine(subpath) : straightLine(subpath),
)
const isValidPoint = (point) => {
return point != null && point[0] != null
}
const POINT_ERASE_LIMIT = 0.0001
const pointWasErased = (pointEraseIntervals) => {
pointEraseIntervals.length > 0 &&
pointEraseIntervals[0][0] <= POINT_ERASE_LIMIT
const needToDrawLastPoint = (points, pathEraseIntervals) => {
if (points.length < 2) return true
const penultimatePointIndex = points.length - 2
const pointEraseIntervals = pathEraseIntervals[penultimatePointIndex] || null
pointEraseIntervals != null &&
pointEraseIntervals.length > 0 &&
pointEraseIntervals.some((interval) => interval[1] >= 1 - POINT_ERASE_LIMIT)
) {
return false
}
return true
}
const applyErasureIntervals = (points, pathEraseIntervals) => {
if (points.length == 0) {
return []
for (let i = 0; i < points.length; i++) {
const point = points[i]
continue
}
const nextPoint = points[i + 1]
const pointEraseIntervals = pathEraseIntervals[i] || null
Moritz Langenstein
committed
} else if (nextPoint == null) {
if (JSON.stringify(pointEraseIntervals) != "[[0,0]]") subpath.push(point)
Moritz Langenstein
committed
continue
if (!pointWasErased(pointEraseIntervals) || subpath.length) {
for (const pointEraseInterval of pointEraseIntervals) {
const eraseIntervalBounds = pointEraseInterval.map((f) =>
interpolate(point, nextPoint, f),
const [endOfDrawnSegment, startOfNewSegment] = eraseIntervalBounds
if (pointEraseInterval[0] > POINT_ERASE_LIMIT) {
subpath.push(endOfDrawnSegment)
subpaths.push(subpath.slice())
if (pointEraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
subpath = [startOfNewSegment]
if (needToDrawLastPoint(points, pathEraseIntervals)) {
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, pathEraseIntervals) => {
let rectShapeStartIndex = -1
// Rect recognition hint shape: pure rect with no erasure
Moritz Langenstein
committed
if (Object.keys(pathEraseIntervals).length == 0 && points.length == 5) {
rectShapeStartIndex = 0
// Recognised rect shape: rect after completely erased raw data
Moritz Langenstein
committed
} else if (Object.keys(pathEraseIntervals).length > 0 && points.length > 5) {
// Check that the preceding raw data is completely erased
Moritz Langenstein
committed
for (let i = 0; i < points.length - 5; i++) {
if (
!pathEraseIntervals[i] ||
pathEraseIntervals[i].length != 1 ||
pathEraseIntervals[i][0][0] != 0 ||
pathEraseIntervals[i][0][1] != 1
) {
break
}
}
rectShapeStartIndex = points.length - 5
Moritz Langenstein
committed
}
// Only draw the path smooth if it is not a recognised rect shape, i.e. the last five points form a cycle
Moritz Langenstein
committed
const pathSmooth = !(
rectShapeStartIndex >= 0 &&
points[rectShapeStartIndex][0] == points[rectShapeStartIndex + 4][0] &&
points[rectShapeStartIndex][1] == points[rectShapeStartIndex + 4][1] &&
points[rectShapeStartIndex][2] == points[rectShapeStartIndex + 4][2] &&
points[rectShapeStartIndex][3] == points[rectShapeStartIndex + 4][3]
Moritz Langenstein
committed
)
let subpaths = applyErasureIntervals(points, pathEraseIntervals)
subpaths = subpaths.map(smoothPath)
subpaths = subpaths.flatMap(splitOnPressures)
Moritz Langenstein
committed
const subpathElems = subpaths.map((subpath) =>
renderSubpath(subpath, pathSmooth),
)
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, name) => {
if (name === "strokeend") return true
Iurii Maksymets
committed
return e.buttons & 1 || e.pointerType === "touch"
}
const dispatchPointerEvent = (name) => (e) => {
if (isValidPointerEvent(e, name)) {
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("onmouseup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("mouseup", dispatchPointerEvent("strokeend"))
Iurii Maksymets
committed
canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
canvas.addEventListener("touchmove", (e) => e.preventDefault())