Lines, not long, not heavy, rarely touching, drawn at random.
Get occasional updates about new fonts, designs and other interesting things.
import sequence from "/scratchpad/_lib/sequence.asset.mjs"
import palette from "/scratchpad/_lib/palette.asset.mjs"
import posterize from "/scratchpad/posterize/posterize.asset.mjs"
import helpers from "/scratchpad/_lib/line.asset.mjs"
import * as random from "/scratchpad/_lib/random.asset.mjs"
const dpi = window.devicePixelRatio
export default async (canvas) => {
canvas.width = canvas.offsetWidth * dpi
canvas.height = canvas.offsetHeight * dpi
const ctx = canvas.getContext("2d")
const buffer = Math.min(canvas.width, canvas.height) * 0.05
const gridSize = random.number(9, 11)
const maxLinks = random.integer(7, 15)
let grid
let chains
return sequence([
() => {
ctx.fillStyle = palette.canvas
ctx.strokeStyle = palette.dark
ctx.lineWidth = 4
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.fillRect(0, 0, canvas.width, canvas.height)
},
() => {
const width = canvas.width - buffer * 2
const height = canvas.height - buffer * 2
grid = helpers.makeGrid(width, height, gridSize)
},
() => {
const driftVar = 0.1
const driftMax = gridSize / 2
const scatter = gridSize / 2
let driftX = 0
let driftY = 0
grid.forEach((point) => {
driftX = random.wobble(driftX, driftVar, -driftMax, driftMax)
driftY = random.wobble(driftY, driftVar, -driftMax, driftMax)
point.x += buffer + random.wobble(scatter) + driftX
point.y += buffer + random.wobble(scatter) + driftY
if (point.row % 2 === 0) point.x += random.number(scatter)
if (point.col % 2 === 0) point.y += random.number(scatter)
point.neighbors = []
})
},
() => {
const r = 0.001
ctx.beginPath()
grid.forEach((point) => {
ctx.moveTo(point.x + r, point.y)
ctx.arc(point.x, point.y, r, 0, Math.PI * 2, 0)
})
ctx.stroke()
},
// For each point, determine nearest neighbours.
// Speed up search by partitioning into a matrix.
() => {
const sqDist = gridSize * gridSize * Math.sqrt(2)
const partitions = []
const neighbours = [
// Same
[0, 0],
// Up, down, left, right
[0, -1],
[0, 1],
[1, 0],
[-1, 0],
// Diagonals
[1, 1],
[1, -1],
[-1, 1],
[-1, -1],
]
let columnCount = 0
const getIndex = (r, c) => r * columnCount + c
grid.forEach((p) => {
p.c = Math.floor(p.x / gridSize)
p.r = Math.floor(p.y / gridSize)
columnCount = Math.max(columnCount, p.c + 1)
})
grid.forEach((p) => {
const i = getIndex(p.r, p.c)
if (!partitions[i]) partitions[i] = [p]
else partitions[i].push(p)
})
grid.forEach((p1) => {
neighbours.forEach(([r, c]) => {
const list = partitions[getIndex(p1.r + r, p1.c + c)]
if (list) {
p1.neighbors.push(
list.filter(
(p2) => p1 !== p2 && helpers.sqdist(p1, p2) <= sqDist
)
)
}
})
})
},
() => {
const stack = random.shuffle([grid])
chains = [[stack.pop()]]
// Check whether the point starts or ends the chain
const isTerminal = (p) =>
p.chain[0] === p || p.chain[p.chain.length - 1] === p
while (stack.length) {
const chain = chains[0]
chain[0].chain = chain
if (chain.length < maxLinks) {
const p1 = chain[0]
const p2 = random.sample(
p1.neighbors.filter(
(p) =>
!p.chain ||
// If a chain exists, it must be different, the combined
// length must not be greater than the limit and the point
// must be a terminal on the chain.
(p.chain.length + chain.length <= maxLinks &&
isTerminal(p) &&
!p.chain.includes(p1))
)
)
if (p2) {
if (p2.chain) {
// Ensure the new points are contiguous and add to chain
if (p2.chain[0] === p2) p2.chain.reverse()
chain.unshift(p2.chain)
// Remove all items from the old chain
p2.chain.splice(0, p2.chain.length)
// Update chain reference for added points
chain.forEach((p) => (p.chain = chain))
} else {
p2.chain = chain
chain.unshift(p2)
}
continue
}
}
let next = stack.pop()
while (next) {
if (!next.chain) break
if (isTerminal(next)) break
next = stack.pop()
}
if (next) chains.unshift([next])
}
},
() => {
// Remove terminals that sit on other lines
// This is a hack, it would ideally be avoided in the previous step
chains.forEach((chain) => {
if (chain.length)
chain[0].neighbors
.filter((p) => p.chain && !chain.includes(p))
.forEach((p) => {
if (p.chain.includes(chain[0])) chain.shift()
})
if (chain.length)
chain[chain.length - 1].neighbors
.filter((p) => p.chain && !chain.includes(p))
.forEach((p) => {
if (p.chain.includes(chain[chain.length - 1])) chain.pop()
})
})
},
() => {
// Tiny chains are synthetically expanded
const mx = (x) => Math.max(Math.min(x, canvas.width - buffer), buffer)
const my = (y) => Math.max(Math.min(y, canvas.height - buffer), buffer)
chains
.filter((c) => c.length === 1)
.forEach((c) => {
c.push({
x: mx(random.wobble(c[0].x, gridSize / 200)),
y: my(random.wobble(c[0].y, gridSize / 200)),
})
c.unshift({
x: mx(random.wobble(c[0].x, gridSize / 200)),
y: my(random.wobble(c[0].y, gridSize / 200)),
})
})
chains = chains.filter((c) => c.length >= 2)
},
() => {
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.beginPath()
chains.forEach((chain) => {
const a = chain[0]
const b = chain[1]
const y = chain[chain.length - 2]
const z = chain[chain.length - 1]
const x1 = (a.x - b.x) * 0.1
const y1 = (a.y - b.y) * 0.1
const x2 = (z.x - y.x) * 0.1
const y2 = (z.y - y.y) * 0.1
chain.unshift({ x: a.x + x1, y: a.y + y1 })
chain.push({ x: z.x + x2, y: z.y + y2 })
const spline = helpers.spline(chain, 40)
ctx.moveTo(spline[0].x, spline[0].y)
spline.slice(1).forEach((p) => ctx.lineTo(p.x, p.y))
})
ctx.stroke()
},
() => {
const d = buffer - gridSize / 4
posterize({
ctx,
radius: 3,
ramp: 30,
overshoot: 0.4,
colors: [ctx.strokeStyle],
background: ctx.fillStyle,
x: d,
y: d,
width: canvas.width - 2 * d,
height: canvas.height - 2 * d,
})
},
])
}