perf(viewer): polylines USV <3s — Phase 1 usv_track endpoint + immediate applyTrailAndCursor

This commit is contained in:
Poulpe
2026-04-28 15:03:53 +00:00
parent f5788a01f4
commit dd6f0cf435
2 changed files with 112 additions and 3 deletions

View File

@@ -1,7 +1,9 @@
import asyncio import asyncio
import csv
import gzip import gzip
import json import json
import time import time
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -138,3 +140,76 @@ 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
@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."""
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"])
return JSONResponse(pts)

View File

@@ -1280,8 +1280,42 @@ function setupSyncedZoom(chartIds) {
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 switchTab('usv'); // BUG1d FIX: switch BEFORE renderUSV so Plotly renders on visible divs
@@ -1291,7 +1325,7 @@ async function loadSortieData(sortieId) {
prog.textContent = 'Chargement AUV…'; prog.textContent = 'Chargement AUV…';
await loadAuvTabs(sortieId); await loadAuvTabs(sortieId);
prog.textContent = `${sortieId} chargé`; prog.textContent = `${sortieId} chargé`;
// POLYLINE FIX: ensure map trail is drawn if allPoints already loaded from datebar // Re-apply after AUV usbl_track loaded (adds AUV trail if available)
if (allPoints.length > 0) applyTrailAndCursor(); if (allPoints.length > 0) applyTrailAndCursor();
} catch(e) { } catch(e) {
prog.textContent = `Erreur: ${e.message}`; prog.textContent = `Erreur: ${e.message}`;