@@ -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 ( )
}
}