Visor de particules
This commit is contained in:
commit
f6e225de55
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
# --- Entorno y secretos (no subir nunca) ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env*.example
|
||||
*.pem
|
||||
secrets/
|
||||
|
||||
# --- Python (backend) ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
*.cover
|
||||
|
||||
# --- Node / Next (frontend; refuerzo desde la raíz) ---
|
||||
node_modules/
|
||||
.npm
|
||||
.pnp.*
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
|
||||
# --- Salidas de build y caché ---
|
||||
.next/
|
||||
out/
|
||||
.turbo/
|
||||
*.tsbuildinfo
|
||||
|
||||
# --- Datos / binarios grandes (NetCDF suele ser muy pesado) ---
|
||||
*.nc
|
||||
|
||||
# --- Logs y temporales ---
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# --- Jupyter ---
|
||||
.ipynb_checkpoints/
|
||||
|
||||
# --- IDE y SO ---
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# Simulador de Contaminació Marina
|
||||
|
||||
Visor web de trajectòries lagrangianes de **~10.000 partícules contaminants** al Mediterrani occidental. Les partícules surten de punts de la costa catalana i es dispersen amb els corrents marins durant 72 hores. Quan arriben a la costa, s’encallen i s’acumulen, mostrant zones d’impacte.
|
||||
|
||||
**Stack:** Python · FastAPI · Next.js · Deck.gl · MapLibre (sense claus d’API)
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
trajectories/
|
||||
├── backend/ # API i dades
|
||||
│ ├── main.py # FastAPI: metadata, blocs JSON/Arrow, CORS, gzip
|
||||
│ ├── data/ # Dades servides per l’API
|
||||
│ │ ├── metadata.json # (opcional) simulació "default" a la arrel
|
||||
│ │ └── simulations/ # Una carpeta per simulació
|
||||
│ │ └── <sim_id>/ # metadata.json, block_0.arrow, block_1.arrow, ...
|
||||
│ ├── etl/ # Pipeline NetCDF → blocs Arrow + metadata
|
||||
│ │ ├── nc_reader.py # Lectura NetCDF (lon, lat, u, v, beached, temps)
|
||||
│ │ ├── pipeline.py # Orquestració: load_nc → subsample horari → write_blocks_arrow
|
||||
│ │ └── to_blocks.py # Escriptura Arrow IPC + metadata.json
|
||||
│ └── scripts/
|
||||
│ └── run_etl.py # CLI: python -m scripts.run_etl fitxer.nc [--sim-id ...]
|
||||
│
|
||||
└── frontend/ # App Next.js (App Router)
|
||||
├── src/
|
||||
│ ├── app/page.tsx # Pàgina principal → TrajectoryMap
|
||||
│ ├── components/ # MapView, MapHeader, TimePlayer, TrajectoryMap, IntroDialog, …
|
||||
│ ├── hooks/ # useBlocks (càrrega Arrow/JSON), useSimulation, useMapLayers, useMetadata
|
||||
│ ├── lib/ # constants, trajectoryData (buildTrails, buildTripsWithTime)
|
||||
│ └── types/ # trajectory (Meta, Block, PointDatum, TripDatum, …)
|
||||
```
|
||||
|
||||
- **Backend:** Servir `metadata.json` i blocs de frames (JSON o **Arrow IPC**). Les dades viuen a `backend/data/` o `backend/data/simulations/<sim_id>/`. L’API accepta `?sim=default` (o qualsevol `sim_id`) per triar simulació.
|
||||
- **Frontend:** Demana metadata a `/api/metadata?sim=...`, després els blocs a `/api/block/{id}` o `/api/block/{id}/arrow`. Construeix trails i trips a memòria (TripsLayer amb timestamps, PathLayer per selecció). Deck.gl + MapLibre renderitzen el mapa; el reproductor temporal avança el `currentTime` i es visualitzen les trajectòries amb color per origen, bloom/glow i partícules actives/beached.
|
||||
|
||||
---
|
||||
|
||||
## Com arrencar el projecte
|
||||
|
||||
### Requisits
|
||||
|
||||
- **Backend:** Python 3.10+, Node 18+ (només si no tens les dependències del backend instal·lades)
|
||||
- **Frontend:** Node 18+
|
||||
|
||||
### 1. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Entorn virtual i dependències
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Dades (una de les dues opcions)
|
||||
# Opció A: Dades de prova (sense NetCDF)
|
||||
python generate_dummy_data.py
|
||||
|
||||
# Opció B: ETL des d’un NetCDF (genera block_*.arrow + metadata a data/simulations/<sim_id>/)
|
||||
python -m scripts.run_etl path/to/trajectories.nc
|
||||
python -m scripts.run_etl path/to/file.nc --sim-id my_run --write-json # opcional JSON
|
||||
|
||||
# Servidor API (port 8000)
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
L’API queda disponible a **http://localhost:8000**.
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Obre **http://localhost:3000**. Per defecte el frontend crida l’API a `http://localhost:8000` (configurable amb `NEXT_PUBLIC_API_URL` si cal).
|
||||
|
||||
### 3. Ús bàsic
|
||||
|
||||
1. Backend en marxa (terminal 1), frontend en marxa (terminal 2).
|
||||
2. Obrir http://localhost:3000.
|
||||
3. Tancar/acceptar el popup d’introducció i esperar que carreguin els blocs (barra de progrés).
|
||||
4. Play / slider / velocitat (×0.5 … ×8). Zoom a les partícules (botó crosshair), Filters (basemap, capes), Info.
|
||||
|
||||
---
|
||||
|
||||
## API (resum)
|
||||
|
||||
| Endpoint | Descripció |
|
||||
|----------|------------|
|
||||
| `GET /api/simulations` | Llista d’IDs de simulació disponibles |
|
||||
| `GET /api/metadata?sim=default` | Metadata (num_particles, num_steps, num_blocks, seed_names, origins, time_start, time_end, …) |
|
||||
| `GET /api/block/{id}?sim=default` | Bloc en JSON (frames + step_start, step_end, accumulation) |
|
||||
| `GET /api/block/{id}/arrow?sim=default` | Bloc en Apache Arrow IPC (columnes: step, particle_id, lon, lat, u, v, beached) |
|
||||
|
||||
---
|
||||
|
||||
## Característiques
|
||||
|
||||
- **Partícules i temps:** milers de partícules, 72 h (1 pas/hora), blocs de 24 h per càrrega progressiva.
|
||||
- **Trajectòries:** TripsLayer amb cap arrodonit, color per origen (paleta), bloom/glow; partícules actives vs beached (vermell).
|
||||
- **Mapa:** MapLibre (basemaps Carto), zoom/fit a partícules, tooltip amb origen i velocitat.
|
||||
- **Dades:** ETL des de NetCDF (subsample horari, beached persistent) o dades de prova amb `generate_dummy_data.py`.
|
||||
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
1
backend/data/block_0.json
Normal file
1
backend/data/block_0.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/data/block_1.json
Normal file
1
backend/data/block_1.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/data/block_2.json
Normal file
1
backend/data/block_2.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/data/metadata.json
Normal file
1
backend/data/metadata.json
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/data/simulations/sim_08_23/block_0.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_0.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_1.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_1.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_2.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_2.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_3.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_3.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_4.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_4.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_5.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_5.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23/block_6.arrow
Normal file
BIN
backend/data/simulations/sim_08_23/block_6.arrow
Normal file
Binary file not shown.
1
backend/data/simulations/sim_08_23/metadata.json
Normal file
1
backend/data/simulations/sim_08_23/metadata.json
Normal file
File diff suppressed because one or more lines are too long
BIN
backend/data/simulations/sim_08_23_test/block_0.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_0.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_1.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_1.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_2.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_2.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_3.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_3.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_4.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_4.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_5.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_5.arrow
Normal file
Binary file not shown.
BIN
backend/data/simulations/sim_08_23_test/block_6.arrow
Normal file
BIN
backend/data/simulations/sim_08_23_test/block_6.arrow
Normal file
Binary file not shown.
1
backend/data/simulations/sim_08_23_test/metadata.json
Normal file
1
backend/data/simulations/sim_08_23_test/metadata.json
Normal file
File diff suppressed because one or more lines are too long
1
backend/etl/__init__.py
Normal file
1
backend/etl/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# ETL: NetCDF trajectory → Apache Arrow blocks for frontend
|
||||
201
backend/etl/nc_reader.py
Normal file
201
backend/etl/nc_reader.py
Normal file
@ -0,0 +1,201 @@
|
||||
"""
|
||||
Read trajectory NetCDF and return normalized arrays: frames[step][particle] = [lon, lat, u, v, beached].
|
||||
Supports common CF / Parcels-style dimensions (time, traj) or (obs, particle).
|
||||
Reads actual time axis when present for correct calendar display (e.g. 27/8–3/9 instead of 83 days).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import xarray as xr
|
||||
except ImportError:
|
||||
xr = None
|
||||
|
||||
from .schema import (
|
||||
LON_NAMES,
|
||||
LAT_NAMES,
|
||||
U_NAMES,
|
||||
V_NAMES,
|
||||
BEACHED_NAMES,
|
||||
TIME_NAMES,
|
||||
)
|
||||
|
||||
|
||||
def _find_var(ds, names: tuple[str, ...]):
|
||||
for n in names:
|
||||
if n in ds:
|
||||
return ds[n]
|
||||
return None
|
||||
|
||||
|
||||
def load_nc(
|
||||
path: str,
|
||||
) -> tuple[
|
||||
np.ndarray, int, int, list[int], list[str], list[int],
|
||||
str | None, str | None, np.ndarray | None,
|
||||
]:
|
||||
"""
|
||||
Load a trajectory NetCDF file.
|
||||
|
||||
Returns:
|
||||
frames: shape (num_steps, num_particles, 5) with [lon, lat, u, v, beached]
|
||||
num_particles: int
|
||||
num_steps: int
|
||||
release_steps: list of length num_particles (step index when particle appears)
|
||||
seed_names: list of origin names (may be empty)
|
||||
origins: list of length num_particles (index into seed_names)
|
||||
time_start_iso: ISO datetime string of first time (or None)
|
||||
time_end_iso: ISO datetime string of last time (or None)
|
||||
time_ref_sec: 1D array (num_steps,) of epoch seconds per step for subsampling (or None)
|
||||
"""
|
||||
if xr is None:
|
||||
raise ImportError("xarray is required for NetCDF ETL. pip install xarray netCDF4")
|
||||
|
||||
ds = xr.open_dataset(path)
|
||||
|
||||
# Resolve dimensions: time/obs and particle/traj
|
||||
time_dim = None
|
||||
particle_dim = None
|
||||
for d in ds.dims:
|
||||
dlo = d.lower()
|
||||
if dlo in ("time", "obs", "step", "t", "nt"):
|
||||
time_dim = d
|
||||
elif dlo in ("particle", "traj", "n_particles", "trajectory", "pid"):
|
||||
particle_dim = d
|
||||
|
||||
if time_dim is None:
|
||||
time_dim = list(ds.dims)[0]
|
||||
if particle_dim is None:
|
||||
dims = [d for d in ds.dims if d != time_dim]
|
||||
particle_dim = dims[0] if dims else None
|
||||
|
||||
if particle_dim is None:
|
||||
raise ValueError("Could not identify time and particle dimensions in NetCDF")
|
||||
|
||||
lon_var = _find_var(ds, LON_NAMES)
|
||||
lat_var = _find_var(ds, LAT_NAMES)
|
||||
if lon_var is None or lat_var is None:
|
||||
raise ValueError("NetCDF must contain lon/lat (or longitude/latitude) variables")
|
||||
|
||||
lon = np.asarray(lon_var).astype(np.float64)
|
||||
lat = np.asarray(lat_var).astype(np.float64)
|
||||
dims = getattr(lon_var, "dims", None) or ()
|
||||
|
||||
if lon.ndim == 1 and lat.ndim == 1:
|
||||
if len(lon) == len(lat):
|
||||
num_particles = 1
|
||||
num_steps = len(lon)
|
||||
lon = lon.reshape(num_steps, 1)
|
||||
lat = lat.reshape(num_steps, 1)
|
||||
else:
|
||||
raise ValueError("lon/lat 1D but different lengths")
|
||||
elif lon.ndim == 2:
|
||||
if dims == (particle_dim, time_dim):
|
||||
lon = lon.T
|
||||
lat = lat.T
|
||||
num_steps, num_particles = lon.shape
|
||||
else:
|
||||
raise ValueError("lon/lat must be 1D or 2D")
|
||||
|
||||
u_var = _find_var(ds, U_NAMES)
|
||||
v_var = _find_var(ds, V_NAMES)
|
||||
if u_var is not None and v_var is not None:
|
||||
u = np.asarray(u_var).astype(np.float32)
|
||||
v = np.asarray(v_var).astype(np.float32)
|
||||
if u.ndim == 2 and getattr(u_var, "dims", ()) == (particle_dim, time_dim):
|
||||
u, v = u.T, v.T
|
||||
if u.shape != (num_steps, num_particles):
|
||||
u = np.broadcast_to(u.ravel()[: num_steps * num_particles].reshape(num_steps, num_particles), (num_steps, num_particles))
|
||||
if v.shape != (num_steps, num_particles):
|
||||
v = np.broadcast_to(v.ravel()[: num_steps * num_particles].reshape(num_steps, num_particles), (num_steps, num_particles))
|
||||
else:
|
||||
# Derive u, v from lon, lat differences (m/s scale: deg/hour -> rough m/s)
|
||||
deg_per_m = 1.0 / 111_320
|
||||
u = np.zeros((num_steps, num_particles), dtype=np.float32)
|
||||
v = np.zeros((num_steps, num_particles), dtype=np.float32)
|
||||
u[1:] = (lon[1:] - lon[:-1]) * deg_per_m * 111320 / 3600
|
||||
v[1:] = (lat[1:] - lat[:-1]) * deg_per_m * 111320 / 3600
|
||||
|
||||
beached_var = _find_var(ds, BEACHED_NAMES)
|
||||
if beached_var is not None:
|
||||
raw = np.asarray(beached_var)
|
||||
if raw.ndim == 2 and getattr(beached_var, "dims", ()) == (particle_dim, time_dim):
|
||||
raw = raw.T
|
||||
if raw.shape != (num_steps, num_particles):
|
||||
beached = np.zeros((num_steps, num_particles), dtype=np.int8)
|
||||
else:
|
||||
# Normalize: 1 = beached, 0 = not. Ignore fill values (e.g. -2e9, NaN)
|
||||
beached = np.where(
|
||||
(np.isfinite(raw)) & (raw > 0.5),
|
||||
1,
|
||||
0,
|
||||
).astype(np.int8)
|
||||
else:
|
||||
beached = np.zeros((num_steps, num_particles), dtype=np.int8)
|
||||
|
||||
frames = np.stack([lon.astype(np.float32), lat.astype(np.float32), u, v, beached], axis=-1)
|
||||
|
||||
# Release step: first non-NaN or first step
|
||||
release_steps = []
|
||||
for p in range(num_particles):
|
||||
valid = np.where(np.isfinite(frames[:, p, 0]))[0]
|
||||
release_steps.append(int(valid[0]) if len(valid) else 0)
|
||||
seed_names = []
|
||||
origins = [0] * num_particles
|
||||
|
||||
# Optional: origin/seed from NetCDF
|
||||
if "origin" in ds:
|
||||
o = np.asarray(ds["origin"]).ravel()
|
||||
if len(o) >= num_particles:
|
||||
origins = o[:num_particles].astype(int).tolist()
|
||||
if "seed_names" in ds.attrs:
|
||||
seed_names = list(ds.attrs["seed_names"])
|
||||
elif "seed_name" in ds:
|
||||
sn = np.asarray(ds["seed_name"])
|
||||
if sn.ndim >= 1:
|
||||
try:
|
||||
seed_names = [str(x) for x in sn.values.ravel()[:num_particles]]
|
||||
except Exception:
|
||||
seed_names = [f"Seed {i}" for i in range(max(origins) + 1)]
|
||||
|
||||
# Optional: actual time axis for calendar display and hourly subsampling
|
||||
time_start_iso: str | None = None
|
||||
time_end_iso: str | None = None
|
||||
time_ref_sec: np.ndarray | None = None
|
||||
time_var = _find_var(ds, ("time",) + TIME_NAMES)
|
||||
if time_var is not None:
|
||||
try:
|
||||
t = np.asarray(time_var)
|
||||
if time_var.dims == (particle_dim, time_dim):
|
||||
t = t.T # (num_steps, num_particles)
|
||||
if t.shape == (num_steps, num_particles):
|
||||
t_flat = t.ravel()
|
||||
valid = t_flat[~np.isnat(t_flat)]
|
||||
if len(valid) > 0:
|
||||
time_start_iso = str(np.nanmin(valid))
|
||||
time_end_iso = str(np.nanmax(valid))
|
||||
# Reference time per step (min over particles) in epoch seconds for subsampling
|
||||
time_ref_sec = np.full(num_steps, np.nan, dtype=np.float64)
|
||||
for s in range(num_steps):
|
||||
row = t[s, :]
|
||||
row = row[~np.isnat(row)]
|
||||
if len(row) > 0:
|
||||
secs = row.astype("datetime64[s]").astype(np.float64)
|
||||
time_ref_sec[s] = float(np.min(secs))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ds.close()
|
||||
return (
|
||||
frames,
|
||||
num_particles,
|
||||
num_steps,
|
||||
release_steps,
|
||||
seed_names,
|
||||
origins,
|
||||
time_start_iso,
|
||||
time_end_iso,
|
||||
time_ref_sec,
|
||||
)
|
||||
133
backend/etl/pipeline.py
Normal file
133
backend/etl/pipeline.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""
|
||||
Orchestrate ETL: NetCDF path → normalized frames → Arrow blocks + metadata.
|
||||
Subsamples to one frame per hour when time axis is available (e.g. 5-min → hourly).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .nc_reader import load_nc
|
||||
from .to_blocks import write_blocks_arrow
|
||||
|
||||
|
||||
def _forward_fill_beached(frames: np.ndarray) -> None:
|
||||
"""Fem el beached persistent per partícula: al NC no és acumulatiu, i si llegim només cada hora
|
||||
es podrien perdre les lectures intermitges on una partícula era beached. Un cop beached=1,
|
||||
el mantenim 1 per a tots els passos següents (in-place)."""
|
||||
num_steps, num_particles = frames.shape[0], frames.shape[1]
|
||||
b = frames[:, :, 4]
|
||||
for p in range(num_particles):
|
||||
seen = False
|
||||
for s in range(num_steps):
|
||||
if b[s, p] == 1:
|
||||
seen = True
|
||||
elif seen:
|
||||
b[s, p] = 1
|
||||
frames[:, :, 4] = b
|
||||
|
||||
|
||||
def _subsample_to_hourly(
|
||||
frames: np.ndarray,
|
||||
num_steps: int,
|
||||
num_particles: int,
|
||||
release_steps: list[int],
|
||||
time_ref_sec: np.ndarray,
|
||||
time_start_iso: str,
|
||||
time_end_iso: str,
|
||||
) -> tuple[np.ndarray, int, list[int]]:
|
||||
"""Keep one frame per hour (hora punta): step closest to each full hour. Returns (frames_hourly, new_num_steps, new_release_steps)."""
|
||||
start_sec = np.datetime64(time_start_iso).astype("datetime64[s]").astype(np.float64)
|
||||
end_sec = np.datetime64(time_end_iso).astype("datetime64[s]").astype(np.float64)
|
||||
num_hours = max(1, int(round((end_sec - start_sec) / 3600)))
|
||||
hourly_indices: list[int] = []
|
||||
for h in range(num_hours):
|
||||
hour_center = start_sec + h * 3600 # full hour (e.g. 07:00:00)
|
||||
t_lo = hour_center
|
||||
t_hi = start_sec + (h + 1) * 3600
|
||||
# Step in this hour whose time is closest to the full hour
|
||||
best_s: int | None = None
|
||||
best_dt = float("inf")
|
||||
for s in range(num_steps):
|
||||
if np.isnan(time_ref_sec[s]):
|
||||
continue
|
||||
if t_lo <= time_ref_sec[s] < t_hi:
|
||||
dt = abs(time_ref_sec[s] - hour_center)
|
||||
if dt < best_dt:
|
||||
best_dt = dt
|
||||
best_s = s
|
||||
if best_s is not None:
|
||||
hourly_indices.append(best_s)
|
||||
if not hourly_indices:
|
||||
return frames, num_steps, release_steps
|
||||
idx = np.array(hourly_indices, dtype=np.intp)
|
||||
frames_hourly = frames[idx]
|
||||
new_num_steps = len(hourly_indices)
|
||||
# Map release_steps: for each particle, first hourly index >= its original release step
|
||||
new_release_steps: list[int] = []
|
||||
for p in range(num_particles):
|
||||
r = release_steps[p]
|
||||
i = 0
|
||||
while i < new_num_steps and idx[i] < r:
|
||||
i += 1
|
||||
new_release_steps.append(min(i, new_num_steps - 1))
|
||||
return frames_hourly, new_num_steps, new_release_steps
|
||||
|
||||
|
||||
def run_etl(
|
||||
nc_path: str | Path,
|
||||
out_dir: str | Path,
|
||||
sim_id: str = "default",
|
||||
write_json: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Run full ETL: read .nc, optionally subsample to hourly, write blocks and metadata.
|
||||
|
||||
Returns metadata dict.
|
||||
"""
|
||||
nc_path = Path(nc_path)
|
||||
out_dir = Path(out_dir)
|
||||
sim_dir = out_dir / "simulations" / sim_id
|
||||
|
||||
(
|
||||
frames,
|
||||
num_particles,
|
||||
num_steps,
|
||||
release_steps,
|
||||
seed_names,
|
||||
origins,
|
||||
time_start_iso,
|
||||
time_end_iso,
|
||||
time_ref_sec,
|
||||
) = load_nc(str(nc_path))
|
||||
|
||||
# Beached: once a particle is beached it stays beached (so hourly sample doesn't miss it)
|
||||
_forward_fill_beached(frames)
|
||||
|
||||
# Subsample to one frame per hour when we have a time reference
|
||||
if (
|
||||
time_ref_sec is not None
|
||||
and time_start_iso is not None
|
||||
and time_end_iso is not None
|
||||
and np.any(np.isfinite(time_ref_sec))
|
||||
):
|
||||
frames, num_steps, release_steps = _subsample_to_hourly(
|
||||
frames, num_steps, num_particles, release_steps,
|
||||
time_ref_sec, time_start_iso, time_end_iso,
|
||||
)
|
||||
|
||||
metadata = write_blocks_arrow(
|
||||
sim_dir,
|
||||
frames,
|
||||
num_particles,
|
||||
num_steps,
|
||||
release_steps,
|
||||
seed_names,
|
||||
origins,
|
||||
write_json=write_json,
|
||||
time_start_iso=time_start_iso,
|
||||
time_end_iso=time_end_iso,
|
||||
)
|
||||
return metadata
|
||||
22
backend/etl/schema.py
Normal file
22
backend/etl/schema.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""Constants and schema for trajectory ETL (NC → Arrow)."""
|
||||
|
||||
BLOCK_SIZE = 24 # steps per block (same as frontend)
|
||||
|
||||
# Default variable names to look for in NetCDF (first match wins)
|
||||
LON_NAMES = ("lon", "longitude", "x", "Lon")
|
||||
LAT_NAMES = ("lat", "latitude", "y", "Lat")
|
||||
U_NAMES = ("u", "u_vel", "velocity_x", "U")
|
||||
V_NAMES = ("v", "v_vel", "velocity_y", "V")
|
||||
BEACHED_NAMES = ("beached", "status", "mask", "beach")
|
||||
TIME_NAMES = ("time", "obs", "step", "t")
|
||||
|
||||
# Arrow table schema for one block (frontend expects this)
|
||||
ARROW_SCHEMA = {
|
||||
"step": "int32",
|
||||
"particle_id": "int32",
|
||||
"lon": "float32",
|
||||
"lat": "float32",
|
||||
"u": "float32",
|
||||
"v": "float32",
|
||||
"beached": "int8",
|
||||
}
|
||||
137
backend/etl/to_blocks.py
Normal file
137
backend/etl/to_blocks.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""
|
||||
Write trajectory frames to block-wise Arrow files and metadata.json.
|
||||
Frontend expects: metadata.json, block_0.arrow, block_1.arrow, ...
|
||||
Arrow table per block: columns step, particle_id, lon, lat, u, v, beached; rows ordered by step then particle_id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
|
||||
from .schema import BLOCK_SIZE
|
||||
|
||||
|
||||
def build_accumulation(lons: np.ndarray, lats: np.ndarray, res: float = 0.1) -> list[list[float]]:
|
||||
"""Build accumulation grid cells for beached particles [lon_cell, lat_cell, count]."""
|
||||
if lons.size == 0:
|
||||
return []
|
||||
cells: dict[tuple[int, int], int] = {}
|
||||
for lo, la in zip(lons.ravel().tolist(), lats.ravel().tolist()):
|
||||
if not np.isfinite(lo) or not np.isfinite(la):
|
||||
continue
|
||||
key = (round(lo / res), round(la / res))
|
||||
cells[key] = cells.get(key, 0) + 1
|
||||
return [[k[0] * res, k[1] * res, v] for k, v in cells.items()]
|
||||
|
||||
|
||||
def write_blocks_arrow(
|
||||
out_dir: Path,
|
||||
frames: np.ndarray,
|
||||
num_particles: int,
|
||||
num_steps: int,
|
||||
release_steps: list[int],
|
||||
seed_names: list[str],
|
||||
origins: list[int],
|
||||
write_json: bool = False,
|
||||
time_start_iso: str | None = None,
|
||||
time_end_iso: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Write blocks as Arrow IPC and metadata.json.
|
||||
frames: (num_steps, num_particles, 5) with [lon, lat, u, v, beached].
|
||||
Returns metadata dict for API.
|
||||
"""
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove any existing block files from a previous run (e.g. after re-ETL with fewer steps)
|
||||
for p in out_dir.iterdir():
|
||||
if p.is_file() and p.name.startswith("block_") and (p.suffix == ".arrow" or p.suffix == ".json"):
|
||||
p.unlink()
|
||||
|
||||
num_blocks = (num_steps + BLOCK_SIZE - 1) // BLOCK_SIZE
|
||||
metadata: dict = {
|
||||
"num_particles": num_particles,
|
||||
"num_steps": num_steps,
|
||||
"num_blocks": num_blocks,
|
||||
"block_size": BLOCK_SIZE,
|
||||
"dt_hours": 1,
|
||||
"seed_names": seed_names or [f"Origin {i}" for i in range(max(origins) + 1)] if origins else ["Default"],
|
||||
"origins": origins,
|
||||
"release_steps": release_steps,
|
||||
}
|
||||
if time_start_iso and time_end_iso:
|
||||
metadata["time_start"] = time_start_iso
|
||||
metadata["time_end"] = time_end_iso
|
||||
|
||||
for block_id in range(num_blocks):
|
||||
step_start = block_id * BLOCK_SIZE
|
||||
step_end = min(step_start + BLOCK_SIZE, num_steps)
|
||||
chunk = frames[step_start:step_end]
|
||||
n_steps_chunk = chunk.shape[0]
|
||||
|
||||
steps_list = []
|
||||
particle_ids_list = []
|
||||
lons_list = []
|
||||
lats_list = []
|
||||
u_list = []
|
||||
v_list = []
|
||||
beached_list = []
|
||||
|
||||
for step_idx in range(n_steps_chunk):
|
||||
step_global = step_start + step_idx
|
||||
for pid in range(num_particles):
|
||||
row = chunk[step_idx, pid]
|
||||
steps_list.append(step_global)
|
||||
particle_ids_list.append(pid)
|
||||
lons_list.append(float(row[0]))
|
||||
lats_list.append(float(row[1]))
|
||||
u_list.append(float(row[2]))
|
||||
v_list.append(float(row[3]))
|
||||
# beached: index 4; ensure 0/1 (source can be float)
|
||||
b_val = row[4]
|
||||
beached_list.append(1 if (float(b_val) > 0.5) else 0)
|
||||
|
||||
# Build columns; beached from numpy to ensure int8 layout for IPC
|
||||
beached_np = np.array(beached_list, dtype=np.int8)
|
||||
table = pa.table({
|
||||
"step": pa.array(steps_list, type=pa.int32()),
|
||||
"particle_id": pa.array(particle_ids_list, type=pa.int32()),
|
||||
"lon": pa.array(lons_list, type=pa.float32()),
|
||||
"lat": pa.array(lats_list, type=pa.float32()),
|
||||
"u": pa.array(u_list, type=pa.float32()),
|
||||
"v": pa.array(v_list, type=pa.float32()),
|
||||
"beached": pa.array(beached_np),
|
||||
})
|
||||
|
||||
arrow_path = out_dir / f"block_{block_id}.arrow"
|
||||
with open(arrow_path, "wb") as f:
|
||||
with pa.ipc.new_stream(f, table.schema) as writer:
|
||||
writer.write_table(table)
|
||||
|
||||
if write_json:
|
||||
frames_json = chunk.astype(float).tolist()
|
||||
beached_mask = chunk[:, :, 4].astype(bool)
|
||||
accumulation = []
|
||||
for step_idx in range(n_steps_chunk):
|
||||
b_lons = chunk[step_idx, beached_mask[step_idx], 0]
|
||||
b_lats = chunk[step_idx, beached_mask[step_idx], 1]
|
||||
accumulation.append(build_accumulation(b_lons, b_lats))
|
||||
block_json = {
|
||||
"block": block_id,
|
||||
"step_start": step_start,
|
||||
"step_end": step_end,
|
||||
"frames": frames_json,
|
||||
"accumulation": accumulation,
|
||||
}
|
||||
with open(out_dir / f"block_{block_id}.json", "w") as f:
|
||||
json.dump(block_json, f, separators=(",", ":"))
|
||||
|
||||
with open(out_dir / "metadata.json", "w") as f:
|
||||
json.dump(metadata, f, separators=(",", ":"))
|
||||
|
||||
return metadata
|
||||
323
backend/generate_dummy_data.py
Normal file
323
backend/generate_dummy_data.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""
|
||||
Lagrangian particle trajectory simulator – compact block output.
|
||||
|
||||
10 000 particles seeded along the Catalan coast, advected by a synthetic
|
||||
Western Mediterranean current field for 72 h (3 days).
|
||||
Output: 3 JSON blocks of 24 h each + a metadata file.
|
||||
Each block stores per-step arrays of [lon, lat, u, v, beached] per particle.
|
||||
Trails are reconstructed client-side for zero data duplication.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
NUM_STEPS = 72
|
||||
DT_SECONDS = 3600
|
||||
DEG_PER_M_LAT = 1.0 / 111_320
|
||||
BLOCK_SIZE = 24 # steps per block
|
||||
PARTICLES_PER_SEED = 333
|
||||
|
||||
JITTER_DEG = 0.008 # small spread so particles start in water near coast
|
||||
OFFSHORE_DEG = 0.012 # push seaward if initial position is on land
|
||||
|
||||
SEED_POINTS = [
|
||||
(3.21, 41.39), (2.82, 41.62), (2.17, 41.45), (1.82, 41.22),
|
||||
(1.25, 41.12), (1.00, 40.95), (0.63, 40.62), (0.85, 40.72),
|
||||
(3.12, 41.98), (3.19, 42.30), (2.82, 41.97), (2.07, 41.38),
|
||||
(3.10, 41.77), (3.17, 41.70), (1.52, 41.08), (3.04, 41.87),
|
||||
(0.75, 40.78), (2.93, 41.69), (1.60, 41.15), (2.40, 41.53),
|
||||
(0.50, 40.55), (2.60, 41.57), (3.16, 42.12), (2.50, 41.55),
|
||||
(1.40, 41.05), (3.05, 42.05), (2.92, 41.83), (1.15, 40.90),
|
||||
(2.70, 41.64), (3.22, 42.20),
|
||||
]
|
||||
|
||||
SEED_NAMES = [
|
||||
"Barcelona", "Badalona", "Castelldefels", "Vilanova",
|
||||
"Tarragona", "Cambrils", "Delta Ebre S", "Delta Ebre N",
|
||||
"Roses", "Portbou", "L'Estartit", "Sitges",
|
||||
"Blanes", "Lloret de Mar", "Salou", "Tossa de Mar",
|
||||
"L'Ampolla", "St. Feliu Guíxols", "Torredembarra", "El Masnou",
|
||||
"Alcanar", "Premià de Mar", "Cadaqués", "Vilassar de Mar",
|
||||
"Altafulla", "L'Escala", "Palamós", "Miami Platja",
|
||||
"Arenys de Mar", "Llançà",
|
||||
]
|
||||
|
||||
# ── Land polygons ────────────────────────────────────────────────────────────
|
||||
|
||||
IBERIAN_PENINSULA = [
|
||||
(-2.0, 36.7), (-1.5, 36.8), (-0.5, 37.4), (0.0, 38.0),
|
||||
(0.2, 38.8), (-0.2, 39.5), (0.0, 39.9), (0.2, 40.4),
|
||||
(0.45, 40.5), (0.5, 40.65), (0.7, 40.75), (0.8, 40.85),
|
||||
(0.9, 40.9), (1.0, 41.0), (1.2, 41.1), (1.5, 41.1),
|
||||
(1.8, 41.2), (2.0, 41.3), (2.1, 41.35), (2.2, 41.4),
|
||||
(2.5, 41.5), (2.8, 41.6), (2.9, 41.65), (3.0, 41.7),
|
||||
(3.1, 41.8), (3.15, 41.9), (3.15, 42.0), (3.2, 42.1),
|
||||
(3.2, 42.3), (3.25, 42.45), (3.05, 42.5), (2.5, 42.6),
|
||||
(1.8, 42.7), (1.0, 42.75), (0.0, 42.7), (-1.0, 42.8),
|
||||
(-2.0, 42.5), (-2.0, 36.7),
|
||||
]
|
||||
FRANCE_SOUTH = [
|
||||
(3.05, 42.5), (3.25, 42.45), (3.3, 42.5), (3.5, 42.6),
|
||||
(3.6, 43.0), (3.5, 43.2), (3.9, 43.3), (4.1, 43.4),
|
||||
(4.5, 43.3), (4.8, 43.4), (5.0, 43.3), (5.5, 43.2),
|
||||
(5.8, 43.1), (6.0, 43.1), (6.3, 43.15), (6.6, 43.0),
|
||||
(7.0, 43.5), (7.5, 43.8), (7.5, 44.5), (6.0, 44.5),
|
||||
(4.0, 44.0), (3.0, 43.5), (3.0, 42.8), (3.05, 42.5),
|
||||
]
|
||||
ITALY_WEST = [
|
||||
(7.5, 43.8), (7.7, 43.8), (8.0, 43.9), (8.5, 44.0),
|
||||
(9.0, 44.1), (9.5, 44.3), (10.0, 44.0), (10.0, 43.5),
|
||||
(10.5, 43.0), (10.5, 42.5), (11.0, 42.0), (11.5, 41.5),
|
||||
(12.0, 41.2), (12.5, 41.0), (13.0, 40.5), (13.5, 40.0),
|
||||
(14.0, 39.5), (14.0, 38.5), (14.0, 44.5), (7.5, 44.5),
|
||||
(7.5, 43.8),
|
||||
]
|
||||
SARDINIA = [
|
||||
(8.1, 39.0), (8.3, 38.8), (8.5, 38.85), (8.8, 38.9),
|
||||
(9.2, 39.0), (9.6, 39.2), (9.7, 39.8), (9.6, 40.2),
|
||||
(9.5, 40.6), (9.2, 40.9), (8.8, 41.0), (8.3, 41.0),
|
||||
(8.1, 40.8), (8.1, 40.4), (8.2, 39.8), (8.1, 39.0),
|
||||
]
|
||||
CORSICA = [
|
||||
(8.6, 41.4), (8.8, 41.4), (9.2, 41.6), (9.4, 42.0),
|
||||
(9.5, 42.4), (9.4, 42.8), (9.2, 43.0), (8.8, 42.7),
|
||||
(8.6, 42.3), (8.6, 41.8), (8.6, 41.4),
|
||||
]
|
||||
MALLORCA = [
|
||||
(2.3, 39.25), (2.7, 39.3), (3.1, 39.5), (3.4, 39.7),
|
||||
(3.5, 39.85), (3.4, 39.95), (3.1, 39.95), (2.7, 39.9),
|
||||
(2.4, 39.75), (2.3, 39.55), (2.3, 39.25),
|
||||
]
|
||||
MENORCA = [
|
||||
(3.8, 39.8), (3.9, 39.82), (4.1, 39.95), (4.25, 40.05),
|
||||
(4.1, 40.1), (3.85, 40.0), (3.8, 39.9), (3.8, 39.8),
|
||||
]
|
||||
IBIZA = [
|
||||
(1.2, 38.8), (1.3, 38.82), (1.45, 38.9), (1.55, 39.0),
|
||||
(1.5, 39.1), (1.35, 39.1), (1.2, 39.0), (1.15, 38.9),
|
||||
(1.2, 38.8),
|
||||
]
|
||||
NORTH_AFRICA = [
|
||||
(-2.0, 36.7), (-2.0, 35.0), (10.0, 35.0), (10.0, 37.0),
|
||||
(9.5, 37.2), (9.0, 37.0), (8.5, 37.0), (8.0, 36.8),
|
||||
(7.5, 36.9), (7.0, 37.0), (6.0, 37.0), (5.0, 36.8),
|
||||
(4.0, 36.7), (3.0, 36.7), (2.0, 36.5), (1.0, 36.3),
|
||||
(0.0, 35.8), (-1.0, 35.5), (-2.0, 36.7),
|
||||
]
|
||||
|
||||
LAND_POLYGONS = [
|
||||
IBERIAN_PENINSULA, FRANCE_SOUTH, ITALY_WEST,
|
||||
SARDINIA, CORSICA, MALLORCA, MENORCA, IBIZA, NORTH_AFRICA,
|
||||
]
|
||||
|
||||
|
||||
def _pip(px, py, poly):
|
||||
n = len(poly)
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = poly[i]
|
||||
xj, yj = poly[j]
|
||||
if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def is_land(lon, lat):
|
||||
for poly in LAND_POLYGONS:
|
||||
if _pip(lon, lat, poly):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Current field (NW Mediterranean / Catalan coast, realistic) ───────────────
|
||||
|
||||
# Typical surface currents: Northern Current ~0.1–0.25 m/s along coast (SW), weaker offshore
|
||||
BASE_SPEED_MS = 0.18 # m/s, main flow magnitude
|
||||
DIFFUSION_MS = 0.025 # turbulent fluctuation (m/s)
|
||||
|
||||
def current_field(lon, lat, rng, t_frac):
|
||||
# Main flow: SW along Catalan coast (negative v = south, slight negative u = west)
|
||||
angle_deg = 215 + 8 * math.sin(2 * math.pi * t_frac) # slow seasonal wobble
|
||||
rad = math.radians(angle_deg)
|
||||
u_base = BASE_SPEED_MS * math.cos(rad)
|
||||
v_base = BASE_SPEED_MS * math.sin(rad)
|
||||
|
||||
# Coastal weakening: reduce speed when close to Iberian coast (lon < 2.5, lat 40–42.5)
|
||||
dist_coast = 1.0
|
||||
if lon < 3.2 and 40.4 < lat < 42.5:
|
||||
dist_coast = max(0.15, (lon - 0.5) * 0.4 + (42.2 - lat) * 0.15)
|
||||
factor = min(1.0, dist_coast)
|
||||
u_main = u_base * factor
|
||||
v_main = v_base * factor
|
||||
|
||||
# Weak cyclonic gyre east of Barcelona (3.5, 41.5), radius ~0.8 deg
|
||||
cx, cy = 3.6, 41.4
|
||||
dx, dy = lon - cx, lat - cy
|
||||
r_deg = math.sqrt(dx * dx + dy * dy)
|
||||
if r_deg < 0.9 and r_deg > 0.05:
|
||||
gyre_speed = 0.06 * math.exp(-r_deg / 0.5) # m/s
|
||||
# tangential: (-dy, dx) for anticlockwise
|
||||
u_gyre = -gyre_speed * (dy / r_deg)
|
||||
v_gyre = gyre_speed * (dx / r_deg)
|
||||
else:
|
||||
u_gyre, v_gyre = 0.0, 0.0
|
||||
|
||||
# Small-scale turbulence (Gaussian, uncorrelated)
|
||||
u_noise = rng.normal(0, DIFFUSION_MS)
|
||||
v_noise = rng.normal(0, DIFFUSION_MS)
|
||||
|
||||
return (u_main + u_gyre + u_noise, v_main + v_gyre + v_noise)
|
||||
|
||||
|
||||
def advect_rk4(lon, lat, rng, t_frac):
|
||||
cos_lat = math.cos(math.radians(lat))
|
||||
dpm_lon = DEG_PER_M_LAT / max(cos_lat, 1e-6)
|
||||
|
||||
def vel(lo, la):
|
||||
u, v = current_field(lo, la, rng, t_frac)
|
||||
return u * DT_SECONDS * dpm_lon, v * DT_SECONDS * DEG_PER_M_LAT
|
||||
|
||||
k1 = vel(lon, lat)
|
||||
k2 = vel(lon + k1[0] / 2, lat + k1[1] / 2)
|
||||
k3 = vel(lon + k2[0] / 2, lat + k2[1] / 2)
|
||||
k4 = vel(lon + k3[0], lat + k3[1])
|
||||
|
||||
new_lon = lon + (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]) / 6
|
||||
new_lat = lat + (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]) / 6
|
||||
u_f, v_f = current_field(new_lon, new_lat, rng, t_frac)
|
||||
return new_lon, new_lat, u_f, v_f
|
||||
|
||||
|
||||
# ── Accumulation grid ────────────────────────────────────────────────────────
|
||||
|
||||
ACCUM_RES = 0.1
|
||||
|
||||
def build_accumulation(b_lons, b_lats):
|
||||
if len(b_lons) == 0:
|
||||
return []
|
||||
cells: dict[tuple[int, int], int] = {}
|
||||
for lo, la in zip(b_lons, b_lats):
|
||||
key = (round(lo / ACCUM_RES), round(la / ACCUM_RES))
|
||||
cells[key] = cells.get(key, 0) + 1
|
||||
return [[k[0] * ACCUM_RES, k[1] * ACCUM_RES, v] for k, v in cells.items()]
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate():
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
rng = np.random.default_rng(42)
|
||||
|
||||
init_lons, init_lats, origins = [], [], []
|
||||
for si, (slon, slat) in enumerate(SEED_POINTS):
|
||||
for _ in range(PARTICLES_PER_SEED):
|
||||
lon = slon + rng.uniform(-JITTER_DEG, JITTER_DEG)
|
||||
lat = slat + rng.uniform(-JITTER_DEG, JITTER_DEG)
|
||||
init_lons.append(lon)
|
||||
init_lats.append(lat)
|
||||
origins.append(si)
|
||||
|
||||
n = len(init_lons)
|
||||
lons = np.array(init_lons, dtype=float)
|
||||
lats = np.array(init_lats, dtype=float)
|
||||
|
||||
# Ensure every particle starts in water: push seaward (east/northeast) if on land
|
||||
for i in range(n):
|
||||
for _ in range(50):
|
||||
if not is_land(lons[i], lats[i]):
|
||||
break
|
||||
lons[i] += rng.uniform(0, OFFSHORE_DEG)
|
||||
lats[i] += rng.uniform(0, OFFSHORE_DEG * 0.5)
|
||||
init_lons = lons.tolist()
|
||||
init_lats = lats.tolist()
|
||||
|
||||
# Staggered release over first 24h for more realistic dispersion
|
||||
release_steps = rng.integers(0, min(24, NUM_STEPS), size=n).tolist()
|
||||
init_lons_arr = np.array(init_lons, dtype=float)
|
||||
init_lats_arr = np.array(init_lats, dtype=float)
|
||||
|
||||
beached = np.zeros(n, dtype=bool)
|
||||
|
||||
# Collect ALL frames: frames[step] = [[lon, lat, u, v, beached], ...]
|
||||
all_frames = []
|
||||
all_accum = []
|
||||
|
||||
for step in range(NUM_STEPS):
|
||||
t_frac = step / NUM_STEPS
|
||||
frame = np.zeros((n, 5))
|
||||
|
||||
for i in range(n):
|
||||
if step < release_steps[i]:
|
||||
frame[i] = [init_lons_arr[i], init_lats_arr[i], 0.0, 0.0, 0]
|
||||
continue
|
||||
if step == release_steps[i]:
|
||||
lons[i], lats[i] = init_lons_arr[i], init_lats_arr[i]
|
||||
|
||||
if not beached[i]:
|
||||
nl, nla, u, v = advect_rk4(float(lons[i]), float(lats[i]), rng, t_frac)
|
||||
if is_land(nl, nla):
|
||||
beached[i] = True
|
||||
u, v = 0.0, 0.0
|
||||
else:
|
||||
lons[i], lats[i] = nl, nla
|
||||
frame[i] = [lons[i], lats[i], u, v, 0]
|
||||
continue
|
||||
|
||||
frame[i] = [lons[i], lats[i], 0.0, 0.0, 1 if beached[i] else 0]
|
||||
|
||||
all_frames.append(np.round(frame, 5).tolist())
|
||||
|
||||
b_mask = beached
|
||||
accum = build_accumulation(lons[b_mask].tolist(), lats[b_mask].tolist())
|
||||
all_accum.append(accum)
|
||||
|
||||
act = int(np.sum(~beached))
|
||||
bch = int(np.sum(beached))
|
||||
print(f" Step {step:>2d}/{NUM_STEPS - 1} | active {act:>5d} beached {bch:>5d}")
|
||||
|
||||
# Write blocks
|
||||
num_blocks = NUM_STEPS // BLOCK_SIZE
|
||||
for b in range(num_blocks):
|
||||
s0 = b * BLOCK_SIZE
|
||||
s1 = s0 + BLOCK_SIZE
|
||||
block = {
|
||||
"block": b,
|
||||
"step_start": s0,
|
||||
"step_end": s1,
|
||||
"frames": all_frames[s0:s1],
|
||||
"accumulation": all_accum[s0:s1],
|
||||
}
|
||||
path = os.path.join(DATA_DIR, f"block_{b}.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(block, f, separators=(",", ":"))
|
||||
size_mb = os.path.getsize(path) / 1_048_576
|
||||
print(f" Block {b} ({s0}h-{s1 - 1}h) → {path} [{size_mb:.1f} MB]")
|
||||
|
||||
# Metadata (release_steps: step at which each particle is released, 0..num_steps-1)
|
||||
meta = {
|
||||
"num_particles": n,
|
||||
"num_steps": NUM_STEPS,
|
||||
"num_blocks": num_blocks,
|
||||
"block_size": BLOCK_SIZE,
|
||||
"dt_hours": 1,
|
||||
"seed_names": SEED_NAMES,
|
||||
"origins": origins,
|
||||
"release_steps": release_steps,
|
||||
}
|
||||
meta_path = os.path.join(DATA_DIR, "metadata.json")
|
||||
with open(meta_path, "w") as f:
|
||||
json.dump(meta, f, separators=(",", ":"))
|
||||
print(f" Metadata → {meta_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
n = len(SEED_POINTS) * PARTICLES_PER_SEED
|
||||
print(f"Simulating {n} particles for {NUM_STEPS} hours …")
|
||||
generate()
|
||||
print("Done.")
|
||||
168
backend/main.py
Normal file
168
backend/main.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""FastAPI server – serves simulation blocks (JSON or Arrow) with gzip compression."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pyarrow as pa
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
app = FastAPI(title="Trajectory Viewer API")
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Cache by (sim_id, name) for metadata/json. Arrow not cached so ETL regeneration is visible without restart.
|
||||
_meta_cache: dict[tuple[str, str], dict] = {}
|
||||
|
||||
|
||||
def _sim_dir(sim_id: str) -> Path:
|
||||
"""Resolve data directory for a simulation. Backward compat: default can be data/ if no simulations/."""
|
||||
sim_path = DATA_DIR / "simulations" / sim_id
|
||||
if sim_path.exists():
|
||||
return sim_path
|
||||
if sim_id == "default" and (DATA_DIR / "metadata.json").exists():
|
||||
return DATA_DIR
|
||||
return sim_path
|
||||
|
||||
|
||||
def _load(sim_id: str, name: str) -> dict | None:
|
||||
p = _sim_dir(sim_id) / name
|
||||
if not p.exists():
|
||||
return None
|
||||
# Never cache metadata so regenerated ETL (e.g. time_start/time_end) is always served
|
||||
if name == "metadata.json":
|
||||
with open(p) as f:
|
||||
return json.load(f)
|
||||
key = (sim_id, name)
|
||||
if key not in _meta_cache:
|
||||
with open(p) as f:
|
||||
_meta_cache[key] = json.load(f)
|
||||
return _meta_cache[key]
|
||||
|
||||
|
||||
def _block_to_arrow(sim_id: str, block_id: int) -> bytes:
|
||||
sim_path = _sim_dir(sim_id)
|
||||
arrow_file = sim_path / f"block_{block_id}.arrow"
|
||||
if arrow_file.exists():
|
||||
with open(arrow_file, "rb") as f:
|
||||
return f.read()
|
||||
data = _load(sim_id, f"block_{block_id}.json")
|
||||
if not data:
|
||||
raise HTTPException(404, f"Block {block_id} not found")
|
||||
frames = data["frames"]
|
||||
step_start = data["step_start"]
|
||||
n_particles = len(frames[0]) if frames else 0
|
||||
steps = []
|
||||
particle_ids = []
|
||||
lons, lats, us, vs, beached = [], [], [], [], []
|
||||
for step_idx, frame in enumerate(frames):
|
||||
step_global = step_start + step_idx
|
||||
for pid, row in enumerate(frame):
|
||||
steps.append(step_global)
|
||||
particle_ids.append(pid)
|
||||
lons.append(row[0])
|
||||
lats.append(row[1])
|
||||
us.append(row[2])
|
||||
vs.append(row[3])
|
||||
beached.append(row[4])
|
||||
table = pa.table({
|
||||
"step": pa.array(steps, type=pa.int32()),
|
||||
"particle_id": pa.array(particle_ids, type=pa.int32()),
|
||||
"lon": pa.array(lons, type=pa.float32()),
|
||||
"lat": pa.array(lats, type=pa.float32()),
|
||||
"u": pa.array(us, type=pa.float32()),
|
||||
"v": pa.array(vs, type=pa.float32()),
|
||||
"beached": pa.array(beached, type=pa.int8()),
|
||||
})
|
||||
sink = pa.BufferOutputStream()
|
||||
with pa.ipc.new_stream(sink, table.schema) as writer:
|
||||
writer.write_table(table)
|
||||
return sink.getvalue()
|
||||
|
||||
|
||||
@app.get("/api/simulations")
|
||||
def list_simulations():
|
||||
"""List available simulation IDs (for future N simulations)."""
|
||||
out = []
|
||||
if (DATA_DIR / "metadata.json").exists():
|
||||
out.append({"id": "default", "name": "Default"})
|
||||
sims_dir = DATA_DIR / "simulations"
|
||||
if sims_dir.exists():
|
||||
for d in sorted(sims_dir.iterdir()):
|
||||
if d.is_dir() and (d / "metadata.json").exists():
|
||||
out.append({"id": d.name, "name": d.name})
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/metadata")
|
||||
def metadata(sim: str = Query("default", description="Simulation ID")):
|
||||
data = _load(sim, "metadata.json")
|
||||
if not data:
|
||||
raise HTTPException(
|
||||
404,
|
||||
"metadata.json not found – run generate_dummy_data.py or scripts/run_etl for this simulation",
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/block/{block_id}")
|
||||
def block(
|
||||
block_id: int,
|
||||
request: Request,
|
||||
sim: str = Query("default", description="Simulation ID"),
|
||||
):
|
||||
data = _load(sim, f"block_{block_id}.json")
|
||||
if not data:
|
||||
arrow_file = _sim_dir(sim) / f"block_{block_id}.arrow"
|
||||
if not arrow_file.exists():
|
||||
raise HTTPException(404, f"Block {block_id} not found")
|
||||
if request.query_params.get("frames") == "false":
|
||||
meta = _load(sim, "metadata.json")
|
||||
if not meta:
|
||||
raise HTTPException(404, "metadata.json not found")
|
||||
num_blocks = meta["num_blocks"]
|
||||
block_size = meta["block_size"]
|
||||
step_start = block_id * block_size
|
||||
step_end = min(step_start + block_size, meta["num_steps"])
|
||||
return {
|
||||
"block": block_id,
|
||||
"step_start": step_start,
|
||||
"step_end": step_end,
|
||||
"accumulation": [],
|
||||
}
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Block available only as Arrow; use /api/block/{id}/arrow (JSON not generated for this simulation)",
|
||||
)
|
||||
if request.query_params.get("frames") == "false":
|
||||
return {
|
||||
"block": data["block"],
|
||||
"step_start": data["step_start"],
|
||||
"step_end": data["step_end"],
|
||||
"accumulation": data["accumulation"],
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/block/{block_id}/arrow")
|
||||
def block_arrow(
|
||||
block_id: int,
|
||||
sim: str = Query("default", description="Simulation ID"),
|
||||
):
|
||||
buf = _block_to_arrow(sim, block_id)
|
||||
return Response(
|
||||
content=bytes(buf),
|
||||
media_type="application/vnd.apache.arrow.stream",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
numpy
|
||||
pyarrow
|
||||
xarray
|
||||
netCDF4
|
||||
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# CLI scripts
|
||||
57
backend/scripts/run_etl.py
Normal file
57
backend/scripts/run_etl.py
Normal file
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CLI to run ETL: NetCDF trajectory file → Arrow blocks + metadata.
|
||||
|
||||
Usage:
|
||||
python -m scripts.run_etl path/to/trajectories.nc
|
||||
python -m scripts.run_etl path/to/file.nc --sim-id my_run
|
||||
python -m scripts.run_etl file.nc --out-dir /path/to/backend/data --write-json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ETL: NetCDF trajectories → Arrow blocks")
|
||||
parser.add_argument("nc_path", type=str, help="Path to input .nc file")
|
||||
parser.add_argument(
|
||||
"--out-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Output base directory (default: backend/data)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sim-id",
|
||||
type=str,
|
||||
default="default",
|
||||
help="Simulation ID (default: default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write-json",
|
||||
action="store_true",
|
||||
help="Also write block_*.json (for backward compat)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.out_dir is None:
|
||||
out_dir = Path(__file__).resolve().parent.parent / "data"
|
||||
else:
|
||||
out_dir = Path(args.out_dir)
|
||||
|
||||
from etl.pipeline import run_etl
|
||||
|
||||
nc_path = Path(args.nc_path)
|
||||
if not nc_path.exists():
|
||||
raise SystemExit(f"File not found: {nc_path}")
|
||||
|
||||
print(f"ETL: {nc_path} → {out_dir / 'simulations' / args.sim_id}")
|
||||
meta = run_etl(nc_path, out_dir, sim_id=args.sim_id, write_json=args.write_json)
|
||||
print(f" num_particles={meta['num_particles']}, num_steps={meta['num_steps']}, num_blocks={meta['num_blocks']}")
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
219
docs/Proposta_Requisits_Visor_Client.html
Normal file
219
docs/Proposta_Requisits_Visor_Client.html
Normal file
@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ca">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Requisits de programari — Visor de trajectòries lagrangianes</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root { --border: #e0e0e0; --muted: #555; --accent: #1a5f7a; --bg-note: #f8f9fa; --bg-capture: #fff9e6; }
|
||||
body { font-family: 'Segoe UI', Calibri, sans-serif; max-width: 780px; margin: 2rem auto; padding: 0 1.5rem; line-height: 1.6; color: #1a1a1a; }
|
||||
h1 { font-size: 1.6rem; font-weight: 700; border-bottom: 2px solid var(--border); padding-bottom: 0.4rem; margin: 0 0 0.25rem 0; }
|
||||
h2 { font-size: 1.2rem; font-weight: 600; margin-top: 1.75rem; margin-bottom: 0.5rem; color: #333; }
|
||||
h3 { font-size: 1.05rem; font-weight: 600; margin-top: 1.25rem; margin-bottom: 0.35rem; color: #444; }
|
||||
p { margin: 0.5rem 0; }
|
||||
ul, ol { margin: 0.6rem 0; padding-left: 1.5rem; }
|
||||
li { margin: 0.4rem 0; }
|
||||
.meta { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
|
||||
.lead { font-size: 0.95rem; color: var(--muted); margin-bottom: 1.5rem; }
|
||||
.toc { font-size: 0.9rem; background: var(--bg-note); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem 1rem; margin: 1.25rem 0; }
|
||||
.toc ul { list-style: none; padding-left: 0; margin: 0; }
|
||||
.toc li { margin: 0.2rem 0; }
|
||||
.toc a { color: var(--accent); text-decoration: none; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
.note { background: var(--bg-note); border-left: 4px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; font-size: 0.9rem; border-radius: 0 4px 4px 0; }
|
||||
.capture-box { background: var(--bg-capture); border: 1px solid #e6d9b3; border-radius: 6px; padding: 0.85rem 1rem; margin: 1rem 0; font-size: 0.9rem; }
|
||||
.capture-box h4 { margin: 0 0 0.5rem 0; font-size: 0.95rem; color: #7a6215; }
|
||||
.capture-box ul { margin: 0.35rem 0 0 0; padding-left: 1.25rem; }
|
||||
.req-id { font-family: monospace; font-size: 0.9em; color: var(--accent); }
|
||||
a { color: var(--accent); }
|
||||
a:hover { text-decoration: underline; }
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||
.mermaid { margin: 1.25rem 0; text-align: center; }
|
||||
.diagram-caption { font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; font-style: italic; }
|
||||
.section { margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Requisits de programari — Visor de trajectòries lagrangianes</h1>
|
||||
<p class="meta"><strong>Document de requisits · Client</strong></p>
|
||||
|
||||
<nav class="toc" aria-label="Contingut">
|
||||
<strong>Índex</strong>
|
||||
<ul>
|
||||
<li>1. <a href="#intro">Introducció</a></li>
|
||||
<li>2. <a href="#descripcio">Descripció general del producte</a></li>
|
||||
<li>3. <a href="#actors">Actors i rols</a></li>
|
||||
<li>4. <a href="#requisits">Requisits funcionals</a></li>
|
||||
<li>5. <a href="#flux">Flux del sistema</a></li>
|
||||
<li>6. <a href="#entregables">Entregables</a></li>
|
||||
<li>7. <a href="#acceptacio">Criteris d'acceptació</a></li>
|
||||
<li>8. <a href="#annexos">Annexos i referències</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<hr>
|
||||
|
||||
<section class="section" id="intro">
|
||||
<h2>1. Introducció</h2>
|
||||
<h3>1.1 Objectiu del document</h3>
|
||||
<p>Aquest document defineix els requisits de programari del projecte <strong>Visor de trajectòries lagrangianes</strong>. Està dirigit al client i estableix l’abast funcional, els actors, els requisits i els criteris d’acceptació.</p>
|
||||
<h3>1.2 Abast</h3>
|
||||
<p>Inclou el visor web de visualització de trajectòries, l’àrea d’administració per a la càrrega i gestió de dades, el processament de fitxers NetCDF i el desplegament del conjunt. Queden fora d’abast els detalls d’arquitectura i implementació.</p>
|
||||
</section>
|
||||
|
||||
<section class="section" id="descripcio">
|
||||
<h2>2. Descripció general del producte</h2>
|
||||
<ul>
|
||||
<li>El producte és una <strong>aplicació web</strong> (visor) accessible des del navegador que permet visualitzar partícules contaminants (o similars) en moviment al Mediterrani occidental sobre un mapa.</li>
|
||||
<li>Les partícules tenen origen en punts de la costa catalana, es dispersen amb els corrents durant un període definit (p. ex. 72 h) i, en arribar a la costa, queden encallades i s’acumulen. El visor permet identificar aquestes zones d’impacte de forma visual.</li>
|
||||
<li>El mapa és interactiu (vistes 2D i 3D), amb animació temporal i controls per analitzar les dades. El producte està pensat per a l’anàlisi tècnica i per a la comunicació de resultats (reunions, informes).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="actors">
|
||||
<h2>3. Actors i rols</h2>
|
||||
<ul>
|
||||
<li><strong>Administrador:</strong> usuari amb permisos per pujar fitxers de dades (format NetCDF) i per gestionar les simulacions (consultar llistat, eliminar i, si es pacta, actualitzar o substituir). És l’únic rol amb accés a aquestes operacions.</li>
|
||||
<li><strong>Usuari final:</strong> usuari que accedeix al visor per consultar simulacions ja publicades. Pot seleccionar simulació, utilitzar el mapa i els controls d’animació, filtres i llegenda. No pot pujar ni eliminar dades.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="requisits">
|
||||
<h2>4. Requisits funcionals</h2>
|
||||
|
||||
<h3>4.1 Administració i càrrega de dades</h3>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-01</span> El sistema ha de permetre a l’administrador pujar fitxers NetCDF des d’una àrea d’administració dedicada.</li>
|
||||
<li><span class="req-id">REQ-F-02</span> Els fitxers NetCDF hauran d’ajustar-se al format acordat (variables de longitud, latitud, velocitats, estat d’encallament, eixos de temps i partícules).</li>
|
||||
<li><span class="req-id">REQ-F-03</span> El sistema ha de mostrar a l’administrador un llistat de simulacions disponibles (identificador, nom o data, segons implementació).</li>
|
||||
<li><span class="req-id">REQ-F-04</span> El sistema ha de permetre a l’administrador eliminar simulacions. La simulació eliminada deixa d’estar disponible per als usuaris finals.</li>
|
||||
<li><span class="req-id">REQ-F-05</span> Si es pacta, el sistema ha de permetre actualitzar o substituir fitxers existents sense crear una simulació nova.</li>
|
||||
<li><span class="req-id">REQ-F-06</span> Un cop pujat un fitxer NetCDF, el sistema ha de validar-lo, processar-lo i deixar les dades disponibles per al visor sense intervenció manual addicional de l’administrador.</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.2 Visor — Mapa i visualització</h3>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-07</span> El visor ha de mostrar un mapa base interactiu sobre el qual es dibuixen les trajectòries, amb zoom i desplaçament (pan).</li>
|
||||
<li><span class="req-id">REQ-F-08</span> El visor ha de oferir diversos estils de mapa base (p. ex. clar, fosc) seleccionables per l’usuari.</li>
|
||||
<li><span class="req-id">REQ-F-09</span> El visor ha de suportar visualització en 2D i 3D de les trajectòries.</li>
|
||||
<li><span class="req-id">REQ-F-10</span> El visor ha de mostrar l’estela del moviment de cada partícula (traça temporal) per diferenciar les partícules en moviment de les encallades.</li>
|
||||
<li><span class="req-id">REQ-F-11</span> El visor ha de assignar un color per punt d’origen (llançament) per permetre identificar l’origen de cada partícula.</li>
|
||||
<li><span class="req-id">REQ-F-12</span> El visor ha de diferenciar visualment les partícules actives (en moviment) de les encallades (p. ex. mitjançant color o símbol).</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.3 Visor — Animació i temps</h3>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-13</span> El visor ha de oferir controls de reproducció: iniciar (play) i aturar (pausa) l’animació temporal.</li>
|
||||
<li><span class="req-id">REQ-F-14</span> El visor ha de oferir un control de velocitat de l’animació (múltiples graus, des de més lent fins a més ràpid).</li>
|
||||
<li><span class="req-id">REQ-F-15</span> El visor ha de oferir un control (slider o equivalent) per desplaçar-se directament a un instant temporal concret sense reproduir l’animació completa.</li>
|
||||
<li><span class="req-id">REQ-F-16</span> El visor ha de mostrar l’instant temporal actual (p. ex. “Dia 2 · 14:00” o data real si les dades la inclouen).</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.4 Visor — Filtres i capes</h3>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-17</span> El visor ha de oferir un panell o menú de filtres per activar o desactivar capes visuals: esteles de moviment, partícules actives i partícules encallades.</li>
|
||||
<li><span class="req-id">REQ-F-18</span> El visor ha de permetre canviar l’estil del mapa base des del panell de filtres o un menú equivalent.</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.5 Visor — Informació i ajuda</h3>
|
||||
<p>Informació contextual en passar el cursor sobre elements del mapa i llegenda per interpretar colors i estats.</p>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-19</span> Tooltip sobre partícula: com a mínim origen i velocitat en aquell instant.</li>
|
||||
<li><span class="req-id">REQ-F-20</span> Llegenda amb el significat dels colors, orígens i estats (actiu / encallat).</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.6 Visor — Navegació i simulacions</h3>
|
||||
<ul>
|
||||
<li><span class="req-id">REQ-F-21</span> El visor ha de oferir una acció (p. ex. botó) per encuadrar totes les partícules visibles al mapa mitjançant zoom automàtic.</li>
|
||||
<li><span class="req-id">REQ-F-22</span> Quan existeixin diverses simulacions, el visor ha de permetre seleccionar quina simulació visualitzar (desplegable, selector o paràmetre d’URL) i carregar les dades corresponents.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="flux">
|
||||
<h2>5. Flux del sistema</h2>
|
||||
|
||||
<h3>5.1 Flux de dades</h3>
|
||||
<p>L’administrador puja fitxers NetCDF. El sistema els valida, els processa (incloent submostreig horari quan sigui aplicable) i genera les dades i metadades necessàries per al visor. Les simulacions queden disponibles per a tots els usuaris amb accés al visor. L’usuari final obre el visor, selecciona una simulació i utilitza els controls d’animació, filtres, tooltips i llegenda.</p>
|
||||
<div class="mermaid">
|
||||
flowchart LR
|
||||
A[Administrador<br/>puja NetCDF] --> B[Sistema<br/>valida i processa]
|
||||
B --> C[Simulacions<br/>disponibles]
|
||||
C --> D[Usuari<br/>visualitza al visor]
|
||||
</div>
|
||||
<p class="diagram-caption">Figura 1 — Flux de dades.</p>
|
||||
|
||||
<h3>5.2 Flux d’ús del visor</h3>
|
||||
<p>L’usuari accedeix al visor des del navegador, selecciona simulació (o se n’ofereix una per defecte), el mapa es carrega amb les trajectòries i l’usuari pot utilitzar zoom, desplaçament, controls de reproducció, filtres, tooltips i zoom a partícules.</p>
|
||||
<div class="mermaid">
|
||||
flowchart TB
|
||||
O([Obrir visor]) --> T[Triar simulació]
|
||||
T --> M[Mapa amb trajectòries]
|
||||
M --> C{Controls}
|
||||
C --> P[Play / Pausa / Velocitat]
|
||||
C --> S[Slider temporal]
|
||||
C --> F[Filtres i capes]
|
||||
C --> Z[Zoom a partícules]
|
||||
C --> L[Tooltips i llegenda]
|
||||
</div>
|
||||
<p class="diagram-caption">Figura 2 — Flux d’ús del visor.</p>
|
||||
</section>
|
||||
|
||||
<section class="section" id="entregables">
|
||||
<h2>6. Entregables</h2>
|
||||
<ul>
|
||||
<li><strong>Visor web:</strong> aplicació accessible des del navegador que implementa els requisits de les seccions 4.2 a 4.6 (mapa, animació, controls de temps, filtres, tooltips, llegenda).</li>
|
||||
<li><strong>Àrea d’administració:</strong> interfície que implementa els requisits REQ-F-01 a REQ-F-06 (pujada de NetCDF, llistat, eliminació i, si s’acorda, actualització o substitució).</li>
|
||||
<li><strong>Processament de dades:</strong> pipeline de validació i transformació dels fitxers NetCDF pujats per deixar les dades disponibles per al visor sense accions manuals addicionals.</li>
|
||||
<li><strong>Documentació d’ús:</strong> instruccions per a l’administrador (càrrega i gestió de fitxers) i per a l’usuari final (ús del visor i controls), segons pacte.</li>
|
||||
<li><strong>Desplegament:</strong> conjunt (visor, administració i processament) desplegat de forma reproducible (p. ex. contenidors), segons acord, per a la posada en marxa en l’entorn del client.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="acceptacio">
|
||||
<h2>7. Criteris d’acceptació</h2>
|
||||
<p>El projecte es donarà per acceptat quan es compleixin les condicions següents:</p>
|
||||
<ul>
|
||||
<li>L’administrador pot pujar fitxers NetCDF en el format acordat i pot llistar i eliminar simulacions des de la interfície d’administració.</li>
|
||||
<li>El visor mostra les trajectòries amb animació temporal i diferenciació clara entre partícules en moviment i encallades, i entre orígens (mitjançant colors i llegenda).</li>
|
||||
<li>L’usuari final pot utilitzar sense errors els controls de reproducció (play, pausa, velocitat, slider), el panell de filtres i capes, els tooltips i la llegenda, la selecció de simulació i el zoom a partícules.</li>
|
||||
<li>El rendiment del visor és acceptable amb el volum de partícules i passos temporals definits al projecte (reproducció fluida sense bloquejos evidents).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section" id="annexos">
|
||||
<h2>8. Annexos i referències</h2>
|
||||
<p>Referències externes per contextualitzar el tipus de visualització. Les captures recomanades poden emprar-se per a documentació o presentacions del client.</p>
|
||||
|
||||
<h3>8.1 Enllaços de referència</h3>
|
||||
<ul>
|
||||
<li><a href="https://deck.gl" target="_blank" rel="noopener">deck.gl</a> — motor de visualització del visor.</li>
|
||||
<li><a href="https://deck.gl/examples/trips-layer" target="_blank" rel="noopener">Trips Layer</a> — exemple de trajectòries animades amb estela; referència propera al comportament del visor.</li>
|
||||
<li><a href="https://deck.gl/examples" target="_blank" rel="noopener">Exemples deck.gl</a>.</li>
|
||||
<li><a href="https://maplibre.org" target="_blank" rel="noopener">MapLibre</a> — mapes base (sense clau d’API). <a href="https://maplibre.org/demos/" target="_blank" rel="noopener">Demos</a>.</li>
|
||||
<li><a href="https://arrow.apache.org" target="_blank" rel="noopener">Apache Arrow</a> — format intern de dades (informatiu).</li>
|
||||
</ul>
|
||||
|
||||
<h3>8.2 Captures recomanades per a documentació o presentació</h3>
|
||||
<div class="capture-box">
|
||||
<ul>
|
||||
<li><strong>Captura principal:</strong> <a href="https://deck.gl/examples/trips-layer" target="_blank" rel="noopener">deck.gl — Trips Layer</a>. Obrir l’enllaç, activar l’animació (Play) i realitzar una captura de pantalla amb trajectòries amb estela i colors. Representa de forma adequada el tipus de visualització del producte.</li>
|
||||
<li><strong>Captura opcional:</strong> <a href="https://maplibre.org/demos/" target="_blank" rel="noopener">MapLibre — Demos</a>. Seleccionar un estil de mapa (clar o fosc) i realitzar una captura per il·lustrar l’aspecte del mapa base del visor.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr>
|
||||
<p class="meta"><em>Document de requisits de programari. Per a consultes sobre requisits o abast, contactar amb el proveïdor.</em></p>
|
||||
|
||||
<div class="note">
|
||||
<strong>Ús del document en Word</strong><br>
|
||||
Seleccioneu tot el contingut (Ctrl+A), copieu i enganxeu en un document Word. Per incloure els diagrames, obrir aquest fitxer al navegador, realitzar una captura de pantalla de cada diagrama (p. ex. Win+Shift+S) i enganxar-les al document. Per il·lustrar el visor, afegir la captura de <a href="https://deck.gl/examples/trips-layer" target="_blank" rel="noopener">Trips Layer</a> amb l’animació activa (secció 8.2).
|
||||
</div>
|
||||
|
||||
<script>
|
||||
mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
172
docs/Proposta_Requisits_Visor_Client.md
Normal file
172
docs/Proposta_Requisits_Visor_Client.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Requisits de programari — Visor de trajectòries lagrangianes
|
||||
|
||||
**Document de requisits · Client**
|
||||
|
||||
---
|
||||
|
||||
## Índex
|
||||
|
||||
1. [Introducció](#1-introducció)
|
||||
2. [Descripció general del producte](#2-descripció-general-del-producte)
|
||||
3. [Actors i rols](#3-actors-i-rols)
|
||||
4. [Requisits funcionals](#4-requisits-funcionals)
|
||||
5. [Flux del sistema](#5-flux-del-sistema)
|
||||
6. [Entregables](#6-entregables)
|
||||
7. [Criteris d'acceptació](#7-criteris-dacceptació)
|
||||
8. [Annexos i referències](#8-annexos-i-referències)
|
||||
|
||||
---
|
||||
|
||||
## 1. Introducció
|
||||
|
||||
### 1.1 Objectiu del document
|
||||
|
||||
Aquest document defineix els requisits de programari del projecte **Visor de trajectòries lagrangianes**. Està dirigit al client i estableix l'abast funcional, els actors, els requisits i els criteris d'acceptació.
|
||||
|
||||
### 1.2 Abast
|
||||
|
||||
Inclou el visor web de visualització de trajectòries, l'àrea d'administració per a la càrrega i gestió de dades, el processament de fitxers NetCDF i el desplegament del conjunt. Queden fora d'abast els detalls d'arquitectura i implementació.
|
||||
|
||||
---
|
||||
|
||||
## 2. Descripció general del producte
|
||||
|
||||
- El producte és una **aplicació web** (visor) accessible des del navegador que permet visualitzar partícules contaminants (o similars) en moviment al Mediterrani occidental sobre un mapa.
|
||||
- Les partícules tenen origen en punts de la costa catalana, es dispersen amb els corrents durant un període definit (p. ex. 72 h) i, en arribar a la costa, queden encallades i s'acumulen. El visor permet identificar aquestes zones d'impacte de forma visual.
|
||||
- El mapa és interactiu (vistes 2D i 3D), amb animació temporal i controls per analitzar les dades. El producte està pensat per a l'anàlisi tècnica i per a la comunicació de resultats (reunions, informes).
|
||||
|
||||
---
|
||||
|
||||
## 3. Actors i rols
|
||||
|
||||
- **Administrador:** usuari amb permisos per pujar fitxers de dades (format NetCDF) i per gestionar les simulacions (consultar llistat, eliminar i, si es pacta, actualitzar o substituir). És l'únic rol amb accés a aquestes operacions.
|
||||
- **Usuari final:** usuari que accedeix al visor per consultar simulacions ja publicades. Pot seleccionar simulació, utilitzar el mapa i els controls d'animació, filtres i llegenda. No pot pujar ni eliminar dades.
|
||||
|
||||
---
|
||||
|
||||
## 4. Requisits funcionals
|
||||
|
||||
### 4.1 Administració i càrrega de dades
|
||||
|
||||
- **REQ-F-01** El sistema ha de permetre a l'administrador pujar fitxers NetCDF des d'una àrea d'administració dedicada.
|
||||
- **REQ-F-02** Els fitxers NetCDF hauran d'ajustar-se al format acordat (variables de longitud, latitud, velocitats, estat d'encallament, eixos de temps i partícules).
|
||||
- **REQ-F-03** El sistema ha de mostrar a l'administrador un llistat de simulacions disponibles (identificador, nom o data, segons implementació).
|
||||
- **REQ-F-04** El sistema ha de permetre a l'administrador eliminar simulacions. La simulació eliminada deixa d'estar disponible per als usuaris finals.
|
||||
- **REQ-F-05** Si es pacta, el sistema ha de permetre actualitzar o substituir fitxers existents sense crear una simulació nova.
|
||||
- **REQ-F-06** Un cop pujat un fitxer NetCDF, el sistema ha de validar-lo, processar-lo i deixar les dades disponibles per al visor sense intervenció manual addicional de l'administrador.
|
||||
|
||||
### 4.2 Visor — Mapa i visualització
|
||||
|
||||
- **REQ-F-07** El visor ha de mostrar un mapa base interactiu sobre el qual es dibuixen les trajectòries, amb zoom i desplaçament (pan).
|
||||
- **REQ-F-08** El visor ha de oferir diversos estils de mapa base (p. ex. clar, fosc) seleccionables per l'usuari.
|
||||
- **REQ-F-09** El visor ha de suportar visualització en 2D i 3D de les trajectòries.
|
||||
- **REQ-F-10** El visor ha de mostrar l'estela del moviment de cada partícula (traça temporal) per diferenciar les partícules en moviment de les encallades.
|
||||
- **REQ-F-11** El visor ha d'assignar un color per punt d'origen (llançament) per permetre identificar l'origen de cada partícula.
|
||||
- **REQ-F-12** El visor ha de diferenciar visualment les partícules actives (en moviment) de les encallades (p. ex. mitjançant color o símbol).
|
||||
|
||||
### 4.3 Visor — Animació i temps
|
||||
|
||||
- **REQ-F-13** El visor ha d'oferir controls de reproducció: iniciar (play) i aturar (pausa) l'animació temporal.
|
||||
- **REQ-F-14** El visor ha d'oferir un control de velocitat de l'animació (múltiples graus, des de més lent fins a més ràpid).
|
||||
- **REQ-F-15** El visor ha d'oferir un control (slider o equivalent) per desplaçar-se directament a un instant temporal concret sense reproduir l'animació completa.
|
||||
- **REQ-F-16** El visor ha de mostrar l'instant temporal actual (p. ex. "Dia 2 · 14:00" o data real si les dades la inclouen).
|
||||
|
||||
### 4.4 Visor — Filtres i capes
|
||||
|
||||
- **REQ-F-17** El visor ha d'oferir un panell o menú de filtres per activar o desactivar capes visuals: esteles de moviment, partícules actives i partícules encallades.
|
||||
- **REQ-F-18** El visor ha de permetre canviar l'estil del mapa base des del panell de filtres o un menú equivalent.
|
||||
|
||||
### 4.5 Visor — Informació i ajuda
|
||||
|
||||
- **REQ-F-19** El visor ha de mostrar informació addicional (tooltip) en passar el cursor sobre una partícula: com a mínim origen i velocitat en aquell instant.
|
||||
- **REQ-F-20** El visor ha d'incloure una llegenda que expliqui el significat dels colors, orígens i estats (actiu / encallat) per a la interpretació per part de qualsevol usuari.
|
||||
|
||||
### 4.6 Visor — Navegació i simulacions
|
||||
|
||||
- **REQ-F-21** El visor ha d'oferir una acció (p. ex. botó) per encuadrar totes les partícules visibles al mapa mitjançant zoom automàtic.
|
||||
- **REQ-F-22** Quan existeixin diverses simulacions, el visor ha de permetre seleccionar quina simulació visualitzar (desplegable, selector o paràmetre d'URL) i carregar les dades corresponents.
|
||||
|
||||
---
|
||||
|
||||
## 5. Flux del sistema
|
||||
|
||||
### 5.1 Flux de dades
|
||||
|
||||
L'administrador puja fitxers NetCDF. El sistema els valida, els processa (incloent submostreig horari quan sigui aplicable) i genera les dades i metadades necessàries per al visor. Les simulacions queden disponibles per a tots els usuaris amb accés al visor. L'usuari final obre el visor, selecciona una simulació i utilitza els controls d'animació, filtres, tooltips i llegenda.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Administrador<br/>puja NetCDF] --> B[Sistema<br/>valida i processa]
|
||||
B --> C[Simulacions<br/>disponibles]
|
||||
C --> D[Usuari<br/>visualitza al visor]
|
||||
```
|
||||
|
||||
*Figura 1 — Flux de dades.*
|
||||
|
||||
### 5.2 Flux d'ús del visor
|
||||
|
||||
L'usuari accedeix al visor des del navegador, selecciona simulació (o se n'ofereix una per defecte), el mapa es carrega amb les trajectòries i l'usuari pot utilitzar zoom, desplaçament, controls de reproducció, filtres, tooltips i zoom a partícules.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
O([Obrir visor]) --> T[Triar simulació]
|
||||
T --> M[Mapa amb trajectòries]
|
||||
M --> C{Controls}
|
||||
C --> P[Play / Pausa / Velocitat]
|
||||
C --> S[Slider temporal]
|
||||
C --> F[Filtres i capes]
|
||||
C --> Z[Zoom a partícules]
|
||||
C --> L[Tooltips i llegenda]
|
||||
```
|
||||
|
||||
*Figura 2 — Flux d'ús del visor.*
|
||||
|
||||
---
|
||||
|
||||
## 6. Entregables
|
||||
|
||||
- **Visor web:** aplicació accessible des del navegador que implementa els requisits de les seccions 4.2 a 4.6 (mapa, animació, controls de temps, filtres, tooltips, llegenda).
|
||||
- **Àrea d'administració:** interfície que implementa els requisits REQ-F-01 a REQ-F-06 (pujada de NetCDF, llistat, eliminació i, si s'acorda, actualització o substitució).
|
||||
- **Processament de dades:** pipeline de validació i transformació dels fitxers NetCDF pujats per deixar les dades disponibles per al visor sense accions manuals addicionals.
|
||||
- **Documentació d'ús:** instruccions per a l'administrador (càrrega i gestió de fitxers) i per a l'usuari final (ús del visor i controls), segons pacte.
|
||||
- **Desplegament:** conjunt (visor, administració i processament) desplegat de forma reproducible (p. ex. contenidors), segons acord, per a la posada en marxa en l'entorn del client.
|
||||
|
||||
---
|
||||
|
||||
## 7. Criteris d'acceptació
|
||||
|
||||
El projecte es donarà per acceptat quan es compleixin les condicions següents:
|
||||
|
||||
- L'administrador pot pujar fitxers NetCDF en el format acordat i pot llistar i eliminar simulacions des de la interfície d'administració.
|
||||
- El visor mostra les trajectòries amb animació temporal i diferenciació clara entre partícules en moviment i encallades, i entre orígens (mitjançant colors i llegenda).
|
||||
- L'usuari final pot utilitzar sense errors els controls de reproducció (play, pausa, velocitat, slider), el panell de filtres i capes, els tooltips i la llegenda, la selecció de simulació i el zoom a partícules.
|
||||
- El rendiment del visor és acceptable amb el volum de partícules i passos temporals definits al projecte (reproducció fluida sense bloquejos evidents).
|
||||
|
||||
---
|
||||
|
||||
## 8. Annexos i referències
|
||||
|
||||
Referències externes per contextualitzar el tipus de visualització. Les captures recomanades poden emprar-se per a documentació o presentacions del client.
|
||||
|
||||
### 8.1 Enllaços de referència
|
||||
|
||||
- [deck.gl](https://deck.gl) — motor de visualització del visor.
|
||||
- [Trips Layer](https://deck.gl/examples/trips-layer) — exemple de trajectòries animades amb estela; referència propera al comportament del visor.
|
||||
- [Exemples deck.gl](https://deck.gl/examples).
|
||||
- [MapLibre](https://maplibre.org) — mapes base (sense clau d'API). [Demos](https://maplibre.org/demos/).
|
||||
- [Apache Arrow](https://arrow.apache.org) — format intern de dades (informatiu).
|
||||
|
||||
### 8.2 Captures recomanades per a documentació o presentació
|
||||
|
||||
- **Captura principal:** [deck.gl — Trips Layer](https://deck.gl/examples/trips-layer). Obrir l'enllaç, activar l'animació (Play) i realitzar una captura de pantalla amb trajectòries amb estela i colors. Representa de forma adequada el tipus de visualització del producte.
|
||||
- **Captura opcional:** [MapLibre — Demos](https://maplibre.org/demos/). Seleccionar un estil de mapa (clar o fosc) i realitzar una captura per il·lustrar l'aspecte del mapa base del visor.
|
||||
|
||||
---
|
||||
|
||||
*Document de requisits de programari. Per a consultes sobre requisits o abast, contactar amb el proveïdor.*
|
||||
|
||||
---
|
||||
|
||||
### Ús del document en Word
|
||||
|
||||
Seleccioneu tot el contingut (Ctrl+A), copieu i enganxeu en un document Word. Per incloure els diagrames, obrir el fitxer HTML al navegador, realitzar una captura de pantalla de cada diagrama (p. ex. Win+Shift+S) i enganxar-les al document. Per il·lustrar el visor, afegir la captura de [Trips Layer](https://deck.gl/examples/trips-layer) amb l'animació activa (secció 8.2).
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
14651
frontend/package-lock.json
generated
Normal file
14651
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "^9.2.9",
|
||||
"@deck.gl/geo-layers": "^9.2.9",
|
||||
"@deck.gl/layers": "^9.2.9",
|
||||
"@deck.gl/react": "^9.2.9",
|
||||
"apache-arrow": "^21.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"deck.gl": "^9.2.9",
|
||||
"lucide-react": "^0.575.0",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
149
frontend/src/app/globals.css
Normal file
149
frontend/src/app/globals.css
Normal file
@ -0,0 +1,149 @@
|
||||
@import "tailwindcss";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-inter);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
a,
|
||||
[type="button"],
|
||||
[type="submit"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
23
frontend/src/app/layout.tsx
Normal file
23
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Simulador de Contaminació Marina",
|
||||
description:
|
||||
"Visor de trajectòries lagrangianes de partícules contaminants al Mediterrani occidental",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ca" className="dark">
|
||||
<body className={`${inter.variable} font-sans antialiased bg-background text-foreground`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
9
frontend/src/app/page.tsx
Normal file
9
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import TrajectoryMap from "@/components/TrajectoryMap";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="w-screen h-screen">
|
||||
<TrajectoryMap />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/IntroDialog.tsx
Normal file
126
frontend/src/components/IntroDialog.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Waves, MapPin, Keyboard } from "lucide-react";
|
||||
|
||||
const INTRO_SKIP_KEY = "trajectories_intro_skip";
|
||||
|
||||
interface IntroDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function IntroDialog({ open, onOpenChange }: IntroDialogProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
const handleStart = () => {
|
||||
if (dontShowAgain && typeof window !== "undefined") {
|
||||
localStorage.setItem(INTRO_SKIP_KEY, "true");
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md rounded-2xl border-slate-600 bg-slate-800/98 shadow-2xl backdrop-blur-sm text-left">
|
||||
<DialogHeader className="space-y-3">
|
||||
<DialogTitle className="flex items-center gap-3 text-white text-xl">
|
||||
<span className="flex size-12 items-center justify-center rounded-2xl bg-gradient-to-br from-teal-400 to-teal-600 text-slate-900 shadow-lg shadow-teal-500/30">
|
||||
<Waves className="size-6" />
|
||||
</span>
|
||||
<span>
|
||||
Visualitzador de trajectòries
|
||||
<span className="block text-sm font-normal text-slate-400 mt-0.5">
|
||||
Mediterrània NW · UPC
|
||||
</span>
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="text-slate-400 space-y-3 text-sm leading-relaxed">
|
||||
<p>
|
||||
Aquesta eina mostra una{" "}
|
||||
<strong className="text-slate-300">simulació lagrangiana</strong>{" "}
|
||||
de partícules al Mediterrani nord-occidental: alliberades des de la
|
||||
costa catalana i advectades per un camp de corrents. Les dades es
|
||||
mostren <strong className="text-slate-300">cada hora</strong> durant
|
||||
diversos dies.
|
||||
</p>
|
||||
<p>
|
||||
Metodologia compatible amb{" "}
|
||||
<a
|
||||
href="https://oceanparcels.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:text-teal-300 underline underline-offset-2"
|
||||
>
|
||||
Ocean Parcels
|
||||
</a>
|
||||
: partícules passives; les que toquen costa es marquen com a{" "}
|
||||
<strong className="text-slate-300">beached</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-slate-600 bg-slate-700/50 p-3 flex items-start gap-2">
|
||||
<span className="size-3 rounded-full bg-teal-400 shrink-0 mt-1 shadow-sm shadow-teal-400/40" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200 text-sm">Actives</p>
|
||||
<p className="text-xs text-slate-500">A l’aigua</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-600 bg-slate-700/50 p-3 flex items-start gap-2">
|
||||
<MapPin className="size-4 shrink-0 mt-0.5 text-red-400" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-200 text-sm">Beached</p>
|
||||
<p className="text-xs text-slate-500">A la costa</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-600 bg-slate-700/30 px-3 py-2 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Keyboard className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-slate-600 text-slate-300 font-mono text-[10px]">←</kbd>{" "}
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-slate-600 text-slate-300 font-mono text-[10px]">→</kbd>{" "}
|
||||
una hora · Espai: play/pausa
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-slate-500 hover:text-slate-400 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontShowAgain}
|
||||
onChange={(e) => setDontShowAgain(e.target.checked)}
|
||||
className="rounded border-slate-500 bg-slate-700 text-teal-500 focus:ring-teal-500/50"
|
||||
/>
|
||||
<span>No tornar a mostrar</span>
|
||||
</label>
|
||||
|
||||
<DialogFooter className="flex flex-row justify-end gap-2 sm:gap-2">
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
className="rounded-xl bg-teal-500 px-6 hover:bg-teal-400 text-slate-900 font-semibold shadow-lg shadow-teal-500/25"
|
||||
>
|
||||
Començar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowIntro(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
return localStorage.getItem(INTRO_SKIP_KEY) !== "true";
|
||||
}
|
||||
33
frontend/src/components/LoadingCard.tsx
Normal file
33
frontend/src/components/LoadingCard.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { Meta } from "@/types/trajectory";
|
||||
|
||||
interface LoadingCardProps {
|
||||
meta: Meta | null;
|
||||
loadProgress: number;
|
||||
}
|
||||
|
||||
export function LoadingCard({ meta, loadProgress }: LoadingCardProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-slate-800">
|
||||
<Card className="w-full max-w-sm rounded-2xl border-slate-600 bg-slate-800 shadow-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-white">
|
||||
Loading trajectories…
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-slate-400">
|
||||
{meta
|
||||
? `${meta.num_particles.toLocaleString()} particles · ${meta.num_steps}h`
|
||||
: "Connecting…"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Progress value={loadProgress} className="h-2 rounded-full" />
|
||||
<p className="text-center text-sm text-slate-400">{loadProgress}%</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
328
frontend/src/components/MapHeader.tsx
Normal file
328
frontend/src/components/MapHeader.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Layers, Filter, ChevronDown, Info, Map as MapIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { BASEMAPS, HEADER_THEMES } from "@/lib/constants";
|
||||
import { useClickOutside } from "@/hooks/useClickOutside";
|
||||
|
||||
interface MapHeaderProps {
|
||||
basemapId: string;
|
||||
setBasemapId: (id: string) => void;
|
||||
day: number;
|
||||
hour: number;
|
||||
/** When set, show calendar date in header (e.g. "27 Aug 06:00") */
|
||||
dateLabel?: string | null;
|
||||
activeN: number;
|
||||
beachedN: number;
|
||||
filtersOpen: boolean;
|
||||
setFiltersOpen: (v: boolean) => void;
|
||||
infoOpen: boolean;
|
||||
setInfoOpen: (v: boolean) => void;
|
||||
showTrails: boolean;
|
||||
setShowTrails: (v: boolean) => void;
|
||||
showActiveParticles: boolean;
|
||||
setShowActiveParticles: (v: boolean) => void;
|
||||
showBeachedParticles: boolean;
|
||||
setShowBeachedParticles: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export function MapHeader({
|
||||
basemapId,
|
||||
setBasemapId,
|
||||
day,
|
||||
hour,
|
||||
dateLabel = null,
|
||||
activeN,
|
||||
beachedN,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
infoOpen,
|
||||
setInfoOpen,
|
||||
showTrails,
|
||||
setShowTrails,
|
||||
showActiveParticles,
|
||||
setShowActiveParticles,
|
||||
showBeachedParticles,
|
||||
setShowBeachedParticles,
|
||||
}: MapHeaderProps) {
|
||||
const filtersRef = useRef<HTMLDivElement>(null);
|
||||
const infoRef = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(filtersRef, filtersOpen, () => setFiltersOpen(false));
|
||||
useClickOutside(infoRef, infoOpen, () => setInfoOpen(false));
|
||||
|
||||
const theme = HEADER_THEMES[basemapId] ?? HEADER_THEMES.dark;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-6 px-5 py-3 shadow-xl backdrop-blur-xl border-b ${theme.bg} ${theme.border} ${theme.text}`}
|
||||
>
|
||||
<div className="flex items-center gap-6 min-w-0">
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div
|
||||
className={`flex size-9 items-center justify-center rounded-lg ring-1 ${theme.accentMuted} ${theme.accent}`}
|
||||
>
|
||||
<Layers className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
className={`text-base font-bold tracking-tight leading-tight ${theme.text}`}
|
||||
>
|
||||
Lagrangian trajectories · UPC
|
||||
</h1>
|
||||
<p className={`text-[11px] font-medium ${theme.subtitle}`}>
|
||||
Catalan coast · 72 h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:block h-8 w-px bg-slate-600/80 shrink-0" />
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 border ${theme.card}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] uppercase tracking-wider font-medium ${theme.subtitle}`}
|
||||
>
|
||||
Time
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-semibold tabular-nums ${theme.text}`}
|
||||
>
|
||||
{dateLabel ?? `Day ${day} · ${hour.toString().padStart(2, "0")}:00`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-teal-500/10 px-2.5 py-1 border border-teal-500/20">
|
||||
<span className="size-2 rounded-full bg-teal-400 shrink-0" />
|
||||
<span className="text-sm font-semibold text-teal-300 tabular-nums">
|
||||
{activeN.toLocaleString()}
|
||||
</span>
|
||||
<span className={`text-[11px] ${theme.subtitle}`}>active</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1 border border-red-500/20">
|
||||
<span className="size-2 rounded-full bg-red-500 shrink-0" />
|
||||
<span className="text-sm font-semibold text-red-400 tabular-nums">
|
||||
{beachedN.toLocaleString()}
|
||||
</span>
|
||||
<span className={`text-[11px] ${theme.subtitle}`}>beached</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0" ref={filtersRef}>
|
||||
<div className="relative" ref={infoRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`gap-2 rounded-lg px-3.5 py-2.5 text-sm font-medium transition-all ${
|
||||
infoOpen
|
||||
? "bg-teal-500/15 text-teal-300 border border-teal-500/40"
|
||||
: `border ${theme.card} hover:opacity-90 ${theme.text}`
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInfoOpen(!infoOpen);
|
||||
}}
|
||||
aria-label="About this project"
|
||||
aria-expanded={infoOpen}
|
||||
>
|
||||
<Info className="size-4" />
|
||||
Info
|
||||
</Button>
|
||||
{infoOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-96 max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-600/80 bg-slate-800/98 shadow-2xl overflow-hidden">
|
||||
<div className="p-4 space-y-3 text-sm">
|
||||
<h3 className="font-semibold text-white">
|
||||
Lagrangian trajectory viewer · UPC
|
||||
</h3>
|
||||
<p className="text-slate-400">
|
||||
This viewer shows a{" "}
|
||||
<strong className="text-slate-300">
|
||||
Lagrangian particle simulation
|
||||
</strong>{" "}
|
||||
of the NW Mediterranean: particles are released from the
|
||||
Catalan coast and advected by a synthetic current field for 72
|
||||
hours.
|
||||
</p>
|
||||
<p className="text-slate-400">
|
||||
The approach is consistent with{" "}
|
||||
<a
|
||||
href="https://oceanparcels.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-teal-400 hover:underline"
|
||||
>
|
||||
Ocean Parcels
|
||||
</a>
|
||||
: passive drifters are integrated in time; particles that hit
|
||||
the coast are marked as beached.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Universitat Politècnica de Catalunya (UPC)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all ${
|
||||
filtersOpen
|
||||
? "bg-teal-500/15 text-teal-300 border border-teal-500/40 shadow-sm"
|
||||
: `border hover:border-slate-500 ${theme.text} ${theme.card} hover:opacity-90`
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFiltersOpen(!filtersOpen);
|
||||
}}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
Filters
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform ${filtersOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
{filtersOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-2xl border border-slate-600/80 bg-slate-800/98 shadow-2xl overflow-hidden">
|
||||
<div className="bg-slate-700/50 px-4 py-3 border-b border-slate-600/80">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-slate-200">
|
||||
<Layers className="size-4 text-teal-400" />
|
||||
Layers & basemap
|
||||
</span>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
Basemap i capes
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-y-auto p-4 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
||||
Basemap
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{BASEMAPS.map((b) => {
|
||||
const isImage =
|
||||
b.thumb.startsWith("/") || b.thumb.startsWith("http");
|
||||
const selected = basemapId === b.id;
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
onClick={() => setBasemapId(b.id)}
|
||||
className={`flex flex-col overflow-hidden rounded-xl border-2 text-left transition-all ${
|
||||
selected
|
||||
? "border-teal-400 ring-2 ring-teal-400/30 shadow-lg shadow-teal-500/10"
|
||||
: "border-slate-600 hover:border-slate-500 bg-slate-700/40"
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-14 w-full shrink-0 overflow-hidden bg-slate-700">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={b.thumb}
|
||||
alt={b.name}
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{ background: b.thumb }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-40">
|
||||
<MapIcon className="size-6 text-slate-400" />
|
||||
</div>
|
||||
{selected && (
|
||||
<div className="absolute inset-0 bg-teal-500/10 pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`px-2.5 py-1.5 text-xs font-medium ${selected ? "text-teal-300" : "text-slate-300"}`}
|
||||
>
|
||||
{b.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 pt-1 border-t border-slate-600/80">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
||||
Overlays
|
||||
</p>
|
||||
<div className="space-y-3 rounded-xl bg-slate-700/30 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="layer-trails"
|
||||
className="text-sm text-slate-200 cursor-pointer"
|
||||
>
|
||||
Trajectories
|
||||
</Label>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Particle paths over time
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="layer-trails"
|
||||
checked={showTrails}
|
||||
onCheckedChange={setShowTrails}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="size-2 rounded-full bg-teal-400 shrink-0" />
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="layer-active"
|
||||
className="text-sm text-slate-200 cursor-pointer"
|
||||
>
|
||||
Active particles
|
||||
</Label>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
In water
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="layer-active"
|
||||
checked={showActiveParticles}
|
||||
onCheckedChange={setShowActiveParticles}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="size-2 rounded-full bg-red-500 shrink-0" />
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="layer-beached"
|
||||
className="text-sm text-slate-200 cursor-pointer"
|
||||
>
|
||||
Beached particles
|
||||
</Label>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
On coast
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="layer-beached"
|
||||
checked={showBeachedParticles}
|
||||
onCheckedChange={setShowBeachedParticles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/MapView.tsx
Normal file
106
frontend/src/components/MapView.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import Map from "react-map-gl/maplibre";
|
||||
import { DeckGL } from "@deck.gl/react";
|
||||
import { ZoomIn, ZoomOut, Crosshair } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PointDatum } from "@/types/trajectory";
|
||||
|
||||
export type ViewState = {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
zoom: number;
|
||||
pitch?: number;
|
||||
bearing?: number;
|
||||
};
|
||||
|
||||
interface MapViewProps {
|
||||
viewState: ViewState;
|
||||
setViewState: React.Dispatch<React.SetStateAction<ViewState>>;
|
||||
layers: React.ComponentProps<typeof DeckGL>["layers"];
|
||||
onClick: (opts: { object?: PointDatum }) => void;
|
||||
getTooltip: (opts: { object?: PointDatum }) => { text: string } | null;
|
||||
mapStyle: string;
|
||||
/** When provided, shows a button to fit the map view to all particles. */
|
||||
onFitToParticles?: () => void;
|
||||
}
|
||||
|
||||
export function MapView({
|
||||
viewState,
|
||||
setViewState,
|
||||
layers,
|
||||
onClick,
|
||||
getTooltip,
|
||||
mapStyle,
|
||||
onFitToParticles,
|
||||
}: MapViewProps) {
|
||||
const zoomIn = useCallback(() => {
|
||||
setViewState((v) => ({
|
||||
...v,
|
||||
zoom: Math.min(18, (v.zoom ?? 7.5) + 1),
|
||||
}));
|
||||
}, [setViewState]);
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setViewState((v) => ({
|
||||
...v,
|
||||
zoom: Math.max(2, (v.zoom ?? 7.5) - 1),
|
||||
}));
|
||||
}, [setViewState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeckGL
|
||||
viewState={viewState}
|
||||
onViewStateChange={({ viewState: vs }) =>
|
||||
setViewState((prev) => ({ ...prev, ...vs }))
|
||||
}
|
||||
controller
|
||||
layers={layers}
|
||||
onClick={({ object }) => onClick({ object: object as PointDatum })}
|
||||
getTooltip={({ object }) =>
|
||||
getTooltip({ object: object as PointDatum })
|
||||
}
|
||||
>
|
||||
<Map mapStyle={mapStyle} />
|
||||
</DeckGL>
|
||||
<div className="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex flex-col gap-1 rounded-xl border border-slate-600/80 bg-slate-800/95 shadow-xl backdrop-blur-sm p-1">
|
||||
{onFitToParticles && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-lg border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white focus:ring-2 focus:ring-teal-500/50"
|
||||
onClick={onFitToParticles}
|
||||
title="Zoom a les partícules"
|
||||
aria-label="Zoom a les partícules"
|
||||
>
|
||||
<Crosshair className="size-5" />
|
||||
</Button>
|
||||
<div className="h-px bg-slate-600/80 mx-1" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-lg border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white focus:ring-2 focus:ring-teal-500/50"
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-5" />
|
||||
</Button>
|
||||
<div className="h-px bg-slate-600/80 mx-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-lg border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white focus:ring-2 focus:ring-teal-500/50"
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
frontend/src/components/TimePlayer.tsx
Normal file
143
frontend/src/components/TimePlayer.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { Play, Pause, ChevronLeft, ChevronRight, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { SPEEDS } from "@/lib/constants";
|
||||
|
||||
interface TimePlayerProps {
|
||||
step: number;
|
||||
onSlider: (value: number[]) => void;
|
||||
maxStep: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
metaNumSteps: number;
|
||||
metaNumParticles: number;
|
||||
playing: boolean;
|
||||
speedIdx: number;
|
||||
togglePlay: () => void;
|
||||
stepBack: () => void;
|
||||
stepForward: () => void;
|
||||
restart: () => void;
|
||||
cycleSpeed: () => void;
|
||||
/** When set, show calendar date instead of "Day X" (e.g. "27 Aug 06:00") */
|
||||
dateLabel?: string | null;
|
||||
startLabel?: string | null;
|
||||
endLabel?: string | null;
|
||||
}
|
||||
|
||||
export function TimePlayer({
|
||||
step,
|
||||
onSlider,
|
||||
maxStep,
|
||||
day,
|
||||
hour,
|
||||
metaNumSteps,
|
||||
metaNumParticles,
|
||||
playing,
|
||||
speedIdx,
|
||||
togglePlay,
|
||||
stepBack,
|
||||
stepForward,
|
||||
restart,
|
||||
cycleSpeed,
|
||||
dateLabel = null,
|
||||
startLabel = null,
|
||||
endLabel = null,
|
||||
}: TimePlayerProps) {
|
||||
const timeDisplay = dateLabel ?? `Day ${day} · ${hour.toString().padStart(2, "0")}:00`;
|
||||
const sliderStart = startLabel ?? "Day 1 · 00:00";
|
||||
const sliderEnd = endLabel ?? `Day ${Math.floor(maxStep / 24) + 1} · ${(maxStep % 24).toString().padStart(2, "0")}:00`;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 z-10 w-full max-w-2xl -translate-x-1/2 rounded-xl border border-slate-600/80 bg-slate-800/95 shadow-xl backdrop-blur-md overflow-hidden">
|
||||
<div className="px-3 py-2.5 space-y-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 shrink-0 rounded-lg border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white"
|
||||
onClick={stepBack}
|
||||
disabled={step <= 0}
|
||||
title="Anterior (←)"
|
||||
aria-label="Step backward"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 rounded-xl bg-teal-500 hover:bg-teal-400 text-slate-900 shadow-md shadow-teal-500/30 focus:ring-2 focus:ring-teal-400 focus:ring-offset-2 focus:ring-offset-slate-800"
|
||||
onClick={togglePlay}
|
||||
title={playing ? "Pausar (Espai)" : "Reproduir (Espai)"}
|
||||
aria-label={playing ? "Pause simulation" : "Play simulation"}
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="size-5" />
|
||||
) : (
|
||||
<Play className="size-5 ml-0.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 shrink-0 rounded-lg border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white"
|
||||
onClick={stepForward}
|
||||
disabled={step >= maxStep}
|
||||
title="Següent (→)"
|
||||
aria-label="Step forward"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-lg font-bold tabular-nums text-white leading-tight">
|
||||
{timeDisplay}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-400 leading-tight tabular-nums">
|
||||
Step {step + 1} / {metaNumSteps}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 rounded-lg text-xs font-semibold border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white"
|
||||
onClick={restart}
|
||||
title="Reiniciar"
|
||||
aria-label="Restart from beginning"
|
||||
>
|
||||
<RotateCcw className="size-3.5 mr-1" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 rounded-lg text-xs font-semibold border-slate-600 bg-slate-700/80 text-slate-200 hover:bg-slate-600 hover:text-white"
|
||||
onClick={cycleSpeed}
|
||||
title="Velocitat"
|
||||
aria-label={`Playback speed: ${SPEEDS[speedIdx]}x`}
|
||||
>
|
||||
×{SPEEDS[speedIdx]}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Slider
|
||||
min={0}
|
||||
max={maxStep}
|
||||
step={1}
|
||||
value={[step]}
|
||||
onValueChange={onSlider}
|
||||
className="w-full py-2"
|
||||
aria-label="Simulation time"
|
||||
/>
|
||||
<div className="flex justify-between text-[11px] text-slate-400 px-0.5">
|
||||
<span>{sliderStart}</span>
|
||||
<span>{sliderEnd}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
frontend/src/components/TrajectoryMap.tsx
Normal file
375
frontend/src/components/TrajectoryMap.tsx
Normal file
@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
import {
|
||||
INIT_VIEW,
|
||||
BASEMAPS,
|
||||
TRAIL_LENGTH_HOURS,
|
||||
TRAIL_STEP_STRIDE,
|
||||
} from "@/lib/constants";
|
||||
import { fitBounds } from "@math.gl/web-mercator";
|
||||
import {
|
||||
buildScatterData,
|
||||
buildTrails,
|
||||
buildTripsWithTime,
|
||||
} from "@/lib/trajectoryData";
|
||||
import type { Meta, PointDatum } from "@/types/trajectory";
|
||||
import { useMetadata } from "@/hooks/useMetadata";
|
||||
import { useBlocks } from "@/hooks/useBlocks";
|
||||
import { useSimulation } from "@/hooks/useSimulation";
|
||||
import { useMapLayers } from "@/hooks/useMapLayers";
|
||||
import { IntroDialog, shouldShowIntro } from "@/components/IntroDialog";
|
||||
import { LoadingCard } from "@/components/LoadingCard";
|
||||
import { MapHeader } from "@/components/MapHeader";
|
||||
import { TimePlayer } from "@/components/TimePlayer";
|
||||
import type { ViewState } from "@/components/MapView";
|
||||
|
||||
const MapView = dynamic(
|
||||
() => import("@/components/MapView").then((m) => ({ default: m.MapView })),
|
||||
{ ssr: false, loading: () => <div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground">Loading map…</div> }
|
||||
);
|
||||
|
||||
const BLOCK_SIZE = 24;
|
||||
|
||||
export default function TrajectoryMap() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [canvasReady, setCanvasReady] = useState(false);
|
||||
const [showIntro, setShowIntro] = useState(true);
|
||||
const [viewState, setViewState] = useState<ViewState>(INIT_VIEW);
|
||||
const [basemapId, setBasemapId] = useState<
|
||||
(typeof BASEMAPS)[number]["id"]
|
||||
>("dark");
|
||||
const [showTrails, setShowTrails] = useState(true);
|
||||
const [showActiveParticles, setShowActiveParticles] = useState(true);
|
||||
const [showBeachedParticles, setShowBeachedParticles] = useState(true);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [selectedParticleId, setSelectedParticleId] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [accumulatedBeached, setAccumulatedBeached] = useState<
|
||||
Map<number, [number, number]>
|
||||
>(new Map());
|
||||
const prevSimIdRef = useRef<string | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const simId = searchParams.get("sim") ?? "default";
|
||||
const meta = useMetadata(simId);
|
||||
const { blocks, loadProgress, ready } = useBlocks(meta, simId);
|
||||
const {
|
||||
step,
|
||||
setStep,
|
||||
playing,
|
||||
speedIdx,
|
||||
togglePlay,
|
||||
stepBack,
|
||||
stepForward,
|
||||
restart,
|
||||
cycleSpeed,
|
||||
onSlider,
|
||||
maxStep,
|
||||
day,
|
||||
hour,
|
||||
dateLabel,
|
||||
startLabel,
|
||||
endLabel,
|
||||
} = useSimulation(meta);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
setShowIntro((prev) => (prev ? shouldShowIntro() : false));
|
||||
}, []);
|
||||
|
||||
// Defer DeckGL mount until after first paint so WebGL context is ready (avoids device.limits undefined in some browsers).
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let rafId2: number | null = null;
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
timeoutId = setTimeout(() => setCanvasReady(true), 150);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
if (rafId2 != null) cancelAnimationFrame(rafId2);
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mounted]);
|
||||
|
||||
const blockIdx = Math.floor(step / BLOCK_SIZE);
|
||||
const currentBlock = blocks[blockIdx];
|
||||
const stepStart = currentBlock?.step_start ?? blockIdx * BLOCK_SIZE;
|
||||
const frameIdx = step - stepStart;
|
||||
const currentFrame =
|
||||
currentBlock?.frames &&
|
||||
frameIdx >= 0 &&
|
||||
frameIdx < currentBlock.frames.length
|
||||
? currentBlock.frames[frameIdx]
|
||||
: null;
|
||||
|
||||
// Reset accumulated beached when changing simulation
|
||||
useEffect(() => {
|
||||
if (prevSimIdRef.current !== null && prevSimIdRef.current !== simId) {
|
||||
setAccumulatedBeached(new Map());
|
||||
}
|
||||
prevSimIdRef.current = simId;
|
||||
}, [simId]);
|
||||
|
||||
// Accumulate beached particles from each frame so they don't disappear in later steps
|
||||
useEffect(() => {
|
||||
if (!currentFrame) return;
|
||||
setAccumulatedBeached((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (let i = 0; i < currentFrame.length; i++) {
|
||||
if (currentFrame[i][4] === 1) {
|
||||
next.set(i, [currentFrame[i][0], currentFrame[i][1]]);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [currentFrame]);
|
||||
|
||||
const scatterData = useMemo(
|
||||
() => (currentFrame ? buildScatterData(currentFrame) : []),
|
||||
[currentFrame]
|
||||
);
|
||||
const trailData = useMemo(
|
||||
() =>
|
||||
ready && meta
|
||||
? buildTrails(
|
||||
blocks,
|
||||
step,
|
||||
meta.num_particles,
|
||||
meta.release_steps,
|
||||
TRAIL_STEP_STRIDE
|
||||
)
|
||||
: [],
|
||||
[blocks, step, ready, meta]
|
||||
);
|
||||
const tripDataWithTime = useMemo(
|
||||
() =>
|
||||
ready && meta
|
||||
? buildTripsWithTime(
|
||||
blocks,
|
||||
step,
|
||||
meta.num_particles,
|
||||
meta.release_steps,
|
||||
TRAIL_STEP_STRIDE
|
||||
)
|
||||
: [],
|
||||
[blocks, step, ready, meta]
|
||||
);
|
||||
|
||||
const currentTimeSec = step * 3600;
|
||||
const trailLengthSec =
|
||||
Math.min(meta?.num_steps ?? 72, TRAIL_LENGTH_HOURS) * 3600;
|
||||
|
||||
const accumulatedBeachedPts = useMemo(
|
||||
() =>
|
||||
Array.from(accumulatedBeached.entries()).map(([id, position]) => ({
|
||||
id,
|
||||
position,
|
||||
beached: true as const,
|
||||
speed: 0,
|
||||
})),
|
||||
[accumulatedBeached]
|
||||
);
|
||||
const beachedN = accumulatedBeached.size;
|
||||
const activeN = (meta?.num_particles ?? 0) - beachedN;
|
||||
|
||||
/** Bounds [[minLon, minLat], [maxLon, maxLat]] for all current particles (frame + beached). */
|
||||
const particleBounds = useMemo((): [[number, number], [number, number]] | null => {
|
||||
const positions: [number, number][] = [
|
||||
...scatterData.map((d) => d.position),
|
||||
...accumulatedBeachedPts.map((d) => d.position),
|
||||
].filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
|
||||
if (positions.length === 0) return null;
|
||||
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||
for (const [lon, lat] of positions) {
|
||||
minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon);
|
||||
minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
|
||||
}
|
||||
if (!Number.isFinite(minLon) || !Number.isFinite(maxLon) || !Number.isFinite(minLat) || !Number.isFinite(maxLat)) return null;
|
||||
const pad = 0.02;
|
||||
if (maxLon - minLon < pad) { minLon -= pad / 2; maxLon += pad / 2; }
|
||||
if (maxLat - minLat < pad) { minLat -= pad / 2; maxLat += pad / 2; }
|
||||
return [[minLon, minLat], [maxLon, maxLat]];
|
||||
}, [scatterData, accumulatedBeachedPts]);
|
||||
|
||||
const fitToParticles = useCallback(() => {
|
||||
if (!particleBounds || !mapContainerRef.current) return;
|
||||
const el = mapContainerRef.current;
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
const padding = 40;
|
||||
if (width <= padding * 2 || height <= padding * 2) return;
|
||||
try {
|
||||
const view = fitBounds({
|
||||
width,
|
||||
height,
|
||||
bounds: particleBounds,
|
||||
padding,
|
||||
minExtent: 0.01,
|
||||
maxZoom: 14,
|
||||
});
|
||||
if (
|
||||
Number.isFinite(view.zoom) &&
|
||||
Number.isFinite(view.longitude) &&
|
||||
Number.isFinite(view.latitude)
|
||||
) {
|
||||
setViewState((prev) => ({ ...prev, ...view }));
|
||||
}
|
||||
} catch {
|
||||
const [[minLon, minLat], [maxLon, maxLat]] = particleBounds;
|
||||
const lon = (minLon + maxLon) / 2;
|
||||
const lat = (minLat + maxLat) / 2;
|
||||
if (Number.isFinite(lon) && Number.isFinite(lat)) {
|
||||
setViewState((prev) => ({
|
||||
...prev,
|
||||
longitude: lon,
|
||||
latitude: lat,
|
||||
zoom: Math.min(prev.zoom ?? 10, 12),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [particleBounds]);
|
||||
|
||||
const layers = useMapLayers(
|
||||
currentFrame,
|
||||
scatterData,
|
||||
trailData,
|
||||
tripDataWithTime,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
meta,
|
||||
step,
|
||||
selectedParticleId,
|
||||
showTrails,
|
||||
showActiveParticles,
|
||||
showBeachedParticles,
|
||||
accumulatedBeachedPts
|
||||
);
|
||||
|
||||
const currentBasemap = useMemo(
|
||||
() => BASEMAPS.find((b) => b.id === basemapId) ?? BASEMAPS[0],
|
||||
[basemapId]
|
||||
);
|
||||
|
||||
const safeViewState = useMemo(() => {
|
||||
const { longitude, latitude, zoom } = viewState;
|
||||
if (
|
||||
Number.isFinite(longitude) &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(zoom)
|
||||
) {
|
||||
return viewState;
|
||||
}
|
||||
return {
|
||||
...viewState,
|
||||
longitude: Number.isFinite(longitude) ? longitude : INIT_VIEW.longitude,
|
||||
latitude: Number.isFinite(latitude) ? latitude : INIT_VIEW.latitude,
|
||||
zoom: Number.isFinite(zoom) ? zoom : INIT_VIEW.zoom,
|
||||
};
|
||||
}, [viewState]);
|
||||
|
||||
const onMapClick = useCallback(({ object }: { object?: PointDatum }) => {
|
||||
if (object && typeof object.id === "number") {
|
||||
setSelectedParticleId((prev) =>
|
||||
prev === object.id ? null : object.id
|
||||
);
|
||||
} else {
|
||||
setSelectedParticleId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTooltip = useCallback(
|
||||
({ object }: { object?: PointDatum }) => {
|
||||
if (!object || !meta) return null;
|
||||
const origin = meta.seed_names[meta.origins[object.id]] ?? "?";
|
||||
return {
|
||||
text: object.beached
|
||||
? `#${object.id} · Beached · Origin: ${origin} (click to select)`
|
||||
: `#${object.id} · ${object.speed.toFixed(3)} m/s · Origin: ${origin} (click to select)`,
|
||||
};
|
||||
},
|
||||
[meta]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntroDialog open={showIntro} onOpenChange={setShowIntro} />
|
||||
|
||||
{!ready && (
|
||||
<LoadingCard meta={meta} loadProgress={loadProgress} />
|
||||
)}
|
||||
|
||||
{ready && (
|
||||
<div className="relative h-full w-full">
|
||||
{!mounted || !canvasReady ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background text-muted-foreground">
|
||||
Preparing map…
|
||||
</div>
|
||||
) : (
|
||||
<div ref={mapContainerRef} className="absolute inset-0">
|
||||
<MapView
|
||||
viewState={safeViewState}
|
||||
setViewState={setViewState}
|
||||
layers={layers}
|
||||
onClick={onMapClick}
|
||||
getTooltip={getTooltip}
|
||||
mapStyle={currentBasemap.url}
|
||||
onFitToParticles={particleBounds ? fitToParticles : undefined}
|
||||
/>
|
||||
|
||||
<MapHeader
|
||||
basemapId={basemapId}
|
||||
setBasemapId={setBasemapId}
|
||||
day={day}
|
||||
hour={hour}
|
||||
dateLabel={dateLabel}
|
||||
activeN={activeN}
|
||||
beachedN={beachedN}
|
||||
filtersOpen={filtersOpen}
|
||||
setFiltersOpen={setFiltersOpen}
|
||||
infoOpen={infoOpen}
|
||||
setInfoOpen={setInfoOpen}
|
||||
showTrails={showTrails}
|
||||
setShowTrails={setShowTrails}
|
||||
showActiveParticles={showActiveParticles}
|
||||
setShowActiveParticles={setShowActiveParticles}
|
||||
showBeachedParticles={showBeachedParticles}
|
||||
setShowBeachedParticles={setShowBeachedParticles}
|
||||
/>
|
||||
|
||||
<TimePlayer
|
||||
step={step}
|
||||
onSlider={onSlider}
|
||||
maxStep={maxStep}
|
||||
day={day}
|
||||
hour={hour}
|
||||
metaNumSteps={meta?.num_steps ?? 0}
|
||||
metaNumParticles={meta?.num_particles ?? 0}
|
||||
playing={playing}
|
||||
speedIdx={speedIdx}
|
||||
togglePlay={togglePlay}
|
||||
stepBack={stepBack}
|
||||
stepForward={stepForward}
|
||||
restart={restart}
|
||||
cycleSpeed={cycleSpeed}
|
||||
dateLabel={dateLabel}
|
||||
startLabel={startLabel}
|
||||
endLabel={endLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
31
frontend/src/components/ui/progress.tsx
Normal file
31
frontend/src/components/ui/progress.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
143
frontend/src/components/ui/sheet.tsx
Normal file
143
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
63
frontend/src/components/ui/slider.tsx
Normal file
63
frontend/src/components/ui/slider.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slider as SliderPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
35
frontend/src/components/ui/switch.tsx
Normal file
35
frontend/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
80
frontend/src/hooks/useBlocks.ts
Normal file
80
frontend/src/hooks/useBlocks.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { tableFromIPC } from "apache-arrow";
|
||||
import { API, USE_ARROW } from "@/lib/constants";
|
||||
import type { Block, Meta } from "@/types/trajectory";
|
||||
import { framesFromArrowTable } from "@/lib/trajectoryData";
|
||||
|
||||
export function useBlocks(
|
||||
meta: Meta | null,
|
||||
simId: string = "default"
|
||||
): {
|
||||
blocks: (Block | null)[];
|
||||
loadProgress: number;
|
||||
ready: boolean;
|
||||
} {
|
||||
const [blocks, setBlocks] = useState<(Block | null)[]>([null, null, null]);
|
||||
const [loadProgress, setLoadProgress] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!meta) return;
|
||||
const nBlocks = meta.num_blocks;
|
||||
const nParticles = meta.num_particles;
|
||||
const simParam = `sim=${encodeURIComponent(simId)}`;
|
||||
let loaded = 0;
|
||||
const result: (Block | null)[] = new Array(nBlocks).fill(null);
|
||||
setBlocks(result);
|
||||
setReady(false);
|
||||
|
||||
function onBlockLoaded(i: number, block: Block | null) {
|
||||
result[i] = block;
|
||||
loaded++;
|
||||
setLoadProgress(Math.round((loaded / nBlocks) * 100));
|
||||
if (loaded === nBlocks) {
|
||||
setBlocks([...result]);
|
||||
setReady(true);
|
||||
} else {
|
||||
setBlocks((prev) => {
|
||||
const next = Array.from({ length: nBlocks }, (_, j) => (j === i ? block : prev[j] ?? result[j] ?? null));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlock(i: number) {
|
||||
try {
|
||||
if (USE_ARROW) {
|
||||
const [metaRes, arrowRes] = await Promise.all([
|
||||
fetch(`${API}/api/block/${i}?frames=false&${simParam}`),
|
||||
fetch(`${API}/api/block/${i}/arrow?${simParam}`),
|
||||
]);
|
||||
if (metaRes.ok && arrowRes.ok) {
|
||||
const metaBlock = (await metaRes.json()) as Pick<
|
||||
Block,
|
||||
"block" | "step_start" | "step_end" | "accumulation"
|
||||
>;
|
||||
const buf = await arrowRes.arrayBuffer();
|
||||
const table = tableFromIPC(new Uint8Array(buf));
|
||||
const frames = framesFromArrowTable(table, nParticles);
|
||||
onBlockLoaded(i, { ...metaBlock, frames });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fallback to JSON */
|
||||
}
|
||||
try {
|
||||
const full = (await fetch(`${API}/api/block/${i}?${simParam}`).then((r) =>
|
||||
r.json()
|
||||
)) as Block;
|
||||
onBlockLoaded(i, full);
|
||||
} catch {
|
||||
onBlockLoaded(i, null);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < nBlocks; i++) loadBlock(i);
|
||||
}, [meta, simId]);
|
||||
|
||||
return { blocks, loadProgress, ready };
|
||||
}
|
||||
16
frontend/src/hooks/useClickOutside.ts
Normal file
16
frontend/src/hooks/useClickOutside.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
|
||||
export function useClickOutside<T extends HTMLElement>(
|
||||
ref: RefObject<T | null>,
|
||||
isOpen: boolean,
|
||||
onClose: () => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("click", onDocClick);
|
||||
return () => document.removeEventListener("click", onDocClick);
|
||||
}, [isOpen, onClose, ref]);
|
||||
}
|
||||
258
frontend/src/hooks/useMapLayers.ts
Normal file
258
frontend/src/hooks/useMapLayers.ts
Normal file
@ -0,0 +1,258 @@
|
||||
import { useMemo } from "react";
|
||||
import { PathLayer, ScatterplotLayer } from "@deck.gl/layers";
|
||||
import { TripsLayer } from "@deck.gl/geo-layers";
|
||||
import type {
|
||||
TrailDatum,
|
||||
TripDatum,
|
||||
PointDatum,
|
||||
Meta,
|
||||
Frame,
|
||||
} from "@/types/trajectory";
|
||||
import {
|
||||
C_TRAIL_ACTIVE,
|
||||
C_TRAIL_BEACHED,
|
||||
C_GLOW_ACTIVE,
|
||||
C_GLOW_BEACHED,
|
||||
C_BLOOM_ACTIVE,
|
||||
C_BLOOM_BEACHED,
|
||||
C_SELECTED,
|
||||
C_BEACHED,
|
||||
PALETTE_ORIGIN,
|
||||
activeColor,
|
||||
} from "@/lib/constants";
|
||||
|
||||
type Layer =
|
||||
| PathLayer<TrailDatum>
|
||||
| TripsLayer<TripDatum>
|
||||
| ScatterplotLayer<PointDatum>;
|
||||
|
||||
type TripColor =
|
||||
| [number, number, number, number]
|
||||
| ((d: TripDatum) => [number, number, number, number]);
|
||||
|
||||
/** Thin streamlines, caps arrodonits, smooth joints; color fix o per origen */
|
||||
function createTripsLayer(
|
||||
id: string,
|
||||
data: TripDatum[],
|
||||
color: TripColor,
|
||||
currentTimeSec: number,
|
||||
trailLengthSec: number,
|
||||
widthMinPixels: number,
|
||||
widthMaxPixels: number
|
||||
): TripsLayer<TripDatum> {
|
||||
return new TripsLayer<TripDatum>({
|
||||
id,
|
||||
data,
|
||||
getPath: (d) => d.path.flatMap((p) => [p[0], p[1]]),
|
||||
getTimestamps: (d) => d.path.map((p) => p[2]),
|
||||
getColor: color,
|
||||
currentTime: currentTimeSec,
|
||||
trailLength: trailLengthSec,
|
||||
fadeTrail: true,
|
||||
widthMinPixels,
|
||||
widthMaxPixels,
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
positionFormat: "XY",
|
||||
});
|
||||
}
|
||||
|
||||
function getColorByOrigin(
|
||||
meta: Meta | null,
|
||||
alpha: number
|
||||
): (d: TripDatum) => [number, number, number, number] {
|
||||
return (d) => {
|
||||
const idx = meta?.origins?.[d.id] ?? 0;
|
||||
const [r, g, b] = PALETTE_ORIGIN[idx % PALETTE_ORIGIN.length];
|
||||
return [r, g, b, alpha];
|
||||
};
|
||||
}
|
||||
|
||||
export function useMapLayers(
|
||||
currentFrame: Frame | null,
|
||||
scatterData: PointDatum[],
|
||||
trailData: TrailDatum[],
|
||||
tripDataWithTime: TripDatum[],
|
||||
currentTimeSec: number,
|
||||
trailLengthSec: number,
|
||||
meta: Meta | null,
|
||||
step: number,
|
||||
selectedParticleId: number | null,
|
||||
showTrails: boolean,
|
||||
showActiveParticles: boolean,
|
||||
showBeachedParticles: boolean,
|
||||
accumulatedBeachedPts: PointDatum[] = []
|
||||
): Layer[] {
|
||||
const activePts = useMemo(() => {
|
||||
if (!scatterData.length) return [];
|
||||
return scatterData.filter((p) => {
|
||||
if (meta?.release_steps && meta.release_steps[p.id] > step) return false;
|
||||
return !p.beached;
|
||||
});
|
||||
}, [scatterData, meta, step]);
|
||||
|
||||
const beachedPtsFromScatter = useMemo(() => {
|
||||
if (!scatterData.length) return [];
|
||||
return scatterData.filter((p) => {
|
||||
if (meta?.release_steps && meta.release_steps[p.id] > step)
|
||||
return false;
|
||||
return p.beached;
|
||||
});
|
||||
}, [scatterData, meta, step]);
|
||||
|
||||
const beachedPts =
|
||||
accumulatedBeachedPts.length > 0 ? accumulatedBeachedPts : beachedPtsFromScatter;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!currentFrame) return [];
|
||||
const out: Layer[] = [];
|
||||
|
||||
if (showTrails && tripDataWithTime.length > 0) {
|
||||
const activeTrips = tripDataWithTime.filter((t) => !t.beached);
|
||||
const beachedTrips = tripDataWithTime.filter((t) => t.beached);
|
||||
if (activeTrips.length) {
|
||||
const activeBloomColor = meta
|
||||
? getColorByOrigin(meta, 40)
|
||||
: C_BLOOM_ACTIVE;
|
||||
const activeGlowColor = meta
|
||||
? getColorByOrigin(meta, 120)
|
||||
: C_GLOW_ACTIVE;
|
||||
const activeCoreColor = meta
|
||||
? getColorByOrigin(meta, 255)
|
||||
: C_TRAIL_ACTIVE;
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-active-bloom",
|
||||
activeTrips,
|
||||
activeBloomColor,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
2,
|
||||
8
|
||||
)
|
||||
);
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-active-glow",
|
||||
activeTrips,
|
||||
activeGlowColor,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
1,
|
||||
4
|
||||
)
|
||||
);
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-active",
|
||||
activeTrips,
|
||||
activeCoreColor,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
0.8,
|
||||
2.2
|
||||
)
|
||||
);
|
||||
}
|
||||
if (beachedTrips.length) {
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-beached-bloom",
|
||||
beachedTrips,
|
||||
C_BLOOM_BEACHED,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
1.5,
|
||||
5
|
||||
)
|
||||
);
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-beached-glow",
|
||||
beachedTrips,
|
||||
C_GLOW_BEACHED,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
0.8,
|
||||
3
|
||||
)
|
||||
);
|
||||
out.push(
|
||||
createTripsLayer(
|
||||
"trips-beached",
|
||||
beachedTrips,
|
||||
C_TRAIL_BEACHED,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
0.6,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedParticleId !== null) {
|
||||
const selectedTrail = trailData.find((t) => t.id === selectedParticleId);
|
||||
if (selectedTrail) {
|
||||
out.push(
|
||||
new PathLayer<TrailDatum>({
|
||||
id: "trail-selected",
|
||||
data: [selectedTrail],
|
||||
getPath: (d) => d.path,
|
||||
getColor: C_SELECTED,
|
||||
getWidth: 3,
|
||||
widthUnits: "pixels",
|
||||
capRounded: true,
|
||||
jointRounded: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (showActiveParticles && activePts.length) {
|
||||
out.push(
|
||||
new ScatterplotLayer<PointDatum>({
|
||||
id: "p-a",
|
||||
data: activePts,
|
||||
getPosition: (d) => d.position,
|
||||
getFillColor: (d) =>
|
||||
d.id === selectedParticleId ? C_SELECTED : activeColor(d.speed),
|
||||
getRadius: (d) => (d.id === selectedParticleId ? 5 : 2.5),
|
||||
radiusUnits: "pixels",
|
||||
pickable: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (showBeachedParticles && beachedPts.length) {
|
||||
out.push(
|
||||
new ScatterplotLayer<PointDatum>({
|
||||
id: "p-b",
|
||||
data: beachedPts,
|
||||
getPosition: (d) => d.position,
|
||||
getFillColor: (d) =>
|
||||
d.id === selectedParticleId ? C_SELECTED : C_BEACHED,
|
||||
getRadius: (d) => (d.id === selectedParticleId ? 5 : 3),
|
||||
radiusUnits: "pixels",
|
||||
pickable: true,
|
||||
stroked: true,
|
||||
lineWidthMinPixels: 1,
|
||||
getLineColor: [255, 255, 255, 80],
|
||||
})
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}, [
|
||||
currentFrame,
|
||||
trailData,
|
||||
tripDataWithTime,
|
||||
activePts,
|
||||
beachedPts,
|
||||
currentTimeSec,
|
||||
trailLengthSec,
|
||||
selectedParticleId,
|
||||
showTrails,
|
||||
showActiveParticles,
|
||||
showBeachedParticles,
|
||||
meta,
|
||||
]);
|
||||
}
|
||||
17
frontend/src/hooks/useMetadata.ts
Normal file
17
frontend/src/hooks/useMetadata.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { API } from "@/lib/constants";
|
||||
import type { Meta } from "@/types/trajectory";
|
||||
|
||||
export function useMetadata(simId: string = "default"): Meta | null {
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ sim: simId });
|
||||
fetch(`${API}/api/metadata?${params}`)
|
||||
.then((r) => r.json())
|
||||
.then(setMeta)
|
||||
.catch(console.error);
|
||||
}, [simId]);
|
||||
|
||||
return meta;
|
||||
}
|
||||
180
frontend/src/hooks/useSimulation.ts
Normal file
180
frontend/src/hooks/useSimulation.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SPEEDS } from "@/lib/constants";
|
||||
import type { Meta } from "@/types/trajectory";
|
||||
|
||||
/** Format current time from step when time_start/time_end exist (calendar display). When dt_hours === 1, one step = one hour (hora punta). */
|
||||
function getTimeFromStep(
|
||||
step: number,
|
||||
maxStep: number,
|
||||
timeStart: string,
|
||||
timeEnd: string,
|
||||
dtHours?: number
|
||||
): { day: number; hour: number; dateLabel: string; startLabel: string; endLabel: string } {
|
||||
const start = new Date(timeStart).getTime();
|
||||
const end = new Date(timeEnd).getTime();
|
||||
const t =
|
||||
maxStep <= 0
|
||||
? start
|
||||
: dtHours === 1
|
||||
? start + step * 3600 * 1000
|
||||
: start + (step / maxStep) * (end - start);
|
||||
const d = new Date(t);
|
||||
const hoursSinceStart = (t - start) / (1000 * 3600);
|
||||
const day = Math.floor(hoursSinceStart / 24) + 1;
|
||||
const hour = Math.floor(hoursSinceStart % 24);
|
||||
const fmt = (date: Date) =>
|
||||
date.toLocaleDateString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return {
|
||||
day,
|
||||
hour,
|
||||
dateLabel: fmt(d),
|
||||
startLabel: fmt(new Date(start)),
|
||||
endLabel: fmt(new Date(end)),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSimulation(meta: Meta | null): {
|
||||
step: number;
|
||||
setStep: (value: number | ((prev: number) => number)) => void;
|
||||
playing: boolean;
|
||||
speedIdx: number;
|
||||
togglePlay: () => void;
|
||||
stepBack: () => void;
|
||||
stepForward: () => void;
|
||||
restart: () => void;
|
||||
cycleSpeed: () => void;
|
||||
onSlider: (value: number[]) => void;
|
||||
maxStep: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
dateLabel: string | null;
|
||||
startLabel: string | null;
|
||||
endLabel: string | null;
|
||||
} {
|
||||
const [step, setStep] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speedIdx, setSpeedIdx] = useState(1);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastStepTimeRef = useRef<number>(0);
|
||||
const stepIntervalRef = useRef(500 / SPEEDS[1]);
|
||||
|
||||
const maxStep = meta ? meta.num_steps - 1 : 0;
|
||||
const hasCalendar =
|
||||
meta?.time_start && meta?.time_end && maxStep > 0;
|
||||
const { day, hour, dateLabel, startLabel, endLabel } = hasCalendar
|
||||
? getTimeFromStep(step, maxStep, meta.time_start!, meta.time_end!, meta.dt_hours)
|
||||
: {
|
||||
day: Math.floor(step / 24) + 1,
|
||||
hour: step % 24,
|
||||
dateLabel: null as string | null,
|
||||
startLabel: null as string | null,
|
||||
endLabel: null as string | null,
|
||||
};
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
setPlaying((p) => {
|
||||
if (!p && meta && step >= meta.num_steps - 1) setStep(0);
|
||||
return !p;
|
||||
});
|
||||
}, [step, meta]);
|
||||
|
||||
const cycleSpeed = useCallback(() => {
|
||||
setSpeedIdx((i) => (i + 1) % SPEEDS.length);
|
||||
}, []);
|
||||
|
||||
const stepBack = useCallback(() => {
|
||||
setStep((s) => Math.max(0, s - 1));
|
||||
}, []);
|
||||
|
||||
const stepForward = useCallback(() => {
|
||||
setStep((s) => (meta ? Math.min(meta.num_steps - 1, s + 1) : s));
|
||||
}, [meta]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
setStep(0);
|
||||
setPlaying(false);
|
||||
}, []);
|
||||
|
||||
const onSlider = useCallback(
|
||||
(value: number[]) => {
|
||||
const raw = value[0] ?? 0;
|
||||
const clamped = Math.max(0, Math.min(maxStep, Math.round(raw)));
|
||||
setStep(clamped);
|
||||
},
|
||||
[maxStep]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || !meta) return;
|
||||
const dtHours = meta.dt_hours;
|
||||
const msPerStep = dtHours === 1 ? 1000 / SPEEDS[speedIdx] : 500 / SPEEDS[speedIdx];
|
||||
stepIntervalRef.current = msPerStep;
|
||||
lastStepTimeRef.current = performance.now();
|
||||
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - lastStepTimeRef.current;
|
||||
if (elapsed >= stepIntervalRef.current) {
|
||||
lastStepTimeRef.current = now;
|
||||
setStep((prev) => {
|
||||
if (prev >= meta.num_steps - 1) {
|
||||
setPlaying(false);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [playing, meta, speedIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
switch (e.key) {
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
stepBack();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
stepForward();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [togglePlay, stepBack, stepForward]);
|
||||
|
||||
return {
|
||||
step,
|
||||
setStep,
|
||||
playing,
|
||||
speedIdx,
|
||||
togglePlay,
|
||||
stepBack,
|
||||
stepForward,
|
||||
restart,
|
||||
cycleSpeed,
|
||||
onSlider,
|
||||
maxStep,
|
||||
day,
|
||||
hour,
|
||||
dateLabel,
|
||||
startLabel,
|
||||
endLabel,
|
||||
};
|
||||
}
|
||||
127
frontend/src/lib/constants.ts
Normal file
127
frontend/src/lib/constants.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { Block } from "@/types/trajectory";
|
||||
|
||||
export const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||
|
||||
export const INIT_VIEW = {
|
||||
longitude: 2.2,
|
||||
latitude: 41.2,
|
||||
zoom: 7.5,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
} as const;
|
||||
|
||||
export const SPEEDS = [0.5, 1, 2, 4, 8] as const;
|
||||
export const BLOCK_SIZE = 24;
|
||||
export const USE_ARROW = true;
|
||||
/** Load this many blocks first so the map becomes interactive quickly; rest load in background. */
|
||||
export const INITIAL_BLOCKS = 10;
|
||||
/** Max particles to draw as trails/trips (subsampling keeps perf). */
|
||||
export const MAX_TRAIL_PARTICLES = 2500;
|
||||
/** Subsample path: one point every N steps to reduce data and speed up animation. */
|
||||
export const TRAIL_STEP_STRIDE = 3;
|
||||
/** Max trail length in hours (FlowRenderer-style: shorter = streak-like). */
|
||||
export const TRAIL_LENGTH_HOURS = 12;
|
||||
/** Options for trail length (hours) in UI. */
|
||||
export const TRAIL_LENGTH_OPTIONS = [6, 12, 24, 72] as const;
|
||||
/** Options for path density: one point every N steps. */
|
||||
export const TRAIL_STRIDE_OPTIONS = [1, 2, 3] as const;
|
||||
|
||||
export const BASEMAPS: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
thumb: string;
|
||||
}[] = [
|
||||
{
|
||||
id: "dark",
|
||||
name: "Dark",
|
||||
url: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
||||
thumb: "https://placehold.co/160x100/1e293b/475569?text=Dark",
|
||||
},
|
||||
{
|
||||
id: "positron",
|
||||
name: "Light",
|
||||
url: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
|
||||
thumb: "https://placehold.co/160x100/f1f5f9/cbd5e1?text=Light",
|
||||
},
|
||||
{
|
||||
id: "voyager",
|
||||
name: "Voyager",
|
||||
url: "https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
|
||||
thumb: "https://placehold.co/160x100/0c4a6e/0ea5e9?text=Voyager",
|
||||
},
|
||||
];
|
||||
|
||||
export type HeaderTheme = {
|
||||
bg: string;
|
||||
border: string;
|
||||
text: string;
|
||||
subtitle: string;
|
||||
accent: string;
|
||||
accentMuted: string;
|
||||
card: string;
|
||||
};
|
||||
|
||||
export const HEADER_THEMES: Record<string, HeaderTheme> = {
|
||||
dark: {
|
||||
bg: "bg-slate-900/92",
|
||||
border: "border-slate-700/60",
|
||||
text: "text-white",
|
||||
subtitle: "text-slate-500",
|
||||
accent: "text-teal-400",
|
||||
accentMuted: "bg-teal-500/20 ring-teal-500/30",
|
||||
card: "bg-slate-800/80 border-slate-700/80",
|
||||
},
|
||||
positron: {
|
||||
bg: "bg-white/95",
|
||||
border: "border-slate-200/80",
|
||||
text: "text-slate-900",
|
||||
subtitle: "text-slate-500",
|
||||
accent: "text-teal-600",
|
||||
accentMuted: "bg-teal-500/15 ring-teal-500/40",
|
||||
card: "bg-slate-100/90 border-slate-200",
|
||||
},
|
||||
voyager: {
|
||||
bg: "bg-sky-950/90",
|
||||
border: "border-sky-800/60",
|
||||
text: "text-white",
|
||||
subtitle: "text-sky-200/80",
|
||||
accent: "text-teal-300",
|
||||
accentMuted: "bg-teal-500/25 ring-teal-400/40",
|
||||
card: "bg-sky-900/60 border-sky-700/80",
|
||||
},
|
||||
};
|
||||
|
||||
export const C_BEACHED: [number, number, number, number] = [239, 68, 68, 240];
|
||||
/** Electric blue core (FlowRenderer-style), thin streamlines */
|
||||
export const C_TRAIL_ACTIVE: [number, number, number, number] = [0, 180, 255, 255];
|
||||
export const C_TRAIL_BEACHED: [number, number, number, number] = [
|
||||
239, 68, 68, 200,
|
||||
];
|
||||
export const C_SELECTED: [number, number, number, number] = [250, 204, 21, 255];
|
||||
/** Inner glow for electric look */
|
||||
export const C_GLOW_ACTIVE: [number, number, number, number] = [0, 180, 255, 120];
|
||||
export const C_GLOW_BEACHED: [number, number, number, number] = [
|
||||
239, 68, 68, 70,
|
||||
];
|
||||
/** Outer bloom (very soft, wide) for FlowRenderer-style halo */
|
||||
export const C_BLOOM_ACTIVE: [number, number, number, number] = [80, 200, 255, 40];
|
||||
export const C_BLOOM_BEACHED: [number, number, number, number] = [
|
||||
255, 100, 100, 30,
|
||||
];
|
||||
|
||||
/** Palette per origen (seed): [R, G, B], un color per zona de llançament */
|
||||
export const PALETTE_ORIGIN: [number, number, number][] = [
|
||||
[0, 180, 255], // blau elèctric
|
||||
[0, 255, 200], // cyan
|
||||
[100, 255, 100], // verd
|
||||
[255, 220, 80], // groc
|
||||
[255, 140, 60], // taronja
|
||||
[255, 100, 200], // magenta
|
||||
[180, 120, 255], // lila
|
||||
[80, 240, 255], // cel
|
||||
];
|
||||
|
||||
export function activeColor(_speed: number): [number, number, number, number] {
|
||||
return [0, 180, 255, 220];
|
||||
}
|
||||
137
frontend/src/lib/trajectoryData.ts
Normal file
137
frontend/src/lib/trajectoryData.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { Block, Frame, PointDatum, TrailDatum, TripDatum } from "@/types/trajectory";
|
||||
import { BLOCK_SIZE, MAX_TRAIL_PARTICLES, TRAIL_STEP_STRIDE } from "./constants";
|
||||
|
||||
export function buildScatterData(frame: Frame): PointDatum[] {
|
||||
const out: PointDatum[] = new Array(frame.length);
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
const p = frame[i];
|
||||
out[i] = {
|
||||
position: [p[0], p[1]],
|
||||
beached: p[4] === 1,
|
||||
speed: Math.sqrt(p[2] * p[2] + p[3] * p[3]),
|
||||
id: i,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTrails(
|
||||
allBlocks: (Block | null)[],
|
||||
currentStep: number,
|
||||
numParticles: number,
|
||||
releaseSteps: number[] | undefined,
|
||||
stepStride: number = TRAIL_STEP_STRIDE
|
||||
): TrailDatum[] {
|
||||
const out: TrailDatum[] = [];
|
||||
const maxP = Math.min(numParticles, MAX_TRAIL_PARTICLES);
|
||||
for (let pid = 0; pid < maxP; pid++) {
|
||||
const releaseStep = releaseSteps?.[pid] ?? 0;
|
||||
if (currentStep < releaseStep) continue;
|
||||
const path: [number, number][] = [];
|
||||
let beached = false;
|
||||
for (let s = releaseStep; s <= currentStep; s++) {
|
||||
const blockIdx = Math.floor(s / BLOCK_SIZE);
|
||||
const block = allBlocks[blockIdx];
|
||||
if (!block?.frames?.length) continue;
|
||||
const frameIdx = s - block.step_start;
|
||||
if (frameIdx < 0 || frameIdx >= block.frames.length) continue;
|
||||
const p = block.frames[frameIdx][pid];
|
||||
if (!p) continue;
|
||||
const isFirst = s === releaseStep;
|
||||
const isSubsample = (s - releaseStep) % stepStride === 0;
|
||||
const isLast = s === currentStep;
|
||||
const isBeached = p[4] === 1;
|
||||
if (isFirst || isSubsample || isLast || isBeached) path.push([p[0], p[1]]);
|
||||
if (isBeached) {
|
||||
beached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (path.length > 0) out.push({ path, beached, id: pid });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildTripsWithTime(
|
||||
allBlocks: (Block | null)[],
|
||||
currentStep: number,
|
||||
numParticles: number,
|
||||
releaseSteps: number[] | undefined,
|
||||
stepStride: number = TRAIL_STEP_STRIDE
|
||||
): TripDatum[] {
|
||||
const out: TripDatum[] = [];
|
||||
const maxP = Math.min(numParticles, MAX_TRAIL_PARTICLES);
|
||||
for (let pid = 0; pid < maxP; pid++) {
|
||||
const releaseStep = releaseSteps?.[pid] ?? 0;
|
||||
if (currentStep < releaseStep) continue;
|
||||
const path: [number, number, number][] = [];
|
||||
let beached = false;
|
||||
for (let s = releaseStep; s <= currentStep; s++) {
|
||||
const blockIdx = Math.floor(s / BLOCK_SIZE);
|
||||
const block = allBlocks[blockIdx];
|
||||
if (!block?.frames?.length) continue;
|
||||
const frameIdx = s - block.step_start;
|
||||
if (frameIdx < 0 || frameIdx >= block.frames.length) continue;
|
||||
const p = block.frames[frameIdx][pid];
|
||||
if (!p) continue;
|
||||
const isFirst = s === releaseStep;
|
||||
const isSubsample = (s - releaseStep) % stepStride === 0;
|
||||
const isLast = s === currentStep;
|
||||
const isBeached = p[4] === 1;
|
||||
if (isFirst || isSubsample || isLast || isBeached)
|
||||
path.push([p[0], p[1], s * 3600]);
|
||||
if (isBeached) {
|
||||
beached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (path.length > 0) out.push({ path, beached, id: pid });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build frames from an Arrow block table. Rows must be ordered by step then particle_id.
|
||||
* Derives numSteps from the step column so the last block (fewer than block_size steps) is correct.
|
||||
*/
|
||||
export function framesFromArrowTable(
|
||||
table: import("apache-arrow").Table,
|
||||
numParticles: number
|
||||
): Frame[] {
|
||||
const n = numParticles;
|
||||
const stepCol = table.getChild("step");
|
||||
const lonCol = table.getChild("lon");
|
||||
const latCol = table.getChild("lat");
|
||||
const uCol = table.getChild("u");
|
||||
const vCol = table.getChild("v");
|
||||
const beachedCol = table.getChild("beached");
|
||||
if (!stepCol || !lonCol || !latCol || !uCol || !vCol || !beachedCol || n <= 0)
|
||||
return [];
|
||||
const totalRows = table.numRows;
|
||||
const minStep = stepCol.get(0) as number;
|
||||
const maxStep = stepCol.get(totalRows - 1) as number;
|
||||
const numSteps = maxStep - minStep + 1;
|
||||
if (numSteps <= 0 || totalRows < numSteps * n) return [];
|
||||
const frames: Frame[] = [];
|
||||
for (let stepIdx = 0; stepIdx < numSteps; stepIdx++) {
|
||||
const frame: number[][] = [];
|
||||
for (let pid = 0; pid < n; pid++) {
|
||||
const row = stepIdx * n + pid;
|
||||
frame.push([
|
||||
lonCol.get(row) as number,
|
||||
latCol.get(row) as number,
|
||||
uCol.get(row) as number,
|
||||
vCol.get(row) as number,
|
||||
(beachedCol.get(row) as number) ?? 0,
|
||||
]);
|
||||
}
|
||||
frames.push(frame);
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function countBeached(frame: Frame): number {
|
||||
let c = 0;
|
||||
for (let i = 0; i < frame.length; i++) if (frame[i][4] === 1) c++;
|
||||
return c;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
46
frontend/src/types/trajectory.ts
Normal file
46
frontend/src/types/trajectory.ts
Normal file
@ -0,0 +1,46 @@
|
||||
export interface Meta {
|
||||
num_particles: number;
|
||||
num_steps: number;
|
||||
num_blocks: number;
|
||||
block_size: number;
|
||||
seed_names: string[];
|
||||
origins: number[];
|
||||
release_steps?: number[];
|
||||
/** ISO datetime of first step (from NetCDF time axis). */
|
||||
time_start?: string;
|
||||
/** ISO datetime of last step (from NetCDF time axis). */
|
||||
time_end?: string;
|
||||
/** Hours per step (e.g. 1 = one sample per hour). */
|
||||
dt_hours?: number;
|
||||
}
|
||||
|
||||
export type Frame = number[][];
|
||||
export type Accum = number[][];
|
||||
|
||||
export interface Block {
|
||||
block: number;
|
||||
step_start: number;
|
||||
step_end: number;
|
||||
frames: Frame[];
|
||||
accumulation: Accum[];
|
||||
}
|
||||
|
||||
export interface PointDatum {
|
||||
position: [number, number];
|
||||
beached: boolean;
|
||||
speed: number;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface TrailDatum {
|
||||
path: [number, number][];
|
||||
beached: boolean;
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** For TripsLayer: path [lon, lat, timeSec], timestamps strictly increasing. */
|
||||
export interface TripDatum {
|
||||
path: [number, number, number][];
|
||||
beached: boolean;
|
||||
id: number;
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user