Visor de particules

This commit is contained in:
shamri 2026-05-04 09:44:56 +02:00
commit f6e225de55
76 changed files with 19251 additions and 0 deletions

66
.gitignore vendored Normal file
View 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
View 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, sencallen i sacumulen, mostrant zones dimpacte.
**Stack:** Python · FastAPI · Next.js · Deck.gl · MapLibre (sense claus dAPI)
---
## Arquitectura
```
trajectories/
├── backend/ # API i dades
│ ├── main.py # FastAPI: metadata, blocs JSON/Arrow, CORS, gzip
│ ├── data/ # Dades servides per lAPI
│ │ ├── 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>/`. LAPI 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 dun 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
```
LAPI 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 lAPI 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 dintroducció 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 dIDs 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`.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

1
backend/etl/__init__.py Normal file
View File

@ -0,0 +1 @@
# ETL: NetCDF trajectory → Apache Arrow blocks for frontend

201
backend/etl/nc_reader.py Normal file
View 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/83/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
View 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
View 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
View 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

View 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.10.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 4042.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
View 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
View File

@ -0,0 +1,6 @@
fastapi
uvicorn
numpy
pyarrow
xarray
netCDF4

View File

@ -0,0 +1 @@
# CLI scripts

View 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()

View 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 labast funcional, els actors, els requisits i els criteris dacceptació.</p>
<h3>1.2 Abast</h3>
<p>Inclou el visor web de visualització de trajectòries, làrea dadministració per a la càrrega i gestió de dades, el processament de fitxers NetCDF i el desplegament del conjunt. Queden fora dabast els detalls darquitectura 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 sacumulen. El visor permet identificar aquestes zones dimpacte 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 lanà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 danimació, 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 ladministrador pujar fitxers NetCDF des duna àrea dadministració dedicada.</li>
<li><span class="req-id">REQ-F-02</span> Els fitxers NetCDF hauran dajustar-se al format acordat (variables de longitud, latitud, velocitats, estat dencallament, eixos de temps i partícules).</li>
<li><span class="req-id">REQ-F-03</span> El sistema ha de mostrar a ladministrador 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 ladministrador eliminar simulacions. La simulació eliminada deixa destar 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 ladministrador.</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 lusuari.</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 lestela 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 dorigen (llançament) per permetre identificar lorigen 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) lanimació temporal.</li>
<li><span class="req-id">REQ-F-14</span> El visor ha de oferir un control de velocitat de lanimació (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 lanimació completa.</li>
<li><span class="req-id">REQ-F-16</span> El visor ha de mostrar linstant 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 lestil 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 dURL) 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>Ladministrador 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. Lusuari final obre el visor, selecciona una simulació i utilitza els controls danimació, 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>Lusuari accedeix al visor des del navegador, selecciona simulació (o se nofereix una per defecte), el mapa es carrega amb les trajectòries i lusuari 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 dadministració:</strong> interfície que implementa els requisits REQ-F-01 a REQ-F-06 (pujada de NetCDF, llistat, eliminació i, si sacorda, 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 ladministrador (càrrega i gestió de fitxers) i per a lusuari 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 lentorn del client.</li>
</ul>
</section>
<section class="section" id="acceptacio">
<h2>7. Criteris dacceptació</h2>
<p>El projecte es donarà per acceptat quan es compleixin les condicions següents:</p>
<ul>
<li>Ladministrador pot pujar fitxers NetCDF en el format acordat i pot llistar i eliminar simulacions des de la interfície dadministració.</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>Lusuari 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 dAPI). <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 lenllaç, activar lanimació (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 laspecte 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 lanimació activa (secció 8.2).
</div>
<script>
mermaid.initialize({ startOnLoad: true, theme: 'neutral' });
</script>
</body>
</html>

View 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
View 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
View 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
View 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": {}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View 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

View 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
View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View 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;
}
}

View 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>
);
}

View File

@ -0,0 +1,9 @@
import TrajectoryMap from "@/components/TrajectoryMap";
export default function Home() {
return (
<main className="w-screen h-screen">
<TrajectoryMap />
</main>
);
}

View 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 laigua</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";
}

View 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>
);
}

View 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 &amp; 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 };
}

View 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]);
}

View 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,
]);
}

View 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;
}

View 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,
};
}

View 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];
}

View 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;
}

View 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))
}

View 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
View 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"]
}