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 93cf720..533adc1 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -41,6 +41,7 @@ const ICONS = {
queue: '
${esc(e.message)}
` @@ -722,6 +724,7 @@ async function viewManga(id, langOverride) {Sin conexión con la fuente: mostrando solo los capítulos descargados.
' : ''} @@ -3073,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) @@ -3823,40 +3827,77 @@ const libCard = ({ href, cover, title, sub, pct, badge, del }) => ` { 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 + 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 (performance.now() - t0 > 2500 || !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) } - wait() + // Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector. + return (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => new Promise(r => requestAnimationFrame(() => { setTimeout(wait, 120); r() }))) // para entonces ya se está en el lector } -async function viewShelf() { +// Lo último que se supo del estante, en este dispositivo: con ello se monta al momento (sin esperar a cada obra) +// y sin conexión. Tomos y logos se refrescan luego, y solo se rehace lo que cambie. +const shelfSnapKey = () => `shelfSnap:${me?.username || ''}` +const LOGO_DAYS = { found: 30, none: 7 } +const shelfSort = (works, order) => order === 'az' + ? [...works].sort((a, b) => String(a.title).replace(/^[^\p{L}\p{N}]+/u, '').localeCompare(String(b.title).replace(/^[^\p{L}\p{N}]+/u, ''), 'es', { sensitivity: 'base', numeric: true })) + : [...works].sort((a, b) => (b.last || 0) - (a.last || 0)) +async function viewShelf(params) { if (!SHELF_OK) { app.innerHTML = `Este navegador no tiene WebGL2.
${icon('library')} Volver a la librería
` return } document.body.classList.add('reading') // sin la barra de arriba - app.innerHTML = `${icon('back')} LibreríaColocando los tomos…
+ let order = store.get('shelfOrder', 'recent') + const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`) + app.innerHTML = ` + ` - const note = $('#shelfnote'), card = $('#shelfpeek') - const focusOn = shelfTrip?.mangaId || null // al volver del lector, con ese tomo a la vista + const note = $('#shelfnote'), card = $('#shelfpeek'), orderBtn = $('#shelforder') + orderBtn.innerHTML = orderLabel() + const say = (text, ms) => { clearTimeout(note.t); note.textContent = text; note.hidden = !text; if (ms) note.t = setTimeout(() => { note.hidden = true }, ms) } + const focusOn = shelfTrip?.mangaId || params?.get('focus') || null // al volver del lector, o desde la ficha shelfTrip = null - let shelf = null, gone = false + let shelf = null, gone = false, offline = !serverReachable const onKey = e => { if (e.key === 'Escape') shelf?.unpeek() } document.addEventListener('keydown', onKey) - cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); shelf?.destroy(); shelf = null } + cleanup = () => { gone = true; document.removeEventListener('keydown', onKey); clearTimeout(snapTimer); saveSnap(); shelf?.destroy(); shelf = null } + // Foto del estante en el dispositivo. + const snap = store.get(shelfSnapKey(), null) + const logos = snap?.logos || {} // id -> { url, at } + let works = [], snapTimer = 0 + const saveSnap = () => { if (works.length && !offline) store.set(shelfSnapKey(), { at: Date.now(), works, logos }) } + const saveSoon = () => { clearTimeout(snapTimer); snapTimer = setTimeout(saveSnap, 1500) } + // Sin conexión, cada tomo se lee por los capítulos guardados en el dispositivo. + const onDevice = id => !offline || offHas(id) // Vistazo: lo que se sabe del tomo sacado, junto a él. const readOf = info => { const { work, vol, state } = info + if (offline) { + if (state === 'reading' && work.chapterId && offHas(work.chapterId) && (!vol.chapters?.length || vol.chapters.includes(work.chapterId))) return readUrl(work.id, work.chapterId, work.page || 0) + const here = (vol.chapters || []).find(offHas) + return here ? readUrl(work.id, here) : null + } if (state === 'reading' && work.chapterId) return readUrl(work.id, work.chapterId, work.page || 0) if (vol.chapters?.length) return readUrl(work.id, vol.chapters[0]) return work.chapterId ? readUrl(work.id, work.chapterId, work.page || 0) : `#/manga/${work.id}` @@ -3866,72 +3907,126 @@ async function viewShelf() { const toTable = info => { if (leaving) return const href = readOf(info) - const go = snap => { + if (!href) return say('Este tomo no está guardado en el dispositivo.', 2500) + const go = async snap => { if (gone) return if (href.startsWith('#/read/')) shelfTrip = { mangaId: info.work.id } - if (snap) handoffCover(snap) + if (snap) await handoffCover(snap) // la foto ya está pintada antes de quitar el estante: no se ve nada entre medias location.hash = href } leaving = true if (!href.startsWith('#/read/') || !shelf?.fly(go)) go(null) } + // El tomo sacado lleva su nota, como la de la balda, con lo que se sabe de él. 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 = ` -${facts}
` : ''} -${where}
- ` +${facts}
` : ''} +${where}
+ ${href ? `Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →` : 'No está guardado en el dispositivo
'}` card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek()) - card.querySelector('#peekread').addEventListener('click', e => { e.preventDefault(); toTable(info) }) + card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) }) clearTimeout(card.hideTimer) card.hidden = false requestAnimationFrame(() => card.classList.add('on')) } const ver = name => document.querySelector(`meta[name=${name}-version]`)?.content || '' - try { - const [{ works }, lib, mod] = await Promise.all([api('/api/shelf'), import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)]) - if (gone) return - if (!works.length) { note.textContent = 'Aún no hay nada en tu biblioteca: los tomos de lo que leas, sigas o descargues aparecerán aquí.'; return } - shelf = mod.createShelf3D($('#shelf3d'), lib, { - works, + let shelfMod = null + // Lo guardado en el dispositivo, para cuando no hay servidor. + const offlineWorks = () => { + const known = new Map((snap?.works || []).map(w => [w.id, w])) + return offByManga().filter(m => !isBookId(m.mangaId)).map(m => known.get(m.mangaId) || { id: m.mangaId, title: m.title, cover: m.cover, last: m.at, chapterId: null, page: 0, lang: m.chapters[0]?.lang || null }) + } + const start = (list, lib) => { + works = list + shelf = shelfMod.createShelf3D($('#shelf3d'), lib, { + works: shelfSort(works, order), still: matchMedia('(prefers-reduced-motion: reduce)').matches, onPeek: showPeek, onOpen: info => toTable(info), // tocar el tomo sacado lo lleva a la mesa table: me?.tableV ? `/api/table?v=${me.tableV}` : null, // Un tomo sin portada saca el arte del lomo de sus primeras páginas: la 0 suele ser de créditos. - pagesOf: chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []), + pagesOf: offline ? null : chapterId => api(`/api/chapter/${chapterId}?saver=0&hd=0`).then(d => (d?.pages || []).slice(1, 6), () => []), }) if (!shelf) throw new Error('sin WebGL2') if (focusOn) shelf.focus(focusOn) - // Los tomos llegan aparte, de pocos en pocos: la primera vez pueden tardar por las fuentes externas. - let next = 0, pending = works.length + for (const w of works) if (logos[w.id]?.url) shelf.setLogo(w.id, logos[w.id].url) + } + orderBtn.addEventListener('click', () => { + order = order === 'az' ? 'recent' : 'az' + store.set('shelfOrder', order) + orderBtn.innerHTML = orderLabel() + shelf?.unpeek() + shelf?.setWorks(shelfSort(works, order)) + shelf?.toTop() + }) + try { + const [lib, mod] = await Promise.all([import(`/book3d.js?v=${ver('b3d')}`), import(`/shelf3d.js?v=${ver('shelf')}`)]) + shelfMod = mod + if (gone) return + mod.loadNoteFonts() + // Con foto guardada, el mueble sale ya; la lista del servidor llega después. + if (snap?.works?.length && !offline) start(snap.works.filter(w => wantsId(w.id)), lib) + else if (!snap?.works?.length && !offline) say('Colocando los tomos…') + let fresh = null + if (!offline) fresh = await api('/api/shelf').catch(e => { if (e.message === NO_SERVER || !serverReachable) offline = true; else throw e; return null }) + if (gone) return + if (offline) { + const list = offlineWorks() + if (shelf) { works = list; shelf.setWorks(shelfSort(works, order)) } else if (list.length) start(list, lib) + return say(list.length ? 'Sin conexión: solo lo guardado en este dispositivo.' : 'Sin conexión, y no hay tomos guardados en este dispositivo.', list.length ? 4000 : 0) + } + // La lista del servidor manda; de la foto se conservan los tomos ya conocidos. + 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.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)) + saveSoon() + // Los tomos llegan aparte, de pocos en pocos: primero las obras que aún no tienen, luego se repasan las demás. + const queue = [...works.filter(w => !w.vols), ...works.filter(w => w.vols)] + let pending = queue.filter(w => !w.vols).length + if (!pending) say('') const worker = async () => { - while (next < works.length && !gone) { - const w = works[next++] + while (queue.length && !gone) { + const w = queue.shift(), first = !w.vols const r = await api(`/api/volumes/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null) if (gone) return - if (r?.volumes?.length) shelf.setVolumes(w.id, r.volumes) - if (--pending === 0) note.hidden = true + const vols = r?.volumes?.length ? r.volumes : null + if (vols && JSON.stringify(vols) !== JSON.stringify(w.vols || null)) { w.vols = vols; shelf.setVolumes(w.id, vols); saveSoon() } + if (first && --pending <= 0) say('') } } for (let k = 0; k < 3; k++) worker() // Logos para el título de los lomos: la primera vez el servidor los busca en Wikimedia Commons, sin prisa. - let nextLogo = 0 + const day = 864e5, now = Date.now() + const logoQueue = works.filter(w => { const l = logos[w.id]; return !l || now - (l.at || 0) > (l.url ? LOGO_DAYS.found : LOGO_DAYS.none) * day }) const logoWorker = async () => { - while (nextLogo < works.length && !gone) { - const w = works[nextLogo++] - const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => null) + while (logoQueue.length && !gone) { + const w = logoQueue.shift() + const r = await api(`/api/logo/${w.id}?lang=${encodeURIComponent(w.lang || 'es')}`).catch(() => undefined) if (gone) return - if (r?.logo) shelf.setLogo(w.id, r.logo) + if (r === undefined) continue // error: se vuelve a preguntar en la próxima visita + logos[w.id] = { url: r.logo || null, at: Date.now() } + if (r.logo) shelf.setLogo(w.id, r.logo) + saveSoon() } } for (let k = 0; k < 2; k++) logoWorker() } catch (e) { - if (!gone) note.textContent = `No se pudo abrir el estante: ${e.message}` + if (!gone) say(`No se pudo abrir el estante: ${e.message}`) } } @@ -3955,7 +4050,7 @@ async function viewLibrary(params) { ...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', contentMode() === 'both' ? l.count : 0]), // el total de la lista incluye lo filtrado ] const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null - 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 = `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.
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 = `
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.
+ + +