Spaces:
Runtime error
Runtime error
| /* ============================================================ | |
| 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 <img> 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 = ` | |
| <div class="loader-scan" aria-hidden="true"> | |
| <span class="loader-corner c1"></span> | |
| <span class="loader-corner c2"></span> | |
| <span class="loader-corner c3"></span> | |
| <span class="loader-corner c4"></span> | |
| <span class="loader-pulse"></span> | |
| <span class="loader-ring"></span> | |
| <img src="/static/img/smart-plate-logo-transparent.png" alt="" /> | |
| </div> | |
| <span class="loader-text" id="loaderText">Scanning plate</span>`; | |
| 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); | |
| })(); | |