feat(viewer): v5 grid 2x2 + trail length + headless screenshot

This commit is contained in:
Poulpe
2026-04-25 22:29:39 +00:00
parent 103bf1cedd
commit 8a5ed6174c
3 changed files with 340 additions and 406 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
screenshots/viewer-v5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -2,85 +2,28 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>COSMA — NAV Viewer v4</title> <title>COSMA — NAV Viewer v5</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; min-height: 100vh; } html, body {
#map { flex: 1; min-height: 0; } height: 100vh; overflow: hidden;
#controls { font-family: monospace; background: #1a1a2e; color: #e0e0e0;
background: #16213e; display: grid;
padding: 10px 14px 8px; grid-template-rows: 40px 1fr 54px 1fr;
border-top: 1px solid #0f3460;
display: flex;
flex-direction: column;
gap: 7px;
flex-shrink: 0;
} }
#top-row { /* Row 1: header */
display: flex; #header {
align-items: center; background: #12122a; border-bottom: 1px solid #0f3460;
gap: 12px; display: flex; align-items: center; gap: 12px; padding: 0 14px;
flex-wrap: wrap; flex-shrink: 0; height: 40px; overflow: hidden;
} }
#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; } #stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#btn-reset { #layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
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-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-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; }
.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 {
position: absolute; bottom: 210px; 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; }
/* USBL stats panel */
#usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000;
background: rgba(22,33,62,0.88); padding: 8px 12px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace; min-width: 180px;
display: none;
}
#usbl-panel .uprow { display: flex; justify-content: space-between; gap: 16px; margin: 1px 0; }
#usbl-panel .uplabel { color: #666; }
#usbl-panel .upval { color: #ff8800; font-weight: bold; }
#usbl-panel .badge {
display: inline-block; background: #ff8800; color: #1a1a2e;
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
}
/* Layer toggles */
#layer-toggles {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
}
.layer-btn { .layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer; font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent; border-radius: 2px; border: 1px solid; background: transparent;
} }
.layer-btn.active { opacity: 1; } .layer-btn.active { opacity: 1; }
@@ -89,66 +32,167 @@
#btn-auv { color: #ff8800; border-color: #ff8800; } #btn-auv { color: #ff8800; border-color: #ff8800; }
#btn-vec { color: #888; border-color: #888; } #btn-vec { color: #888; border-color: #888; }
#btn-usbl-panel { color: #aaa; border-color: #444; } #btn-usbl-panel { color: #aaa; border-color: #444; }
#graphs-section { background:#12122a; border-top:1px solid #0f3460; overflow-y:auto; max-height:40vh; flex-shrink:0; }
.chart-container { padding:6px 12px 4px; border-bottom:1px solid #0f3460; } /* Row 2: map */
.chart-container canvas { display:block; } #map { position: relative; min-height: 0; }
.chart-title { font-size:10px; color:#a0c4ff; margin-bottom:2px; font-family:monospace; }
/* 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; }
#ctrl-labels { display: flex; align-items: center; gap: 6px; font-size: 10px; color: #666; }
label[for="trail-select"] { font-size: 10px; color: #666; }
/* 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;
}
.chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; }
.chart-wrap canvas { flex: 1; min-height: 0; display: block; }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
</head> </head>
<body> <body>
<div id="map"></div>
<div id="legend"></div> <!-- Row 1: Header -->
<div id="usbl-panel"> <div id="header">
<span id="title">COSMA NAV v5</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">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">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">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 class="uprow"><span class="uplabel">SNR</span><span class="upval" id="up-snr"></span></div>
<div><span class="badge">REL HEADING</span></div> <div><span class="badge">REL HEADING</span></div>
</div>
</div> </div>
<!-- Row 3: Controls -->
<div id="controls"> <div id="controls">
<div id="top-row"> <div id="ctrl-row1">
<span id="title">COSMA NAV v3</span> <span id="ctrl-label">t</span>
<span id="stats">Chargement...</span>
<div id="layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV track</button>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">AUV track</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">Vecteur USBL</button>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats USBL</button>
</div>
<button id="btn-reset">Reset window</button>
</div>
<div id="range-row">
<span id="range-label">Fenetre</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-slider-wrap"><div id="cursor-slider"></div></div>
<div id="cursor-info">-</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>
<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 Charts -->
<div id="graphs-section">
<div class="chart-wrap"><div class="chart-title">Profondeur AUV (m)</div><canvas id="chart-depth"></canvas></div>
<div class="chart-wrap"><div class="chart-title">PWM AUV (canaux)</div><canvas id="chart-pwm-auv"></canvas></div>
<div class="chart-wrap"><div class="chart-title">PWM USV (M1-M8)</div><canvas id="chart-pwm-usv"></canvas></div>
<div class="chart-wrap"><div class="chart-title">USBL Distance (m)</div><canvas id="chart-usbl"></canvas></div>
</div> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/>
<script> <script>
// == Constants == // == Constants ==
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316']; const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
const AUV_COLOR = '#ff8800'; const AUV_COLOR = '#ff8800';
const PWM_COLORS_AUV = ['#ff8800','#ffd166','#06d6a0','#00b4d8','#a855f7','#f97316','#e94560','#88c0d0'];
const PWM_COLORS_USV = ['#00b4d8','#0096b4','#0077a0','#005f8c','#ffd166','#e6b800','#c49a00','#a07d00'];
// == 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: '(c) OpenStreetMap contributors', maxZoom: 19, attribution: '(c) OpenStreetMap contributors', maxZoom: 19
}); });
osm.addTo(map); osm.addTo(map);
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', { const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8, attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8
}); });
const gebco = L.tileLayer( const gebco = L.tileLayer(
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}', 'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
@@ -160,59 +204,79 @@ L.control.layers(
{ collapsed: false } { collapsed: false }
).addTo(map); ).addTo(map);
// == Arrow marker == // == Helpers ==
function makeArrowIcon(heading, color) { function makeArrowIcon(heading, color) {
color = color || '#e94560'; color = color || '#e94560';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32"> const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
<g transform="rotate(${heading || 0})"> <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>`;
<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] }); return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
} }
function makeAuvIcon() { function makeAuvIcon() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-10 -10 20 20"> 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="7" fill="${AUV_COLOR}" stroke="#fff" stroke-width="2"/><circle r="2" fill="#fff"/></svg>`;
<circle r="2" fill="#fff"/>
</svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [20,20], iconAnchor: [10,10] }); return L.divIcon({ html: svg, className: '', iconSize: [20,20], iconAnchor: [10,10] });
} }
// == Binary search ==
function bisectLeft(arr, val) { function bisectLeft(arr, val) {
let lo = 0, hi = arr.length; let lo = 0, hi = arr.length;
while (lo < hi) { while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid].t_ms < val) lo = mid+1; else hi = mid; }
const mid = (lo + hi) >> 1;
if (arr[mid].t_ms < val) lo = mid + 1; else hi = mid;
}
return lo; return lo;
} }
function findNearest(arr, tms) { function findNearest(arr, tms) {
if (!arr.length) return null; if (!arr.length) return null;
let i = bisectLeft(arr, tms); let i = bisectLeft(arr, tms);
if (i === 0) return arr[0]; if (i === 0) return arr[0];
if (i >= arr.length) return arr[arr.length - 1]; 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]; 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`;
}
// == State == // == State ==
let allPoints = []; let allPoints=[], usblPoints=[], manifest=null, usblMeta=null;
let usblPoints = []; // from usbl.json (sorted by t_ms) let mcapSignals=null, usvPwm=null;
let manifest = null; let tMin=0, tMax=0;
let usblMeta = null; let tNow=0;
let trackLayers = []; let trailMs=60000;
let auvTrackLayer = null; let charts={};
let windowPoints = []; let trackLayers=[], auvTrackLayer=null, cursorMarker=null, auvMarker=null, usblVector=null;
let usblWindow = []; const layerVis = { usv:true, auv:true, vec:true, panel:true };
let playTimer=null;
// == Graph state == // == Layer toggles ==
let charts = {}; function toggleLayer(name) {
let mcapSignals = null; layerVis[name] = !layerVis[name];
let usvPwm = null; 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 ? 'block' : 'none';
}
const PWM_COLORS_AUV = ['#ff8800','#ffd166','#06d6a0','#00b4d8','#a855f7','#f97316','#e94560','#88c0d0']; // == View All ==
const PWM_COLORS_USV = ['#00b4d8','#0096b4','#0077a0','#005f8c','#ffd166','#e6b800','#c49a00','#a07d00']; function viewAll() {
document.getElementById('trail-select').value = '0';
trailMs = 0;
applyTrailAndCursor();
}
// == Charts ==
function makeChartOptions() { function makeChartOptions() {
return { return {
animation: false, parsing: false, responsive: true, maintainAspectRatio: false, animation: false, parsing: false, responsive: true, maintainAspectRatio: false,
@@ -221,13 +285,12 @@ function makeChartOptions() {
annotation: { annotations: {} } annotation: { annotations: {} }
}, },
scales: { scales: {
x: { type:'linear', ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:8, x: { type:'linear', ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:6,
callback:(v)=>new Date(v).toISOString().substr(11,8) }, grid:{color:'#1a1a3e'} }, callback:(v)=>new Date(v).toISOString().substr(11,8) }, grid:{color:'#1a1a3e'} },
y: { ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:5 }, grid:{color:'#1a1a3e'} }, y: { ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:5 }, grid:{color:'#1a1a3e'} }
}, }
}; };
} }
function initCharts() { function initCharts() {
const mkDs = (lbl, col) => ({ label:lbl, data:[], borderColor:col, borderWidth:1.5, pointRadius:0, tension:0 }); const mkDs = (lbl, col) => ({ label:lbl, data:[], borderColor:col, borderWidth:1.5, pointRadius:0, tension:0 });
charts.depth = new Chart(document.getElementById('chart-depth'), { type:'line', data:{datasets:[mkDs('depth','#06d6a0')]}, options:makeChartOptions() }); charts.depth = new Chart(document.getElementById('chart-depth'), { type:'line', data:{datasets:[mkDs('depth','#06d6a0')]}, options:makeChartOptions() });
@@ -235,29 +298,16 @@ function initCharts() {
charts.pwmUsv = new Chart(document.getElementById('chart-pwm-usv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() }); charts.pwmUsv = new Chart(document.getElementById('chart-pwm-usv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() });
charts.usbl = new Chart(document.getElementById('chart-usbl'), { type:'line', data:{datasets:[mkDs('dist','#a855f7')]}, options:makeChartOptions() }); charts.usbl = new Chart(document.getElementById('chart-usbl'), { type:'line', data:{datasets:[mkDs('dist','#a855f7')]}, options:makeChartOptions() });
} }
function updateGraphCursor(t_ms) {
const ann = { type:'line', xMin:t_ms, xMax:t_ms, borderColor:'#e94560', borderWidth:1.5, borderDash:[4,2] };
for (const c of Object.values(charts)) { c.options.plugins.annotation.annotations = {cursor:ann}; c.update('none'); }
}
function updateGraphWindow(t0, t1) {
for (const c of Object.values(charts)) { c.options.scales.x.min=t0; c.options.scales.x.max=t1; c.update('none'); }
}
function populateCharts() { function populateCharts() {
if (mcapSignals) { if (mcapSignals) {
if (mcapSignals.depth) { if (mcapSignals.depth) { charts.depth.data.datasets[0].data = mcapSignals.depth.map(p=>({x:p.t,y:p.v})); charts.depth.update('none'); }
charts.depth.data.datasets[0].data = mcapSignals.depth.map(p=>({x:p.t,y:p.v}));
charts.depth.update('none');
}
if (mcapSignals.pwm_auv) { if (mcapSignals.pwm_auv) {
const {channels,samples} = mcapSignals.pwm_auv; const {channels,samples} = mcapSignals.pwm_auv;
charts.pwmAuv.data.datasets = channels.map((ch,i)=>({ charts.pwmAuv.data.datasets = channels.map((ch,i)=>({
label:'Ch'+ch, data:samples.map(s=>({x:s.t,y:s.v[i]})), label:'Ch'+ch, data:samples.map(s=>({x:s.t,y:s.v[i]})),
borderColor:PWM_COLORS_AUV[i%8], borderWidth:1, pointRadius:0, tension:0 borderColor:PWM_COLORS_AUV[i%8], borderWidth:1, pointRadius:0, tension:0
})); }));
charts.pwmAuv.options.plugins.legend.display = channels.length > 1; charts.pwmAuv.options.plugins.legend.display = channels.length>1;
charts.pwmAuv.update('none'); charts.pwmAuv.update('none');
} }
} }
@@ -267,295 +317,190 @@ function populateCharts() {
label:k, data:usvPwm.M[k].map(p=>({x:p.t,y:p.v})), label:k, data:usvPwm.M[k].map(p=>({x:p.t,y:p.v})),
borderColor:PWM_COLORS_USV[i%8], borderWidth:1, pointRadius:0, tension:0 borderColor:PWM_COLORS_USV[i%8], borderWidth:1, pointRadius:0, tension:0
})); }));
charts.pwmUsv.options.plugins.legend.display = keys.length > 1; charts.pwmUsv.options.plugins.legend.display = keys.length>1;
charts.pwmUsv.update('none'); charts.pwmUsv.update('none');
} }
if (usblPoints && usblPoints.length) { if (usblPoints && usblPoints.length) {
charts.usbl.data.datasets[0].data = usblPoints.map(p=>({x:p.t_ms, y:p.dist!==undefined?p.dist:p.distance})); charts.usbl.data.datasets[0].data = usblPoints.map(p=>({x:p.t_ms,y:p.dist!==undefined?p.dist:p.distance}));
charts.usbl.update('none'); charts.usbl.update('none');
} }
} }
let cursorMarker = null;
let auvMarker = null;
let usblVector = null;
let rangeSlider = null;
let cursorSlider = null;
let tMin = 0, tMax = 0;
// layer visibility // == Apply trail + cursor ==
const layerVis = { usv: true, auv: true, vec: true, panel: true }; function applyTrailAndCursor() {
const trail = +document.getElementById('trail-select').value;
trailMs = trail;
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
const t1 = tNow;
// == Layer toggles == // Update chart x-window
function toggleLayer(name) { const ann = { type:'line', xMin:tNow, xMax:tNow, borderColor:'#e94560', borderWidth:1.5, borderDash:[4,2] };
layerVis[name] = !layerVis[name]; const chartT0 = trailMs===0 ? tMin : t0;
const btn = document.getElementById('btn-' + (name === 'panel' ? 'usbl-panel' : name)); const chartT1 = trailMs===0 ? tMax : Math.max(t1, tMin+1);
btn.classList.toggle('active', layerVis[name]); for (const c of Object.values(charts)) {
btn.classList.toggle('inactive', !layerVis[name]); c.options.scales.x.min = chartT0;
c.options.scales.x.max = chartT1;
if (name === 'usv') { c.options.plugins.annotation.annotations = { cursor: ann };
trackLayers.forEach(l => layerVis.usv ? map.addLayer(l) : map.removeLayer(l)); c.update('none');
} }
if (name === 'auv') {
if (auvTrackLayer) { layerVis.auv ? map.addLayer(auvTrackLayer) : map.removeLayer(auvTrackLayer); }
if (auvMarker) { layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker); }
}
if (name === 'vec') {
if (usblVector) { layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector); }
}
if (name === 'panel') {
document.getElementById('usbl-panel').style.display = layerVis.panel ? 'block' : 'none';
}
}
// == Utils == // Trail track on map
function fmtMs(ms) { const trailPtsUsv = filterWindow(allPoints, t0, t1);
if (!ms) return '-'; const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
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 == // Rebuild USV trail layers
function filterPointsInWindow(arr, startMs, endMs) {
const lo = bisectLeft(arr, startMs);
const result = [];
for (let i = lo; i < arr.length && arr[i].t_ms <= endMs; i++) result.push(arr[i]);
return result;
}
function rebuildAuvTrackLayer() {
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer = null; }
if (usblWindow.length < 2) return;
const coords = usblWindow.map(p => [p.auv_lat, p.auv_lon]);
auvTrackLayer = L.polyline(coords, { color: AUV_COLOR, weight: 2.5, opacity: 0.85 });
if (layerVis.auv) auvTrackLayer.addTo(map);
}
function updateWindow(startMs, endMs) {
document.getElementById('date-start').textContent = fmtMs(startMs);
document.getElementById('date-end').textContent = fmtMs(endMs);
windowPoints = filterPointsInWindow(allPoints, startMs, endMs);
usblWindow = filterPointsInWindow(usblPoints, startMs, endMs);
// Rebuild USV track layers by session
trackLayers.forEach(l => map.removeLayer(l)); trackLayers.forEach(l => map.removeLayer(l));
trackLayers = []; trackLayers = [];
if (manifest) { if (manifest) {
const sessionGroups = {}; const groups = {};
windowPoints.forEach(p => { trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
if (!sessionGroups[p.source]) sessionGroups[p.source] = [];
sessionGroups[p.source].push(p);
});
manifest.sessions.forEach((sess, i) => { manifest.sessions.forEach((sess, i) => {
const pts = sessionGroups[sess.source_name] || []; const pts = groups[sess.source_name]||[];
if (pts.length < 2) return; if (pts.length < 2) return;
const coords = pts.map(p => [p.lat, p.lon]); const layer = L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:2.5, opacity:0.85 });
const color = COLORS[i % COLORS.length];
const layer = L.polyline(coords, { color, weight: 2.5, opacity: 0.85 });
trackLayers.push(layer); trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map); if (layerVis.usv) layer.addTo(map);
}); });
} }
// Rebuild AUV track // Rebuild AUV trail layer
rebuildAuvTrackLayer(); if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
if (trailPtsUsbl.length >= 2) {
// Update cursor slider range auvTrackLayer = L.polyline(trailPtsUsbl.map(p=>[p.auv_lat,p.auv_lon]), { color:AUV_COLOR, weight:2.5, opacity:0.85 });
if (cursorSlider && windowPoints.length > 0) { if (layerVis.auv) auvTrackLayer.addTo(map);
cursorSlider.updateOptions({
range: { min: 0, max: Math.max(windowPoints.length - 1, 1) },
});
cursorSlider.set(0);
} }
updateStats(startMs, endMs); // USV cursor marker at tNow
} const pUsv = findNearest(allPoints, tNow);
if (pUsv) {
function updateStats(startMs, endMs) { const sessIdx = manifest ? manifest.sessions.findIndex(s=>s.source_name===pUsv.source) : 0;
const dur = endMs - startMs; const color = COLORS[Math.max(0,sessIdx)%COLORS.length];
document.getElementById('stats').textContent =
`USV: ${windowPoints.length} pts | AUV: ${usblWindow.length} pts | ${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;
const sessIdx = manifest ? manifest.sessions.findIndex(s => s.source_name === p.source) : 0;
const color = COLORS[Math.max(0, sessIdx) % COLORS.length];
// USV cursor marker
if (!cursorMarker) { if (!cursorMarker) {
cursorMarker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0, color), zIndexOffset: 1000 }).addTo(map); cursorMarker = L.marker([pUsv.lat,pUsv.lon], { icon:makeArrowIcon(pUsv.heading||0,color), zIndexOffset:1000 }).addTo(map);
} else { } else {
cursorMarker.setLatLng([p.lat, p.lon]); cursorMarker.setLatLng([pUsv.lat,pUsv.lon]);
cursorMarker.setIcon(makeArrowIcon(p.heading || 0, color)); cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color));
} }
const hdg = p.heading !== null && p.heading !== undefined ? `${p.heading.toFixed(1)}` : 'N/A';
document.getElementById('cursor-info').textContent = document.getElementById('cursor-info').textContent =
`[${idx+1}/${windowPoints.length}] ${p.t} | ${p.lat.toFixed(6)}, ${p.lon.toFixed(6)} | cap: ${hdg} | ${p.source}`; `${pUsv.t} | ${pUsv.lat.toFixed(6)}, ${pUsv.lon.toFixed(6)} | cap: ${pUsv.heading!==null&&pUsv.heading!==undefined?pUsv.heading.toFixed(1):'N/A'} | ${pUsv.source}`;
// Find nearest USBL point
const nearest = findNearest(usblWindow, p.t_ms);
if (nearest) {
updateAuvCursor(nearest, p);
} else {
clearAuvCursor();
} }
}
function updateAuvCursor(up, usvPt) { // AUV + USBL at tNow
// AUV marker const pUsbl = findNearest(usblPoints, tNow);
if (pUsbl) {
if (layerVis.auv) { if (layerVis.auv) {
if (!auvMarker) { if (!auvMarker) {
auvMarker = L.marker([up.auv_lat, up.auv_lon], { icon: makeAuvIcon(), zIndexOffset: 900 }).addTo(map); auvMarker = L.marker([pUsbl.auv_lat,pUsbl.auv_lon], { icon:makeAuvIcon(), zIndexOffset:900 }).addTo(map);
} else { } else {
if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker); if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker);
auvMarker.setLatLng([up.auv_lat, up.auv_lon]); auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]);
} }
} }
const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.usv_lat,pUsbl.usv_lon];
// USBL vector USV -> AUV
const usvPos = usvPt ? [usvPt.lat, usvPt.lon] : [up.usv_lat, up.usv_lon];
const auvPos = [up.auv_lat, up.auv_lon];
if (layerVis.vec) { if (layerVis.vec) {
if (!usblVector) { if (!usblVector) {
usblVector = L.polyline([usvPos, auvPos], { usblVector = L.polyline([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]], { color:'#888', weight:1.5, dashArray:'6,4', opacity:0.9 }).addTo(map);
color: '#888', weight: 1.5, dashArray: '6,4', opacity: 0.9
}).addTo(map);
} else { } else {
if (!map.hasLayer(usblVector)) map.addLayer(usblVector); if (!map.hasLayer(usblVector)) map.addLayer(usblVector);
usblVector.setLatLngs([usvPos, auvPos]); usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]);
} }
usblVector.bindTooltip(`Dist ${up.dist.toFixed(1)}m / Az ${up.az.toFixed(1)}deg`, { permanent: false, className: 'usbl-tip' });
} }
// Stats panel
if (layerVis.panel) { if (layerVis.panel) {
document.getElementById('usbl-panel').style.display = 'block'; document.getElementById('usbl-panel').style.display='block';
document.getElementById('up-dist').textContent = `${up.dist.toFixed(2)} m`; document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${up.az.toFixed(1)} deg`; document.getElementById('up-az').textContent = `${pUsbl.az.toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${up.elev.toFixed(1)} deg`; document.getElementById('up-elev').textContent = `${pUsbl.elev.toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${up.snr.toFixed(1)}`; document.getElementById('up-snr').textContent = `${pUsbl.snr.toFixed(1)}`;
} }
}
// stats
const dur = tNow - (trailMs===0?tMin:t0);
document.getElementById('stats').textContent =
`trail: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
} }
function clearAuvCursor() { // == Cursor slider ==
if (auvMarker) { map.removeLayer(auvMarker); auvMarker = null; } let cursorSlider = null;
if (usblVector) { map.removeLayer(usblVector); usblVector = null; } function initCursorSlider() {
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), {
start: [tMin],
range: { min: tMin, max: tMax },
step: 1000,
});
cursorSlider.on('update', (values) => {
tNow = Math.round(+values[0]);
document.getElementById('cursor-time').textContent = fmtMs(tNow);
applyTrailAndCursor();
});
// trail select
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 == // == Legend ==
function buildLegend() { function buildLegend() {
const el = document.getElementById('legend');
el.style.display = 'block';
let html = ''; let html = '';
if (manifest) { if (manifest) {
manifest.sessions.forEach((s, i) => { manifest.sessions.forEach((s,i) => {
const color = COLORS[i % COLORS.length];
const name = s.source_name.replace('_navigation_log','').replace('.csv',''); const name = s.source_name.replace('_navigation_log','').replace('.csv','');
html += `<div class="legend-item"><div class="legend-dot" style="background:${color}"></div><span>USV ${name}</span></div>`; 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-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>`; html += `<div class="legend-item"><div class="legend-dashed"></div><span>Vecteur USBL</span></div>`;
el.innerHTML = html; document.getElementById('legend').innerHTML = html;
} }
// == Init sliders == // == Load ==
function initSliders() {
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]);
});
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]));
});
}
// == Reset ==
document.getElementById('btn-reset').addEventListener('click', () => {
if (rangeSlider) rangeSlider.set([tMin, tMax]);
});
// == Main load ==
async function loadData() { async function loadData() {
try { try {
const [trackResp, pointsResp, manifestResp, usblResp, auvTrackResp] = await Promise.all([ const [trackResp, pointsResp, manifestResp, usblResp] = await Promise.all([
fetch('data/track.geojson'), fetch('data/track.geojson'), fetch('data/points.json'),
fetch('data/points.json'), fetch('data/manifest.json'), fetch('data/usbl.json'),
fetch('data/manifest.json'),
fetch('data/usbl.json'),
fetch('data/auv_track.geojson'),
]); ]);
if (!trackResp.ok) throw new Error('track.geojson not found'); if (!trackResp.ok) throw new Error('track.geojson not found');
if (!pointsResp.ok) throw new Error('points.json not found'); if (!pointsResp.ok) throw new Error('points.json not found');
if (!manifestResp.ok) throw new Error('manifest.json not found'); if (!manifestResp.ok) throw new Error('manifest.json not found');
if (!usblResp.ok) throw new Error('usbl.json not found'); if (!usblResp.ok) throw new Error('usbl.json not found');
if (!auvTrackResp.ok) throw new Error('auv_track.geojson not found');
const trackGeo = await trackResp.json(); const trackGeo = await trackResp.json();
allPoints = await pointsResp.json(); allPoints = await pointsResp.json();
manifest = await manifestResp.json(); manifest = await manifestResp.json();
usblMeta = await usblResp.json(); usblMeta = await usblResp.json();
const auvTrackGeo = await auvTrackResp.json(); usblPoints = usblMeta.points.sort((a,b)=>a.t_ms-b.t_ms);
// Sort USBL points by t_ms (already sorted but ensure) // Static faded USV track background
usblPoints = usblMeta.points.sort((a, b) => a.t_ms - b.t_ms); L.geoJSON(trackGeo, { style:(f)=>({ color:f.properties.color||'#00b4d8', weight:2, opacity:0.3 }) }).addTo(map);
// Static USV track (all sessions, faded)
L.geoJSON(trackGeo, {
style: (f) => ({ color: f.properties.color || '#00b4d8', weight: 2, opacity: 0.35 }),
}).addTo(map);
// Static AUV track (always visible as background)
auvTrackLayer = L.geoJSON(auvTrackGeo, {
style: (f) => ({ color: AUV_COLOR, weight: 2, opacity: 0.35 }),
}).addTo(map);
// Fit bounds to USV
if (allPoints.length > 0) { if (allPoints.length > 0) {
const lats = allPoints.map(p => p.lat); const lats=allPoints.map(p=>p.lat), lons=allPoints.map(p=>p.lon);
const lons = allPoints.map(p => p.lon); map.fitBounds([[Math.min(...lats),Math.min(...lons)],[Math.max(...lats),Math.max(...lons)]], {padding:[40,40]});
map.fitBounds([[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]], { padding: [40,40] });
} }
tMin = manifest.t_min_ms || Math.min(...allPoints.map(p => p.t_ms || Infinity)); tMin = manifest.t_min_ms || Math.min(...allPoints.map(p=>p.t_ms||Infinity));
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p => p.t_ms || -Infinity)); tMax = manifest.t_max_ms || Math.max(...allPoints.map(p=>p.t_ms||-Infinity));
if (tMin === tMax) tMax = tMin + 1000; if (tMin===tMax) tMax=tMin+1000;
tNow = tMin;
document.getElementById('title').textContent = document.getElementById('title').textContent =
`COSMA v4 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`; `COSMA v5 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`;
buildLegend(); buildLegend();
initSliders(); initCursorSlider();
windowPoints = [...allPoints]; applyTrailAndCursor();
usblWindow = [...usblPoints]; } catch(e) {
updateStats(tMin, tMax); document.getElementById('stats').textContent = 'Erreur: '+e.message;
} catch (e) {
document.getElementById('stats').textContent = 'Erreur: ' + e.message;
console.error(e); console.error(e);
} }
} }
@@ -564,27 +509,16 @@ async function loadGraphData() {
initCharts(); initCharts();
try { try {
const [mcapResp, usvPwmResp] = await Promise.allSettled([ const [mcapResp, usvPwmResp] = await Promise.allSettled([
fetch('data/mcap_signals.json'), fetch('data/mcap_signals.json'), fetch('data/usv_pwm.json'),
fetch('data/usv_pwm.json'),
]); ]);
if (mcapResp.status === 'fulfilled' && mcapResp.value.ok) { if (mcapResp.status==='fulfilled' && mcapResp.value.ok) mcapSignals = await mcapResp.value.json();
mcapSignals = await mcapResp.value.json(); if (usvPwmResp.status==='fulfilled' && usvPwmResp.value.ok) usvPwm = await usvPwmResp.value.json();
}
if (usvPwmResp.status === 'fulfilled' && usvPwmResp.value.ok) {
usvPwm = await usvPwmResp.value.json();
}
populateCharts(); populateCharts();
} catch (e) { console.warn('Graph data load error:', e); } } catch(e) { console.warn('Graph data error:',e); }
} }
loadData().then(() => { populateCharts(); }); loadData();
loadGraphData(); loadGraphData();
</script> </script>
<div id="graphs-section">
<div class="chart-container"><div class="chart-title">Profondeur AUV (m)</div><canvas id="chart-depth" height="80"></canvas></div>
<div class="chart-container"><div class="chart-title">PWM AUV (canaux)</div><canvas id="chart-pwm-auv" height="80"></canvas></div>
<div class="chart-container"><div class="chart-title">PWM USV (M1-M8)</div><canvas id="chart-pwm-usv" height="80"></canvas></div>
<div class="chart-container"><div class="chart-title">USBL Distance (m)</div><canvas id="chart-usbl" height="60"></canvas></div>
</div>
</body> </body>
</html> </html>