// ─── Configuration ──────────────────────────────────────────────────────────── const CONFIG = { bezelWidth: 0.18, glassThickness: 2.0, ior: 1.65, scaleRatio: 1.6, specularAngle: -55, specularOpacity: 0.9, specularSaturation: 6, borderRadius: 50, }; // ─── Maths helpers ──────────────────────────────────────────────────────────── function surfaceHeight(t) { return Math.pow(1 - Math.pow(1 - t, 4), 0.25); } function surfaceNormal(t) { const d = 0.001; const y1 = surfaceHeight(Math.max(0, t - d)); const y2 = surfaceHeight(Math.min(1, t + d)); const derivative = (y2 - y1) / (2 * d); return { x: -derivative, y: 1 }; } function refract(incidentDir, normal, n1, n2) { const nLen = Math.hypot(normal.x, normal.y); const nx = normal.x / nLen; const ny = normal.y / nLen; const ratio = n1 / n2; const cosI = -(incidentDir.x * nx + incidentDir.y * ny); const sinT2 = ratio * ratio * (1 - cosI * cosI); if (sinT2 > 1) return null; const cosT = Math.sqrt(1 - sinT2); return { x: ratio * incidentDir.x + (ratio * cosI - cosT) * nx, y: ratio * incidentDir.y + (ratio * cosI - cosT) * ny, }; } // ─── Rounded rect helper ────────────────────────────────────────────────────── function roundedRectInfo(px, py, width, height, r) { const radius = Math.min(r, width / 2, height / 2); const inCornerZone = (px < radius || px > width - radius) && (py < radius || py > height - radius); let dist, nx, ny; if (inCornerZone) { const corners = [ { cx: radius, cy: radius }, { cx: width - radius, cy: radius }, { cx: radius, cy: height - radius }, { cx: width - radius, cy: height - radius }, ]; let best = corners[0], bestD = Math.hypot(px - corners[0].cx, py - corners[0].cy); for (let i = 1; i < corners.length; i++) { const d = Math.hypot(px - corners[i].cx, py - corners[i].cy); if (d < bestD) { bestD = d; best = corners[i]; } } dist = radius - bestD; const len = bestD || 1; nx = (best.cx - px) / len; ny = (best.cy - py) / len; } else { const dLeft = px; const dRight = width - 1 - px; const dTop = py; const dBottom = height - 1 - py; const minD = Math.min(dLeft, dRight, dTop, dBottom); dist = minD; if (minD === dLeft) { nx = 1; ny = 0; } else if (minD === dRight) { nx = -1; ny = 0; } else if (minD === dTop) { nx = 0; ny = 1; } else { nx = 0; ny = -1; } } return { dist, nx, ny }; } // ─── Displacement map generation ───────────────────────────────────────────── function buildDisplacementMap(width, height) { const bezel = CONFIG.bezelWidth * Math.min(width, height); const samples = 128; const magnitudes = new Float32Array(samples); const incident = { x: 0, y: 1 }; for (let i = 0; i < samples; i++) { const t = i / (samples - 1); const normal = surfaceNormal(t); const refracted = refract(incident, normal, 1.0, CONFIG.ior); magnitudes[i] = refracted ? (refracted.x - incident.x) * CONFIG.glassThickness * bezel : 0; } const maxDisp = Math.max(...magnitudes) || 1; const data = new Uint8ClampedArray(width * height * 4); for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { const idx = (py * width + px) * 4; const { dist, nx, ny } = roundedRectInfo(px, py, width, height, CONFIG.borderRadius); let dispX = 0, dispY = 0; if (dist >= 0 && dist < bezel) { const t = dist / bezel; const sampleIdx = Math.min(samples - 1, Math.floor(t * (samples - 1))); const rawMag = magnitudes[sampleIdx] / maxDisp; const smooth = t * t * (3 - 2 * t); const mag = rawMag * (1 - smooth); dispX = nx * mag; dispY = ny * mag; } data[idx] = Math.round(128 + dispX * 127); data[idx + 1] = Math.round(128 + dispY * 127); data[idx + 2] = 128; data[idx + 3] = 255; } } return { imageData: new ImageData(data, width, height), maxDisplacement: maxDisp * CONFIG.scaleRatio, }; } // ─── Specular highlight map generation ─────────────────────────────────────── function buildSpecularMap(width, height) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const bezel = CONFIG.bezelWidth * Math.min(width, height); const lightAngleRad = (CONFIG.specularAngle * Math.PI) / 180; const lightDir = { x: Math.cos(lightAngleRad), y: Math.sin(lightAngleRad) }; const imageData = ctx.createImageData(width, height); const data = imageData.data; for (let py = 0; py < height; py++) { for (let px = 0; px < width; px++) { const idx = (py * width + px) * 4; const { dist, nx, ny } = roundedRectInfo(px, py, width, height, CONFIG.borderRadius); let intensity = 0; if (dist >= 0 && dist < bezel) { const t = dist / bezel; const dot = nx * lightDir.x + ny * lightDir.y; const rim = Math.max(0, dot); const slope = Math.abs(surfaceNormal(t).x); const smooth = t * t * (3 - 2 * t); const raw = rim * slope * (1 - smooth); intensity = Math.pow(raw, 0.5); } data[idx] = Math.round(235 * intensity); data[idx + 1] = Math.round(245 * intensity); data[idx + 2] = Math.round(255 * intensity); data[idx + 3] = Math.round(CONFIG.specularOpacity * 255 * intensity); } } ctx.putImageData(imageData, 0, 0); return canvas.toDataURL(); } // ─── ImageData → data URL ───────────────────────────────────────────────────── function imageDataToDataURL(imageData) { const canvas = document.createElement('canvas'); canvas.width = imageData.width; canvas.height = imageData.height; canvas.getContext('2d').putImageData(imageData, 0, 0); return canvas.toDataURL(); } // ─── Apply filter to card ───────────────────────────────────────────────────── function applyLiquidGlass() { const card = document.getElementById('card'); // offsetWidth/Height for map generation (unaffected by zoom) const w = card.offsetWidth; const h = card.offsetHeight; if (w === 0 || h === 0) return; // getBoundingClientRect for SVG filter dimensions — these must match the // actual rendered pixel size that backdrop-filter sees at current zoom level const rect = card.getBoundingClientRect(); const fw = Math.round(rect.width); const fh = Math.round(rect.height); CONFIG.borderRadius = parseFloat(getComputedStyle(card).borderRadius) || 25; const { imageData, maxDisplacement } = buildDisplacementMap(w, h); const dispDataURL = imageDataToDataURL(imageData); const specularDataURL = buildSpecularMap(w, h); const dispImg = document.getElementById('displacement-map-img'); const specImg = document.getElementById('specular-map-img'); const dispFilter = document.getElementById('displacement-map-filter'); // Set filter image dimensions to the zoomed rect size so the map aligns correctly dispImg.setAttribute('href', dispDataURL); dispImg.setAttribute('width', fw); dispImg.setAttribute('height', fh); specImg.setAttribute('href', specularDataURL); specImg.setAttribute('width', fw); specImg.setAttribute('height', fh); dispFilter.setAttribute('scale', maxDisplacement); const isChrome = /Chrome/.test(navigator.userAgent) && !/Edg|Firefox/.test(navigator.userAgent); if (isChrome) { card.style.backdropFilter = 'url(#liquid-glass) blur(3px) saturate(1.8) brightness(1.05)'; card.style.webkitBackdropFilter = 'url(#liquid-glass) blur(3px) saturate(1.8) brightness(1.05)'; } } // ─── Nav glass ──────────────────────────────────────────────────────────────── // Phase durations in ms — keep in sync with CSS transitions const OPEN_WIDTH_MS = 250; // width/radius expand duration const OPEN_DROPDOWN_MS = 300; // dropdown slide-down duration const OPEN_DROPDOWN_DELAY = 220; // CSS delay before dropdown opens const CLOSE_DROPDOWN_MS = 200; // dropdown collapse duration (no CSS delay) const CLOSE_WIDTH_MS = 250; // width/radius collapse duration const CLOSE_WIDTH_DELAY = 200; // wait for dropdown to finish before collapsing width // Build and apply nav glass maps at a specific border-radius function applyNavGlassAt(radius) { const pill = document.getElementById('nav-pill'); const w = pill.offsetWidth; const h = pill.offsetHeight; if (w === 0 || h === 0) return; const rect = pill.getBoundingClientRect(); const fw = Math.round(rect.width); const fh = Math.round(rect.height); const savedBezel = CONFIG.bezelWidth; const savedScale = CONFIG.scaleRatio; const savedRadius = CONFIG.borderRadius; CONFIG.bezelWidth = 0.35; CONFIG.scaleRatio = 1.6; CONFIG.borderRadius = Math.min(radius, w / 2, h / 2); const { imageData, maxDisplacement } = buildDisplacementMap(w, h); const dispDataURL = imageDataToDataURL(imageData); const specularDataURL = buildSpecularMap(w, h); CONFIG.bezelWidth = savedBezel; CONFIG.scaleRatio = savedScale; CONFIG.borderRadius = savedRadius; document.getElementById('nav-disp-img').setAttribute('href', dispDataURL); document.getElementById('nav-disp-img').setAttribute('width', fw); document.getElementById('nav-disp-img').setAttribute('height', fh); document.getElementById('nav-spec-img').setAttribute('href', specularDataURL); document.getElementById('nav-spec-img').setAttribute('width', fw); document.getElementById('nav-spec-img').setAttribute('height', fh); document.getElementById('nav-disp-filter').setAttribute('scale', maxDisplacement); const isChrome = /Chrome/.test(navigator.userAgent) && !/Edg|Firefox/.test(navigator.userAgent); if (isChrome) { pill.style.backdropFilter = 'url(#liquid-glass-nav) blur(3px) saturate(1.8) brightness(1.05)'; pill.style.webkitBackdropFilter = 'url(#liquid-glass-nav) blur(3px) saturate(1.8) brightness(1.05)'; } } // Animate refraction maps across a time window function scheduleNavGlassFrames(startRadius, targetRadius, delayMs, durationMs, steps = 14) { for (let i = 1; i <= steps; i++) { const t = i / steps; const eased = 1 - Math.pow(1 - t, 3); const radius = startRadius + (targetRadius - startRadius) * eased; setTimeout(() => applyNavGlassAt(radius), delayMs + t * durationMs); } } function applyNavGlass() { const pill = document.getElementById('nav-pill'); const radius = parseFloat(getComputedStyle(pill).borderRadius) || 999; applyNavGlassAt(radius); } // ─── Event listeners ────────────────────────────────────────────────────────── window.addEventListener('load', () => { applyLiquidGlass(); applyNavGlass(); }); window.addEventListener('resize', () => { applyLiquidGlass(); applyNavGlass(); }); let lastDPR = window.devicePixelRatio; setInterval(() => { if (window.devicePixelRatio !== lastDPR) { lastDPR = window.devicePixelRatio; applyLiquidGlass(); applyNavGlass(); } }, 250); // ─── Nav pill interactions ──────────────────────────────────────────────────── const navPill = document.getElementById('nav-pill'); const navBar = document.getElementById('nav-pill-bar'); navBar.addEventListener('click', () => { const opening = !navPill.classList.contains('open'); navPill.classList.toggle('open'); if (opening) { // Phase 1: width expands (0 → OPEN_WIDTH_MS) scheduleNavGlassFrames(999, 25, 0, OPEN_WIDTH_MS); // Phase 2: dropdown falls (OPEN_DROPDOWN_DELAY → +OPEN_DROPDOWN_MS) // refraction height grows as the pill gets taller — track it live scheduleNavGlassFrames(25, 25, OPEN_DROPDOWN_DELAY, OPEN_DROPDOWN_MS); // Final settle setTimeout(applyNavGlass, OPEN_DROPDOWN_DELAY + OPEN_DROPDOWN_MS + 50); } else { // Phase 1: dropdown collapses (0 → CLOSE_DROPDOWN_MS) scheduleNavGlassFrames(25, 25, 0, CLOSE_DROPDOWN_MS); // Phase 2: width shrinks after dropdown finishes scheduleNavGlassFrames(25, 999, CLOSE_WIDTH_DELAY, CLOSE_WIDTH_MS); // Final settle setTimeout(applyNavGlass, CLOSE_WIDTH_DELAY + CLOSE_WIDTH_MS + 50); } }); document.addEventListener('click', (e) => { if (!navPill.contains(e.target) && navPill.classList.contains('open')) { navPill.classList.remove('open'); scheduleNavGlassFrames(25, 25, 0, CLOSE_DROPDOWN_MS); scheduleNavGlassFrames(25, 999, CLOSE_WIDTH_DELAY, CLOSE_WIDTH_MS); setTimeout(applyNavGlass, CLOSE_WIDTH_DELAY + CLOSE_WIDTH_MS + 50); } }); // ─── Nav pin on scroll ──────────────────────────────────────────────────────── const navWrap = document.querySelector('.nav-wrap'); const navPlaceholder = document.getElementById('nav-placeholder'); function updateNavPin() { navWrap.classList.remove('pinned'); navPlaceholder.classList.remove('visible'); const offsetTop = navWrap.offsetTop; if (window.scrollY > offsetTop) { navWrap.classList.add('pinned'); navPlaceholder.classList.add('visible'); } } window.addEventListener('scroll', updateNavPin); window.addEventListener('load', updateNavPin);