feat(viewer): v3 AUV track + USBL vector overlay
This commit is contained in:
125
tools/usbl_to_json.py
Normal file
125
tools/usbl_to_json.py
Normal 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()
|
||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user