Files
cosma-nav-tools/viewer/index.html

1315 lines
50 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>COSMA — NAV Viewer v6</title>
<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://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css"/>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100vh; overflow: hidden;
font-family: monospace; background: #1a1a2e; color: #e0e0e0;
display: grid;
grid-template-rows: 36px 40px minmax(200px, 1fr) 54px 28px 1fr;
}
.hidden { display: none !important; }
/* Row 0: datebar */
#datebar {
background: #0d0d20; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 8px; padding: 0 14px;
flex-shrink: 0; height: 36px; overflow: hidden;
}
#date-picker {
background: #0f3460; border: 1px solid #00b4d8; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer;
}
#date-picker::-webkit-calendar-picker-indicator { filter: invert(0.8); cursor: pointer; }
#btn-today {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-today:hover { background: #00b4d8; color: #1a1a2e; }
#mission-label { font-size: 11px; font-family: monospace; padding: 2px 8px; }
#mission-label.has-data { color: #06d6a0; }
#mission-label.no-data { color: #555; }
#load-status { font-size: 10px; color: #888; flex: 1; }
/* Row 1: header */
#header {
background: #12122a; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 12px; padding: 0 14px;
flex-shrink: 0; height: 40px; overflow: hidden;
}
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
#stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#layer-toggles { display: none; }
.layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 7px; 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; }
/* Row 2: map */
#map { position: relative; min-height: 0; }
/* No-data overlay */
#no-data-overlay {
position: absolute; inset: 0; z-index: 2000;
background: rgba(10,10,26,0.82);
display: flex; align-items: center; justify-content: center;
pointer-events: none;
}
#no-data-overlay.hidden { display: none; }
#no-data-overlay .nodata-msg {
font-size: 18px; color: #555; font-family: monospace; text-align: center;
border: 1px solid #1a1a3e; padding: 20px 32px; background: #0a0a1a;
pointer-events: none;
}
/* USBL panel over map */
#usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000;
background: rgba(22,33,62,0.92); 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 bottom-right on map */
#map-layer-toggles {
position: absolute; bottom: 10px; right: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px; align-items: flex-end;
}
/* legend over map */
#legend {
position: absolute; bottom: 10px; 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;
}
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 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; }
/* Row 3: controls */
#controls {
background: #16213e; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; display: flex; flex-direction: column; gap: 4px;
flex-shrink: 0;
}
#ctrl-row1, #ctrl-row2 { display: flex; align-items: center; gap: 10px; }
#ctrl-label { font-size: 10px; color: #666; white-space: nowrap; }
#cursor-slider-wrap { flex: 1; padding: 4px 0; }
.noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; }
.noUi-horizontal { height: 6px; }
.noUi-connect { background: #e94560; }
.noUi-handle {
background: #e94560; 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-time { font-size: 11px; color: #e0e0e0; white-space: nowrap; min-width: 180px; }
#trail-select {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px; cursor: pointer;
}
#btn-viewall, #btn-play {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
/* Row 4: scroll container with 2x2 charts + USV/AUV panels */
#graphs-section {
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #0a0a1a;
}
#charts-4grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
padding: 3px;
flex: 1;
min-height: 180px;
}
.chart-wrap {
background: #12122a;
border: 1px solid #0f3460;
display: flex; flex-direction: column;
padding: 4px 8px 3px;
min-height: 0;
overflow: hidden;
position: relative;
}
.chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; }
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
/* Tab navigation */
#panels-tabs {
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
display: flex; gap: 0; flex-shrink: 0;
}
.panel-tab {
font-family: monospace; font-size: 11px; padding: 6px 16px; cursor: pointer;
border: none; border-right: 1px solid #0f3460; background: transparent;
color: #666;
}
.panel-tab.active { background: #16213e; color: #e0e0e0; }
.panel-tab:hover:not(.active) { background: #0f3460; color: #a0c4ff; }
.panel-section { display: none; }
.panel-section.active { display: block; }
/* Pipeline overlay */
#pipeline-overlay {
display: none;
position: fixed; inset: 0; z-index: 9000;
background: rgba(10,10,26,0.92);
backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
#pipeline-overlay.visible { display: flex; }
#pipeline-box {
background: #12122a; border: 1px solid #0f3460;
padding: 20px 24px; border-radius: 4px;
max-width: 95vw; max-height: 90vh;
overflow: auto; position: relative;
min-width: 320px;
}
#pipeline-close {
position: absolute; top: 8px; right: 10px;
background: none; border: none; color: #e94560;
font-size: 18px; cursor: pointer; font-family: monospace; line-height: 1;
}
#pipeline-title {
font-size: 12px; color: #a0c4ff; margin-bottom: 14px;
font-family: monospace; letter-spacing: 1px;
}
#btn-pipeline {
background: #0f3460; border: 1px solid #a855f7; color: #a855f7;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px; flex-shrink: 0;
}
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
#sortie-select {
background: #0f3460; border: 1px solid #e94560; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer; max-width: 160px;
}
#btn-sync {
background: #0f3460; border: 1px solid #e94560; color: #e94560;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-sync:hover { background: #e94560; color: #1a1a2e; }
#btn-sync:disabled { opacity: 0.4; cursor: not-allowed; }
#sync-progress {
font-size: 10px; color: #06d6a0; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.panel-header {
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; font-size: 11px; font-weight: bold; color: #e94560;
display: flex; align-items: center; gap: 8px;
}
.auv-tab {
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
border: 1px solid #0f3460; background: transparent; color: #a0c4ff; border-radius: 2px;
}
.auv-tab.active { background: #0f3460; color: #e0e0e0; }
.graphs-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px;
padding: 4px 8px; background: #12122a;
}
.graph-cell { height: 130px; background: #1a1a2e; }
.graph-cell.wide { grid-column: span 2; height: 130px; }
</style>
</head>
<body>
<!-- Row 0: Datebar -->
<div id="datebar">
<input type="date" id="date-picker">
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data"></span>
<span id="load-status" style="font-size:10px;color:#888;font-style:italic;flex:1;"></span>
<button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button>
</div>
<!-- Pipeline overlay -->
<div id="pipeline-overlay" onclick="hidePipelineOnBackdrop(event)">
<div id="pipeline-box">
<button id="pipeline-close" onclick="togglePipeline()"></button>
<div id="pipeline-title">DATA PIPELINE — COSMA NAV VIEWER</div>
<div class="mermaid">
flowchart LR
subgraph SOURCES
MCAP["USV/AUV .mcap files\nROS2 bags post-Monaco"]
CSV["USV/AUV .csv files\npré-Monaco sans ROS2"]
end
subgraph PARSING
MCAP_R["MCAP Reader\nextract topics"]
CSV_R["pandas read_csv"]
end
subgraph CACHE
PQ["Parquet cache\n/cache/*.parquet"]
end
subgraph API["FastAPI 8766"]
A1["/api/data-dates/"]
A2["/api/missions/"]
A3["/api/ship/.../track/"]
A4["/api/sub/.../series/"]
A5["/api/sub/.../usbl_track/"]
end
subgraph VIEWER["NAV Viewer 8765"]
MAP["Leaflet map\nUSV arrow + AUV USBL"]
CHARTS["Plotly 4 charts\ndepth/PWM/USBL"]
SLIDER["Slider 24h cursor"]
end
MCAP --> MCAP_R --> PQ
CSV --> CSV_R --> PQ
PQ --> A1 & A2 & A3 & A4 & A5
A1 --> SLIDER
A3 --> MAP
A4 --> CHARTS
A5 --> MAP
</div>
</div>
</div>
<!-- Row 1: Header -->
<div id="header">
<span id="title">COSMA NAV v6</span>
<span id="stats" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#a0c4ff;"></span>
<select id="sortie-select"><option value="">Chargement Gdrive (~30s)...</option></select>
<span id="mission-label-header" style="font-size:11px;font-family:monospace;padding:2px 8px;color:#555;white-space:nowrap;"></span>
<button id="btn-sync" disabled>Sync &amp; Process</button>
<span id="sync-progress"></span>
</div>
<!-- Row 2: Map -->
<div id="map">
<div id="no-data-overlay">
<div class="nodata-msg">⬇ Sélectionnez une sortie pour charger les données</div>
</div>
<div id="map-layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">⛵ USV</button>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">🛥 AUV</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
</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>
<!-- Row 3: Controls -->
<div id="controls">
<div id="ctrl-row1">
<span id="ctrl-label" style="font-size:10px;color:#666;white-space:nowrap;">Heure:</span>
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<span id="cursor-time" style="font-size:11px;color:#e0e0e0;white-space:nowrap;min-width:70px;"></span>
<label for="trail-select" style="font-size:10px;color:#666;white-space:nowrap;">Trail:</label>
<select id="trail-select">
<option value="10000">10s</option>
<option value="30000">30s</option>
<option value="60000" selected>60s</option>
<option value="300000">5min</option>
<option value="900000">15min</option>
<option value="3600000">1h</option>
<option value="0">ALL</option>
</select>
<button id="btn-viewall" onclick="viewAll()">View all</button>
<button id="btn-play"></button>
<label for="window-select" style="font-size:10px;color:#666;white-space:nowrap;">Window:</label>
<select id="window-select" style="background:#0f3460;border:1px solid #a855f7;color:#a855f7;font-family:monospace;font-size:11px;padding:2px 6px;border-radius:2px;cursor:pointer;">
<option value="10000">10s</option>
<option value="30000">30s</option>
<option value="60000" selected>60s</option>
<option value="300000">5min</option>
<option value="900000">15min</option>
<option value="0">ALL</option>
</select>
</div>
<div id="ctrl-row2">
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
</div>
</div>
<!-- Row 4: tabs + panels -->
<div id="panels-tabs">
<button class="panel-tab active" onclick="switchTab('charts')">🗺 Charts globaux</button>
<button class="panel-tab" onclick="switchTab('usv')">⛵ USV</button>
<button class="panel-tab" onclick="switchTab('auv')">🛥 AUV <span id="auv-tabs"></span></button>
</div>
<div id="graphs-section">
<!-- Tab: Charts globaux -->
<div id="panel-charts" class="panel-section active">
<div id="charts-4grid">
<div class="chart-wrap">
<div class="chart-title">Depth AUV (m)</div>
<div class="plotly-wrap"><div id="chart-depth"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors AUV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-auv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors USV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-usv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div>
</div>
</div>
<!-- Tab: USV -->
<div id="panel-usv" class="panel-section hidden">
<div class="panel-header">⛵ USV</div>
<div class="graphs-grid" id="usv-graphs">
<div class="graph-cell" id="usv-yaw"></div>
<div class="graph-cell" id="usv-heading"></div>
<div class="graph-cell" id="usv-batt"></div>
<div class="graph-cell" id="usv-gps"></div>
<div class="graph-cell" id="usv-usbl-dist"></div>
<div class="graph-cell" id="usv-usbl-angle"></div>
<div class="graph-cell wide" id="usv-motors"></div>
<div class="graph-cell wide" id="usv-status"></div>
</div>
</div>
<!-- Tab: AUV -->
<div id="panel-auv" class="panel-section hidden">
<div class="panel-header" id="auv-panel-header">🛥 AUV</div>
<div class="graphs-grid" id="auv-graphs">
<div class="graph-cell" id="auv-pry"></div>
<div class="graph-cell" id="auv-depth"></div>
<div class="graph-cell" id="auv-alt"></div>
<div class="graph-cell" id="auv-obs"></div>
<div class="graph-cell" id="auv-usbl-dist"></div>
<div class="graph-cell" id="auv-usbl-angle"></div>
<div class="graph-cell" id="auv-batt"></div>
<div class="graph-cell" id="auv-status"></div>
<div class="graph-cell wide" id="auv-motors"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.js"></script>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<script>
// == Constants ==
const API = (location.hostname === 'laboratoire.freeboxos.fr')
? '/cosma-pK8j876lkj-api'
: 'http://192.168.0.83:8766';
const API2 = (location.hostname === 'laboratoire.freeboxos.fr')
? '/cosma-pK8j876lkj-pipe'
: 'http://192.168.0.83:8767';
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
const AUV_COLOR = '#ff8800';
const PLOTLY_LAYOUT = {
paper_bgcolor: '#12122a',
plot_bgcolor: '#12122a',
margin: { t: 2, r: 8, b: 24, l: 42 },
font: { color: '#a0c4ff', size: 9, family: 'monospace' },
xaxis: {
gridcolor: '#1a1a3e', zerolinecolor: '#1a1a3e',
tickformat: '%H:%M:%S', color: '#666'
},
yaxis: { gridcolor: '#1a1a3e', zerolinecolor: '#1a1a3e', color: '#666' },
showlegend: false,
autosize: true,
};
const PLOTLY_CONFIG = { responsive: true, displayModeBar: false };
// == Map init ==
const map = L.map('map', { zoomControl: true });
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '(c) OpenStreetMap contributors', maxZoom: 19
});
osm.addTo(map);
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8
});
const gebco = L.tileLayer(
'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 }
);
L.control.layers(
{ 'OpenStreetMap': osm },
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymetrie)': gebco },
{ collapsed: false }
).addTo(map);
// == Helpers ==
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">
<g transform="rotate(${heading||0})"><polygon points="0,-12 6,8 0,4 -6,8" fill="${color}" stroke="#fff" stroke-width="1.5"/></g></svg>`;
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] });
}
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];
}
function filterWindow(arr, t0, t1) {
const lo = bisectLeft(arr, t0);
const res = [];
for (let i = lo; i < arr.length && arr[i].t_ms <= t1; i++) res.push(arr[i]);
return res;
}
function fmtMs(ms) {
if (!ms) return '—';
return new Date(ms).toISOString().replace('T',' ').slice(0,19)+' UTC';
}
function fmtDur(ms) {
const s = Math.floor(ms/1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sc = s%60;
return h>0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
}
// Convert ISO timestamp string to ms
function isoToMs(t) { return new Date(t).getTime(); }
// Convert Unix seconds to ms
function unixToMs(t) { return t * 1000; }
// == State ==
let allPoints = []; // {t_ms, lat, lon, heading, source}
let usblPoints = []; // {t_ms, auv_lat, auv_lon, dist, az, elev, snr}
let sessionsMeta = []; // loaded session metadata
let tMin = 0, tMax = 0, tNow = 0;
let trailMs = 60000;
let trackLayers = [], auvTrackLayer = null, cursorMarker = null, auvMarker = null, usblVector = null;
const layerVis = { usv: true, auv: true, vec: true, panel: true };
let playTimer = null;
let cursorSlider = null;
// Plotly chart data state
let depthTraces = [];
let pwmAuvTraces = [];
let pwmUsvTraces = [];
let usblDistTraces = [];
// == 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' && usblVector) layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector);
if (name==='panel') document.getElementById('usbl-panel').style.display = layerVis.panel && usblPoints.length ? 'block' : 'none';
}
// == View All ==
function viewAll() {
document.getElementById('trail-select').value = '0';
trailMs = 0;
applyTrailAndCursor();
}
// == Plotly charts init ==
function initCharts() {
const base = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
Plotly.newPlot('chart-depth', [], { ...base, yaxis: { ...base.yaxis, autorange: 'reversed' } }, PLOTLY_CONFIG);
Plotly.newPlot('chart-pwm-auv', [], { ...base }, PLOTLY_CONFIG);
Plotly.newPlot('chart-pwm-usv', [], { ...base }, PLOTLY_CONFIG);
Plotly.newPlot('chart-usbl', [], { ...base }, PLOTLY_CONFIG);
}
function populatePlotlyCharts() {
const layout = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
const depthLayout = { ...layout, yaxis: { ...layout.yaxis, autorange: 'reversed' } };
Plotly.react('chart-depth', depthTraces, depthLayout, PLOTLY_CONFIG);
Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
}
// Update cursor line on all Plotly charts
function updateChartsCursor() {
if (!tMin || !tMax) return;
const tNowDate = new Date(tNow);
const t0Date = new Date(trailMs === 0 ? tMin : Math.max(tMin, tNow - trailMs));
const t1Date = new Date(trailMs === 0 ? tMax : tNow);
const shapeBase = { type: 'line', x0: tNowDate, x1: tNowDate, y0: 0, y1: 1, yref: 'paper',
line: { color: '#e94560', width: 1.5, dash: 'dot' } };
const rangeUpdate = { 'xaxis.range': [t0Date, t1Date] };
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
Plotly.relayout(id, { ...rangeUpdate, shapes: [shapeBase] });
});
}
// == Apply trail + cursor ==
function applyTrailAndCursor() {
const trail = +document.getElementById('trail-select').value;
trailMs = trail;
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
const t1 = tNow;
const trailPtsUsv = filterWindow(allPoints, t0, t1);
const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
// Rebuild USV trail layers
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
const sourceNames = [...new Set(allPoints.map(p => p.source))];
const groups = {};
trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
sourceNames.forEach((src, i) => {
const pts = groups[src] || [];
if (pts.length < 2) return;
const layer = L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:2.5, opacity:0.85 });
trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map);
});
// Rebuild AUV trail layer
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
if (trailPtsUsbl.length >= 2) {
auvTrackLayer = L.polyline(trailPtsUsbl.map(p=>[p.auv_lat,p.auv_lon]), { color:AUV_COLOR, weight:2.5, opacity:0.85 });
if (layerVis.auv) auvTrackLayer.addTo(map);
}
// USV cursor marker at tNow
const pUsv = findNearest(allPoints, tNow);
if (pUsv) {
const srcIdx = sourceNames.indexOf(pUsv.source);
const color = COLORS[Math.max(0, srcIdx) % COLORS.length];
if (!cursorMarker) {
cursorMarker = L.marker([pUsv.lat,pUsv.lon], { icon:makeArrowIcon(pUsv.heading||0,color), zIndexOffset:1000 }).addTo(map);
} else {
cursorMarker.setLatLng([pUsv.lat,pUsv.lon]);
cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color));
}
document.getElementById('cursor-info').textContent =
`${fmtMs(pUsv.t_ms)} | ${pUsv.lat.toFixed(6)}, ${pUsv.lon.toFixed(6)} | cap: ${pUsv.heading!=null?pUsv.heading.toFixed(1):'N/A'} | ${pUsv.source}`;
}
// AUV + USBL at tNow
const pUsbl = findNearest(usblPoints, tNow);
if (pUsbl) {
if (layerVis.auv) {
if (!auvMarker) {
auvMarker = L.marker([pUsbl.auv_lat,pUsbl.auv_lon], { icon:makeAuvIcon(), zIndexOffset:900 }).addTo(map);
} else {
if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker);
auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]);
}
}
const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.auv_lat,pUsbl.auv_lon];
if (layerVis.vec) {
if (!usblVector) {
usblVector = L.polyline([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]], { color:'#888', weight:1.5, dashArray:'6,4', opacity:0.9 }).addTo(map);
} else {
if (!map.hasLayer(usblVector)) map.addLayer(usblVector);
usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]);
}
}
if (layerVis.panel && pUsbl.dist != null) {
document.getElementById('usbl-panel').style.display = 'block';
document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${(pUsbl.az||0).toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${(pUsbl.elev||0).toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${(pUsbl.snr||0).toFixed(1)}`;
}
}
document.getElementById('stats').textContent =
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
updateChartsCursor();
if (tNow) updateCursor(tNow / 1000);
}
// == Cursor slider ==
function initCursorSlider() {
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider');
cursorSlider = noUiSlider.create(el, {
start: [tMin],
range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
step: 1000,
});
cursorSlider.on('update', (values) => {
tNow = Math.round(+values[0]);
// Show HH:MM:SS for slider label
const d = new Date(tNow);
const hms = d.toISOString().slice(11,19);
document.getElementById('cursor-time').textContent = `Heure: ${hms}`;
applyTrailAndCursor();
});
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
}
// == Play ==
document.getElementById('btn-play').addEventListener('click', () => {
if (playTimer) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
document.getElementById('btn-play').textContent='❚❚';
playTimer = setInterval(() => {
if (tNow >= tMax) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
tNow = Math.min(tNow + 5000, tMax);
if (cursorSlider) cursorSlider.set(tNow);
}, 100);
});
// == Legend ==
function buildLegend(sourceNames) {
let html = '';
sourceNames.forEach((src, i) => {
const name = src.replace(/_navigation_log|\.csv/g,'');
html += `<div class="legend-item"><div class="legend-dot" style="background:${COLORS[i%COLORS.length]}"></div><span>USV ${name}</span></div>`;
});
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>`;
document.getElementById('legend').innerHTML = html;
}
// == Clear map layers ==
function clearMapLayers() {
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
if (cursorMarker) { map.removeLayer(cursorMarker); cursorMarker=null; }
if (auvMarker) { map.removeLayer(auvMarker); auvMarker=null; }
if (usblVector) { map.removeLayer(usblVector); usblVector=null; }
}
// == Load data for a given date ==
async function loadDate(date) {
setStatus('chargement...');
showNoDataOverlay(true);
clearMapLayers();
allPoints = [];
usblPoints = [];
depthTraces = [];
pwmAuvTraces = [];
pwmUsvTraces = [];
usblDistTraces = [];
// Find which missions/dives match this date
try {
const mResp = await fetch(`${API}/api/missions`);
const missions = await mResp.json();
const dateStr = date.replace(/-/g,''); // YYYYMMDD
const fetches = missions.map(async mission => {
const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`);
const dives = await dResp.json();
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
});
const missionDives = (await Promise.all(fetches)).filter(md => md.dives.length > 0);
if (!missionDives.length) {
setStatus('Aucune donnée pour cette date');
document.getElementById('stats').textContent = 'Aucune donnée';
return;
}
// Fetch all sessions for matching dives
let totalShip = 0, totalSub = 0;
const allFetches = [];
for (const { mission, dives } of missionDives) {
for (const dive of dives) {
allFetches.push(loadDiveData(mission.id, dive.id));
totalShip += dive.ship_session_count || 0;
totalSub += dive.sub_session_count || 0;
}
}
await Promise.all(allFetches);
// Sort
allPoints.sort((a,b)=>a.t_ms-b.t_ms);
usblPoints.sort((a,b)=>a.t_ms-b.t_ms);
if (!allPoints.length && !usblPoints.length) {
setStatus('Pas de points pour cette date');
return;
}
const allTimes = [
...allPoints.map(p=>p.t_ms),
...usblPoints.map(p=>p.t_ms)
];
tMin = Math.min(...allTimes);
tMax = Math.max(...allTimes);
tNow = tMin;
// Fit map
const allLats = [
...allPoints.map(p=>p.lat),
...usblPoints.map(p=>p.auv_lat)
].filter(Boolean);
const allLons = [
...allPoints.map(p=>p.lon),
...usblPoints.map(p=>p.auv_lon)
].filter(Boolean);
if (allLats.length) {
map.fitBounds([
[Math.min(...allLats), Math.min(...allLons)],
[Math.max(...allLats), Math.max(...allLons)]
], { padding: [40,40] });
}
// Static faded USV track background
const sourceNames = [...new Set(allPoints.map(p=>p.source))];
const groups = {};
allPoints.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
sourceNames.forEach((src,i) => {
const pts = groups[src]||[];
if (pts.length < 2) return;
L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:1.5, opacity:0.2 }).addTo(map);
});
buildLegend(sourceNames);
populatePlotlyCharts();
initCursorSlider();
applyTrailAndCursor();
showNoDataOverlay(false);
document.getElementById('title').textContent = `COSMA v6 — ${date}`;
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
} catch(e) {
setStatus('Erreur: ' + e.message);
console.error(e);
}
}
async function loadDiveData(missionId, diveId) {
try {
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
const sessions = await sResp.json();
const sessionFetches = [];
// Ship sessions
if (sessions.ship) {
sessions.ship.forEach(sess => {
sessionFetches.push(loadShipSession(missionId, diveId, sess.id));
});
}
// Sub sessions
if (sessions.sub) {
sessions.sub.forEach(sess => {
sessionFetches.push(loadSubSession(missionId, diveId, sess.id));
});
}
await Promise.all(sessionFetches);
} catch(e) { console.warn('loadDiveData error', diveId, e); }
}
async function loadShipSession(missionId, diveId, sessionId) {
try {
const [trackResp, seriesResp] = await Promise.all([
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`),
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`),
]);
if (trackResp.ok) {
const d = await trackResp.json();
const pts = (d.points||[]).map(p => ({
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
heading: p.heading || null, source: sessionId
}));
allPoints.push(...pts);
}
if (seriesResp.ok) {
const d = await seriesResp.json();
// USV PWM: M1..M8
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
if (!pts || !pts.length) return;
pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 },
});
});
}
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
}
async function loadSubSession(missionId, diveId, sessionId) {
try {
const [seriesResp, usblResp] = await Promise.all([
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`),
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`),
]);
if (seriesResp.ok) {
const d = await seriesResp.json();
// Depth trace
if (d.depth && d.depth.length) {
depthTraces.push({
x: d.depth.map(p => new Date(unixToMs(p.t))),
y: d.depth.map(p => p.v),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#06d6a0', width: 1.5 },
});
}
// AUV motors: m1..m8
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
if (!pts || !pts.length) return;
pwmAuvTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
});
});
}
if (usblResp.ok) {
const d = await usblResp.json();
const pts = (d.points||[]);
if (pts.length) {
// Build usblPoints — API fields: t(unix s), lat/lon(AUV), usv_lat/usv_lon, distance_m, azimuth_deg, snr
usblPoints.push(...pts.map(p => ({
t_ms: unixToMs(p.t),
auv_lat: p.lat, auv_lon: p.lon,
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
})));
// USBL distance trace
usblDistTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.distance_m),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#a855f7', width: 1.5 },
});
}
}
} catch(e) { console.warn('loadSubSession error', sessionId, e); }
}
function setStatus(msg) {
document.getElementById('load-status').textContent = msg;
}
// == Datebar ==
let availableDates = [];
async function initDatebar() {
const label = document.getElementById('mission-label');
const picker = document.getElementById('date-picker');
try {
const resp = await fetch(`${API}/api/data-dates`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
availableDates = data.dates || [];
if (!availableDates.length) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
const dates = availableDates.map(d => d.date).sort();
picker.min = dates[0];
picker.max = dates[dates.length-1];
picker.value = dates[dates.length-1];
updateMissionLabel(picker.value);
picker.addEventListener('change', () => {
updateMissionLabel(picker.value);
loadDate(picker.value);
});
loadDate(picker.value);
} catch(e) {
label.textContent = 'API indisponible';
label.className = 'no-data';
setStatus('API 8766 inaccessible');
console.warn(e);
}
}
function updateMissionLabel(date) {
const label = document.getElementById('mission-label');
const entry = availableDates.find(d => d.date === date);
if (!entry) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
const mission = entry.missions && entry.missions.length ? entry.missions[0] : '?';
const n = entry.session_count || 0;
label.textContent = `${mission} (${n} session${n>1?'s':''})`;
label.className = 'has-data';
}
function datePickerToday() {
const today = new Date().toISOString().slice(0,10);
const picker = document.getElementById('date-picker');
picker.value = today;
updateMissionLabel(today);
loadDate(today);
}
// == Tab switching ==
function switchTab(name) {
['charts','usv','auv'].forEach(t => {
const p = document.getElementById('panel-'+t);
if (p) p.classList.toggle('active', t === name);
if (p) p.classList.toggle('hidden', t !== name);
});
document.querySelectorAll('.panel-tab').forEach((btn, i) => {
const names = ['charts','usv','auv'];
btn.classList.toggle('active', names[i] === name);
});
}
// == No-data overlay ==
function showNoDataOverlay(show) {
const el = document.getElementById('no-data-overlay');
if (el) el.classList.toggle('hidden', !show);
// Show/hide layer toggles
const lt = document.getElementById('map-layer-toggles');
if (lt) lt.style.display = show ? 'none' : 'flex';
}
// == Pipeline overlay ==
let _pipelineRendered = false;
function togglePipeline() {
const ov = document.getElementById('pipeline-overlay');
ov.classList.toggle('visible');
if (ov.classList.contains('visible') && !_pipelineRendered) {
mermaid.run({ nodes: document.querySelectorAll('#pipeline-overlay .mermaid') });
_pipelineRendered = true;
}
}
function hidePipelineOnBackdrop(e) {
if (e.target === document.getElementById('pipeline-overlay')) togglePipeline();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
// == Task 8: USV rendering helpers ==
function _pts(sig) {
if (!sig || !sig.length) return [[], []];
return [sig.map(p => new Date(p.t * 1000)), sig.map(p => p.v)];
}
const PLOTLY_LAYOUT_BASE = {
margin: {l:40, r:8, t:20, b:30},
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
font: {color: '#e0e0e0', size: 9, family: 'monospace'},
xaxis: {color: '#555', gridcolor: '#1e1e3a', type: 'date'},
yaxis: {color: '#555', gridcolor: '#1e1e3a'},
showlegend: false,
};
function _layout(title, yLabel) {
const l = JSON.parse(JSON.stringify(PLOTLY_LAYOUT_BASE));
l.title = {text: title, font: {size: 9, color: '#888'}};
if (yLabel) l.yaxis.title = {text: yLabel, font: {size: 8}};
return l;
}
function renderUSV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const [yt, yv] = _pts(signals.Yaw);
Plotly.react('usv-yaw', [{x:yt, y:yv, type:'scatter', mode:'lines', line:{color:'#00b4d8',width:1}, name:'Yaw'}], _layout('Yaw','°'), cfg);
const [ht, hv] = _pts(signals.Heading);
Plotly.react('usv-heading', [{x:ht, y:hv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1}}], _layout('Heading','°'), cfg);
const [bt, bv] = _pts(signals.BattVoltage);
Plotly.react('usv-batt', [{x:bt, y:bv, type:'scatter', mode:'lines', line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [gt, gv] = _pts(signals.gps_fix);
Plotly.react('usv-gps', [{x:gt, y:gv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('GPS fix'), cfg);
const [dt, dv] = _pts(signals.usbl_dist);
Plotly.react('usv-usbl-dist', [{x:dt, y:dv, type:'scatter', mode:'lines', line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [at, av] = _pts(signals.usbl_angle);
Plotly.react('usv-usbl-angle', [{x:at, y:av, type:'scatter', mode:'lines', line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const motorColorsUSV = ['#ef476f','#ff8800'];
const motorTracesUSV = ['M1','M2'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
return {x:t,y:v,type:'scatter',mode:'lines',name:mk,line:{color:motorColorsUSV[i],width:1}};
});
Plotly.react('usv-motors', motorTracesUSV,
Object.assign(_layout('Motors USV','cmd'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
const armPts = _pts(signals.Armed);
const modePts = _pts(signals.Mode);
const statusTraces = [];
if (armPts[0].length) statusTraces.push({x:armPts[0], y:armPts[1], name:'Armed', type:'scatter', mode:'lines', line:{color:'#ffd166',width:1,shape:'hv'}});
if (modePts[0].length) statusTraces.push({x:modePts[0], y:modePts[1], name:'Mode', type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}});
Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg);
}
// == Task 9: AUV rendering + tabs ==
let _currentSortieId = null;
async function loadAuvTabs(sortieId) {
const resp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auvs`);
const auvs = await resp.json();
const tabsEl = document.getElementById('auv-tabs');
tabsEl.innerHTML = '';
auvs.forEach((auv, i) => {
const btn = document.createElement('button');
btn.className = 'auv-tab' + (i === 0 ? ' active' : '');
btn.textContent = auv;
btn.onclick = async () => {
document.querySelectorAll('.auv-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auv}`);
const data = await r.json();
renderAUV(data.signals);
};
tabsEl.appendChild(btn);
});
if (auvs.length > 0) {
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auvs[0]}`);
const data = await r.json();
renderAUV(data.signals);
}
}
function renderAUV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const pryTraces = [
['pitch', '#ef476f'], ['roll', '#06d6a0'], ['yaw', '#00b4d8']
].map(([k, c]) => { const [t,v]=_pts(signals[k]); return {x:t,y:v,name:k,type:'scatter',mode:'lines',line:{color:c,width:1}}; });
Plotly.react('auv-pry', pryTraces, Object.assign(_layout('Pitch/Roll/Yaw','°'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',x:0,y:1}}), cfg);
const [dpt, dpv] = _pts(signals.depth);
Plotly.react('auv-depth', [{x:dpt,y:dpv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('Depth','m'), cfg);
const [alt, alv] = _pts(signals.altitude);
Plotly.react('auv-alt', [{x:alt,y:alv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Altitude','m'), cfg);
const [obt, obv] = _pts(signals.obstacle_dist);
Plotly.react('auv-obs', [{x:obt,y:obv,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('Obstacle','m'), cfg);
const [udt, udv] = _pts(signals.usbl_dist);
Plotly.react('auv-usbl-dist', [{x:udt,y:udv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [uat, uav] = _pts(signals.usbl_angle);
Plotly.react('auv-usbl-angle', [{x:uat,y:uav,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const [bbt, bbv] = _pts(signals.battery_v);
Plotly.react('auv-batt', [{x:bbt,y:bbv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [stt, stv] = _pts(signals.arm_status);
Plotly.react('auv-status', [{x:stt,y:stv,type:'scatter',mode:'lines',line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('Arm/Mode'), cfg);
const motorColors = ['#ef476f','#ffd166','#06d6a0','#00b4d8','#a0c4ff','#c77dff'];
const motorTraces = ['m1','m2','m3','m4','m5','m6'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
return {x:t,y:v,type:'scatter',mode:'lines',name:`M${i+1}`,line:{color:motorColors[i],width:1}};
});
Plotly.react('auv-motors', motorTraces,
Object.assign(_layout('Motors x6 PWM','µs'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
}
// == Task 10: Slider cursor sync ==
const ALL_GRAPH_IDS = [
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps',
'usv-usbl-dist', 'usv-usbl-angle', 'usv-motors', 'usv-status',
'auv-pry', 'auv-depth', 'auv-alt', 'auv-obs',
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
];
function updateCursor(epochSec) {
const ts = new Date(epochSec * 1000).toISOString();
const tMs = epochSec * 1000;
const winMs = +document.getElementById('window-select').value;
const t0 = winMs === 0 ? null : new Date(tMs - winMs).toISOString();
const t1 = new Date(tMs).toISOString();
const shape = {
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
};
const rangeUpdate = t0 ? {'xaxis.range': [t0, t1]} : {'xaxis.autorange': true};
ALL_GRAPH_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el._fullLayout) {
Plotly.relayout(id, {...rangeUpdate, 'shapes': [shape]});
}
});
}
document.getElementById('window-select').addEventListener('change', () => {
if (tNow) updateCursor(tNow / 1000);
});
// == Task 11: loadSortieData + sorties loading + wiring ==
async function loadSortieData(sortieId) {
const prog = document.getElementById('sync-progress');
try {
prog.textContent = 'Chargement USV…';
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`);
if (usvResp.ok) {
const usvData = await usvResp.json();
renderUSV(usvData.signals);
}
prog.textContent = 'Chargement AUV…';
await loadAuvTabs(sortieId);
prog.textContent = `${sortieId} chargé`;
} catch(e) {
prog.textContent = `Erreur: ${e.message}`;
}
}
function _wireSortieSelect() {
const sel = document.getElementById('sortie-select');
// Evite double-binding si appelé plusieurs fois
if (sel._wired) return;
sel._wired = true;
sel.addEventListener('change', () => {
const btn = document.getElementById('btn-sync');
btn.disabled = !sel.value;
_updateMissionLabelHeader(sel.value);
if (sel.value) {
const opt = sel.options[sel.selectedIndex];
if (opt.textContent.includes('✓')) {
loadSortieData(sel.value);
}
}
});
}
function _populateSortieSelect(sorties) {
const sel = document.getElementById('sortie-select');
// Vider sauf première option placeholder
while (sel.options.length > 1) sel.remove(1);
if (!sorties || !sorties.length) {
sel.options[0].textContent = '— Aucune sortie —';
return;
}
sel.options[0].textContent = `${sorties.length} sorties dispo — sélectionnez`;
sorties.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.id + (s.processed ? ' ✓' : '');
sel.appendChild(opt);
});
_wireSortieSelect();
}
function _updateMissionLabelHeader(sortieId) {
const el = document.getElementById('mission-label-header');
if (!el) return;
el.textContent = sortieId ? `Mission: ${sortieId}` : '';
el.style.color = sortieId ? '#06d6a0' : '#555';
}
function loadSorties() {
const sel = document.getElementById('sortie-select');
sel.options[0].textContent = 'Sorties: chargement…';
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
fetch(`${API2}/sorties`, { signal: controller.signal })
.then(resp => {
clearTimeout(timeoutId);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(sorties => { _populateSortieSelect(sorties); })
.catch(e => {
clearTimeout(timeoutId);
sel.options[0].textContent = '— Pipeline indisponible —';
console.warn('loadSorties:', e.message);
});
}
document.getElementById('btn-sync').addEventListener('click', async () => {
const sortieId = document.getElementById('sortie-select').value;
if (!sortieId) return;
const btn = document.getElementById('btn-sync');
const prog = document.getElementById('sync-progress');
btn.disabled = true;
prog.textContent = 'Démarrage…';
const encoded = encodeURIComponent(sortieId);
await fetch(`${API2}/run/${encoded}`, {method: 'POST'});
const es = new EventSource(`${API2}/events/${encoded}`);
es.onmessage = async (e) => {
const evt = JSON.parse(e.data);
if (evt.step === 'ping') return;
prog.textContent = `[${evt.step}] ${evt.pct}% ${evt.msg}`;
if (evt.step === 'write' && evt.pct === 100) {
es.close();
btn.disabled = false;
prog.textContent = 'Terminé — chargement…';
await loadSortieData(sortieId);
}
if (evt.step === 'error') {
es.close();
btn.disabled = false;
prog.textContent = `Erreur: ${evt.msg}`;
}
};
});
loadSorties();
// == Init ==
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
initCharts();
initDatebar();
</script>
</body>
</html>