700 Hexagons

Generating every unique hexagonal pattern made from 18 triangles and 18 diamonds.

Take an equilateral triangle, and divide it into six shapes — three triangles and three diamonds. Each diamond is equal in size to two triangles.

There are four main ways to lay out these six shapes to recreate the original triangle. Ignoring symmetries (layouts derived from rotating or mirroring the shapes), the four possibilities differ by the number of vertices that the diamonds share.

In the first layout, there is a single shared vertex at the center, marked with a blue circle. The second layout contains two shared vertices, the next three and then finally four shared vertices. These four layouts will be represented by the numbers 0, 1, 2, and 3.

Rotating these layouts around their apex forms the following four hexagons.

The hexagons can be named based on the triangle layouts that they use. The names are formed by joining the corresponding layout identifier for each triangle, starting with the top triangle. So these hexagons are 000000, 111111, 222222 and 333333. In other words, the first hexagon is made by rotating triangle layout 0 six times.

Rather than reuse the same six triangle layouts in each hexagon, any six triangles can be used. For example, these are the hexagons 000123 and 312010.

000123 312010

At first blush, this suggests 4096, or 46, possible combinations. However, many of these would be rotations of each other. For example, 000111 is a rotation of 111000 and 110001. To find unique rotations, iterate through the six possible options and take the one with the smallest base-4 value.

For example, 000001 is equal to 1, while 000010 equals 4, 000100 equals 16 and so on. The first option is kept and the others are discarded. The following code generates the unique rotations, of which there are 700.

const base = 4
const len = 6
const pad = Array.from({ length: len - 1 }, () => "0").join("")

Array
  .from({ length: Math.pow(base, len) }, (_, i) => i)
  .reduce((m, i) => {
    // Base-4 encoding, padded with leading zeroes
    i = (pad + i.toString(base)).slice(-len)

    // Take the smallest of the six rotations
    i = Array.from(i).slice(0, -1).reduce((m, j) =>
      (i = (i + j).slice(-len)) < m ? i : m
    , i)

    // Only append unique values
    return m.includes(i) ? m : m.concat(i)
  }, [])
  .sort()

// ["000000", "000001", "000002", "000003", "000011"…
import palette from "/scratchpad/_lib/palette.asset.mjs"
import { shuffle } from "/scratchpad/_lib/random.asset.mjs"

const ease = (t, p = 5) =>
  t <= 0.5 ? Math.pow(t * 2, p) / 2 : 1 - Math.pow(2 - t * 2, p) / 2

const t = Math.sqrt(3)

const getPatterns = (k = 100) => {
  // k is the vertical distance from A to the mid-point of IB.
  // J lies at the origin

  //    A
  //   I B
  //  H J C
  // G F E D
  const A = [0, -(k * 2)]
  const B = [k / t, -k]
  const C = [(k * 2) / t, 0]
  const D = [k * t, k]
  const E = [k / t, k]
  const F = [-k / t, k]
  const G = [-k * t, k]
  const H = [-(k * 2) / t, 0]
  const I = [-k / t, -k]
  const J = [0, 0]

  return {
    triangle: ["M", A, D, G, "Z"].join(" "),
    patterns: [
      ["M", A, B, J, I, "ZM", J, C, D, E, "ZM", H, J, F, G, "Z"].join(" "),
      ["M", A, B, J, I, "ZM", J, C, D, E, "ZM", I, J, F, H, "Z"].join(" "),
      ["M", A, B, J, I, "ZM", J, C, E, F, "ZM", I, J, F, H, "Z"].join(" "),
      ["M", I, B, J, H, "ZM", B, C, E, J, "ZM", H, J, E, F, "Z"].join(" "),
    ],
  }
}

const combinations = Array.from({ length: Math.pow(4, 6) }, (_, i) => i)
  .reduce((m, i) => {
    // Base-4 encoding, padded with leading zeroes
    i = ("00000" + i.toString(4)).slice(-6)

    // Take the smallest of the six rotations
    i = Array.from(i)
      .slice(0, -1)
      .reduce((m, j) => ((i = (i + j).slice(-i.length)) < m ? i : m), i)

    // Only append unique values
    return m.includes(i) ? m : m.concat(i)
  }, [])
  .sort()

shuffle(combinations)

const rotations = Array.from(
  { length: 6 },
  (_, i) => (i / 6 + 0.5) * Math.PI * 2
)

const draw = async ({ ctx, index, scale }) => {
  const k = (ctx.canvas.height / 12) * scale
  const w = k * 3 * t * 2
  const h = k * 3
  const rows = Math.ceil(ctx.canvas.height / h / 2 + 1) * 2
  const cols = Math.ceil(ctx.canvas.width / w / 2 + 1) * 2

  const { patterns, triangle } = getPatterns(k)

  const combo = combinations[index]

  const cx = (ctx.canvas.width - cols * w) / 2
  const cy = (ctx.canvas.height - rows * h) / 2

  ctx.save()

  for (let r = 0; r < rows; r++) {
    const m = r % 2 === (rows % 4) / 2 ? 1 : 0
    for (let c = 0; c < cols + 1 - m; c++) {
      const x = cx + (c + m / 2) * w
      const y = cy + r * h

      ctx.save()

      ctx.translate(x, y)

      rotations.forEach((t, i) => {
        const pattern = new Path2D(patterns[combo[i]])
        const outline = new Path2D(triangle)
        ctx.save()
        ctx.rotate(t)
        ctx.translate(0, k * 2)

        ctx.strokeStyle = palette.dark
        ctx.stroke(outline)

        ctx.fillStyle = palette.dark
        ctx.strokeStyle = palette.canvas
        ctx.fill(pattern)
        ctx.stroke(pattern)

        ctx.restore()
      })

      ctx.restore()
    }
  }

  ctx.restore()
}

export default (canvas) => {
  const ispeed = document.getElementById("speed")
  const iscale = document.getElementById("scale")

  canvas.width = canvas.offsetWidth * window.devicePixelRatio
  canvas.height = canvas.offsetHeight * window.devicePixelRatio

  const ctx = canvas.getContext("2d")

  let index = 0

  const update = () => {
    index = (index + 1) % combinations.length
  }

  let playing = true
  let start = Date.now()

  update()
  ;(function loop() {
    if (playing) {
      const speed = Math.pow(+ispeed.value, 3)
      const scale = +iscale.value
      const duration = 2000 / speed

      ispeed.setAttribute("title", +speed.toFixed(1) + "x")
      iscale.setAttribute("title", +scale.toFixed(1) + "x")

      if (speed < Infinity) {
        const t = Math.min((Date.now() - start) / duration, 1)

        const f = Math.min(t * 1.15, 1)

        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)

        ctx.globalAlpha = 1
        draw({ ctx, index, scale })

        ctx.globalAlpha = ease(t, 8)
        ctx.fillStyle = palette.canvas
        ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)

        ctx.globalAlpha = ease(f, 8)
        draw({ ctx, index: (index + 1) % combinations.length, scale })

        if (t === 1) {
          start = Date.now()
          update()
        }
      } else {
        start = Date.now()
      }

      window.requestAnimationFrame(loop)
    }
  })()

  return { cancel: () => (playing = false) }
}