/* ============================================================ theme.js — the "magic": pull a color from the food image and morph the whole page to it. No external library. ============================================================ */ const Theme = (() => { const root = document.documentElement; /* Extract a pleasant dominant color from an using a tiny canvas. */ function extract(imgEl) { const c = document.createElement('canvas'); const S = 48; // downscale -> fast c.width = c.height = S; const ctx = c.getContext('2d'); ctx.drawImage(imgEl, 0, 0, S, S); const { data } = ctx.getImageData(0, 0, S, S); let r = 0, g = 0, b = 0, n = 0; let rs = 0, gs = 0, bs = 0, ns = 0; // saturated-only accumulator for (let i = 0; i < data.length; i += 4) { const R = data[i], G = data[i + 1], B = data[i + 2]; const max = Math.max(R, G, B), min = Math.min(R, G, B); const sat = max - min; // skip near-white / near-black / near-grey backgrounds if (max < 40 || min > 225) continue; r += R; g += G; b += B; n++; if (sat > 40) { rs += R; gs += G; bs += B; ns++; } } // prefer the vibrant average; fall back to plain average; then green if (ns > n * 0.06) return [rs / ns | 0, gs / ns | 0, bs / ns | 0]; if (n) return [r / n | 0, g / n | 0, b / n | 0]; return [91, 140, 62]; } function luminance([r, g, b]) { return 0.299 * r + 0.587 * g + 0.114 * b; } /* ── color space helpers (for building a 2-tone gradient) ─────────── */ function rgb2hsl(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b); let h, s, l = (max + min) / 2; if (max === min) { h = s = 0; } else { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; } h /= 6; } return [h * 360, s, l]; } function hsl2rgb(h, s, l) { h /= 360; let r, g, b; if (s === 0) { r = g = b = l; } else { const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } const css = ([r, g, b]) => `rgb(${r},${g},${b})`; /* Apply a color to the CSS variables, deriving a richer 2-tone gradient. */ function apply(rgb) { const [r, g, b] = rgb; const [h, s, l] = rgb2hsl(r, g, b); const accent2 = hsl2rgb((h + 16) % 360, Math.min(s * 1.05, 1), Math.min(l * 1.18 + 0.05, 0.74)); const accentD = hsl2rgb(h, Math.min(s * 1.08, 1), Math.max(l * 0.7, 0.14)); root.style.setProperty('--accent', css(rgb)); root.style.setProperty('--accent-2', css(accent2)); root.style.setProperty('--accent-d', css(accentD)); root.style.setProperty('--bg-tint', `rgba(${r},${g},${b},0.10)`); root.style.setProperty('--on-accent', luminance(rgb) > 150 ? '#1c2418' : '#ffffff'); // NOTE: intentionally NOT persisted — the color is local to this page only. // Navigating away / returning resets to the default theme. } /* From an image element (must be loaded + same-origin / blob). */ function fromImage(imgEl) { try { apply(extract(imgEl)); } catch (e) { /* tainted/empty: keep default */ } } return { fromImage, apply, extract }; })(); /* Animate a number counting up to `target` inside an element. */ function countUp(el, target, { dur = 900, suffix = '' } = {}) { const start = performance.now(); const from = 0; function tick(now) { const p = Math.min((now - start) / dur, 1); const eased = 1 - Math.pow(1 - p, 3); el.textContent = Math.round(from + (target - from) * eased) + suffix; if (p < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); } /* Lightweight page-visit loader. Uses transform/opacity so it stays smooth on Android. */ (function pageLoader() { if (document.body.dataset.loader === 'off') return; const loader = document.createElement('div'); loader.className = 'page-loader'; loader.innerHTML = ` Scanning plate`; document.body.appendChild(loader); const messages = ['Scanning plate', 'Classifying dish', 'Estimating calories']; const text = loader.querySelector('#loaderText'); let msgIndex = 0; const msgTimer = window.setInterval(() => { msgIndex = (msgIndex + 1) % messages.length; text.classList.remove('swap'); void text.offsetWidth; text.textContent = messages[msgIndex]; text.classList.add('swap'); }, 720); const hide = () => { window.clearInterval(msgTimer); loader.classList.add('hide'); window.setTimeout(() => loader.remove(), 420); }; const minTime = 650; const start = performance.now(); window.addEventListener('load', () => { const elapsed = performance.now() - start; window.setTimeout(hide, Math.max(0, minTime - elapsed)); }, { once: true }); window.setTimeout(hide, 1800); })();