Files

689 lines
27 KiB
JavaScript
Raw Permalink Normal View History

// uhhm.no's hero: "YES - Rasterized Lines", the piece that has been
// live at uhhm.no since the static site (ported from
// ~/repos/webpage/content/visualize/ah.html). Content-owned: portal
// knows only site.yaml's `hero: {kind: module, module: hero.js}` and
// the contract below - it serves this file same-origin at
// /site/hero.js, starts it at HTML parse time, adopts it on hydration,
// and calls stop() when the page is left.
//
// Contract: `export function mount(container) -> handle`, handle has
// `stop()`. Everything visual - the canvases, the sticky full-viewport
// header, the palette - is this module's, including the stylesheet it
// injects once below (selectors target portal's .hero-module header
// and .hero-piece box).
//
// Canvas paint can't read CSS custom properties, so THEMES mirrors
// the portal stylesheet's palette for both color schemes - a palette
// change lands there AND here.
const STYLE_ID = 'uhhm-hero-style';
const CSS = `
.hero-module {
/* Sticky at the viewport top for the whole scroll (its containing
block is the page itself), so the piece stays animating behind
everything that follows - the translucent cards scroll over it
and it shows through them and in the gaps around them. z-index 0
so positioned content below can stack above with z-index 1. */
position: sticky;
top: 0;
z-index: 0;
max-width: none;
width: 100%;
/* svh here is only the pre-JS/no-JS fallback (and first paint before
yes.js's constructor runs). Once yes.js mounts, it overwrites this
with an inline \`height: <px>\` frozen from a single measurement -
see setupCanvas()'s comment in yes.js for why: on real mobile
Safari, content bottom-aligned inside this box kept sliding down
as the address bar collapsed even with a spec'd-stable viewport
unit here, so the box's actual height can't be trusted to stay
put on that unit alone. An inline style set from JS always wins
the cascade over this rule, so that frozen number is what
actually governs once the page is interactive.
Plain-vh fallback declared first - an engine without svh support
ignores the invalid second line rather than falling through to
auto height, which would collapse this to the height of its
in-flow content and clip the canvas via overflow:hidden below. */
height: 100vh;
height: 100svh;
padding: 0;
justify-content: flex-end;
overflow: hidden;
}
.hero-module .hero-copy {
position: relative;
z-index: 1;
padding: 0 1.5rem 3.5rem;
gap: 0.6rem;
text-shadow: 0 0.12em 1.4em var(--hero-glow);
}
.hero-canvas {
position: absolute;
inset: 0;
}
.hero-canvas canvas {
position: absolute;
top: 0;
left: 0;
cursor: pointer;
}
.hero-canvas #rasterCanvas {
mix-blend-mode: overlay;
opacity: 0.5;
}
/* The piece wants the whole viewport, not portal's 55svh reservation:
the canvases position against the sticky header itself. */
.hero-module .hero-piece {
position: static;
min-height: 0;
}
@media (prefers-color-scheme: light) {
/* overlay against near-white paper resolves to ~white and the
raster ghost vanishes; multiply lets the light theme's gray YES
show as ink. */
.hero-canvas #rasterCanvas {
mix-blend-mode: multiply;
}
}
`;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
class RasterizedYES {
constructor(container) {
ensureStyle();
// The piece builds its own DOM inside portal's box - two
// stacked canvases, raster ghost under the drifting lines.
const wrap = document.createElement('div');
wrap.className = 'hero-canvas';
this.lineCanvas = document.createElement('canvas');
this.lineCanvas.id = 'lineCanvas';
this.rasterCanvas = document.createElement('canvas');
this.rasterCanvas.id = 'rasterCanvas';
wrap.append(this.lineCanvas, this.rasterCanvas);
container.appendChild(wrap);
this.wrap = wrap;
// 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 = container.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.wrap) this.wrap.remove();
if (this.heroEl) this.heroEl.style.height = '';
if (this.heroCopy) { this.heroCopy.style.opacity = ''; this.heroCopy.style.visibility = ''; }
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());
}
}
export function mount(container) {
return new RasterizedYES(container);
}