Compare commits
1
Commits
8ae3594803
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e40127265f |
@@ -1,37 +0,0 @@
|
||||
// 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 }
|
||||
}
|
||||
+13
-35
@@ -395,7 +395,6 @@ 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 = `<p class="error">${esc(e.message)}</p>`
|
||||
@@ -3076,7 +3075,6 @@ 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)
|
||||
@@ -3835,21 +3833,15 @@ function handoffCover(src) {
|
||||
document.body.append(img)
|
||||
document.body.classList.add('handing')
|
||||
const t0 = performance.now()
|
||||
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) }
|
||||
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) }
|
||||
const wait = () => {
|
||||
if (!img.isConnected) return document.body.classList.remove('handing')
|
||||
const ready = document.querySelector('#b3d:not([hidden]) canvas')
|
||||
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')
|
||||
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()
|
||||
setTimeout(wait, 60)
|
||||
}
|
||||
// Se navega cuando la foto ya está pintada: así no asoma la pantalla de carga entre el estante y el lector.
|
||||
@@ -3921,19 +3913,15 @@ 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'}` : ''].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 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 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(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>'}`
|
||||
<h2 class="ptitle">${esc(shelfMod.shortTitle(work.title, 60))}</h2>${facts ? `<p class="psub">${facts}</p>` : ''}
|
||||
<div class="pbar"><i style="width:${pct}%"></i></div><p class="pwhere">${where}</p>
|
||||
<div class="pacts">${href ? `<a class="btn primary" id="peekread" href="${href}">${icon('book')} ${state === 'reading' ? 'Seguir leyendo' : 'Leer'}</a>` : '<span class="pwhere">No está guardado en el dispositivo</span>'}${offline ? '' : `<a class="btn" href="#/manga/${work.id}">Ver ficha</a>`}</div>`
|
||||
card.querySelector('.peekx').addEventListener('click', () => shelf?.unpeek())
|
||||
card.querySelector('#peekread')?.addEventListener('click', e => { e.preventDefault(); toTable(info) })
|
||||
clearTimeout(card.hideTimer)
|
||||
@@ -3990,7 +3978,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.chapterNum === w.chapterNum && 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.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))
|
||||
@@ -4441,13 +4429,6 @@ 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">
|
||||
@@ -4483,9 +4464,6 @@ 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).
|
||||
|
||||
+30
-55
@@ -303,48 +303,33 @@ function rimShape(pts, y0, y1, thick) {
|
||||
return s
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
export const MAX_DEG = 15
|
||||
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
|
||||
const FADE_S = 2.5, QUIET = 0.6, ORBIT_TILT = 7, ORBIT_YAW = 10, ORBIT_BACK = 0.06
|
||||
export function createTilt(onChange) {
|
||||
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.
|
||||
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))
|
||||
function screenAngle() {
|
||||
let a = (((window.orientation ?? screen.orientation?.angle ?? 0) % 360) + 360) % 360
|
||||
const landscape = innerWidth > innerHeight
|
||||
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
|
||||
if (landscape && a % 180 === 0) a = 90
|
||||
if (!landscape && a % 180) a = 0
|
||||
return (a * Math.PI) / 180
|
||||
}
|
||||
function add(dx, dy) {
|
||||
const a = screenAngle(), c = Math.cos(a), s = Math.sin(a)
|
||||
raw.x = lim(raw.x + dx * c - dy * s)
|
||||
raw.y = lim(raw.y + dx * s + dy * c)
|
||||
soft()
|
||||
target.x = clamp(target.x + dx * c - dy * s)
|
||||
target.y = clamp(target.y + dx * s + dy * c)
|
||||
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
|
||||
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
|
||||
gotOrientation = true
|
||||
const dt = stepOf(e.timeStamp)
|
||||
if (lastOri) {
|
||||
const dx = e.beta - lastOri.b, dy = e.gamma - lastOri.g
|
||||
@@ -354,16 +339,10 @@ export function createTilt(onChange) {
|
||||
lastOri = { b: e.beta, g: e.gamma }
|
||||
}
|
||||
function onMotion(e) {
|
||||
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 r = e.rotationRate
|
||||
if (gotOrientation || r?.alpha == null || r?.beta == null) return
|
||||
const dt = stepOf(e.timeStamp)
|
||||
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)
|
||||
add(quiet(r.alpha) * dt, quiet(r.beta) * dt) // especificación: alpha es el eje x y beta el eje y
|
||||
}
|
||||
function listen(on) {
|
||||
if (on === listening) return
|
||||
@@ -371,7 +350,7 @@ export function createTilt(onChange) {
|
||||
const method = on ? 'addEventListener' : 'removeEventListener'
|
||||
window[method]('devicemotion', onMotion)
|
||||
window[method]('deviceorientation', onOrientation)
|
||||
target.x = target.y = raw.x = raw.y = shake = 0
|
||||
target.x = target.y = 0
|
||||
lastT = performance.now()
|
||||
lastOri = null
|
||||
onChange()
|
||||
@@ -398,26 +377,11 @@ 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']) raw[k] = Math.abs(raw[k] * keep) < 0.3 ? 0 : raw[k] * keep
|
||||
soft()
|
||||
for (const k of ['x', 'y']) target[k] = Math.abs(target[k] * keep) < 0.3 ? 0 : target[k] * keep
|
||||
},
|
||||
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
|
||||
@@ -814,7 +778,18 @@ export function createBook3D(container, opts) {
|
||||
return pts.at(-1)
|
||||
}
|
||||
|
||||
const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom.s === 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
|
||||
}
|
||||
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
|
||||
@@ -1073,7 +1048,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: { 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 } }),
|
||||
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 } }),
|
||||
destroy() {
|
||||
dead = true
|
||||
cancelAnimationFrame(raf)
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
+46
-78
@@ -117,7 +117,6 @@ 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;
|
||||
@@ -132,16 +131,7 @@ 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) { // 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 == 2) c = uHas > 0.5 ? texture(uTex, vUV, -0.5).rgb : uColor; // portada
|
||||
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);
|
||||
@@ -166,19 +156,6 @@ 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])
|
||||
@@ -226,10 +203,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 = 14, ORBIT_YAW = 26, ORBIT_BACK = 0.11, REST_TILT = -3, FRICTION_MS = 320
|
||||
const ZOOM_MAX = 3, ORBIT_TILT = 10, ORBIT_YAW = 18, ORBIT_BACK = 0.08, 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, followTilt } = lib
|
||||
const { Shape, woodCanvas, cameraMatrix, project, FOV, coverPalette, inkOn, css, createTilt, MAX_DEG } = lib
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.className = 'shelfcanvas'
|
||||
const gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, powerPreference: 'high-performance' })
|
||||
@@ -290,7 +267,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', 'uFace']) u[n] = gl.getUniformLocation(prog, n)
|
||||
for (const n of ['uM', 'uModel', 'uKind', 'uTex', 'uColor', 'uHas', 'uPitch', 'uDepth', 'uSize', 'uDim', 'uHide', 'uAlpha', 'uSpot', 'uTab']) u[n] = gl.getUniformLocation(prog, n)
|
||||
gl.uniform1i(u.uTex, 0)
|
||||
gl.uniformMatrix4fv(u.uModel, false, IDENTITY)
|
||||
gl.uniform1f(u.uDim, 1)
|
||||
@@ -369,29 +346,15 @@ 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])
|
||||
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
|
||||
})
|
||||
waiting.push(url)
|
||||
pumpImages()
|
||||
}
|
||||
async function keptSrc(url) { // ya se miró lo guardado: de la red, y se guarda
|
||||
async function keptSrc(url) {
|
||||
const hit = await keptBlob('img', url)
|
||||
if (hit) return URL.createObjectURL(hit)
|
||||
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()
|
||||
@@ -423,7 +386,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 bajado no se pudo leer: tal cual
|
||||
if (src) { URL.revokeObjectURL(src); src = null; im.src = url; return } // lo guardado no sirve: de la red
|
||||
finish(false)
|
||||
}
|
||||
im.src = src || url
|
||||
@@ -754,26 +717,24 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
|
||||
g.drawImage(logo.im, -len / 2, -thick / 2, len, thick)
|
||||
g.restore()
|
||||
}
|
||||
// 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()
|
||||
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)
|
||||
}
|
||||
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`
|
||||
@@ -811,13 +772,12 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
|
||||
faces.push({ it, mesh: makeMesh(sh), edge: makeMesh(edge), pages: makeMesh(pages) })
|
||||
continue
|
||||
}
|
||||
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])
|
||||
quad(spines, [x0, base + H, F], [it.w, 0, 0], [0, -H, 0], [0, 0, 1], [U0, 0, U1, 1])
|
||||
// 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 - 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
|
||||
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
|
||||
}
|
||||
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 }
|
||||
@@ -948,7 +908,18 @@ export function createShelf3D(container, lib, { works: firstWorks = [], rowsLand
|
||||
if (Math.abs(velocity) < 0.00004) velocity = 0
|
||||
return !!velocity
|
||||
}
|
||||
const stepTilt = now => followTilt(tilt, tiltNow, now, !still && zoom === 1)
|
||||
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
|
||||
}
|
||||
function paint() {
|
||||
raf = 0
|
||||
if (dead) return
|
||||
@@ -999,10 +970,8 @@ 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)
|
||||
@@ -1035,8 +1004,7 @@ 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])
|
||||
// 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(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
|
||||
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])
|
||||
@@ -1356,7 +1324,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: { 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() }),
|
||||
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() }),
|
||||
destroy() {
|
||||
if (faceWorker) faceWorker.terminate()
|
||||
dead = true
|
||||
|
||||
+15
-15
@@ -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, un pósit cuadrado pegado junto a él (a la derecha en apaisado, abajo en vertical).
|
||||
/* Vistazo del estante: la nota del tomo sacado, como un pósit 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);
|
||||
.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);
|
||||
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; }
|
||||
.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; }
|
||||
@media (orientation: portrait) {
|
||||
.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)); }
|
||||
.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)); }
|
||||
}
|
||||
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; }
|
||||
|
||||
@@ -18,7 +18,6 @@ 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)
|
||||
@@ -928,7 +927,6 @@ 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])
|
||||
@@ -1179,7 +1177,6 @@ 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
@@ -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, chapterNum: null, page: 0, lang: null }
|
||||
const w = out.get(id) || { id, title: '', cover: null, last: 0, chapterId: 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, chapterNum: fields.chapterNum ?? null, page: fields.page || 0, lang: fields.lang || null })
|
||||
if (fields.chapterId) Object.assign(w, { chapterId: fields.chapterId, 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, 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, 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, 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 })
|
||||
|
||||
Reference in New Issue
Block a user