Deploy / deploy (push) Successful in 33s
The container-rect-based sizing (getBoundingClientRect on the parent element) broke on real mobile Safari - the hero stopped rendering entirely, most likely a layout-timing dependency window.innerWidth/ innerHeight never had. Revert to the simple, reliable measurement. For the actual jump: mobile browsers only change window.innerHeight (not width) as the address bar hides/shows during scroll, firing `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 or trying to debounce/detect the toolbar animation itself.
462 lines
17 KiB
JavaScript
462 lines
17 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);
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
}
|