feat: v2 multi-session parser + timeline range viewer
This commit is contained in:
@@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user