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:
+55
-30
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user