Compare commits
24 Commits
34e709b7c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66bb52fec | ||
|
|
dd6f0cf435 | ||
|
|
f5788a01f4 | ||
| f5debc8afc | |||
|
|
63270beeff | ||
|
|
4164f32694 | ||
|
|
b45a5368ee | ||
|
|
7a5d442fbd | ||
| b21e306a86 | |||
|
|
a79f63e59e | ||
|
|
31b5a221b8 | ||
|
|
b962997008 | ||
|
|
6978b36650 | ||
| 70608de221 | |||
|
|
0a0dac7fda | ||
|
|
62091a09b7 | ||
|
|
3e1da53cc7 | ||
|
|
24f9394c75 | ||
|
|
c9dca1d071 | ||
|
|
682050ef14 | ||
|
|
4aec9d6295 | ||
|
|
af2bb6581f | ||
|
|
bd3a2359d9 | ||
|
|
02e357b874 |
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
version: "3.9"
|
||||||
|
services:
|
||||||
|
pipeline-runner:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: pipeline_runner/Dockerfile
|
||||||
|
container_name: cosma-pipeline-runner
|
||||||
|
ports:
|
||||||
|
- "8767:8767"
|
||||||
|
volumes:
|
||||||
|
- /mnt/nas-cosma/cosma/sorties:/data/sorties
|
||||||
|
- /home/floppyrj45/.config/rclone/rclone.conf:/root/.config/rclone/rclone.conf:ro
|
||||||
|
environment:
|
||||||
|
- GDRIVE_REMOTE=cosma:06-Operations/06 - Sorties
|
||||||
|
- OUTPUT_DIR=/data/sorties
|
||||||
|
- TOOLS_DIR=/app/tools
|
||||||
|
restart: unless-stopped
|
||||||
1219
docs/superpowers/plans/2026-04-27-gdrive-pipeline-replay.md
Normal file
1219
docs/superpowers/plans/2026-04-27-gdrive-pipeline-replay.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
|||||||
|
# Design : GDrive Pipeline Replay
|
||||||
|
|
||||||
|
**Date :** 2026-04-27
|
||||||
|
**Repo :** cosma-nav-tools
|
||||||
|
**Branche :** feat/flag-local
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Bouton dans le viewer 8765 qui déclenche un sync GDrive → pipeline Python → affichage replay des signaux USV et AUV sur la même page, synchronisés avec le slider 24h existant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture globale
|
||||||
|
|
||||||
|
```
|
||||||
|
GDrive (G:)
|
||||||
|
└─ 06-Operations/06-Sorties/#XX-lieu/YYYYMMDD-lieu/raw_data/
|
||||||
|
├─ SHIP/*.csv (USV nav + USBL + full)
|
||||||
|
└─ SUB/*.mcap (AUV ROS2) + bin/
|
||||||
|
|
||||||
|
↓ rclone sync (déclenché par bouton viewer)
|
||||||
|
|
||||||
|
.83 /data/sorties/#XX/raw/
|
||||||
|
├─ SHIP/
|
||||||
|
└─ SUB/
|
||||||
|
|
||||||
|
↓ pipeline Python (scripts existants cosma-nav-tools/tools/)
|
||||||
|
|
||||||
|
.83 /data/sorties/#XX/processed/
|
||||||
|
├─ usv.json.gz ← signaux USV downsamplés LTTB
|
||||||
|
├─ auv_AUV010.json.gz ← signaux AUV010 downsamplés LTTB
|
||||||
|
├─ auv_AUVxxx.json.gz ← un fichier par AUV détecté
|
||||||
|
└─ tracks.geojson ← USV + AUV tracks (Leaflet)
|
||||||
|
|
||||||
|
↓ servi par
|
||||||
|
|
||||||
|
port 8767 pipeline-runner FastAPI (cosma-nav-tools/pipeline_runner/)
|
||||||
|
port 8765 viewer/index.html ← page unique, fetch 8766 + 8767
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 : Pipeline-runner (port 8767)
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cosma-nav-tools/
|
||||||
|
pipeline_runner/
|
||||||
|
main.py ← FastAPI app + endpoints
|
||||||
|
runner.py ← logique rclone + orchestration scripts
|
||||||
|
config.py ← chemins GDrive, output dir, rclone remote
|
||||||
|
docker-compose.yml ← service pipeline-runner port 8767
|
||||||
|
data/sorties/ ← gitignored
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| `GET` | `/sorties` | Liste sorties détectées sur GDrive (scan dossier) |
|
||||||
|
| `POST` | `/run/{sortie_id}` | Lance rclone pull + pipeline complet |
|
||||||
|
| `GET` | `/events/{sortie_id}` | SSE stream progress (étape + %) |
|
||||||
|
| `GET` | `/sorties/{id}/usv` | Données USV processed (JSON gzip) |
|
||||||
|
| `GET` | `/sorties/{id}/auvs` | Liste AUVs disponibles pour cette sortie |
|
||||||
|
| `GET` | `/sorties/{id}/auv/{auv_id}` | Données AUV processed (JSON gzip) |
|
||||||
|
| `GET` | `/sorties/{id}/tracks` | GeoJSON tracks USV + AUV |
|
||||||
|
|
||||||
|
### Flow interne POST /run/{sortie_id}
|
||||||
|
|
||||||
|
```
|
||||||
|
1. rclone sync GDrive/#id → /data/sorties/#id/raw/ SSE: "sync" 0→50%
|
||||||
|
2. parse_usv_nav.py + extract_usv_pwm.py SSE: "usv_parse" 50→65%
|
||||||
|
3. parse_kogger_usbl.py + merge_nav_usbl.py SSE: "usbl_merge" 65→80%
|
||||||
|
4. extract_mcap_signals.py (par AUV détecté) SSE: "auv_parse" 80→90%
|
||||||
|
5. usbl_to_json.py SSE: "usbl_json" 90→95%
|
||||||
|
6. downsample LTTB + écriture .json.gz SSE: "write" 95→100%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 : Format données
|
||||||
|
|
||||||
|
### Signaux (usv.json.gz, auv_*.json.gz)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"sortie": "#71-golrest",
|
||||||
|
"date": "2026-04-16",
|
||||||
|
"vehicle": "USV001",
|
||||||
|
"t_start": 1713268477,
|
||||||
|
"t_end": 1713282877
|
||||||
|
},
|
||||||
|
"signals": {
|
||||||
|
"yaw": [{ "t": 1713268477, "v": 142.3 }, ...],
|
||||||
|
"heading": [...],
|
||||||
|
"gps_status": [{ "t": ..., "v": "3D_FIX" }, ...],
|
||||||
|
"battery_v": [...],
|
||||||
|
"usbl_dist": [...],
|
||||||
|
"usbl_angle": [...],
|
||||||
|
"motor_1": [...],
|
||||||
|
"motor_2": [...],
|
||||||
|
"auv_status": [{ "t": ..., "v": "MISSION" }, ...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `t` = epoch UNIX secondes
|
||||||
|
- Max **4000 points par signal** via LTTB (Plotly optimal)
|
||||||
|
- Valeurs discrètes (status, mode) = strings → step-plot Plotly
|
||||||
|
- Endpoint optionnel `/range?from=&to=` pour zoom fin sur brutes
|
||||||
|
|
||||||
|
### Tracks
|
||||||
|
|
||||||
|
- **GeoJSON** FeatureCollection : une Feature LineString par véhicule
|
||||||
|
- Poids estimé : ~300KB gzip pour 4h à 1Hz
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 : Viewer 8765 — extensions
|
||||||
|
|
||||||
|
### Layout (page unique)
|
||||||
|
|
||||||
|
```
|
||||||
|
datebar ← inchangé
|
||||||
|
header ← + dropdown sortie + [Sync & Process] + progress bar SSE
|
||||||
|
map Leaflet ← inchangé
|
||||||
|
slider 24h ← shared X axis, curseur vertical sur tous les graphs
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
USV PANEL
|
||||||
|
[Yaw] [Heading]
|
||||||
|
[GPS status] [Battery voltage]
|
||||||
|
[USBL dist] [USBL angle]
|
||||||
|
[Motor 1] [Motor 2]
|
||||||
|
[AUV status: disarm/mission/goto — step-plot]
|
||||||
|
─────────────────────────────────────────────
|
||||||
|
AUV PANEL ← tabs [AUV010] [AUV011] … si multi-AUV
|
||||||
|
[Pitch/Roll/Yaw — 3 traces] [Depth]
|
||||||
|
[Altitude] [Obstacle dist]
|
||||||
|
[USBL dist] [USBL angle]
|
||||||
|
[Battery voltage] [Arm/Disarm/Mode — step-plot]
|
||||||
|
[Motors ×6 PWM — 1 graph multi-trace M1…M6]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Synchronisation slider
|
||||||
|
|
||||||
|
- Slider 24h existant → événement → `updateCursor(t)` sur tous les graphs Plotly via `Plotly.relayout` shapes
|
||||||
|
- Données chargées une fois en mémoire au click de sortie, pas de re-fetch au scrub
|
||||||
|
|
||||||
|
### Bouton Sync & Process
|
||||||
|
|
||||||
|
- `POST /run/{sortie_id}` → ouvre SSE `/events/{sortie_id}`
|
||||||
|
- Progress bar inline dans le header (ex: `rclone 34% → usv_parse…`)
|
||||||
|
- À 100% → fetch automatique USV + AUV + tracks → render graphs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 : Déploiement .83
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml (ajout)
|
||||||
|
pipeline-runner:
|
||||||
|
build: ./pipeline_runner
|
||||||
|
ports:
|
||||||
|
- "8767:8767"
|
||||||
|
volumes:
|
||||||
|
- /data/sorties:/data/sorties
|
||||||
|
- /mnt/gdrive:/mnt/gdrive # rclone mount ou rclone exec
|
||||||
|
environment:
|
||||||
|
- GDRIVE_REMOTE=gdrive:Cosma - Internal/06-Operations/06 - Sorties
|
||||||
|
- OUTPUT_DIR=/data/sorties
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hors scope (v1)
|
||||||
|
|
||||||
|
- Authentification / accès multi-user
|
||||||
|
- HDF5 archivage (peut venir en v2)
|
||||||
|
- Replay animé temps-réel (curseur qui avance automatiquement)
|
||||||
|
- Tests unitaires pipeline
|
||||||
9
pipeline_runner/Dockerfile
Normal file
9
pipeline_runner/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y rclone && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY tools/ ./tools/
|
||||||
|
COPY vendor/ ./vendor/
|
||||||
|
COPY pipeline_runner/ ./pipeline_runner/
|
||||||
|
CMD ["uvicorn", "pipeline_runner.main:app", "--host", "0.0.0.0", "--port", "8767"]
|
||||||
0
pipeline_runner/__init__.py
Normal file
0
pipeline_runner/__init__.py
Normal file
7
pipeline_runner/config.py
Normal file
7
pipeline_runner/config.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GDRIVE_REMOTE = os.getenv("GDRIVE_REMOTE", "gdrive:Cosma - Internal/06-Operations/06 - Sorties")
|
||||||
|
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/data/sorties"))
|
||||||
|
TOOLS_DIR = Path(os.getenv("TOOLS_DIR", Path(__file__).parent.parent / "tools"))
|
||||||
|
LTTB_MAX_PTS = 4000
|
||||||
221
pipeline_runner/main.py
Normal file
221
pipeline_runner/main.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from .config import OUTPUT_DIR
|
||||||
|
from .runner import run_pipeline, scan_sorties, scan_sorties_local
|
||||||
|
|
||||||
|
app = FastAPI(title="COSMA Pipeline Runner")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
# Active pipeline jobs: sortie_id → asyncio.Queue
|
||||||
|
_jobs: dict[str, asyncio.Queue] = {}
|
||||||
|
|
||||||
|
# Cache sorties avec TTL 10min
|
||||||
|
_sorties_cache: list | None = None
|
||||||
|
_sorties_cache_ts: float = 0.0
|
||||||
|
_SORTIES_TTL = 600.0 # 10 minutes
|
||||||
|
_sorties_refresh_lock: asyncio.Lock | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_lock() -> asyncio.Lock:
|
||||||
|
global _sorties_refresh_lock
|
||||||
|
if _sorties_refresh_lock is None:
|
||||||
|
_sorties_refresh_lock = asyncio.Lock()
|
||||||
|
return _sorties_refresh_lock
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_sorties_cache() -> None:
|
||||||
|
"""Refresh cache in background (holds lock to avoid parallel rclone calls)."""
|
||||||
|
global _sorties_cache, _sorties_cache_ts
|
||||||
|
lock = _get_lock()
|
||||||
|
async with lock:
|
||||||
|
# Double-check after acquiring lock
|
||||||
|
if time.monotonic() - _sorties_cache_ts < _SORTIES_TTL:
|
||||||
|
return
|
||||||
|
result = await asyncio.to_thread(scan_sorties)
|
||||||
|
_sorties_cache = result
|
||||||
|
_sorties_cache_ts = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties")
|
||||||
|
async def list_sorties():
|
||||||
|
global _sorties_cache, _sorties_cache_ts
|
||||||
|
now = time.monotonic()
|
||||||
|
if _sorties_cache is None:
|
||||||
|
# Premier appel: bloquant (cache vide)
|
||||||
|
await _refresh_sorties_cache()
|
||||||
|
elif now - _sorties_cache_ts >= _SORTIES_TTL:
|
||||||
|
# Cache périmé: retourne le cache, refresh en arrière-plan
|
||||||
|
asyncio.create_task(_refresh_sorties_cache())
|
||||||
|
return _sorties_cache or []
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/local")
|
||||||
|
async def list_sorties_local():
|
||||||
|
"""Scan /data/sorties local (NAS, instantané) sans rclone."""
|
||||||
|
sorties = await asyncio.to_thread(scan_sorties_local)
|
||||||
|
return sorties
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/run/{sortie_id:path}")
|
||||||
|
async def run_sortie(sortie_id: str):
|
||||||
|
if sortie_id in _jobs:
|
||||||
|
return {"status": "already_running"}
|
||||||
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
_jobs[sortie_id] = queue
|
||||||
|
asyncio.create_task(_run_and_cleanup(sortie_id, queue))
|
||||||
|
return {"status": "started"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_and_cleanup(sortie_id: str, queue: asyncio.Queue):
|
||||||
|
try:
|
||||||
|
await run_pipeline(sortie_id, queue)
|
||||||
|
finally:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
_jobs.pop(sortie_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/events/{sortie_id:path}")
|
||||||
|
async def sse_events(sortie_id: str):
|
||||||
|
if sortie_id not in _jobs:
|
||||||
|
raise HTTPException(404, "No active job for this sortie")
|
||||||
|
|
||||||
|
async def generate() -> AsyncGenerator[str, None]:
|
||||||
|
queue = _jobs[sortie_id]
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(queue.get(), timeout=60)
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
if event.get("step") in ("error", "write") and event.get("pct") in (0, 100):
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
yield "data: {\"step\":\"ping\"}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_gz(path: Path) -> dict:
|
||||||
|
with gzip.open(path) as f:
|
||||||
|
return json.loads(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/{sortie_id:path}/usv")
|
||||||
|
async def get_usv(sortie_id: str):
|
||||||
|
p = OUTPUT_DIR / sortie_id / "processed" / "usv.json.gz"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "USV data not found — run pipeline first")
|
||||||
|
return JSONResponse(_read_gz(p))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/{sortie_id:path}/auvs")
|
||||||
|
async def list_auvs(sortie_id: str):
|
||||||
|
proc = OUTPUT_DIR / sortie_id / "processed"
|
||||||
|
auvs = [p.name.removesuffix(".json.gz").removeprefix("auv_")
|
||||||
|
for p in proc.glob("auv_*.json.gz")]
|
||||||
|
return sorted(auvs)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/{sortie_id:path}/auv/{auv_id}")
|
||||||
|
async def get_auv(sortie_id: str, auv_id: str):
|
||||||
|
p = OUTPUT_DIR / sortie_id / "processed" / f"auv_{auv_id}.json.gz"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, f"AUV {auv_id} data not found")
|
||||||
|
return JSONResponse(_read_gz(p))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/{sortie_id:path}/tracks")
|
||||||
|
async def get_tracks(sortie_id: str):
|
||||||
|
p = OUTPUT_DIR / sortie_id / "processed" / "tracks.geojson"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "tracks.geojson not found")
|
||||||
|
with open(p) as f:
|
||||||
|
return JSONResponse(json.load(f))
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_nav(ts_str: str) -> float:
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(ts_str.strip(), fmt).replace(tzinfo=timezone.utc).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_usv_track(nav_log_path: Path, max_pts: int = 2000) -> list[dict]:
|
||||||
|
"""Read navigation_log.csv → [{t_ms, lat, lon, heading, source}] downsampled."""
|
||||||
|
pts: list[dict] = []
|
||||||
|
lat_map: dict[float, float] = {}
|
||||||
|
lon_map: dict[float, float] = {}
|
||||||
|
heading_map: dict[float, float] = {}
|
||||||
|
with open(nav_log_path, newline="", encoding="utf-8") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
next(reader, None) # skip header
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 3:
|
||||||
|
continue
|
||||||
|
ts_str, field, val = row[0], row[1], row[2]
|
||||||
|
if field not in ("Lat", "Lon", "Heading"):
|
||||||
|
continue
|
||||||
|
t = _ts_nav(ts_str)
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if field == "Lat":
|
||||||
|
lat_map[t] = v
|
||||||
|
elif field == "Lon":
|
||||||
|
lon_map[t] = v
|
||||||
|
else:
|
||||||
|
heading_map[t] = v
|
||||||
|
# Join on lat timestamps (master)
|
||||||
|
source = nav_log_path.parent.name
|
||||||
|
for t, lat in sorted(lat_map.items()):
|
||||||
|
lon = lon_map.get(t)
|
||||||
|
if lon is None:
|
||||||
|
# nearest lon within 1s
|
||||||
|
near = min(lon_map.keys(), key=lambda x: abs(x - t), default=None)
|
||||||
|
if near is None or abs(near - t) > 1.0:
|
||||||
|
continue
|
||||||
|
lon = lon_map[near]
|
||||||
|
pts.append({
|
||||||
|
"t_ms": int(t * 1000),
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"heading": heading_map.get(t),
|
||||||
|
"source": source,
|
||||||
|
})
|
||||||
|
# Simple stride downsampling
|
||||||
|
if len(pts) > max_pts:
|
||||||
|
step = len(pts) // max_pts
|
||||||
|
pts = pts[::step]
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
_track_cache: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sorties/{sortie_id:path}/usv_track")
|
||||||
|
async def get_usv_track(sortie_id: str):
|
||||||
|
"""Return USV GPS track [{t_ms, lat, lon, heading, source}] for map polylines."""
|
||||||
|
if sortie_id in _track_cache:
|
||||||
|
return JSONResponse(_track_cache[sortie_id])
|
||||||
|
raw_dir = OUTPUT_DIR / sortie_id / "raw"
|
||||||
|
nav_logs = list(raw_dir.rglob("*_navigation_log.csv")) if raw_dir.exists() else []
|
||||||
|
if not nav_logs:
|
||||||
|
raise HTTPException(404, "No navigation_log.csv found — run pipeline first")
|
||||||
|
pts: list[dict] = []
|
||||||
|
for nav_log in nav_logs:
|
||||||
|
pts.extend(await asyncio.to_thread(_read_usv_track, nav_log))
|
||||||
|
pts.sort(key=lambda p: p["t_ms"])
|
||||||
|
_track_cache[sortie_id] = pts
|
||||||
|
return JSONResponse(pts)
|
||||||
174
pipeline_runner/processor.py
Normal file
174
pipeline_runner/processor.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .config import LTTB_MAX_PTS
|
||||||
|
|
||||||
|
|
||||||
|
def _lttb(points: list[dict], max_pts: int) -> list[dict]:
|
||||||
|
"""Largest Triangle Three Buckets downsampling."""
|
||||||
|
n = len(points)
|
||||||
|
if n <= max_pts:
|
||||||
|
return points
|
||||||
|
ts = np.array([p["t"] for p in points], dtype=float)
|
||||||
|
vs = np.array([p["v"] if isinstance(p["v"], (int, float)) else 0.0 for p in points], dtype=float)
|
||||||
|
bucket_size = (n - 2) / (max_pts - 2)
|
||||||
|
out = [points[0]]
|
||||||
|
a = 0
|
||||||
|
for i in range(max_pts - 2):
|
||||||
|
avg_start = int((i + 1) * bucket_size) + 1
|
||||||
|
avg_end = min(int((i + 2) * bucket_size) + 1, n)
|
||||||
|
avg_t = np.mean(ts[avg_start:avg_end])
|
||||||
|
avg_v = np.mean(vs[avg_start:avg_end])
|
||||||
|
rng_start = int(i * bucket_size) + 1
|
||||||
|
rng_end = min(int((i + 1) * bucket_size) + 1, n)
|
||||||
|
max_area = -1.0
|
||||||
|
best = rng_start
|
||||||
|
at, av = ts[a], vs[a]
|
||||||
|
for j in range(rng_start, rng_end):
|
||||||
|
area = abs((at - avg_t) * (vs[j] - av) - (ts[j] - at) * (avg_v - av)) * 0.5
|
||||||
|
if area > max_area:
|
||||||
|
max_area = area
|
||||||
|
best = j
|
||||||
|
out.append(points[best])
|
||||||
|
a = best
|
||||||
|
out.append(points[-1])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ts_to_epoch(ts_str: str) -> float:
|
||||||
|
"""Parse 'YYYY-MM-DD HH:MM:SS.ffffff' → epoch seconds."""
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(ts_str.strip(), fmt).replace(tzinfo=timezone.utc)
|
||||||
|
return dt.timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nav_log(nav_log_path: Path) -> dict[str, list[dict]]:
|
||||||
|
"""Read long-format navigation_log.csv → dict of signal arrays."""
|
||||||
|
signals: dict[str, list] = {}
|
||||||
|
wanted = {"Yaw", "Heading", "Roll", "Pitch", "BattVoltage", "gps_fix",
|
||||||
|
"Armed", "Mode", "M1", "M2"}
|
||||||
|
with open(nav_log_path, newline="", encoding="utf-8") as f:
|
||||||
|
reader = csv.reader(f)
|
||||||
|
next(reader, None) # skip header
|
||||||
|
for row in reader:
|
||||||
|
if len(row) < 3:
|
||||||
|
continue
|
||||||
|
ts_str, field, val = row[0], row[1], row[2]
|
||||||
|
if field not in wanted:
|
||||||
|
continue
|
||||||
|
t = _ts_to_epoch(ts_str)
|
||||||
|
try:
|
||||||
|
v: Any = float(val)
|
||||||
|
except ValueError:
|
||||||
|
v = val.strip()
|
||||||
|
signals.setdefault(field, []).append({"t": t, "v": v})
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def _read_usbl_csv(usbl_csv_path: Path) -> dict[str, list[dict]]:
|
||||||
|
"""Read combined_usbl.csv → Dist and Azimuth signal arrays."""
|
||||||
|
dist_pts, az_pts = [], []
|
||||||
|
with open(usbl_csv_path, newline="", encoding="utf-8") as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
try:
|
||||||
|
t = _ts_to_epoch(row["Timestamp"])
|
||||||
|
d = float(row["Dist"]) if row["Dist"] else None
|
||||||
|
az = float(row["Azimuth"]) if row["Azimuth"] else None
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
if d is not None and not math.isnan(d):
|
||||||
|
dist_pts.append({"t": t, "v": d})
|
||||||
|
if az is not None and not math.isnan(az):
|
||||||
|
az_pts.append({"t": t, "v": az})
|
||||||
|
return {"usbl_dist": dist_pts, "usbl_angle": az_pts}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mcap_signals(mcap_json_path: Path) -> dict[str, list[dict]]:
|
||||||
|
"""Read mcap_signals.json → depth, motors M1-M6, state signals."""
|
||||||
|
with open(mcap_json_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
signals: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
def _unpack(key: str, series: list):
|
||||||
|
pts = []
|
||||||
|
for item in series:
|
||||||
|
t = item.get("t_ms", item.get("t", 0))
|
||||||
|
if isinstance(t, (int, float)) and t > 1e9:
|
||||||
|
t = t / 1000.0 # ms → s
|
||||||
|
pts.append({"t": float(t), "v": item.get("v", item.get("value", 0))})
|
||||||
|
signals[key] = pts
|
||||||
|
|
||||||
|
if "depth" in data:
|
||||||
|
_unpack("depth", data["depth"])
|
||||||
|
if "pwm_auv" in data:
|
||||||
|
for m_key, series in data["pwm_auv"].items():
|
||||||
|
_unpack(m_key.lower(), series)
|
||||||
|
if "state" in data:
|
||||||
|
_unpack("arm_status", data["state"])
|
||||||
|
# New signals added by extended extract_mcap_signals.py
|
||||||
|
for key in ("pitch", "roll", "yaw", "altitude", "battery_v", "obstacle_dist"):
|
||||||
|
if key in data:
|
||||||
|
_unpack(key, data[key])
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def write_usv_json(
|
||||||
|
nav_log_path: Path,
|
||||||
|
usbl_csv_path: Path | None,
|
||||||
|
output_path: Path,
|
||||||
|
sortie_id: str,
|
||||||
|
date: str,
|
||||||
|
) -> None:
|
||||||
|
signals = _read_nav_log(nav_log_path)
|
||||||
|
if usbl_csv_path and usbl_csv_path.exists():
|
||||||
|
signals.update(_read_usbl_csv(usbl_csv_path))
|
||||||
|
|
||||||
|
t_all = [p["t"] for pts in signals.values() for p in pts if pts]
|
||||||
|
meta = {
|
||||||
|
"sortie": sortie_id,
|
||||||
|
"date": date,
|
||||||
|
"vehicle": "USV",
|
||||||
|
"t_start": min(t_all) if t_all else 0,
|
||||||
|
"t_end": max(t_all) if t_all else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
downsampled = {k: _lttb(v, LTTB_MAX_PTS) for k, v in signals.items()}
|
||||||
|
payload = json.dumps({"meta": meta, "signals": downsampled}).encode()
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with gzip.open(output_path, "wb") as f:
|
||||||
|
f.write(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def write_auv_json(
|
||||||
|
mcap_json_path: Path,
|
||||||
|
output_path: Path,
|
||||||
|
auv_id: str,
|
||||||
|
sortie_id: str,
|
||||||
|
date: str,
|
||||||
|
) -> None:
|
||||||
|
signals = _read_mcap_signals(mcap_json_path)
|
||||||
|
t_all = [p["t"] for pts in signals.values() for p in pts if pts]
|
||||||
|
meta = {
|
||||||
|
"sortie": sortie_id,
|
||||||
|
"date": date,
|
||||||
|
"vehicle": auv_id,
|
||||||
|
"t_start": min(t_all) if t_all else 0,
|
||||||
|
"t_end": max(t_all) if t_all else 0,
|
||||||
|
}
|
||||||
|
downsampled = {k: _lttb(v, LTTB_MAX_PTS) for k, v in signals.items()}
|
||||||
|
payload = json.dumps({"meta": meta, "signals": downsampled}).encode()
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with gzip.open(output_path, "wb") as f:
|
||||||
|
f.write(payload)
|
||||||
149
pipeline_runner/runner.py
Normal file
149
pipeline_runner/runner.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import GDRIVE_REMOTE, OUTPUT_DIR, TOOLS_DIR
|
||||||
|
from .processor import write_auv_json, write_usv_json
|
||||||
|
|
||||||
|
|
||||||
|
async def _emit(queue: asyncio.Queue, step: str, pct: int, msg: str = ""):
|
||||||
|
await queue.put({"step": step, "pct": pct, "msg": msg})
|
||||||
|
|
||||||
|
|
||||||
|
def _find_nav_log(ship_dir: Path) -> Path | None:
|
||||||
|
for p in ship_dir.glob("*_navigation_log.csv"):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_usbl_csv(ship_dir: Path) -> Path | None:
|
||||||
|
for p in ship_dir.glob("*_usbl.csv"):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_auvs(sub_dir: Path) -> list[str]:
|
||||||
|
"""Return AUV IDs from SUB/ subfolders (e.g. '20260416_125418_AUV010' → 'AUV010')."""
|
||||||
|
auvs = []
|
||||||
|
for d in sub_dir.iterdir():
|
||||||
|
if d.is_dir():
|
||||||
|
m = re.search(r"(AUV\d+)", d.name, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
auvs.append(m.group(1).upper())
|
||||||
|
return sorted(set(auvs))
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_session_dir(sub_dir: Path, auv_id: str) -> Path | None:
|
||||||
|
for d in sub_dir.iterdir():
|
||||||
|
if d.is_dir() and auv_id.upper() in d.name.upper():
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _run_script(script_name: str, args: list[str]) -> subprocess.CompletedProcess:
|
||||||
|
cmd = ["python3", str(TOOLS_DIR / script_name)] + args
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_pipeline(sortie_id: str, queue: asyncio.Queue) -> None:
|
||||||
|
"""Full pipeline: rclone sync → parse → process → write JSON.gz"""
|
||||||
|
raw_dir = OUTPUT_DIR / sortie_id / "raw"
|
||||||
|
proc_dir = OUTPUT_DIR / sortie_id / "processed"
|
||||||
|
proc_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Step 1: rclone sync
|
||||||
|
await _emit(queue, "sync", 0, "rclone sync en cours…")
|
||||||
|
gdrive_path = f'{GDRIVE_REMOTE}/{sortie_id}'
|
||||||
|
result = subprocess.run(
|
||||||
|
["rclone", "sync", gdrive_path, str(raw_dir), "--progress"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
await _emit(queue, "error", 0, f"rclone: {result.stderr[:200]}")
|
||||||
|
return
|
||||||
|
await _emit(queue, "sync", 50, "rclone terminé")
|
||||||
|
|
||||||
|
# Detect SHIP/SUB dirs inside raw/
|
||||||
|
ship_dir = next(raw_dir.rglob("logs/SHIP"), None)
|
||||||
|
sub_dir = next(raw_dir.rglob("logs/SUB"), None)
|
||||||
|
|
||||||
|
if not ship_dir or not ship_dir.exists():
|
||||||
|
await _emit(queue, "error", 50, "Dossier SHIP introuvable après sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 2: USV parsing
|
||||||
|
await _emit(queue, "usv_parse", 55, "Parsing logs USV…")
|
||||||
|
nav_log = _find_nav_log(ship_dir)
|
||||||
|
usbl_csv_raw = _find_usbl_csv(ship_dir)
|
||||||
|
usbl_parsed_path = proc_dir / "combined_usbl.csv"
|
||||||
|
|
||||||
|
if usbl_csv_raw:
|
||||||
|
_run_script("parse_kogger_usbl.py", [str(usbl_csv_raw), "-o", str(usbl_parsed_path)])
|
||||||
|
|
||||||
|
date_str = sortie_id.split("/")[-1][:10] if "/" in sortie_id else sortie_id[:10]
|
||||||
|
if nav_log:
|
||||||
|
write_usv_json(
|
||||||
|
nav_log_path=nav_log,
|
||||||
|
usbl_csv_path=usbl_parsed_path if usbl_parsed_path.exists() else None,
|
||||||
|
output_path=proc_dir / "usv.json.gz",
|
||||||
|
sortie_id=sortie_id,
|
||||||
|
date=date_str,
|
||||||
|
)
|
||||||
|
await _emit(queue, "usv_parse", 70, "USV OK")
|
||||||
|
|
||||||
|
# Step 3: AUV parsing
|
||||||
|
if sub_dir and sub_dir.exists():
|
||||||
|
auv_ids = _detect_auvs(sub_dir)
|
||||||
|
total = len(auv_ids) or 1
|
||||||
|
for i, auv_id in enumerate(auv_ids):
|
||||||
|
await _emit(queue, "auv_parse", 70 + int(20 * i / total), f"AUV {auv_id}…")
|
||||||
|
session_dir = _detect_session_dir(sub_dir, auv_id)
|
||||||
|
if not session_dir:
|
||||||
|
continue
|
||||||
|
mcap_out = proc_dir / f"mcap_{auv_id}.json"
|
||||||
|
_run_script("extract_mcap_signals.py", ["--session-dir", str(session_dir), "--max-pts", "0"])
|
||||||
|
# extract_mcap_signals writes to tools/../output/mcap_signals.json (hardcoded)
|
||||||
|
default_out = TOOLS_DIR.parent / "output" / "mcap_signals.json"
|
||||||
|
if not default_out.exists():
|
||||||
|
default_out = session_dir / "mcap_signals.json"
|
||||||
|
if default_out.exists():
|
||||||
|
shutil.copy(default_out, mcap_out)
|
||||||
|
if mcap_out.exists():
|
||||||
|
write_auv_json(
|
||||||
|
mcap_json_path=mcap_out,
|
||||||
|
output_path=proc_dir / f"auv_{auv_id}.json.gz",
|
||||||
|
auv_id=auv_id,
|
||||||
|
sortie_id=sortie_id,
|
||||||
|
date=date_str,
|
||||||
|
)
|
||||||
|
await _emit(queue, "write", 100, "Pipeline terminé")
|
||||||
|
|
||||||
|
|
||||||
|
def scan_sorties() -> list[dict]:
|
||||||
|
"""List available sorties on GDrive via rclone lsd (lent ~30s)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["rclone", "lsd", GDRIVE_REMOTE],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
sorties = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.strip().split(None, 4)
|
||||||
|
if len(parts) == 5:
|
||||||
|
name = parts[4].strip()
|
||||||
|
processed = (OUTPUT_DIR / name / "processed" / "usv.json.gz").exists()
|
||||||
|
sorties.append({"id": name, "processed": processed})
|
||||||
|
return sorties
|
||||||
|
|
||||||
|
|
||||||
|
def scan_sorties_local() -> list[dict]:
|
||||||
|
"""List sorties already synced locally in OUTPUT_DIR (instantané, pas rclone)."""
|
||||||
|
if not OUTPUT_DIR.exists():
|
||||||
|
return []
|
||||||
|
sorties = []
|
||||||
|
for d in sorted(OUTPUT_DIR.iterdir()):
|
||||||
|
if d.is_dir():
|
||||||
|
processed = (d / "processed" / "usv.json.gz").exists()
|
||||||
|
sorties.append({"id": d.name, "processed": processed})
|
||||||
|
return sorties
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
aiofiles==24.1.0
|
||||||
|
numpy==2.1.1
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Extract AUV signals from MCAP files: depth, PWM, state."""
|
"""Extract AUV signals from MCAP files: depth, PWM, state."""
|
||||||
import argparse, glob, json, os, sys
|
import argparse, glob, json, math, os, sys
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
@@ -26,7 +26,16 @@ def main():
|
|||||||
depth_raw = []
|
depth_raw = []
|
||||||
pwm_raw = []
|
pwm_raw = []
|
||||||
state_raw = []
|
state_raw = []
|
||||||
TOPICS = ['/mavros/imu/static_pressure', '/mavros/rc/out', '/mavros/state']
|
signals = {}
|
||||||
|
TOPICS = [
|
||||||
|
'/mavros/imu/static_pressure',
|
||||||
|
'/mavros/rc/out',
|
||||||
|
'/mavros/state',
|
||||||
|
'/mavros/imu/data',
|
||||||
|
'/mavros/altitude',
|
||||||
|
'/mavros/battery',
|
||||||
|
'/mavros/distance_sensor/hrlv_ez4_pub',
|
||||||
|
]
|
||||||
|
|
||||||
for mcap_file in mcap_files:
|
for mcap_file in mcap_files:
|
||||||
try:
|
try:
|
||||||
@@ -53,6 +62,40 @@ def main():
|
|||||||
state_raw.append({'t': t_ms, 'mode': str(ros_msg.mode), 'armed': bool(ros_msg.armed)})
|
state_raw.append({'t': t_ms, 'mode': str(ros_msg.mode), 'armed': bool(ros_msg.armed)})
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
elif topic == '/mavros/imu/data':
|
||||||
|
try:
|
||||||
|
q = ros_msg.orientation
|
||||||
|
sinr = 2*(q.w*q.x + q.y*q.z)
|
||||||
|
cosr = 1 - 2*(q.x*q.x + q.y*q.y)
|
||||||
|
roll = math.degrees(math.atan2(sinr, cosr))
|
||||||
|
sinp = 2*(q.w*q.y - q.z*q.x)
|
||||||
|
pitch = math.degrees(math.asin(max(-1, min(1, sinp))))
|
||||||
|
siny = 2*(q.w*q.z + q.x*q.y)
|
||||||
|
cosy = 1 - 2*(q.y*q.y + q.z*q.z)
|
||||||
|
yaw = math.degrees(math.atan2(siny, cosy))
|
||||||
|
signals.setdefault('pitch', []).append({'t': t_ms, 'v': pitch})
|
||||||
|
signals.setdefault('roll', []).append({'t': t_ms, 'v': roll})
|
||||||
|
signals.setdefault('yaw', []).append({'t': t_ms, 'v': yaw})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif topic == '/mavros/altitude':
|
||||||
|
try:
|
||||||
|
signals.setdefault('altitude', []).append(
|
||||||
|
{'t': t_ms, 'v': ros_msg.relative})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif topic == '/mavros/battery':
|
||||||
|
try:
|
||||||
|
signals.setdefault('battery_v', []).append(
|
||||||
|
{'t': t_ms, 'v': ros_msg.voltage})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif topic == '/mavros/distance_sensor/hrlv_ez4_pub':
|
||||||
|
try:
|
||||||
|
signals.setdefault('obstacle_dist', []).append(
|
||||||
|
{'t': t_ms, 'v': ros_msg.range})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Skip {os.path.basename(mcap_file)}: {e}")
|
print(f" Skip {os.path.basename(mcap_file)}: {e}")
|
||||||
|
|
||||||
@@ -69,7 +112,8 @@ def main():
|
|||||||
pwm_samples = sample(pwm_raw, args.max_pts)
|
pwm_samples = sample(pwm_raw, args.max_pts)
|
||||||
state = state_raw # events, keep all
|
state = state_raw # events, keep all
|
||||||
|
|
||||||
all_t = [p['t'] for p in depth_raw + pwm_raw + state_raw]
|
signals_flat = [pt for pts in signals.values() for pt in pts]
|
||||||
|
all_t = [p['t'] for p in depth_raw + pwm_raw + state_raw + signals_flat]
|
||||||
t_min = min(all_t) if all_t else 0
|
t_min = min(all_t) if all_t else 0
|
||||||
t_max = max(all_t) if all_t else 0
|
t_max = max(all_t) if all_t else 0
|
||||||
|
|
||||||
@@ -94,6 +138,7 @@ def main():
|
|||||||
'depth': depth,
|
'depth': depth,
|
||||||
'pwm_auv': {'channels': channels, 'samples': pwm_samples},
|
'pwm_auv': {'channels': channels, 'samples': pwm_samples},
|
||||||
'state': state,
|
'state': state,
|
||||||
|
**{k: sample(v, args.max_pts) for k, v in signals.items()},
|
||||||
}
|
}
|
||||||
|
|
||||||
outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')
|
outdir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'output')
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
height: 100vh; overflow: hidden;
|
height: 100vh; overflow: hidden;
|
||||||
font-family: monospace; background: #1a1a2e; color: #e0e0e0;
|
font-family: monospace; background: #1a1a2e; color: #e0e0e0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 36px 40px 1fr 54px 1fr;
|
grid-template-rows: 36px 40px minmax(200px, 1fr) 54px 28px 1fr;
|
||||||
}
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
/* Row 0: datebar */
|
/* Row 0: datebar */
|
||||||
#datebar {
|
#datebar {
|
||||||
background: #0d0d20; border-bottom: 1px solid #0f3460;
|
background: #0d0d20; border-bottom: 1px solid #0f3460;
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
}
|
}
|
||||||
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
|
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
|
||||||
#stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
#stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
#layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
#layer-toggles { display: none; }
|
||||||
.layer-btn {
|
.layer-btn {
|
||||||
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
|
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
|
||||||
border-radius: 2px; border: 1px solid; background: transparent;
|
border-radius: 2px; border: 1px solid; background: transparent;
|
||||||
@@ -60,6 +61,20 @@
|
|||||||
/* Row 2: map */
|
/* Row 2: map */
|
||||||
#map { position: relative; min-height: 0; }
|
#map { position: relative; min-height: 0; }
|
||||||
|
|
||||||
|
/* No-data overlay */
|
||||||
|
#no-data-overlay {
|
||||||
|
position: absolute; inset: 0; z-index: 2000;
|
||||||
|
background: rgba(10,10,26,0.82);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#no-data-overlay.hidden { display: none; }
|
||||||
|
#no-data-overlay .nodata-msg {
|
||||||
|
font-size: 18px; color: #555; font-family: monospace; text-align: center;
|
||||||
|
border: 1px solid #1a1a3e; padding: 20px 32px; background: #0a0a1a;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* USBL panel over map */
|
/* USBL panel over map */
|
||||||
#usbl-panel {
|
#usbl-panel {
|
||||||
position: absolute; top: 10px; left: 50px; z-index: 1000;
|
position: absolute; top: 10px; left: 50px; z-index: 1000;
|
||||||
@@ -73,6 +88,11 @@
|
|||||||
display: inline-block; background: #ff8800; color: #1a1a2e;
|
display: inline-block; background: #ff8800; color: #1a1a2e;
|
||||||
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
|
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
/* layer toggles bottom-right on map */
|
||||||
|
#map-layer-toggles {
|
||||||
|
position: absolute; bottom: 10px; right: 10px; z-index: 1000;
|
||||||
|
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
|
||||||
|
}
|
||||||
/* legend over map */
|
/* legend over map */
|
||||||
#legend {
|
#legend {
|
||||||
position: absolute; bottom: 10px; left: 10px; z-index: 1000;
|
position: absolute; bottom: 10px; left: 10px; z-index: 1000;
|
||||||
@@ -115,15 +135,23 @@
|
|||||||
}
|
}
|
||||||
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
|
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
|
||||||
|
|
||||||
/* Row 4: 2×2 charts grid */
|
/* Row 4: scroll container with 2x2 charts + USV/AUV panels */
|
||||||
#graphs-section {
|
#graphs-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #0a0a1a;
|
||||||
|
}
|
||||||
|
#charts-4grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
background: #0a0a1a;
|
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
min-height: 0; overflow: hidden;
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
.chart-wrap {
|
.chart-wrap {
|
||||||
background: #12122a;
|
background: #12122a;
|
||||||
@@ -138,6 +166,21 @@
|
|||||||
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
|
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
|
||||||
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
|
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* Tab navigation */
|
||||||
|
#panels-tabs {
|
||||||
|
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
|
||||||
|
display: flex; gap: 0; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.panel-tab {
|
||||||
|
font-family: monospace; font-size: 11px; padding: 6px 16px; cursor: pointer;
|
||||||
|
border: none; border-right: 1px solid #0f3460; background: transparent;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.panel-tab.active { background: #16213e; color: #e0e0e0; }
|
||||||
|
.panel-tab:hover:not(.active) { background: #0f3460; color: #a0c4ff; }
|
||||||
|
.panel-section { display: none; }
|
||||||
|
.panel-section.active { display: block; }
|
||||||
|
|
||||||
/* Pipeline overlay */
|
/* Pipeline overlay */
|
||||||
#pipeline-overlay {
|
#pipeline-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -169,6 +212,39 @@
|
|||||||
border-radius: 2px; flex-shrink: 0;
|
border-radius: 2px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
|
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
|
||||||
|
|
||||||
|
#sortie-select {
|
||||||
|
background: #0f3460; border: 1px solid #e94560; color: #e0e0e0;
|
||||||
|
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
|
||||||
|
cursor: pointer; max-width: 160px;
|
||||||
|
}
|
||||||
|
#btn-sync {
|
||||||
|
background: #0f3460; border: 1px solid #e94560; color: #e94560;
|
||||||
|
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
#btn-sync:hover { background: #e94560; color: #1a1a2e; }
|
||||||
|
#btn-sync:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
#sync-progress {
|
||||||
|
font-size: 10px; color: #06d6a0; flex: 1;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
|
||||||
|
padding: 4px 14px; font-size: 11px; font-weight: bold; color: #e94560;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.auv-tab {
|
||||||
|
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
|
||||||
|
border: 1px solid #0f3460; background: transparent; color: #a0c4ff; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.auv-tab.active { background: #0f3460; color: #e0e0e0; }
|
||||||
|
.graphs-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px;
|
||||||
|
padding: 4px 8px; background: #12122a;
|
||||||
|
}
|
||||||
|
.graph-cell { height: 130px; background: #1a1a2e; }
|
||||||
|
.graph-cell.wide { grid-column: span 2; height: 130px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -177,8 +253,8 @@
|
|||||||
<div id="datebar">
|
<div id="datebar">
|
||||||
<input type="date" id="date-picker">
|
<input type="date" id="date-picker">
|
||||||
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
|
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
|
||||||
<span id="mission-label" class="no-data">Chargement...</span>
|
<span id="mission-label" class="no-data">—</span>
|
||||||
<span id="load-status"></span>
|
<span id="load-status" style="font-size:10px;color:#888;font-style:italic;flex:1;"></span>
|
||||||
<button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button>
|
<button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,17 +307,24 @@ flowchart LR
|
|||||||
<!-- Row 1: Header -->
|
<!-- Row 1: Header -->
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<span id="title">COSMA NAV v6</span>
|
<span id="title">COSMA NAV v6</span>
|
||||||
<span id="stats">Chargement...</span>
|
<span id="stats" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#a0c4ff;"></span>
|
||||||
<div id="layer-toggles">
|
<select id="sortie-select"><option value="">Chargement Gdrive (~30s)...</option></select>
|
||||||
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV</button>
|
<span id="mission-label-header" style="font-size:11px;font-family:monospace;padding:2px 8px;color:#555;white-space:nowrap;"></span>
|
||||||
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">AUV</button>
|
<button id="btn-sync" disabled>Sync & Process</button>
|
||||||
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
|
<span id="sync-progress"></span>
|
||||||
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Map -->
|
<!-- Row 2: Map -->
|
||||||
<div id="map">
|
<div id="map">
|
||||||
|
<div id="no-data-overlay">
|
||||||
|
<div class="nodata-msg">⬇ Sélectionnez une sortie pour charger les données</div>
|
||||||
|
</div>
|
||||||
|
<div id="map-layer-toggles">
|
||||||
|
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">⛵ USV</button>
|
||||||
|
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">🛥 AUV</button>
|
||||||
|
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
|
||||||
|
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
|
||||||
|
</div>
|
||||||
<div id="legend"></div>
|
<div id="legend"></div>
|
||||||
<div id="usbl-panel">
|
<div id="usbl-panel">
|
||||||
<div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist">—</span></div>
|
<div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist">—</span></div>
|
||||||
@@ -255,10 +338,10 @@ flowchart LR
|
|||||||
<!-- Row 3: Controls -->
|
<!-- Row 3: Controls -->
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<div id="ctrl-row1">
|
<div id="ctrl-row1">
|
||||||
<span id="ctrl-label">t</span>
|
<span id="ctrl-label" style="font-size:10px;color:#666;white-space:nowrap;">Heure:</span>
|
||||||
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
|
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
|
||||||
<span id="cursor-time">—</span>
|
<span id="cursor-time" style="font-size:11px;color:#e0e0e0;white-space:nowrap;min-width:70px;">—</span>
|
||||||
<label for="trail-select">trail</label>
|
<label for="trail-select" style="font-size:10px;color:#666;white-space:nowrap;">Trail:</label>
|
||||||
<select id="trail-select">
|
<select id="trail-select">
|
||||||
<option value="10000">10s</option>
|
<option value="10000">10s</option>
|
||||||
<option value="30000">30s</option>
|
<option value="30000">30s</option>
|
||||||
@@ -270,14 +353,31 @@ flowchart LR
|
|||||||
</select>
|
</select>
|
||||||
<button id="btn-viewall" onclick="viewAll()">View all</button>
|
<button id="btn-viewall" onclick="viewAll()">View all</button>
|
||||||
<button id="btn-play">▶</button>
|
<button id="btn-play">▶</button>
|
||||||
|
<label for="window-select" style="font-size:10px;color:#666;white-space:nowrap;">Window:</label>
|
||||||
|
<select id="window-select" style="background:#0f3460;border:1px solid #a855f7;color:#a855f7;font-family:monospace;font-size:11px;padding:2px 6px;border-radius:2px;cursor:pointer;">
|
||||||
|
<option value="10000">10s</option>
|
||||||
|
<option value="30000">30s</option>
|
||||||
|
<option value="60000" selected>60s</option>
|
||||||
|
<option value="300000">5min</option>
|
||||||
|
<option value="900000">15min</option>
|
||||||
|
<option value="0">ALL</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="ctrl-row2">
|
<div id="ctrl-row2">
|
||||||
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">—</span>
|
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 4: 2×2 Plotly charts -->
|
<!-- Row 4: tabs + panels -->
|
||||||
|
<div id="panels-tabs">
|
||||||
|
<button class="panel-tab active" onclick="switchTab('charts')">🗺 Charts globaux</button>
|
||||||
|
<button class="panel-tab" onclick="switchTab('usv')">⛵ USV</button>
|
||||||
|
<button class="panel-tab" onclick="switchTab('auv')">🛥 AUV <span id="auv-tabs"></span></button>
|
||||||
|
</div>
|
||||||
<div id="graphs-section">
|
<div id="graphs-section">
|
||||||
|
<!-- Tab: Charts globaux -->
|
||||||
|
<div id="panel-charts" class="panel-section active">
|
||||||
|
<div id="charts-4grid">
|
||||||
<div class="chart-wrap">
|
<div class="chart-wrap">
|
||||||
<div class="chart-title">Depth AUV (m)</div>
|
<div class="chart-title">Depth AUV (m)</div>
|
||||||
<div class="plotly-wrap"><div id="chart-depth"></div></div>
|
<div class="plotly-wrap"><div id="chart-depth"></div></div>
|
||||||
@@ -295,6 +395,37 @@ flowchart LR
|
|||||||
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
|
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab: USV -->
|
||||||
|
<div id="panel-usv" class="panel-section hidden">
|
||||||
|
<div class="panel-header">⛵ USV</div>
|
||||||
|
<div class="graphs-grid" id="usv-graphs">
|
||||||
|
<div class="graph-cell" id="usv-yaw"></div>
|
||||||
|
<div class="graph-cell" id="usv-heading"></div>
|
||||||
|
<div class="graph-cell" id="usv-batt"></div>
|
||||||
|
<div class="graph-cell" id="usv-gps"></div>
|
||||||
|
<div class="graph-cell" id="usv-usbl-dist"></div>
|
||||||
|
<div class="graph-cell" id="usv-usbl-angle"></div>
|
||||||
|
<div class="graph-cell wide" id="usv-motors"></div>
|
||||||
|
<div class="graph-cell wide" id="usv-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab: AUV -->
|
||||||
|
<div id="panel-auv" class="panel-section hidden">
|
||||||
|
<div class="panel-header" id="auv-panel-header">🛥 AUV</div>
|
||||||
|
<div class="graphs-grid" id="auv-graphs">
|
||||||
|
<div class="graph-cell" id="auv-pry"></div>
|
||||||
|
<div class="graph-cell" id="auv-depth"></div>
|
||||||
|
<div class="graph-cell" id="auv-alt"></div>
|
||||||
|
<div class="graph-cell" id="auv-obs"></div>
|
||||||
|
<div class="graph-cell" id="auv-usbl-dist"></div>
|
||||||
|
<div class="graph-cell" id="auv-usbl-angle"></div>
|
||||||
|
<div class="graph-cell" id="auv-batt"></div>
|
||||||
|
<div class="graph-cell" id="auv-status"></div>
|
||||||
|
<div class="graph-cell wide" id="auv-motors"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
@@ -302,7 +433,12 @@ flowchart LR
|
|||||||
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// == Constants ==
|
// == Constants ==
|
||||||
const API = 'http://192.168.0.83:8766';
|
const API = (location.hostname === 'laboratoire.freeboxos.fr')
|
||||||
|
? '/cosma-pK8j876lkj-api'
|
||||||
|
: 'http://192.168.0.83:8766';
|
||||||
|
const API2 = (location.hostname === 'laboratoire.freeboxos.fr')
|
||||||
|
? '/cosma-pK8j876lkj-pipe'
|
||||||
|
: 'http://192.168.0.83:8767';
|
||||||
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
|
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
|
||||||
const AUV_COLOR = '#ff8800';
|
const AUV_COLOR = '#ff8800';
|
||||||
const PLOTLY_LAYOUT = {
|
const PLOTLY_LAYOUT = {
|
||||||
@@ -438,6 +574,14 @@ function populatePlotlyCharts() {
|
|||||||
Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
|
Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
|
||||||
Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
|
Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
|
||||||
Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
|
Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
|
||||||
|
// Force resize: Plotly can't compute size on hidden/zero-size divs at init time
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el._fullLayout) Plotly.Plots.resize(el);
|
||||||
|
});
|
||||||
|
setupSyncedZoom(['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cursor line on all Plotly charts
|
// Update cursor line on all Plotly charts
|
||||||
@@ -533,6 +677,7 @@ function applyTrailAndCursor() {
|
|||||||
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
|
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
|
||||||
|
|
||||||
updateChartsCursor();
|
updateChartsCursor();
|
||||||
|
if (tNow) updateCursor(tNow / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// == Cursor slider ==
|
// == Cursor slider ==
|
||||||
@@ -540,13 +685,16 @@ function initCursorSlider() {
|
|||||||
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
|
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
|
||||||
const el = document.getElementById('cursor-slider');
|
const el = document.getElementById('cursor-slider');
|
||||||
cursorSlider = noUiSlider.create(el, {
|
cursorSlider = noUiSlider.create(el, {
|
||||||
start: [tMin],
|
start: [tMax], // BUG1 FIX: start at end so trail window shows recent data
|
||||||
range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
|
range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
|
||||||
step: 1000,
|
step: 1000,
|
||||||
});
|
});
|
||||||
cursorSlider.on('update', (values) => {
|
cursorSlider.on('update', (values) => {
|
||||||
tNow = Math.round(+values[0]);
|
tNow = Math.round(+values[0]);
|
||||||
document.getElementById('cursor-time').textContent = fmtMs(tNow);
|
// Show HH:MM:SS for slider label
|
||||||
|
const d = new Date(tNow);
|
||||||
|
const hms = d.toISOString().slice(11,19);
|
||||||
|
document.getElementById('cursor-time').textContent = `Heure: ${hms}`;
|
||||||
applyTrailAndCursor();
|
applyTrailAndCursor();
|
||||||
});
|
});
|
||||||
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
|
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
|
||||||
@@ -587,7 +735,8 @@ function clearMapLayers() {
|
|||||||
|
|
||||||
// == Load data for a given date ==
|
// == Load data for a given date ==
|
||||||
async function loadDate(date) {
|
async function loadDate(date) {
|
||||||
setStatus('Chargement...');
|
setStatus('chargement...');
|
||||||
|
showNoDataOverlay(true);
|
||||||
clearMapLayers();
|
clearMapLayers();
|
||||||
allPoints = [];
|
allPoints = [];
|
||||||
usblPoints = [];
|
usblPoints = [];
|
||||||
@@ -603,7 +752,18 @@ async function loadDate(date) {
|
|||||||
|
|
||||||
const dateStr = date.replace(/-/g,''); // YYYYMMDD
|
const dateStr = date.replace(/-/g,''); // YYYYMMDD
|
||||||
|
|
||||||
const fetches = missions.map(async mission => {
|
// BUG3 FIX: filter missions using availableDates to avoid fetching all missions' dives
|
||||||
|
const dateEntry = availableDates.find(d => d.date === date);
|
||||||
|
const knownMissions = dateEntry && dateEntry.missions && dateEntry.missions.length
|
||||||
|
? dateEntry.missions
|
||||||
|
: null;
|
||||||
|
// BUG3b FIX: API data-dates uses #71-golrest but /api/missions uses _71-golrest — normalize for compare
|
||||||
|
const normalize = id => (id || '').replace(/^[#_]/, '').toLowerCase();
|
||||||
|
const missionsToFetch = knownMissions
|
||||||
|
? missions.filter(m => knownMissions.some(km => normalize(m.id) === normalize(km)))
|
||||||
|
: missions;
|
||||||
|
|
||||||
|
const fetches = (missionsToFetch.length > 0 ? missionsToFetch : missions).map(async mission => {
|
||||||
const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`);
|
const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`);
|
||||||
const dives = await dResp.json();
|
const dives = await dResp.json();
|
||||||
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
|
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
|
||||||
@@ -623,7 +783,7 @@ async function loadDate(date) {
|
|||||||
|
|
||||||
for (const { mission, dives } of missionDives) {
|
for (const { mission, dives } of missionDives) {
|
||||||
for (const dive of dives) {
|
for (const dive of dives) {
|
||||||
allFetches.push(loadDiveData(mission.id, dive.id));
|
allFetches.push(loadDiveData(mission.id, dive.id, date));
|
||||||
totalShip += dive.ship_session_count || 0;
|
totalShip += dive.ship_session_count || 0;
|
||||||
totalSub += dive.sub_session_count || 0;
|
totalSub += dive.sub_session_count || 0;
|
||||||
}
|
}
|
||||||
@@ -645,7 +805,7 @@ async function loadDate(date) {
|
|||||||
];
|
];
|
||||||
tMin = Math.min(...allTimes);
|
tMin = Math.min(...allTimes);
|
||||||
tMax = Math.max(...allTimes);
|
tMax = Math.max(...allTimes);
|
||||||
tNow = tMin;
|
tNow = tMax; // BUG1 FIX: start at end so trail window [tMax-trailMs, tMax] contains data
|
||||||
|
|
||||||
// Fit map
|
// Fit map
|
||||||
const allLats = [
|
const allLats = [
|
||||||
@@ -677,6 +837,7 @@ async function loadDate(date) {
|
|||||||
populatePlotlyCharts();
|
populatePlotlyCharts();
|
||||||
initCursorSlider();
|
initCursorSlider();
|
||||||
applyTrailAndCursor();
|
applyTrailAndCursor();
|
||||||
|
showNoDataOverlay(false);
|
||||||
|
|
||||||
document.getElementById('title').textContent = `COSMA v6 — ${date}`;
|
document.getElementById('title').textContent = `COSMA v6 — ${date}`;
|
||||||
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
|
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
|
||||||
@@ -687,45 +848,60 @@ async function loadDate(date) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDiveData(missionId, diveId) {
|
async function loadDiveData(missionId, diveId, filterDate) {
|
||||||
try {
|
try {
|
||||||
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
|
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
|
||||||
const sessions = await sResp.json();
|
const sessions = await sResp.json();
|
||||||
const sessionFetches = [];
|
|
||||||
|
|
||||||
// Ship sessions
|
// T3 FIX: filter ship sessions by date if provided (session.start = "YYYY-MM-DD_HH-MM-SS")
|
||||||
if (sessions.ship) {
|
const shipSessions = (sessions.ship || []).filter(sess => {
|
||||||
sessions.ship.forEach(sess => {
|
if (!filterDate) return true;
|
||||||
sessionFetches.push(loadShipSession(missionId, diveId, sess.id));
|
// sess.start: "2026-04-17_07-43-23" → compare first 10 chars "2026-04-17" to filterDate
|
||||||
|
return !sess.start || sess.start.slice(0, 10) === filterDate;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
// Sub sessions
|
// Phase 1: track-only fetches (needed for map polylines) — batch by 4
|
||||||
if (sessions.sub) {
|
const trackFetches = shipSessions.map(sess =>
|
||||||
sessions.sub.forEach(sess => {
|
fetchShipTrack(missionId, diveId, sess.id)
|
||||||
sessionFetches.push(loadSubSession(missionId, diveId, sess.id));
|
);
|
||||||
});
|
await _batchedAll(trackFetches, 4);
|
||||||
}
|
|
||||||
await Promise.all(sessionFetches);
|
// Phase 2: series + sub sessions in background (charts data)
|
||||||
|
const seriesFetches = [
|
||||||
|
...shipSessions.map(sess => fetchShipSeries(missionId, diveId, sess.id)),
|
||||||
|
...(sessions.sub || []).map(sess => loadSubSession(missionId, diveId, sess.id)),
|
||||||
|
];
|
||||||
|
await _batchedAll(seriesFetches, 4);
|
||||||
} catch(e) { console.warn('loadDiveData error', diveId, e); }
|
} catch(e) { console.warn('loadDiveData error', diveId, e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadShipSession(missionId, diveId, sessionId) {
|
// Run an array of promise-factories in batches of size n
|
||||||
|
async function _batchedAll(fns, n) {
|
||||||
|
for (let i = 0; i < fns.length; i += n) {
|
||||||
|
await Promise.all(fns.slice(i, i + n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchShipTrack(missionId, diveId, sessionId) {
|
||||||
try {
|
try {
|
||||||
const [trackResp, seriesResp] = await Promise.all([
|
const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`,
|
||||||
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`),
|
{ signal: AbortSignal.timeout(20000) });
|
||||||
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`),
|
if (!resp.ok) { console.warn('fetchShipTrack fail', sessionId, resp.status); return; }
|
||||||
]);
|
const d = await resp.json();
|
||||||
if (trackResp.ok) {
|
|
||||||
const d = await trackResp.json();
|
|
||||||
const pts = (d.points||[]).map(p => ({
|
const pts = (d.points||[]).map(p => ({
|
||||||
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
|
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
|
||||||
heading: p.heading || null, source: sessionId
|
heading: p.heading || null, source: sessionId
|
||||||
}));
|
}));
|
||||||
allPoints.push(...pts);
|
allPoints.push(...pts);
|
||||||
|
} catch(e) { console.warn('fetchShipTrack error', sessionId, e.name); }
|
||||||
}
|
}
|
||||||
if (seriesResp.ok) {
|
|
||||||
const d = await seriesResp.json();
|
async function fetchShipSeries(missionId, diveId, sessionId) {
|
||||||
// USV PWM: M1..M8
|
try {
|
||||||
|
const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`,
|
||||||
|
{ signal: AbortSignal.timeout(20000) });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const d = await resp.json();
|
||||||
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
|
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
|
||||||
motorKeys.forEach((k, i) => {
|
motorKeys.forEach((k, i) => {
|
||||||
const pts = d[k];
|
const pts = d[k];
|
||||||
@@ -733,24 +909,48 @@ async function loadShipSession(missionId, diveId, sessionId) {
|
|||||||
pwmUsvTraces.push({
|
pwmUsvTraces.push({
|
||||||
x: pts.map(p => new Date(isoToMs(p.t))),
|
x: pts.map(p => new Date(isoToMs(p.t))),
|
||||||
y: pts.map(p => p.v),
|
y: pts.map(p => p.v),
|
||||||
name: k,
|
name: k, type: 'scatter', mode: 'lines',
|
||||||
type: 'scatter', mode: 'lines',
|
|
||||||
line: { color: COLORS[i % COLORS.length], width: 1 },
|
line: { color: COLORS[i % COLORS.length], width: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
} catch(e) { console.warn('fetchShipSeries error', sessionId, e.name); }
|
||||||
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function loadSubSession(missionId, diveId, sessionId) {
|
async function loadSubSession(missionId, diveId, sessionId) {
|
||||||
try {
|
// Both usbl_track and series can hang — use parallel with timeout, non-blocking
|
||||||
const [seriesResp, usblResp] = await Promise.all([
|
const [usblResult, seriesResult] = await Promise.allSettled([
|
||||||
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`),
|
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`,
|
||||||
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`),
|
{ signal: AbortSignal.timeout(25000) }),
|
||||||
|
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`,
|
||||||
|
{ signal: AbortSignal.timeout(25000) }),
|
||||||
]);
|
]);
|
||||||
if (seriesResp.ok) {
|
if (usblResult.status === 'fulfilled' && usblResult.value.ok) {
|
||||||
const d = await seriesResp.json();
|
try {
|
||||||
// Depth trace
|
const d = await usblResult.value.json();
|
||||||
|
const pts = (d.points||[]);
|
||||||
|
if (pts.length) {
|
||||||
|
usblPoints.push(...pts.map(p => ({
|
||||||
|
t_ms: unixToMs(p.t),
|
||||||
|
auv_lat: p.lat, auv_lon: p.lon,
|
||||||
|
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
|
||||||
|
})));
|
||||||
|
usblDistTraces.push({
|
||||||
|
x: pts.map(p => new Date(unixToMs(p.t))),
|
||||||
|
y: pts.map(p => p.distance_m),
|
||||||
|
name: sessionId,
|
||||||
|
type: 'scatter', mode: 'lines',
|
||||||
|
line: { color: '#a855f7', width: 1.5 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.warn('loadSubSession usbl json error', sessionId, e); }
|
||||||
|
} else {
|
||||||
|
console.warn('loadSubSession usbl timeout/fail', sessionId,
|
||||||
|
usblResult.status === 'rejected' ? usblResult.reason.name : usblResult.value?.status);
|
||||||
|
}
|
||||||
|
if (seriesResult.status === 'fulfilled' && seriesResult.value.ok) {
|
||||||
|
try {
|
||||||
|
const d = await seriesResult.value.json();
|
||||||
if (d.depth && d.depth.length) {
|
if (d.depth && d.depth.length) {
|
||||||
depthTraces.push({
|
depthTraces.push({
|
||||||
x: d.depth.map(p => new Date(unixToMs(p.t))),
|
x: d.depth.map(p => new Date(unixToMs(p.t))),
|
||||||
@@ -760,7 +960,6 @@ async function loadSubSession(missionId, diveId, sessionId) {
|
|||||||
line: { color: '#06d6a0', width: 1.5 },
|
line: { color: '#06d6a0', width: 1.5 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// AUV motors: m1..m8
|
|
||||||
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
|
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
|
||||||
motorKeys.forEach((k, i) => {
|
motorKeys.forEach((k, i) => {
|
||||||
const pts = d[k];
|
const pts = d[k];
|
||||||
@@ -773,28 +972,11 @@ async function loadSubSession(missionId, diveId, sessionId) {
|
|||||||
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
|
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} catch(e) { console.warn('loadSubSession series json error', sessionId, e); }
|
||||||
|
} else {
|
||||||
|
console.warn('loadSubSession series timeout/fail', sessionId,
|
||||||
|
seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status);
|
||||||
}
|
}
|
||||||
if (usblResp.ok) {
|
|
||||||
const d = await usblResp.json();
|
|
||||||
const pts = (d.points||[]);
|
|
||||||
if (pts.length) {
|
|
||||||
// Build usblPoints — API fields: t(unix s), lat/lon(AUV), usv_lat/usv_lon, distance_m, azimuth_deg, snr
|
|
||||||
usblPoints.push(...pts.map(p => ({
|
|
||||||
t_ms: unixToMs(p.t),
|
|
||||||
auv_lat: p.lat, auv_lon: p.lon,
|
|
||||||
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
|
|
||||||
})));
|
|
||||||
// USBL distance trace
|
|
||||||
usblDistTraces.push({
|
|
||||||
x: pts.map(p => new Date(unixToMs(p.t))),
|
|
||||||
y: pts.map(p => p.distance_m),
|
|
||||||
name: sessionId,
|
|
||||||
type: 'scatter', mode: 'lines',
|
|
||||||
line: { color: '#a855f7', width: 1.5 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(e) { console.warn('loadSubSession error', sessionId, e); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
@@ -857,6 +1039,37 @@ function datePickerToday() {
|
|||||||
loadDate(today);
|
loadDate(today);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// == Tab switching ==
|
||||||
|
function switchTab(name) {
|
||||||
|
['charts','usv','auv'].forEach(t => {
|
||||||
|
const p = document.getElementById('panel-'+t);
|
||||||
|
if (p) p.classList.toggle('active', t === name);
|
||||||
|
if (p) p.classList.toggle('hidden', t !== name);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.panel-tab').forEach((btn, i) => {
|
||||||
|
const names = ['charts','usv','auv'];
|
||||||
|
btn.classList.toggle('active', names[i] === name);
|
||||||
|
});
|
||||||
|
// BUG1 FIX: resize Plotly charts when tab becomes visible
|
||||||
|
if (name === 'charts') {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el._fullLayout) Plotly.Plots.resize(el);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// == No-data overlay ==
|
||||||
|
function showNoDataOverlay(show) {
|
||||||
|
const el = document.getElementById('no-data-overlay');
|
||||||
|
if (el) el.classList.toggle('hidden', !show);
|
||||||
|
// Show/hide layer toggles
|
||||||
|
const lt = document.getElementById('map-layer-toggles');
|
||||||
|
if (lt) lt.style.display = show ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
// == Pipeline overlay ==
|
// == Pipeline overlay ==
|
||||||
let _pipelineRendered = false;
|
let _pipelineRendered = false;
|
||||||
function togglePipeline() {
|
function togglePipeline() {
|
||||||
@@ -872,6 +1085,347 @@ function hidePipelineOnBackdrop(e) {
|
|||||||
}
|
}
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
|
||||||
|
|
||||||
|
// == Task 8: USV rendering helpers ==
|
||||||
|
function _pts(sig) {
|
||||||
|
if (!sig || !sig.length) return [[], []];
|
||||||
|
return [sig.map(p => new Date(p.t * 1000)), sig.map(p => p.v)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLOTLY_LAYOUT_BASE = {
|
||||||
|
margin: {l:40, r:8, t:20, b:30},
|
||||||
|
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
|
||||||
|
font: {color: '#e0e0e0', size: 9, family: 'monospace'},
|
||||||
|
xaxis: {color: '#555', gridcolor: '#1e1e3a', type: 'date'},
|
||||||
|
yaxis: {color: '#555', gridcolor: '#1e1e3a'},
|
||||||
|
showlegend: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _layout(title, yLabel) {
|
||||||
|
const l = JSON.parse(JSON.stringify(PLOTLY_LAYOUT_BASE));
|
||||||
|
l.title = {text: title, font: {size: 9, color: '#888'}};
|
||||||
|
if (yLabel) l.yaxis.title = {text: yLabel, font: {size: 8}};
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUSV(signals) {
|
||||||
|
const cfg = {responsive: true, displayModeBar: false};
|
||||||
|
|
||||||
|
const [yt, yv] = _pts(signals.Yaw);
|
||||||
|
Plotly.react('usv-yaw', [{x:yt, y:yv, type:'scatter', mode:'lines', line:{color:'#00b4d8',width:1}, name:'Yaw'}], _layout('Yaw','°'), cfg);
|
||||||
|
|
||||||
|
const [ht, hv] = _pts(signals.Heading);
|
||||||
|
Plotly.react('usv-heading', [{x:ht, y:hv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1}}], _layout('Heading','°'), cfg);
|
||||||
|
|
||||||
|
const [bt, bv] = _pts(signals.BattVoltage);
|
||||||
|
Plotly.react('usv-batt', [{x:bt, y:bv, type:'scatter', mode:'lines', line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
|
||||||
|
|
||||||
|
const [gt, gv] = _pts(signals.gps_fix);
|
||||||
|
Plotly.react('usv-gps', [{x:gt, y:gv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('GPS fix'), cfg);
|
||||||
|
|
||||||
|
const [dt, dv] = _pts(signals.usbl_dist);
|
||||||
|
Plotly.react('usv-usbl-dist', [{x:dt, y:dv, type:'scatter', mode:'lines', line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
|
||||||
|
|
||||||
|
const [at, av] = _pts(signals.usbl_angle);
|
||||||
|
Plotly.react('usv-usbl-angle', [{x:at, y:av, type:'scatter', mode:'lines', line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
|
||||||
|
|
||||||
|
const motorColorsUSV = ['#ef476f','#ff8800'];
|
||||||
|
const motorTracesUSV = ['M1','M2'].map((mk,i) => {
|
||||||
|
const [t,v] = _pts(signals[mk]);
|
||||||
|
return {x:t,y:v,type:'scatter',mode:'lines',name:mk,line:{color:motorColorsUSV[i],width:1}};
|
||||||
|
});
|
||||||
|
Plotly.react('usv-motors', motorTracesUSV,
|
||||||
|
Object.assign(_layout('Motors USV','cmd'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
|
||||||
|
|
||||||
|
const armPts = _pts(signals.Armed);
|
||||||
|
const modePts = _pts(signals.Mode);
|
||||||
|
const statusTraces = [];
|
||||||
|
if (armPts[0].length) statusTraces.push({x:armPts[0], y:armPts[1], name:'Armed', type:'scatter', mode:'lines', line:{color:'#ffd166',width:1,shape:'hv'}});
|
||||||
|
if (modePts[0].length) statusTraces.push({x:modePts[0], y:modePts[1], name:'Mode', type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}});
|
||||||
|
Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
|
||||||
|
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setupSyncedZoom(['usv-yaw','usv-heading','usv-batt','usv-gps','usv-usbl-dist','usv-usbl-angle','usv-motors','usv-status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 9: AUV rendering + tabs ==
|
||||||
|
let _currentSortieId = null;
|
||||||
|
|
||||||
|
async function loadAuvTabs(sortieId) {
|
||||||
|
const resp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auvs`);
|
||||||
|
const auvs = await resp.json();
|
||||||
|
const tabsEl = document.getElementById('auv-tabs');
|
||||||
|
tabsEl.innerHTML = '';
|
||||||
|
auvs.forEach((auv, i) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'auv-tab' + (i === 0 ? ' active' : '');
|
||||||
|
btn.textContent = auv;
|
||||||
|
btn.onclick = async () => {
|
||||||
|
document.querySelectorAll('.auv-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auv}`);
|
||||||
|
const data = await r.json();
|
||||||
|
renderAUV(data.signals);
|
||||||
|
};
|
||||||
|
tabsEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
if (auvs.length > 0) {
|
||||||
|
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auvs[0]}`);
|
||||||
|
const data = await r.json();
|
||||||
|
renderAUV(data.signals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAUV(signals) {
|
||||||
|
const cfg = {responsive: true, displayModeBar: false};
|
||||||
|
|
||||||
|
const pryTraces = [
|
||||||
|
['pitch', '#ef476f'], ['roll', '#06d6a0'], ['yaw', '#00b4d8']
|
||||||
|
].map(([k, c]) => { const [t,v]=_pts(signals[k]); return {x:t,y:v,name:k,type:'scatter',mode:'lines',line:{color:c,width:1}}; });
|
||||||
|
Plotly.react('auv-pry', pryTraces, Object.assign(_layout('Pitch/Roll/Yaw','°'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',x:0,y:1}}), cfg);
|
||||||
|
|
||||||
|
const [dpt, dpv] = _pts(signals.depth);
|
||||||
|
Plotly.react('auv-depth', [{x:dpt,y:dpv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('Depth','m'), cfg);
|
||||||
|
|
||||||
|
const [alt, alv] = _pts(signals.altitude);
|
||||||
|
Plotly.react('auv-alt', [{x:alt,y:alv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Altitude','m'), cfg);
|
||||||
|
|
||||||
|
const [obt, obv] = _pts(signals.obstacle_dist);
|
||||||
|
Plotly.react('auv-obs', [{x:obt,y:obv,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('Obstacle','m'), cfg);
|
||||||
|
|
||||||
|
const [udt, udv] = _pts(signals.usbl_dist);
|
||||||
|
Plotly.react('auv-usbl-dist', [{x:udt,y:udv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
|
||||||
|
|
||||||
|
const [uat, uav] = _pts(signals.usbl_angle);
|
||||||
|
Plotly.react('auv-usbl-angle', [{x:uat,y:uav,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
|
||||||
|
|
||||||
|
const [bbt, bbv] = _pts(signals.battery_v);
|
||||||
|
Plotly.react('auv-batt', [{x:bbt,y:bbv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
|
||||||
|
|
||||||
|
const [stt, stv] = _pts(signals.arm_status);
|
||||||
|
Plotly.react('auv-status', [{x:stt,y:stv,type:'scatter',mode:'lines',line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('Arm/Mode'), cfg);
|
||||||
|
|
||||||
|
const motorColors = ['#ef476f','#ffd166','#06d6a0','#00b4d8','#a0c4ff','#c77dff'];
|
||||||
|
const motorTraces = ['m1','m2','m3','m4','m5','m6'].map((mk,i) => {
|
||||||
|
const [t,v] = _pts(signals[mk]);
|
||||||
|
return {x:t,y:v,type:'scatter',mode:'lines',name:`M${i+1}`,line:{color:motorColors[i],width:1}};
|
||||||
|
});
|
||||||
|
Plotly.react('auv-motors', motorTraces,
|
||||||
|
Object.assign(_layout('Motors x6 PWM','µs'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setupSyncedZoom(['auv-pry','auv-depth','auv-alt','auv-obs','auv-usbl-dist','auv-usbl-angle','auv-batt','auv-status','auv-motors']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 10: Slider cursor sync ==
|
||||||
|
const ALL_GRAPH_IDS = [
|
||||||
|
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
|
||||||
|
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps',
|
||||||
|
'usv-usbl-dist', 'usv-usbl-angle', 'usv-motors', 'usv-status',
|
||||||
|
'auv-pry', 'auv-depth', 'auv-alt', 'auv-obs',
|
||||||
|
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
|
||||||
|
];
|
||||||
|
|
||||||
|
function updateCursor(epochSec) {
|
||||||
|
const ts = new Date(epochSec * 1000).toISOString();
|
||||||
|
const tMs = epochSec * 1000;
|
||||||
|
const winMs = +document.getElementById('window-select').value;
|
||||||
|
const t0 = winMs === 0 ? null : new Date(tMs - winMs).toISOString();
|
||||||
|
const t1 = new Date(tMs).toISOString();
|
||||||
|
const shape = {
|
||||||
|
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
|
||||||
|
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
|
||||||
|
};
|
||||||
|
const rangeUpdate = t0 ? {'xaxis.range': [t0, t1]} : {'xaxis.autorange': true};
|
||||||
|
ALL_GRAPH_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el._fullLayout) {
|
||||||
|
Plotly.relayout(id, {...rangeUpdate, 'shapes': [shape]});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById('window-select').addEventListener('change', () => {
|
||||||
|
if (tNow) updateCursor(tNow / 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// == Feature: Synced zoom across all Plotly charts ==
|
||||||
|
let _syncing = false;
|
||||||
|
function setupSyncedZoom(chartIds) {
|
||||||
|
chartIds.forEach(id => {
|
||||||
|
const div = document.getElementById(id);
|
||||||
|
if (!div) return;
|
||||||
|
div.on('plotly_relayout', (ev) => {
|
||||||
|
if (_syncing) return;
|
||||||
|
const x0 = ev['xaxis.range[0]'] || (ev['xaxis.range'] && ev['xaxis.range'][0]);
|
||||||
|
const x1 = ev['xaxis.range[1]'] || (ev['xaxis.range'] && ev['xaxis.range'][1]);
|
||||||
|
const reset = ev['xaxis.autorange'];
|
||||||
|
if (x0 == null && x1 == null && !reset) return;
|
||||||
|
_syncing = true;
|
||||||
|
chartIds.forEach(otherId => {
|
||||||
|
if (otherId === id) return;
|
||||||
|
const otherDiv = document.getElementById(otherId);
|
||||||
|
if (!otherDiv || !otherDiv._fullLayout) return;
|
||||||
|
if (reset) {
|
||||||
|
Plotly.relayout(otherDiv, { 'xaxis.autorange': true });
|
||||||
|
} else {
|
||||||
|
Plotly.relayout(otherDiv, { 'xaxis.range': [x0, x1] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setTimeout(() => { _syncing = false; }, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 11: loadSortieData + sorties loading + wiring ==
|
||||||
|
async function loadSortieData(sortieId) {
|
||||||
|
const prog = document.getElementById('sync-progress');
|
||||||
|
try {
|
||||||
|
// Reset state for sortie mode (independent from datebar)
|
||||||
|
clearMapLayers();
|
||||||
|
allPoints = [];
|
||||||
|
usblPoints = [];
|
||||||
|
|
||||||
|
// Phase 1: USV GPS track → polylines on map immediately (<3s target)
|
||||||
|
prog.textContent = 'Chargement track USV…';
|
||||||
|
const trackResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv_track`,
|
||||||
|
{ signal: AbortSignal.timeout(15000) });
|
||||||
|
if (trackResp.ok) {
|
||||||
|
const trackPts = await trackResp.json();
|
||||||
|
allPoints.push(...trackPts);
|
||||||
|
allPoints.sort((a, b) => a.t_ms - b.t_ms);
|
||||||
|
if (allPoints.length > 0) {
|
||||||
|
const times = allPoints.map(p => p.t_ms);
|
||||||
|
tMin = Math.min(...times);
|
||||||
|
tMax = Math.max(...times);
|
||||||
|
tNow = tMax;
|
||||||
|
// Fit map bounds
|
||||||
|
const lats = allPoints.map(p => p.lat).filter(Boolean);
|
||||||
|
const lons = allPoints.map(p => p.lon).filter(Boolean);
|
||||||
|
if (lats.length) {
|
||||||
|
map.fitBounds([
|
||||||
|
[Math.min(...lats), Math.min(...lons)],
|
||||||
|
[Math.max(...lats), Math.max(...lons)],
|
||||||
|
], { padding: [40, 40] });
|
||||||
|
}
|
||||||
|
showNoDataOverlay(false);
|
||||||
|
applyTrailAndCursor(); // PERF FIX: polylines visible NOW, before charts
|
||||||
|
prog.textContent = `Track USV ${allPoints.length} pts — chargement charts…`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: series + AUV (charts populate progressively)
|
||||||
|
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`,
|
||||||
|
{ signal: AbortSignal.timeout(20000) });
|
||||||
|
if (usvResp.ok) {
|
||||||
|
const usvData = await usvResp.json();
|
||||||
|
switchTab('usv'); // BUG1d FIX: switch BEFORE renderUSV so Plotly renders on visible divs
|
||||||
|
showNoDataOverlay(false); // BUG2 FIX: hide overlay when data loaded
|
||||||
|
renderUSV(usvData.signals);
|
||||||
|
}
|
||||||
|
prog.textContent = 'Chargement AUV…';
|
||||||
|
await loadAuvTabs(sortieId);
|
||||||
|
prog.textContent = `${sortieId} chargé`;
|
||||||
|
// Re-apply after AUV usbl_track loaded (adds AUV trail if available)
|
||||||
|
if (allPoints.length > 0) applyTrailAndCursor();
|
||||||
|
} catch(e) {
|
||||||
|
prog.textContent = `Erreur: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireSortieSelect() {
|
||||||
|
const sel = document.getElementById('sortie-select');
|
||||||
|
// Evite double-binding si appelé plusieurs fois
|
||||||
|
if (sel._wired) return;
|
||||||
|
sel._wired = true;
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const btn = document.getElementById('btn-sync');
|
||||||
|
btn.disabled = !sel.value;
|
||||||
|
_updateMissionLabelHeader(sel.value);
|
||||||
|
if (sel.value) {
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
if (opt.textContent.includes('✓')) {
|
||||||
|
loadSortieData(sel.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateSortieSelect(sorties) {
|
||||||
|
const sel = document.getElementById('sortie-select');
|
||||||
|
// Vider sauf première option placeholder
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
if (!sorties || !sorties.length) {
|
||||||
|
sel.options[0].textContent = '— Aucune sortie —';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.options[0].textContent = `${sorties.length} sorties dispo — sélectionnez`;
|
||||||
|
sorties.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
opt.textContent = s.id + (s.processed ? ' ✓' : '');
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
_wireSortieSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateMissionLabelHeader(sortieId) {
|
||||||
|
const el = document.getElementById('mission-label-header');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = sortieId ? `Mission: ${sortieId}` : '';
|
||||||
|
el.style.color = sortieId ? '#06d6a0' : '#555';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSorties() {
|
||||||
|
const sel = document.getElementById('sortie-select');
|
||||||
|
sel.options[0].textContent = 'Sorties: chargement…';
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
fetch(`${API2}/sorties`, { signal: controller.signal })
|
||||||
|
.then(resp => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
})
|
||||||
|
.then(sorties => { _populateSortieSelect(sorties); })
|
||||||
|
.catch(e => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
sel.options[0].textContent = '— Pipeline indisponible —';
|
||||||
|
console.warn('loadSorties:', e.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-sync').addEventListener('click', async () => {
|
||||||
|
const sortieId = document.getElementById('sortie-select').value;
|
||||||
|
if (!sortieId) return;
|
||||||
|
const btn = document.getElementById('btn-sync');
|
||||||
|
const prog = document.getElementById('sync-progress');
|
||||||
|
btn.disabled = true;
|
||||||
|
prog.textContent = 'Démarrage…';
|
||||||
|
const encoded = encodeURIComponent(sortieId);
|
||||||
|
await fetch(`${API2}/run/${encoded}`, {method: 'POST'});
|
||||||
|
const es = new EventSource(`${API2}/events/${encoded}`);
|
||||||
|
es.onmessage = async (e) => {
|
||||||
|
const evt = JSON.parse(e.data);
|
||||||
|
if (evt.step === 'ping') return;
|
||||||
|
prog.textContent = `[${evt.step}] ${evt.pct}% ${evt.msg}`;
|
||||||
|
if (evt.step === 'write' && evt.pct === 100) {
|
||||||
|
es.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
prog.textContent = 'Terminé — chargement…';
|
||||||
|
await loadSortieData(sortieId);
|
||||||
|
}
|
||||||
|
if (evt.step === 'error') {
|
||||||
|
es.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
prog.textContent = `Erreur: ${evt.msg}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSorties();
|
||||||
|
|
||||||
// == Init ==
|
// == Init ==
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
||||||
initCharts();
|
initCharts();
|
||||||
|
|||||||
Reference in New Issue
Block a user