
Chismes del Lince 11: El Sismógrafo de la Mano, un medidor de temblor con el acelerómetro del móvil
Sierra Bermeja, las cuatro de la madrugada. Un ruido sordo sube por el suelo y las hojas de los pinsapos se estremecen. No es un jabalí. No es el viento. Es la tierra, que tiembla un poquito, como hace a veces en esta parte del mundo.
El Lince de la Noche se queda inmóvil, con las orejas tiesas.
—¿Has notado eso, Gata?
—Claro que lo he notado. Y mañana sabremos cuánto ha sido, porque los sismólogos tienen un aparato que escucha cuando la tierra tiembla. Se llama sismógrafo. Dibuja una línea que se mueve, y de esa línea sacan cuánta fuerza tuvo el temblor y cada cuánto tiempo se repetía.
—Qué listos. A mí, cuando me pongo nervioso, me tiemblan las patas.
—Y a todo el mundo, Lince. Un temblorcillo así es normal. Pero te voy a decir una cosa: a la tierra nadie le dice que tiemble «mal». La escuchan, la miden y aprenden de ella. Con las manos que tiemblan debería pasar igual.
El Lince se queda pensando.
—¿Y si hiciéramos un sismógrafo… para la mano?
La Gata Montesa sonríe en la rama. Esa idea es suya, pero ya hace tiempo que aprendió a dejar que el Lince crea que se le ha ocurrido a él.
Introducción
Hasta ahora el móvil había escuchado ruido (Chisme 05), hablado (Chisme 07), contado pasos con su acelerómetro (Chisme 08), leído papeles (Chisme 09) y medido la voz (Chisme 10). En este chisme usamos otra vez el acelerómetro, pero para algo más fino: medir cómo se mueve la mano.
La idea es sencilla. Se sujeta el móvil en la palma de la mano unos segundos. Mientras, una línea dibuja el movimiento en pantalla, como un sismógrafo. Al terminar, la aplicación nos dice tres cosas:
- A qué frecuencia se mueve la mano (cuántas veces por segundo, en hercios).
- Cuánto se mueve (la amplitud, en milímetros).
- Si esa frecuencia cae en la franja de 4 a 6 Hz, que es la típica del temblor en reposo de la enfermedad de Parkinson.
Y se puede guardar la medida en un registro del móvil, con una gráfica del día, para ver cómo evoluciona.
Las reglas de diseño son las de los chismes anteriores, pensadas para personas con Parkinson:
- Un solo botón enorme. Todo lo demás es secundario.
- Nada de teclear. Botones grandes y con mucho espacio.
- Freno anti doble toque, porque con temblor es fácil pulsar dos veces.
- Todo lo que dice la Gata, también escrito en grande.
⚠️ Esto es una prueba técnica, no un diagnóstico
Esta aplicación no es un producto sanitario y no diagnostica nada. Un móvil no es un aparato médico, y la frecuencia de un temblor no basta para saber qué lo causa: hay otros temblores (el esencial, el fisiológico, el de los nervios o el de la cafeína) que se solapan con ese rango. Si a alguien le preocupa su temblor, tiene que hablar con su médico.
Una diferencia importante con los Chismes 07, 08 y 10: este chisme no usa IA ni Worker. Es a propósito. Un dato de temblor es un dato de salud, y la forma más segura de protegerlo es que no salga del móvil. El registro se guarda solo en el navegador del propio teléfono.
Pruébala aquí (ábrela en su propia dirección, en el móvil): https://montesteam.org/app/chisme11temblor.html
¿Por qué medir el temblor?
El temblor de reposo es uno de los síntomas más conocidos de la enfermedad de Parkinson. Aparece cuando la mano está quieta y relajada, y suele disminuir al hacer un movimiento voluntario. Según las descripciones clásicas, su frecuencia está entre 4 y 6 Hz: unas cuatro a seis sacudidas por segundo.
Hay una razón para medirlo con números y no solo «a ojo». El temblor va y viene: cambia con el cansancio, el estrés, la hora del día o el momento de la medicación. En la consulta, el médico lo ve unos minutos. Un registro sencillo entre visitas le puede dar una idea de cómo evoluciona a lo largo del día.
Por eso pensamos en un registro, pero con dos condiciones:
- Es de la persona. Se guarda en su móvil y se puede copiar para enseñárselo a su médico, sin que pase por nadie más.
- Es orientativo. No sustituye a un examen clínico ni a los aparatos que usan los profesionales. Decirlo con claridad es parte del trabajo.
Cómo funciona por dentro
El recorrido completo es este, y no sale del móvil:
Mano → acelerómetro (devicemotion) → muestras → quitar lo lento → Fourier → frecuencia y amplitud → pantalla
- La persona pulsa el botón. Hay 8 segundos para colocar el móvil en la mano (se necesita la otra mano para pulsar, y luego hay que cambiar de mano).
- Suena un pitido y empieza la medida. Durante 15 segundos el móvil guarda las tres aceleraciones (
x,y,z) y el instante de cada una. - Al terminar, suenan dos pitidos y empieza el análisis.
Dos detalles de diseño:
- Ni voz ni vibración durante la medida. El altavoz y el motor de vibración mueven el móvil, y el acelerómetro lo nota. Por eso la Gata solo habla antes y después, nunca mientras se mide.
- Se descartan los primeros 3 segundos, que es cuando la mano todavía se está colocando y acaba de sonar el pitido.
El análisis: de una línea temblona a un número
Este es el corazón de la entrada, y es física y matemáticas de Bachillerato con el móvil como laboratorio.
1. Las muestras no llegan a intervalos iguales
El navegador entrega una muestra del acelerómetro unas 50 o 60 veces por segundo, pero no exactamente a los mismos intervalos. Para analizar frecuencias necesitamos una rejilla regular. Con remuestrear() pasamos las muestras a 50 por segundo exactos, por interpolación lineal.
2. Quitar la gravedad y los movimientos lentos
En reposo el móvil mide siempre unos 9,8 m/s² (la gravedad), y además la mano se mueve despacio sin querer. Nada de eso es temblor. Con quitarLento() restamos una media móvil de medio segundo: lo que queda es el movimiento rápido.
Un detalle fino: ese filtro también se come un poco de señal cerca de 5 Hz (un 13 % aproximadamente), así que lo compensamos al calcular la amplitud. Es una buena pregunta de clase: ¿por qué restar una media quita las frecuencias bajas?
3. La transformada de Fourier
Una línea que tiembla es la suma de muchas ondas de distintas frecuencias. Con la transformada de Fourier descubrimos cuánta amplitud tiene cada frecuencia. La hacemos a mano, sin librerías, solo en las frecuencias que nos interesan (de 3 a 12 Hz, de décima en décima), y con una ventana de Hann para que los bordes no ensucien el resultado.
Para cada frecuencia fr:
ang = 2·π·fr·i / fs
re += x[i] · cos(ang)
im += x[i] · sin(ang)
amplitud = 2 · √(re² + im²) / suma de la ventana
Se hace con los tres ejes y se combinan. El resultado es el espectro: el dibujo de barras que ves debajo de la línea.
4. Buscar el pico
- La frecuencia es la del pico más alto, afinada con una parábola entre sus dos vecinos.
- La claridad dice cuánto de la energía se concentra alrededor del pico. Si hay una sola frecuencia dominante, la claridad es alta (un temblor regular). Si la energía está repartida, es baja (ruido o movimiento irregular).
- Hay movimiento regular solo si el pico tiene amplitud suficiente y claridad suficiente.
5. De aceleración a milímetros
Una onda de frecuencia f y amplitud A tiene una aceleración máxima a = ω²·A, con ω = 2π·f. Despejando:
A = a / (2π·f)²
Por eso el móvil, que mide aceleraciones, nos puede dar milímetros. Y por eso la misma aceleración es menos movimiento a mayor frecuencia: a 9 Hz la mano se desplaza bastante menos que a 4 Hz para la misma aceleración.
Tres decisiones de ingeniería (y una duda honesta)
1. Sin IA, sin Worker, sin nube. Lo repetimos porque es la decisión más importante. Podríamos pedirle a Gemini que «interprete» el temblor. No lo hacemos: una IA puede equivocarse con total seguridad, y aquí el dato es de salud. Los números los calcula el móvil, y los explicamos nosotros.
2. La aplicación no dice «leve», «moderado» ni «grave». Esas categorías las usan los médicos con escalas clínicas. Nosotros damos un dato (frecuencia, amplitud) y un aviso (no es un diagnóstico).
3. «No se ha detectado» no quiere decir «no hay». El temblor va y viene. Si en 15 segundos no aparece un movimiento regular, el mensaje lo dice con esas palabras y no dice nada más.
La duda honesta: los umbrales. Los valores con los que decidimos que hay movimiento regular (amplitud mínima de 0,05 m/s² y claridad mínima del 25 %) son valores de partida nuestros, sin validar. Están al principio del código para que se puedan cambiar. Ajustarlos con datos reales es, justamente, el trabajo que quedaría por hacer con ayuda de profesionales.
Para el aula
- Calibra con una frecuencia que conoces. Pon el metrónomo del Chisme 08 a 300 tics por minuto (300 ÷ 60 = 5 Hz) y sacude el móvil al ritmo. La aplicación debería decir unos 5 Hz. Prueba con 240 y con 420: ¿salen 4 y 7 Hz?
- Mide tu propio temblor. Todo el mundo tiene un temblor muy pequeño. Mide en reposo y con el brazo extendido. ¿Cambia después de subir escaleras? ¿Y si sostienes un peso?
- Un muelle y una masa. Cuelga el móvil de una goma elástica y baja
BANDAa[0.5, 5]en el código. Calcula la frecuencia conf = (1/2π)·√(k/m)y compárala con la medida. Física pura. - Juega con la ventana. ¿Qué pasa si no usas la ventana de Hann? ¿Y si mides solo 5 segundos en vez de 15? Verás por qué se necesita tiempo para distinguir frecuencias cercanas.
- Rompe los umbrales. Sube
AMP_MINhasta 1 y bajaCLARIDAD_MINa 0,05. ¿Qué mensajes salen con el móvil quieto sobre la mesa? - Piensa en la ética. ¿Quién debería poder ver un registro de temblor? ¿Qué pasaría si se pusiera en una nube? ¿Y si lo viera un seguro o un empleador?
Del aula a la calle: Aprendizaje-Servicio
Como en el Chisme 10, esto no es solo un ejercicio técnico. Nos gustaría que fuera parte de un proyecto de Aprendizaje-Servicio en el que el alumnado aprende construyendo algo útil para alguien de su entorno y esa persona participa en el diseño.
Con este chisme ya tenemos tres piezas pensadas para personas con Parkinson: voz (Chisme 10), paso (Chisme 08) y mano (Chisme 11). La ruta sigue siendo la misma:
- Aprender cómo funcionan los sensores y el análisis de señales.
- Escuchar a profesionales (fisioterapia, neurología) y a personas usuarias antes de seguir programando.
- Construir y probar, con acompañamiento de profesionales y consentimiento.
- Mejorar, cambiando lo que no funcione y no lo que a nosotros nos parezca bonito.
- Devolver: contar lo aprendido, con sus límites.
Queremos ofrecérselo a la asociación Parkinson Sol Estepona para que nos digan qué sirve y qué sobra. De nuevo, lo de «ofrecer» es literal.
Lo que queremos aprender de las personas
Cuando la probemos, no vamos a demostrar nada, vamos a preguntar:
- ¿Se puede sujetar el móvil cómodamente en la palma durante 15 segundos? ¿Es demasiado tiempo?
- ¿Los 8 segundos de preparación son suficientes para cambiar el móvil de mano?
- ¿Se entiende bien qué es la frecuencia y la amplitud, o hay que explicarlo de otra forma?
- ¿Los botones son cómodos? ¿Hay pulsaciones dobles por error?
- ¿Qué te gustaría ver en el registro que ahora no está?
- ¿Te resulta cómodo que los datos se queden en el móvil? ¿Preferirías otra cosa?
- ¿Cómo te has sentido al medirte? (Importante: medirse puede generar ansiedad).
Y una condición que no negociamos: la sesión la dirige o supervisa un profesional, y si alguien se siente incómodo, se para.
Paso 1: prepara la página
Este chisme no necesita Worker ni clave de API. Solo hace falta el HTML.
Copia el código de la sección HTML y guárdalo como chisme11temblor.html. No hay que cambiar nada para que funcione. Si quieres experimentar, las constantes están al principio del <script>:
const PREP = 8; // segundos para colocar el móvil
const DURACION = 15; // segundos de medida
const BANDA = [3, 12]; // franja donde buscamos temblor (Hz)
const FRANJA = [4, 6]; // franja típica del temblor de reposo
const AMP_MIN = 0.05; // amplitud mínima (m/s²)
const CLARIDAD_MIN = 0.25; // claridad mínima del pico
Paso 2: súbela y ábrela
Sube el archivo a tu carpeta app y ábrelo directamente en su dirección (no dentro de una entrada de WordPress), desde el móvil:
https://montesteam.org/app/chisme11temblor.html
Tiene que ser https. Sin https, el navegador no da acceso a los sensores de movimiento, y si la página está incrustada en un iframe, tampoco.
Paso 3: pruébala
- Elige «En reposo» o «Brazo extendido».
- Pulsa «Medir temblor». Suena un aviso y hay 8 segundos para colocar el móvil en la palma de la mano, pantalla hacia arriba, sin apretarlo.
- Suena un pitido. Quédate quieto y sin hablar durante 15 segundos.
- Suenan dos pitidos. Mira los tres números y el espectro.
- Pulsa «Guardar en mi registro» o «Descartar».
- Repite unas cuantas veces a lo largo del día y mira la gráfica.
- Prueba «Copiar registro» y pégalo en una hoja de cálculo.
Si algo falla
- «El sensor de movimiento no envía datos»: en Android, mira el candado junto a la dirección y mira que los Sensores de movimiento estén permitidos. En iPhone, acepta el permiso cuando aparezca. Abre la página directamente, no dentro de otra.
- La línea está siempre plana: ¿has abierto la página en un ordenador? Un ordenador normalmente no tiene acelerómetro.
- «Este móvil envía pocos datos por segundo»: algunos móviles o ahorros de batería limitan el sensor. Desactiva el modo ahorro y prueba en Chrome o Safari.
- Siempre sale «no se ha detectado»: prueba a sacudir el móvil a propósito a unos 5 Hz. Si así lo detecta, el sensor funciona. Si tienes el móvil en una funda blanda o agarras con mucha fuerza, la medida puede quedar amortiguada.
- Sale siempre algo parecido: si haces todas las medidas en la misma postura y situación, es normal. Cambia la postura o el momento del día.
- El registro desaparece: el registro vive en el navegador. Si borras los datos del navegador, o usas modo incógnito, se pierde. Por eso existe el botón de copiar.
Y una vez más, lo de siempre: antes de sospechar del código, comprueba lo más tonto (permisos, dirección, modo ahorro). Mira la cadena eslabón a eslabón.
Los límites: qué da de sí esta prueba
- Un móvil no es un aparato médico. Su acelerómetro es bueno para aprender, no para medir con precisión clínica. La amplitud que damos es una estimación.
- La frecuencia de muestreo está limitada. Los navegadores entregan unas 60 muestras por segundo como mucho, así que solo podemos ver frecuencias hasta unos 30 Hz (teorema de Nyquist). Para temblores de 3 a 12 Hz es suficiente.
- Cómo se sujeta el móvil importa. Apretarlo, apoyarlo en algo duro o ponerlo en una funda gruesa cambia la medida.
- Una sola medida vale poco. El valor está en repetirla, en el mismo sitio y postura, y mirar la tendencia.
- No distingue tipos de temblor. La franja de 4 a 6 Hz no identifica una enfermedad. Hay temblores de otras causas que también caen ahí.
El siguiente chisme
Este chisme mide la mano sujeta al móvil. Queda otro reto más difícil: medir el movimiento sin sujetar nada, por ejemplo, con la cámara, sin que la imagen salga del dispositivo. Y otro más humano: enseñar el registro a un profesional sanitario para que nos cuente qué datos le serían realmente útiles, y cuáles son solo números bonitos.
Conclusión
Hemos construido un sismógrafo con un acelerómetro, una ventana, una transformada de Fourier y una fórmula de física de primero de Bachillerato:
mano → acelerómetro → remuestreo → filtro → Fourier → frecuencia y amplitud → registro
Lo técnico es asequible y muy didáctico. Lo interesante, de nuevo, son las decisiones: no usar IA con datos de salud, no poner etiquetas clínicas que no nos corresponden, decir «no se ha detectado» y no «no hay», dejar el dato en el móvil de la persona y reconocer que los umbrales son nuestros y están sin validar.
El Lince ya sabe que un temblor no es una vergüenza: es una señal, y las señales se escuchan. La Gata lo sabía desde el principio, pero prefiere que lo descubra él.
⚠️ ANTES DE PUBLICAR NADA, LEE ESTO
No es un producto sanitario ni un diagnóstico. Es una prueba técnica de un centro educativo. No la presentes como herramienta para detectar, diagnosticar o seguir una enfermedad, ni prometas beneficios que no hemos comprobado.
Los datos de temblor son datos de salud. Esta aplicación no los envía a ningún sitio, y el registro vive solo en el móvil. Aun así, pide consentimiento a la persona (y a su familia), explícale dónde se guardan los datos y cómo se borran, y no hagas fotos ni capturas del registro de otras personas.
Un móvil compartido comparte el registro. Si varias personas usan el mismo móvil y navegador, verán el mismo historial. Usa el botón de borrar al terminar una sesión de prueba.
La medida es orientativa. Los umbrales son valores de partida, sin validar. No saques conclusiones de una sola medida, y no pongas etiquetas como «leve» o «grave».
Si hay una persona con temblor, que lo sepa su médico. Un resultado «normal» no descarta nada, y uno «alto» tampoco diagnostica nada. Ante cualquier duda de salud, el médico o la familia.
Mide con seguridad. Sentado, sin cargar pesos ni forzar el brazo. Si hay cansancio, dolor o ansiedad, se para.
Si lo usas con menores o en un centro educativo, revisa las condiciones de uso de los servicios y las indicaciones de la persona responsable de protección de datos.
La tecnología es la parte fácil. Decidir cómo, con quién y para qué la usamos es el verdadero chisme.
Para saber más
- Deuschl G, Bain P, Brin M. Consensus statement of the Movement Disorder Society on Tremor. Movement Disorders, 1998.
- Deuschl G, Raethjen J, Lindemann M, Krack P. The pathophysiology of tremor. Muscle & Nerve, 2001.
- Kostikis N, Hristu-Varsakelis D, Arnaoutoglou M, Kotsavasiloglou C. A smartphone-based tool for assessing parkinsonian hand tremor. IEEE Journal of Biomedical and Health Informatics, 2015.
- MDN Web Docs:
DeviceMotionEvent,Web Audio APIyWindow.localStorage.
HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f4c5c">
<title>Chisme 11 · El Sismógrafo de la Mano</title>
<style>
:root{--bg:#e8f0f1;--card:#fbfdfd;--ink:#13262b;--mute:#4c6268;--line:#c5d6d9;--pine:#0f4c5c;--ocre:#c77d0a;--ok:#1f6b3a;--bad:#8a1c1c}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:20px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
header{background:var(--pine);color:#fff;padding:14px 18px;border-bottom:10px solid var(--ocre)}
h1{margin:0;font-size:1.45rem}
h2{font-size:1.05rem;margin:18px 0 8px}
main{max-width:680px;margin:0 auto;padding:14px}
.aviso{background:#f6e3df;border:1px solid #dfb1a8;color:var(--bad);border-radius:10px;padding:10px 13px;font-size:.85rem;margin:0 0 14px}
button{font:inherit;font-weight:700;border:0;border-radius:14px;cursor:pointer;color:#fff;background:var(--pine)}
button:focus-visible{outline:4px solid var(--ocre);outline-offset:3px}
button:disabled{opacity:.5;cursor:default}
.fila{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0 0 14px}
.fila button,.sec{min-height:68px;font-size:1.05rem;padding:6px;background:var(--card);color:var(--pine);border:2.5px solid var(--pine)}
.fila button[aria-pressed="true"]{background:var(--pine);color:#fff}
canvas{display:block;width:100%;background:var(--card);border:1px solid var(--line);border-radius:12px}
#traza{height:170px;margin:0 0 10px}
#espectro{height:150px}
#hist{height:150px;margin:0 0 10px}
#tiempo{height:14px;background:var(--line);border-radius:7px;overflow:hidden;margin:0 0 6px}
#prog{height:100%;width:0;background:var(--ok)}
#cuenta{font-size:1.6rem;font-weight:700;text-align:center;margin:0 0 12px;min-height:1.5em}
#go{width:100%;min-height:22vh;font-size:2.1rem;padding:16px;margin:0 0 14px}
#go[data-e="on"]{background:var(--bad)}
#texto{min-height:5.5em;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;font-size:1.25rem;margin:0 0 14px;overflow-wrap:anywhere}
#texto.err{color:var(--bad)}
.dat{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0 0 10px;text-align:center}
.dat div{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:8px 2px}
.dat b{display:block;font-size:1.6rem}.dat span{font-size:.8rem;color:var(--mute)}
#lista{font-size:.95rem;color:var(--mute);margin:0 0 12px;padding-left:20px}
small{display:block;color:var(--mute);font-size:.8rem;margin-top:12px}
</style>
</head>
<body>
<header><h1>El Sismógrafo de la Mano</h1></header>
<main>
<p class="aviso"><strong>Prueba técnica, no un diagnóstico ni un producto sanitario.</strong> Mide el movimiento de su mano con el sensor del móvil. Nada sale de este móvil: no hay cuenta, ni IA, ni envío de datos. Si le preocupa su temblor, consulte a su médico.</p>
<h2>¿Cómo va a medir?</h2>
<div class="fila" id="pos">
<button type="button" data-p="reposo" aria-pressed="true">En reposo<br><small style="margin:0;color:inherit">mano sobre la pierna</small></button>
<button type="button" data-p="brazo" aria-pressed="false">Brazo extendido<br><small style="margin:0;color:inherit">al frente, sin apoyo</small></button>
</div>
<canvas id="traza" aria-label="Trazo en directo del movimiento del móvil"></canvas>
<div id="tiempo"><div id="prog"></div></div>
<div id="cuenta" aria-live="polite"></div>
<button id="go" type="button" data-e="off">Medir temblor</button>
<div id="texto" aria-live="polite">Siéntese y apoye el antebrazo. Pulse el botón grande y, cuando suene el aviso, ponga el móvil en la palma de la mano, pantalla hacia arriba, sin apretarlo.</div>
<div class="dat" aria-live="polite">
<div><b id="rf">–</b><span>frecuencia</span></div>
<div><b id="ra">–</b><span>amplitud (estimada)</span></div>
<div><b id="rb">–</b><span>franja 4 a 6 Hz</span></div>
</div>
<canvas id="espectro" aria-label="Espectro de frecuencias de la medida"></canvas>
<div class="fila" style="margin-top:10px">
<button id="guardar" type="button" class="sec" disabled>Guardar en mi registro</button>
<button id="descartar" type="button" class="sec" disabled>Descartar</button>
</div>
<h2>Mi registro de hoy</h2>
<canvas id="hist" aria-label="Amplitud del temblor a lo largo del día"></canvas>
<ul id="lista"></ul>
<div class="fila">
<button id="copiar" type="button" class="sec">Copiar registro</button>
<button id="borrar" type="button" class="sec">Borrar registro</button>
</div>
<small>El registro se guarda solo en este móvil y en este navegador. Si otra persona usa el móvil, podría verlo: bórrelo si lo necesita. La frecuencia y la amplitud son estimaciones de un móvil, no de un aparato médico.</small>
</main>
<script>
/* ---------- Ajustes: aquí se cambia el experimento ---------- */
const PREP = 8; // segundos para colocar el móvil en la mano
const DURACION = 15; // segundos de medida
const DESCARTE = 3; // segundos iniciales que no se analizan (colocación y pitido)
const FS = 50; // frecuencia de remuestreo (Hz)
const BANDA = [3, 12]; // franja donde buscamos temblor (Hz)
const FRANJA = [4, 6]; // franja típica del temblor de reposo en el Parkinson (Hz)
const AMP_MIN = 0.05; // m/s²: por debajo, no se distingue del ruido del sensor (valor de partida, sin validar)
const CLARIDAD_MIN = 0.25; // parte de la potencia que debe concentrarse alrededor del pico (valor de partida, sin validar)
const CLAVE = 'sismografo-mano-v1';
/*PURO-INICIO*/
/* ---------- Matemáticas: no tocan la pantalla ---------- */
// Pasa muestras con tiempos irregulares a una rejilla regular (fs muestras por segundo), por interpolación lineal.
function remuestrear(ts, vs, t0, t1, fs) {
const n = Math.floor((t1 - t0) * fs), out = new Float64Array(n);
let j = 0;
for (let i = 0; i < n; i++) {
const t = t0 + i / fs;
while (j < ts.length - 2 && ts[j + 1] < t) j++;
const a = ts[j], b = ts[j + 1];
const k = b > a ? Math.min(1, Math.max(0, (t - a) / (b - a))) : 0;
out[i] = vs[j] + k * (vs[j + 1] - vs[j]);
}
return out;
}
// Resta la media móvil de "seg" segundos: quita la gravedad y los movimientos lentos de la mano.
function quitarLento(x, fs, seg) {
const n = x.length, h = Math.floor(seg * fs / 2), pre = new Float64Array(n + 1);
for (let i = 0; i < n; i++) pre[i + 1] = pre[i] + x[i];
const y = new Float64Array(n);
for (let i = 0; i < n; i++) {
const a = Math.max(0, i - h), b = Math.min(n, i + h + 1);
y[i] = x[i] - (pre[b] - pre[a]) / (b - a);
}
return y;
}
// Transformada de Fourier directa, solo en las frecuencias que nos interesan. Ventana de Hann para que no "gotee".
// Devuelve, para cada frecuencia, la amplitud (m/s²) de la senoide que mejor encaja, combinando los tres ejes.
function espectro(ejes, fs, f0, f1, paso) {
const n = ejes[0].length, w = new Float64Array(n);
let sw = 0;
for (let i = 0; i < n; i++) { w[i] = .5 - .5 * Math.cos(2 * Math.PI * i / (n - 1)); sw += w[i]; }
const f = [], p = [];
for (let fr = f0; fr <= f1 + 1e-9; fr += paso) {
let pot = 0;
for (const x of ejes) {
let re = 0, im = 0;
for (let i = 0; i < n; i++) {
const ang = 2 * Math.PI * fr * i / fs, v = x[i] * w[i];
re += v * Math.cos(ang); im += v * Math.sin(ang);
}
const amp = 2 * Math.hypot(re, im) / sw;
pot += amp * amp;
}
f.push(fr); p.push(pot); // p = amplitud² combinada
}
return {f, p};
}
// m = {t:[s], x:[], y:[], z:[]} con las muestras crudas del acelerómetro
function analizar(m) {
const n = m.t.length;
if (n < 30) return {error: 'pocos'};
const tasa = (n - 1) / (m.t[n - 1] - m.t[0]);
if (tasa < 20) return {error: 'lento', tasa};
const ini = m.t[0] + DESCARTE, fin = m.t[n - 1];
if (fin - ini < 8) return {error: 'corto'};
const c = Math.round(.3 * FS); // recortamos los bordes, donde la media móvil es menos fiable
const ejes = [m.x, m.y, m.z].map(v => quitarLento(remuestrear(m.t, v, ini, fin, FS), FS, .5).slice(c, -c));
const paso = .1, e = espectro(ejes, FS, BANDA[0], BANDA[1], paso);
const A = e.p.map(Math.sqrt);
let k = 0; for (let i = 1; i < A.length; i++) if (A[i] > A[k]) k = i;
let fpico = e.f[k], apico = A[k];
if (k > 0 && k < A.length - 1) { // afinamos el pico con una parábola entre sus vecinos
const y0 = A[k - 1], y1 = A[k], y2 = A[k + 1], den = y0 - 2 * y1 + y2;
if (den < 0) { const d = .5 * (y0 - y2) / den; fpico = e.f[k] + d * paso; apico = y1 - .25 * (y0 - y2) * d; }
}
// La media móvil de 0,5 s que restamos también "come" un poco de señal cerca del pico: lo compensamos.
const L = (2 * Math.floor(.5 * FS / 2) + 1) / FS, xx = Math.PI * fpico * L, ganancia = 1 - Math.sin(xx) / xx;
if (ganancia > .5) apico /= ganancia;
let total = 0, cerca = 0, en46 = 0;
e.f.forEach((fr, i) => {
total += e.p[i];
if (Math.abs(fr - fpico) <= .75) cerca += e.p[i];
if (fr >= FRANJA[0] && fr <= FRANJA[1]) en46 += e.p[i];
});
const claridad = total > 0 ? cerca / total : 0;
const mm = apico / Math.pow(2 * Math.PI * fpico, 2) * 1000; // a = ω²·A → A = a / ω²
return {
f: fpico, a: apico, mm, claridad, fr46: total > 0 ? en46 / total : 0, tasa,
hay: apico >= AMP_MIN && claridad >= CLARIDAD_MIN,
enFranja: fpico >= FRANJA[0] && fpico <= FRANJA[1],
espec: e
};
}
/*PURO-FIN*/
/* ---------- Pantalla ---------- */
const $ = id => document.getElementById(id);
const nf = (x, d = 1) => x.toFixed(d).replace('.', ',');
const css = getComputedStyle(document.documentElement), C = n => css.getPropertyValue(n).trim();
let pos = 'reposo', est = 'idle', tEst = 0, datos = null, evAntes = 0, ev = 0, wake = null, ac = null, bloq = 0, ultimo = null, escala = .3, borrarT = 0;
const ema = {x: 0, y: 0, z: 0, ok: false}, vista = [];
function msg(t, e) { $('texto').textContent = t; $('texto').className = e ? 'err' : ''; }
function decir(t) {
if (!('speechSynthesis' in window)) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(t); u.lang = 'es-ES'; u.rate = .95;
const v = speechSynthesis.getVoices().find(v => v.lang.toLowerCase().startsWith('es')); if (v) u.voice = v;
speechSynthesis.speak(u);
}
function audio() { try { ac = ac || new (window.AudioContext || window.webkitAudioContext)(); ac.resume(); } catch (_) {} }
function pitido(veces) {
if (!ac) return;
for (let i = 0; i < veces; i++) {
const t = ac.currentTime + i * .25, o = ac.createOscillator(), g = ac.createGain();
o.frequency.value = 880;
g.gain.setValueAtTime(.0001, t); g.gain.exponentialRampToValueAtTime(.5, t + .01); g.gain.exponentialRampToValueAtTime(.0001, t + .15);
o.connect(g).connect(ac.destination); o.start(t); o.stop(t + .16);
}
}
async function pantalla() { try { wake = await navigator.wakeLock.request('screen'); } catch (_) {} }
function sueltaPantalla() { if (wake) { wake.release(); wake = null; } }
/* ---------- Sensor ---------- */
async function permiso() {
if (typeof DeviceMotionEvent !== 'undefined' && DeviceMotionEvent.requestPermission) {
const r = await DeviceMotionEvent.requestPermission(); if (r !== 'granted') throw new Error('permiso');
}
window.removeEventListener('devicemotion', movimiento); window.addEventListener('devicemotion', movimiento);
}
function movimiento(e) {
const g = e.accelerationIncludingGravity || e.acceleration; if (!g || g.x == null) return;
const t = performance.now() / 1000; ev++;
if (!ema.ok) { ema.x = g.x; ema.y = g.y; ema.z = g.z; ema.ok = true; }
ema.x += .05 * (g.x - ema.x); ema.y += .05 * (g.y - ema.y); ema.z += .05 * (g.z - ema.z);
vista.push({t, x: g.x - ema.x, y: g.y - ema.y, z: g.z - ema.z});
while (vista.length && t - vista[0].t > 4) vista.shift();
if (est === 'medir') { datos.t.push(t); datos.x.push(g.x); datos.y.push(g.y); datos.z.push(g.z); }
}
/* ---------- Dibujo ---------- */
function lienzo(c) {
const d = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight;
if (c.width !== Math.round(w * d)) { c.width = Math.round(w * d); c.height = Math.round(h * d); }
const g = c.getContext('2d'); g.setTransform(d, 0, 0, d, 0, 0); g.clearRect(0, 0, w, h);
return {g, w, h};
}
function traza() {
const {g, w, h} = lienzo($('traza'));
g.strokeStyle = C('--line'); g.lineWidth = 1; g.beginPath(); g.moveTo(0, h / 2); g.lineTo(w, h / 2); g.stroke();
const ahora = performance.now() / 1000;
if (vista.length < 5 || ahora - vista[vista.length - 1].t > 1) {
g.fillStyle = C('--mute'); g.font = '16px system-ui'; g.textAlign = 'center';
g.fillText('Aquí se dibujará el movimiento del móvil', w / 2, h / 2 - 10); return;
}
let eje = 'x', mejor = -1; // dibujamos el eje que más se mueve
for (const k of ['x', 'y', 'z']) { let s = 0; for (const p of vista) s += p[k] * p[k]; if (s > mejor) { mejor = s; eje = k; } }
const pico = Math.max(.3, ...vista.map(p => Math.abs(p[eje])));
escala += .05 * (pico - escala);
g.strokeStyle = C('--pine'); g.lineWidth = 2.5; g.beginPath();
vista.forEach((p, i) => {
const x = (p.t - (ahora - 4)) / 4 * w, y = h / 2 - p[eje] / escala * (h / 2 - 8);
i ? g.lineTo(x, y) : g.moveTo(x, y);
});
g.stroke();
}
function dibujarEspectro(r) {
const {g, w, h} = lienzo($('espectro'));
if (!r || !r.espec) {
g.fillStyle = C('--mute'); g.font = '16px system-ui'; g.textAlign = 'center';
g.fillText('Aquí aparecerá el espectro de su medida', w / 2, h / 2); return;
}
const {f, p} = r.espec, A = p.map(Math.sqrt), max = Math.max(...A, .1);
const m = {l: 8, r: 8, t: 22, b: 26}, W = w - m.l - m.r, H = h - m.t - m.b;
const X = fr => m.l + (fr - BANDA[0]) / (BANDA[1] - BANDA[0]) * W;
g.fillStyle = 'rgba(199,125,10,.25)'; g.fillRect(X(FRANJA[0]), m.t, X(FRANJA[1]) - X(FRANJA[0]), H);
g.fillStyle = C('--ink'); g.font = '13px system-ui'; g.textAlign = 'center';
g.fillText('4 a 6 Hz', (X(FRANJA[0]) + X(FRANJA[1])) / 2, 14);
g.fillStyle = C('--pine'); g.beginPath(); g.moveTo(X(f[0]), m.t + H);
A.forEach((a, i) => g.lineTo(X(f[i]), m.t + H - a / max * H));
g.lineTo(X(f[f.length - 1]), m.t + H); g.closePath(); g.fill();
g.fillStyle = C('--ink'); g.font = '14px system-ui';
for (let fr = BANDA[0]; fr <= BANDA[1]; fr++) g.fillText(fr, X(fr), h - 8);
if (r.hay) { g.strokeStyle = C('--bad'); g.lineWidth = 3; g.beginPath(); g.moveTo(X(r.f), m.t); g.lineTo(X(r.f), m.t + H); g.stroke(); }
}
/* ---------- Medida ---------- */
function barra(x) { $('prog').style.width = Math.min(100, Math.max(0, x * 100)) + '%'; }
function reposoUI() {
est = 'idle'; sueltaPantalla(); barra(0); $('cuenta').textContent = '';
$('go').dataset.e = 'off'; $('go').textContent = 'Medir temblor';
}
function cancelar(t, err) { reposoUI(); if (t) msg(t, err); }
async function empezar() {
audio();
try { await permiso(); } catch (_) { msg('Sin permiso de movimiento no puedo medir. En iPhone, acéptelo cuando lo pida.', true); return; }
ultimo = null; ponerResultado(null); evAntes = ev;
est = 'prep'; tEst = performance.now() / 1000; pantalla();
$('go').dataset.e = 'on'; $('go').textContent = 'Cancelar';
const f = pos === 'reposo'
? 'Apoye el antebrazo y ponga el móvil en la palma de la mano cuando suene el pitido. Mano relajada, sin hablar.'
: 'Extienda el brazo al frente con el móvil en la palma cuando suene el pitido. Sin hablar.';
msg(f); decir(f);
}
function empezarMedida(ahora) {
est = 'medir'; tEst = ahora; datos = {t: [], x: [], y: [], z: []};
if ('speechSynthesis' in window) speechSynthesis.cancel();
pitido(1);
msg('Midiendo. Quédese quieto y no hable.');
}
function terminar() {
const d = datos; est = 'idle'; reposoUI(); pitido(2);
const r = analizar(d);
if (r.error) {
const t = {pocos: 'El móvil no ha enviado datos. Abra la página directamente en Chrome o Safari y permita los sensores de movimiento.',
lento: 'Este móvil envía pocos datos por segundo para medir temblor.',
corto: 'La medida ha sido demasiado corta. Pruebe otra vez.'}[r.error];
msg(t, true); return;
}
ultimo = r; ponerResultado(r);
}
function ponerResultado(r) {
$('guardar').disabled = $('descartar').disabled = !r || !!r.error;
dibujarEspectro(r);
if (!r) { $('rf').textContent = $('ra').textContent = $('rb').textContent = '–'; return; }
$('rf').textContent = r.hay ? nf(r.f) + ' Hz' : '–';
$('ra').textContent = r.hay ? nf(r.mm) + ' mm' : '–';
$('rb').textContent = r.hay ? (r.enFranja ? 'Sí' : 'No') : '–';
let t, v;
if (!r.hay) {
t = 'No se ha detectado un movimiento regular en estos quince segundos. Eso no significa nada más: el temblor va y viene.';
v = 'No se ha detectado un movimiento regular. El temblor va y viene.';
} else if (r.enFranja) {
t = `Se ha detectado un movimiento regular de ${nf(r.f)} Hz, dentro de la franja de 4 a 6 Hz. Es solo un dato: no es un diagnóstico.`;
v = `Movimiento regular de ${nf(r.f)} hercios, dentro de la franja de cuatro a seis. Es solo un dato.`;
} else {
t = `Se ha detectado un movimiento regular de ${nf(r.f)} Hz, fuera de la franja de 4 a 6 Hz. Muchas cosas lo producen (nervios, cansancio, cafeína, el propio pulso). No es un diagnóstico.`;
v = `Movimiento regular de ${nf(r.f)} hercios, fuera de la franja de cuatro a seis. No es un diagnóstico.`;
}
msg(t); decir(v);
}
function bucle() {
const ahora = performance.now() / 1000;
if (est === 'prep') {
const r = PREP - (ahora - tEst);
if (ahora - tEst > 2 && ev === evAntes) cancelar('El sensor de movimiento no envía datos. En Android, mire el candado junto a la dirección y permita "Sensores de movimiento". Abra la página directamente, no dentro de otra.', true);
else if (r <= 0) empezarMedida(ahora);
else { $('cuenta').textContent = 'Empiezo en ' + Math.ceil(r) + '…'; barra(0); }
} else if (est === 'medir') {
const r = DURACION - (ahora - tEst);
if (r <= 0) terminar(); else { $('cuenta').textContent = 'Quedan ' + Math.ceil(r) + ' s'; barra((ahora - tEst) / DURACION); }
}
traza(); requestAnimationFrame(bucle);
}
/* ---------- Registro (solo en este móvil) ---------- */
function leer() { try { return JSON.parse(localStorage.getItem(CLAVE)) || []; } catch (_) { return []; } }
function escribir(l) { try { localStorage.setItem(CLAVE, JSON.stringify(l.slice(-500))); return true; } catch (_) { return false; } }
const mismoDia = (a, b) => new Date(a).toDateString() === new Date(b).toDateString();
const hora = t => new Date(t).toLocaleTimeString('es-ES', {hour: '2-digit', minute: '2-digit'});
function pintarRegistro() {
const todo = leer(), hoy = todo.filter(e => mismoDia(e.t, Date.now()));
const {g, w, h} = lienzo($('hist'));
const m = {l: 34, r: 10, t: 10, b: 24}, W = w - m.l - m.r, H = h - m.t - m.b;
const max = Math.max(1, ...hoy.map(e => e.mm)), X = t => { const d = new Date(t); return m.l + (d.getHours() + d.getMinutes() / 60) / 24 * W; }, Y = v => m.t + H - v / max * H;
g.strokeStyle = C('--line'); g.lineWidth = 1; g.strokeRect(m.l, m.t, W, H);
g.fillStyle = C('--ink'); g.font = '13px system-ui'; g.textAlign = 'center';
[0, 6, 12, 18, 24].forEach(hh => g.fillText(hh + ' h', m.l + hh / 24 * W, h - 6));
g.textAlign = 'right'; g.fillText(nf(max) + ' mm', m.l - 4, m.t + 10); g.fillText('0', m.l - 4, m.t + H);
if (!hoy.length) { g.fillStyle = C('--mute'); g.textAlign = 'center'; g.fillText('Hoy todavía no hay medidas guardadas', m.l + W / 2, m.t + H / 2); }
g.strokeStyle = C('--pine'); g.lineWidth = 2; g.beginPath();
hoy.forEach((e, i) => i ? g.lineTo(X(e.t), Y(e.mm)) : g.moveTo(X(e.t), Y(e.mm))); g.stroke();
hoy.forEach(e => { // círculo lleno: dentro de 4 a 6 Hz · cuadrado vacío: el resto
const x = X(e.t), y = Y(e.mm);
if (e.hay && e.en) { g.fillStyle = C('--ocre'); g.beginPath(); g.arc(x, y, 6, 0, 7); g.fill(); }
else { g.strokeStyle = C('--pine'); g.lineWidth = 2.5; g.strokeRect(x - 5, y - 5, 10, 10); }
});
$('lista').innerHTML = '';
todo.slice(-6).reverse().forEach(e => {
const li = document.createElement('li');
li.textContent = `${new Date(e.t).toLocaleDateString('es-ES', {day: 'numeric', month: 'short'})} ${hora(e.t)} · ${e.p === 'reposo' ? 'reposo' : 'brazo'} · ` +
(e.hay ? `${nf(e.f)} Hz, ${nf(e.mm)} mm` : 'sin movimiento regular');
$('lista').appendChild(li);
});
}
/* ---------- Botones (con freno anti doble toque, por el temblor) ---------- */
const unico = fn => () => { const n = Date.now(); if (n - bloq < 500) return; bloq = n; fn(); };
$('go').onclick = unico(() => { est === 'idle' ? empezar() : cancelar('Medida cancelada.'); });
document.querySelectorAll('#pos button').forEach(b => b.onclick = unico(() => {
if (est !== 'idle') return;
pos = b.dataset.p; document.querySelectorAll('#pos button').forEach(x => x.setAttribute('aria-pressed', x === b));
}));
$('guardar').onclick = unico(() => {
if (!ultimo) return;
const l = leer(); l.push({t: Date.now(), p: pos, hay: ultimo.hay, f: +ultimo.f.toFixed(2), mm: +ultimo.mm.toFixed(2), en: ultimo.enFranja});
const ok = escribir(l);
msg(ok ? 'Guardado en su registro de este móvil.' : 'No he podido guardar en este navegador.', !ok);
ultimo = null; ponerResultado(null); pintarRegistro();
});
$('descartar').onclick = unico(() => { ultimo = null; ponerResultado(null); msg('Medida descartada.'); });
$('copiar').onclick = unico(async () => {
const l = leer(); if (!l.length) { msg('Todavía no hay nada que copiar.'); return; }
const csv = ['fecha;hora;posicion;frecuencia_hz;amplitud_mm;movimiento_regular;franja_4_6'].concat(l.map(e =>
[new Date(e.t).toLocaleDateString('es-ES'), hora(e.t), e.p, e.hay ? nf(e.f, 2) : '', e.hay ? nf(e.mm, 2) : '', e.hay ? 'si' : 'no', e.hay ? (e.en ? 'si' : 'no') : ''].join(';'))).join('\n');
try { await navigator.clipboard.writeText(csv); msg('Registro copiado. Puede pegarlo en un correo o en una hoja de cálculo.'); }
catch (_) { msg('Este navegador no deja copiar aquí.', true); }
});
$('borrar').onclick = unico(() => {
if (Date.now() - borrarT > 4000) { borrarT = Date.now(); $('borrar').textContent = 'Toque otra vez para borrar todo'; setTimeout(() => $('borrar').textContent = 'Borrar registro', 4000); return; }
try { localStorage.removeItem(CLAVE); } catch (_) {}
$('borrar').textContent = 'Borrar registro'; borrarT = 0; pintarRegistro(); msg('Registro borrado.');
});
dibujarEspectro(null); pintarRegistro(); requestAnimationFrame(bucle);
window.addEventListener('resize', () => { dibujarEspectro(ultimo); pintarRegistro(); });
document.addEventListener('visibilitychange', () => { if (est !== 'idle' && document.visibilityState === 'hidden') cancelar('Medida cancelada: la página se ha quedado en segundo plano.', true); });
if (window.self !== window.top) msg('Esta página está incrustada en otra: el sensor no funcionará. Ábrala en su propia dirección.', true);
else if (!window.isSecureContext) msg('La dirección no es https: el sensor no funcionará.', true);
</script>
</body>
</html>
Etiqueta:acelerómetro del móvil, análisis de frecuencias, aprendizaje-servicio, Chismes del Lince, datos de salud, devicemotion, física con el móvil, Gata Montesa, ies monterroso, lince de la noche, medidor de temblor, privacidad, sismógrafo de la mano, tecnología y Parkinson, temblor de reposo, transformada de Fourier



