feat: v2 multi-session parser + timeline range viewer

This commit is contained in:
Poulpe
2026-04-25 20:31:17 +00:00
parent 4ceec113b6
commit b46f136b76
2 changed files with 464 additions and 128 deletions

View File

@@ -1,24 +1,39 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Parse USV long-format CSV → track.geojson + points.json""" """Parse USV long-format CSV → track.geojson + points.json + manifest.json
v2: multi-session support via --input-dir, retro-compat with --input (single file)
"""
import argparse import argparse
import csv import csv
import glob
import json import json
import os import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone
MAX_SLIDER_POINTS = 5000 MAX_SLIDER_POINTS = 10000
def parse_args(): def parse_args():
p = argparse.ArgumentParser(description="Parse USV nav CSV") p = argparse.ArgumentParser(description="Parse USV nav CSV v2")
p.add_argument("--input", required=True, help="CSV navigation log") g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--input", help="Single CSV navigation log (v1 compat)")
g.add_argument("--input-dir", help="Directory: glob *navigation_log*.csv")
p.add_argument("--output", required=True, help="Output directory") p.add_argument("--output", required=True, help="Output directory")
return p.parse_args() return p.parse_args()
def find_csvs(input_dir):
pattern = os.path.join(input_dir, "*navigation_log*.csv")
files = sorted(glob.glob(pattern))
if not files:
print(f"No navigation_log CSVs found in {input_dir}", file=sys.stderr)
sys.exit(1)
return files
def load_csv(path): def load_csv(path):
"""Load long-format CSV into {timestamp: {field: value}}""" """Load long-format CSV {timestamp: {field: value}}"""
rows_by_ts = defaultdict(dict) rows_by_ts = defaultdict(dict)
with open(path, newline="", encoding="utf-8") as f: with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
@@ -41,13 +56,26 @@ def get_float(d, *keys):
return None return None
def build_points(rows_by_ts): def ts_to_ms(ts_str):
"""Build sorted list of {t, lat, lon, heading} where lat/lon valid.""" """Convert ISO-like timestamp string to epoch ms (UTC)."""
# We need to track last known lat/lon/heading per timestamp cluster. # Try formats: '2026-03-24 09:04:07.123456' or '2026-03-24T09:04:07.123456'
# Strategy: walk timestamps in order, emit a point each time we see a Lat or Lon update. for fmt in (
# Accumulate state across timestamps. "%Y-%m-%dT%H:%M:%S.%f",
timestamps = sorted(rows_by_ts.keys()) "%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
):
try:
dt = datetime.strptime(ts_str, fmt).replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def build_points(rows_by_ts, source_name):
"""Build sorted list of {t, t_ms, lat, lon, heading, source}."""
timestamps = sorted(rows_by_ts.keys())
state = {} state = {}
points = [] points = []
@@ -55,7 +83,6 @@ def build_points(rows_by_ts):
updates = rows_by_ts[ts] updates = rows_by_ts[ts]
state.update(updates) state.update(updates)
# Only emit point if we have both Lat and Lon from this or earlier ts
lat = get_float(state, "Lat", "RAW_Lat") lat = get_float(state, "Lat", "RAW_Lat")
lon = get_float(state, "Lon", "RAW_Lon") lon = get_float(state, "Lon", "RAW_Lon")
heading = get_float(state, "Heading", "Yaw") heading = get_float(state, "Heading", "Yaw")
@@ -64,7 +91,6 @@ def build_points(rows_by_ts):
continue continue
if lat == 0.0 and lon == 0.0: if lat == 0.0 and lon == 0.0:
continue continue
# GPS_RAW_INT fallback (1e-7 degrees)
if abs(lat) < 1 and abs(lon) < 1: if abs(lat) < 1 and abs(lon) < 1:
raw_lat = get_float(state, "GPS_RAW_INT_lat") raw_lat = get_float(state, "GPS_RAW_INT_lat")
raw_lon = get_float(state, "GPS_RAW_INT_lon") raw_lon = get_float(state, "GPS_RAW_INT_lon")
@@ -74,87 +100,200 @@ def build_points(rows_by_ts):
else: else:
continue continue
# Only emit if Lat or Lon just updated (reduce duplicate consecutive points)
if "Lat" in updates or "Lon" in updates or "RAW_Lat" in updates or "RAW_Lon" in updates: if "Lat" in updates or "Lon" in updates or "RAW_Lat" in updates or "RAW_Lon" in updates:
t_ms = ts_to_ms(ts)
points.append({ points.append({
"t": ts, "t": ts,
"t_ms": t_ms,
"lat": round(lat, 8), "lat": round(lat, 8),
"lon": round(lon, 8), "lon": round(lon, 8),
"heading": round(heading, 2) if heading is not None else None, "heading": round(heading, 2) if heading is not None else None,
"source": source_name,
}) })
return points return points
def sample_points(points, max_n): def sample_points_session(points, max_total, n_sessions):
if len(points) <= max_n: """Sample per session, always keeping first+last point of each session."""
if not points:
return points return points
step = len(points) / max_n quota = max(10, max_total // max(n_sessions, 1))
return [points[int(i * step)] for i in range(max_n)] if len(points) <= quota:
return points
step = (len(points) - 2) / max(quota - 2, 1)
sampled = [points[0]]
for i in range(1, quota - 1):
sampled.append(points[min(int(1 + i * step), len(points) - 2)])
sampled.append(points[-1])
return sampled
def write_geojson(points, path): def session_bbox(points):
coords = [[p["lon"], p["lat"]] for p in points] lats = [p["lat"] for p in points]
geojson = { lons = [p["lon"] for p in points]
"type": "FeatureCollection", return [min(lons), min(lats), max(lons), max(lats)]
"features": [{
def write_outputs(all_sessions, output_dir):
"""Write track.geojson, points.json, manifest.json."""
os.makedirs(output_dir, exist_ok=True)
# Colors for multi-track
COLORS = ["#00b4d8", "#e94560", "#06d6a0", "#ffd166", "#a855f7", "#f97316"]
# ── track.geojson (MultiLineString per session) ──
features = []
for i, sess in enumerate(all_sessions):
coords = [[p["lon"], p["lat"]] for p in sess["points"]]
features.append({
"type": "Feature", "type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords}, "geometry": {"type": "LineString", "coordinates": coords},
"properties": { "properties": {
"start": points[0]["t"] if points else None, "source_file": sess["source_file"],
"end": points[-1]["t"] if points else None, "source_name": sess["source_name"],
"n_points": len(points), "start_iso": sess["t_start"],
"end_iso": sess["t_end"],
"n_points": len(coords),
"color": COLORS[i % len(COLORS)],
"session_index": i,
} }
}] })
}
with open(path, "w") as f: geojson = {"type": "FeatureCollection", "features": features}
geo_path = os.path.join(output_dir, "track.geojson")
with open(geo_path, "w") as f:
json.dump(geojson, f) json.dump(geojson, f)
print(f" track.geojson: {len(coords)} coords → {path}") print(f" track.geojson: {len(features)} sessions → {geo_path}")
# ── points.json (all sampled, sorted by t_ms) ──
all_points = []
n_sessions = len(all_sessions)
for sess in all_sessions:
sampled = sample_points_session(sess["points"], MAX_SLIDER_POINTS, n_sessions)
all_points.extend(sampled)
# Sort by t_ms (sessions may overlap in time)
all_points.sort(key=lambda p: (p["t_ms"] or 0))
pts_path = os.path.join(output_dir, "points.json")
with open(pts_path, "w") as f:
json.dump(all_points, f)
print(f" points.json: {len(all_points)} points (sampled) → {pts_path}")
# ── manifest.json ──
all_lats = [p["lat"] for s in all_sessions for p in s["points"]]
all_lons = [p["lon"] for s in all_sessions for p in s["points"]]
global_bbox = [min(all_lons), min(all_lats), max(all_lons), max(all_lats)]
all_t_ms = [p["t_ms"] for s in all_sessions for p in s["points"] if p["t_ms"]]
t_min_ms = min(all_t_ms) if all_t_ms else None
t_max_ms = max(all_t_ms) if all_t_ms else None
sessions_meta = []
for sess in all_sessions:
sessions_meta.append({
"file": sess["source_file"],
"source_name": sess["source_name"],
"n_points": sess["n_points_raw"],
"t_start": sess["t_start"],
"t_end": sess["t_end"],
"t_start_ms": sess["t_start_ms"],
"t_end_ms": sess["t_end_ms"],
"bbox": sess["bbox"],
})
manifest = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"n_sessions": len(all_sessions),
"sessions": sessions_meta,
"global_bbox": global_bbox,
"t_min": all_sessions[0]["t_start"] if all_sessions else None,
"t_max": all_sessions[-1]["t_end"] if all_sessions else None,
"t_min_ms": t_min_ms,
"t_max_ms": t_max_ms,
"n_points_total_raw": sum(s["n_points_raw"] for s in all_sessions),
"n_points_sampled": len(all_points),
}
mf_path = os.path.join(output_dir, "manifest.json")
with open(mf_path, "w") as f:
json.dump(manifest, f, indent=2)
print(f" manifest.json → {mf_path}")
return manifest
def write_points_json(points, path): def print_global_stats(manifest, all_sessions):
with open(path, "w") as f: print(f"\n=== Stats globales ===")
json.dump(points, f) print(f" Sessions: {manifest['n_sessions']}")
print(f" points.json: {len(points)} points → {path}") print(f" Points bruts: {manifest['n_points_total_raw']}")
print(f" Points sampled: {manifest['n_points_sampled']}")
print(f" t_min: {manifest['t_min']}")
print(f" t_max: {manifest['t_max']}")
bb = manifest["global_bbox"]
print(f" Bbox: lon [{bb[0]:.5f}, {bb[2]:.5f}] lat [{bb[1]:.5f}, {bb[3]:.5f}]")
if manifest["t_min_ms"] and manifest["t_max_ms"]:
dur_s = (manifest["t_max_ms"] - manifest["t_min_ms"]) / 1000
h, rem = divmod(int(dur_s), 3600)
m, s = divmod(rem, 60)
print(f" Durée totale: {h}h{m:02d}m{s:02d}s")
for i, sess in enumerate(all_sessions):
print(f" Session {i+1}: {sess['source_name']} {sess['n_points_raw']} pts {sess['t_start']}{sess['t_end']}")
def print_stats(points): def process_file(path):
source_name = os.path.basename(path)
print(f"\nChargement {source_name} ...")
rows = load_csv(path)
print(f" {len(rows)} timestamps uniques")
points = build_points(rows, source_name)
if not points: if not points:
print("No valid points found!") print(f" WARNING: aucun point GPS valide dans {source_name}")
return return None
# Filter points without t_ms
points = [p for p in points if p["t_ms"] is not None]
lats = [p["lat"] for p in points] lats = [p["lat"] for p in points]
lons = [p["lon"] for p in points] lons = [p["lon"] for p in points]
print(f"\n=== Stats ===") return {
print(f" N points (full): {len(points)}") "source_file": path,
print(f" First ts: {points[0]['t']}") "source_name": source_name,
print(f" Last ts: {points[-1]['t']}") "points": points,
print(f" Bbox lat: {min(lats):.6f}{max(lats):.6f}") "n_points_raw": len(points),
print(f" Bbox lon: {min(lons):.6f}{max(lons):.6f}") "t_start": points[0]["t"],
headings = [p["heading"] for p in points if p["heading"] is not None] "t_end": points[-1]["t"],
print(f" Heading data: {'yes' if headings else 'no'} ({len(headings)} values)") "t_start_ms": points[0]["t_ms"],
"t_end_ms": points[-1]["t_ms"],
"bbox": [min(lons), min(lats), max(lons), max(lats)],
}
def main(): def main():
args = parse_args() args = parse_args()
os.makedirs(args.output, exist_ok=True)
print(f"Loading {args.input} ...") if args.input:
rows = load_csv(args.input) csv_files = [args.input]
print(f" {len(rows)} unique timestamps") else:
csv_files = find_csvs(args.input_dir)
points = build_points(rows) print(f"Fichiers trouvés: {len(csv_files)}")
print_stats(points) for f in csv_files:
print(f" {os.path.basename(f)}")
if not points: all_sessions = []
for path in csv_files:
sess = process_file(path)
if sess:
all_sessions.append(sess)
if not all_sessions:
print("Aucune session valide.", file=sys.stderr)
sys.exit(1) sys.exit(1)
write_geojson(points, os.path.join(args.output, "track.geojson")) manifest = write_outputs(all_sessions, args.output)
print_global_stats(manifest, all_sessions)
sampled = sample_points(points, MAX_SLIDER_POINTS)
if len(sampled) < len(points):
print(f" Sampled {len(sampled)} points for slider (from {len(points)})")
write_points_json(sampled, os.path.join(args.output, "points.json"))
print("\nDone.") print("\nDone.")

View File

@@ -2,59 +2,128 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>COSMA — USV Track Viewer</title> <title>COSMA — USV Multi-Track Viewer v2</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"/>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; } body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; }
#map { flex: 1; } #map { flex: 1; min-height: 0; }
#controls { #controls {
background: #16213e; background: #16213e;
padding: 8px 12px; padding: 10px 14px 8px;
border-top: 1px solid #0f3460;
display: flex;
flex-direction: column;
gap: 7px;
flex-shrink: 0;
}
#top-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
border-top: 1px solid #0f3460; flex-wrap: wrap;
} }
#slider { flex: 1; cursor: pointer; }
#info { font-size: 11px; min-width: 340px; color: #a0c4ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#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; }
#btn-reset {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 3px 10px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-reset:hover { background: #00b4d8; color: #1a1a2e; }
/* Range slider row */
#range-row {
display: flex;
align-items: center;
gap: 10px;
}
#range-label { font-size: 10px; color: #666; white-space: nowrap; }
#range-slider-wrap { flex: 1; padding: 4px 0; }
#range-dates { display: flex; justify-content: space-between; font-size: 10px; color: #a0c4ff; }
/* Cursor slider row */
#cursor-row {
display: flex;
align-items: center;
gap: 10px;
}
#cursor-label { font-size: 10px; color: #666; white-space: nowrap; }
#cursor-slider-wrap { flex: 1; }
#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-horizontal { height: 6px; }
.noUi-connect { background: #00b4d8; }
.noUi-handle {
background: #00b4d8; border: 2px solid #fff;
border-radius: 50%; width: 14px !important; height: 14px !important;
top: -5px !important; right: -7px !important;
box-shadow: none; cursor: pointer;
}
.noUi-handle::before, .noUi-handle::after { display: none; }
#cursor-slider .noUi-connect { background: #e94560; }
#cursor-slider .noUi-handle { background: #e94560; }
/* Legend */
#legend {
position: absolute; bottom: 200px; left: 10px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace;
display: none;
}
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 12px; height: 4px; border-radius: 2px; flex-shrink: 0; }
</style> </style>
</head> </head>
<body> <body>
<div id="map"></div> <div id="map"></div>
<div id="legend"></div>
<div id="controls"> <div id="controls">
<span id="title">USV Track</span> <div id="top-row">
<input type="range" id="slider" min="0" value="0"> <span id="title">USV Multi-Track v2</span>
<div id="info">Chargement…</div> <span id="stats">Chargement…</span>
<button id="btn-reset">Reset window</button>
</div>
<div id="range-row">
<span id="range-label">Fenêtre</span>
<div id="range-slider-wrap"><div id="range-slider"></div></div>
</div>
<div id="range-dates">
<span id="date-start"></span>
<span id="date-end"></span>
</div>
<div id="cursor-row">
<span id="cursor-label">Curseur</span>
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<div id="cursor-info"></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> <script>
// ── Constants ────────────────────────────────────────────────────────────
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
// ── 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', attribution: '© OpenStreetMap contributors', maxZoom: 19,
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', attribution: '© OpenSeaMap', maxZoom: 18, opacity: 0.8,
maxZoom: 18,
opacity: 0.8,
}); });
// GEBCO bathymetry (tile-based, simpler than WMS)
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(
@@ -63,79 +132,207 @@ L.control.layers(
{ collapsed: false } { collapsed: false }
).addTo(map); ).addTo(map);
// ── SVG arrow marker factory ────────────────────────────────────────────── // ── Arrow marker ──────────────────────────────────────────────────────────
function makeArrowIcon(heading) { function makeArrowIcon(heading, color) {
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">
<g transform="rotate(${heading || 0})"> <g transform="rotate(${heading || 0})">
<polygon points="0,-12 6,8 0,4 -6,8" fill="#e94560" stroke="#fff" stroke-width="1.5"/> <polygon points="0,-12 6,8 0,4 -6,8" fill="${color}" stroke="#fff" stroke-width="1.5"/>
</g> </g>
</svg>`; </svg>`;
return L.divIcon({ return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
html: svg, }
className: '',
iconSize: [32, 32], // ── State ─────────────────────────────────────────────────────────────────
iconAnchor: [16, 16], let allPoints = []; // all sampled points sorted by t_ms
let manifest = null;
let trackLayers = []; // per-session polyline layers
let windowPoints = []; // filtered by range window
let cursorMarker = null;
let rangeSlider = null;
let cursorSlider = null;
let tMin = 0, tMax = 0;
// ── Utils ─────────────────────────────────────────────────────────────────
function fmtMs(ms) {
if (!ms) return '—';
return new Date(ms).toISOString().replace('T',' ').slice(0,19) + ' UTC';
}
function fmtDuration(ms) {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sc = s % 60;
return h > 0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
}
// ── Range window filtering ────────────────────────────────────────────────
function filterPointsInWindow(startMs, endMs) {
return allPoints.filter(p => p.t_ms >= startMs && p.t_ms <= endMs);
}
function updateWindow(startMs, endMs) {
document.getElementById('date-start').textContent = fmtMs(startMs);
document.getElementById('date-end').textContent = fmtMs(endMs);
windowPoints = filterPointsInWindow(startMs, endMs);
// Rebuild track layers by session
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
if (!manifest) return;
const sessionGroups = {};
windowPoints.forEach(p => {
if (!sessionGroups[p.source]) sessionGroups[p.source] = [];
sessionGroups[p.source].push(p);
});
manifest.sessions.forEach((sess, i) => {
const pts = sessionGroups[sess.source_name] || [];
if (pts.length < 2) return;
const coords = pts.map(p => [p.lat, p.lon]);
const color = COLORS[i % COLORS.length];
const layer = L.polyline(coords, { color, weight: 2.5, opacity: 0.85 }).addTo(map);
trackLayers.push(layer);
});
// Update cursor slider range
if (cursorSlider && windowPoints.length > 0) {
cursorSlider.updateOptions({
range: { min: 0, max: Math.max(windowPoints.length - 1, 1) },
});
cursorSlider.set(0);
}
updateStats(startMs, endMs);
}
function updateStats(startMs, endMs) {
const dur = endMs - startMs;
document.getElementById('stats').textContent =
`${windowPoints.length} pts dans fenêtre | durée: ${fmtDuration(dur)} | sessions: ${manifest ? manifest.n_sessions : '?'}`;
}
// ── Cursor ────────────────────────────────────────────────────────────────
function updateCursor(idx) {
idx = Math.max(0, Math.min(idx, windowPoints.length - 1));
const p = windowPoints[idx];
if (!p) return;
// Find session color
const sessIdx = manifest ? manifest.sessions.findIndex(s => s.source_name === p.source) : 0;
const color = COLORS[Math.max(0, sessIdx) % COLORS.length];
if (!cursorMarker) {
cursorMarker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0, color), zIndexOffset: 1000 }).addTo(map);
} else {
cursorMarker.setLatLng([p.lat, p.lon]);
cursorMarker.setIcon(makeArrowIcon(p.heading || 0, color));
}
const hdg = p.heading !== null && p.heading !== undefined ? `${p.heading.toFixed(1)}°` : 'N/A';
document.getElementById('cursor-info').textContent =
`[${idx+1}/${windowPoints.length}] ${p.t} | ${p.lat.toFixed(6)}, ${p.lon.toFixed(6)} | cap: ${hdg} | ${p.source}`;
}
// ── Legend ────────────────────────────────────────────────────────────────
function buildLegend() {
if (!manifest || manifest.n_sessions <= 1) return;
const el = document.getElementById('legend');
el.style.display = 'block';
el.innerHTML = manifest.sessions.map((s, i) => {
const color = COLORS[i % COLORS.length];
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>`;
}).join('');
}
// ── Init sliders ──────────────────────────────────────────────────────────
function initSliders() {
// Range slider
rangeSlider = noUiSlider.create(document.getElementById('range-slider'), {
start: [tMin, tMax],
connect: true,
range: { min: tMin, max: tMax },
step: 1000,
behaviour: 'drag',
});
rangeSlider.on('update', (values) => {
updateWindow(+values[0], +values[1]);
});
// Cursor slider (single handle inside window)
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
start: [0],
range: { min: 0, max: Math.max(allPoints.length - 1, 1) },
step: 1,
});
cursorSlider.on('update', (values) => {
updateCursor(Math.round(+values[0]));
}); });
} }
// ── Data loading ────────────────────────────────────────────────────────── // ── Reset window ──────────────────────────────────────────────────────────
let points = []; document.getElementById('btn-reset').addEventListener('click', () => {
let trackLayer = null; if (rangeSlider) rangeSlider.set([tMin, tMax]);
let marker = null; });
// ── Main load ─────────────────────────────────────────────────────────────
async function loadData() { async function loadData() {
try { try {
const [trackResp, pointsResp] = await Promise.all([ const [trackResp, pointsResp, manifestResp] = await Promise.all([
fetch('track.geojson'), fetch('data/track.geojson'),
fetch('points.json'), fetch('data/points.json'),
fetch('data/manifest.json'),
]); ]);
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');
const trackGeo = await trackResp.json(); const trackGeo = await trackResp.json();
points = await pointsResp.json(); allPoints = await pointsResp.json();
manifest = await manifestResp.json();
// Draw track // Initial full-track display (all sessions)
trackLayer = L.geoJSON(trackGeo, { L.geoJSON(trackGeo, {
style: { color: '#00b4d8', weight: 2, opacity: 0.8 }, style: (feature) => ({
color: feature.properties.color || '#00b4d8',
weight: 2,
opacity: 0.5,
}),
}).addTo(map); }).addTo(map);
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
// Init slider // Fit bounds
const slider = document.getElementById('slider'); if (allPoints.length > 0) {
slider.max = points.length - 1; const lats = allPoints.map(p => p.lat);
slider.value = 0; const lons = allPoints.map(p => p.lon);
slider.addEventListener('input', () => updateMarker(+slider.value)); map.fitBounds([[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]], { padding: [40,40] });
// Init marker
if (points.length > 0) {
const p = points[0];
marker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0) }).addTo(map);
updateInfo(0);
} }
document.getElementById('title').textContent = `USV Track — ${points.length} pts`; // Time range
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));
if (tMin === tMax) tMax = tMin + 1000; // safety
document.getElementById('title').textContent =
`USV Multi-Track — ${manifest.n_sessions} sessions — ${manifest.n_points_sampled} pts`;
buildLegend();
initSliders();
// Initial window = full range
windowPoints = [...allPoints];
updateStats(tMin, tMax);
} catch (e) { } catch (e) {
document.getElementById('info').textContent = 'Erreur: ' + e.message; document.getElementById('stats').textContent = 'Erreur: ' + e.message;
console.error(e);
} }
} }
function updateMarker(idx) {
const p = points[idx];
if (!p || !marker) return;
marker.setLatLng([p.lat, p.lon]);
marker.setIcon(makeArrowIcon(p.heading || 0));
updateInfo(idx);
}
function updateInfo(idx) {
const p = points[idx];
if (!p) return;
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A';
document.getElementById('info').textContent =
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`;
}
loadData(); loadData();
</script> </script>
</body> </body>