Estante 3D: entradas, orden A–Z, sin conexión, pósit y giroscopio calibrado

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:08:51 -06:00
co-authored by Claude Opus 5.5
parent 8b82cbbfdb
commit 8ae3594803
9 changed files with 737 additions and 145 deletions
+37
View File
@@ -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 }
}
+146 -41
View File
@@ -41,6 +41,7 @@ const ICONS = {
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
shelf: '<path d="M3 3v18M21 3v18M3 12h18M3 21h18"/><path d="M6 12V6M9 12V5M12.5 12l1.6-6"/><path d="M6 21v-5M9 21v-6M15 21v-6M18 21v-4"/>',
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
@@ -388,12 +389,13 @@ async function router() {
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'shelf') return await viewShelf()
if (parts[0] === 'shelf') return await viewShelf(params)
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
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 = `<p class="error">${esc(e.message)}</p>`
@@ -722,6 +724,7 @@ async function viewManga(id, langOverride) {
<div id="mscores"></div>
<div class="actions">${mainBtn}
${chapters.length ? `<button class="btn" id="tochap" title="Ir a la lista de capítulos">${icon('queue')} Capítulos</button>` : ''}
${SHELF_OK && !isBook && !/^(manhwa|manhua|webtoon)$/i.test(manga.type || '') ? `<a class="btn" id="toshelf" href="#/shelf?focus=${encodeURIComponent(id)}" title="Ver la obra en el estante 3D">${icon('shelf')} Estante</a>` : ''}
<button class="btn" id="libbtn" title="Más opciones" aria-label="Más opciones"></button>
</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
@@ -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 }) => `<a class="car
</a>`
// ---------- Estante 3D ----------
const SHELF_OK = 'WebGL2RenderingContext' in window // también con ratón o teclado: a diferencia del lector, no depende de los gestos táctiles
if (!SHELF_OK) document.querySelector('#topbar nav a[href="#/shelf"]')?.remove()
// Foto del último fotograma del estante, encima de todo mientras el lector se monta; se desvanece cuando el libro 3D ya está dibujado.
function handoffCover(src) {
document.querySelector('.handoff')?.remove()
const img = Object.assign(document.createElement('img'), { className: 'handoff', src, alt: '' })
document.body.append(img)
document.body.classList.add('handing')
const t0 = performance.now()
const fade = () => { 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 = `<h1>Estante 3D</h1><p class="muted">Este navegador no tiene WebGL2.</p><p><a class="btn" href="#/library">${icon('library')} Volver a la librería</a></p>`
return
}
document.body.classList.add('reading') // sin la barra de arriba
app.innerHTML = `<div id="shelf3d"></div><a class="btn small shelfback" href="#/library">${icon('back')} Librería</a><p class="shelfnote" id="shelfnote">Colocando los tomos…</p>
let order = store.get('shelfOrder', 'recent')
const orderLabel = () => (order === 'az' ? `${icon('sort')} A–Z` : `${icon('sort')} Recientes`)
app.innerHTML = `<div id="shelf3d"></div><div class="shelftools"><a class="btn small" href="#/library">${icon('back')} Librería</a><button class="btn small" id="shelforder" type="button" title="Cambiar el orden"></button></div>
<p class="shelfnote" id="shelfnote"></p>
<section class="shelfpeek" id="shelfpeek" hidden aria-live="polite"></section>`
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 = `<button class="peekx" type="button" aria-label="Devolver al estante">${icon('x')}</button>
<h2 class="ptitle">${esc(work.title)}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
<div class="pacts"><a class="btn primary" id="peekread" href="${readOf(info)}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a><a class="btn" href="#/manga/${work.id}">Ver ficha</a></div>`
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
<p class="pwhere">${where}</p>
${href ? `<a class="phint" id="peekread" href="${href}">Toca el tomo para ${state === 'reading' ? 'seguir leyendo' : 'leerlo'} →</a>` : '<p class="phint">No está guardado en el dispositivo</p>'}`
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 = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('library')} Estante 3D</a>` : ''}</div>
app.innerHTML = `<div class="row libhead"><h1>Librería</h1>${SHELF_OK ? `<a class="btn small" href="#/shelf" id="toshelf">${icon('shelf')} Estante 3D</a>` : ''}</div>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
@@ -4346,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 = '<p class="muted">Cargando el laboratorio…</p>'
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 = `<h1>Ajustes</h1>
<div class="settings">
@@ -4381,6 +4483,9 @@ function viewSettings() {
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
</section>
<section><b>${icon('phone')} Desarrollo: laboratorio del giroscopio</b>
<p class="muted" style="margin:0;font-size:.88rem">Graba los sensores de movimiento de este dispositivo para calibrar el giroscopio del estante y del libro 3D. Es temporal: se quitará al terminar.</p>
<div class="row"><a class="btn small" href="#/gyrolab">${icon('phone')} Iniciar el laboratorio</a></div></section>
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
+55 -30
View File
@@ -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)
+312
View File
@@ -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 = `<div class="glab">
<h1>Laboratorio del giroscopio</h1>
<p class="muted" style="margin:0">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.</p>
<div class="gtop">
<label class="muted" for="gdev">Dispositivo</label><input id="gdev" value="${esc(device)}" maxlength="40" autocomplete="off">
<button class="btn primary" id="gperm">Activar sensores</button>
</div>
<p class="muted" id="gmsg" style="margin:0">Toca «Activar sensores» para empezar.</p>
<div class="glive" id="glive"></div>
<canvas id="gplot"></canvas>
<ol id="gsteps"></ol>
<div class="gtop"><span class="muted" id="gsess"></span><button class="btn small" id="gnew">Nueva sesión</button></div>
</div>`
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 `<li class="${done ? 'done' : ''}"><div class="gtxt"><b>${esc(s.name)}</b><small>${esc(s.say)} (${s.secs} s)</small></div>
${done ? '<span class="gok">✓ Guardado</span>' : ''}
<button class="btn small${done ? '' : ' primary'}" data-step="${s.id}" ${listening ? '' : 'disabled'}>${done ? 'Repetir' : 'Grabar'}</button></li>`
}).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 = `<div class="muted">${esc(step.name)}</div><div class="gbig"></div><div class="gsay">${esc(step.say)}</div>
<div class="gbar"><i></i></div><button class="btn" id="gcancel">Cancelar</button>`
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 = `
<div><b>Orientación · ${hz(hist.ori).toFixed(0)} Hz</b>α ${fmt(o?.a)}<br>β ${fmt(o?.b)}<br>γ ${fmt(o?.g)}</div>
<div><b>Giroscopio · ${hz(hist.mot).toFixed(0)} Hz</b>α ${fmt(m?.r.alpha)}<br>β ${fmt(m?.r.beta)}<br>γ ${fmt(m?.r.gamma)}</div>
<div><b>Acelerómetro con gravedad</b>x ${fmt(m?.g.x)}<br>y ${fmt(m?.g.y)}<br>z ${fmt(m?.g.z)}</div>
<div><b>Sin gravedad</b>x ${fmt(m?.a.x)}<br>y ${fmt(m?.a.y)}<br>z ${fmt(m?.a.z)}</div>
<div><b>Pantalla · ${hz(hist.frames).toFixed(0)} Hz</b>giro ${screenNow()[0]}°<br>${innerWidth}×${innerHeight}<br>intervalo ${interval ?? '—'} ms</div>`
}
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()
}
}
+1
View File
@@ -23,6 +23,7 @@
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/library" data-icon="library">Librería</a>
<a href="#/shelf" data-icon="shelf">Estante</a>
<a href="#/queues" data-icon="queue">Colas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
+157 -57
View File
@@ -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])
@@ -164,6 +187,15 @@ const hash = s => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.char
const hsl = (h, s, l) => { const a = s * Math.min(l, 1 - l), f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)) }; return [f(0), f(8), f(4)] }
const STORE = 'shelfPalette'
// Lo que cuesta sacar se guarda en el dispositivo (Cache Storage, se conserva al actualizar la app): el arte recortado
// de cada lomo, y las portadas de frente y los logos tal cual. La siguiente visita no espera a la red, y sin
// conexión el mueble se ve igual.
const KEEP = 'shelf-v1'
let keepBox = null
const keeper = () => (keepBox ||= typeof caches !== 'undefined' && globalThis.isSecureContext !== false ? caches.open(KEEP).catch(() => null) : Promise.resolve(null))
const keptKey = (kind, url) => `/__shelf/${kind}?u=${encodeURIComponent(url)}`
export const keptBlob = (kind, url) => keeper().then(c => c?.match(keptKey(kind, url))).then(r => (r ? r.blob() : null)).catch(() => null)
export const keepBlob = (kind, url, blob) => keeper().then(c => c?.put(keptKey(kind, url), new Response(blob, { headers: { 'content-type': blob.type || 'image/jpeg' } }))).catch(() => {})
const STRIP_PER_UNIT = 440, STRIP_MAX = 2048, STRIP_H = 512, IMG_AT_ONCE = 4 // ancho de la tira según el de la fila; más de 2048 no se nota y gasta memoria
const blurBox = typeof document === 'undefined' ? null : document.createElement('canvas') // de paso, para desenfocar bajo las letras
const ART_SKIP = 0.26, FACE_INPUT = 360 // sin rostro se salta lo alto de la portada; ancho con que se buscan los rostros
@@ -194,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' })
@@ -209,6 +241,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
let rows = [], rowWidth = 3, size = { w: 1, h: 1 }, visibleH = 2.8, top = 0, bottom = 0, y = 0, layoutTimer = 0, rowsKey = ''
const built = new Map(), covers = new Map(), loadingImg = new Map(), waiting = []
const arts = new Map(), panels = new Map(), askedPanel = new Set() // arte de los lomos, y página a color de los tomos sin portada
let toTopNext = false
let x = 0, zoom = 1, velocity = 0, lastMove = 0, focusId = null // focusId: obra que se mantiene a la vista hasta que se toque el mueble
const tiltNow = { x: 0, y: 0 }
const tilt = createTilt(() => draw())
@@ -257,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)
@@ -333,11 +366,37 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
}
const coverKey = item => urlOf(item) || `plain:${item.work.id}`
const plainFor = key => item => { if (!covers.has(key)) { covers.set(key, texFrom(plainCover(item))); draw() } }
function loadImage(url, done) {
// 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 (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) { // 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()
keepBlob('img', url, blob)
return URL.createObjectURL(blob)
}
function pumpImages() {
while (!dead && imgBusy < IMG_AT_ONCE && waiting.length) {
@@ -355,9 +414,20 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
if (!dead) for (const f of list) f(ok ? im : null)
free()
}
im.onload = () => finish(true)
im.onerror = () => finish(false)
im.src = url
if (!keepUrls.has(url)) {
im.onload = () => finish(true)
im.onerror = () => finish(false)
im.src = url
continue
}
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 bajado no se pudo leer: tal cual
finish(false)
}
im.src = src || url
})
}
}
// Alto y fondo de cada obra, por la proporción de su portada. Lo aprendido rehace el mueble una vez; luego se recuerda.
@@ -456,10 +526,37 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
const at = bestColumn(g.getImageData(0, 0, small.width, small.height).data, small.width, small.height, Math.min(cw, small.width), from)
out.getContext('2d').drawImage(im, (at / small.width) * w, h * ART_SKIP, h * ART_RATIO * keep, h * keep, 0, 0, out.width, out.height)
}
arts.set(url, out)
if (arts.size > ART_KEEP) arts.delete(arts.keys().next().value) // se vuelve a sacar de la caché del navegador si hace falta
putArt(url, out)
out.toBlob?.(b => { if (b) keepBlob('art', url, b) }, 'image/jpeg', 0.88)
} catch {}
}
function putArt(url, art) {
arts.delete(url)
arts.set(url, art)
if (arts.size > ART_KEEP) { const old = arts.keys().next().value; arts.get(old)?.close?.(); arts.delete(old) } // se vuelve a sacar del dispositivo si hace falta
}
// El arte ya recortado en otra visita. Solo vale si también se recuerda su color (localStorage); si no, se rehace.
const artAsked = new Set()
function keptArt(url, done) {
if (artAsked.has(url)) return
artAsked.add(url)
const miss = () => { artAsked.delete(url); done(false) }
if (!palettes[url]) return miss()
keptBlob('art', url).then(async b => {
if (dead) return
if (!b) return miss()
let art = null
try { art = typeof createImageBitmap === 'function' ? await createImageBitmap(b) : null } catch {}
if (!art) {
art = await new Promise(res => { const im = new Image(), src = URL.createObjectURL(b); im.onload = () => { URL.revokeObjectURL(src); res(im) }; im.onerror = () => { URL.revokeObjectURL(src); res(null) }; im.src = src })
}
artAsked.delete(url)
if (dead) return
if (!art) return done(false)
putArt(url, art)
done(true)
})
}
// Tomo sin portada: la primera de sus páginas que sea a color. Lo encontrado se recuerda; lo no encontrado, también, para no volver a descargar páginas.
function findPanel(item, done) {
const key = panelKey(item)
@@ -504,7 +601,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
if (!im || dead || im.naturalWidth < im.naturalHeight * 1.2) return
logos.set(id, { im, light: logoLight(im) })
for (const b of built.values()) if ((rows[b.k] || []).some(i => i.work.id === id)) b.repaint?.()
})
}, true)
}
// ----- Notas: un pósit por colección, pegado al canto de la balda de arriba -----
@@ -657,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`
@@ -712,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 }
@@ -744,7 +844,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
b.repaint = repaint
if (!it.face && !it.vol.art && !it.vol.thumb) findPanel(it, () => {
const panel = artOf(it)
if (panel && !arts.has(panel)) loadImage(panel, im => { if (im) { learnPalette(panel, im); learnArt(panel, im, true); repaint() } })
if (panel && !arts.has(panel)) keptArt(panel, ok => ok ? repaint() : loadImage(panel, im => { if (im) { learnPalette(panel, im); learnArt(panel, im, true); repaint() } }))
else repaint()
})
if (!url) continue
@@ -756,15 +856,21 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
learnArt(url, im)
covers.set(url, texFrom(im))
draw()
})
}, true)
} else if (arts.has(url)) {
if (palettes[`face:${url}`] === undefined) askFace(url) // su arte salió de la portada de frente, donde no se buscan rostros
} else loadImage(url, im => {
if (!im) return
learnSize(it, url, im)
learnPalette(url, im)
learnArt(url, im, true)
repaint()
} else keptArt(url, ok => {
if (ok) {
if (palettes[`face:${url}`] === undefined) askFace(url)
return repaint()
}
loadImage(url, im => {
if (!im) return
learnSize(it, url, im)
learnPalette(url, im)
learnArt(url, im, true)
repaint()
})
})
}
return b
@@ -821,7 +927,8 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
} else for (const b of [...built.values()]) if (b.key !== rowKey(rows[b.k])) freeRow(b)
const was = top
limits()
y = y === was || !y ? top : y
y = y === was || !y || toTopNext ? top : y
toTopNext = false
if (focusId) { const k = rows.findIndex(r => r.some(i => i.work.id === focusId)); if (k >= 0) y = rowBase(k) + SHELF.PITCH * 0.42 }
clampView()
}
@@ -841,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
@@ -903,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)
@@ -937,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])
@@ -1227,6 +1326,7 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
const api = {
setWorks(next) { works = next; api.relayout() },
toTop() { focusId = null; velocity = 0; toTopNext = true; api.relayout() }, // tras cambiar el orden
relayout() {
clearTimeout(layoutTimer)
layoutTimer = setTimeout(() => {
@@ -1256,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
+22 -13
View File
@@ -462,7 +462,8 @@ body.reading #netbanner { display: none; }
/* Libro 3D: el canvas dibuja el libro y #pages sigue debajo de los dedos, invisible, para los gestos. */
#shelf3d { position: fixed; inset: 0; z-index: 0; touch-action: none; background: #17100b; }
#shelf3d canvas { display: block; width: 100%; height: 100%; }
.shelfback { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); }
.shelftools { position: fixed; z-index: 2; top: calc(10px + env(safe-area-inset-top)); left: calc(10px + env(safe-area-inset-left)); display: flex; gap: 8px; }
.shelfnote:empty { display: none; } /* vacía no se ve; [hidden] dice que ya llegaron los tomos */
.shelfnote { position: fixed; z-index: 2; left: 50%; bottom: calc(14px + env(safe-area-inset-bottom)); transform: translateX(-50%); margin: 0; padding: 6px 12px; max-width: calc(100vw - 32px); border-radius: 8px; background: rgb(0 0 0 / .62); color: #fff; font-size: .85rem; text-align: center; }
.libhead { justify-content: space-between; align-items: center; }
#b3d { position: fixed; inset: 0; z-index: 0; }
@@ -486,19 +487,27 @@ 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: junto al tomo sacado, a la derecha en apaisado y abajo en vertical */
.shelfpeek { position: fixed; z-index: 3; right: calc(4vw + env(safe-area-inset-right)); top: 50%; width: min(38vw, 380px); transform: translate(12px, -50%); opacity: 0; transition: opacity .22s, transform .22s; padding: 18px 18px 16px; border-radius: 14px; background: rgb(20 14 10 / .78); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); color: #f4ede4; box-shadow: 0 10px 30px rgb(0 0 0 / .4); }
.shelfpeek.on { opacity: 1; transform: translate(0, -50%); }
.shelfpeek .ptitle { margin: 0 34px 4px 0; font-size: 1.2rem; line-height: 1.25; }
.shelfpeek .psub, .shelfpeek .pwhere { margin: 4px 0; color: #cdbfae; font-size: .9rem; }
.shelfpeek .pbar { height: 4px; margin: 12px 0 6px; border-radius: 2px; background: rgb(255 255 255 / .16); overflow: hidden; }
.shelfpeek .pbar i { display: block; height: 100%; background: var(--accent); }
.shelfpeek .pacts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.shelfpeek .pacts .btn { flex: 1 1 auto; justify-content: center; }
.shelfpeek .peekx { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgb(255 255 255 / .1); color: inherit; cursor: pointer; }
/* 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; --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 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(12px + env(safe-area-inset-left)); right: calc(12px + env(safe-area-inset-right)); top: auto; bottom: calc(12px + env(safe-area-inset-bottom)); width: auto; transform: translateY(12px); }
.shelfpeek.on { transform: none; }
.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; }
.handoff.out { opacity: 0; }
@media (max-width: 420px) { #topbar nav a { padding: 8px 7px; } }
+3
View File
@@ -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 })
+4 -4
View File
@@ -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 })