feat(viewer): v3 AUV track + USBL vector overlay

This commit is contained in:
Poulpe
2026-04-25 21:40:05 +00:00
parent be2cd1d156
commit 3198164aff
2 changed files with 376 additions and 101 deletions

125
tools/usbl_to_json.py Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""usbl_to_json.py - Convert combined_nav_usbl.csv to usbl.json + auv_track.geojson"""
import csv, json, math, argparse, statistics
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_INPUT = ROOT / "output" / "combined_nav_usbl.csv"
OUTPUT_DIR = ROOT / "output"
def parse_ts(s):
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
dt = datetime.strptime(s.strip(), fmt).replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except ValueError:
pass
return None
def haversine_m(lat1, lon1, lat2, lon2):
R = 6371000.0
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", default=str(DEFAULT_INPUT))
ap.add_argument("--max-points", type=int, default=10000)
args = ap.parse_args()
rows = []
with open(args.input, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
try:
dist = float(row["Dist"])
auv_lat = float(row["auv_lat"])
auv_lon = float(row["auv_lon"])
except (ValueError, KeyError):
continue
if dist <= 0 or auv_lat == 0 or auv_lon == 0:
continue
t_ms = parse_ts(row["Timestamp"])
if t_ms is None:
continue
rows.append({
"t": row["Timestamp"].strip(),
"t_ms": t_ms,
"usv_lat": float(row["lat"]),
"usv_lon": float(row["lon"]),
"heading": float(row["Heading"]),
"dist": dist,
"az": float(row["Azimuth"]),
"elev": float(row["Elev"]),
"snr": float(row["SNR"]),
"auv_lat": auv_lat,
"auv_lon": auv_lon,
})
rows.sort(key=lambda r: r["t_ms"])
n_raw = len(rows)
# Sample if > max-points (preserve begin/end)
if n_raw > args.max_points:
step = n_raw / args.max_points
indices = set()
indices.add(0)
indices.add(n_raw - 1)
for i in range(1, args.max_points - 1):
indices.add(int(i * step))
rows = [rows[i] for i in sorted(indices)]
n = len(rows)
dists = [r["dist"] for r in rows]
snrs = [r["snr"] for r in rows]
auv_lats = [r["auv_lat"] for r in rows]
auv_lons = [r["auv_lon"] for r in rows]
auv_bbox = [min(auv_lons), min(auv_lats), max(auv_lons), max(auv_lats)]
out = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"n_points": n,
"n_raw": n_raw,
"auv_bbox": auv_bbox,
"stats": {
"dist_min": round(min(dists), 3),
"dist_max": round(max(dists), 3),
"dist_median": round(statistics.median(dists), 3),
"snr_min": round(min(snrs), 4),
"snr_max": round(max(snrs), 4),
},
"points": rows,
}
usbl_out = OUTPUT_DIR / "usbl.json"
with open(usbl_out, "w") as f:
json.dump(out, f, separators=(",", ":"))
print(f"usbl.json: {n} points (raw={n_raw}) -> {usbl_out}")
# AUV track GeoJSON
coords = [[r["auv_lon"], r["auv_lat"]] for r in rows]
geojson = {
"type": "FeatureCollection",
"features": [{
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": {"name": "AUV track (USBL projection)", "color": "#ff8800"},
}]
}
track_out = OUTPUT_DIR / "auv_track.geojson"
with open(track_out, "w") as f:
json.dump(geojson, f, separators=(",", ":"))
print(f"auv_track.geojson: {len(coords)} coords -> {track_out}")
# Sanity check: first point haversine vs USBL dist
r0 = rows[0]
hav = haversine_m(r0["usv_lat"], r0["usv_lon"], r0["auv_lat"], r0["auv_lon"])
print(f"Sanity [0]: USV=({r0['usv_lat']:.6f},{r0['usv_lon']:.6f}) AUV=({r0['auv_lat']:.6f},{r0['auv_lon']:.6f}) hav={hav:.2f}m USBL_dist={r0['dist']:.2f}m diff={abs(hav-r0['dist']):.3f}m")
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>COSMA — USV Multi-Track Viewer v2</title> <title>COSMA — NAV Viewer v3</title>
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/>
@@ -33,28 +33,14 @@
border-radius: 2px; border-radius: 2px;
} }
#btn-reset:hover { background: #00b4d8; color: #1a1a2e; } #btn-reset:hover { background: #00b4d8; color: #1a1a2e; }
#range-row { display: flex; align-items: center; gap: 10px; }
/* Range slider row */
#range-row {
display: flex;
align-items: center;
gap: 10px;
}
#range-label { font-size: 10px; color: #666; white-space: nowrap; } #range-label { font-size: 10px; color: #666; white-space: nowrap; }
#range-slider-wrap { flex: 1; padding: 4px 0; } #range-slider-wrap { flex: 1; padding: 4px 0; }
#range-dates { display: flex; justify-content: space-between; font-size: 10px; color: #a0c4ff; } #range-dates { display: flex; justify-content: space-between; font-size: 10px; color: #a0c4ff; }
#cursor-row { display: flex; align-items: center; gap: 10px; }
/* Cursor slider row */
#cursor-row {
display: flex;
align-items: center;
gap: 10px;
}
#cursor-label { font-size: 10px; color: #666; white-space: nowrap; } #cursor-label { font-size: 10px; color: #666; white-space: nowrap; }
#cursor-slider-wrap { flex: 1; } #cursor-slider-wrap { flex: 1; }
#cursor-info { font-size: 11px; color: #e0e0e0; min-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #cursor-info { font-size: 11px; color: #e0e0e0; min-width: 280px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* noUiSlider overrides */
.noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; } .noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; }
.noUi-horizontal { height: 6px; } .noUi-horizontal { height: 6px; }
.noUi-connect { background: #00b4d8; } .noUi-connect { background: #00b4d8; }
@@ -67,72 +53,108 @@
.noUi-handle::before, .noUi-handle::after { display: none; } .noUi-handle::before, .noUi-handle::after { display: none; }
#cursor-slider .noUi-connect { background: #e94560; } #cursor-slider .noUi-connect { background: #e94560; }
#cursor-slider .noUi-handle { background: #e94560; } #cursor-slider .noUi-handle { background: #e94560; }
/* Legend */
#legend { #legend {
position: absolute; bottom: 200px; left: 10px; z-index: 1000; position: absolute; bottom: 210px; left: 10px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460; background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace; font-size: 11px; font-family: monospace;
display: none;
} }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; } .legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 12px; height: 4px; border-radius: 2px; flex-shrink: 0; } .legend-dot { width: 14px; height: 4px; border-radius: 2px; flex-shrink: 0; }
.legend-dashed { border-top: 2px dashed #888; width: 14px; flex-shrink: 0; }
/* USBL stats panel */
#usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000;
background: rgba(22,33,62,0.88); padding: 8px 12px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace; min-width: 180px;
display: none;
}
#usbl-panel .uprow { display: flex; justify-content: space-between; gap: 16px; margin: 1px 0; }
#usbl-panel .uplabel { color: #666; }
#usbl-panel .upval { color: #ff8800; font-weight: bold; }
#usbl-panel .badge {
display: inline-block; background: #ff8800; color: #1a1a2e;
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
}
/* Layer toggles */
#layer-toggles {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent;
}
.layer-btn.active { opacity: 1; }
.layer-btn.inactive { opacity: 0.35; }
#btn-usv { color: #00b4d8; border-color: #00b4d8; }
#btn-auv { color: #ff8800; border-color: #ff8800; }
#btn-vec { color: #888; border-color: #888; }
#btn-usbl-panel { color: #aaa; border-color: #444; }
</style> </style>
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div id="legend"></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>
<div class="uprow"><span class="uplabel">Az (rel)</span><span class="upval" id="up-az"></span></div>
<div class="uprow"><span class="uplabel">Elev</span><span class="upval" id="up-elev"></span></div>
<div class="uprow"><span class="uplabel">SNR</span><span class="upval" id="up-snr"></span></div>
<div><span class="badge">REL HEADING</span></div>
</div>
<div id="controls"> <div id="controls">
<div id="top-row"> <div id="top-row">
<span id="title">USV Multi-Track v2</span> <span id="title">COSMA NAV v3</span>
<span id="stats">Chargement</span> <span id="stats">Chargement...</span>
<div id="layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV track</button>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">AUV track</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">Vecteur USBL</button>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats USBL</button>
</div>
<button id="btn-reset">Reset window</button> <button id="btn-reset">Reset window</button>
</div> </div>
<div id="range-row"> <div id="range-row">
<span id="range-label">Fenêtre</span> <span id="range-label">Fenetre</span>
<div id="range-slider-wrap"><div id="range-slider"></div></div> <div id="range-slider-wrap"><div id="range-slider"></div></div>
</div> </div>
<div id="range-dates"> <div id="range-dates">
<span id="date-start"></span> <span id="date-start">-</span>
<span id="date-end"></span> <span id="date-end">-</span>
</div> </div>
<div id="cursor-row"> <div id="cursor-row">
<span id="cursor-label">Curseur</span> <span id="cursor-label">Curseur</span>
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div> <div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<div id="cursor-info"></div> <div id="cursor-info">-</div>
</div> </div>
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
<script> <script>
// ── Constants ──────────────────────────────────────────────────────────── // == Constants ==
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316']; const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
const AUV_COLOR = '#ff8800';
// ── Map init ────────────────────────────────────────────────────────────── // == Map init ==
const map = L.map('map', { zoomControl: true }); const map = L.map('map', { zoomControl: true });
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', maxZoom: 19, attribution: '(c) OpenStreetMap contributors', maxZoom: 19,
}); });
osm.addTo(map); osm.addTo(map);
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '© OpenSeaMap', maxZoom: 18, opacity: 0.8, attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8,
}); });
const gebco = L.tileLayer( const gebco = L.tileLayer(
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}', 'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
{ attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 } { attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 }
); );
L.control.layers( L.control.layers(
{ 'OpenStreetMap': osm }, { 'OpenStreetMap': osm },
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymétrie)': gebco }, { 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymetrie)': gebco },
{ collapsed: false } { collapsed: false }
).addTo(map); ).addTo(map);
// ── Arrow marker ────────────────────────────────────────────────────────── // == Arrow marker ==
function makeArrowIcon(heading, color) { function makeArrowIcon(heading, color) {
color = color || '#e94560'; color = color || '#e94560';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32"> const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
@@ -142,20 +164,75 @@ function makeArrowIcon(heading, color) {
</svg>`; </svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] }); return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
} }
function makeAuvIcon() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-10 -10 20 20">
<circle r="7" fill="${AUV_COLOR}" stroke="#fff" stroke-width="2"/>
<circle r="2" fill="#fff"/>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [20,20], iconAnchor: [10,10] });
}
// ── State ───────────────────────────────────────────────────────────────── // == Binary search ==
let allPoints = []; // all sampled points sorted by t_ms function bisectLeft(arr, val) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid].t_ms < val) lo = mid + 1; else hi = mid;
}
return lo;
}
function findNearest(arr, tms) {
if (!arr.length) return null;
let i = bisectLeft(arr, tms);
if (i === 0) return arr[0];
if (i >= arr.length) return arr[arr.length - 1];
return (arr[i].t_ms - tms) < (tms - arr[i-1].t_ms) ? arr[i] : arr[i-1];
}
// == State ==
let allPoints = [];
let usblPoints = []; // from usbl.json (sorted by t_ms)
let manifest = null; let manifest = null;
let trackLayers = []; // per-session polyline layers let usblMeta = null;
let windowPoints = []; // filtered by range window let trackLayers = [];
let auvTrackLayer = null;
let windowPoints = [];
let usblWindow = [];
let cursorMarker = null; let cursorMarker = null;
let auvMarker = null;
let usblVector = null;
let rangeSlider = null; let rangeSlider = null;
let cursorSlider = null; let cursorSlider = null;
let tMin = 0, tMax = 0; let tMin = 0, tMax = 0;
// ── Utils ───────────────────────────────────────────────────────────────── // layer visibility
const layerVis = { usv: true, auv: true, vec: true, panel: true };
// == Layer toggles ==
function toggleLayer(name) {
layerVis[name] = !layerVis[name];
const btn = document.getElementById('btn-' + (name === 'panel' ? 'usbl-panel' : name));
btn.classList.toggle('active', layerVis[name]);
btn.classList.toggle('inactive', !layerVis[name]);
if (name === 'usv') {
trackLayers.forEach(l => layerVis.usv ? map.addLayer(l) : map.removeLayer(l));
}
if (name === 'auv') {
if (auvTrackLayer) { layerVis.auv ? map.addLayer(auvTrackLayer) : map.removeLayer(auvTrackLayer); }
if (auvMarker) { layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker); }
}
if (name === 'vec') {
if (usblVector) { layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector); }
}
if (name === 'panel') {
document.getElementById('usbl-panel').style.display = layerVis.panel ? 'block' : 'none';
}
}
// == Utils ==
function fmtMs(ms) { function fmtMs(ms) {
if (!ms) return ''; if (!ms) return '-';
return new Date(ms).toISOString().replace('T',' ').slice(0,19) + ' UTC'; return new Date(ms).toISOString().replace('T',' ').slice(0,19) + ' UTC';
} }
function fmtDuration(ms) { function fmtDuration(ms) {
@@ -166,37 +243,51 @@ function fmtDuration(ms) {
return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`; return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
} }
// ── Range window filtering ──────────────────────────────────────────────── // == Range window filtering ==
function filterPointsInWindow(startMs, endMs) { function filterPointsInWindow(arr, startMs, endMs) {
return allPoints.filter(p => p.t_ms >= startMs && p.t_ms <= endMs); const lo = bisectLeft(arr, startMs);
const result = [];
for (let i = lo; i < arr.length && arr[i].t_ms <= endMs; i++) result.push(arr[i]);
return result;
}
function rebuildAuvTrackLayer() {
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer = null; }
if (usblWindow.length < 2) return;
const coords = usblWindow.map(p => [p.auv_lat, p.auv_lon]);
auvTrackLayer = L.polyline(coords, { color: AUV_COLOR, weight: 2.5, opacity: 0.85 });
if (layerVis.auv) auvTrackLayer.addTo(map);
} }
function updateWindow(startMs, endMs) { function updateWindow(startMs, endMs) {
document.getElementById('date-start').textContent = fmtMs(startMs); document.getElementById('date-start').textContent = fmtMs(startMs);
document.getElementById('date-end').textContent = fmtMs(endMs); document.getElementById('date-end').textContent = fmtMs(endMs);
windowPoints = filterPointsInWindow(startMs, endMs); windowPoints = filterPointsInWindow(allPoints, startMs, endMs);
usblWindow = filterPointsInWindow(usblPoints, startMs, endMs);
// Rebuild track layers by session // Rebuild USV track layers by session
trackLayers.forEach(l => map.removeLayer(l)); trackLayers.forEach(l => map.removeLayer(l));
trackLayers = []; trackLayers = [];
if (manifest) {
if (!manifest) return;
const sessionGroups = {}; const sessionGroups = {};
windowPoints.forEach(p => { windowPoints.forEach(p => {
if (!sessionGroups[p.source]) sessionGroups[p.source] = []; if (!sessionGroups[p.source]) sessionGroups[p.source] = [];
sessionGroups[p.source].push(p); sessionGroups[p.source].push(p);
}); });
manifest.sessions.forEach((sess, i) => { manifest.sessions.forEach((sess, i) => {
const pts = sessionGroups[sess.source_name] || []; const pts = sessionGroups[sess.source_name] || [];
if (pts.length < 2) return; if (pts.length < 2) return;
const coords = pts.map(p => [p.lat, p.lon]); const coords = pts.map(p => [p.lat, p.lon]);
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
const layer = L.polyline(coords, { color, weight: 2.5, opacity: 0.85 }).addTo(map); const layer = L.polyline(coords, { color, weight: 2.5, opacity: 0.85 });
trackLayers.push(layer); trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map);
}); });
}
// Rebuild AUV track
rebuildAuvTrackLayer();
// Update cursor slider range // Update cursor slider range
if (cursorSlider && windowPoints.length > 0) { if (cursorSlider && windowPoints.length > 0) {
@@ -212,19 +303,19 @@ function updateWindow(startMs, endMs) {
function updateStats(startMs, endMs) { function updateStats(startMs, endMs) {
const dur = endMs - startMs; const dur = endMs - startMs;
document.getElementById('stats').textContent = document.getElementById('stats').textContent =
`${windowPoints.length} pts dans fenêtre | durée: ${fmtDuration(dur)} | sessions: ${manifest ? manifest.n_sessions : '?'}`; `USV: ${windowPoints.length} pts | AUV: ${usblWindow.length} pts | ${fmtDuration(dur)} | sessions: ${manifest ? manifest.n_sessions : '?'}`;
} }
// ── Cursor ──────────────────────────────────────────────────────────────── // == Cursor ==
function updateCursor(idx) { function updateCursor(idx) {
idx = Math.max(0, Math.min(idx, windowPoints.length - 1)); idx = Math.max(0, Math.min(idx, windowPoints.length - 1));
const p = windowPoints[idx]; const p = windowPoints[idx];
if (!p) return; if (!p) return;
// Find session color
const sessIdx = manifest ? manifest.sessions.findIndex(s => s.source_name === p.source) : 0; const sessIdx = manifest ? manifest.sessions.findIndex(s => s.source_name === p.source) : 0;
const color = COLORS[Math.max(0, sessIdx) % COLORS.length]; const color = COLORS[Math.max(0, sessIdx) % COLORS.length];
// USV cursor marker
if (!cursorMarker) { if (!cursorMarker) {
cursorMarker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0, color), zIndexOffset: 1000 }).addTo(map); cursorMarker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0, color), zIndexOffset: 1000 }).addTo(map);
} else { } else {
@@ -232,26 +323,79 @@ function updateCursor(idx) {
cursorMarker.setIcon(makeArrowIcon(p.heading || 0, color)); cursorMarker.setIcon(makeArrowIcon(p.heading || 0, color));
} }
const hdg = p.heading !== null && p.heading !== undefined ? `${p.heading.toFixed(1)}°` : 'N/A'; const hdg = p.heading !== null && p.heading !== undefined ? `${p.heading.toFixed(1)}` : 'N/A';
document.getElementById('cursor-info').textContent = document.getElementById('cursor-info').textContent =
`[${idx+1}/${windowPoints.length}] ${p.t} | ${p.lat.toFixed(6)}, ${p.lon.toFixed(6)} | cap: ${hdg} | ${p.source}`; `[${idx+1}/${windowPoints.length}] ${p.t} | ${p.lat.toFixed(6)}, ${p.lon.toFixed(6)} | cap: ${hdg} | ${p.source}`;
// Find nearest USBL point
const nearest = findNearest(usblWindow, p.t_ms);
if (nearest) {
updateAuvCursor(nearest, p);
} else {
clearAuvCursor();
}
} }
// ── Legend ──────────────────────────────────────────────────────────────── function updateAuvCursor(up, usvPt) {
// AUV marker
if (layerVis.auv) {
if (!auvMarker) {
auvMarker = L.marker([up.auv_lat, up.auv_lon], { icon: makeAuvIcon(), zIndexOffset: 900 }).addTo(map);
} else {
if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker);
auvMarker.setLatLng([up.auv_lat, up.auv_lon]);
}
}
// USBL vector USV -> AUV
const usvPos = usvPt ? [usvPt.lat, usvPt.lon] : [up.usv_lat, up.usv_lon];
const auvPos = [up.auv_lat, up.auv_lon];
if (layerVis.vec) {
if (!usblVector) {
usblVector = L.polyline([usvPos, auvPos], {
color: '#888', weight: 1.5, dashArray: '6,4', opacity: 0.9
}).addTo(map);
} else {
if (!map.hasLayer(usblVector)) map.addLayer(usblVector);
usblVector.setLatLngs([usvPos, auvPos]);
}
usblVector.bindTooltip(`Dist ${up.dist.toFixed(1)}m / Az ${up.az.toFixed(1)}deg`, { permanent: false, className: 'usbl-tip' });
}
// Stats panel
if (layerVis.panel) {
document.getElementById('usbl-panel').style.display = 'block';
document.getElementById('up-dist').textContent = `${up.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${up.az.toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${up.elev.toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${up.snr.toFixed(1)}`;
}
}
function clearAuvCursor() {
if (auvMarker) { map.removeLayer(auvMarker); auvMarker = null; }
if (usblVector) { map.removeLayer(usblVector); usblVector = null; }
}
// == Legend ==
function buildLegend() { function buildLegend() {
if (!manifest || manifest.n_sessions <= 1) return;
const el = document.getElementById('legend'); const el = document.getElementById('legend');
el.style.display = 'block'; el.style.display = 'block';
el.innerHTML = manifest.sessions.map((s, i) => { let html = '';
if (manifest) {
manifest.sessions.forEach((s, i) => {
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
const name = s.source_name.replace('_navigation_log','').replace('.csv',''); const name = s.source_name.replace('_navigation_log','').replace('.csv','');
return `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div><span>${name}</span></div>`; html += `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div><span>USV ${name}</span></div>`;
}).join(''); });
}
html += `<div class="legend-item"><div class="legend-dot" style="background:${AUV_COLOR}"></div><span>AUV (USBL proj.)</span></div>`;
html += `<div class="legend-item"><div class="legend-dashed"></div><span>Vecteur USBL</span></div>`;
el.innerHTML = html;
} }
// ── Init sliders ────────────────────────────────────────────────────────── // == Init sliders ==
function initSliders() { function initSliders() {
// Range slider
rangeSlider = noUiSlider.create(document.getElementById('range-slider'), { rangeSlider = noUiSlider.create(document.getElementById('range-slider'), {
start: [tMin, tMax], start: [tMin, tMax],
connect: true, connect: true,
@@ -263,7 +407,6 @@ function initSliders() {
updateWindow(+values[0], +values[1]); updateWindow(+values[0], +values[1]);
}); });
// Cursor slider (single handle inside window)
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), { cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
start: [0], start: [0],
range: { min: 0, max: Math.max(allPoints.length - 1, 1) }, range: { min: 0, max: Math.max(allPoints.length - 1, 1) },
@@ -274,57 +417,64 @@ function initSliders() {
}); });
} }
// ── Reset window ────────────────────────────────────────────────────────── // == Reset ==
document.getElementById('btn-reset').addEventListener('click', () => { document.getElementById('btn-reset').addEventListener('click', () => {
if (rangeSlider) rangeSlider.set([tMin, tMax]); if (rangeSlider) rangeSlider.set([tMin, tMax]);
}); });
// ── Main load ───────────────────────────────────────────────────────────── // == Main load ==
async function loadData() { async function loadData() {
try { try {
const [trackResp, pointsResp, manifestResp] = await Promise.all([ const [trackResp, pointsResp, manifestResp, usblResp, auvTrackResp] = await Promise.all([
fetch('data/track.geojson'), fetch('data/track.geojson'),
fetch('data/points.json'), fetch('data/points.json'),
fetch('data/manifest.json'), fetch('data/manifest.json'),
fetch('data/usbl.json'),
fetch('data/auv_track.geojson'),
]); ]);
if (!trackResp.ok) throw new Error('track.geojson not found'); if (!trackResp.ok) throw new Error('track.geojson not found');
if (!pointsResp.ok) throw new Error('points.json not found'); if (!pointsResp.ok) throw new Error('points.json not found');
if (!manifestResp.ok) throw new Error('manifest.json not found'); if (!manifestResp.ok) throw new Error('manifest.json not found');
if (!usblResp.ok) throw new Error('usbl.json not found');
if (!auvTrackResp.ok) throw new Error('auv_track.geojson not found');
const trackGeo = await trackResp.json(); const trackGeo = await trackResp.json();
allPoints = await pointsResp.json(); allPoints = await pointsResp.json();
manifest = await manifestResp.json(); manifest = await manifestResp.json();
usblMeta = await usblResp.json();
const auvTrackGeo = await auvTrackResp.json();
// Initial full-track display (all sessions) // Sort USBL points by t_ms (already sorted but ensure)
usblPoints = usblMeta.points.sort((a, b) => a.t_ms - b.t_ms);
// Static USV track (all sessions, faded)
L.geoJSON(trackGeo, { L.geoJSON(trackGeo, {
style: (feature) => ({ style: (f) => ({ color: f.properties.color || '#00b4d8', weight: 2, opacity: 0.35 }),
color: feature.properties.color || '#00b4d8',
weight: 2,
opacity: 0.5,
}),
}).addTo(map); }).addTo(map);
// Fit bounds // Static AUV track (always visible as background)
auvTrackLayer = L.geoJSON(auvTrackGeo, {
style: (f) => ({ color: AUV_COLOR, weight: 2, opacity: 0.35 }),
}).addTo(map);
// Fit bounds to USV
if (allPoints.length > 0) { if (allPoints.length > 0) {
const lats = allPoints.map(p => p.lat); const lats = allPoints.map(p => p.lat);
const lons = allPoints.map(p => p.lon); const lons = allPoints.map(p => p.lon);
map.fitBounds([[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]], { padding: [40,40] }); map.fitBounds([[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]], { padding: [40,40] });
} }
// Time range
tMin = manifest.t_min_ms || Math.min(...allPoints.map(p => p.t_ms || Infinity)); tMin = manifest.t_min_ms || Math.min(...allPoints.map(p => p.t_ms || Infinity));
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p => p.t_ms || -Infinity)); tMax = manifest.t_max_ms || Math.max(...allPoints.map(p => p.t_ms || -Infinity));
if (tMin === tMax) tMax = tMin + 1000;
if (tMin === tMax) tMax = tMin + 1000; // safety
document.getElementById('title').textContent = document.getElementById('title').textContent =
`USV Multi-Track — ${manifest.n_sessions} sessions — ${manifest.n_points_sampled} pts`; `COSMA v3 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`;
buildLegend(); buildLegend();
initSliders(); initSliders();
// Initial window = full range
windowPoints = [...allPoints]; windowPoints = [...allPoints];
usblWindow = [...usblPoints];
updateStats(tMin, tMax); updateStats(tMin, tMax);
} catch (e) { } catch (e) {