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:12:58 -06:00
co-authored by Claude Opus 5.5
parent e40127265f
commit d7400f27ad
8 changed files with 539 additions and 108 deletions
+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)