Compare commits

13 Commits

Author SHA1 Message Date
Poulpe
f66bb52fec perf(pipeline): in-memory cache for usv_track (warm: 20ms vs 1.8s cold) 2026-04-28 15:06:09 +00:00
Poulpe
dd6f0cf435 perf(viewer): polylines USV <3s — Phase 1 usv_track endpoint + immediate applyTrailAndCursor 2026-04-28 15:03:53 +00:00
Flag
f5788a01f4 fix(viewer): polylines + batched T3 load
- loadSortieData: call applyTrailAndCursor() after sortie load so map
  polylines appear when allPoints already populated from datebar
- loadDiveData: split into Phase1 (track only, batched by 4) + Phase2
  (series + sub, batched by 4) — map draws as soon as tracks load
- loadShipSession split into fetchShipTrack + fetchShipSeries helpers
- T3: filter ship sessions by date (sess.start.slice(0,10) === filterDate)
- Pass date param from loadDate to loadDiveData

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 14:54:16 +00:00
f5debc8afc fix(viewer): tNow=tMax so trail shows data on init + synced zoom across all Plotly charts
Bug 1: cursor slider started at tMin (no data in 60s window at start of data).
Fix: start slider at tMax so trail window covers the last trailMs of actual data.

Feature 2 (Alexandre Larribau): setupSyncedZoom() propagates xaxis.range
to all sibling charts on plotly_relayout, with _syncing guard vs infinite loop.
Applied to global charts (depth/PWM/USBL), USV tab, and AUV tab independently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:15:26 +00:00
Poulpe
63270beeff fix(viewer): BUG1d switchTab before renderUSV to fix Plotly on hidden divs
Plotly.react fails silently on display:none divs.
Switch tab to USV first, then render so divs are visible when Plotly mounts.
2026-04-27 23:07:20 +00:00
Poulpe
4164f32694 fix(viewer): normalize mission IDs hash vs underscore + auto-switch USV tab
BUG3b: data-dates API returns hash prefix, missions API returns underscore.
Normalize both sides before comparing to properly filter mission fetches.
BUG1c: auto-switch to USV tab after sortie data loaded so charts are visible.
2026-04-27 23:05:57 +00:00
Poulpe
b45a5368ee fix(viewer): 4 bugs — Plotly resize, no-data overlay, fetch overkill, tab resize
Bug1: populatePlotlyCharts now forces requestAnimationFrame Plotly.Plots.resize
      after react() so charts render correctly even after hidden/late init
Bug1b: switchTab(charts) triggers resize on all 4 chart divs
Bug2: loadSortieData calls showNoDataOverlay(false) when USV data loaded
Bug3: loadDate filters missions via availableDates cache before fetching
      all dives — avoids O(n*missions) requests when date entry known
2026-04-27 23:03:21 +00:00
Poulpe
7a5d442fbd feat(viewer): auto-detect API URLs + 9 UX improvements (no-data overlay, tabs, labels, layer toggles map) 2026-04-27 22:36:08 +00:00
b21e306a86 fix(pipeline): scan_sorties sync (was async, broke to_thread → coroutine instead of list → 500) 2026-04-27 22:13:02 +00:00
Poulpe
a79f63e59e fix(viewer): map visible — wrap USV/AUV panels in graphs-section, minmax(200px) map row 2026-04-27 22:08:49 +00:00
Poulpe
31b5a221b8 fix(viewer+backend): cache sorties TTL 10min + /sorties/local + viewer non-bloquant 2026-04-27 22:02:21 +00:00
Poulpe
b962997008 feat(viewer): merge USV M1+M2 into one graph, add history window control
- USV panel: usv-m1 + usv-m2 → single usv-motors multi-trace (2 lines)
- AUV panel: auv-motors already combined (6 lines), no change
- Add window dropdown (10s/30s/60s/5min/15min/ALL) next to trail control
- updateCursor applies xaxis.range=[T-win, T] on all panel graphs
- Plots before: ~9 USV + ~9 AUV = ~18; after: ~7 USV + ~8 AUV = ~15
2026-04-27 21:54:19 +00:00
Poulpe
6978b36650 merge feat/flag-local: pipeline-runner + viewer USV/AUV panels 2026-04-27 21:34:07 +00:00
3 changed files with 582 additions and 179 deletions

View File

@@ -1,6 +1,9 @@
import asyncio
import csv
import gzip
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import AsyncGenerator
@@ -9,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from .config import OUTPUT_DIR
from .runner import run_pipeline, scan_sorties
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=["*"])
@@ -17,10 +20,51 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], all
# 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():
return await scan_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}")
@@ -96,3 +140,82 @@ async def get_tracks(sortie_id: str):
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)

View File

@@ -121,8 +121,8 @@ async def run_pipeline(sortie_id: str, queue: asyncio.Queue) -> None:
await _emit(queue, "write", 100, "Pipeline terminé")
async def scan_sorties() -> list[dict]:
"""List available sorties on GDrive via rclone lsd."""
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
@@ -135,3 +135,15 @@ async def scan_sorties() -> list[dict]:
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

View File

@@ -12,8 +12,9 @@
height: 100vh; overflow: hidden;
font-family: monospace; background: #1a1a2e; color: #e0e0e0;
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 */
#datebar {
background: #0d0d20; border-bottom: 1px solid #0f3460;
@@ -45,7 +46,7 @@
}
#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; }
#layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
#layer-toggles { display: none; }
.layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent;
@@ -60,6 +61,20 @@
/* Row 2: map */
#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 {
position: absolute; top: 10px; left: 50px; z-index: 1000;
@@ -73,6 +88,11 @@
display: inline-block; background: #ff8800; color: #1a1a2e;
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 {
position: absolute; bottom: 10px; left: 10px; z-index: 1000;
@@ -115,15 +135,23 @@
}
#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 {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #0a0a1a;
}
#charts-4grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
background: #0a0a1a;
padding: 3px;
min-height: 0; overflow: hidden;
flex: 1;
min-height: 180px;
}
.chart-wrap {
background: #12122a;
@@ -138,6 +166,21 @@
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
.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 {
display: none;
@@ -210,8 +253,8 @@
<div id="datebar">
<input type="date" id="date-picker">
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span>
<span id="load-status"></span>
<span id="mission-label" class="no-data"></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>
</div>
@@ -264,20 +307,24 @@ flowchart LR
<!-- Row 1: Header -->
<div id="header">
<span id="title">COSMA NAV v6</span>
<span id="stats">Chargement...</span>
<div id="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>
<select id="sortie-select"><option value="">— Sortie —</option></select>
<span id="stats" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#a0c4ff;"></span>
<select id="sortie-select"><option value="">Chargement Gdrive (~30s)...</option></select>
<span id="mission-label-header" style="font-size:11px;font-family:monospace;padding:2px 8px;color:#555;white-space:nowrap;"></span>
<button id="btn-sync" disabled>Sync &amp; Process</button>
<span id="sync-progress"></span>
</div>
<!-- Row 2: 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="usbl-panel">
<div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist"></span></div>
@@ -291,10 +338,10 @@ flowchart LR
<!-- Row 3: Controls -->
<div id="controls">
<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>
<span id="cursor-time"></span>
<label for="trail-select">trail</label>
<span id="cursor-time" style="font-size:11px;color:#e0e0e0;white-space:nowrap;min-width:70px;"></span>
<label for="trail-select" style="font-size:10px;color:#666;white-space:nowrap;">Trail:</label>
<select id="trail-select">
<option value="10000">10s</option>
<option value="30000">30s</option>
@@ -306,14 +353,31 @@ flowchart LR
</select>
<button id="btn-viewall" onclick="viewAll()">View all</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 id="ctrl-row2">
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
</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">
<!-- Tab: Charts globaux -->
<div id="panel-charts" class="panel-section active">
<div id="charts-4grid">
<div class="chart-wrap">
<div class="chart-title">Depth AUV (m)</div>
<div class="plotly-wrap"><div id="chart-depth"></div></div>
@@ -330,25 +394,26 @@ flowchart LR
<div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div>
</div>
<div class="panel-header">USV</div>
<div class="graphs-grid" id="usv-graphs">
</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" id="usv-m1"></div>
<div class="graph-cell" id="usv-m2"></div>
<div class="graph-cell wide" id="usv-motors"></div>
<div class="graph-cell wide" id="usv-status"></div>
</div>
<div class="panel-header" id="auv-panel-header">
AUV
<span id="auv-tabs"></span>
</div>
<div class="graphs-grid" id="auv-graphs">
</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>
@@ -358,6 +423,8 @@ flowchart LR
<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>
@@ -366,8 +433,12 @@ flowchart LR
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<script>
// == Constants ==
const API = 'http://192.168.0.83:8766';
const API2 = 'http://192.168.0.83:8767';
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 AUV_COLOR = '#ff8800';
const PLOTLY_LAYOUT = {
@@ -503,6 +574,14 @@ function populatePlotlyCharts() {
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-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
@@ -606,13 +685,16 @@ function initCursorSlider() {
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider');
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 },
step: 1000,
});
cursorSlider.on('update', (values) => {
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();
});
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
@@ -653,7 +735,8 @@ function clearMapLayers() {
// == Load data for a given date ==
async function loadDate(date) {
setStatus('Chargement...');
setStatus('chargement...');
showNoDataOverlay(true);
clearMapLayers();
allPoints = [];
usblPoints = [];
@@ -669,7 +752,18 @@ async function loadDate(date) {
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 dives = await dResp.json();
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
@@ -689,7 +783,7 @@ async function loadDate(date) {
for (const { mission, dives } of missionDives) {
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;
totalSub += dive.sub_session_count || 0;
}
@@ -711,7 +805,7 @@ async function loadDate(date) {
];
tMin = Math.min(...allTimes);
tMax = Math.max(...allTimes);
tNow = tMin;
tNow = tMax; // BUG1 FIX: start at end so trail window [tMax-trailMs, tMax] contains data
// Fit map
const allLats = [
@@ -743,6 +837,7 @@ async function loadDate(date) {
populatePlotlyCharts();
initCursorSlider();
applyTrailAndCursor();
showNoDataOverlay(false);
document.getElementById('title').textContent = `COSMA v6 — ${date}`;
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
@@ -753,45 +848,60 @@ async function loadDate(date) {
}
}
async function loadDiveData(missionId, diveId) {
async function loadDiveData(missionId, diveId, filterDate) {
try {
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
const sessions = await sResp.json();
const sessionFetches = [];
// Ship sessions
if (sessions.ship) {
sessions.ship.forEach(sess => {
sessionFetches.push(loadShipSession(missionId, diveId, sess.id));
// T3 FIX: filter ship sessions by date if provided (session.start = "YYYY-MM-DD_HH-MM-SS")
const shipSessions = (sessions.ship || []).filter(sess => {
if (!filterDate) return true;
// 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
if (sessions.sub) {
sessions.sub.forEach(sess => {
sessionFetches.push(loadSubSession(missionId, diveId, sess.id));
});
}
await Promise.all(sessionFetches);
// Phase 1: track-only fetches (needed for map polylines) — batch by 4
const trackFetches = shipSessions.map(sess =>
fetchShipTrack(missionId, diveId, sess.id)
);
await _batchedAll(trackFetches, 4);
// 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); }
}
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 {
const [trackResp, seriesResp] = await Promise.all([
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`),
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`),
]);
if (trackResp.ok) {
const d = await trackResp.json();
const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`,
{ signal: AbortSignal.timeout(20000) });
if (!resp.ok) { console.warn('fetchShipTrack fail', sessionId, resp.status); return; }
const d = await resp.json();
const pts = (d.points||[]).map(p => ({
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
heading: p.heading || null, source: sessionId
}));
allPoints.push(...pts);
}
if (seriesResp.ok) {
const d = await seriesResp.json();
// USV PWM: M1..M8
} catch(e) { console.warn('fetchShipTrack error', sessionId, e.name); }
}
async function fetchShipSeries(missionId, diveId, sessionId) {
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));
motorKeys.forEach((k, i) => {
const pts = d[k];
@@ -799,24 +909,48 @@ async function loadShipSession(missionId, diveId, sessionId) {
pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
name: k, type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 },
});
});
}
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
} catch(e) { console.warn('fetchShipSeries error', sessionId, e.name); }
}
async function loadSubSession(missionId, diveId, sessionId) {
try {
const [seriesResp, usblResp] = await Promise.all([
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`),
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`),
// Both usbl_track and series can hang — use parallel with timeout, non-blocking
const [usblResult, seriesResult] = await Promise.allSettled([
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) {
const d = await seriesResp.json();
// Depth trace
if (usblResult.status === 'fulfilled' && usblResult.value.ok) {
try {
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) {
depthTraces.push({
x: d.depth.map(p => new Date(unixToMs(p.t))),
@@ -826,7 +960,6 @@ async function loadSubSession(missionId, diveId, sessionId) {
line: { color: '#06d6a0', width: 1.5 },
});
}
// AUV motors: m1..m8
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
@@ -839,28 +972,11 @@ async function loadSubSession(missionId, diveId, sessionId) {
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) {
@@ -923,6 +1039,37 @@ function datePickerToday() {
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 ==
let _pipelineRendered = false;
function togglePipeline() {
@@ -981,11 +1128,13 @@ function renderUSV(signals) {
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 [m1t, m1v] = _pts(signals.M1);
Plotly.react('usv-m1', [{x:m1t, y:m1v, type:'scatter', mode:'lines', line:{color:'#ef476f',width:1}}], _layout('Motor 1','cmd'), cfg);
const [m2t, m2v] = _pts(signals.M2);
Plotly.react('usv-m2', [{x:m2t, y:m2v, type:'scatter', mode:'lines', line:{color:'#ff6b6b',width:1}}], _layout('Motor 2','cmd'), 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);
@@ -994,6 +1143,9 @@ function renderUSV(signals) {
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 ==
@@ -1060,64 +1212,135 @@ function renderAUV(signals) {
});
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-m1', 'usv-m2', 'usv-status',
'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, {'shapes': [shape]});
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 {
prog.textContent = 'Chargement USV…';
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`);
// 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}`;
}
}
async function loadSorties() {
try {
const resp = await fetch(`${API2}/sorties`);
if (!resp.ok) return;
const sorties = await resp.json();
function _wireSortieSelect() {
const sel = document.getElementById('sortie-select');
sorties.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.id + (s.processed ? ' ✓' : '');
sel.appendChild(opt);
});
// 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('✓')) {
@@ -1125,7 +1348,52 @@ async function loadSorties() {
}
}
});
} catch(e) { console.warn('pipeline-runner unavailable', e); }
}
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 () => {