openvibers/openvibe.shared ov-fx.js
ov-fx.js
| 1 | /** |
| 2 | * openvibe-shared/ov-fx.js — the network's effects, one kit (plan L45, 4.66.0). The mark's motion (4.65.0: a glint, |
| 3 | * a glow, a press) for anything on any OpenVibe page, plus reveals, counting numbers, a ripple, a tilt and confetti. |
| 4 | * navbar.js loads it from beside itself once the page settles, and only when the page has an element with data-fx. |
| 5 | * |
| 6 | * <a class="card" data-fx="glint lift press">…</a> effects by name, any number of them |
| 7 | * <section data-fx="reveal" data-fx-stagger="40">…</section> |
| 8 | * its heading rises 12 px (8 on a phone) out of a 4 px blur, |
| 9 | * a [data-fx-rule] draws from the left, and its cards follow in |
| 10 | * 40 ms steps (at most six; the rest together), once. |
| 11 | * <b data-fx="count">12,480</b> the number counts up the first time it is seen |
| 12 | * <button data-fx="confetti press">Send tip</button> a burst of confetti from the button when it is pressed |
| 13 | * OVFx.confetti({ x, y, count, colors }) the same burst from anywhere (a first stream, a goal) |
| 14 | * OVFx.scan(root) wire effects in markup added later (scanned by itself too) |
| 15 | * |
| 16 | * Effects: glint (a band of light crosses on hover), glow (the accent glows behind on hover), lift (rises a little), |
| 17 | * press (gives on press and springs back), ripple (from the point pressed), tilt (leans toward the pointer; mouse |
| 18 | * only), reveal, count, confetti, shimmer (a placeholder that shimmers until data-fx is removed). |
| 19 | * |
| 20 | * Motion tokens (a look can set them; theme-loader.js passes a theme's own): --ov-dur-1…4, --ov-ease-out, |
| 21 | * --ov-ease-spring, --ov-ease-in-out. Each look has a feel (4.67.0): the quiet paper and light themes move calmly (a |
| 22 | * little slower, no overshoot), the neon and terminal ones snap (quicker, a livelier spring); the rest are lively. Nothing here runs by itself: every effect waits for a hover, a press, or the |
| 23 | * element coming on screen, plays once, and stops. Calm (the person's choice, or the device asking for less motion: |
| 24 | * html[data-ov-motion=reduced]) keeps every effect and makes it smaller and slower: a lift rises a pixel, a tilt |
| 25 | * leans a third as far, a burst of confetti is a third the size, nothing overshoots; a reveal keeps only a short |
| 26 | * fade. Only Off (html[data-ov-motion=off]) stops them: reveals and numbers are simply there, and no confetti falls. |
| 27 | * |
| 28 | * The reveal (278) is driven by the scroll timeline where the browser has animation-timeline: view(), so no script |
| 29 | * runs on the main thread while scrolling; elsewhere one shared IntersectionObserver does the same job. Content is |
| 30 | * visible by default: the start state is only applied (a) by CSS inside @supports, or (b) to elements the script |
| 31 | * knows are below the fold, with an observer ready to show them. A heading is [data-fx-heading] or the first h1–h6 |
| 32 | * in the container; a rule is [data-fx-rule] (or an <hr>); cards are [data-fx-card]s, the container's own children, |
| 33 | * or the children of a plain list/grid child. Point at them with data-fx-cards="selector" when the markup is not |
| 34 | * one of those shapes. data-fx-self reveals the element itself. Distances halve on a phone, Calm fades, Off is still. |
| 35 | */ |
| 36 | (function (root) { |
| 37 | ; |
| 38 | if (!root || root.OVFx) return; |
| 39 | const doc = root.document; |
| 40 | const feel = (ids) => ids.map((id) => `[data-theme=${id}]`).join(','); |
| 41 | const CALM = feel(['paper', 'daylight', 'cloud', 'snow', 'sand', 'rose', 'meadow', 'peach', 'lavender', 'dawn', 'solarized-light', 'catppuccin-latte', 'nord', 'nord-light']); |
| 42 | const SNAPPY = feel(['neon-tokyo', 'vapor', 'matrix', 'hacker', 'high-contrast', 'dracula', 'monokai', 'abyss']); |
| 43 | const CSS = ':root{--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s;--ov-ease-out:cubic-bezier(.2,.8,.2,1);--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-ease-in-out:cubic-bezier(.65,0,.35,1)}' |
| 44 | + `html:is(${CALM}){--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}` |
| 45 | + `html:is(${SNAPPY}){--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s;--ov-ease-spring:cubic-bezier(.3,1.8,.6,1)}` |
| 46 | + 'html[data-ov-motion=reduced]{--ov-move:.35;--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}' |
| 47 | + '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]){--ov-move:.35;--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s;--ov-ease-spring:var(--ov-ease-out)}}' |
| 48 | + 'html[data-ov-motion=off]{--ov-move:0;--ov-dur-1:0s;--ov-dur-2:0s;--ov-dur-3:0s;--ov-dur-4:0s}' |
| 49 | // A demo scope plays in full whatever the page's motion is, in the look's own feel. |
| 50 | + '[data-fx-demo]{--ov-move:1;--ov-ease-spring:cubic-bezier(.34,1.56,.64,1);--ov-dur-1:.12s;--ov-dur-2:.2s;--ov-dur-3:.32s;--ov-dur-4:.52s}' |
| 51 | + `html:is(${CALM}) [data-fx-demo]{--ov-dur-1:.15s;--ov-dur-2:.25s;--ov-dur-3:.4s;--ov-dur-4:.62s}` |
| 52 | + `html:is(${SNAPPY}) [data-fx-demo]{--ov-dur-1:.09s;--ov-dur-2:.15s;--ov-dur-3:.24s;--ov-dur-4:.42s}` |
| 53 | + '.ovfx-glint{position:relative;overflow:hidden;isolation:isolate}' |
| 54 | + '.ovfx-glint::after{content:"";position:absolute;inset:-2px;pointer-events:none;background:linear-gradient(105deg,transparent 35%,rgba(255,255,255,.28) 50%,transparent 65%);transform:translateX(-120%);z-index:1}' |
| 55 | + '.ovfx-glint:hover::after,.ovfx-glint:focus-visible::after{transform:translateX(120%);transition:transform .75s var(--ov-ease-in-out)}' |
| 56 | + '.ovfx-glow{transition:box-shadow var(--ov-dur-3) var(--ov-ease-out)}' |
| 57 | + '.ovfx-glow:hover,.ovfx-glow:focus-visible{box-shadow:0 0 0 1px color-mix(in srgb,var(--accent,#4f7cff) 45%,transparent),0 10px 34px -10px color-mix(in srgb,var(--accent,#4f7cff) 70%,transparent)}' |
| 58 | + '.ovfx-lift{transition:transform var(--ov-dur-3) var(--ov-ease-spring),box-shadow var(--ov-dur-3) var(--ov-ease-out)}' |
| 59 | + '.ovfx-lift:hover{transform:translateY(calc(-3px * var(--ov-move,1)));box-shadow:0 12px 28px -14px rgba(0,0,0,.45)}' |
| 60 | + '.ovfx-press{transition:transform var(--ov-dur-4) var(--ov-ease-spring)}' |
| 61 | + '.ovfx-press:active{transform:scale(calc(1 - .04 * var(--ov-move,1)));transition-duration:var(--ov-dur-1)}' |
| 62 | + '.ovfx-lift.ovfx-press:active{transform:translateY(calc(-1px * var(--ov-move,1))) scale(calc(1 - .03 * var(--ov-move,1)))}' |
| 63 | + '.ovfx-ripple{position:relative;overflow:hidden;isolation:isolate}' |
| 64 | + '.ovfx-rip{position:absolute;border-radius:50%;pointer-events:none;background:currentColor;opacity:.22;transform:scale(0);animation:ovfxRip .6s var(--ov-ease-out) forwards}' |
| 65 | + '@keyframes ovfxRip{to{transform:scale(1);opacity:0}}' |
| 66 | + '.ovfx-tilt{transition:transform var(--ov-dur-3) var(--ov-ease-out);transform-style:preserve-3d;will-change:transform}' |
| 67 | // The reveal (278): a heading out of a blur, its rule drawing from the left, then its cards. The scroll timeline |
| 68 | // plays it where view() exists; the classes only carry the start state, so the page is never left blank by CSS. |
| 69 | + '.ovfx-head{--ovfx-rise:12px}.ovfx-card{--ovfx-card-rise:10px}' |
| 70 | + '@media (max-width:600px){.ovfx-head{--ovfx-rise:8px}.ovfx-card{--ovfx-card-rise:6px}}' |
| 71 | + '.ovfx-rule{transform-origin:left center}' |
| 72 | + '@keyframes ovfxHeadRise{from{opacity:0;transform:translateY(calc(var(--ovfx-rise,12px) * var(--ov-move,1)));filter:blur(4px)}to{opacity:1;transform:none;filter:blur(0)}}' |
| 73 | + '@keyframes ovfxCardRise{from{opacity:0;transform:translateY(calc(var(--ovfx-card-rise,10px) * var(--ov-move,1)))}to{opacity:1;transform:none}}' |
| 74 | + '@keyframes ovfxRuleDraw{from{transform:scaleX(0);opacity:.35}to{transform:scaleX(1);opacity:1}}' |
| 75 | + '@keyframes ovfxFadeIn{from{opacity:0}to{opacity:1}}' |
| 76 | + '@supports (animation-timeline:view()){.ovfx-head.ovfx-scroll{animation:ovfxHeadRise linear backwards;animation-timeline:view();animation-range:entry 0% cover 25%}.ovfx-rule.ovfx-scroll{animation:ovfxRuleDraw linear backwards;animation-timeline:view();animation-range:entry 5% cover 35%}.ovfx-card.ovfx-scroll{animation:ovfxCardRise linear backwards;animation-timeline:view();animation-range:entry calc(5% + var(--ovfx-i,0) * 3%) cover calc(30% + var(--ovfx-i,0) * 3%)}.ovfx-fade.ovfx-scroll{animation:ovfxFadeIn linear backwards;animation-timeline:view();animation-range:entry 0% cover 15%}}' |
| 77 | + '.ovfx-head.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-rise,12px));filter:blur(4px)}' |
| 78 | + '.ovfx-head.ovfx-in{opacity:1;transform:none;filter:blur(0);transition:opacity .5s var(--ov-ease-out),transform .55s var(--ov-ease-spring),filter .45s var(--ov-ease-out)}' |
| 79 | + '.ovfx-card.ovfx-wait{opacity:0;transform:translateY(var(--ovfx-card-rise,10px))}' |
| 80 | + '.ovfx-card.ovfx-in{opacity:1;transform:none;transition:opacity .45s var(--ov-ease-out),transform .55s var(--ov-ease-spring);transition-delay:var(--ovfx-d,0ms)}' |
| 81 | + '.ovfx-rule.ovfx-wait{transform:scaleX(0);opacity:.35}' |
| 82 | + '.ovfx-rule.ovfx-in{transform:scaleX(1);opacity:1;transition:transform .6s var(--ov-ease-out) 80ms,opacity .4s var(--ov-ease-out) 80ms}' |
| 83 | + '.ovfx-fade.ovfx-wait{opacity:0}' |
| 84 | + '.ovfx-fade.ovfx-in{opacity:1;transition:opacity .28s var(--ov-ease-out)}' |
| 85 | + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}' |
| 86 | + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}' |
| 87 | + 'html[data-ov-motion=reduced] :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}' |
| 88 | + '@media (prefers-reduced-motion:reduce){html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card,.ovfx-rule).ovfx-scroll:not([data-fx-demo] *){animation-name:ovfxFadeIn;animation-range:entry 0% cover 15%}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-wait:not([data-fx-demo] *){opacity:0;transform:none;filter:none}html:not([data-ov-motion]) :is(.ovfx-head,.ovfx-card).ovfx-in:not([data-fx-demo] *){opacity:1;transform:none;filter:none;transition:opacity .28s var(--ov-ease-out)}}' |
| 89 | + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade):not([data-fx-demo] *){animation:none!important;transition:none!important}' |
| 90 | + 'html[data-ov-motion=off] :is(.ovfx-head,.ovfx-card,.ovfx-rule,.ovfx-fade).ovfx-wait:not([data-fx-demo] *){opacity:1!important;transform:none!important;filter:none!important}' |
| 91 | + '.ovfx-shimmer{background:linear-gradient(100deg,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 30%,color-mix(in srgb,var(--text-muted,#8b93ad) 22%,transparent) 50%,color-mix(in srgb,var(--text-muted,#8b93ad) 12%,transparent) 70%) 0 0/300% 100%;animation:ovfxShim 1.4s linear infinite;color:transparent!important;border-radius:8px}' |
| 92 | + '@keyframes ovfxShim{from{background-position:100% 0}to{background-position:0 0}}' |
| 93 | + '.ovfx-confetti{position:fixed;inset:0;pointer-events:none;z-index:2147483000}' |
| 94 | // Calm: the sheen crosses fainter and slower, the ripple is fainter. Off: no sheen, no shimmer, nothing fades in. |
| 95 | + 'html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *)::after{opacity:.55}html[data-ov-motion=reduced] .ovfx-glint:not([data-fx-demo] *):is(:hover,:focus-visible)::after{transition-duration:1.1s}html[data-ov-motion=reduced] .ovfx-rip:not([data-fx-demo] *){opacity:.14}' |
| 96 | + 'html[data-ov-motion=off] .ovfx-glint:not([data-fx-demo] *)::after{display:none}html[data-ov-motion=off] .ovfx-shimmer:not([data-fx-demo] *){animation:none}html[data-ov-motion=off] :is(.ovfx-in,.ovfx-lift,.ovfx-press,.ovfx-tilt):not([data-fx-demo] *){transition:none!important}'; |
| 97 | |
| 98 | // The demo scope (4.180.0): inside [data-fx-demo] the effects play in full whatever the page's motion is. A page |
| 99 | // that exists to show motion sets it on one container, only after the person presses a button that says so. |
| 100 | const demo = (el) => !!(el && el.closest && el.closest('[data-fx-demo]')); |
| 101 | // How far things move here: 1 in full, CALM_MOVE under Calm, 0 when motion is off. <html data-ov-motion> is the signal |
| 102 | // (theme-loader.js writes it, the device's own setting counted in); a page without the loader asks the device. |
| 103 | const CALM_MOVE = 0.35; |
| 104 | const move = (el) => { |
| 105 | if (demo(el)) return 1; |
| 106 | const m = doc.documentElement.getAttribute('data-ov-motion'); |
| 107 | if (m === 'off') return 0; |
| 108 | if (m === 'reduced') return CALM_MOVE; |
| 109 | return !m && root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches ? CALM_MOVE : 1; |
| 110 | }; |
| 111 | const off = (el) => move(el) === 0; |
| 112 | const fine = () => !!(root.matchMedia && root.matchMedia('(hover: hover) and (pointer: fine)').matches); |
| 113 | const done = new WeakSet(); |
| 114 | let io = null; |
| 115 | |
| 116 | function css() { |
| 117 | if (doc.getElementById('ov-fx-css')) return; |
| 118 | const st = doc.createElement('style'); st.id = 'ov-fx-css'; st.textContent = CSS; (doc.head || doc.documentElement).appendChild(st); |
| 119 | } |
| 120 | |
| 121 | // ── Seen: reveal and count, once each, when they come on screen ── |
| 122 | function observe(el, fn) { |
| 123 | if (!('IntersectionObserver' in root)) return fn(el); |
| 124 | io = io || new root.IntersectionObserver((entries) => entries.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); (e.target.__ovfxSeen || fn)(e.target); } }), { rootMargin: '0px 0px -8% 0px' }); |
| 125 | el.__ovfxSeen = fn; |
| 126 | io.observe(el); |
| 127 | } |
| 128 | const below = (el) => { try { return el.getBoundingClientRect().top > (root.innerHeight || 0); } catch (e) { return false; } }; |
| 129 | function seenCount(el) { const c = el.__ovfxCount; if (c) { el.__ovfxCount = null; countUp(el, c); } } |
| 130 | |
| 131 | // ── Reveal (278): a heading, its rule and its cards, once. The scroll timeline (where view() exists) plays it |
| 132 | // without any script on the main thread; the shared observer is the fallback. Only elements the script knows are |
| 133 | // below the fold ever get a start state, and every path has a way back to the finished state. |
| 134 | const HEAD_SEL = 'h1,h2,h3,h4,h5,h6'; |
| 135 | const CARDISH = 'li,article,a,[data-fx-card],[class*="card"],[class*="tile"],[class*="item"]'; |
| 136 | function findHead(el) { |
| 137 | if (el.hasAttribute('data-fx-self')) return el; |
| 138 | const marked = el.querySelector('[data-fx-heading]'); |
| 139 | if (marked) return marked; |
| 140 | if (el.matches(HEAD_SEL)) return el; |
| 141 | for (const h of el.querySelectorAll(HEAD_SEL)) { |
| 142 | const card = h.closest(CARDISH); |
| 143 | if (card && card !== el && el.contains(card)) continue; // a card's own title is not the section's heading |
| 144 | let depth = 0, p = h; |
| 145 | while (p && p !== el) { p = p.parentElement; depth++; } |
| 146 | if (depth <= 3) return h; |
| 147 | } |
| 148 | return null; |
| 149 | } |
| 150 | function findRule(el, head) { |
| 151 | const marked = el.querySelector('[data-fx-rule]'); |
| 152 | if (marked && marked !== head) return marked; |
| 153 | const hr = el.querySelector('hr'); |
| 154 | return hr && hr !== head ? hr : null; |
| 155 | } |
| 156 | function findCards(el, head, rule) { |
| 157 | const sel = el.getAttribute('data-fx-cards'); |
| 158 | if (sel) { try { const list = Array.from(el.querySelectorAll(sel)); if (list.length) return list; } catch (e) { /* the default below */ } } |
| 159 | const marked = Array.from(el.querySelectorAll('[data-fx-card]')); |
| 160 | if (marked.length) return marked; |
| 161 | const out = []; |
| 162 | for (const k of Array.from(el.children)) { |
| 163 | if (k === head || k === rule || (head && k.contains(head)) || (rule && k.contains(rule))) continue; |
| 164 | const cls = typeof k.className === 'string' ? k.className : ''; |
| 165 | if (k.matches('ul,ol,[role="list"]') || /(^|[\s_-])(grid|cards|rail|row|list)([\s_-]|$)/i.test(cls)) { |
| 166 | const inner = Array.from(k.children); |
| 167 | if (inner.length) { out.push(...inner); continue; } |
| 168 | } |
| 169 | out.push(k); |
| 170 | } |
| 171 | return out; |
| 172 | } |
| 173 | let viewTimeline = null; |
| 174 | function hasViewTimeline() { |
| 175 | if (viewTimeline != null) return viewTimeline; |
| 176 | try { viewTimeline = !!(root.CSS && root.CSS.supports && root.CSS.supports('animation-timeline: view()')); } catch (e) { viewTimeline = false; } |
| 177 | return viewTimeline; |
| 178 | } |
| 179 | function showReveal(el) { |
| 180 | if (el.__ovfxShown) return; |
| 181 | el.__ovfxShown = true; |
| 182 | el.classList.add('ovfx-in'); |
| 183 | const delay = Number(String(el.style.getPropertyValue('--ovfx-d')).replace('ms', '')) || 0; |
| 184 | root.setTimeout(() => { el.classList.remove('ovfx-wait', 'ovfx-in'); el.style.removeProperty('--ovfx-d'); }, 900 + delay); |
| 185 | } |
| 186 | function wireReveal(el) { |
| 187 | if (off(el)) return; |
| 188 | const mode = demo(el) ? 'full' : move(el) < 1 ? 'fade' : 'full'; |
| 189 | const self = el.hasAttribute('data-fx-self'); |
| 190 | const head = findHead(el), rule = self ? null : findRule(el, head), cards = self ? [] : findCards(el, head, rule); |
| 191 | const parts = []; |
| 192 | if (head) parts.push([head, 'head', 0]); |
| 193 | if (rule) parts.push([rule, 'rule', 0]); |
| 194 | cards.forEach((k, i) => parts.push([k, 'card', Math.min(i, 5)])); |
| 195 | if (!parts.length) return; |
| 196 | // Never hide what someone can already see: a section below the fold is safe whole, one already on screen |
| 197 | // is checked part by part. |
| 198 | const live = below(el) ? parts : parts.filter(([k]) => below(k)); |
| 199 | if (!live.length) return; |
| 200 | const timeline = hasViewTimeline(); |
| 201 | if (!timeline && !('IntersectionObserver' in root)) return; |
| 202 | const gap = Math.max(0, Math.min(200, Number(el.getAttribute('data-fx-stagger')) || 40)); |
| 203 | const follow = head ? 140 : 0; |
| 204 | live.forEach(([k, kind, i]) => { |
| 205 | k.classList.add(mode === 'fade' ? 'ovfx-fade' : kind === 'head' ? 'ovfx-head' : kind === 'rule' ? 'ovfx-rule' : 'ovfx-card'); |
| 206 | if (timeline) { |
| 207 | k.classList.add('ovfx-scroll'); |
| 208 | if (kind === 'card') k.style.setProperty('--ovfx-i', String(i)); |
| 209 | } else { |
| 210 | k.classList.add('ovfx-wait'); |
| 211 | if (kind === 'card' && mode === 'full') k.style.setProperty('--ovfx-d', `${follow + i * gap}ms`); |
| 212 | observe(k, showReveal); |
| 213 | } |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | function countUp(el, c) { |
| 218 | const t0 = root.performance ? root.performance.now() : Date.now(), ms = Math.min(1600, 500 + Math.log10(Math.max(10, c.to)) * 220) * (move(el) < 1 ? 0.6 : 1); |
| 219 | const step = (now) => { |
| 220 | const p = Math.min(1, ((now || Date.now()) - t0) / ms), e = 1 - Math.pow(1 - p, 3); |
| 221 | el.textContent = p < 1 ? c.fmt(c.to * e) : c.text; |
| 222 | if (p < 1) root.requestAnimationFrame(step); |
| 223 | }; |
| 224 | root.requestAnimationFrame(step); |
| 225 | } |
| 226 | function countOf(el) { |
| 227 | const text = el.textContent; |
| 228 | const m = /^(\D*?)(\d(?:[\d.,\s ]*\d)?)(\D*)$/.exec(text.trim()); |
| 229 | if (!m) return null; |
| 230 | const digits = m[2].replace(/[\s ]/g, ''); |
| 231 | const dec = /[.,]\d{1,2}$/.test(digits) && !/^\d{1,3}([.,]\d{3})+$/.test(digits) ? digits.slice(-3).replace(/^\D/, '').length : 0; |
| 232 | const to = Number(dec ? digits.replace(/[.,](?=\d{3}(\D|$))/g, '').replace(',', '.') : digits.replace(/[.,]/g, '')); |
| 233 | if (!Number.isFinite(to) || to <= 0) return null; |
| 234 | const nf = new Intl.NumberFormat(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec }); |
| 235 | return { to, text, fmt: (v) => m[1] + nf.format(v) + m[3] }; |
| 236 | } |
| 237 | |
| 238 | // ── Pointer effects ── |
| 239 | function ripple(el, ev) { |
| 240 | if (off(el)) return; |
| 241 | const r = el.getBoundingClientRect(), d = Math.max(r.width, r.height) * 2.2; |
| 242 | const s = doc.createElement('span'); s.className = 'ovfx-rip'; |
| 243 | s.style.cssText = `width:${d}px;height:${d}px;left:${(ev.clientX || r.left + r.width / 2) - r.left - d / 2}px;top:${(ev.clientY || r.top + r.height / 2) - r.top - d / 2}px`; |
| 244 | el.appendChild(s); root.setTimeout(() => s.remove(), 650); |
| 245 | } |
| 246 | function tilt(el) { |
| 247 | let raf = 0; |
| 248 | el.addEventListener('pointermove', (ev) => { |
| 249 | if (ev.pointerType !== 'mouse' || off(el) || raf) return; |
| 250 | raf = root.requestAnimationFrame(() => { |
| 251 | raf = 0; |
| 252 | const r = el.getBoundingClientRect(), x = (ev.clientX - r.left) / r.width - 0.5, y = (ev.clientY - r.top) / r.height - 0.5; |
| 253 | const k = move(el); |
| 254 | el.style.transform = `perspective(900px) rotateX(${(-y * 6 * k).toFixed(2)}deg) rotateY(${(x * 7 * k).toFixed(2)}deg)`; |
| 255 | }); |
| 256 | }); |
| 257 | el.addEventListener('pointerleave', () => { if (raf) { root.cancelAnimationFrame(raf); raf = 0; } el.style.transform = ''; }); |
| 258 | } |
| 259 | |
| 260 | // ── Confetti: one canvas, about 1.6 s, then gone. Calm: a third of the pieces, thrown half as hard ── |
| 261 | function confetti(opts) { |
| 262 | const k = move(opts && opts.from); |
| 263 | if (!k || !doc.body) return false; |
| 264 | const o = opts || {}, cs = root.getComputedStyle(doc.documentElement); |
| 265 | const accent = (cs.getPropertyValue('--accent') || '').trim() || '#4f7cff'; |
| 266 | const colors = o.colors || [accent, '#4f7cff', '#8b5cf6', '#22c55e', '#facc15', '#f472b6']; |
| 267 | const cv = doc.createElement('canvas'); cv.className = 'ovfx-confetti'; cv.setAttribute('aria-hidden', 'true'); |
| 268 | const dpr = Math.min(2, root.devicePixelRatio || 1), W = root.innerWidth, H = root.innerHeight; |
| 269 | cv.width = W * dpr; cv.height = H * dpr; doc.body.appendChild(cv); |
| 270 | const ctx = cv.getContext('2d'); if (!ctx) { cv.remove(); return false; } |
| 271 | ctx.scale(dpr, dpr); |
| 272 | const x0 = o.x == null ? W / 2 : o.x, y0 = o.y == null ? H / 3 : o.y, n = Math.max(8, Math.round(Math.min(160, o.count || 80) * (k < 1 ? k : 1))), throwBy = k < 1 ? 0.55 : 1; |
| 273 | const ps = Array.from({ length: n }, (_, i) => { |
| 274 | const a = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9, v = (6 + Math.random() * 7) * throwBy; |
| 275 | return { x: x0, y: y0, vx: Math.cos(a) * v, vy: Math.sin(a) * v, r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.3, w: 5 + Math.random() * 5, h: 3 + Math.random() * 4, c: colors[i % colors.length], round: Math.random() < 0.3 }; |
| 276 | }); |
| 277 | const t0 = Date.now(); |
| 278 | const frame = () => { |
| 279 | const t = (Date.now() - t0) / 1600; |
| 280 | ctx.clearRect(0, 0, W, H); |
| 281 | ps.forEach((p) => { |
| 282 | p.vy += 0.28; p.vx *= 0.985; p.vy *= 0.985; p.x += p.vx; p.y += p.vy; p.r += p.vr; |
| 283 | ctx.save(); ctx.globalAlpha = Math.max(0, 1 - Math.max(0, t - 0.6) / 0.4); ctx.translate(p.x, p.y); ctx.rotate(p.r); ctx.fillStyle = p.c; |
| 284 | if (p.round) { ctx.beginPath(); ctx.arc(0, 0, p.h / 1.6, 0, Math.PI * 2); ctx.fill(); } else ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); |
| 285 | ctx.restore(); |
| 286 | }); |
| 287 | if (t < 1 && !doc.hidden) root.requestAnimationFrame(frame); else cv.remove(); |
| 288 | }; |
| 289 | root.requestAnimationFrame(frame); |
| 290 | return true; |
| 291 | } |
| 292 | |
| 293 | // ── Wiring ── |
| 294 | function wire(el) { |
| 295 | if (done.has(el)) return; |
| 296 | done.add(el); |
| 297 | const names = String(el.getAttribute('data-fx') || '').toLowerCase().split(/\s+/).filter(Boolean); |
| 298 | for (const n of names) { |
| 299 | if (n === 'glint' || n === 'glow' || n === 'lift' || n === 'press' || n === 'ripple' || n === 'shimmer') el.classList.add(`ovfx-${n}`); |
| 300 | if (n === 'ripple') el.addEventListener('pointerdown', (ev) => ripple(el, ev)); |
| 301 | if (n === 'tilt' && fine()) { el.classList.add('ovfx-tilt'); tilt(el); } |
| 302 | if (n === 'confetti') el.addEventListener('click', () => { const r = el.getBoundingClientRect(); confetti({ x: r.left + r.width / 2, y: r.top + r.height / 2, count: 70, from: el }); }); |
| 303 | if (n === 'reveal') wireReveal(el); |
| 304 | if (n === 'count' && !off(el)) { |
| 305 | const c = countOf(el); |
| 306 | if (c && below(el)) { el.__ovfxCount = c; observe(el, seenCount); } else if (c) { el.__ovfxCount = c; seenCount(el); } |
| 307 | } |
| 308 | } |
| 309 | } |
| 310 | function scan(scope) { |
| 311 | const base = scope && scope.querySelectorAll ? scope : doc; |
| 312 | if (base.matches && base.matches('[data-fx]')) wire(base); |
| 313 | base.querySelectorAll('[data-fx]').forEach(wire); |
| 314 | } |
| 315 | function init() { |
| 316 | css(); |
| 317 | scan(doc); |
| 318 | if ('MutationObserver' in root) new root.MutationObserver((rs) => rs.forEach((r) => r.addedNodes.forEach((n) => { if (n.nodeType === 1) scan(n); }))).observe(doc.documentElement, { childList: true, subtree: true }); |
| 319 | } |
| 320 | |
| 321 | root.OVFx = { scan, confetti, wire, move, FEEL: { CALM, SNAPPY } }; |
| 322 | if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', init); else init(); |
| 323 | })(typeof window !== 'undefined' ? window : this); |
| 324 |