Skip to content
Snippets Groups Projects
canvas.js 7.35 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, curveCatmullRom } from "d3-shape"
    
    
    import { canvas } from "./elements.js"
    
    
    const SVG_URL = "http://www.w3.org/2000/svg"
    
    
    import * as HTML from "./elements.js"
    
    
    // TODO: look at paper.js which has path.smooth() and curve.getPart()
    // TODO: look at snap.svg which has path.getTotalLength(), path.subpath() and Snap.closestPoint()
    // TODO: look at curve interpolation that respects mouse points based on velocity
    const curve = curveCatmullRom.alpha(1.0)
    
    const smoothLine = line()
    
      .x((d) => d[0])
      .y((d) => d[1])
    
    const pathGroupElems = new Map()
    
    const MAX_POINT_DISTANCE = 5
    
    
    // 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
    
    // 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.splice(i, 0, ...newPoints)
        i += newPoints.length
      }
    
    
      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)
    
      groupElem.innerHTML = ""
    
      return groupElem
    
      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
    
      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)
    
    const applyErasureIntervals = (points, pathEraseIntervals) => {
    
      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 pointEraseIntervals = pathEraseIntervals[i] || null
    
        if (pointEraseIntervals == null) {
    
          subpath.push(point)
    
        } else if (nextPoint == null) {
          if (JSON.stringify(pointEraseIntervals) != "[[0,0]]") subpath.push(point)
    
        if (!pointWasErased(pointEraseIntervals) || subpath.length) {
    
          subpath.push(point)
    
        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 subpaths = applyErasureIntervals(points, pathEraseIntervals)
    
      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()
    
    // 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())