Deploy / deploy (push) Successful in 33s
Even with dimensions now sourced from the stable CSS container (previous commit), assigning canvas.width/height unconditionally clears the canvas buffer regardless of whether the value actually changed. Mobile Safari fires `resize` repeatedly *during* the address -bar hide/show animation, not just once at the end, so the handler was still clearing+redrawing on every one of those events. Debounce to let the animation settle, then skip the redraw entirely if the resolved size didn't change.
479 lines
18 KiB
JavaScript
479 lines
18 KiB
JavaScript
// 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);
|
|
}
|
|
clearTimeout(this._resizeDebounce);
|
|
}
|
|
|
|
setupCanvas() {
|
|
const pixelRatio = window.devicePixelRatio || 1;
|
|
// Sized from the containing .hero-canvas box (CSS-driven by
|
|
// .hero-yes's 100svh), not window.innerWidth/innerHeight
|
|
// directly - mobile Safari fires `resize` continuously as the
|
|
// address bar hides/shows while scrolling, and innerHeight
|
|
// tracks that dynamic viewport. Reading the container's own
|
|
// rendered size instead means a toolbar-only resize recomputes
|
|
// to the same stable numbers (a no-op redraw) instead of
|
|
// visibly rescaling the rasterized "YES" text mid-scroll.
|
|
const rect = this.rasterCanvas.parentElement.getBoundingClientRect();
|
|
const width = rect.width;
|
|
const height = rect.height;
|
|
|
|
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) {
|
|
// Debounced, and a no-op if the size didn't actually
|
|
// change: assigning canvas.width/height clears the canvas
|
|
// even when set to its current value, and mobile Safari
|
|
// fires `resize` repeatedly *during* the address-bar
|
|
// hide/show animation (not once at the end), so an
|
|
// un-debounced handler was still clearing+redrawing on
|
|
// every one of those events even after the dimensions
|
|
// themselves became stable (see setupCanvas above).
|
|
this._resizeHandler = () => {
|
|
clearTimeout(this._resizeDebounce);
|
|
this._resizeDebounce = setTimeout(() => {
|
|
if (this.destroyed) return;
|
|
const rect = this.rasterCanvas.parentElement.getBoundingClientRect();
|
|
if (rect.width === this.displayWidth && rect.height === this.displayHeight) return;
|
|
this.setupCanvas();
|
|
this.rasterizeText();
|
|
}, 150);
|
|
};
|
|
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());
|
|
}
|
|
}
|