// 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.mouseX = 0.5; this.mouseY = 0.5; this.containmentStrength = 0.5; this.wiggleAmount = 0.5; this.setupCanvas(); this.setupMouseTracking(); this.setupClickHandler(); this.rasterizeText(); this.initializeLines(); this.animate(); } stop() { this.destroyed = true; if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); } } setupCanvas() { const pixelRatio = window.devicePixelRatio || 1; const width = window.innerWidth; const height = window.innerHeight; 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; if (!this._resizeHandler) { // Mobile browsers change window.innerHeight (not width) as // the address bar hides/shows during scroll, firing a // `resize` with no real layout change to react to. // Genuine resizes (orientation change, desktop window // drag) always change the width too, so gate the redraw // on that instead of reacting to every resize event. this._resizeHandler = () => { if (window.innerWidth === this.displayWidth) return; this.setupCanvas(); this.rasterizeText(); }; window.addEventListener('resize', this._resizeHandler); } } setupMouseTracking() { const updatePosition = (clientX, clientY) => { this.mouseX = clientX / window.innerWidth; this.mouseY = clientY / window.innerHeight; this.containmentStrength = 0.1 + (this.mouseX * 0.9); this.wiggleAmount = 0.1 + (this.mouseY * 1.9); }; window.addEventListener('mousemove', (e) => { updatePosition(e.clientX, e.clientY); }); // Scoped to the canvas itself, not `window` - the original // standalone page was the whole document, so preventDefault()ing // touchmove globally was harmless (nothing else to scroll to). // Embedded as a hero above a longer page, that same global // handler silently blocks scrolling everywhere, not just over // the canvas - this only intercepts touches that start there. this.lineCanvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (e.touches.length > 0) { const touch = e.touches[0]; updatePosition(touch.clientX, touch.clientY); } }, { passive: false }); this.lineCanvas.addEventListener('touchstart', (e) => { e.preventDefault(); if (e.touches.length > 0) { const touch = e.touches[0]; updatePosition(touch.clientX, touch.clientY); } }, { passive: false }); this.mouseX = 0.5; this.mouseY = 0.5; this.containmentStrength = 0.55; this.wiggleAmount = 1.05; } setupClickHandler() { this.lineCanvas.addEventListener('click', () => { this.restartAnimation(); }); } restartAnimation() { this.time = 0; this.lineCtx.fillStyle = '#0a0a0a'; 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); } this.rasterCtx.fillStyle = '#111'; this.rasterCtx.fillRect(0, 0, width, height); this.rasterCtx.fillStyle = '#ffffff'; 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; } const colors = [ 'hsl(180, 90%, 70%)', 'hsl(300, 90%, 70%)', 'hsl(60, 90%, 70%)' ]; 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, color: colors[letterIndex], 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; 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 = 'rgba(10, 10, 10, 0.03)'; 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 = line.color.replace('70%)', '80%, 0.8)'); 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()); } }