
Chismes del Lince 14: La Espiral del Lince, medir el tamaño y el temblor de un dibujo
Categorías: AIDARAC – Ciencia Ciudadana · Los Chismes del Lince · Proyecto AIDARAC
Atardecer en el claro del pinsapo. La Gata ha dibujado un punto en la tierra con una ramita.
—Lince, dibuja una espiral desde ahí, hacia fuera. Despacio.
El Lince saca la lengua, se concentra y empieza. La primera vuelta sale redonda. La segunda, más apretada. La tercera parece un caracol con hipo.
—¡Me ha salido un muelle!
—Te ha salido un dato. Las vueltas se te van cerrando y la línea tiembla un poquito. Las dos cosas se pueden medir.
—¿Y eso cómo se mide con el dedo?
La Gata sonríe: —Con un canvas, trigonometría y la transformada de Fourier que ya conoces del chisme 11.
Introducción
El móvil ya había escuchado, hablado, contado pasos, medido la voz y la mano, y llevado un diario. En el Chisme 13 usó la pantalla como tambor. Ahora la usa como papel: dibujas una espiral con el dedo y la aplicación mira dos cosas.
- El tamaño del trazo y cómo cambia la separación entre vueltas: si la espiral se va apretando (en el lenguaje clínico se habla de micrografía, que es dibujar o escribir cada vez más pequeño).
- El temblor del dibujo: cuánto y a qué frecuencia oscila la línea alrededor de la espiral «ideal». Para esto reutilizamos la FFT del Chisme 11, que ahora analiza un trazo en vez de un acelerómetro.
Dibujar espirales es una prueba clásica en consulta, y por eso nos interesa. Pero el límite es evidente y lo ponemos desde el principio: un dedo sobre un cristal no es un bolígrafo sobre un papel.
Reglas de diseño, las de siempre: botones enormes, nada de teclear, freno anti toques extra (solo cuenta un dedo) y todo lo que dice la Gata, escrito en grande.
⚠️ Esto es una prueba técnica, no un diagnóstico
No es un producto sanitario y no diagnostica nada. Una espiral que se aprieta o tiembla puede deberse al cansancio, a la hora del día, a la pantalla, al dedo que uses o a que hoy no te apetecía dibujar. Si a alguien le preocupa su movimiento, tiene que hablar con su médico.
Como en los Chismes 11 y 13, no usa IA ni Worker. 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.
Pruébala aquí (en su propia dirección, en el móvil): https://montesteam.org/app/chisme14espiral.html
Cómo funciona por dentro
Dedo → pointerdown/move/up → lista de puntos (x, y, t) → coordenadas polares → tamaño y separación → radio residual → FFT → gráfica propia
- Pulsas «Empezar». Suena un pitido y el
canvasse activa. Aparece un punto en el centro. - Dibujas la espiral hacia fuera. Cada punto guarda dónde (
x,y) y cuándo (event.timeStamp). Se usagetCoalescedEvents()cuando el navegador lo ofrece, para recuperar los puntos que se agrupan entre fotogramas. - Al levantar el dedo se analiza el trazo.
Cuatro decisiones de ingeniería
1. pointer events y touch-action: none. Sin esa regla, el navegador entiende que quieres desplazar la página y se lleva el dedo. Solo se acepta un puntero: un segundo dedo se ignora.
2. Polares, no píxeles. Una espiral se entiende mejor como «radio en función del ángulo». Tomamos como centro el primer punto del trazo, calculamos el ángulo con atan2, lo desenrollamos (para que 359° → 361° no salte a 1°) y así sabemos cuántas vueltas hay y a qué distancia está cada punto.
3. Medir la separación, no solo el tamaño. El tamaño final depende de dónde decidas parar. La separación entre vueltas es la pendiente de una recta radio–ángulo. Se calcula en la primera mitad del recorrido y en la segunda, y se compara. Si la segunda separación es mucho menor, la espiral se está apretando.
4. Nada de «leve» ni «grave». Solo hay avisos con dos umbrales (MICRO_MIN y BANDA_MIN). Son valores de partida nuestros, sin validar, y están al principio del código.
El análisis, paso a paso
- Tamaño: distancia máxima al centro ÷ radio de la pantalla de dibujo.
- Cambio de la separación: (separación de la 1ª mitad − separación de la 2ª) ÷ separación de la 1ª.
- Temblor, en cinco pasos:
- Nos quedamos con el radio en cada instante y lo remuestreamos a 64 Hz (los puntos del móvil no llegan a intervalos iguales).
- Le restamos su tendencia lenta (una media móvil de medio segundo): queda solo lo que oscila rápido.
- Aplicamos una ventana de Hann y la FFT, la del Chisme 11.
- Sumamos la energía de la banda de 3 a 12 Hz, que es deliberadamente amplia, y miramos qué fracción es del total.
- Buscamos el pico. Solo se muestra si supera cuatro veces la media de la banda; si no, la pantalla dice «sin pico».
- Amplitud del temblor: el valor eficaz (RMS) de esa banda, en píxeles.
Lo probamos con una espiral sintética: sin temblor, la energía en banda sale ≈ 0 %; con un temblor de 5 Hz y 2 píxeles, sale ≈ 100 % y el pico en 5 Hz. Es una comprobación del código, no de la persona.
Una duda honesta: ¿temblor de la mano o ruido del móvil?
La aplicación no puede distinguirlo del todo. Un pico en 5 Hz puede venir del temblor, pero también de un dedo que roza el cristal a trompicones o de cómo el móvil filtra el tacto. Por eso la pantalla enseña el espectro: para que se vea qué hay, no solo un número.
Los límites: qué da de sí esta prueba
- El dedo no es un bolígrafo. Hay rozamiento con el cristal, el contacto es una mancha y no una punta, y la muñeca se apoya distinto. Todo eso suaviza o altera el temblor real.
- El tamaño de la pantalla manda. Una espiral no puede crecer más que el
canvas. Si el móvil es pequeño, o la espiral ya ocupa casi todo, la micrografía no tiene dónde verse. Por eso el tamaño se da en % de la pantalla, no en milímetros: no conocemos el tamaño físico del píxel. - La frecuencia de muestreo del móvil. Hay móviles que dan 60 puntos por segundo y otros 120 o más. Si da menos de unos 30, la aplicación pone «no disp.» en el temblor y no se inventa nada.
- Solo miramos una componente. Medimos el temblor en la dirección del radio, no a lo largo del trazo.
- La variabilidad entre horas. Cansancio, sueño, medicación o ánimo cambian la espiral. Una sola prueba vale poco.
- Por eso se compara consigo mismo. La aplicación enseña tu media de las últimas pruebas guardadas. Lo que importa es tu tendencia, en el mismo móvil, con el mismo dedo y en condiciones parecidas. Nunca compararte con otra persona.
- Un móvil no es un aparato médico. Sirve para aprender y para preguntar, no para medir con precisión clínica.
Para el aula
- Genera tu propio temblor. Haz una espiral normal y otra moviendo el dedo a propósito, a un ritmo constante. ¿Qué cambia en el espectro? ¿Aparece un pico? ¿En qué frecuencia?
- Cambia la banda. Pon
F1 = 8yF2 = 12. ¿Qué pasa con el porcentaje de energía? ¿Por qué elegimos una banda tan ancha? - Rompe el umbral. Pon
MICRO_MINen 0,1 y en 0,6. ¿Cuántas espirales «salen mal»? Así se entiende qué es un umbral sin validar. - Cambia
FS. Pasa de 64 a 32 y a 128. ¿Qué frecuencias dejan de verse? Es el teorema de Nyquist en acción. - Móvil contra tablet. Haz la misma espiral en una pantalla grande y en una pequeña. ¿Cambia el porcentaje de tamaño? ¿Y el temblor?
- Dedo contra lápiz táctil. Si hay uno en clase, compara. ¿Cuánto del temblor era del dedo y no de la mano?
- Conecta con el 11. ¿En qué se parece el espectro del acelerómetro al del trazo? ¿En qué no?
- Piensa en la ética. ¿Quién debería ver este registro? ¿Qué pasaría si lo viera un seguro?
Del aula a la calle: Aprendizaje-Servicio
La ruta de siempre: aprender → escuchar → construir y probar con acompañamiento → mejorar → devolver. Queremos ofrecérsela a Parkinson Sol Estepona y preguntar sobre todo a profesionales (neurología, fisioterapia) si este dato les sirve para algo. Lo de ofrecerla es literal: puede que no les interese, y también aprenderemos.
Lo que queremos aprender de las personas
- ¿Se entiende qué hay que dibujar? ¿Ayuda el punto del centro?
- ¿Tres vueltas son pocas, muchas o justas? ¿Se cansa el brazo?
- ¿Se entiende «separación entre vueltas»? ¿Cómo lo explicarías tú?
- ¿Es incómodo dibujar con el dedo? ¿Mejor con un lápiz táctil?
- ¿Genera ansiedad medirse? Si alguien se siente incómodo, se para.
- ¿Qué querría ver un profesional en el registro que ahora no está?
Paso 1: prepara la página
Este chisme no necesita Worker ni clave de API. Copia el código de la sección HTML y guárdalo como chisme14espiral.html en tu carpeta app. Las constantes están al principio del <script>:
const VUELTAS_MIN = 1.5; // vueltas mínimas para analizar
const DUR_MIN = 2; // segundos mínimos de trazo
const MICRO_MIN = 0.3; // caída de la separación que avisamos (sin validar)
const BANDA_MIN = 0.3; // fracción de energía en banda que avisamos (sin validar)
const F1 = 3, F2 = 12; // banda de temblor (Hz)
const FS = 64; // Hz de remuestreo
Paso 2: súbela y ábrela
Ábrela directamente en su dirección (no dentro de una entrada de WordPress), desde el móvil y con https.
Paso 3: pruébala
- Apoya el móvil en una mesa, siéntate y pulsa «Empezar».
- Empieza en el punto del centro y dibuja una espiral hacia fuera, unas 3 vueltas, sin levantar el dedo.
- Levanta el dedo y mira el tamaño, la separación, el temblor y el espectro.
- Pulsa «Guardar en mi registro» o «Descartar y repetir».
- Repite a distintas horas y compara con tu media. Prueba «Copiar registro» para llevarlo a una hoja de cálculo.
Si algo falla
- Dice que ha salido muy corto o con pocas vueltas: dibuja más grande y sin levantar el dedo. Hacen falta al menos 1,5 vueltas.
- Dice que has ido muy deprisa: menos de 2 segundos no da para analizar el temblor.
- El temblor sale «no disp.»: tu móvil toma pocos puntos por segundo. Es normal en algunos modelos.
- La página se mueve al dibujar: ábrela en su propia dirección, no incrustada.
- El registro desaparece: vive en el navegador. Si borras los datos o usas modo incógnito, se pierde. Por eso existe «Copiar registro».
Conclusión
Con un canvas, pointer events, un poco de trigonometría y la FFT que ya teníamos hemos hecho una prueba que mira el tamaño y el temblor de un dibujo: dedo → puntos con tiempo → polares → separación → radio residual → FFT → registro. Lo interesante, de nuevo, son las decisiones: no usar IA con datos de salud, medir la separación y no solo el tamaño, decir «no disp.» y «sin pico» antes que inventar, enseñar el espectro en vez de esconderlo tras un número, reconocer que un dedo no es un bolígrafo y que los umbrales son nuestros y están sin validar.
El Lince ya sabe que su espiral se aprieta. Ahora tiene un espectro para mirarlo sin dramatismo. La Gata, que lo sabía desde el principio, ya está pensando en el chisme 15.
⚠️ 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.
Es un dato de salud. La aplicación no lo 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.
Un móvil compartido comparte el registro. Usa el botón de borrar al terminar una sesión de prueba.
La medida es orientativa. Los umbrales (30 % de separación y 30 % de energía en banda) son valores de partida, sin validar. No saques conclusiones de una sola prueba ni pongas etiquetas como «leve» o «grave».
Prueba con seguridad. Sentado y sin forzar. Si hay cansancio, dolor o ansiedad, se para.
Si lo usas con menores o en un centro educativo, revisa 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.

HTML
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Chisme 14 · La Espiral del Lince</title>
<style>
:root{--bg:#edf0ea;--cd:#fafbf8;--tx:#1b2a24;--mu:#58665f;--ac:#1f6b52;--tr:#a63d2f;--bd:#c9d2c8}
@media(prefers-color-scheme:dark){:root{--bg:#141c18;--cd:#1e2823;--tx:#e8efe9;--mu:#a2b1a8;--ac:#6fc3a2;--tr:#ee8a78;--bd:#38473f}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);font:18px/1.45 Georgia,"Times New Roman",serif;padding:12px 12px 40px;-webkit-text-size-adjust:100%}
main{max-width:600px;margin:0 auto}
h1{font-size:1.5rem;margin:.3em 0 .1em}
h2{font-size:1.15rem;margin:1.2em 0 .4em}
.sub{color:var(--mu);margin:0 0 .8em;font-size:.95rem}
.gata{background:var(--cd);border-left:6px solid var(--ac);padding:12px 14px;font-size:1.2rem;margin:10px 0;border-radius:4px;min-height:5.2em}
#cvw{display:flex;justify-content:center;margin:8px 0}
canvas#cv{background:var(--cd);border:3px solid var(--bd);border-radius:8px;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none}
body[data-s=ready] canvas#cv,body[data-s=draw] canvas#cv{border-color:var(--ac)}
.row{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0}
button{flex:1 1 150px;min-height:58px;font:inherit;font-weight:700;font-size:1.1rem;border:2px solid var(--ac);background:var(--cd);color:var(--ac);border-radius:10px;padding:8px 14px;touch-action:manipulation;cursor:pointer}
button.main{background:var(--ac);color:var(--bg)}
button:focus-visible{outline:4px solid var(--tr);outline-offset:2px}
.sm{min-height:46px;font-size:.95rem;font-weight:400}
body:not([data-s=idle]) .only-idle,body:not([data-s=done]) .only-done{display:none}
#res{display:none}body[data-s=done] #res{display:block}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.k{background:var(--cd);border:1px solid var(--bd);border-radius:8px;padding:10px 12px}
.k b{display:block;font-size:1.7rem;line-height:1.15}
.k span{color:var(--mu);font-size:.9rem}
.k.t b{color:var(--tr)}
#sp{width:100%;height:160px;display:block;background:var(--cd);border:1px solid var(--bd);border-radius:8px;margin-top:10px}
.msg{font-size:1.1rem;margin:10px 0}
.warn{border:2px solid var(--tr);border-radius:8px;padding:10px 12px;font-size:.95rem;margin-top:22px}
small{color:var(--mu)}
</style>
</head>
<body data-s="idle">
<main>
<h1>La Espiral del Lince</h1>
<p class="sub">Chisme 14 · Prueba técnica de dibujo · Sin IA · Los datos no salen del móvil</p>
<div class="gata" id="gata" aria-live="polite"></div>
<div id="cvw"><canvas id="cv" aria-label="Zona para dibujar la espiral"></canvas></div>
<div class="row">
<button class="main only-idle" id="bStart">Empezar</button>
<button class="only-idle sm" id="bHand">Mano: derecha</button>
<button class="main only-done" id="bSave">Guardar en mi registro</button>
<button class="only-done" id="bDiscard">Descartar y repetir</button>
</div>
<section id="res">
<h2>Resultado</h2>
<div class="grid" id="cards"></div>
<p class="msg" id="verdict"></p>
<canvas id="sp"></canvas>
<small id="spcap"></small>
</section>
<section>
<h2>Mi registro</h2>
<p class="msg" id="regInfo"></p>
<div class="row">
<button class="sm" id="bCopy">Copiar registro</button>
<button class="sm" id="bClear">Borrar registro</button>
</div>
</section>
<div class="warn"><b>⚠️ Esto es una prueba técnica, no un diagnóstico.</b> No es un producto sanitario. Una espiral puede salir distinta por el cansancio, la hora, la pantalla o el dedo. Si algo te preocupa de tu movimiento, habla con tu médico. Los datos de salud se quedan en este móvil: borra el registro si lo compartes.</div>
</main>
<script>
// ---- Valores de partida NUESTROS, sin validar (cámbialos y experimenta) ----
const VUELTAS_MIN = 1.5; // vueltas mínimas para analizar
const DUR_MIN = 2; // segundos mínimos de trazo
const MICRO_MIN = 0.3; // caída de la separación entre vueltas que avisamos
const BANDA_MIN = 0.3; // fracción de potencia en la banda de temblor que avisamos
const F1 = 3, F2 = 12; // banda de temblor (Hz), a propósito amplia
const FS = 64; // Hz a los que remuestreamos el trazo
const PICO_RATIO = 4; // el pico debe superar 4 veces la media de la banda
const K = 'chisme14_registro';
const $ = id => document.getElementById(id);
const cv = $('cv'), ctx = cv.getContext('2d');
const nf = (v, d = 0) => v.toFixed(d).replace('.', ',');
let W = 0, pts = [], drawing = false, pid = null, ready = false, res = null, mano = 'derecha', reg = [];
const TXT = {
idle: 'Pulsa «Empezar». Luego dibuja con el dedo una espiral desde el punto del centro hacia fuera: unas 3 vueltas, sin prisa y sin levantar el dedo.',
ready: '¡Ahora! Empieza en el punto y ve hacia fuera. No levantes el dedo hasta terminar.',
draw: 'Sigue, sigue… Cuando acabes, levanta el dedo.'
};
function estado(s, t) { document.body.dataset.s = s; $('gata').textContent = t || TXT[s] || ''; }
// ---------- Canvas ----------
function tam() {
const s = Math.floor(Math.min(innerWidth - 30, innerHeight * 0.58, 560)), d = devicePixelRatio || 1;
if (s === W) return;
W = s; cv.style.width = cv.style.height = s + 'px'; cv.width = cv.height = s * d; ctx.setTransform(d, 0, 0, d, 0, 0); redraw();
}
function css(v) { return getComputedStyle(document.documentElement).getPropertyValue(v).trim(); }
function redraw() {
ctx.clearRect(0, 0, W, W);
ctx.strokeStyle = css('--bd'); ctx.setLineDash([4, 6]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(W / 2, W / 2, W / 2 - 2, 0, 7); ctx.stroke(); ctx.setLineDash([]);
ctx.fillStyle = css('--ac'); ctx.beginPath(); ctx.arc(W / 2, W / 2, 6, 0, 7); ctx.fill();
if (pts.length > 1) {
ctx.strokeStyle = css('--tr'); ctx.lineWidth = 3; ctx.lineJoin = ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (const p of pts) ctx.lineTo(p.x, p.y); ctx.stroke();
}
}
function add(e) {
const b = cv.getBoundingClientRect(), p = { x: e.clientX - b.left, y: e.clientY - b.top, t: e.timeStamp }, q = pts[pts.length - 1];
if (q && p.t <= q.t) return;
pts.push(p);
if (q) { ctx.strokeStyle = css('--tr'); ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(q.x, q.y); ctx.lineTo(p.x, p.y); ctx.stroke(); }
}
cv.addEventListener('pointerdown', e => {
if (!ready || drawing) return; // freno: solo un dedo, solo si estamos listos
e.preventDefault(); cv.setPointerCapture(e.pointerId); pid = e.pointerId; drawing = true; pts = []; redraw(); add(e); estado('draw');
});
cv.addEventListener('pointermove', e => {
if (!drawing || e.pointerId !== pid) return;
const l = e.getCoalescedEvents ? e.getCoalescedEvents() : []; (l.length ? l : [e]).forEach(add);
});
function fin(e) { if (!drawing || e.pointerId !== pid) return; drawing = false; ready = false; pit(660, .2); analizar(); }
cv.addEventListener('pointerup', fin); cv.addEventListener('pointercancel', fin);
cv.addEventListener('contextmenu', e => e.preventDefault());
addEventListener('resize', () => { if (!drawing && !ready && document.body.dataset.s === 'idle') tam(); });
function pit(f, d) {
try { const a = pit.a || (pit.a = new AudioContext()), o = a.createOscillator(), g = a.createGain(); o.frequency.value = f; g.gain.value = .1; o.connect(g); g.connect(a.destination); o.start(); o.stop(a.currentTime + d); } catch (e) {}
}
// ---------- Fourier (la FFT del Chisme 11: misma idea, aquí en una función) ----------
function fft(re, im) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) { let b = n >> 1; for (; j & b; b >>= 1) j ^= b; j ^= b; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } }
for (let L = 2; L <= n; L <<= 1) {
const a = -2 * Math.PI / L;
for (let i = 0; i < n; i += L) for (let k = 0; k < L / 2; k++) {
const c = Math.cos(a * k), s = Math.sin(a * k), u = i + k, v = u + L / 2, tr = re[v] * c - im[v] * s, ti = re[v] * s + im[v] * c;
re[v] = re[u] - tr; im[v] = im[u] - ti; re[u] += tr; im[u] += ti;
}
}
}
function pend(x, y) {
const n = x.length, mx = x.reduce((a, b) => a + b, 0) / n, my = y.reduce((a, b) => a + b, 0) / n; let sxy = 0, sxx = 0;
for (let i = 0; i < n; i++) { sxy += (x[i] - mx) * (y[i] - my); sxx += (x[i] - mx) ** 2; }
return sxx ? sxy / sxx : 0;
}
// ---------- Análisis ----------
function fallo(m) { res = null; redraw(); estado('idle', m + ' Pulsa «Empezar» para repetir.'); pts = []; }
function analizar() {
if (pts.length < 30) return fallo('El trazo ha salido muy corto.');
const c = pts[0], T0 = c.t; let acc = 0, pa = null; const A = [], R = [], T = [];
for (const p of pts) {
const dx = p.x - c.x, dy = p.y - c.y, r = Math.hypot(dx, dy); if (r < 4) continue;
const a = Math.atan2(dy, dx);
if (pa !== null) { let d = a - pa; if (d > Math.PI) d -= 2 * Math.PI; if (d < -Math.PI) d += 2 * Math.PI; acc += d; }
pa = a; A.push(acc); R.push(r); T.push((p.t - T0) / 1000);
}
if (A.length < 30) return fallo('El trazo ha salido muy corto.');
const sg = Math.sign(A[A.length - 1]) || 1; for (let i = 0; i < A.length; i++) A[i] *= sg;
const th = A[A.length - 1], vueltas = th / (2 * Math.PI), dur = T[T.length - 1];
if (vueltas < VUELTAS_MIN) return fallo('Solo ' + nf(vueltas, 1) + ' vueltas: hacen falta al menos ' + nf(VUELTAS_MIN, 1) + '.');
if (dur < DUR_MIN) return fallo('Has ido muy deprisa (' + nf(dur, 1) + ' s). Más despacio.');
// 1. Tamaño y separación entre vueltas (micrografía)
const rmax = Math.max(...R), tamano = rmax / (W / 2), h = th / 2, x1 = [], y1 = [], x2 = [], y2 = [];
for (let i = 0; i < A.length; i++) { (A[i] < h ? x1 : x2).push(A[i]); (A[i] < h ? y1 : y2).push(R[i]); }
const s1 = pend(x1, y1) * 2 * Math.PI, s2 = pend(x2, y2) * 2 * Math.PI, cambio = s1 > 1 ? (s1 - s2) / s1 : null;
// 2. Temblor: residuo radial → remuestreo → ventana → FFT
const dts = []; for (let i = 1; i < T.length; i++) dts.push(T[i] - T[i - 1]); dts.sort((a, b) => a - b);
const fsReal = 1 / dts[dts.length >> 1];
let tr = null;
if (fsReal >= 2.4 * F2) {
const N = Math.floor(dur * FS), y = new Float64Array(N); let j = 0;
for (let i = 0; i < N; i++) {
const t = i / FS; while (j < T.length - 2 && T[j + 1] < t) j++;
const f = T[j + 1] > T[j] ? Math.min(1, Math.max(0, (t - T[j]) / (T[j + 1] - T[j]))) : 0; y[i] = R[j] + (R[j + 1] - R[j]) * f;
}
const e = new Float64Array(N);
for (let i = 0; i < N; i++) { let s = 0, k = 0; for (let q = Math.max(0, i - 16); q <= Math.min(N - 1, i + 16); q++) { s += y[q]; k++; } e[i] = y[i] - s / k; }
const a0 = Math.round(.25 * FS), Nn = N - 2 * a0;
if (Nn >= 64) {
let NF = 1; while (NF < Nn * 2) NF <<= 1;
const re = new Float64Array(NF), im = new Float64Array(NF);
for (let i = 0; i < Nn; i++) re[i] = e[a0 + i] * (.5 - .5 * Math.cos(2 * Math.PI * i / (Nn - 1)));
fft(re, im);
const df = FS / NF, P = []; for (let k = 0; k <= NF / 2; k++) P.push(re[k] ** 2 + im[k] ** 2);
const kA = Math.ceil(F1 / df), kB = Math.floor(F2 / df), kT = Math.floor(1 / df);
let Pb = 0, Pt = 0, km = kA;
for (let k = kT; k < P.length; k++) Pt += P[k];
for (let k = kA; k <= kB; k++) { Pb += P[k]; if (P[k] > P[km]) km = k; }
const media = Pb / (kB - kA + 1);
tr = { banda: Pb / Pt, f: P[km] >= PICO_RATIO * media ? km * df : null, amp: Math.sqrt(2 * Pb / (NF * Nn) / .375), P, df };
}
}
res = { vueltas, dur, tam: tamano, s1, s2, cambio, tr, fsReal, W };
mostrar();
}
// ---------- Resultados ----------
function mostrar() {
const r = res, t = r.tr, k = (v, l, c) => `<div class="k ${c || ''}"><b>${v}</b><span>${l}</span></div>`;
$('cards').innerHTML =
k(nf(r.tam * 100) + ' %', 'del radio de la pantalla que ocupa') +
k(nf(r.vueltas, 1), 'vueltas en ' + nf(r.dur, 1) + ' s') +
k(r.cambio === null ? 'no disp.' : nf(r.cambio * 100) + ' %', 'cuánto se aprieta la separación entre vueltas (1ª mitad → 2ª)') +
k(t ? nf(t.banda * 100) + ' %' : 'no disp.', 'de la energía del temblor del trazo en ' + F1 + '–' + F2 + ' Hz', 't') +
k(t && t.f ? nf(t.f, 1) + ' Hz' : (t ? 'sin pico' : 'no disp.'), 'frecuencia dominante en esa banda', 't') +
k(t ? nf(t.amp, 1) + ' px' : 'no disp.', 'amplitud del temblor (RMS), en píxeles de pantalla', 't');
let v = [];
v.push(r.cambio === null ? 'No he podido medir la separación entre vueltas.' :
r.cambio > MICRO_MIN ? 'Las vueltas se han apretado más de un ' + nf(MICRO_MIN * 100) + ' % de la primera mitad a la segunda.' : 'La separación entre vueltas se ha mantenido bastante parecida.');
v.push(!t ? 'Tu móvil toma pocos puntos por segundo (' + nf(r.fsReal) + ' Hz): no puedo mirar el temblor.' :
(t.banda > BANDA_MIN && t.f) ? 'Hay un pico de movimiento hacia los ' + nf(t.f, 1) + ' Hz.' : 'No destaca ningún pico claro en la banda de temblor.');
v.push('Una sola espiral vale poco: compárala con las tuyas de otros días.');
$('verdict').textContent = v.join(' ');
$('res').style.display = '';
estado('done', 'Ya está. Mira los números sin dramatismo: son un dato, no un veredicto. Guarda la prueba o descártala.');
if (t) { $('sp').style.display = 'block'; espectro(t); $('spcap').textContent = 'Espectro del temblor del trazo (0–20 Hz). La zona sombreada es la banda ' + F1 + '–' + F2 + ' Hz.'; }
else { $('sp').style.display = 'none'; $('spcap').textContent = ''; }
}
function espectro(t) {
const c = $('sp'), x = c.getContext('2d'), d = devicePixelRatio || 1, w = c.clientWidth, h = 160, m = 22, fmax = 20;
c.width = w * d; c.height = h * d; x.setTransform(d, 0, 0, d, 0, 0); x.clearRect(0, 0, w, h);
const kmax = Math.floor(fmax / t.df); let pm = 0; for (let k = Math.ceil(1 / t.df); k <= kmax; k++) pm = Math.max(pm, t.P[k]);
const X = f => 8 + f / fmax * (w - 16), Y = p => h - m - p / pm * (h - m - 10);
x.fillStyle = css('--tr'); x.globalAlpha = .15; x.fillRect(X(F1), 4, X(F2) - X(F1), h - m - 4); x.globalAlpha = 1;
x.strokeStyle = css('--tr'); x.lineWidth = 2; x.beginPath();
for (let k = 0; k <= kmax; k++) { const px = X(k * t.df), py = Y(Math.min(t.P[k], pm)); k ? x.lineTo(px, py) : x.moveTo(px, py); } x.stroke();
x.fillStyle = css('--mu'); x.font = '12px sans-serif'; x.textAlign = 'center';
for (let f = 0; f <= fmax; f += 5) x.fillText(f + ' Hz', Math.min(w - 16, Math.max(16, X(f))), h - 6);
}
// ---------- Registro ----------
try { reg = JSON.parse(localStorage.getItem(K) || '[]'); } catch (e) { reg = []; }
function guardaLS() { try { localStorage.setItem(K, JSON.stringify(reg.slice(-200))); } catch (e) {} }
function pintaReg() {
if (!reg.length) { $('regInfo').textContent = 'Todavía no hay pruebas guardadas.'; return; }
const u = reg.slice(-5), m = f => { const v = u.map(f).filter(x => x !== null && x !== undefined); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null; };
const a = m(o => o.tam), b = m(o => o.cambio), c = m(o => o.banda);
$('regInfo').textContent = 'Pruebas guardadas: ' + reg.length + '. Tu media de las últimas ' + u.length + ' → tamaño ' + (a === null ? 'no disp.' : nf(a * 100) + ' %') +
', cambio de separación ' + (b === null ? 'no disp.' : nf(b * 100) + ' %') + ', energía en banda de temblor ' + (c === null ? 'no disp.' : nf(c * 100) + ' %') + '.';
}
$('bStart').onclick = () => { pts = []; res = null; ready = true; redraw(); pit(880, .15); estado('ready'); };
$('bHand').onclick = () => { mano = mano === 'derecha' ? 'izquierda' : 'derecha'; $('bHand').textContent = 'Mano: ' + mano; };
$('bDiscard').onclick = () => { pts = []; res = null; redraw(); estado('idle'); };
$('bSave').onclick = () => {
const r = res, t = r.tr;
reg.push({ d: new Date().toISOString().slice(0, 16).replace('T', ' '), m: mano, v: r.vueltas, dur: r.dur, tam: r.tam, s1: r.s1, s2: r.s2, cambio: r.cambio, f: t && t.f ? t.f : null, banda: t ? t.banda : null, amp: t ? t.amp : null, w: r.W });
guardaLS(); pintaReg(); pts = []; res = null; redraw(); estado('idle', 'Guardada. Cuando quieras otra, pulsa «Empezar».');
};
$('bCopy').onclick = () => {
const n = (v, d) => v === null || v === undefined ? '' : v.toFixed(d).replace('.', ',');
const L = ['fecha\tmano\tvueltas\tduracion_s\ttam_pct\tsep_ini_px\tsep_fin_px\tcambio_pct\tf_pico_hz\tbanda_pct\tamp_px\tancho_px'];
reg.forEach(o => L.push([o.d, o.m, n(o.v, 2), n(o.dur, 1), n(o.tam * 100, 1), n(o.s1, 1), n(o.s2, 1), o.cambio === null ? '' : n(o.cambio * 100, 1), n(o.f, 1), o.banda === null ? '' : n(o.banda * 100, 1), n(o.amp, 2), o.w].join('\t')));
const s = L.join('\n');
(navigator.clipboard ? navigator.clipboard.writeText(s) : Promise.reject()).then(() => alert('Registro copiado. Pégalo en una hoja de cálculo.'), () => prompt('Copia el registro:', s));
};
$('bClear').onclick = () => { if (confirm('¿Borrar todo el registro de este móvil?')) { reg = []; guardaLS(); pintaReg(); } };
tam(); estado('idle'); pintaReg();
</script>
</body>
</html>
Etiquetas: AIDARAC, aprendizaje-servicio, micrografía, temblor, espiral, canvas, transformada de Fourier, FFT, pointer events, datos de salud, Chismes del Lince, Gata Montesa, ies monterroso, montesteam, localStorage, privacidad, tecnología y Parkinson



