Quitar el laboratorio del giroscopio
Ya sirvió para calibrar createTilt con grabaciones del iPad mini 6 y el Pixel 10: fuera la página #/gyrolab, su botón en Ajustes y la ruta /api/gyrolab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -395,7 +395,6 @@ async function router() {
|
|||||||
if (parts[0] === 'stats') return await viewStats()
|
if (parts[0] === 'stats') return await viewStats()
|
||||||
if (parts[0] === 'clips') return await viewClips(params)
|
if (parts[0] === 'clips') return await viewClips(params)
|
||||||
if (parts[0] === 'settings') return viewSettings()
|
if (parts[0] === 'settings') return viewSettings()
|
||||||
if (parts[0] === 'gyrolab') return await viewGyroLab() // solo desarrollo
|
|
||||||
return await viewHome(params)
|
return await viewHome(params)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
|
if (e.message !== 'No autenticado') app.innerHTML = `<p class="error">${esc(e.message)}</p>`
|
||||||
@@ -4441,13 +4440,6 @@ async function tableJpeg(file) {
|
|||||||
return blob
|
return blob
|
||||||
} finally { URL.revokeObjectURL(url) }
|
} 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() {
|
function viewSettings() {
|
||||||
app.innerHTML = `<h1>Ajustes</h1>
|
app.innerHTML = `<h1>Ajustes</h1>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
@@ -4483,9 +4475,6 @@ function viewSettings() {
|
|||||||
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
|
<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>`}
|
: `<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>
|
||||||
<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 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>
|
<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).
|
<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).
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ function rimShape(pts, y0, y1, thick) {
|
|||||||
// el dispositivo vertical. La orientación del sistema (deviceorientation) solo se usa si no hay giroscopio: sus ángulos β/γ saltan
|
// 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
|
// 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.
|
// 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).
|
// Calibrado con grabaciones reales del iPad mini 6 y el Pixel 10 (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.
|
// 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
|
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 FADE_S = 2.5, QUIET = 0.6, DEAD = 1, ORBIT_TILT = 10, ORBIT_YAW = 15, ORBIT_BACK = 0.12
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { setupMeta } from './meta.js'
|
|||||||
import { setupVolumes } from './volumes.js'
|
import { setupVolumes } from './volumes.js'
|
||||||
import { setupCredits } from './credits.js'
|
import { setupCredits } from './credits.js'
|
||||||
import { setupShelf } from './shelf.js'
|
import { setupShelf } from './shelf.js'
|
||||||
import { setupGyroLab } from './gyrolab.js' // solo desarrollo
|
|
||||||
import { setupBooks, isBookId } from './books.js'
|
import { setupBooks, isBookId } from './books.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3352)
|
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 features.route(p, method, q, req, res, user)) !== false) return
|
||||||
if ((await credits.route(p, method, 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 (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) {
|
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.
|
// 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])
|
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 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 credits = setupCredits({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID })
|
||||||
const shelf = setupShelf({ db, json, isBookId, downloadCover, MY_DOWNLOADS })
|
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 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 })
|
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user