// 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" // TODO: switch to curve interpolation that respects mouse points based on velocity const lineFn = line() .x((d) => d[0]) .y((d) => d[1]) .curve(curveBasis) const pathGroupElems = new Map() let strokeColour = "#0000ff" let strokeRadius = 5 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 radius 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 createPathElem = (d, width) => { const pathGroupElem = document.createElementNS(SVG_URL, "path") pathGroupElem.setAttribute("stroke-width", width) pathGroupElem.setAttribute("d", d) return pathGroupElem } 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 } if (pathGroupElem == null) { pathGroupElem = document.createElementNS(SVG_URL, "g") pathGroupElem.setAttribute("stroke", colour) pathGroupElem.setAttribute("fill", "none") pathGroupElem.setAttribute("stroke-linecap", "round") pathGroupElem.setAttribute("pointer-events", "none") canvas.appendChild(pathGroupElem) pathGroupElems.set(id, pathGroupElem) } pathGroupElem.innerHTML = "" for (const subpath of segments) { if (subpath.length == 1) { const circleElem = document.createElementNS(SVG_URL, "circle") circleElem.setAttribute("stroke", "none") circleElem.setAttribute("fill", colour) circleElem.setAttribute("cx", subpath[0][0]) circleElem.setAttribute("cy", subpath[0][1]) circleElem.setAttribute("r", subpath[0][2]) pathGroupElem.appendChild(circleElem) } 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 = lineFn([...subpath_.splice(0, i), subpath_[0]]) pathGroupElem.appendChild(createPathElem(d, w * 2)) w = subpath_[0][2] i = 1 } } const d = lineFn(subpath_) pathGroupElem.appendChild(createPathElem(d, 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) { strokeColour = colour } export function getStrokeColour() { return strokeColour } const MIN_PRESSURE_FACTOR = 0.1 const MAX_PRESSURE_FACTOR = 1.5 // This is a quadratic such that: // - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR // - getPressureFactor(0.5) = 1.0 // - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR // For sensible results, maintain that: // - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0 // - 1.0 <= MAX_PRESSURE_FACTOR // For intuitive results, maintain that: // - MAX_PRESSURE_FACTOR <= ~2.0 const getPressureFactor = (pressure) => { const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4 const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4 const c = MIN_PRESSURE_FACTOR return a * pressure ** 2 + b * pressure + c } export function getStrokeRadius(pressure) { return strokeRadius * getPressureFactor(pressure) } export function setStrokeRadius(radius) { strokeRadius = radius }