Skip to content
Snippets Groups Projects
canvas.js 9.9 KiB
Newer Older
  • Learn to ignore specific revisions
  • // 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])
    
    const pathGroupElems = new Map()
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
    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)
    }
    
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
    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
    }
    
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
    function generateSvgPoint(subPath, colour) {
    
      const subpathElem = SVG.create.circle()
    
      subpathElem.setAttribute("stroke", "none")
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
      subpathElem.setAttribute("fill", colour)
    
      subpathElem.setAttribute("cx", subPath[0][0])
      subpathElem.setAttribute("cy", subPath[0][1])
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
      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)
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
      let colour = ""
    
      // Split up points into completely non-erased segments.
      let segments = [[]]
      for (const point of points) {
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
        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
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
      pathGroupElem = ensureSvgPath(pathGroupElem, colour, id)
    
    
      for (const subpath of segments) {
        if (subpath.length == 1) {
    
    Yuriy Maksymets's avatar
    Yuriy Maksymets committed
          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()
    
    // 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
    }
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
    
    export function getStrokeColour() {
      return stroke_colour
    }