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