Snowflake Generator

January 1, 2022

Happy New Year! Here's a snowflake generator I created using an HTML canvas:


And here's the JavaScript. Happy coding!

class SnowflakeGenerator {
  constructor(canvas, lineWidth, numberOfSegments, branchAngle, speed) {
    this.canvas = canvas
    this.lineWidth = lineWidth
    this.numberOfSegments = numberOfSegments
    this.branchAngle = branchAngle
    this.speed = speed
    this.canceled = false
  }

  create() {
    this.resizeCanvas()
    this.defineLayers()
    this.drawBackground()
    this.computeLengths()
    window.requestAnimationFrame(this.drawFrame.bind(this))
  }

  resizeCanvas() {
    let rect = this.canvas.getBoundingClientRect()
    this.canvas.width = rect.width * 4
    this.canvas.height = this.canvas.width
    this.context = this.canvas.getContext('2d')
    this.center = this.canvas.width / 2
  }

  defineLayers() {
    this.context.lineCap = 'round'
    this.layers = [
      { color: '#ffffff', lineWidth: this.lineWidth, shadowColor: '#c0c0c0', shadowBlur: 25 },
      { color: '#ffffff', lineWidth: this.lineWidth },
      { color: '#000000', lineWidth: 1 }
    ]
  }

  drawBackground() {
    let gradient = this.context.createLinearGradient(0, 1000, 1000, 0)
    gradient.addColorStop(0, '#0b3774')
    gradient.addColorStop(1, '#3480eb')
    this.context.fillStyle = gradient
  }

  computeLengths() {
    this.spokeLength = this.canvas.width * .45
    this.segmentLength = this.spokeLength / this.numberOfSegments

    // Make the branches of the middle segements longer than those at the center and outside
    this.branchLengths = []
    for (let i = 0; i < this.numberOfSegments; i++) {
      let length = (this.numberOfSegments / 2 - Math.abs(this.numberOfSegments / 2 - i)) * this.segmentLength * 0.75
      this.branchLengths.push(length)
    }
  }

  drawFrame(timestamp) {
    if (this.canceled) { return }  // Abort if a new snowflake generation has started

    this.advanceElapsedTime(timestamp)
    this.computeVisibleSpokeLength()
    this.buildLines()
    this.drawLines()

    // If there's more to draw, request a new animation frame
    if (this.spokeLength > this.visibleSpokeLength) { window.requestAnimationFrame(this.drawFrame.bind(this)) }
  }

  advanceElapsedTime(timestamp) {
    if (!this.startTime) { this.startTime = timestamp }
    this.elapsedSeconds = (timestamp - this.startTime) / 1000
  }

  // Compute how much of each spoke should be visible for the current animation frame
  computeVisibleSpokeLength() {
    this.visibleSpokeLength = this.elapsedSeconds > 0 ? this.elapsedSeconds * this.speed / 5 * this.spokeLength : 0
    if (this.visibleSpokeLength > this.spokeLength) { this.visibleSpokeLength = this.spokeLength }
    this.remainingLength = this.visibleSpokeLength
    this.maxLineWidth = (this.visibleSpokeLength > this.spokeLength) ? this.lineWidth : this.lineWidth * this.visibleSpokeLength / this.spokeLength
  }

  buildLines() {
    let spokes = []
    for (let i = 0; i < 6; i++) { spokes.push({ x: this.center, y: this.center }) }
    this.lines = []

    for (let segment = 0; segment < this.numberOfSegments; segment++) {
      let degree = 0
      let visibleSegmentLength = this.segmentLength
      let visibleBranchLength = this.branchLengths[segment]

      // Compute how much of the segment to draw
      if (this.remainingLength > visibleSegmentLength) {
        this.remainingLength -= visibleSegmentLength
      } else {
        visibleBranchLength *= this.remainingLength / visibleSegmentLength
        visibleSegmentLength = this.remainingLength
        this.remainingLength = 0
      }

      if (visibleSegmentLength < 0) { break }

      spokes.forEach(spoke => {
        let x1 = spoke.x
        let y1 = spoke.y

        // Ice crystals form in a hexagonal pattern.
        // Move 60° and draw the next segment for the current spoke.
        degree += 60
        let line = this.computeLine(degree, x1, y1, visibleSegmentLength)
        this.lines.push(line)
        spoke.x = line.x2
        spoke.y = line.y2

        this.lines.push(this.computeLine(degree + this.branchAngle, x1, y1, visibleBranchLength))
        this.lines.push(this.computeLine(degree - this.branchAngle, x1, y1, visibleBranchLength))
      })
    }
  }

  computeLine(degree, x1, y1, length) {
    let radians = degree * Math.PI / 180
    let x2 = x1 + length * Math.cos(radians)
    let y2 = y1 + length * Math.sin(radians)
    return { x1: x1, x2: x2, y1: y1, y2: y2 }
  }

  drawLines() {
    this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this.context.fillRect(0, 0, this.canvas.width, this.canvas.height)
    this.layers.forEach(layer => {
      this.lines.forEach(line => { this.drawLine(line, layer) })
    })
  }

  drawLine(line, layer) {
    let lineWidth = layer.lineWidth > this.maxLineWidth ? this.maxLineWidth : layer.lineWidth
    this.context.beginPath()
    this.context.strokeStyle = layer.color
    this.context.lineWidth = lineWidth
    this.context.shadowColor = layer.shadowColor
    this.context.shadowBlur = layer.shadowBlur || 0
    this.context.moveTo(line.x1, line.y1)
    this.context.lineTo(line.x2, line.y2)
    this.context.stroke()
  }

  cancel() {
    this.canceled = true
  }
}