Canyon

Crocodilian crags and undulating eskers.

Loading...
import * as random from "/scratchpad/_lib/random.asset.mjs"
import sequence from "/scratchpad/_lib/sequence.asset.mjs"
import palette from "/scratchpad/_lib/palette.asset.mjs"
import helpers from "/scratchpad/_lib/line.asset.mjs"
import posterize from "/scratchpad/posterize/posterize.asset.mjs"

const dpi = window.devicePixelRatio
const π = Math.PI

export default async (canvas) => {
  canvas.width = canvas.offsetWidth * dpi
  canvas.height = canvas.offsetHeight * dpi

  const ctx = canvas.getContext("2d")
  const square = Math.min(canvas.width, canvas.height)

  const cells = []

  const buffer = square * 0.05
  const cellSize = square / random.number(20, 110)
  const lineWidth = 4 + cellSize / 8

  let matrix, colCount, rowCount

  return sequence([
    () => {
      ctx.lineCap = "round"
      ctx.lineJoin = "round"

      // Flip the canvas to randomise draw order
      if (random.maybe()) {
        ctx.translate(canvas.width / 2, 0)
        ctx.scale(-1, 1)
        ctx.translate(-canvas.width / 2, 0)
      }
    },

    () => {
      const width = canvas.width - buffer * 2
      const height = canvas.height - buffer * 2

      const grid = helpers.makeGrid(width, height, cellSize)

      colCount = Math.floor(width / cellSize)
      rowCount = Math.floor(height / cellSize)

      const skewY = random.wobble(π / 4)
      const skewX = random.wobble(π / 2, π / 4)

      const waveCount = random.integer(4, 20)

      const waves = Array.from({ length: waveCount }, () => ({
        freq: random.number(1, 8),
        amp: (random.number(0.4, 1.4) * square) / waveCount,
        sigma: random.number(π),
      }))

      const sawToothBase = random.number(0.25, 0.4)

      grid.forEach((p, i) => {
        const timeR = p.row / (rowCount - 1)
        const timeC = p.col / (colCount - 1)
        const dampR = Math.sin(π * timeR)
        const dampC = Math.sin(π * timeC) * (0.2 + dampR * 0.8)

        const { x, y } = p

        // Skew
        p.x += Math.cos(skewX) * ((p.row - rowCount / 2) * cellSize) * dampR
        p.y += Math.sin(skewY) * ((p.col - colCount / 2) * cellSize) * dampC

        // Waveforms
        waves.forEach((wave) => {
          p.x += Math.cos(wave.sigma + wave.freq * π * timeR) * wave.amp * dampR

          p.y += Math.sin(wave.sigma + wave.freq * π * timeC) * wave.amp * dampC
        })

        // Saw tooth
        if (p.col % 2 === 1 && random.maybe(0.9)) {
          const prev = grid[i - 1]
          const theta = -π / 2 + Math.atan2(p.y - prev.y, p.x - prev.x)
          const dist = Math.sqrt(helpers.sqdist(prev, p))

          p.x += Math.cos(theta) * dist * random.wobble(sawToothBase, 0.1)
          p.y += Math.sin(theta) * dist * random.wobble(sawToothBase, 0.1)
        }

        // Capture angular change in position
        p.t = Math.atan2(p.y - y, p.x - x)

        // Randomness
        p.x += random.wobble(cellSize / 3)
        p.y += random.wobble(cellSize / 3)
      })

      // Re-center the grid to fit within the buffer zone
      const minX = Math.min(...grid.map((p) => p.x))
      const maxX = Math.max(...grid.map((p) => p.x))
      const minY = Math.min(...grid.map((p) => p.y))
      const maxY = Math.max(...grid.map((p) => p.y))

      const sx = (maxX - minX) / width
      const sy = (maxY - minY) / height

      grid.forEach((p) => {
        p.x = (p.x - minX) / sx + buffer
        p.y = (p.y - minY) / sy + buffer
      })

      // Map the grid to the matrix for quick look-ups
      matrix = Array.from({ length: rowCount }, () => [])
      grid.forEach((p) => {
        matrix[p.row][p.col] = [p.x, p.y]
        matrix[p.row][p.col].t = p.t
      })
    },

    // Draw borders
    () => {
      ctx.beginPath()

      for (let row = 0; row < rowCount - 1; row++) {
        ctx.moveTo(...matrix[row][0])
        for (let col = 1; col < colCount - 1; col++) {
          ctx.lineTo(...matrix[row][col])
        }
      }

      ctx.strokeStyle = palette.dark
      ctx.stroke()
    },

    // Compute cells
    // Moving along a row, add pentagons with alternating sides (eg /__|__\)
    () => {
      for (let row = 0; row < rowCount - 1; row++) {
        const alt = row % 2
        const r0 = matrix[row]
        const r1 = matrix[row + 1]

        // Bound columns at the outer rows within a squircle
        const skip = Math.floor(
          colCount * (Math.pow(1 - Math.sin(π * (row / (rowCount - 2))), 2) / 2)
        )

        for (let col = 1 + skip; col < colCount - 1 - skip; col++) {
          if ((col + alt) % 3 !== 0) continue

          if ((col + alt) % 6 === alt * 3) {
            cells.push(
              [r0[col + 0], r0[col - 1], r0[col - 2], r1[col - 2], r1[col - 1]],
              [r0[col + 0], r0[col + 1], r1[col + 1], r1[col + 0], r1[col - 1]]
            )
          } else {
            cells.push(
              [r0[col - 1], r0[col - 2], r1[col - 2], r1[col - 1], r1[col + 0]],
              [r0[col - 1], r0[col + 0], r0[col + 1], r1[col + 1], r1[col + 0]]
            )
          }

          if (
            row === rowCount - 2 ||
            (row > rowCount / 2 &&
              (col <= 9 + skip || col >= colCount - (9 + skip)))
          ) {
            cells[cells.length - 2].isAtBase = true
            cells[cells.length - 1].isAtBase = true
          }
        }
      }
    },

    // Render each cell in order
    () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height)

      const hatchLength = random.number(cellSize * 2, cellSize * 6)

      cells.forEach((cell) => {
        const hatchX = Math.cos(π * 1.25 + cell[0].t) * hatchLength
        const hatchY = Math.sin(π * 1.25 + cell[0].t) * hatchLength

        const horizX = random.number(cellSize * 3)
        const horizY = random.number(cellSize * 1)

        // Horizontals at edges
        ctx.beginPath()

        cell.slice(0, 1).forEach((p) => {
          ctx.moveTo(p[0] - horizX, p[1] + horizY)
          ctx.lineTo(...p)
          ctx.lineTo(p[0] + horizX, p[1] + horizY)
        })

        ctx.globalCompositeOperation = "destination-over"
        ctx.lineWidth = lineWidth
        ctx.strokeStyle = palette.dark
        ctx.stroke()

        // Directional shading strokes
        ctx.beginPath()

        helpers
          .interpolate(helpers.objectify(cell), ctx.lineWidth * 2)
          .forEach((p) => {
            const s1 = random.number()
            const s2 = random.number()
            const y1 = random.number(cellSize / 4)
            const y2 = random.number(cellSize / 4)
            ctx.moveTo(p.x + s1 * hatchX, p.y + y1 + s1 * hatchY)
            ctx.lineTo(p.x + s2 * hatchX, p.y + y2 + s2 * hatchY)
          })

        ctx.globalCompositeOperation = "source-atop"
        ctx.lineWidth = lineWidth / 2
        ctx.strokeStyle = palette.dark
        ctx.stroke()

        // Mask over preceding layers below cell
        if (cell.isAtBase) {
          const byX = [...cell].sort((a, b) => a[0] - b[0])
          const pl = byX[0]
          const pr = byX[byX.length - 1]

          ctx.beginPath()
          ctx.moveTo(...pl)
          ctx.lineTo(pl[0] - cellSize, canvas.height)
          ctx.lineTo(pr[0] + cellSize, canvas.height)
          ctx.lineTo(...pr)
          ctx.closePath()

          ctx.globalCompositeOperation = "source-over"
          ctx.fillStyle = palette.canvas
          ctx.fill()
        }

        // Core cell cell
        ctx.beginPath()
        cell.forEach((p) => ctx.lineTo(...p))
        ctx.closePath()

        ctx.globalCompositeOperation = "source-over"
        ctx.lineWidth = lineWidth
        ctx.fillStyle = palette.canvas
        ctx.fill()
        ctx.strokeStyle = palette.dark
        ctx.stroke()
      })
    },

    () => {
      ctx.beginPath()
      ctx.fillStyle = palette.dark
      ctx.globalCompositeOperation = "source-over"

      matrix.forEach((row) => {
        row.forEach((p) => {
          if (random.maybe(0.1)) {
            ctx.moveTo(...p)
            ctx.arc(...p, lineWidth / 2, 0, π * 2)
          }
        })
      })

      ctx.fill()
    },

    () => {
      posterize({
        ctx,
        radius: ctx.lineWidth * 0.6,
        ramp: ctx.lineWidth * 2,
      })
    },
  ])
}