Skip to content
Snippets Groups Projects
canvas.js 8.16 KiB
Newer Older
// 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 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],
  ])
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 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,
    )
      subpath.push(point)
    } else if (nextPoint === undefined) {
      if (JSON.stringify(eraseIntervals) != "[[0,0]]") 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]
      }
    }
  }

  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()
// Necessary since buttons property is non standard on iOS versions < 13.2
const isValidPointerEvent = (e, name) => {
  if (name === "strokeend") return true
  return e.buttons & 1 || e.pointerType === "touch"
}

const dispatchPointerEvent = (name) => (e) => {
  if (isValidPointerEvent(e, name)) {
    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("onmouseup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("mouseup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
canvas.addEventListener("touchmove", (e) => e.preventDefault())