Skip to content

Commit 14f7295

Browse files
Merge pull request #5 from Santiago13dev/santidev
Animación temporal de las ondas P/S con un “timeline”
2 parents 9b40875 + d0a6f57 commit 14f7295

File tree

3 files changed

+216
-8
lines changed

3 files changed

+216
-8
lines changed

frontend/app/page.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState, useMemo, useEffect } from "react";
44
import dynamic from "next/dynamic";
5+
import Playback from "../components/Playback"; // timeline play/pause/slider
56

67
// Carga en cliente
78
const Globe = dynamic(() => import("../components/Globe"), { ssr: false });
@@ -27,6 +28,9 @@ export default function Home() {
2728
const [resp, setResp] = useState<SimResponse | null>(null);
2829
const [error, setError] = useState<string | null>(null);
2930

31+
// Timeline (minutos)
32+
const [liveT, setLiveT] = useState(0);
33+
3034
// Chequeo de texturas para evitar crash del loader
3135
const [texOk, setTexOk] = useState<boolean | null>(null);
3236
useEffect(() => {
@@ -38,7 +42,9 @@ export default function Home() {
3842
"/textures/earth_normal_2k.jpg",
3943
"/textures/earth_specular_2k.jpg",
4044
];
41-
const resps = await Promise.all(urls.map((u) => fetch(u, { method: "HEAD", cache: "no-store" })));
45+
const resps = await Promise.all(
46+
urls.map((u) => fetch(u, { method: "HEAD", cache: "no-store" }))
47+
);
4248
if (!alive) return;
4349
setTexOk(resps.every((r) => r.ok));
4450
} catch {
@@ -59,14 +65,44 @@ export default function Home() {
5965
return null;
6066
}, [lat, lon]);
6167

62-
// Adaptar formato para el globo
68+
// Adaptar formato para el globo (solo radios)
6369
const ringsForGlobe = useMemo(() => {
6470
const p = resp?.rings?.P?.map((r) => r.radiusKm) ?? [];
6571
const s = resp?.rings?.S?.map((r) => r.radiusKm) ?? [];
6672
if (p.length === 0 && s.length === 0) return null;
6773
return { p, s };
6874
}, [resp]);
6975

76+
// Estimar velocidades P/S a partir de los anillos (si existen)
77+
const vp = useMemo(() => {
78+
const arr = resp?.rings?.P;
79+
if (!arr || arr.length === 0) return undefined;
80+
const best = [...arr].sort((a, b) => a.minutes - b.minutes)[0];
81+
if (!best || best.minutes <= 0) return undefined;
82+
return best.radiusKm / (best.minutes * 60); // km/s
83+
}, [resp]);
84+
85+
const vs = useMemo(() => {
86+
const arr = resp?.rings?.S;
87+
if (!arr || arr.length === 0) return undefined;
88+
const best = [...arr].sort((a, b) => a.minutes - b.minutes)[0];
89+
if (!best || best.minutes <= 0) return undefined;
90+
return best.radiusKm / (best.minutes * 60); // km/s
91+
}, [resp]);
92+
93+
// Tope del timeline (min) según data (fallback 60)
94+
const maxTimeline = useMemo(() => {
95+
const pMax = Math.max(0, ...(resp?.rings?.P?.map((r) => r.minutes) ?? [0]));
96+
const sMax = Math.max(0, ...(resp?.rings?.S?.map((r) => r.minutes) ?? [0]));
97+
const m = Math.max(pMax, sMax);
98+
return m > 0 ? Math.ceil(m * 1.1) : 60;
99+
}, [resp]);
100+
101+
// Reset del timeline al cambiar simulación
102+
useEffect(() => {
103+
setLiveT(0);
104+
}, [resp?.rings]);
105+
70106
async function simular() {
71107
setLoading(true);
72108
setError(null);
@@ -188,6 +224,9 @@ export default function Home() {
188224
key={center ? `${center.lat},${center.lon}` : "no-center"}
189225
center={center}
190226
rings={ringsForGlobe}
227+
liveMinutes={liveT}
228+
liveVpKmS={vp}
229+
liveVsKmS={vs}
191230
/>
192231
)}
193232

@@ -212,6 +251,18 @@ export default function Home() {
212251
)}
213252
{error && <p className="text-red-400 text-sm mt-3 break-words">Error: {error}</p>}
214253
</aside>
254+
255+
{/* Timeline debajo del globo, ocupando las mismas 3 columnas */}
256+
<div className="lg:col-span-3">
257+
<div className="mt-2">
258+
<Playback
259+
value={liveT}
260+
onChange={setLiveT}
261+
max={maxTimeline}
262+
disabled={!center || texOk === false}
263+
/>
264+
</div>
265+
</div>
215266
</div>
216267

217268
{/* Debug JSON */}

frontend/components/Globe.tsx

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import { useMemo, useRef } from "react";
88
export type Center = { lat: number; lon: number };
99
export type RingSet = { p?: number[]; s?: number[] };
1010

11-
type GlobeProps = { center?: Center | null; rings?: RingSet | null };
11+
type GlobeProps = {
12+
center?: Center | null;
13+
rings?: RingSet | null;
14+
liveMinutes?: number; // minutos transcurridos para frentes vivos
15+
liveVpKmS?: number; // velocidad P (km/s)
16+
liveVsKmS?: number; // velocidad S (km/s)
17+
};
1218

1319
/* ------------------- utilidades geo ------------------- */
1420
const d2r = (d: number) => (d * Math.PI) / 180;
@@ -206,7 +212,19 @@ function RingCircle({
206212
}
207213

208214
/* ------------------- World (dentro del Canvas) ------------------- */
209-
function World({ center, rings }: { center: Center; rings?: RingSet | null }) {
215+
function World({
216+
center,
217+
rings,
218+
liveMinutes,
219+
liveVpKmS,
220+
liveVsKmS,
221+
}: {
222+
center: Center;
223+
rings?: RingSet | null;
224+
liveMinutes?: number;
225+
liveVpKmS?: number;
226+
liveVsKmS?: number;
227+
}) {
210228
const worldRef = useRef<THREE.Group>(null!);
211229

212230
useFrame((_, delta) => {
@@ -216,25 +234,55 @@ function World({ center, rings }: { center: Center; rings?: RingSet | null }) {
216234
g.rotation.y += delta * 0.05; // rotación suave
217235
});
218236

237+
// defaults de velocidad si no llegan por props
238+
const vp = typeof liveVpKmS === "number" ? liveVpKmS : 6.0;
239+
const vs = typeof liveVsKmS === "number" ? liveVsKmS : 3.5;
240+
219241
return (
220242
<group ref={worldRef}>
221243
<EarthBall />
222244
<Atmosphere radius={1.04} />
223245
<Graticule />
224246
<EquatorNeon />
247+
248+
{/* Epicentro */}
225249
<Pin lat={center.lat} lon={center.lon} />
250+
251+
{/* Anillos estáticos (respuesta de backend) */}
226252
{rings?.p?.map((r, i) => (
227253
<RingCircle key={`p-${i}`} center={center} radiusKm={r} color="#7cf9f1" />
228254
))}
229255
{rings?.s?.map((r, i) => (
230256
<RingCircle key={`s-${i}`} center={center} radiusKm={r} color="#ff8ec9" />
231257
))}
258+
259+
{/* Frentes vivos P/S (timeline) */}
260+
{typeof liveMinutes === "number" && (
261+
<>
262+
<RingCircle
263+
center={center}
264+
radiusKm={liveMinutes * 60 * vp}
265+
color="#b0fff7"
266+
/>
267+
<RingCircle
268+
center={center}
269+
radiusKm={liveMinutes * 60 * vs}
270+
color="#ffb3d5"
271+
/>
272+
</>
273+
)}
232274
</group>
233275
);
234276
}
235277

236278
/* ============================ GLOBE ============================ */
237-
export default function Globe({ center, rings }: GlobeProps) {
279+
export default function Globe({
280+
center,
281+
rings,
282+
liveMinutes,
283+
liveVpKmS,
284+
liveVsKmS,
285+
}: GlobeProps) {
238286
if (!center) return null;
239287

240288
return (
@@ -244,16 +292,22 @@ export default function Globe({ center, rings }: GlobeProps) {
244292
gl={{ antialias: true, alpha: true, toneMapping: THREE.ACESFilmicToneMapping }}
245293
style={{ width: "100%", height: "100%" }}
246294
>
247-
{/* Luces y fondo (Canvas transparente para ver las estrellas del body) */}
295+
{/* Luces */}
248296
<hemisphereLight intensity={0.5} color={"#cde8ff"} groundColor={"#0b1220"} />
249297
<directionalLight position={[5, 3, 5]} intensity={1.2} />
250298
<pointLight position={[-4, -3, -4]} intensity={0.5} />
251299

252-
{/* Estrellas suaves dentro del Canvas (además de las del body si las tienes) */}
300+
{/* Estrellas dentro del Canvas */}
253301
<Stars radius={120} depth={50} count={4000} factor={3} fade speed={0.15} />
254302

255303
{/* Mundo */}
256-
<World center={center} rings={rings} />
304+
<World
305+
center={center}
306+
rings={rings}
307+
liveMinutes={liveMinutes}
308+
liveVpKmS={liveVpKmS}
309+
liveVsKmS={liveVsKmS}
310+
/>
257311
<Stand />
258312

259313
{/* Controles */}

frontend/components/Playback.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// frontend/components/Playback.tsx
2+
"use client";
3+
4+
import { useEffect, useRef, useState } from "react";
5+
6+
type Props = {
7+
value: number; // minutos actuales
8+
onChange: (t: number) => void; // callback al mover/animar
9+
min?: number;
10+
max?: number; // tope del timeline (min)
11+
step?: number;
12+
disabled?: boolean;
13+
};
14+
15+
export default function Playback({
16+
value,
17+
onChange,
18+
min = 0,
19+
max = 60,
20+
step = 0.1,
21+
disabled,
22+
}: Props) {
23+
const [playing, setPlaying] = useState(false);
24+
const [speed, setSpeed] = useState(1); // 1x, 2x
25+
const raf = useRef<number | null>(null);
26+
const last = useRef<number | null>(null);
27+
28+
useEffect(() => {
29+
if (!playing || disabled) return;
30+
31+
const tick = (ts: number) => {
32+
if (last.current == null) last.current = ts;
33+
const dt = (ts - last.current) / 1000; // seg
34+
last.current = ts;
35+
36+
const tNext = value + dt * speed; // minutos por segundo = 1
37+
onChange(Math.min(max, tNext));
38+
39+
if (tNext >= max) {
40+
setPlaying(false);
41+
last.current = null;
42+
raf.current && cancelAnimationFrame(raf.current);
43+
return;
44+
}
45+
raf.current = requestAnimationFrame(tick);
46+
};
47+
48+
raf.current = requestAnimationFrame(tick);
49+
return () => {
50+
last.current = null;
51+
if (raf.current) cancelAnimationFrame(raf.current);
52+
};
53+
}, [playing, speed, disabled, value, max, onChange]);
54+
55+
return (
56+
<div className="card p-3 flex flex-wrap items-center gap-3">
57+
<button
58+
className="btn-primary"
59+
onClick={() => setPlaying((p) => !p)}
60+
disabled={disabled}
61+
>
62+
{playing ? "Pausar" : "Play"}
63+
</button>
64+
65+
<div className="flex items-center gap-2">
66+
<label className="text-xs text-slate-300">Vel</label>
67+
<select
68+
className="input w-20"
69+
value={speed}
70+
onChange={(e) => setSpeed(Number(e.target.value))}
71+
disabled={disabled}
72+
>
73+
<option value={0.5}>0.5×</option>
74+
<option value={1}></option>
75+
<option value={2}></option>
76+
<option value={4}></option>
77+
</select>
78+
</div>
79+
80+
<input
81+
type="range"
82+
min={min}
83+
max={max}
84+
step={step}
85+
value={value}
86+
onChange={(e) => onChange(Number(e.target.value))}
87+
className="flex-1"
88+
disabled={disabled}
89+
/>
90+
<span className="tabular-nums text-sm text-slate-300">
91+
{value.toFixed(1)} min
92+
</span>
93+
94+
<button
95+
className="input px-3 py-2"
96+
onClick={() => onChange(min)}
97+
disabled={disabled}
98+
>
99+
Reset
100+
</button>
101+
</div>
102+
);
103+
}

0 commit comments

Comments
 (0)