- Date picker onChange charge toutes sessions du jour - Mini-graphs Plotly: depth AUV, motors AUV, motors USV, USBL distance - Slider 24h + cursor line Plotly synchronisé - Map v5 intacte (Leaflet USV arrow + AUV USBL + panel) - API: /api/missions -> /dives -> /sessions -> track/series/usbl_track
786 lines
29 KiB
HTML
786 lines
29 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 1fr 54px 1fr;
|
||
}
|
||
/* 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: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||
.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; }
|
||
|
||
/* 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;
|
||
}
|
||
/* 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: 2×2 charts grid */
|
||
#graphs-section {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
grid-template-rows: 1fr 1fr;
|
||
gap: 3px;
|
||
background: #0a0a1a;
|
||
padding: 3px;
|
||
min-height: 0; overflow: hidden;
|
||
}
|
||
.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; }
|
||
</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">Chargement...</span>
|
||
<span id="load-status"></span>
|
||
</div>
|
||
|
||
<!-- Row 1: Header -->
|
||
<div id="header">
|
||
<span id="title">COSMA NAV v6</span>
|
||
<span id="stats">Chargement...</span>
|
||
<div id="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>
|
||
|
||
<!-- Row 2: Map -->
|
||
<div id="map">
|
||
<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">t</span>
|
||
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
|
||
<span id="cursor-time">—</span>
|
||
<label for="trail-select">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>
|
||
</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: 2×2 Plotly charts -->
|
||
<div id="graphs-section">
|
||
<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>
|
||
|
||
<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 = 'http://192.168.0.83:8766';
|
||
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();
|
||
}
|
||
|
||
// == 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]);
|
||
document.getElementById('cursor-time').textContent = fmtMs(tNow);
|
||
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...');
|
||
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();
|
||
|
||
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);
|
||
}
|
||
|
||
// == Init ==
|
||
initCharts();
|
||
initDatebar();
|
||
</script>
|
||
</body>
|
||
</html>
|