// Laboratorio del giroscopio (solo para desarrollo; se borrará junto con /gyrolab.js del servidor y su botón en Ajustes). // Graba a la vez las tres fuentes: la orientación que calcula el sistema (deviceorientation), el giroscopio // (rotationRate) y el acelerómetro con y sin gravedad (devicemotion), siguiendo un guion de situaciones. // Cada paso se sube a /api/gyrolab en cuanto termina. const STEPS = [ { id: 'mesa', name: 'Apoyado en la mesa', say: 'Déjalo sobre la mesa con la pantalla hacia arriba y no lo toques.', secs: 10, lead: 5 }, { id: 'quieto', name: 'En la mano, quieto', say: 'Sostenlo como cuando lees, sin moverlo a propósito.', secs: 10, lead: 3 }, { id: 'lento', name: 'Inclinar despacio', say: 'Inclínalo despacio hacia ti y sostén 2 s. Luego hacia un lado y sostén. Luego vuelve.', secs: 14, lead: 3 }, { id: 'rapido', name: 'Inclinar rápido', say: 'Inclínalo rápido a un lado y vuelve, varias veces, a los dos lados.', secs: 10, lead: 3 }, { id: 'vertical', name: 'Casi vertical', say: 'Sostenlo casi vertical frente a ti, como leyendo de pie, e inclínalo un poco a los lados.', secs: 12, lead: 3 }, { id: 'acostado', name: 'Acostado', say: 'Recuéstate o acuéstate como para leer y, a la mitad, cambia de postura una vez.', secs: 15, lead: 5 }, { id: 'girar', name: 'Girar la pantalla', say: 'Gíralo a horizontal, espera 2 s y vuelve a vertical. Quita antes el bloqueo de rotación.', secs: 12, lead: 3 }, { id: 'caminar', name: 'Caminando', say: 'Camina unos pasos sosteniéndolo como para leer.', secs: 12, lead: 3 }, ] const KEEP_S = 5 // lo que enseña la gráfica en vivo const COLORS = ['#ff7a8a', '#6fb3ff', '#8be28b'] const CSS = ` .glab { max-width: 760px; margin: 0 auto; display: grid; gap: 14px; } .glab h1 { margin: 0; } .glab .gtop { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .glab .gtop input { flex: 1; min-width: 140px; } .glab .glive { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; font: 12px/1.45 ui-monospace, Menlo, monospace; } .glab .glive div { background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); padding: 8px 10px; } .glab .glive b { font-family: system-ui, sans-serif; font-size: 13px; display: block; } .glab canvas { width: 100%; height: 240px; background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); } .glab ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } .glab li { display: flex; gap: 12px; align-items: center; background: var(--surface); border: 1px solid var(--surface-2); border-radius: var(--radius); padding: 10px 12px; } .glab li .gtxt { flex: 1; min-width: 0; } .glab li .gtxt small { display: block; color: var(--muted); } .glab li.done { border-color: color-mix(in srgb, var(--accent) 55%, transparent); } .glab li .gok { color: var(--accent); font-weight: 600; white-space: nowrap; } .gover { position: fixed; inset: 0; z-index: 1000; background: rgba(10, 9, 14, .94); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 24px; text-align: center; } .gover .gbig { font-size: clamp(48px, 18vw, 120px); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } .gover .gsay { font-size: 1.15rem; max-width: 34em; } .gover .gbar { width: min(420px, 80vw); height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; } .gover .gbar i { display: block; height: 100%; width: 0; background: var(--accent); } ` const r3 = v => (v == null || !Number.isFinite(v) ? null : Math.round(v * 1000) / 1000) const fmt = v => (v == null ? ' — ' : (v >= 0 ? ' ' : '') + v.toFixed(2)) const guessDevice = () => { const ua = navigator.userAgent if (/iPad|Macintosh/.test(ua) && navigator.maxTouchPoints > 1) return 'iPad' if (/iPhone/.test(ua)) return 'iPhone' const m = /Android [^;]*; ([^;)]+)/.exec(ua) return m ? m[1].trim() : 'otro' } const load = () => { try { return JSON.parse(localStorage.getItem('gyrolab')) || null } catch { return null } } const save = s => { try { localStorage.setItem('gyrolab', JSON.stringify(s)) } catch {} } const newSession = () => ({ session: Date.now().toString(36), done: [] }) export function mountGyroLab(app, { esc, toast }) { const style = document.createElement('style') style.textContent = CSS document.head.append(style) let state = load() || newSession() let device = localStorage.getItem('gyrolabDevice') || guessDevice() let listening = false, audio = null, wake = null, raf = 0, rec = null, timers = [], pending = null, dead = false let interval = null, permission = 'sin pedir', lastOri = null, lastMot = null const hist = { ori: [], mot: [], frames: [] } // lecturas recientes, para la gráfica y los Hz app.innerHTML = `

Laboratorio del giroscopio

Solo para desarrollo. Graba la orientación del sistema, el giroscopio y el acelerómetro mientras haces cada paso. Cada grabación se sube sola al servidor. Haz los pasos en el orden que quieras y repite los que salgan mal.

Toca «Activar sensores» para empezar.

    ` const $ = s => app.querySelector(s) const msg = t => { $('#gmsg').textContent = t } function renderSteps() { $('#gsess').textContent = `Sesión ${state.session} · ${state.done.length} de ${STEPS.length} pasos guardados` $('#gsteps').innerHTML = STEPS.map(s => { const done = state.done.includes(s.id) return `
  1. ${esc(s.name)}${esc(s.say)} (${s.secs} s)
    ${done ? '✓ Guardado' : ''}
  2. ` }).join('') } renderSteps() $('#gsteps').addEventListener('click', e => { const b = e.target.closest('[data-step]') if (b && !rec) start(STEPS.find(s => s.id === b.dataset.step)) }) $('#gdev').addEventListener('change', e => { device = e.target.value.trim() || guessDevice(); localStorage.setItem('gyrolabDevice', device) }) $('#gnew').addEventListener('click', () => { state = newSession(); save(state); renderSteps(); toast('Sesión nueva') }) // ---- Sensores ---- const now = e => (e.timeStamp > 0 ? e.timeStamp : performance.now()) function onOri(e) { const t = now(e) lastOri = { a: e.alpha, b: e.beta, g: e.gamma, abs: e.absolute } hist.ori.push([t, e.alpha, e.beta, e.gamma]) if (rec?.on) rec.ori.push([r3(t - rec.t0), r3(e.alpha), r3(e.beta), r3(e.gamma)]) } function onMot(e) { const t = now(e), r = e.rotationRate || {}, g = e.accelerationIncludingGravity || {}, a = e.acceleration || {} if (e.interval != null) interval = e.interval lastMot = { r, g, a } hist.mot.push([t, r.alpha, r.beta, r.gamma, g.x, g.y, g.z, a.x, a.y, a.z]) if (rec?.on) rec.mot.push([r3(t - rec.t0), r3(r.alpha), r3(r.beta), r3(r.gamma), r3(g.x), r3(g.y), r3(g.z), r3(a.x), r3(a.y), r3(a.z)]) } const screenNow = () => [screen.orientation?.angle ?? window.orientation ?? 0, innerWidth, innerHeight] function onScreen() { if (rec?.on) rec.screen.push([r3(performance.now() - rec.t0), ...screenNow()]) } function listen(on) { if (on === listening) return listening = on const m = on ? 'addEventListener' : 'removeEventListener' window[m]('deviceorientation', onOri) window[m]('devicemotion', onMot) window[m]('resize', onScreen) screen.orientation?.[m]('change', onScreen) } async function keepAwake() { try { if (!wake && navigator.wakeLock && document.visibilityState === 'visible') { wake = await navigator.wakeLock.request('screen'); wake.addEventListener('release', () => { wake = null }) } } catch {} } $('#gperm').addEventListener('click', async () => { try { audio ||= new (window.AudioContext || window.webkitAudioContext)(); audio.resume() } catch {} if (!isSecureContext) return msg('Los sensores solo funcionan por HTTPS.') for (const C of [window.DeviceMotionEvent, window.DeviceOrientationEvent]) { if (!C?.requestPermission) continue permission = await C.requestPermission().catch(err => `error: ${err?.message || err}`) if (permission !== 'granted') return msg(`El navegador no dio permiso para los sensores (${permission}). En iOS: Ajustes → Safari → Movimiento y orientación.`) } if (permission === 'sin pedir') permission = 'no hace falta' listen(true) keepAwake() $('#gperm').hidden = true renderSteps() msg('Sensores activos. Escoge un paso y toca «Grabar».') timers.push(setTimeout(() => { if (!hist.ori.length && !hist.mot.length) msg('No llega ninguna lectura de los sensores. ¿Este dispositivo tiene giroscopio?') else if (!hist.ori.length) msg('Llega el giroscopio pero no la orientación del sistema.') else if (!hist.mot.length) msg('Llega la orientación pero no el giroscopio ni el acelerómetro.') }, 1500)) }) const onVis = () => { if (document.visibilityState === 'visible') { if (listening) keepAwake() } else if (rec) cancel('Se canceló porque la página dejó de verse.') } document.addEventListener('visibilitychange', onVis) // ---- Avisos sonoros: sirven con el dispositivo sobre la mesa o caminando ---- function beep(freq = 880, ms = 120, at = 0) { try { if (!audio) return const o = audio.createOscillator(), g = audio.createGain(), t = audio.currentTime + at o.frequency.value = freq g.gain.setValueAtTime(0.0001, t) g.gain.exponentialRampToValueAtTime(0.25, t + 0.01) g.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000) o.connect(g).connect(audio.destination) o.start(t) o.stop(t + ms / 1000 + 0.02) } catch {} } const buzz = p => { try { navigator.vibrate?.(p) } catch {} } // ---- Grabar un paso ---- let over = null function overlay(step) { over = document.createElement('div') over.className = 'gover' over.innerHTML = `
    ${esc(step.name)}
    ${esc(step.say)}
    ` document.body.append(over) over.querySelector('#gcancel').addEventListener('click', () => cancel('Grabación cancelada.')) } function start(step) { overlay(step) rec = { step, on: false } const big = over.querySelector('.gbig') let left = step.lead big.textContent = left beep(660, 80) const tick = () => { if (!rec) return left-- if (left > 0) { big.textContent = left; beep(660, 80); timers.push(setTimeout(tick, 1000)); return } big.textContent = 'Grabando' beep(1040, 220) buzz(80) Object.assign(rec, { on: true, t0: performance.now(), ori: [], mot: [], frames: [], screen: [[0, ...screenNow()]], startedAt: Date.now() }) const bar = over.querySelector('.gbar i'), end = rec.t0 + step.secs * 1000 const show = () => { if (!rec?.on) return const p = Math.min(1, (performance.now() - rec.t0) / (step.secs * 1000)) bar.style.width = `${p * 100}%` big.textContent = Math.max(0, Math.ceil((end - performance.now()) / 1000)) if (p >= 1) finish() else timers.push(setTimeout(show, 100)) } show() } timers.push(setTimeout(tick, 1000)) } function closeOverlay() { over?.remove(); over = null } function cancel(text) { rec = null; closeOverlay(); msg(text) } async function finish() { const r = rec rec = null beep(880, 120); beep(880, 120, 0.2); buzz([60, 60, 60]) closeOverlay() const body = { v: 1, session: state.session, step: r.step.id, device, secs: r.step.secs, startedAt: r.startedAt, ua: navigator.userAgent, dpr: devicePixelRatio, screenSize: [screen.width, screen.height], interval, permission, absolute: lastOri?.abs ?? null, cols: { ori: ['t', 'alpha', 'beta', 'gamma'], mot: ['t', 'rrAlpha', 'rrBeta', 'rrGamma', 'gx', 'gy', 'gz', 'ax', 'ay', 'az'], screen: ['t', 'angle', 'w', 'h'], frames: ['t'] }, ori: r.ori, mot: r.mot, screen: r.screen, frames: r.frames, } msg(`Subiendo «${r.step.name}»: ${r.ori.length} lecturas de orientación y ${r.mot.length} de movimiento…`) await upload(body, r.step) } async function upload(body, step) { try { const res = await fetch('/api/gyrolab', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) const data = await res.json().catch(() => ({})) if (!res.ok) throw new Error(data.error || `Error ${res.status}`) pending = null if (!state.done.includes(step.id)) state.done.push(step.id) save(state) if (dead) return renderSteps() msg(`«${step.name}» guardado (${body.ori.length} + ${body.mot.length} lecturas).`) } catch (err) { pending = { body, step } if (dead) return msg(`No se pudo subir «${step.name}»: ${err.message}. `) const b = document.createElement('button') b.className = 'btn small' b.textContent = 'Reintentar' b.addEventListener('click', () => pending && upload(pending.body, pending.step)) $('#gmsg').append(b) } } // ---- Vista en vivo ---- const canvas = $('#gplot'), ctx = canvas.getContext('2d') const STRIPS = [ { name: 'Orientación del sistema β γ (°)', src: 'ori', cols: [2, 3], range: 100 }, { name: 'Giroscopio α β γ (°/s)', src: 'mot', cols: [1, 2, 3], range: 200 }, { name: 'Acelerómetro con gravedad x y z (m/s²)', src: 'mot', cols: [4, 5, 6], range: 12 }, ] const hz = list => { const n = list.length; return n > 1 ? ((n - 1) * 1000) / (list[n - 1][0] - list[0][0]) : 0 } let liveAt = 0 function frame(t) { raf = requestAnimationFrame(frame) hist.frames.push([t]) if (rec?.on) rec.frames.push(r3(t - rec.t0)) const cut = t - KEEP_S * 1000 for (const k of ['ori', 'mot', 'frames']) { const l = hist[k]; let i = 0; while (i < l.length && l[i][0] < cut) i++; if (i) l.splice(0, i) } if (rec?.on) return // grabando: no se dibuja nada que no haga falta const w = Math.round(canvas.clientWidth * devicePixelRatio), h = Math.round(canvas.clientHeight * devicePixelRatio) if (canvas.width !== w || canvas.height !== h) Object.assign(canvas, { width: w, height: h }) ctx.clearRect(0, 0, w, h) const sh = h / STRIPS.length, d = devicePixelRatio ctx.font = `${11 * d}px system-ui, sans-serif` STRIPS.forEach((s, i) => { const y0 = i * sh, mid = y0 + sh / 2 ctx.strokeStyle = 'rgba(255,255,255,.08)' ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke() ctx.fillStyle = 'rgba(255,255,255,.55)' ctx.fillText(s.name, 6 * d, y0 + 14 * d) s.cols.forEach((c, j) => { ctx.strokeStyle = COLORS[j] ctx.lineWidth = 1.5 * d ctx.beginPath() let first = true for (const row of hist[s.src]) { const v = row[c] if (v == null) continue const x = ((row[0] - cut) / (KEEP_S * 1000)) * w, y = mid - (Math.max(-s.range, Math.min(s.range, v)) / s.range) * (sh / 2 - 4 * d) first ? ctx.moveTo(x, y) : ctx.lineTo(x, y) first = false } ctx.stroke() }) }) if (t - liveAt < 150) return liveAt = t const o = lastOri, m = lastMot $('#glive').innerHTML = `
    Orientación · ${hz(hist.ori).toFixed(0)} Hzα ${fmt(o?.a)}
    β ${fmt(o?.b)}
    γ ${fmt(o?.g)}
    Giroscopio · ${hz(hist.mot).toFixed(0)} Hzα ${fmt(m?.r.alpha)}
    β ${fmt(m?.r.beta)}
    γ ${fmt(m?.r.gamma)}
    Acelerómetro con gravedadx ${fmt(m?.g.x)}
    y ${fmt(m?.g.y)}
    z ${fmt(m?.g.z)}
    Sin gravedadx ${fmt(m?.a.x)}
    y ${fmt(m?.a.y)}
    z ${fmt(m?.a.z)}
    Pantalla · ${hz(hist.frames).toFixed(0)} Hzgiro ${screenNow()[0]}°
    ${innerWidth}×${innerHeight}
    intervalo ${interval ?? '—'} ms
    ` } raf = requestAnimationFrame(frame) return () => { dead = true cancelAnimationFrame(raf) timers.forEach(clearTimeout) rec = null closeOverlay() listen(false) document.removeEventListener('visibilitychange', onVis) try { wake?.release() } catch {} try { audio?.close() } catch {} style.remove() } }