diff --git a/app/gyrolab.js b/app/gyrolab.js
new file mode 100644
index 0000000..3b9b3a5
--- /dev/null
+++ b/app/gyrolab.js
@@ -0,0 +1,37 @@
+// Laboratorio del giroscopio (solo para desarrollo; se borrará junto con public/gyrolab.js y su botón en Ajustes).
+// Guarda las grabaciones de los sensores en DATA_DIR/gyrolab/, una por paso del guion.
+import fs from 'node:fs'
+import path from 'node:path'
+
+const MAX_BYTES = 4 * 1024 * 1024
+const SESSION = /^[a-z0-9]{4,24}$/, STEP = /^[a-z0-9-]{2,24}$/
+
+export function setupGyroLab({ DATA_DIR, HttpError, json }) {
+ const dir = path.join(DATA_DIR, 'gyrolab')
+ async function readRaw(req) {
+ let size = 0
+ const chunks = []
+ for await (const c of req) {
+ size += c.length
+ if (size > MAX_BYTES) throw new HttpError(413, 'Grabación demasiado grande')
+ chunks.push(c)
+ }
+ try { return JSON.parse(Buffer.concat(chunks).toString()) } catch { throw new HttpError(400, 'JSON inválido') }
+ }
+ async function route(p, method, req, res, user) {
+ if (p !== '/api/gyrolab') return false
+ if (method === 'GET') {
+ const mine = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.startsWith(`u${user.id}-`)) : []
+ return json(res, 200, { files: mine.sort() })
+ }
+ if (method !== 'POST') throw new HttpError(405, 'Método no permitido')
+ const body = await readRaw(req)
+ if (!SESSION.test(body?.session || '') || !STEP.test(body?.step || '')) throw new HttpError(400, 'Sesión o paso no válidos')
+ if (!Array.isArray(body.ori) || !Array.isArray(body.mot)) throw new HttpError(400, 'Faltan lecturas')
+ fs.mkdirSync(dir, { recursive: true })
+ const name = `u${user.id}-${body.session}-${body.step}.json`
+ fs.writeFileSync(path.join(dir, name), JSON.stringify({ ...body, user: user.username, savedAt: Date.now() }))
+ return json(res, 200, { ok: true, file: name })
+ }
+ return { route }
+}
diff --git a/app/public/app.js b/app/public/app.js
index 7b1ec7e..533adc1 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -395,6 +395,7 @@ async function router() {
if (parts[0] === 'stats') return await viewStats()
if (parts[0] === 'clips') return await viewClips(params)
if (parts[0] === 'settings') return viewSettings()
+ if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo
return await viewHome(params)
} catch (e) {
if (e.message !== 'No autenticado') app.innerHTML = `
${esc(e.message)}
`
@@ -3075,6 +3076,7 @@ async function viewReader(mangaId, chapterId, startPage) {
const fromShelf = shelfTrip?.mangaId === mangaId // desde el estante se lee en 3D aunque el ajuste diga otra cosa (sin guardarlo)
const b3dWanted = () => B3D_OK && (!!settings.book3d || fromShelf) && isPaged() && settings.fit !== 'width'
const b3dOn = () => b3dWanted() && !atEnd
+ if (fromShelf) document.body.dataset.handoff = b3dWanted() ? '3d' : 'flat' // la foto del estante espera al libro 3D, o no (handoffCover)
const b3dPaper = () => {
const hex = book ? (BOOK_PAPERS[bookSettings().paper] || BOOK_PAPERS.light).bg : '#efeadf'
return [1, 3, 5].map(n => parseInt(hex.slice(n, n + 2), 16) / 255)
@@ -3833,15 +3835,21 @@ function handoffCover(src) {
document.body.append(img)
document.body.classList.add('handing')
const t0 = performance.now()
- let mounted = 0 // cuándo apareció el lector: si no va a haber libro 3D (modo vertical, error), no se espera más
- const fade = () => { document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
+ let mounted = 0 // cuándo se supo que el lector se monta sin libro 3D
+ delete document.body.dataset.handoff // lo dice el lector al montarse: '3d' o 'flat'
+ const fade = why => { img.dataset.why = why; delete document.body.dataset.handoff; document.body.classList.remove('handing'); img.classList.add('out'); setTimeout(() => img.remove(), 450) }
const wait = () => {
if (!img.isConnected) return document.body.classList.remove('handing')
const ready = document.querySelector('#b3d:not([hidden]) canvas')
- if (ready) return setTimeout(fade, 350) // las texturas de la tapa tardan un instante
- if (!mounted && document.querySelector('.reader .rbar')) mounted = performance.now()
- const failed = !document.querySelector('.reader') && performance.now() - t0 > 400 // el lector no llegó a montarse (error)
- if (failed || (mounted && performance.now() - mounted > 1500) || performance.now() - t0 > 30000 || !location.hash.startsWith('#/read/')) return fade()
+ if (ready) return setTimeout(() => fade('3d'), 350) // las texturas de la tapa tardan un instante
+ // Montado sin libro 3D (modo vertical, ajuste a lo ancho): ya no habrá lienzo que esperar. Con él, se espera a que dibuje.
+ if (!mounted && document.body.dataset.handoff === 'flat') mounted = performance.now()
+ // El lector no llegó a montarse (un error en su lugar). Mientras siga el estante, la ruta aún no ha cambiado de vista.
+ const failed = !document.querySelector('.reader, #shelf3d') && performance.now() - t0 > 400
+ if (failed) return fade('error')
+ if (mounted && performance.now() - mounted > 600) return fade('flat')
+ if (performance.now() - t0 > 90000) return fade('tarde') // por si el lector se queda colgado: mientras carga, la foto sigue
+ if (!location.hash.startsWith('#/read/')) return fade('ruta')
setTimeout(wait, 60)
}
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
@@ -3913,15 +3921,19 @@ async function viewShelf(params) {
const showPeek = info => {
if (!info) { card.classList.remove('on'); clearTimeout(card.hideTimer); card.hideTimer = setTimeout(() => { if (!card.classList.contains('on')) card.hidden = true }, 250); return }
const { work, vol, state } = info, n = vol.chapters?.length || 0, at = n && work.chapterId ? vol.chapters.indexOf(work.chapterId) : -1
- const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : '', vol.pages ? `${vol.pages} páginas` : ''].filter(Boolean).join(' · ')
- const pct = state === 'read' ? 100 : at >= 0 ? Math.round((at / n) * 100) : 0
- const where = state === 'read' ? 'Leído' : state === 'reading' ? (at >= 0 ? `Vas por el capítulo ${at + 1} de ${n} de este tomo` : 'Lo estás leyendo') : 'Sin empezar'
+ const facts = [vol.vol ? `Tomo ${esc(vol.vol)}` : '', n ? `${n} ${n === 1 ? 'capítulo' : 'capítulos'}` : ''].filter(Boolean).join(' · ')
+ // Por dónde va, escrito: el capítulo de la obra (si se sabe su número) y la página.
+ const page = work.page > 0 ? `, página ${work.page + 1}` : ''
+ const where = state === 'read' ? 'Ya leído'
+ : state === 'reading' || state === 'unknown' ? (work.chapterNum != null && work.chapterNum !== '' ? `Vas por el capítulo ${esc(work.chapterNum)}${page}` : at >= 0 ? `Vas por el capítulo ${at + 1} de este tomo${page}` : 'Lo estás leyendo')
+ : 'Sin empezar'
const href = readOf(info), st = shelfMod.noteStyle(work.id)
card.style.cssText = `--np:${st.paper};--ni:${st.ink};--nf:"${st.family}";--nw:${st.weight};--ns:${st.scale};--nt:${(st.tilt * -18).toFixed(2)}deg`
+ // Un pósit de verdad: sin botones. Se lee tocando el tomo (o esta línea, que lleva al mismo sitio).
card.innerHTML = `
-
'}`
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
clearTimeout(card.hideTimer)
@@ -3978,7 +3990,7 @@ async function viewShelf(params) {
const had = new Map(works.map(w => [w.id, w]))
const next = fresh.works.map(w => (had.get(w.id)?.vols ? { ...w, vols: had.get(w.id).vols } : w))
if (!next.length) { shelf?.destroy(); shelf = null; works = []; store.set(shelfSnapKey(), null); return say('Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.') }
- const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.page === w.page && o.last === w.last })
+ const sameList = shelf && next.length === works.length && next.every((w, i) => { const o = had.get(w.id); return o && o.title === w.title && o.cover === w.cover && o.chapterId === w.chapterId && o.chapterNum === w.chapterNum && o.page === w.page && o.last === w.last })
if (!shelf) start(next, lib)
else if (!sameList) { works = next; shelf.setWorks(shelfSort(works, order)) }
else works = next.map(w => had.get(w.id))
@@ -4429,6 +4441,13 @@ async function tableJpeg(file) {
return blob
} finally { URL.revokeObjectURL(url) }
}
+// Laboratorio del giroscopio: solo para desarrollo, se borrará (con public/gyrolab.js, gyrolab.js del servidor y su botón en Ajustes).
+async function viewGyroLab() {
+ app.innerHTML = '
Cargando el laboratorio…
'
+ const m = await import(`/gyrolab.js?v=${Date.now()}`)
+ if (!location.hash.startsWith('#/gyrolab')) return
+ cleanup = m.mountGyroLab(app, { esc, toast })
+}
function viewSettings() {
app.innerHTML = `
Ajustes
@@ -4464,6 +4483,9 @@ function viewSettings() {
`
: `
Instalar la app y leer sin conexión necesita que la página se abra por HTTPS (el navegador no lo permite en http://${esc(location.host)}). Por ejemplo con tailscale serve o un proxy inverso con certificado.
`}
+ ${icon('phone')} Desarrollo: laboratorio del giroscopio
+
Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.
El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
diff --git a/app/public/book3d.js b/app/public/book3d.js
index 3c314bb..15c6151 100644
--- a/app/public/book3d.js
+++ b/app/public/book3d.js
@@ -303,33 +303,48 @@ function rimShape(pts, y0, y1, thick) {
return s
}
-// El sensor: los ejes de la pantalla a partir de la orientación del dispositivo. La inclinación se desvanece con el tiempo.
-// MAX_DEG: lo que hay que girar el dispositivo para llegar al tope. ORBIT_*: cuánto rodea entonces la cámara al libro, en grados.
+// El sensor. Fuente principal: el giroscopio (devicemotion.rotationRate, en °/s y en los ejes del dispositivo), que no falla con
+// el dispositivo vertical. La orientación del sistema (deviceorientation) solo se usa si no hay giroscopio: sus ángulos β/γ saltan
+// cerca de β = 90°, aunque el dispositivo esté quieto. La inclinación acumulada vuelve sola al centro con el tiempo y se acerca al
+// tope con suavidad (tanh). Al caminar, la agitación que mide el acelerómetro baja la sensibilidad.
+// Calibrado con grabaciones reales (laboratorio del giroscopio, ver HANDOFF-LIBRO3D.md).
+// MAX_DEG: lo que hay que girar el dispositivo para llegar al 76 % del tope. ORBIT_*: cuánto rodea la cámara al libro en el tope, en grados.
export const MAX_DEG = 15
-const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
+const FADE_S = 2.5, QUIET = 0.6, DEAD = 1, ORBIT_TILT = 10, ORBIT_YAW = 15, ORBIT_BACK = 0.12
+const SMOOTH_S = 0.058, WALK_FROM = 0.25, WALK_K = 2.5, WALK_S = 0.4
export function createTilt(onChange) {
- const target = { x: 0, y: 0 }
- let listening = false, gotOrientation = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
- const clamp = v => Math.max(-MAX_DEG, Math.min(MAX_DEG, v))
+ const target = { x: 0, y: 0 }, raw = { x: 0, y: 0 }
+ let listening = false, gotGyro = false, lastT = 0, lastOri = null, lastFade = 0, askOnTouch = null
+ // iOS da la gravedad con el signo al revés que la especificación; se confirma con β en cuanto se puede.
+ let gSign = window.DeviceMotionEvent?.requestPermission ? -1 : 1, grav = null, land = null, shake = 0
+ const LIM = 2 * MAX_DEG
+ const lim = v => Math.max(-LIM, Math.min(LIM, v))
+ const soft = () => { for (const k of ['x', 'y']) target[k] = MAX_DEG * Math.tanh(raw[k] / MAX_DEG) }
+ // Giro de la pantalla. Safari del iPad informa mal el ángulo (90 en vertical, 180 en horizontal con el borde derecho abajo):
+ // la disposición dice si es vertical u horizontal y, en horizontal, la gravedad dice hacia qué lado.
function screenAngle() {
let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
const landscape = innerWidth > innerHeight
- if (landscape && a % 180 === 0) a = 90
- if (!landscape && a % 180) a = 0
+ if (landscape) {
+ if (grav && Math.abs(grav[0]) > 3) land = grav[0] > 0 ? 90 : 270
+ a = land ?? (a % 180 ? a : 90)
+ } else if (a % 180) a = 0
return (a * Math.PI) / 180
}
function add(dx, dy) {
const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
- target.x = clamp(target.x + dx * c - dy * s)
- target.y = clamp(target.y + dx * s + dy * c)
+ raw.x = lim(raw.x + dx * c - dy * s)
+ raw.y = lim(raw.y + dx * s + dy * c)
+ soft()
lastFade = lastFade || performance.now()
onChange()
}
const stepOf = t => { const dt = Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t; return dt }
- const quiet = v => (Math.abs(v) < QUIET ? 0 : v)
function onOrientation(e) {
if (e.beta == null || e.gamma == null) return
- gotOrientation = true
+ const sb = Math.sin((e.beta * Math.PI) / 180)
+ if (grav && Math.abs(sb) > 0.7 && Math.abs(grav[1]) > 6) gSign *= Math.sign(grav[1] * sb) // y de la reacción a la gravedad = 9,8·sin β
+ if (gotGyro) return
const dt = stepOf(e.timeStamp)
if (lastOri) {
const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g
@@ -339,10 +354,16 @@ export function createTilt(onChange) {
lastOri = { b: e.beta, g: e.gamma }
}
function onMotion(e) {
- const r = e.rotationRate
- if (gotOrientation || r?.alpha == null || r?.beta == null) return
+ const r = e.rotationRate, g = e.accelerationIncludingGravity, l = e.acceleration
+ if (g?.x != null && g?.y != null) grav = [g.x * gSign, g.y * gSign]
+ if (r?.alpha == null || r?.beta == null) return
+ gotGyro = true
const dt = stepOf(e.timeStamp)
- add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y
+ if (l?.x != null) shake += (Math.hypot(l.x, l.y || 0, l.z || 0) - shake) * Math.min(1, dt / WALK_S)
+ const speed = Math.hypot(r.alpha, r.beta) // especificación: alpha es el eje x y beta el eje y; gamma (girar como un volante) no cuenta
+ if (speed <= DEAD) return
+ const k = ((speed - DEAD) / speed) * dt / (1 + Math.max(0, shake - WALK_FROM) * WALK_K)
+ add(r.alpha * k, r.beta * k)
}
function listen(on) {
if (on === listening) return
@@ -350,7 +371,7 @@ export function createTilt(onChange) {
const method = on ? 'addEventListener' : 'removeEventListener'
window[method]('devicemotion', onMotion)
window[method]('deviceorientation', onOrientation)
- target.x = target.y = 0
+ target.x = target.y = raw.x = raw.y = shake = 0
lastT = performance.now()
lastOri = null
onChange()
@@ -377,11 +398,26 @@ export function createTilt(onChange) {
fade(now) {
const dt = Math.min(0.1, Math.max(0, (now - lastFade) / 1000)), keep = Math.exp(-dt / FADE_S)
lastFade = now
- for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep
+ for (const k of ['x', 'y']) raw[k] = Math.abs(raw[k] * keep) < 0.3 ? 0 : raw[k] * keep
+ soft()
},
stop() { forget(); listen(false) },
}
}
+// La cámara sigue a la inclinación con un suavizado por tiempo, igual a 60 que a 120 Hz. cur: { x, y } en fracción del tope.
+export function followTilt(tilt, cur, now, on) {
+ tilt.fade(now)
+ const dt = Math.min(1 / 30, Math.max(0, (now - (cur.t ?? now)) / 1000)), a = 1 - Math.exp(-dt / SMOOTH_S)
+ cur.t = now
+ const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
+ let moving = !!(tilt.target.x || tilt.target.y) && on
+ for (const k of ['x', 'y']) {
+ const d = want[k] - cur[k]
+ if (Math.abs(d) < 0.002) cur[k] = want[k]
+ else { cur[k] += d * a; moving = true }
+ }
+ return moving
+}
export function createBook3D(container, opts) {
const { total, src, whenReady, spreadOf, isWide, still = false } = opts
@@ -778,18 +814,7 @@ export function createBook3D(container, opts) {
return pts.at(-1)
}
- function stepTilt(now) {
- tilt.fade(now)
- const on = !still && zoom.s === 1
- const want = on ? { x: tilt.target.x / MAX_DEG, y: tilt.target.y / MAX_DEG } : { x: 0, y: 0 }
- let moving = !!(tilt.target.x || tilt.target.y) && on
- for (const k of ['x', 'y']) {
- const d = want[k] - tiltNow[k]
- if (Math.abs(d) < 0.002) tiltNow[k] = want[k]
- else { tiltNow[k] += d * 0.25; moving = true }
- }
- return moving
- }
+ const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom.s === 1)
function stepAnim(now) {
if (!anim) return false
const dt = Math.min(0.12, (now - anim.last) / 1000) // tras un tirón, recuperar sin alargar la animación
@@ -1048,7 +1073,7 @@ export function createBook3D(container, opts) {
},
draw,
// Para las pruebas.
- debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { ...tiltNow }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }),
+ debug: () => ({ view: view?.key, focus: view?.focus, anim: anim && { kind: anim.kind, p: anim.p, side: anim.side, dragging: anim.dragging }, tilt: { x: tiltNow.x, y: tiltNow.y }, target: { ...tilt.target }, listening: tilt.listening, textures: [...textures.keys()], W, zoom: zoom.s, cam: camNow && { x: camNow.target[0], dist: camNow.dist } }),
destroy() {
dead = true
cancelAnimationFrame(raf)
diff --git a/app/public/gyrolab.js b/app/public/gyrolab.js
new file mode 100644
index 0000000..5d79ac6
--- /dev/null
+++ b/app/public/gyrolab.js
@@ -0,0 +1,312 @@
+// 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 `
${esc(s.name)}${esc(s.say)} (${s.secs} s)
+ ${done ? '✓ Guardado' : ''}
+
`
+ }).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 = `
+