// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized // Lines"), the piece currently live at uhhm.no - kept as the actual asset, // not reinvented. Two changes from the original: exported as a class (no // auto-init on `window load`, since Leptos controls when this mounts) and // a `stop()` method that actually breaks the requestAnimationFrame loop - // the original ran forever once started, fine for a static page that's // the whole document, not fine in an SPA where this hero mounts/unmounts // as you navigate. export class RasterizedYES { constructor() { this.rasterCanvas = document.getElementById('rasterCanvas'); this.lineCanvas = document.getElementById('lineCanvas'); // The Rust side only constructs this once it's confirmed (via a // NodeRef) that the canvas is mounted, but that guard has shown // a real gap on fast client-side re-navigation back to `/` - // this is the actual failure point, so it gets its own defense // rather than depending on getting that timing exactly right // from the other side of the wasm boundary. Leaving the // instance otherwise-inert (no crash, no animation) rather than // throwing mid-render - `stop()` already tolerates a partially // (non-)initialized instance. if (!this.rasterCanvas || !this.lineCanvas) { console.warn('RasterizedYES: canvas not in DOM yet, skipping'); this.destroyed = true; return; } this.rasterCtx = this.rasterCanvas.getContext('2d'); this.lineCtx = this.lineCanvas.getContext('2d'); this.lines = []; this.rasterData = null; this.isActive = true; this.destroyed = false; this.time = 0; this.containmentStrength = 0.5; this.wiggleAmount = 0.5; // .hero-canvas is inset:0 inside this - freezing an inline // height here (below) is what actually locks the box, not // just reading its rect. this.heroEl = this.rasterCanvas.closest('.hero'); this.setupCanvas(); this.setupResizeHandler(); this.setupDrift(); this.setupTheme(); this.setupScrollFade(); this.setupClickHandler(); this.rasterizeText(); this.initializeLines(); this.animate(); } stop() { this.destroyed = true; if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); } if (this._themeQuery) { this._themeQuery.removeEventListener('change', this._themeHandler); } if (this._scrollHandler) { window.removeEventListener('scroll', this._scrollHandler); } } // The hero is position: sticky (main.css), so without this the // title/wordmark stay pinned at the viewport bottom for the whole // page and ghost through the translucent cards scrolling over // them. Fade the copy out across the first half-screen of scroll; // visibility: hidden at the end so the wordmark link can't be // clicked while invisible. setupScrollFade() { this.heroCopy = this.heroEl ? this.heroEl.querySelector('.hero-copy') : null; if (!this.heroCopy) return; this._scrollHandler = () => { const opacity = Math.max(0, 1 - window.scrollY / (this.displayHeight * 0.5)); this.heroCopy.style.opacity = opacity; this.heroCopy.style.visibility = opacity <= 0.01 ? 'hidden' : ''; }; window.addEventListener('scroll', this._scrollHandler, { passive: true }); this._scrollHandler(); } // Canvas paint can't read CSS custom properties, so the piece // carries its own copy of both palettes and follows // prefers-color-scheme itself - values must track main.css's :root // (--paper especially: the fade fill IS the page background where // the canvas shows through translucent cards). Dark keeps the // original neon-on-black inks; light restates them as CMYK process // inks dark enough to carry on paper, since 80%-lightness pastels // vanish on white. setupTheme() { this._themeQuery = window.matchMedia('(prefers-color-scheme: light)'); this._themeHandler = () => { this.applyTheme(); // Repaint the raster ghost in the new theme's ink and // hard-clear the trails - a slow 3%-alpha fade from the // old paper color would smear across the flip otherwise. this.rasterizeText(); this.lineCtx.fillStyle = this.theme.paper; this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); }; this._themeQuery.addEventListener('change', this._themeHandler); this.applyTheme(); } applyTheme() { this.theme = this._themeQuery.matches ? { paper: '#f6f5f1', fade: 'rgba(246, 245, 241, 0.03)', // White ground so multiply (the light theme's CSS // blend mode for #rasterCanvas) leaves the paper // untouched; gray ink becomes the faint YES ghost. rasterBg: '#ffffff', rasterInk: '#6b6b6b', strokes: [ 'hsla(185, 70%, 32%, 0.85)', 'hsla(315, 60%, 38%, 0.85)', 'hsla(50, 90%, 40%, 0.85)' ] } : { paper: '#0a0a0a', fade: 'rgba(10, 10, 10, 0.03)', rasterBg: '#111111', rasterInk: '#ffffff', strokes: [ 'hsla(180, 90%, 80%, 0.8)', 'hsla(300, 90%, 80%, 0.8)', 'hsla(60, 90%, 80%, 0.8)' ] }; } // Reading .hero-canvas's rendered rect (a prior attempt at this) // only helps if that rect actually stays put - and on real mobile // Safari it didn't: .hero-copy (bottom-aligned via flexbox inside // .hero-yes) kept sliding down as the address bar collapsed, even // with a spec'd-stable viewport unit (svh, then lvh) driving the // box's height. Either the browser isn't holding up its end, or // something in the cascade is still landing on a live value - not // provable from here without the device. Rather than keep // guessing at which CSS viewport unit actually holds still, yes.js // becomes the source of truth instead: it freezes .hero-yes's // rendered height to a literal inline px value once, up front. An // inline style always wins the cascade over the stylesheet's // `height: 100svh`, so once this runs, nothing the browser does // with that unit afterward can move the box - the number is fixed // in the DOM, not recomputed from a unit at all. setupCanvas() { const pixelRatio = window.devicePixelRatio || 1; const width = window.innerWidth; const height = window.innerHeight; if (this.heroEl) { this.heroEl.style.height = height + 'px'; } this.rasterCanvas.style.width = width + 'px'; this.rasterCanvas.style.height = height + 'px'; this.lineCanvas.style.width = width + 'px'; this.lineCanvas.style.height = height + 'px'; this.rasterCanvas.width = width * pixelRatio; this.rasterCanvas.height = height * pixelRatio; this.lineCanvas.width = width * pixelRatio; this.lineCanvas.height = height * pixelRatio; this.rasterCtx.scale(pixelRatio, pixelRatio); this.lineCtx.scale(pixelRatio, pixelRatio); this.displayWidth = width; this.displayHeight = height; this.pixelRatio = pixelRatio; } setupResizeHandler() { // Gate on width, not height: mobile Safari's address-bar // animation changes window.innerHeight continuously with no // real layout change to react to (that's the live value this // whole method exists to stop trusting). A genuine resize - // orientation change, desktop window drag - always changes // width too, so that's the real signal to re-freeze on. this._resizeHandler = () => { if (window.innerWidth === this.displayWidth) return; this.setupCanvas(); this.rasterizeText(); }; window.addEventListener('resize', this._resizeHandler); } // The two behavior dials (containmentStrength from x, wiggleAmount // from y) used to follow the pointer. Now a smooth noise field // wanders them instead - the same 0..1 inputs a mouse would give, // but drifting at cloud pace, so the piece breathes on its own and // behaves identically with nobody touching it (which on a landing // hero is most of the time, and on touch devices was always the // case between taps). Value noise with two octaves: smooth // (C1-continuous via smoothstep), never repeats visibly, no jumps. setupDrift() { const channel = (seed) => { const rand = (i) => { let h = Math.imul(i ^ seed, 2654435761) >>> 0; h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995) >>> 0; // The >>> 0 here is load-bearing: ^ yields a SIGNED // 32-bit value, and without the reinterpret a set top // bit made this "0..1" noise go as low as -0.5, // pushing both dials below their intended floors. h = (h ^ (h >>> 15)) >>> 0; return h / 4294967296; }; const noise = (t) => { const i = Math.floor(t); const f = t - i; const s = f * f * (3 - 2 * f); return rand(i) * (1 - s) + rand(i + 1) * s; }; // Two octaves, renormalized to 0..1: the slow octave sets // the overall weather, the faster one keeps it from // feeling like a pendulum. return (t) => (noise(t) * 2 / 3 + noise(t * 2.7 + 913) * 1 / 3); }; // One full "weather change" roughly every DRIFT_PERIOD // seconds per octave - the pace of watching clouds, not of a // hand on a mouse. this.driftPeriod = 25; this.driftX = channel(0x9e3779b9); this.driftY = channel(0x85ebca6b); this.containmentStrength = 0.55; this.wiggleAmount = 1.05; } updateDrift() { const t = this.time / this.driftPeriod; const x = this.driftX(t); const y = this.driftY(t); // Same mapping the mouse position used to feed. this.containmentStrength = 0.1 + (x * 0.9); this.wiggleAmount = 0.1 + (y * 1.9); } setupClickHandler() { this.lineCanvas.addEventListener('click', () => { this.restartAnimation(); }); } restartAnimation() { this.time = 0; this.lineCtx.fillStyle = this.theme.paper; this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); this.initializeLines(); this.isActive = true; } rasterizeText() { const width = this.displayWidth; const height = this.displayHeight; const aspectRatio = width / height; let fontSize; if (aspectRatio < 1) { fontSize = width * 0.4; } else { fontSize = height * 0.5; } this.fontSize = fontSize; this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`; this.rasterCtx.textAlign = 'left'; this.rasterCtx.textBaseline = 'middle'; const fullTextMetrics = this.rasterCtx.measureText('YES'); const textWidth = fullTextMetrics.width; const textStartX = (width - textWidth) / 2; const textY = height / 2; const letters = ['Y', 'E', 'S']; this.letterPositions = []; let currentX = textStartX; for (let i = 0; i < letters.length; i++) { const letterMetrics = this.rasterCtx.measureText(letters[i]); this.letterPositions[i] = { x: currentX, y: textY, width: letterMetrics.width, centerX: currentX + letterMetrics.width / 2 }; currentX += letterMetrics.width; } this.letterRasters = []; for (let i = 0; i < 3; i++) { this.rasterCtx.fillStyle = '#111'; this.rasterCtx.fillRect(0, 0, width, height); this.rasterCtx.fillStyle = '#ffffff'; this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y); this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); } // The visible layer, distinct from the letterRasters sampled // above (those stay #111/#fff - isInSpecificLetter's red>128 // test depends on it): painted in theme ink so the ghost works // under the theme's blend mode (overlay on dark, multiply on // light - see #rasterCanvas in main.css). this.rasterCtx.fillStyle = this.theme.rasterBg; this.rasterCtx.fillRect(0, 0, width, height); this.rasterCtx.fillStyle = this.theme.rasterInk; this.rasterCtx.fillText('YES', textStartX, textY); this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height); } isInSpecificLetter(x, y, letterIndex) { const canvasX = x * this.pixelRatio; const canvasY = y * this.pixelRatio; if (!this.letterRasters || !this.letterRasters[letterIndex] || canvasX < 0 || canvasY < 0 || canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) { return false; } const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4; const red = this.letterRasters[letterIndex].data[index]; return red > 128; } initializeLines() { this.lines = []; const numLines = 60; const letterCentroids = this.calculateLetterCentroids(); for (let i = 0; i < numLines; i++) { const letterIndex = Math.floor(i / (numLines / 3)); let startX, startY, centerX, centerY; if (letterCentroids[letterIndex]) { centerX = letterCentroids[letterIndex].x; centerY = letterCentroids[letterIndex].y; const startVariation = this.fontSize * 0.1; startX = centerX + (Math.random() - 0.5) * startVariation; startY = centerY + (Math.random() - 0.5) * startVariation; if (!this.isInSpecificLetter(startX, startY, letterIndex)) { const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex); if (nearestPoint) { startX = nearestPoint.x; startY = nearestPoint.y; } else { startX = centerX; startY = centerY; } } } else { const letterPos = this.letterPositions[letterIndex]; startX = letterPos.centerX; startY = letterPos.y; centerX = startX; centerY = startY; } // Stroke color lives on the theme, looked up per frame by // letterIndex (see draw()) - not frozen per line - so a // theme flip recolors live lines instead of leaving // dark-theme neon smearing across light paper. this.lines.push({ relativeX: (startX - centerX) / this.fontSize, relativeY: (startY - centerY) / this.fontSize, prevRelativeX: (startX - centerX) / this.fontSize, prevRelativeY: (startY - centerY) / this.fontSize, angle: Math.random() * Math.PI * 2, letterIndex: letterIndex, lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize }, outsideDuration: 0 }); } } calculateLetterCentroids() { const centroids = []; for (let letterIndex = 0; letterIndex < 3; letterIndex++) { let sumX = 0, sumY = 0, count = 0; const letterPos = this.letterPositions[letterIndex]; const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1); const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1); const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6); const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6); const step = Math.max(1, Math.floor(this.fontSize * 0.02)); for (let y = searchStartY; y <= searchEndY; y += step) { for (let x = searchStartX; x <= searchEndX; x += step) { if (this.isInSpecificLetter(x, y, letterIndex)) { sumX += x; sumY += y; count++; } } } if (count > 0) { centroids[letterIndex] = { x: sumX / count, y: sumY / count }; } else { centroids[letterIndex] = { x: letterPos.centerX, y: letterPos.y }; } } return centroids; } updateLines() { this.time += 0.016; this.updateDrift(); if (!this.isActive) return; const letterCentroids = this.calculateLetterCentroids(); this.lines.forEach(line => { line.prevRelativeX = line.relativeX; line.prevRelativeY = line.relativeY; const centroid = letterCentroids[line.letterIndex]; if (!centroid) return; const currentX = centroid.x + line.relativeX * this.fontSize; const currentY = centroid.y + line.relativeY * this.fontSize; const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex); if (currentlyInside) { line.outsideDuration = 0; line.lastSeenInside = { x: line.relativeX, y: line.relativeY }; } else { line.outsideDuration++; } const visionDistance = 0.08 * this.fontSize; const centerX = currentX + Math.cos(line.angle) * visionDistance; const centerY = currentY + Math.sin(line.angle) * visionDistance; const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance; const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance; const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance; const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance; const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex); const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex); const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex); let speed = 0.02; const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45); if (line.outsideDuration > attractionThreshold) { const targetX = line.lastSeenInside.x; const targetY = line.lastSeenInside.y; const deltaX = targetX - line.relativeX; const deltaY = targetY - line.relativeY; const angleToTarget = Math.atan2(deltaY, deltaX); let angleDiff = angleToTarget - line.angle; while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; const baseAttraction = Math.min(0.4, line.outsideDuration / 80); const attractionStrength = baseAttraction * this.containmentStrength; line.angle += angleDiff * attractionStrength; } if (centerSees) { const baseWiggle = 0.15; line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount; } else { speed *= (0.3 + this.containmentStrength * 0.4); const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4); const randomTurnAmount = 0.2 * this.wiggleAmount; if (leftSees && !rightSees) { line.angle -= baseTurnStrength + Math.random() * randomTurnAmount; } else if (rightSees && !leftSees) { line.angle += baseTurnStrength + Math.random() * randomTurnAmount; } else { const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7); line.angle += randomTurn; } } line.relativeX += Math.cos(line.angle) * speed; line.relativeY += Math.sin(line.angle) * speed; line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX)); line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY)); }); } findNearestSpecificLetterPixel(x, y, letterIndex) { const searchRadius = this.fontSize * 0.08; const step = Math.max(1, Math.floor(this.fontSize * 0.01)); let nearestPoint = null; let nearestDistance = Infinity; for (let dy = -searchRadius; dy <= searchRadius; dy += step) { for (let dx = -searchRadius; dx <= searchRadius; dx += step) { const testX = x + dx; const testY = y + dy; if (this.isInSpecificLetter(testX, testY, letterIndex)) { const distance = Math.sqrt(dx * dx + dy * dy); if (distance < nearestDistance) { nearestDistance = distance; nearestPoint = { x: testX, y: testY }; } } } } return nearestPoint; } draw() { this.lineCtx.fillStyle = this.theme.fade; this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight); const letterCentroids = this.calculateLetterCentroids(); this.lines.forEach(line => { const centroid = letterCentroids[line.letterIndex]; if (!centroid) return; const currentX = centroid.x + line.relativeX * this.fontSize; const currentY = centroid.y + line.relativeY * this.fontSize; const prevX = centroid.x + line.prevRelativeX * this.fontSize; const prevY = centroid.y + line.prevRelativeY * this.fontSize; if (prevX === currentX && prevY === currentY) return; this.lineCtx.strokeStyle = this.theme.strokes[line.letterIndex]; this.lineCtx.lineWidth = this.fontSize * 0.003; this.lineCtx.lineCap = 'round'; this.lineCtx.beginPath(); this.lineCtx.moveTo(prevX, prevY); this.lineCtx.lineTo(currentX, currentY); this.lineCtx.stroke(); }); } animate() { if (this.destroyed) return; this.updateLines(); this.draw(); requestAnimationFrame(() => this.animate()); } }