Mixed Signals

Layered waves of turbulent dips and crests.

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 posterize from "/scratchpad/posterize/posterize.asset.mjs"

const dpi = window.devicePixelRatio
const π = Math.PI
const τ = π * 2

// Each signal is a function that calculates a value at time `t`
// from an array of stacked sine waves of varying frequency (f),
// phase (φ), and amplitude (A).
const makeSignal = () => {
  const len = random.integer(3, 8)
  const amp = 0.85 / len

  const waves = Array.from({ length: len }, () => ({
    f: τ * random.number(2, 10),
    A: random.wobble(amp, amp / 10),
    φ: random.wobble(τ),
  }))

  return (t, limit = waves.length) =>
    waves
      .slice(0, limit)
      .map(({ f, A, φ }) => A * Math.sin(f * t + φ))
      .reduce((a, b) => a + b, 0)
}

// Create a path object bounding the signal wave
const makeOutline = (signal, w, h) => {
  const path = new Path2D()
  const steps = w / 3
  const extra = 2.5

  path.moveTo(0, h * extra)

  for (let i = 0; i < steps; i++) {
    const t = i / (steps - 1)
    const x = t * w
    const y = h * signal(t)

    path.lineTo(x, y)
  }

  path.lineTo(w, h * extra)
  path.closePath()

  return path
}

// Create a path object hatching the signal wave
const makeHatches = (signal, w, h) => {
  const width = random.number(8, 16)
  const steps = w / (width * 2.2)

  const dy = random.number(0.5, 0.8) * h

  const f = random.number(2, 7)
  const φ = random.wobble(π)
  const A = h / 5
  const dx = (t) => A * Math.sin(τ * f * t + φ)

  const path = new Path2D()

  for (let i = 0; i < steps; i++) {
    const t = i / (steps - 1)
    const y = h * signal(t)
    const fx = (h * signal(t, -1)) / 1.5

    const x0 = t * w
    const y0 = y + dy

    const x2 = x0 + dx(t)
    const y2 = y0 + h * 3

    const x1 = x0 + (x2 - x0) / 2 + fx
    const y1 = y0 + (y2 - y0) / 2

    path.moveTo(x0, y0)
    path.quadraticCurveTo(x1, y1, x2, y2)
  }

  path.lineWidth = width

  return path
}

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

  const ctx = canvas.getContext("2d")
  const rows = random.integer(7, 14)

  const inset = random.number(15, 40)
  const frame = new Path2D()

  frame.rect(inset, inset, canvas.width - inset * 2, canvas.height - inset * 2)

  return sequence([
    () => {
      ctx.lineCap = "round"
      ctx.strokeStyle = palette.dark
      ctx.fillStyle = palette.canvas

      ctx.lineWidth = inset / 2
      ctx.stroke(frame)

      ctx.save()
      ctx.clip(frame)
    },

    ...Array.from({ length: rows }, (n, i) => () => {
      const t = (i - 1) / (rows - 2)

      const w = canvas.width
      const h = canvas.height / rows

      const x = 0
      const y = canvas.height * random.wobble(t, 0.03)

      const signal = makeSignal()
      const outline = makeOutline(signal, w, h)
      const hatches = makeHatches(signal, w, h)

      ctx.save()
      ctx.translate(x, y)

      ctx.lineWidth = random.number(6, 18)
      ctx.fill(outline)
      ctx.stroke(outline)

      ctx.save()
      ctx.clip(outline)
      ctx.lineWidth = hatches.lineWidth
      ctx.stroke(hatches)
      ctx.restore()

      ctx.restore()
    }),

    () => {
      ctx.restore()
      ctx.lineWidth = inset / 2
      ctx.stroke(frame)
    },

    () => {
      posterize({
        ctx,
        iterations: 8,
        radius: 2,
        ramp: 12,
      })
    },
  ])
}