Skip to content
Snippets Groups Projects
canvas.js 9.9 KiB
Newer Older
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.

import { line, curveLinear } 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(curveLinear)

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
}