The torus backdrop and film-grain glass were too much, so this goes back to the quieter dithered field and the lighter frosted surfaces, keeping the light-theme and status-chip removals from the previous commit. The hero now lays out as a three-part grid: the photo pinned to the top left, the contact buttons directly under it, and the text column beside them, so the left column carries weight instead of leaving ragged space. The contact buttons lose their labels and become 48px icon squares that line up exactly with the width of the photo. On narrow screens the photo drops out and the contact row moves below the text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
4.4 KiB
JavaScript
173 lines
4.4 KiB
JavaScript
(function() {
|
|
const canvas = document.getElementById("ascii-bg");
|
|
|
|
if (!canvas || !canvas.getContext) {
|
|
return;
|
|
}
|
|
|
|
const context = canvas.getContext("2d", { alpha: true });
|
|
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
|
|
const CHARS = "·.:-=+*#";
|
|
const CELL_WIDTH = 14;
|
|
const CELL_HEIGHT = 19;
|
|
const FRAME_MS = 1000 / 18;
|
|
const BASE_DENSITY = 0.09;
|
|
const WAVE_DENSITY = 0.24;
|
|
|
|
let columns = 0;
|
|
let rows = 0;
|
|
let dither = new Float32Array(0);
|
|
let baseColor = "rgba(255, 255, 255, 1)";
|
|
let accentColor = "rgba(235, 82, 52, 1)";
|
|
let time = 0;
|
|
let lastFrame = 0;
|
|
let rafId = 0;
|
|
|
|
// Interfering waves, centred on the viewport, give the field slow bright and
|
|
// dark swells. Which cells are lit comes from a fixed per-cell dither value,
|
|
// so the texture stays even everywhere instead of pooling into blobs, and
|
|
// cells brighten as a swell passes rather than flickering at random.
|
|
const waveAt = (x, y, t) => {
|
|
const radius = Math.sqrt(
|
|
((x - columns / 2) * 0.55) ** 2 + (y - rows / 2) ** 2
|
|
);
|
|
|
|
const a = Math.sin(x * 0.068 + t * 0.55);
|
|
const b = Math.sin(y * 0.1 - t * 0.42);
|
|
const c = Math.sin((x * 0.4 + y * 0.6) * 0.07 + t * 0.33);
|
|
const d = Math.sin(radius * 0.1 - t * 0.6);
|
|
|
|
return (a + b + c + d) / 8 + 0.5;
|
|
};
|
|
|
|
const buildDither = () => {
|
|
dither = new Float32Array(columns * rows);
|
|
|
|
for (let row = 0; row < rows; row += 1) {
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const noise = Math.sin(column * 127.1 + row * 311.7) * 43758.5453;
|
|
dither[row * columns + column] = noise - Math.floor(noise);
|
|
}
|
|
}
|
|
};
|
|
|
|
const readThemeColors = () => {
|
|
const styles = getComputedStyle(document.body);
|
|
baseColor = styles.getPropertyValue("--ascii-base").trim() || baseColor;
|
|
accentColor = styles.getPropertyValue("--ascii-accent").trim() || accentColor;
|
|
};
|
|
|
|
const resize = () => {
|
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
canvas.width = Math.floor(width * ratio);
|
|
canvas.height = Math.floor(height * ratio);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
context.font = `12px "JetBrains Mono", ui-monospace, monospace`;
|
|
context.textBaseline = "top";
|
|
|
|
columns = Math.ceil(width / CELL_WIDTH);
|
|
rows = Math.ceil(height / CELL_HEIGHT);
|
|
buildDither();
|
|
};
|
|
|
|
const draw = () => {
|
|
const width = canvas.width;
|
|
const height = canvas.height;
|
|
|
|
context.save();
|
|
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
context.clearRect(0, 0, width, height);
|
|
context.restore();
|
|
|
|
let currentColor = null;
|
|
|
|
for (let row = 0; row < rows; row += 1) {
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const level = waveAt(column, row, time);
|
|
|
|
if (dither[row * columns + column] > BASE_DENSITY + level * WAVE_DENSITY) {
|
|
continue;
|
|
}
|
|
|
|
const glyph = CHARS[Math.min(CHARS.length - 1, Math.floor(level * CHARS.length))];
|
|
const color = level > 0.9 ? accentColor : baseColor;
|
|
|
|
if (color !== currentColor) {
|
|
context.fillStyle = color;
|
|
currentColor = color;
|
|
}
|
|
|
|
context.globalAlpha = 0.06 + level * 0.5;
|
|
context.fillText(glyph, column * CELL_WIDTH, row * CELL_HEIGHT);
|
|
}
|
|
}
|
|
|
|
context.globalAlpha = 1;
|
|
};
|
|
|
|
const loop = (timestamp) => {
|
|
rafId = window.requestAnimationFrame(loop);
|
|
|
|
if (timestamp - lastFrame < FRAME_MS) {
|
|
return;
|
|
}
|
|
|
|
lastFrame = timestamp;
|
|
time += 0.021;
|
|
draw();
|
|
};
|
|
|
|
const stop = () => {
|
|
if (rafId) {
|
|
window.cancelAnimationFrame(rafId);
|
|
rafId = 0;
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
if (rafId || prefersReducedMotion.matches) {
|
|
return;
|
|
}
|
|
|
|
lastFrame = 0;
|
|
rafId = window.requestAnimationFrame(loop);
|
|
};
|
|
|
|
const reset = () => {
|
|
readThemeColors();
|
|
resize();
|
|
draw();
|
|
};
|
|
|
|
reset();
|
|
start();
|
|
|
|
let resizeTimer = 0;
|
|
window.addEventListener("resize", () => {
|
|
window.clearTimeout(resizeTimer);
|
|
resizeTimer = window.setTimeout(reset, 150);
|
|
});
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) {
|
|
stop();
|
|
return;
|
|
}
|
|
|
|
start();
|
|
});
|
|
|
|
prefersReducedMotion.addEventListener("change", () => {
|
|
stop();
|
|
start();
|
|
draw();
|
|
});
|
|
})();
|