openvibers/openvibe.shared motion.js

motion.js

main 9.1 KB · 140 lines · javascript

1/**
2 * openvibe-shared/motion.js — the network's motion, small and opt-in (plan 2026-10-28 §3.11): a page marks what moves and
3 * this makes it move, once, when it is seen. The server always renders the finished state (the real number, the whole
4 * scene, the plain placeholder), so a page is complete without this file, with motion off (html[data-ov-motion=off])
5 * and for crawlers. Calm (html[data-ov-motion=reduced]: the person's choice, or the device asking for less motion)
6 * plays all of it, gentler: a reveal rises a third as far, a number counts up quicker, the ghost cursor glides slower
7 * and waits longer.
8 *
9 * [data-ov-reveal] fades and rises in the first time it scrolls into view (html.ov-motion hides it until then)
10 * [data-ov-ticker="1204"] counts up to its number when seen (the text the server wrote is the end state)
11 * input[data-ov-typed="a|b|c"] types each hint into the placeholder in turn, erases it, moves on; stops for good
12 * once the box is focused or has text
13 * [data-ov-ghost] a scene's ghost cursor (4.106.0, MOTION-1): an arrow walks to each [data-ov-ghost-step="N"]
14 * inside it in order, "clicks" (a ring that widens and fades) and marks that step
15 * data-ov-ghost-on (the page draws what "chosen" looks like), dwells, moves on, and starts
16 * over after the last. Only while the scene is in view; never under reduced motion; gone for
17 * good the moment the reader touches, clicks, types or focuses inside the scene. The
18 * cursor is aria-hidden and takes no focus or pointer events.
19 *
20 * Runs by itself when it loads; OpenVibeMotion.enhance(container) again for markup added later. Idempotent.
21 */
22(function (root) {
23 'use strict';
24 if (typeof document === 'undefined') return;
25 const html = document.documentElement;
26 // 'full' | 'calm' | 'off': <html data-ov-motion> is the signal (theme-loader.js); without the loader, ask the device.
27 const motion = () => { const m = html.getAttribute('data-ov-motion'); return m === 'off' ? 'off' : m === 'reduced' || (!m && typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ? 'calm' : 'full'; };
28 const still = () => motion() === 'off';
29 const seen = typeof IntersectionObserver === 'function'
30 ? (el, fn, margin) => { const io = new IntersectionObserver((es) => es.forEach((e) => { if (e.isIntersecting) { io.unobserve(e.target); fn(e.target); } }), { rootMargin: margin || '0px 0px -8% 0px' }); io.observe(el); }
31 : (el, fn) => fn(el);
32
33 function tick(el) {
34 const end = Number(el.getAttribute('data-ov-ticker'));
35 const text = el.textContent;
36 if (!Number.isFinite(end) || end <= 0 || still()) return;
37 const t0 = performance.now(), dur = Math.min(1400, 500 + Math.log10(end + 1) * 220) * (motion() === 'calm' ? 0.6 : 1);
38 const step = (t) => {
39 const k = Math.min(1, (t - t0) / dur), e = 1 - Math.pow(1 - k, 3);
40 el.textContent = k < 1 ? Math.round(end * e).toLocaleString('en-US') : text;
41 if (k < 1) requestAnimationFrame(step);
42 };
43 requestAnimationFrame(step);
44 }
45
46 function typed(input) {
47 const hints = String(input.getAttribute('data-ov-typed') || '').split('|').map((s) => s.trim()).filter(Boolean);
48 const base = input.getAttribute('placeholder') || '';
49 if (!hints.length || still()) return;
50 let i = 0, n = 0, dir = 1, timer = 0, stopped = false;
51 const stop = () => { stopped = true; clearTimeout(timer); input.setAttribute('placeholder', base); };
52 input.addEventListener('focus', stop, { once: true });
53 input.addEventListener('input', stop, { once: true });
54 const run = () => {
55 if (stopped || input.value) return stop();
56 const h = hints[i];
57 n += dir;
58 input.setAttribute('placeholder', h.slice(0, n));
59 let wait = dir > 0 ? 45 + Math.random() * 40 : 22;
60 if (dir > 0 && n >= h.length) { dir = -1; wait = 1700; } else if (dir < 0 && n <= 0) { dir = 1; i = (i + 1) % hints.length; wait = 380; }
61 timer = setTimeout(run, wait);
62 };
63 timer = setTimeout(run, 900);
64 }
65
66 const GHOST_CSS = `
67.ov-ghost-host{position:relative}
68.ov-ghost{position:absolute;left:0;top:0;z-index:5;width:22px;height:22px;pointer-events:none;opacity:0;transition:transform .55s cubic-bezier(.3,.8,.3,1),opacity .3s ease;filter:drop-shadow(0 2px 4px rgba(0,0,0,.35))}
69.ov-ghost.is-on{opacity:1}
70html[data-ov-motion=reduced] .ov-ghost{transition-duration:.9s,.3s}
71.ov-ghost svg{display:block;width:22px;height:22px}
72.ov-ghost-ring{position:absolute;left:-9px;top:-9px;width:18px;height:18px;border-radius:50%;border:2px solid var(--room-accent,var(--accent,#3b82f6));opacity:0}
73.ov-ghost.is-click .ov-ghost-ring{animation:ovGhostRing .5s ease-out}
74@keyframes ovGhostRing{from{opacity:.9;transform:scale(.4)}to{opacity:0;transform:scale(1.8)}}`;
75 const GHOST_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3l14 8.2-6.1 1.3 3.6 6.8-2.6 1.4-3.6-6.8L5 18.6z" fill="#fff" stroke="#111" stroke-width="1.4" stroke-linejoin="round"/></svg><span class="ov-ghost-ring"></span>';
76
77 function ghost(scene) {
78 if (still()) return;
79 const steps = Array.prototype.slice.call(scene.querySelectorAll('[data-ov-ghost-step]'))
80 .sort((a, b) => Number(a.getAttribute('data-ov-ghost-step')) - Number(b.getAttribute('data-ov-ghost-step')));
81 if (!steps.length) return;
82 if (!document.getElementById('ov-ghost-css')) { const st = document.createElement('style'); st.id = 'ov-ghost-css'; st.textContent = GHOST_CSS; document.head.appendChild(st); }
83 if (getComputedStyle(scene).position === 'static') scene.classList.add('ov-ghost-host');
84 const cur = document.createElement('span');
85 cur.className = 'ov-ghost';
86 cur.setAttribute('aria-hidden', 'true');
87 cur.innerHTML = GHOST_SVG;
88 scene.appendChild(cur);
89 let i = 0, timer = 0, visible = false, gone = false;
90 const slow = () => (motion() === 'calm' ? 1.5 : 1);
91 const mark = (el) => { steps.forEach((x) => x.removeAttribute('data-ov-ghost-on')); if (el) el.setAttribute('data-ov-ghost-on', ''); };
92 const place = (el) => {
93 const a = scene.getBoundingClientRect(), b = el.getBoundingClientRect();
94 // Toward the left of the step (where a radio or an icon usually is), so the arrow does not cover its words.
95 const x = b.left - a.left + Math.min(b.width * 0.18, 26), y = b.top - a.top + b.height * 0.6;
96 cur.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
97 };
98 const step = () => {
99 if (gone || !visible) return;
100 const el = steps[i];
101 place(el);
102 cur.classList.add('is-on');
103 timer = setTimeout(() => {
104 if (gone) return;
105 cur.classList.remove('is-click'); void cur.offsetWidth; cur.classList.add('is-click');
106 mark(el);
107 i = (i + 1) % steps.length;
108 timer = setTimeout(step, (i === 0 ? 2600 : 1300) * slow());
109 }, 650 * slow());
110 };
111 const stop = () => {
112 if (gone) return;
113 gone = true; clearTimeout(timer); mark(null); cur.remove();
114 ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.removeEventListener(ev, stop, true));
115 };
116 ['pointerdown', 'keydown', 'focusin', 'wheel', 'touchstart'].forEach((ev) => scene.addEventListener(ev, stop, { capture: true, passive: true }));
117 if (typeof IntersectionObserver === 'function') {
118 new IntersectionObserver((es) => es.forEach((e) => {
119 const was = visible; visible = e.isIntersecting;
120 if (visible && !was) { clearTimeout(timer); timer = setTimeout(step, 500); } else if (!visible) { clearTimeout(timer); cur.classList.remove('is-on'); }
121 }), { threshold: 0.4 }).observe(scene);
122 } else { visible = true; timer = setTimeout(step, 500); }
123 }
124
125 function enhance(scope) {
126 const box = scope || document;
127 const all = (sel) => Array.prototype.slice.call(box.querySelectorAll(sel));
128 const once = (el, key) => { if (el.dataset[key]) return false; el.dataset[key] = '1'; return true; };
129 all('[data-ov-reveal]').forEach((el) => { if (once(el, 'ovReveal')) { if (still()) el.classList.add('is-in'); else seen(el, (x) => x.classList.add('is-in')); } });
130 all('[data-ov-ticker]').forEach((el) => { if (once(el, 'ovTicker')) seen(el, tick, '0px'); });
131 all('input[data-ov-typed]').forEach((el) => { if (once(el, 'ovTyped')) typed(el); });
132 all('[data-ov-ghost]').forEach((el) => { if (once(el, 'ovGhost')) ghost(el); });
133 }
134
135 html.classList.add('ov-motion');
136 root.OpenVibeMotion = { enhance };
137 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document));
138 else enhance(document);
139})(typeof globalThis !== 'undefined' ? globalThis : this);
140