Get occasional updates about new fonts, designs and other interesting things.
import blur from "./stack-blur-alpha.asset.mjs"
import palette from "/scratchpad/_lib/palette.asset.mjs"
// Clamp between min and max
const clamp = (n) => Math.max(Math.min(n, 255), 0)
// Exponential easing
const ramp = (t, p = 3) =>
t <= 0.5 ? Math.pow(t * 2, p) / 2 : 1 - Math.pow(2 - t * 2, p) / 2
// Store the ramp calculations for greater speed
const rampMap = new Uint8ClampedArray(256)
// Distance between two colours. If only one color is passed,
// the maximum distance is returned.
// Use perceived strengths of each RGB channel
const distance = (r1, g1, b1, r2, g2, b2) =>
r2 != undefined
? Math.abs(r1 - r2) * 0.299 +
Math.abs(g1 - g2) * 0.587 +
Math.abs(b1 - b2) * 0.114
: 255
const merge = (layers, colors, width, height) => {
const base = new ImageData(width, height)
for (let i = 0; i < layers[0].length; i++) {
const ii = i << 2
let r = 0
let g = 0
let b = 0
let sum = 0
layers.forEach((layer, j) => {
const a = layer[i]
sum += a
r += colors[j][0] * a
g += colors[j][1] * a
b += colors[j][2] * a
})
base.data[ii + 0] = clamp(r / sum)
base.data[ii + 1] = clamp(g / sum)
base.data[ii + 2] = clamp(b / sum)
base.data[ii + 3] = clamp(sum)
}
return base
}
const parseColors = (colors, ctx) =>
colors &&
colors.map((c) => {
if (typeof c === "string") {
ctx.save()
ctx.fillStyle = c
c = ctx.fillStyle
ctx.restore()
return c.match(/[\dA-F]{2}/gi).map((d) => parseInt(d, 16))
} else {
return c
}
})
export default ({
ctx,
// Blur radius and iterations
radius = 1,
iterations = 1,
// De-blur ramping (should be approximately 10x radius)
ramp: pow = 8,
// Color range to support
colors = [palette.dark],
// Optional clipping
x = 0,
y = 0,
height,
width,
// Optional color tweaks
overshoot = 0,
colorMap = false,
ignoreDelta = false,
background = false,
}) => {
const image = ctx.getImageData(
x,
y,
width || ctx.canvas.width,
height || ctx.canvas.height
)
radius = ~~radius
colors = parseColors(colors, ctx)
colorMap = parseColors(colorMap, ctx)
if (rampMap.pow !== pow) {
rampMap.pow = pow
Array.from({ length: 256 }).forEach(
(e, i) => (rampMap[i] = ramp(i / 255, pow) * 255)
)
}
const bg = background ? parseColors([background], ctx)[0] : []
// Create a new alpha channel for each color
const layers = colors.map(([r1, g1, b1, a1], i) => {
const layer = new Uint8ClampedArray(image.width * image.height)
// The threshold is the smallest distance between adjacent colors
const d0 = distance(r1, g1, b1, (colors[i - 1] || bg))
const d1 = distance(r1, g1, b1, (colors[i + 1] || []))
const threshold = Math.min(d0, d1) * 0.5 * (1 + overshoot)
// If this pixel is closest to this [r1,g1,b1,a1], set the alpha
for (let i = 0; i < layer.length; i++) {
const j = i << 2
const r2 = image.data[j + 0]
const g2 = image.data[j + 1]
const b2 = image.data[j + 2]
const a2 = image.data[j + 3]
const d = distance(r1, g1, b1, r2, g2, b2)
if (d < threshold) {
layer[i] = ignoreDelta ? a2 : clamp(a2 * (1 - d / threshold))
}
}
if (radius >= 1) {
for (let i = 0; i < iterations; i++) {
blur({
layer,
radius,
width: image.width,
height: image.height,
})
}
}
return layer.map((v) => rampMap[v])
})
const mix = merge(layers, colorMap || colors, image.width, image.height)
ctx.putImageData(mix, x, y)
if (background) {
ctx.save()
ctx.globalCompositeOperation = "destination-over"
ctx.fillStyle = background
ctx.fillRect(0, 0, image.width, image.height)
ctx.restore()
}
}