// 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" 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 const smoothLine = line() .x((d) => d[0]) .y((d) => d[1]) .curve(curveBasis) const pathGroupElems = new Map() let stroke_colour = "blue" export const MIN_STROKE_RADIUS = 0.1 export const MAX_STROKE_RADIUS = 3.9 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 } function generateSvgPoint(subPath, colour) { const subpathElem = SVG.create.circle() subpathElem.setAttribute("stroke", "none") subpathElem.setAttribute("fill", colour) subpathElem.setAttribute("cx", subPath[0][0]) subpathElem.setAttribute("cy", subPath[0][1]) subpathElem.setAttribute("r", getStrokeRadius(subPath[0][2])) 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) 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([]) } } 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) { generateSvgPoint(subpath, colour) } 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 = smoothLine([...subpath_.splice(0, i), subpath_[0]]) pathGroupElem.appendChild(createPathElem(d, getStrokeRadius(w) * 2)) w = subpath_[0][2] i = 1 } } const d = smoothLine(subpath_) pathGroupElem.appendChild(createPathElem(d, getStrokeRadius(w) * 2)) } } } export const clear = () => { pathGroupElems.clear() canvas.innerHTML = "" } // 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 })) } } // 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 }