From d7400f27ad67f214c132186c766026d9f1912f72 Mon Sep 17 00:00:00 2001 From: Nino Date: Fri, 6 Nov 2026 21:12:58 -0600 Subject: [PATCH] =?UTF-8?q?Estante=203D:=20entradas,=20orden=20A=E2=80=93Z?= =?UTF-8?q?,=20sin=20conexi=C3=B3n,=20p=C3=B3sit=20y=20giroscopio=20calibr?= =?UTF-8?q?ado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Estante: entradas desde el menú, la librería y la ficha; orden reciente o A–Z; foto del estante, arte de lomos y portadas guardados en el dispositivo; sin conexión muestra solo lo guardado. - Notas de 40 letras sin cortar palabras, lomo curvo, sombra en las portadas de frente y lomos con logo sin el título. - Pósit cuadrado con el capítulo y la página por la que va el usuario; paso al lector sin pantalla de carga. - Giroscopio: integra rotationRate (sin los saltos de β/γ con el dispositivo vertical), tope suave, menos sensibilidad al caminar, lado en horizontal según la gravedad, suavizado por tiempo y más rango en el estante y el libro. - Laboratorio del giroscopio en Ajustes (temporal, solo desarrollo) para grabar los sensores. Co-Authored-By: Claude Opus 5.5 --- app/gyrolab.js | 37 +++++ app/public/app.js | 48 +++++-- app/public/book3d.js | 85 ++++++++---- app/public/gyrolab.js | 312 ++++++++++++++++++++++++++++++++++++++++++ app/public/shelf3d.js | 124 ++++++++++------- app/public/style.css | 30 ++-- app/server.js | 3 + app/shelf.js | 8 +- 8 files changed, 539 insertions(+), 108 deletions(-) create mode 100644 app/gyrolab.js create mode 100644 app/public/gyrolab.js 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 = ` -

${esc(shelfMod.shortTitle(work.title, 60))}

${facts ? `

${facts}

` : ''} -

${where}

-
${href ? `${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}` : 'No está guardado en el dispositivo'}${offline ? '' : `Ver ficha`}
` +

${esc(shelfMod.shortTitle(work.title))}

${facts ? `

${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) }) 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.

+
${icon('key')} Seguridad: IPs bloqueadas
Cargando…
${icon('bell')} Avisos de capítulos nuevos

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 `
  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() + } +} diff --git a/app/public/shelf3d.js b/app/public/shelf3d.js index 358c6b7..35393c4 100644 --- a/app/public/shelf3d.js +++ b/app/public/shelf3d.js @@ -117,6 +117,7 @@ uniform float uHas, uPitch, uDepth, uDim, uAlpha; uniform vec4 uSpot; // mesa: centro del tomo y radio de la luz uniform vec2 uTab; // mesa propia: escala de la foto; sin ella, madera repetida uniform vec2 uSize; +uniform vec2 uFace; // portada de frente: ancho y alto, para la sombra que le hace el hueco del estante (0: sin sombra) uniform vec4 uHide; // x0, x1, y0, y1 del hueco del tomo sacado: ni él ni sus costados se pintan en la fila in vec3 vPos; in vec3 vNor; in vec2 vUV; out vec4 color; @@ -131,7 +132,16 @@ void main() { vec3 c; if (uKind == 0) c = texture(uTex, vUV, -0.4).rgb * vec3(0.92, 0.84, 0.76); // madera del mueble, algo más oscura que la mesa else if (uKind == 1) c = texture(uTex, vUV).rgb; // lomos - else if (uKind == 2) c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor; // portada + else if (uKind == 2) { // portada + c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor; + if (uFace.x > 0.0) { + // Metida en el estante: la luz llega de arriba a la izquierda, así que el lomo o el costado de la izquierda le hace + // sombra, y la balda de arriba y el fondo la oscurecen hacia los bordes. + float dl = vUV.x * uFace.x, dr = (1.0 - vUV.x) * uFace.x, dt = vUV.y * uFace.y, db = (1.0 - vUV.y) * uFace.y; + c *= mix(0.28, 1.0, smoothstep(0.0, 0.13, dl)) * mix(0.6, 1.0, smoothstep(0.0, 0.06, dr)) + * mix(0.45, 1.0, smoothstep(0.0, 0.36, dt)) * mix(0.66, 1.0, smoothstep(0.0, 0.04, db)) * 0.88; + } + } else if (uKind == 3 || uKind == 5) { // las hojas, vistas por arriba: rayas irregulares a lo ancho del tomo, medidas en el mueble float a = uKind == 3 ? vUV.x : vUV.y, v = a / 0.0042, w = fwidth(v), k = floor(v); float n = fract(sin(k * 12.9898) * 43758.5453); @@ -156,6 +166,19 @@ void main() { color = vec4(c * lit * shade * room * uDim * vec3(1.03, 1.0, 0.95), uAlpha); }` +// Lomo curvo: a lo ancho, el centro sobresale y los bordes quedan SPINE_ROUND del ancho más atrás. +// a0 y len: dónde empieza y cuánto mide a lo ancho; top y H: arriba y alto; front: lo más saliente. +// place(a lo ancho, salida) da x y z del punto; turn pasa la normal (a lo ancho, 0, salida) a esos mismos ejes. +const SPINE_ROUND = 0.16, SPINE_COLS = 8 +function roundSpine(sh, a0, len, top, H, front, R, place, [U0, U1], turn = n => n) { + const w = Math.abs(len) + sh.grid(SPINE_COLS, 1, (c, r) => { + const t = c / SPINE_COLS, out = front - R * (1 - Math.sin(Math.PI * t)), slope = ((R * Math.PI) / w) * Math.cos(Math.PI * t) + const k = Math.hypot(slope, 1), n = turn([-slope / k * Math.sign(len), 0, 1 / k]) + const [px, , pz] = place(a0 + len * t, out) + return [px, top - H * r, pz, ...n, U0 + (U1 - U0) * t, r] + }) +} // Un cuadrilátero: origen, dos lados, normal y coordenadas de textura en sus esquinas. function quad(sh, o, a, b, n, uv = [0, 0, 1, 1]) { sh.grid(1, 1, (c, r) => [o[0] + a[0] * c + b[0] * r, o[1] + a[1] * c + b[1] * r, o[2] + a[2] * c + b[2] * r, ...n, uv[0] + (uv[2] - uv[0]) * c, uv[1] + (uv[3] - uv[1]) * r]) @@ -203,10 +226,10 @@ export function loadNoteFonts() { const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) const FLY_MS = 900 const PEEK_OPEN_MS = 720, PEEK_CLOSE_MS = 520, PEEK_SLIDE = 0.34 // el tomo sale recto de la fila y luego gira hacia la cámara -const ZOOM_MAX = 3, ORBIT_TILT = 10, ORBIT_YAW = 18, ORBIT_BACK = 0.08, REST_TILT = -3, FRICTION_MS = 320 +const ZOOM_MAX = 3, ORBIT_TILT = 14, ORBIT_YAW = 26, ORBIT_BACK = 0.11, REST_TILT = -3, FRICTION_MS = 320 export function createShelf3D(container, lib, { works: firstWorks = [], rowsLandscape = 2.3, rowsPortrait = 3, still = false, pagesOf = null, onPeek = null, onOpen = null, table = null } = {}) { - const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, MAX_DEG } = lib + const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, followTilt } = lib const canvas = document.createElement('canvas') canvas.className = 'shelfcanvas' const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' }) @@ -267,7 +290,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || 'shader') gl.useProgram(prog) u = {} - for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab']) u[n] = gl.getUniformLocation(prog, n) + for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab', 'uFace']) u[n] = gl.getUniformLocation(prog, n) gl.uniform1i(u.uTex, 0) gl.uniformMatrix4fv(u.uModel, false, IDENTITY) gl.uniform1f(u.uDim, 1) @@ -346,15 +369,29 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand // keep: la imagen se guarda en el dispositivo (portadas de frente y logos) y de ahí sale la próxima vez. const keepUrls = new Set() function loadImage(url, done, keep = false) { - if (keep && url.startsWith('/')) keepUrls.add(url) if (loadingImg.has(url)) { loadingImg.get(url).push(done); return } loadingImg.set(url, [done]) - waiting.push(url) - pumpImages() + if (!keep || !url.startsWith('/')) { waiting.push(url); return pumpImages() } + keepUrls.add(url) + // Lo guardado en el dispositivo no espera a la red: no ocupa sitio en la cola. + keptBlob('img', url).then(hit => { + if (dead) return + if (!hit) { waiting.push(url); return pumpImages() } + const im = new Image(), src = URL.createObjectURL(hit) + im.decoding = 'async' + const finish = ok => { + URL.revokeObjectURL(src) + if (!ok) { waiting.push(url); return pumpImages() } // lo guardado no sirve: de la red + const list = loadingImg.get(url) || [] + loadingImg.delete(url) + for (const f of list) f(im) + } + im.onload = () => finish(true) + im.onerror = () => finish(false) + im.src = src + }) } - async function keptSrc(url) { - const hit = await keptBlob('img', url) - if (hit) return URL.createObjectURL(hit) + async function keptSrc(url) { // ya se miró lo guardado: de la red, y se guarda const r = await fetch(url, { credentials: 'same-origin' }).catch(() => null) if (!r?.ok || !/^image\//.test(r.headers.get('content-type') || '')) return null const blob = await r.blob() @@ -386,7 +423,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand keptSrc(url).catch(() => null).then(src => { im.onload = () => { if (src) URL.revokeObjectURL(src); finish(true) } im.onerror = () => { - if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo guardado no sirve: de la red + if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo bajado no se pudo leer: tal cual finish(false) } im.src = src || url @@ -717,24 +754,26 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand g.drawImage(logo.im, -len / 2, -thick / 2, len, thick) g.restore() } - let size = wText * (art ? 0.44 : 0.5) - g.font = `700 ${Math.round(size)}px system-ui, sans-serif` - let long = g.measureText(it.work.title).width - if (long > room) { size = Math.max(wText * 0.32, (size * room) / long); g.font = `700 ${Math.round(size)}px system-ui, sans-serif`; long = g.measureText(it.work.title).width } - const len = Math.min(long, room), mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2 - if (backing && !logo) { - const bw = size * 1.45 * sx, pad = size * 0.55 - ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2) + // Con logo, el título ya está: solo queda el número del tomo. + if (!logo) { + let size = wText * (art ? 0.44 : 0.5) + g.font = `700 ${Math.round(size)}px system-ui, sans-serif` + let long = g.measureText(it.work.title).width + if (long > room) { size = Math.max(wText * 0.32, (size * room) / long); g.font = `700 ${Math.round(size)}px system-ui, sans-serif`; long = g.measureText(it.work.title).width } + const len = Math.min(long, room), mid = art ? to - len / STRIP_H / 2 - 0.012 : (from + to) / 2 + if (backing) { + const bw = size * 1.45 * sx, pad = size * 0.55 + ink = backing((w - bw) / 2, STRIP_H * mid - len / 2 - pad, bw, len + pad * 2) + } + g.save() + g.scale(sx, 1) + g.fillStyle = css(ink) + if (backing) { g.shadowColor = ink[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 } + g.translate(wText / 2, STRIP_H * mid) + g.rotate(Math.PI / 2) + g.fillText(it.work.title, 0, 0, room) + g.restore() } - g.save() - g.scale(sx, 1) - if (logo) g.globalAlpha = 0 // ya está el logo - g.fillStyle = css(ink) - if (backing) { g.shadowColor = ink[0] > 0.5 ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.5)'; g.shadowBlur = 3 } - g.translate(wText / 2, STRIP_H * mid) - g.rotate(Math.PI / 2) - g.fillText(it.work.title, 0, 0, room) - g.restore() if (it.vol.vol != null) { const nSize = wText * 0.5, label = String(it.vol.vol), cy = STRIP_H * 0.935 g.font = `800 ${Math.round(nSize)}px system-ui, sans-serif` @@ -772,12 +811,13 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand faces.push({ it, mesh: makeMesh(sh), edge: makeMesh(edge), pages: makeMesh(pages) }) continue } - quad(spines, [x0, base + H, F], [it.w, 0, 0], [0, -H, 0], [0, 0, 1], [U0, 0, U1, 1]) + const R = it.w * SPINE_ROUND, Fe = F - R // los bordes del lomo, algo más atrás que su centro + roundSpine(spines, x0, it.w, base + H, H, F, R, (along, out) => [along, 0, out], [U0, U1]) // Los costados son las tapas: un solo color del lomo, no una columna de la tira estirada a lo largo del fondo. const Um = (U0 + U1) / 2, side = [Um, 0.02, Um, 0.02] - quad(spines, [x0, base, F - D], [0, 0, D], [0, H, 0], [-1, 0, 0], side) - quad(spines, [x1, base, F], [0, 0, -D], [0, H, 0], [1, 0, 0], side) - quad(paper, [x0, base + H, F - D], [it.w, 0, 0], [0, 0, D], [0, 1, 0], [0, it.w, it.w, it.w]) // u: a lo ancho, en el mueble; v: el grosor + quad(spines, [x0, base, F - D], [0, 0, D - R], [0, H, 0], [-1, 0, 0], side) + quad(spines, [x1, base, Fe], [0, 0, -(D - R)], [0, H, 0], [1, 0, 0], side) + quad(paper, [x0, base + H, F - D], [it.w, 0, 0], [0, 0, D - R], [0, 1, 0], [0, it.w, it.w, it.w]) // u: a lo ancho, en el mueble; v: el grosor } paintStrip(row, strip) const b = { k, key: rowKey(row), strip, tex: texFrom(strip), spines: spines.i.length ? makeMesh(spines) : null, paper: paper.i.length ? makeMesh(paper) : null, faces, stale: 0 } @@ -908,18 +948,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand if (Math.abs(velocity) < 0.00004) velocity = 0 return !!velocity } - function stepTilt(now) { - tilt.fade(now) - const on = !still && zoom === 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 === 1) function paint() { raf = 0 if (dead) return @@ -970,8 +999,10 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand gl.uniform1i(u.uKind, 2) gl.uniform1f(u.uHas, tex ? 1 : 0) gl.uniform3fv(u.uColor, base) + gl.uniform2f(u.uFace, f.it.w, f.it.h) if (tex) gl.bindTexture(gl.TEXTURE_2D, tex) drawMesh(f.mesh) + gl.uniform2f(u.uFace, 0, 0) gl.uniform1i(u.uKind, 4) drawMesh(f.edge) gl.uniform1i(u.uKind, 5) @@ -1004,7 +1035,8 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand function peekBox(W, H, T, U0, U1) { const cover = new Shape(), spine = new Shape(), back = new Shape(), paper = new Shape(), X = W / 2, Y = H / 2, Z = T / 2 quad(cover, [-X, Y, Z], [W, 0, 0], [0, -H, 0], [0, 0, 1]) - quad(spine, [X, Y, Z], [0, 0, -T], [0, -H, 0], [1, 0, 0], [U0, 0, U1, 1]) // en la fila, con el giro, coincide con su trozo de la tira + // En la fila, con el giro, coincide con su trozo de la tira, también en la curva. + roundSpine(spine, Z, -T, Y, H, X, T * SPINE_ROUND, (along, out) => [out, 0, along], [U0, U1], n => [n[2], 0, n[0]]) quad(back, [X, Y, -Z], [-W, 0, 0], [0, -H, 0], [0, 0, -1]) quad(paper, [-X, Y, -Z], [W, 0, 0], [0, 0, T], [0, 1, 0], [0, 0, W, T]) quad(paper, [-X, -Y, -Z], [W, 0, 0], [0, 0, T], [0, -1, 0], [0, 0, W, T]) @@ -1324,7 +1356,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand setZoom, draw, // Para las pruebas. - debug: () => ({ faces: Object.entries(palettes).filter(([k, v]) => k.startsWith('face:') && v).length, facesAsked: Object.keys(palettes).filter(k => k.startsWith('face:')).length, faceWaiting: faceWaiting.length, faceBusy, faceWorker: faceWorker === false ? 'off' : !!faceWorker, rows: rows.map(r => r.map(i => ({ id: i.work.id, vol: i.vol.vol, x: i.x, w: i.w, h: i.h, d: i.d, face: i.face }))), rowWidth, y, x, zoom, velocity, top, bottom, peek: peek && { id: peek.it.work.id, vol: peek.it.vol.vol, t: peek.t, dir: peek.dir, rank: peek.rank, rect: peekRect(), fly: peek.fly ? peek.fly.t : null }, focusId, notes: [...built.values()].map(b => ({ k: b.k, notes: (b.notes?.list || []).map(n => ({ id: n.work.id, x: n.x, w: n.w })) })), logos: logos.size, tilt: { ...tiltNow }, listening: tilt.listening, arts: arts.size, panels: panels.size, built: [...built.keys()], covers: covers.size, palettes: Object.keys(palettes).length, waiting: waiting.length, busy: imgBusy, ctx: !gl.isContextLost() }), + debug: () => ({ faces: Object.entries(palettes).filter(([k, v]) => k.startsWith('face:') && v).length, facesAsked: Object.keys(palettes).filter(k => k.startsWith('face:')).length, faceWaiting: faceWaiting.length, faceBusy, faceWorker: faceWorker === false ? 'off' : !!faceWorker, rows: rows.map(r => r.map(i => ({ id: i.work.id, vol: i.vol.vol, x: i.x, w: i.w, h: i.h, d: i.d, face: i.face }))), rowWidth, y, x, zoom, velocity, top, bottom, peek: peek && { id: peek.it.work.id, vol: peek.it.vol.vol, t: peek.t, dir: peek.dir, rank: peek.rank, rect: peekRect(), fly: peek.fly ? peek.fly.t : null }, focusId, notes: [...built.values()].map(b => ({ k: b.k, notes: (b.notes?.list || []).map(n => ({ id: n.work.id, x: n.x, w: n.w })) })), logos: logos.size, tilt: { x: tiltNow.x, y: tiltNow.y }, listening: tilt.listening, arts: arts.size, panels: panels.size, built: [...built.keys()], covers: covers.size, palettes: Object.keys(palettes).length, waiting: waiting.length, busy: imgBusy, ctx: !gl.isContextLost() }), destroy() { if (faceWorker) faceWorker.terminate() dead = true diff --git a/app/public/style.css b/app/public/style.css index b6ff979..2d30729 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -487,25 +487,25 @@ body.reading #netbanner { display: none; } .reader.b3d.b3dcrop { background: #000; } .reader.b3d.b3dcrop > #b3d { display: none; } .reader.b3d.b3dcrop > #pages img { visibility: visible; } -/* Vistazo del estante: la nota del tomo sacado, como un pósit pegado junto a él (a la derecha en apaisado, abajo en vertical). +/* Vistazo del estante: la nota del tomo sacado, un pósit cuadrado pegado junto a él (a la derecha en apaisado, abajo en vertical). Papel, tinta, letra e inclinación son los de la nota de su colección en la balda (--np, --ni, --nf, --nw, --ns, --nt). */ -.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg; - position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(36vw, 360px); transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s; - padding: 30px 20px 18px; border-radius: 2px 2px 3px 18px / 2px 2px 3px 10px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw); - background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 22px, transparent 22px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np); +.shelfpeek { --np: #fff27a; --ni: #24242e; --nf: cursive; --nw: 400; --ns: 1; --nt: 0deg; --side: min(34vw, 330px); + position: fixed; z-index: 3; right: calc(5vw + env(safe-area-inset-right)); top: 50%; width: var(--side); height: var(--side); box-sizing: border-box; + display: flex; flex-direction: column; overflow: hidden; transform: translate(14px, -50%) rotate(var(--nt)); opacity: 0; transition: opacity .22s, transform .26s; + padding: 34px 22px 18px; border-radius: 1px 1px 2px 14px / 1px 1px 2px 8px; color: var(--ni); font-family: var(--nf), cursive; font-weight: var(--nw); + background: linear-gradient(180deg, rgb(0 0 0 / .07) 0 24px, transparent 24px), linear-gradient(160deg, rgb(255 255 255 / .18), transparent 40%), linear-gradient(0deg, rgb(0 0 0 / .1), transparent 30%), var(--np); box-shadow: 0 1px 1px rgb(0 0 0 / .18), 4px 14px 22px -8px rgb(0 0 0 / .55); } .shelfpeek.on { opacity: 1; transform: translate(0, -50%) rotate(var(--nt)); } -.shelfpeek .ptitle { margin: 0 30px 6px 0; font-family: inherit; font-weight: inherit; font-size: calc(1.75rem * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; } -.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; font-size: calc(1.15rem * var(--ns)); line-height: 1.2; opacity: .85; } -.shelfpeek .pbar { height: 5px; margin: 12px 0 6px; border-radius: 3px; background: color-mix(in srgb, var(--ni) 16%, transparent); overflow: hidden; } -.shelfpeek .pbar i { display: block; height: 100%; background: var(--ni); opacity: .75; border-radius: 3px; } -.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; } -.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; font-family: inherit; font-weight: inherit; font-size: calc(1.15rem * var(--ns)); background: transparent; color: var(--ni); border: 2px solid var(--ni); border-radius: 14px 10px 16px 9px / 10px 15px 9px 14px; box-shadow: none; } -.shelfpeek .pacts .btn.primary { background: var(--ni); color: var(--np); } -.shelfpeek .peekx { position: absolute; top: 4px; right: 6px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .7; } +.shelfpeek .ptitle { margin: 0 26px 8px 0; font-family: inherit; font-weight: inherit; font-size: calc(clamp(1.3rem, 2.6vw, 1.8rem) * var(--ns)); line-height: 1.05; overflow-wrap: anywhere; } +.shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { margin: 4px 0; font-size: calc(clamp(1rem, 1.7vw, 1.2rem) * var(--ns)); line-height: 1.2; } +.shelfpeek .psub { opacity: .8; } +.shelfpeek .phint { margin-top: auto; color: inherit; text-decoration: none; opacity: .75; } +.shelfpeek .peekx { position: absolute; top: 2px; right: 4px; width: 30px; height: 30px; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .6; } @media (orientation: portrait) { - .shelfpeek { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); top: auto; bottom: calc(14px + env(safe-area-inset-bottom)); width: auto; transform: translateY(14px) rotate(var(--nt)); } - .shelfpeek.on { transform: rotate(var(--nt)); } + .shelfpeek { --side: min(64vw, 300px); right: auto; left: 50%; top: auto; bottom: calc(18px + env(safe-area-inset-bottom)); transform: translate(-50%, 14px) rotate(var(--nt)); } + .shelfpeek.on { transform: translate(-50%, 0) rotate(var(--nt)); } + .shelfpeek .ptitle { font-size: calc(1.45rem * var(--ns)); } + .shelfpeek .psub, .shelfpeek .pwhere, .shelfpeek .phint { font-size: calc(1.08rem * var(--ns)); } } body.handing .reader .loading { visibility: hidden; } .handoff { position: fixed; inset: 0; z-index: 60; width: 100vw; height: 100vh; object-fit: cover; pointer-events: none; transition: opacity .35s ease; } diff --git a/app/server.js b/app/server.js index 218ded3..681001e 100644 --- a/app/server.js +++ b/app/server.js @@ -18,6 +18,7 @@ import { setupMeta } from './meta.js' import { setupVolumes } from './volumes.js' import { setupCredits } from './credits.js' import { setupShelf } from './shelf.js' +import { setupGyroLab } from './gyrolab.js' // solo desarrollo import { setupBooks, isBookId } from './books.js' const PORT = Number(process.env.PORT || 3352) @@ -927,6 +928,7 @@ async function route(req, res) { if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await credits.route(p, method, req, res, user)) !== false) return if (shelf.route(p, method, res, user) !== false) return + if ((await gyrolab.route(p, method, req, res, user)) !== false) return // solo desarrollo if ((await upscale.route(p, method, req, res)) !== false) { // Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide. if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1]) @@ -1177,6 +1179,7 @@ net.whenOnline(() => pumpDownloads()) const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) const credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID }) const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) +const gyrolab = setupGyroLab({ DATA_DIR, HttpError, json }) // solo desarrollo const volumes = setupVolumes({ md, sourceOf, fetchMangaFull, stored: meta.stored, mdMatches: meta.mdMatches, muVolumes: meta.muVolumes, similar: meta.similar, miSearch: q => mi.search(q), imgUrl, DATA_DIR, UA, HttpError, net }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) diff --git a/app/shelf.js b/app/shelf.js index f764faf..5e67f7c 100644 --- a/app/shelf.js +++ b/app/shelf.js @@ -5,15 +5,15 @@ export function setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS }) function works(userId) { const out = new Map() const add = (id, fields) => { - const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, page: 0, lang: null } + const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: null, chapterNum: null, page: 0, lang: null } w.title ||= fields.title || '' w.cover ||= fields.cover || null w.last = Math.max(w.last, fields.at || 0) - if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, page: fields.page || 0, lang: fields.lang || null }) + if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, chapterNum: fields.chapterNum ?? null, page: fields.page || 0, lang: fields.lang || null }) out.set(id, w) } - for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) { - add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, page: r.page, lang: r.lang, at: r.updated_at }) + for (const r of db.prepare('SELECT manga_id, manga_title, cover, chapter_id, chapter_num, page, lang, updated_at FROM progress WHERE user_id = ?').all(userId)) { + add(r.manga_id, { title: r.manga_title, cover: r.cover, chapterId: r.chapter_id, chapterNum: r.chapter_num, page: r.page, lang: r.lang, at: r.updated_at }) } for (const r of db.prepare('SELECT manga_id, title, cover, last_new_at, created_at FROM follows WHERE user_id = ? AND active = 1').all(userId)) { add(r.manga_id, { title: r.title, cover: r.cover, at: r.last_new_at || r.created_at })