From 0c2892c4aa87fcf7cf9bbc33bff2b505b34f6b58 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 3 May 2026 17:56:58 -0700 Subject: [PATCH 1/3] WIP: Website navbar ripple --- website/sass/base.scss | 37 ++++-- website/static/js/navbar.js | 246 +++++++++++++++++++++++++++++------- website/templates/base.html | 6 +- 3 files changed, 231 insertions(+), 58 deletions(-) diff --git a/website/sass/base.scss b/website/sass/base.scss index 3ad0985807..9a5749459d 100644 --- a/website/sass/base.scss +++ b/website/sass/base.scss @@ -227,23 +227,34 @@ body > .page { .ripple { display: block; background: none; - // Covers up content that extends up underneath the header - fill: white; - stroke: currentColor; - --ripple-height: 16px; + // Total SVG height splits into space above the baseline (for raised bumps and wave crests) and space below (for wave troughs that dip into the page body) + --ripple-height: 32px; + --ripple-baseline-from-top: 16px; + --ripple-taper-half-width: 40px; height: var(--ripple-height); - margin-top: calc(-1 * var(--ripple-height) + var(--border-thickness)); - margin-bottom: calc(-1 * var(--border-thickness)); - stroke-width: var(--border-thickness); + margin-top: calc(-1 * var(--ripple-baseline-from-top) + var(--border-thickness)); + margin-bottom: calc(-1 * (var(--ripple-height) - var(--ripple-baseline-from-top)) - var(--border-thickness)); + // Allow taper polygons (apex at +/- 40px) and wave crests/troughs to spill outside the SVG box; the path's off-screen fill corners are still drawn within bounds since the line/mask are split + overflow: visible; + clip-path: inset(-100px calc(-1 * var(--ripple-taper-half-width)) -100px calc(-1 * var(--ripple-taper-half-width))); + pointer-events: none; + + .ripple-mask { + // Covers up content that extends up underneath the header + fill: white; + stroke: none; + } - &::before, - &::after { - content: none; + .ripple-line { + fill: none; + stroke: currentColor; + stroke-width: var(--border-thickness); } - } - hr { - background: none; + polygon { + fill: currentColor; + stroke: none; + } } @media screen and (max-width: 1400px) { diff --git a/website/static/js/navbar.js b/website/static/js/navbar.js index c96bf00cc2..f6ac82e48f 100644 --- a/website/static/js/navbar.js +++ b/website/static/js/navbar.js @@ -1,14 +1,34 @@ -const NAV_BUTTON_INITIAL_FONT_SIZE = 28; // Keep up to date with the initial `--nav-font-size` in base.scss -const RIPPLE_ANIMATION_MILLISECONDS = 100; -const RIPPLE_WIDTH = 100; -const HANDLE_STRETCH = 0.4; +// Keep up to date with the initial `--nav-font-size` in base.scss +const NAV_BUTTON_INITIAL_FONT_SIZE = 28; + +// Local "lift" bump under each hovered/active button (gravitational attractor that pulls the surface up) +const BUMP_RAISE_MILLISECONDS = 120; +const BUMP_WIDTH = 100; + +// Propagating wave pulse emitted when a lifted button drops back down (the splash from removing your finger from the water) +const WAVE_SPEED_PX_PER_SECOND = 1000; +const WAVE_PACKET_SIGMA = 200; +const WAVE_WAVELENGTH = 300; +const WAVE_AMPLITUDE = 10; +const WAVE_ATTENUATION_LENGTH = 500; +const WAVE_RAMP_UP_MILLISECONDS = 80; +const WAVE_PRUNE_AMPLITUDE = 0.15; +const WAVE_SAMPLE_SPACING = 6; + +// Wider-than-the-bump zone around each lifted button where a passing wave's contribution to the surface is locally damped, so the bump doesn't tilt or jiggle when waves pass through it +const WAVE_SUPPRESSION_HALF_WIDTH = 200; let /** @type {NodeList | undefined} **/ navButtons; let /** @type {Element | undefined} **/ rippleSvg; -let /** @type {Element | undefined} **/ ripplePath; -let /** @type {number | undefined} **/ fullRippleHeight; +let /** @type {Element | undefined} **/ rippleMaskPath; +let /** @type {Element | undefined} **/ rippleLinePath; +let /** @type {Element | undefined} **/ rippleTaperLeft; +let /** @type {Element | undefined} **/ rippleTaperRight; +let /** @type {number | undefined} **/ baselineFromTop; +let /** @type {number | undefined} **/ taperHalfWidth; let /** @type {{ element: HTMLElement, goingUp: boolean, animationStartTime: number, animationEndTime: number }[]} **/ ripples; let /** @type {number} **/ activeRippleIndex; +let /** @type {{ originX: number, startTime: number }[]} **/ wavePulses = []; window.addEventListener("DOMContentLoaded", initializeRipples); @@ -17,8 +37,12 @@ function initializeRipples() { navButtons = document.querySelectorAll("header nav a") || undefined; rippleSvg = document.querySelector("header .ripple") || undefined; - ripplePath = rippleSvg?.querySelector("path") || undefined; - fullRippleHeight = rippleSvg ? Number.parseInt(window.getComputedStyle(rippleSvg).height, 10) || undefined : undefined; + rippleMaskPath = rippleSvg?.querySelector(".ripple-mask") || undefined; + rippleLinePath = rippleSvg?.querySelector(".ripple-line") || undefined; + rippleTaperLeft = rippleSvg?.querySelector(".ripple-taper-left") || undefined; + rippleTaperRight = rippleSvg?.querySelector(".ripple-taper-right") || undefined; + baselineFromTop = rippleSvg ? Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--ripple-baseline-from-top"), 10) || undefined : undefined; + taperHalfWidth = rippleSvg ? Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--ripple-taper-half-width"), 10) || undefined : undefined; ripples = Array.from(navButtons) .filter((x) => x instanceof HTMLElement) @@ -47,7 +71,7 @@ function initializeRipples() { const updateTimings = (/** @type {boolean} **/ goingUp) => { const start = ripple.animationStartTime; const now = Date.now(); - const stop = ripple.animationStartTime + RIPPLE_ANIMATION_MILLISECONDS; + const stop = ripple.animationStartTime + BUMP_RAISE_MILLISECONDS; const elapsed = now - start; const remaining = stop - now; @@ -55,8 +79,10 @@ function initializeRipples() { ripple.goingUp = goingUp; // Encode the potential reversing of direction via the animation start and end times ripple.animationStartTime = now < stop ? now - remaining : now; - ripple.animationEndTime = now < stop ? now + elapsed : now + RIPPLE_ANIMATION_MILLISECONDS; + ripple.animationEndTime = now < stop ? now + elapsed : now + BUMP_RAISE_MILLISECONDS; + // Only the drop emits a ripple, like releasing a finger from the water surface — the lift only deforms it locally + if (!goingUp) emitWavePulse(ripple); animate(); }; @@ -70,66 +96,200 @@ function initializeRipples() { goingUp: true, // Set to non-zero, but very old times (1ms after epoch), so the math works out as if the animation has already completed animationStartTime: 1, - animationEndTime: 1 + RIPPLE_ANIMATION_MILLISECONDS, + animationEndTime: 1 + BUMP_RAISE_MILLISECONDS, }; } setRipples(); } +function emitWavePulse(/** @type {{ element: HTMLElement }} **/ ripple) { + if (!rippleSvg) return; + + const buttonRect = ripple.element.getBoundingClientRect(); + const svgRect = rippleSvg.getBoundingClientRect(); + const originX = buttonRect.left - svgRect.left + buttonRect.width / 2; + + wavePulses.push({ + originX, + startTime: Date.now(), + }); +} + function animate(forceRefresh = false) { + const now = Date.now(); + + // Drop pulses whose amplitude has decayed below the visible threshold + wavePulses = wavePulses.filter((pulse) => { + const traveled = (WAVE_SPEED_PX_PER_SECOND * (now - pulse.startTime)) / 1000; + return Math.exp(-traveled / WAVE_ATTENUATION_LENGTH) > WAVE_PRUNE_AMPLITUDE / WAVE_AMPLITUDE; + }); + const FUZZ_MILLISECONDS = 100; - const animateThisFrame = ripples.some((ripple) => ripple.animationStartTime > 0 && ripple.animationEndTime > 0 && Date.now() <= ripple.animationEndTime + FUZZ_MILLISECONDS); + const bumpsAnimating = ripples.some((ripple) => ripple.animationStartTime > 0 && ripple.animationEndTime > 0 && now <= ripple.animationEndTime + FUZZ_MILLISECONDS); + const wavesActive = wavePulses.length > 0; - if (animateThisFrame || forceRefresh) { + if (bumpsAnimating || wavesActive || forceRefresh) { setRipples(); window.requestAnimationFrame(() => animate()); } } function setRipples() { - const lerp = (/** @type {number} **/ a, /** @type {number} **/ b, /** @type {number} **/ t) => a + (b - a) * t; const ease = (/** @type {number} **/ x) => 1 - (1 - x) * (1 - x); const clamp01 = (/** @type {number} **/ x) => Math.min(Math.max(x, 0), 1); - if (!rippleSvg || !ripplePath || !navButtons || !fullRippleHeight || !(navButtons[0] instanceof HTMLElement)) return; + if (!rippleSvg || !rippleMaskPath || !rippleLinePath) return; + if (!rippleTaperLeft || !rippleTaperRight) return; + if (!navButtons || !baselineFromTop || !taperHalfWidth) return; + if (!(navButtons[0] instanceof HTMLElement)) return; + const now = Date.now(); const rippleSvgRect = rippleSvg.getBoundingClientRect(); - const rippleStrokeWidth = Number.parseInt(window.getComputedStyle(ripplePath).getPropertyValue("--border-thickness"), 10); + const rippleStrokeWidth = Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--border-thickness"), 10); const navButtonFontSize = Number.parseInt(window.getComputedStyle(navButtons[0]).fontSize, 10) || NAV_BUTTON_INITIAL_FONT_SIZE; const mediaQueryScaleFactor = navButtonFontSize / NAV_BUTTON_INITIAL_FONT_SIZE; - // Position of bottom centerline to top centerline - const rippleBaselineCenterline = fullRippleHeight - rippleStrokeWidth / 2; - const rippleToplineCenterline = rippleStrokeWidth / 2; + // Baseline centerline: --ripple-baseline-from-top marks where the bottom edge of the baseline stroke sits, so the centerline is half a stroke above + const baselineY = baselineFromTop - rippleStrokeWidth / 2; + const toplineY = rippleStrokeWidth / 2; + const maxBumpHeight = baselineY - toplineY; - let path = `M -16,${rippleBaselineCenterline - 16} L 0,${rippleBaselineCenterline} `; + // Snapshot per-button lift state for this frame: a "gravity" bump that pulls the surface up linearly + const bumpHalfWidth = (BUMP_WIDTH / 2) * mediaQueryScaleFactor; + const suppressionHalfWidth = WAVE_SUPPRESSION_HALF_WIDTH * mediaQueryScaleFactor; + const bumps = ripples + .map((ripple) => { + if (ripple.animationStartTime === 0 && ripple.animationEndTime === 0) return null; - ripples.forEach((ripple) => { - if (ripple.animationStartTime === 0 || ripple.animationEndTime === 0) return; - - const elapsed = Date.now() - ripple.animationStartTime; - const duration = ripple.animationEndTime - ripple.animationStartTime; - const t = ease(clamp01(elapsed / duration)); - - const bumpCrestRaiseFactor = (ripple.goingUp ? t : 1 - t) * mediaQueryScaleFactor; - const bumpCrest = lerp(rippleToplineCenterline, rippleBaselineCenterline, bumpCrestRaiseFactor); - const bumpCrestDelta = bumpCrest - rippleStrokeWidth / 2; - - const buttonRect = ripple.element.getBoundingClientRect(); - const buttonCenter = buttonRect.width / 2; - const rippleCenter = (RIPPLE_WIDTH / 2) * mediaQueryScaleFactor; - const rippleOffset = rippleCenter - buttonCenter; - const rippleStartX = buttonRect.left - rippleSvgRect.left - rippleOffset; - const handleRadius = rippleCenter * HANDLE_STRETCH; - - path += `L ${rippleStartX},${rippleBaselineCenterline} `; - path += `c ${handleRadius},0 ${rippleCenter - handleRadius},${-bumpCrestDelta} ${rippleCenter},${-bumpCrestDelta} `; - path += `s ${rippleCenter - handleRadius},${bumpCrestDelta} ${rippleCenter},${bumpCrestDelta} `; + const elapsed = now - ripple.animationStartTime; + const duration = ripple.animationEndTime - ripple.animationStartTime; + const t = ease(clamp01(elapsed / duration)); + const liftFraction = clamp01(ripple.goingUp ? t : 1 - t); + if (liftFraction <= 0) return null; + + const buttonRect = ripple.element.getBoundingClientRect(); + const centerX = buttonRect.left - rippleSvgRect.left + buttonRect.width / 2; + + return { centerX, height: maxBumpHeight * liftFraction * mediaQueryScaleFactor, halfWidth: bumpHalfWidth, liftFraction, suppressionHalfWidth }; + }) + .filter((bump) => bump !== null); + + // Snapshot per-pulse propagation state for this frame + const pulses = wavePulses.map((pulse) => { + const ageMs = now - pulse.startTime; + const ageSeconds = ageMs / 1000; + const traveled = WAVE_SPEED_PX_PER_SECOND * ageSeconds; + const rampFactor = clamp01(ageMs / WAVE_RAMP_UP_MILLISECONDS); + const distanceAttenuation = Math.exp(-traveled / WAVE_ATTENUATION_LENGTH); + const sigma = WAVE_PACKET_SIGMA * mediaQueryScaleFactor; + const wavelength = WAVE_WAVELENGTH * mediaQueryScaleFactor; + const amplitude = WAVE_AMPLITUDE * mediaQueryScaleFactor * rampFactor * distanceAttenuation; + return { originX: pulse.originX, traveled, sigma, wavelength, amplitude }; }); - path += `L ${rippleSvgRect.width + 16},${rippleBaselineCenterline} L${rippleSvgRect.width + 16},${rippleBaselineCenterline - 16}`; + // Sample the surface: the lift bump adds directly while the wave is damped within a vicinity around each lifted button to avoid jiggling the bump + const sampleSpacing = WAVE_SAMPLE_SPACING * mediaQueryScaleFactor; + const numSamples = Math.max(2, Math.ceil(rippleSvgRect.width / sampleSpacing) + 1); + const samples = new Array(numSamples); + + for (let i = 0; i < numSamples; i++) { + const x = (i / (numSamples - 1)) * rippleSvgRect.width; + + let liftHeight = 0; + let waveSuppression = 0; + for (const bump of bumps) { + const dist = x - bump.centerX; + + if (Math.abs(dist) < bump.halfWidth) { + const shape = Math.cos((Math.PI * dist) / (2 * bump.halfWidth)) ** 2; + liftHeight += bump.height * shape; + } + + // Wave damping zone: a wider cos² envelope around each lifted button, scaled by how lifted that button currently is + if (Math.abs(dist) < bump.suppressionHalfWidth) { + const shape = Math.cos((Math.PI * dist) / (2 * bump.suppressionHalfWidth)) ** 2; + waveSuppression += bump.liftFraction * shape; + } + } + waveSuppression = Math.min(1, waveSuppression); + + let waveHeight = 0; + for (const pulse of pulses) { + // d'Alembert split: the source disturbance radiates as two equal halves moving in opposite directions + for (const direction of [-1, 1]) { + const center = pulse.originX + direction * pulse.traveled; + const dist = x - center; + const distNorm = dist / pulse.sigma; + if (Math.abs(distNorm) > 4) continue; + const envelope = Math.exp(-distNorm * distNorm); + const oscillation = Math.cos((2 * Math.PI * dist) / pulse.wavelength); + waveHeight += 0.5 * pulse.amplitude * envelope * oscillation; + } + } + + const displacement = liftHeight + waveHeight * (1 - waveSuppression); + samples[i] = { x, y: baselineY - displacement }; + } + + const waveCurve = buildSmoothCurve(samples); + const cornerY = baselineY - 16; + const leftCornerX = -16; + const rightCornerX = rippleSvgRect.width + 16; + const last = samples[samples.length - 1]; + + // Mask: closed region above the wave that hides navbar content under the SVG. Includes off-screen corners for a clean fill closure. + const maskPath = `M ${leftCornerX},${cornerY} L ${samples[0].x.toFixed(2)},${samples[0].y.toFixed(2)} ${waveCurve} L ${rightCornerX},${last.y.toFixed(2)} L ${rightCornerX},${cornerY}`; + rippleMaskPath.setAttribute("d", maskPath); + + // Visible wave line: just the curve, no off-screen extensions, so its stroke never appears outside the SVG bounds + const linePath = `M ${samples[0].x.toFixed(2)},${samples[0].y.toFixed(2)} ${waveCurve}`; + rippleLinePath.setAttribute("d", linePath); + + // Tapered end caps: apex sits at the baseline's bottom edge so the bottom stays flat while the top slopes down to meet it, matching the original CSS-border triangles + const halfStroke = rippleStrokeWidth / 2; + const apexY = baselineY + halfStroke; + const leftApexX = -taperHalfWidth; + const rightApexX = rippleSvgRect.width + taperHalfWidth; + const wideRightX = rippleSvgRect.width.toFixed(2); + const leftPoints = `${leftApexX},${apexY} 0,${(samples[0].y - halfStroke).toFixed(2)} 0,${(samples[0].y + halfStroke).toFixed(2)}`; + const rightPoints = `${rightApexX},${apexY} ${wideRightX},${(last.y - halfStroke).toFixed(2)} ${wideRightX},${(last.y + halfStroke).toFixed(2)}`; + rippleTaperLeft.setAttribute("points", leftPoints); + rippleTaperRight.setAttribute("points", rightPoints); +} + +function buildSmoothCurve(/** @type {{ x: number, y: number }[]} **/ samples) { + const get = (/** @type {number} **/ index) => { + if (index < 0) { + // Reflect first segment to derive a virtual point with matching tangent + const a = samples[0]; + const b = samples[1]; + return { x: 2 * a.x - b.x, y: 2 * a.y - b.y }; + } + if (index >= samples.length) { + const a = samples[samples.length - 1]; + const b = samples[samples.length - 2]; + return { x: 2 * a.x - b.x, y: 2 * a.y - b.y }; + } + return samples[index]; + }; + + // Catmull-Rom-to-cubic-Bezier across the sample chain for a smooth surface curve + let curve = ""; + for (let i = 0; i < samples.length - 1; i++) { + const p0 = get(i - 1); + const p1 = samples[i]; + const p2 = samples[i + 1]; + const p3 = get(i + 2); + + const cp1x = p1.x + (p2.x - p0.x) / 6; + const cp1y = p1.y + (p2.y - p0.y) / 6; + const cp2x = p2.x - (p3.x - p1.x) / 6; + const cp2y = p2.y - (p3.y - p1.y) / 6; + + curve += `C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)} `; + } - ripplePath.setAttribute("d", path); + return curve; } diff --git a/website/templates/base.html b/website/templates/base.html index 19a642ff1a..3167eaa21a 100644 --- a/website/templates/base.html +++ b/website/templates/base.html @@ -124,9 +124,11 @@ - + + + + -
{# This is a comment. It exists to prevent the {%- -%} on the lines below from removing the line break between `
` and the `content` block #} From 9ec1103a58073eb7f1c4ceea245e98922ea3c3bd Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 8 Jun 2026 22:30:25 -0700 Subject: [PATCH 2/3] Fix performance and other bugs --- website/static/js/navbar.js | 148 +++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 54 deletions(-) diff --git a/website/static/js/navbar.js b/website/static/js/navbar.js index f6ac82e48f..3311f05ea2 100644 --- a/website/static/js/navbar.js +++ b/website/static/js/navbar.js @@ -30,19 +30,32 @@ let /** @type {{ element: HTMLElement, goingUp: boolean, animationStartTime: num let /** @type {number} **/ activeRippleIndex; let /** @type {{ originX: number, startTime: number }[]} **/ wavePulses = []; +// Metrics derived from computed styles, cached here and refreshed only on resize (they shift across media-query breakpoints) rather than re-read every frame +let /** @type {number} **/ rippleStrokeWidth; +let /** @type {number} **/ mediaQueryScaleFactor; + +// The in-flight animation frame, if any; used to guarantee a single render loop no matter how many pointer events arrive +let /** @type {number | undefined} **/ animationFrameId; + +// Set by the resize handler, consumed by the next animation frame +let /** @type {boolean} **/ pendingResize = false; + window.addEventListener("DOMContentLoaded", initializeRipples); function initializeRipples() { - window.addEventListener("resize", () => animate(true)); + // Defer resize work to the next animation frame so rapid resize events coalesce into a single metrics refresh and redraw + window.addEventListener("resize", () => { + pendingResize = true; + requestAnimate(); + }); - navButtons = document.querySelectorAll("header nav a") || undefined; + navButtons = document.querySelectorAll("header nav a"); rippleSvg = document.querySelector("header .ripple") || undefined; rippleMaskPath = rippleSvg?.querySelector(".ripple-mask") || undefined; rippleLinePath = rippleSvg?.querySelector(".ripple-line") || undefined; rippleTaperLeft = rippleSvg?.querySelector(".ripple-taper-left") || undefined; rippleTaperRight = rippleSvg?.querySelector(".ripple-taper-right") || undefined; - baselineFromTop = rippleSvg ? Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--ripple-baseline-from-top"), 10) || undefined : undefined; - taperHalfWidth = rippleSvg ? Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--ripple-taper-half-width"), 10) || undefined : undefined; + refreshMetrics(); ripples = Array.from(navButtons) .filter((x) => x instanceof HTMLElement) @@ -67,7 +80,7 @@ function initializeRipples() { return location.startsWith(link); }); - ripples.forEach((ripple) => { + ripples.forEach((ripple, index) => { const updateTimings = (/** @type {boolean} **/ goingUp) => { const start = ripple.animationStartTime; const now = Date.now(); @@ -81,9 +94,9 @@ function initializeRipples() { ripple.animationStartTime = now < stop ? now - remaining : now; ripple.animationEndTime = now < stop ? now + elapsed : now + BUMP_RAISE_MILLISECONDS; - // Only the drop emits a ripple, like releasing a finger from the water surface — the lift only deforms it locally - if (!goingUp) emitWavePulse(ripple); - animate(); + // Only the drop emits a ripple, like releasing a finger from the water surface; the active page's button stays lifted and never drops, so it's excluded + if (!goingUp && index !== activeRippleIndex) emitWavePulse(ripple); + requestAnimate(); }; ripple.element.addEventListener("pointerenter", () => updateTimings(true)); @@ -101,6 +114,9 @@ function initializeRipples() { } setRipples(); + + // Web fonts can load after this initial layout and reflow the nav buttons, leaving the active page's static bump offset; redraw once they're ready + document.fonts?.ready.then(setRipples); } function emitWavePulse(/** @type {{ element: HTMLElement }} **/ ripple) { @@ -116,22 +132,47 @@ function emitWavePulse(/** @type {{ element: HTMLElement }} **/ ripple) { }); } -function animate(forceRefresh = false) { +function refreshMetrics() { + if (!rippleSvg || !navButtons || !(navButtons[0] instanceof HTMLElement)) return; + + const svgStyle = window.getComputedStyle(rippleSvg); + baselineFromTop = Number.parseInt(svgStyle.getPropertyValue("--ripple-baseline-from-top"), 10) || undefined; + taperHalfWidth = Number.parseInt(svgStyle.getPropertyValue("--ripple-taper-half-width"), 10) || undefined; + rippleStrokeWidth = Number.parseInt(svgStyle.getPropertyValue("--border-thickness"), 10); + + const navButtonFontSize = Number.parseInt(window.getComputedStyle(navButtons[0]).fontSize, 10) || NAV_BUTTON_INITIAL_FONT_SIZE; + mediaQueryScaleFactor = navButtonFontSize / NAV_BUTTON_INITIAL_FONT_SIZE; +} + +// Schedule the render loop, but only if it isn't already running, so a burst of pointer events can't stack up redundant concurrent loops +function requestAnimate() { + if (animationFrameId !== undefined) return; + animationFrameId = window.requestAnimationFrame(animationTick); +} + +function animationTick() { + animationFrameId = undefined; + + // A resize since the last frame may have changed the cached metrics; refresh them here (once) rather than on every resize event + if (pendingResize) refreshMetrics(); + const now = Date.now(); // Drop pulses whose amplitude has decayed below the visible threshold wavePulses = wavePulses.filter((pulse) => { - const traveled = (WAVE_SPEED_PX_PER_SECOND * (now - pulse.startTime)) / 1000; - return Math.exp(-traveled / WAVE_ATTENUATION_LENGTH) > WAVE_PRUNE_AMPLITUDE / WAVE_AMPLITUDE; + const traveled = (WAVE_SPEED_PX_PER_SECOND * mediaQueryScaleFactor * (now - pulse.startTime)) / 1000; + return Math.exp(-traveled / (WAVE_ATTENUATION_LENGTH * mediaQueryScaleFactor)) > WAVE_PRUNE_AMPLITUDE / WAVE_AMPLITUDE; }); const FUZZ_MILLISECONDS = 100; const bumpsAnimating = ripples.some((ripple) => ripple.animationStartTime > 0 && ripple.animationEndTime > 0 && now <= ripple.animationEndTime + FUZZ_MILLISECONDS); const wavesActive = wavePulses.length > 0; - if (bumpsAnimating || wavesActive || forceRefresh) { + // Keep looping while anything is animating; a lone pending resize just needs the single redraw below + if (bumpsAnimating || wavesActive || pendingResize) { + pendingResize = false; setRipples(); - window.requestAnimationFrame(() => animate()); + if (bumpsAnimating || wavesActive) requestAnimate(); } } @@ -147,10 +188,6 @@ function setRipples() { const now = Date.now(); const rippleSvgRect = rippleSvg.getBoundingClientRect(); - const rippleStrokeWidth = Number.parseInt(window.getComputedStyle(rippleSvg).getPropertyValue("--border-thickness"), 10); - const navButtonFontSize = Number.parseInt(window.getComputedStyle(navButtons[0]).fontSize, 10) || NAV_BUTTON_INITIAL_FONT_SIZE; - const mediaQueryScaleFactor = navButtonFontSize / NAV_BUTTON_INITIAL_FONT_SIZE; - // Baseline centerline: --ripple-baseline-from-top marks where the bottom edge of the baseline stroke sits, so the centerline is half a stroke above const baselineY = baselineFromTop - rippleStrokeWidth / 2; const toplineY = rippleStrokeWidth / 2; @@ -180,9 +217,10 @@ function setRipples() { const pulses = wavePulses.map((pulse) => { const ageMs = now - pulse.startTime; const ageSeconds = ageMs / 1000; - const traveled = WAVE_SPEED_PX_PER_SECOND * ageSeconds; + // Speed and attenuation distance scale with the UI so the wave looks identical (just smaller) when media queries shrink the navbar + const traveled = WAVE_SPEED_PX_PER_SECOND * mediaQueryScaleFactor * ageSeconds; const rampFactor = clamp01(ageMs / WAVE_RAMP_UP_MILLISECONDS); - const distanceAttenuation = Math.exp(-traveled / WAVE_ATTENUATION_LENGTH); + const distanceAttenuation = Math.exp(-traveled / (WAVE_ATTENUATION_LENGTH * mediaQueryScaleFactor)); const sigma = WAVE_PACKET_SIGMA * mediaQueryScaleFactor; const wavelength = WAVE_WAVELENGTH * mediaQueryScaleFactor; const amplitude = WAVE_AMPLITUDE * mediaQueryScaleFactor * rampFactor * distanceAttenuation; @@ -193,40 +231,31 @@ function setRipples() { const sampleSpacing = WAVE_SAMPLE_SPACING * mediaQueryScaleFactor; const numSamples = Math.max(2, Math.ceil(rippleSvgRect.width / sampleSpacing) + 1); const samples = new Array(numSamples); - for (let i = 0; i < numSamples; i++) { const x = (i / (numSamples - 1)) * rippleSvgRect.width; + // The local lift bump adds directly to the surface height, while each lifted button damps passing waves within a wider zone (scaled by how lifted it is) so its bump doesn't jiggle let liftHeight = 0; let waveSuppression = 0; - for (const bump of bumps) { + for (let j = 0; j < bumps.length; j++) { + const bump = bumps[j]; const dist = x - bump.centerX; if (Math.abs(dist) < bump.halfWidth) { - const shape = Math.cos((Math.PI * dist) / (2 * bump.halfWidth)) ** 2; - liftHeight += bump.height * shape; + liftHeight += bump.height * Math.cos((Math.PI * dist) / (2 * bump.halfWidth)) ** 2; } - - // Wave damping zone: a wider cos² envelope around each lifted button, scaled by how lifted that button currently is if (Math.abs(dist) < bump.suppressionHalfWidth) { - const shape = Math.cos((Math.PI * dist) / (2 * bump.suppressionHalfWidth)) ** 2; - waveSuppression += bump.liftFraction * shape; + waveSuppression += bump.liftFraction * Math.cos((Math.PI * dist) / (2 * bump.suppressionHalfWidth)) ** 2; } } waveSuppression = Math.min(1, waveSuppression); + // Each pulse contributes two d'Alembert halves moving in opposite directions let waveHeight = 0; - for (const pulse of pulses) { - // d'Alembert split: the source disturbance radiates as two equal halves moving in opposite directions - for (const direction of [-1, 1]) { - const center = pulse.originX + direction * pulse.traveled; - const dist = x - center; - const distNorm = dist / pulse.sigma; - if (Math.abs(distNorm) > 4) continue; - const envelope = Math.exp(-distNorm * distNorm); - const oscillation = Math.cos((2 * Math.PI * dist) / pulse.wavelength); - waveHeight += 0.5 * pulse.amplitude * envelope * oscillation; - } + for (let j = 0; j < pulses.length; j++) { + const pulse = pulses[j]; + waveHeight += halfPulseContribution(x, pulse.originX - pulse.traveled, pulse); + waveHeight += halfPulseContribution(x, pulse.originX + pulse.traveled, pulse); } const displacement = liftHeight + waveHeight * (1 - waveSuppression); @@ -259,6 +288,18 @@ function setRipples() { rippleTaperRight.setAttribute("points", rightPoints); } +// One d'Alembert half-pulse's contribution to the surface height at position `x`, as a Gaussian-windowed cosine wave packet +function halfPulseContribution(/** @type {number} **/ x, /** @type {number} **/ center, /** @type {{ sigma: number, wavelength: number, amplitude: number }} **/ pulse) { + const dist = x - center; + const distNorm = dist / pulse.sigma; + // The Gaussian envelope is negligible past 4 sigma, so skip the transcendentals out there + if (Math.abs(distNorm) > 4) return 0; + + const envelope = Math.exp(-distNorm * distNorm); + const oscillation = Math.cos((2 * Math.PI * dist) / pulse.wavelength); + return 0.5 * pulse.amplitude * envelope * oscillation; +} + function buildSmoothCurve(/** @type {{ x: number, y: number }[]} **/ samples) { const get = (/** @type {number} **/ index) => { if (index < 0) { @@ -276,20 +317,19 @@ function buildSmoothCurve(/** @type {{ x: number, y: number }[]} **/ samples) { }; // Catmull-Rom-to-cubic-Bezier across the sample chain for a smooth surface curve - let curve = ""; - for (let i = 0; i < samples.length - 1; i++) { - const p0 = get(i - 1); - const p1 = samples[i]; - const p2 = samples[i + 1]; - const p3 = get(i + 2); - - const cp1x = p1.x + (p2.x - p0.x) / 6; - const cp1y = p1.y + (p2.y - p0.y) / 6; - const cp2x = p2.x - (p3.x - p1.x) / 6; - const cp2y = p2.y - (p3.y - p1.y) / 6; - - curve += `C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)} `; - } - - return curve; + return samples + .slice(0, -1) + .map((p1, i) => { + const p0 = get(i - 1); + const p2 = samples[i + 1]; + const p3 = get(i + 2); + + const cp1x = p1.x + (p2.x - p0.x) / 6; + const cp1y = p1.y + (p2.y - p0.y) / 6; + const cp2x = p2.x - (p3.x - p1.x) / 6; + const cp2y = p2.y - (p3.y - p1.y) / 6; + + return `C ${cp1x.toFixed(2)},${cp1y.toFixed(2)} ${cp2x.toFixed(2)},${cp2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)} `; + }) + .join(""); } From fdb86e8073eb27c168b061fb7fd071b96e5ea16a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 8 Jun 2026 22:55:22 -0700 Subject: [PATCH 3/3] Make the navbar ripple's active button explicitly inert --- website/static/js/navbar.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/website/static/js/navbar.js b/website/static/js/navbar.js index 3311f05ea2..54f112a7d7 100644 --- a/website/static/js/navbar.js +++ b/website/static/js/navbar.js @@ -81,6 +81,9 @@ function initializeRipples() { }); ripples.forEach((ripple, index) => { + // The active page's button is permanently lifted and inert, so it gets no hover listeners + if (index === activeRippleIndex) return; + const updateTimings = (/** @type {boolean} **/ goingUp) => { const start = ripple.animationStartTime; const now = Date.now(); @@ -94,8 +97,8 @@ function initializeRipples() { ripple.animationStartTime = now < stop ? now - remaining : now; ripple.animationEndTime = now < stop ? now + elapsed : now + BUMP_RAISE_MILLISECONDS; - // Only the drop emits a ripple, like releasing a finger from the water surface; the active page's button stays lifted and never drops, so it's excluded - if (!goingUp && index !== activeRippleIndex) emitWavePulse(ripple); + // Only the drop emits a ripple, like releasing a finger from the water surface; the lift only deforms it locally + if (!goingUp) emitWavePulse(ripple); requestAnimate(); }; @@ -104,13 +107,11 @@ function initializeRipples() { }); if (activeRippleIndex >= 0) { - ripples[activeRippleIndex] = { - ...ripples[activeRippleIndex], - goingUp: true, - // Set to non-zero, but very old times (1ms after epoch), so the math works out as if the animation has already completed - animationStartTime: 1, - animationEndTime: 1 + BUMP_RAISE_MILLISECONDS, - }; + // Initialize the active button lifted; the very old times (1ms after epoch) make the math treat its lift as already complete + const active = ripples[activeRippleIndex]; + active.goingUp = true; + active.animationStartTime = 1; + active.animationEndTime = 1 + BUMP_RAISE_MILLISECONDS; } setRipples();