Crocodilian crags and undulating eskers.
Get occasional updates about new fonts, designs and other interesting things.
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,
})
},
])
}