Compare commits

...

12 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
3 changed files with 582 additions and 179 deletions

View File

@@ -1,6 +1,9 @@
import asyncio import asyncio
import csv
import gzip import gzip
import json import json
import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -9,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from .config import OUTPUT_DIR 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 = FastAPI(title="COSMA Pipeline Runner")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) 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 # Active pipeline jobs: sortie_id → asyncio.Queue
_jobs: dict[str, 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") @app.get("/sorties")
async def list_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}") @app.post("/run/{sortie_id:path}")
@@ -96,3 +140,82 @@ async def get_tracks(sortie_id: str):
raise HTTPException(404, "tracks.geojson not found") raise HTTPException(404, "tracks.geojson not found")
with open(p) as f: with open(p) as f:
return JSONResponse(json.load(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é") await _emit(queue, "write", 100, "Pipeline terminé")
async def scan_sorties() -> list[dict]: def scan_sorties() -> list[dict]:
"""List available sorties on GDrive via rclone lsd.""" """List available sorties on GDrive via rclone lsd (lent ~30s)."""
result = subprocess.run( result = subprocess.run(
["rclone", "lsd", GDRIVE_REMOTE], ["rclone", "lsd", GDRIVE_REMOTE],
capture_output=True, text=True capture_output=True, text=True
@@ -135,3 +135,15 @@ async def scan_sorties() -> list[dict]:
processed = (OUTPUT_DIR / name / "processed" / "usv.json.gz").exists() processed = (OUTPUT_DIR / name / "processed" / "usv.json.gz").exists()
sorties.append({"id": name, "processed": processed}) sorties.append({"id": name, "processed": processed})
return sorties 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; 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;
@@ -210,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>
@@ -264,20 +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 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>
<button id="btn-sync" disabled>Sync &amp; Process</button> <button id="btn-sync" disabled>Sync &amp; Process</button>
<span id="sync-progress"></span> <span id="sync-progress"></span>
</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>
@@ -291,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>
@@ -306,58 +353,78 @@ 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">
<div class="chart-wrap"> <!-- Tab: Charts globaux -->
<div class="chart-title">Depth AUV (m)</div> <div id="panel-charts" class="panel-section active">
<div class="plotly-wrap"><div id="chart-depth"></div></div> <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>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors AUV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-auv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors USV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-usv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div>
</div>
</div> </div>
<div class="chart-wrap"> <!-- Tab: USV -->
<div class="chart-title">Motors AUV (PWM)</div> <div id="panel-usv" class="panel-section hidden">
<div class="plotly-wrap"><div id="chart-pwm-auv"></div></div> <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> </div>
<div class="chart-wrap"> <!-- Tab: AUV -->
<div class="chart-title">Motors USV (PWM)</div> <div id="panel-auv" class="panel-section hidden">
<div class="plotly-wrap"><div id="chart-pwm-usv"></div></div> <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>
<div class="chart-wrap">
<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 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-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 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>
<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>
@@ -366,8 +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')
const API2 = 'http://192.168.0.83:8767'; ? '/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 = {
@@ -503,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
@@ -606,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());
@@ -653,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 = [];
@@ -669,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)) };
@@ -689,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;
} }
@@ -711,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 = [
@@ -743,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`);
@@ -753,70 +848,109 @@ 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
if (sessions.sub) { // Phase 1: track-only fetches (needed for map polylines) — batch by 4
sessions.sub.forEach(sess => { const trackFetches = shipSessions.map(sess =>
sessionFetches.push(loadSubSession(missionId, diveId, sess.id)); fetchShipTrack(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
try { async function _batchedAll(fns, n) {
const [trackResp, seriesResp] = await Promise.all([ for (let i = 0; i < fns.length; i += n) {
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`), await Promise.all(fns.slice(i, i + n));
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`), }
]);
if (trackResp.ok) {
const d = await trackResp.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
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
if (!pts || !pts.length) return;
pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 },
});
});
}
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
} }
async function loadSubSession(missionId, diveId, sessionId) { async function fetchShipTrack(missionId, diveId, sessionId) {
try { try {
const [seriesResp, usblResp] = await Promise.all([ const resp = await fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`,
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`), { signal: AbortSignal.timeout(20000) });
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`), if (!resp.ok) { console.warn('fetchShipTrack fail', sessionId, resp.status); return; }
]); const d = await resp.json();
if (seriesResp.ok) { const pts = (d.points||[]).map(p => ({
const d = await seriesResp.json(); t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
// Depth trace heading: p.heading || null, source: sessionId
}));
allPoints.push(...pts);
} 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];
if (!pts || !pts.length) return;
pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v),
name: k, type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 },
});
});
} catch(e) { console.warn('fetchShipSeries error', sessionId, e.name); }
}
async function loadSubSession(missionId, diveId, sessionId) {
// 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 (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) { 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))),
@@ -826,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];
@@ -839,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); }
if (usblResp.ok) { } else {
const d = await usblResp.json(); console.warn('loadSubSession series timeout/fail', sessionId,
const pts = (d.points||[]); seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status);
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) {
@@ -923,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() {
@@ -981,11 +1128,13 @@ function renderUSV(signals) {
const [at, av] = _pts(signals.usbl_angle); 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); 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); const motorColorsUSV = ['#ef476f','#ff8800'];
Plotly.react('usv-m1', [{x:m1t, y:m1v, type:'scatter', mode:'lines', line:{color:'#ef476f',width:1}}], _layout('Motor 1','cmd'), cfg); const motorTracesUSV = ['M1','M2'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
const [m2t, m2v] = _pts(signals.M2); return {x:t,y:v,type:'scatter',mode:'lines',name:mk,line:{color:motorColorsUSV[i],width:1}};
Plotly.react('usv-m2', [{x:m2t, y:m2v, type:'scatter', mode:'lines', line:{color:'#ff6b6b',width:1}}], _layout('Motor 2','cmd'), cfg); });
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 armPts = _pts(signals.Armed);
const modePts = _pts(signals.Mode); 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'}}); 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:[]}], Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg); 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 == // == Task 9: AUV rendering + tabs ==
@@ -1060,72 +1212,188 @@ function renderAUV(signals) {
}); });
Plotly.react('auv-motors', motorTraces, 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); 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 == // == Task 10: Slider cursor sync ==
const ALL_GRAPH_IDS = [ const ALL_GRAPH_IDS = [
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl', 'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps', '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-pry', 'auv-depth', 'auv-alt', 'auv-obs',
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors', 'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
]; ];
function updateCursor(epochSec) { function updateCursor(epochSec) {
const ts = new Date(epochSec * 1000).toISOString(); 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 = { const shape = {
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1, type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'}, yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
}; };
const rangeUpdate = t0 ? {'xaxis.range': [t0, t1]} : {'xaxis.autorange': true};
ALL_GRAPH_IDS.forEach(id => { ALL_GRAPH_IDS.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el && el._fullLayout) { 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 == // == Task 11: loadSortieData + sorties loading + wiring ==
async function loadSortieData(sortieId) { async function loadSortieData(sortieId) {
const prog = document.getElementById('sync-progress'); const prog = document.getElementById('sync-progress');
try { try {
prog.textContent = 'Chargement USV…'; // Reset state for sortie mode (independent from datebar)
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`); 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) { if (usvResp.ok) {
const usvData = await usvResp.json(); 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); renderUSV(usvData.signals);
} }
prog.textContent = 'Chargement AUV…'; prog.textContent = 'Chargement AUV…';
await loadAuvTabs(sortieId); await loadAuvTabs(sortieId);
prog.textContent = `${sortieId} chargé`; prog.textContent = `${sortieId} chargé`;
// Re-apply after AUV usbl_track loaded (adds AUV trail if available)
if (allPoints.length > 0) applyTrailAndCursor();
} catch(e) { } catch(e) {
prog.textContent = `Erreur: ${e.message}`; prog.textContent = `Erreur: ${e.message}`;
} }
} }
async function loadSorties() { function _wireSortieSelect() {
try { const sel = document.getElementById('sortie-select');
const resp = await fetch(`${API2}/sorties`); // Evite double-binding si appelé plusieurs fois
if (!resp.ok) return; if (sel._wired) return;
const sorties = await resp.json(); sel._wired = true;
const sel = document.getElementById('sortie-select'); sel.addEventListener('change', () => {
sorties.forEach(s => { const btn = document.getElementById('btn-sync');
const opt = document.createElement('option'); btn.disabled = !sel.value;
opt.value = s.id; _updateMissionLabelHeader(sel.value);
opt.textContent = s.id + (s.processed ? ' ✓' : ''); if (sel.value) {
sel.appendChild(opt); const opt = sel.options[sel.selectedIndex];
}); if (opt.textContent.includes('✓')) {
sel.addEventListener('change', () => { loadSortieData(sel.value);
const btn = document.getElementById('btn-sync');
btn.disabled = !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);
}); });
} catch(e) { console.warn('pipeline-runner unavailable', e); }
} }
document.getElementById('btn-sync').addEventListener('click', async () => { document.getElementById('btn-sync').addEventListener('click', async () => {