// 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 strokeColour = "blue" let strokeRadius = 1 export const MIN_PRESSURE = 0.1 export const MAX_PRESSURE = 1.0 const MAX_POINT_DISTANCE = 5 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]) return circleElem } 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 [] } const subpaths = [] let subpath = [] for (let i = 0; i < points.length; i++) { const point = points[i] if (!isValidPoint(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) } subpaths.push(subpath.slice()) if (eraseInterval[1] < 1 - POINT_ERASE_LIMIT) { subpath = [startOfNewSegment] } else { subpath = [] } } } 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)) } export const clear = () => { pathGroupElems.clear() canvas.innerHTML = "" } // Necessary since buttons property is non standard on iOS versions < 13.2 const isValidPointerEvent = (e) => { return e.buttons & 1 || e.pointerType === "touch" } const dispatchPointerEvent = (name) => (e) => { if (isValidPointerEvent(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 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) }