Files
cosma-nav-tools/viewer/index.html
Flag 6f2f6d2d72 feat(viewer): v5 grid 2x2 + datebar calendar + mission label
- NAV viewer v5: grid 2x2 (map + charts), trail slider, play, layer toggles
- Datebar: date picker + datalist, fetches /api/data-dates from :8766
- Mission label shows #NN-folder (X sessions) in green or grey
- Tools: parse_usv_nav, extract_mcap_signals, extract_usv_pwm, merge_nav_usbl, usbl_to_json, check_sync, parse_kogger_usbl
- Vendor: Kogger-Protocol docs
2026-04-27 13:48:36 +00:00

618 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>COSMA — NAV Viewer v5</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"/>
<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; }
/* 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; }
#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>
<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>
</head>
<body>
<!-- Row 0: Datebar -->
<div id="datebar">
<input type="date" id="date-picker" list="available-dates">
<datalist id="available-dates"></datalist>
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span>
</div>
<!-- Row 1: Header -->
<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">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 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>
<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>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/>
<script>
// == Constants ==
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316'];
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 ==
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`;
}
// == State ==
let allPoints=[], usblPoints=[], manifest=null, usblMeta=null;
let mcapSignals=null, usvPwm=null;
let tMin=0, tMax=0;
let tNow=0;
let trailMs=60000;
let charts={};
let trackLayers=[], auvTrackLayer=null, cursorMarker=null, auvMarker=null, usblVector=null;
const layerVis = { usv:true, auv:true, vec:true, panel:true };
let playTimer=null;
// == 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 ? 'block' : 'none';
}
// == View All ==
function viewAll() {
document.getElementById('trail-select').value = '0';
trailMs = 0;
applyTrailAndCursor();
}
// == Charts ==
function makeChartOptions() {
return {
animation: false, parsing: false, responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false, labels: { color:'#a0c4ff', font:{size:9,family:'monospace'}, boxWidth:12 } },
annotation: { annotations: {} }
},
scales: {
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'} },
y: { ticks:{ color:'#666', font:{size:9,family:'monospace'}, maxTicksLimit:5 }, grid:{color:'#1a1a3e'} }
}
};
}
function initCharts() {
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.pwmAuv = new Chart(document.getElementById('chart-pwm-auv'), { 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() });
}
function populateCharts() {
if (mcapSignals) {
if (mcapSignals.depth) { charts.depth.data.datasets[0].data = mcapSignals.depth.map(p=>({x:p.t,y:p.v})); charts.depth.update('none'); }
if (mcapSignals.pwm_auv) {
const {channels,samples} = mcapSignals.pwm_auv;
charts.pwmAuv.data.datasets = channels.map((ch,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
}));
charts.pwmAuv.options.plugins.legend.display = channels.length>1;
charts.pwmAuv.update('none');
}
}
if (usvPwm && usvPwm.M) {
const keys = Object.keys(usvPwm.M).sort();
charts.pwmUsv.data.datasets = keys.map((k,i)=>({
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
}));
charts.pwmUsv.options.plugins.legend.display = keys.length>1;
charts.pwmUsv.update('none');
}
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.update('none');
}
}
// == 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;
// Update chart x-window
const ann = { type:'line', xMin:tNow, xMax:tNow, borderColor:'#e94560', borderWidth:1.5, borderDash:[4,2] };
const chartT0 = trailMs===0 ? tMin : t0;
const chartT1 = trailMs===0 ? tMax : Math.max(t1, tMin+1);
for (const c of Object.values(charts)) {
c.options.scales.x.min = chartT0;
c.options.scales.x.max = chartT1;
c.options.plugins.annotation.annotations = { cursor: ann };
c.update('none');
}
// Trail track on map
const trailPtsUsv = filterWindow(allPoints, t0, t1);
const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
// Rebuild USV trail layers
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
if (manifest) {
const groups = {};
trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
manifest.sessions.forEach((sess, i) => {
const pts = groups[sess.source_name]||[];
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 sessIdx = manifest ? manifest.sessions.findIndex(s=>s.source_name===pUsv.source) : 0;
const color = COLORS[Math.max(0,sessIdx)%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 =
`${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}`;
}
// 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.usv_lat,pUsbl.usv_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) {
document.getElementById('usbl-panel').style.display='block';
document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${pUsbl.az.toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${pUsbl.elev.toFixed(1)} deg`;
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)}`;
}
// == Cursor slider ==
let cursorSlider = 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 ==
function buildLegend() {
let html = '';
if (manifest) {
manifest.sessions.forEach((s,i) => {
const name = s.source_name.replace('_navigation_log','').replace('.csv','');
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;
}
// == Load ==
async function loadData() {
try {
const [trackResp, pointsResp, manifestResp, usblResp] = await Promise.all([
fetch('data/track.geojson'), fetch('data/points.json'),
fetch('data/manifest.json'), fetch('data/usbl.json'),
]);
if (!trackResp.ok) throw new Error('track.geojson not found');
if (!pointsResp.ok) throw new Error('points.json not found');
if (!manifestResp.ok) throw new Error('manifest.json not found');
if (!usblResp.ok) throw new Error('usbl.json not found');
const trackGeo = await trackResp.json();
allPoints = await pointsResp.json();
manifest = await manifestResp.json();
usblMeta = await usblResp.json();
usblPoints = usblMeta.points.sort((a,b)=>a.t_ms-b.t_ms);
// Static faded USV track background
L.geoJSON(trackGeo, { style:(f)=>({ color:f.properties.color||'#00b4d8', weight:2, opacity:0.3 }) }).addTo(map);
if (allPoints.length > 0) {
const lats=allPoints.map(p=>p.lat), lons=allPoints.map(p=>p.lon);
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));
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p=>p.t_ms||-Infinity));
if (tMin===tMax) tMax=tMin+1000;
tNow = tMin;
document.getElementById('title').textContent =
`COSMA v5 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`;
buildLegend();
initCursorSlider();
applyTrailAndCursor();
} catch(e) {
document.getElementById('stats').textContent = 'Erreur: '+e.message;
console.error(e);
}
}
async function loadGraphData() {
initCharts();
try {
const [mcapResp, usvPwmResp] = await Promise.allSettled([
fetch('data/mcap_signals.json'), fetch('data/usv_pwm.json'),
]);
if (mcapResp.status==='fulfilled' && mcapResp.value.ok) mcapSignals = await mcapResp.value.json();
if (usvPwmResp.status==='fulfilled' && usvPwmResp.value.ok) usvPwm = await usvPwmResp.value.json();
populateCharts();
} catch(e) { console.warn('Graph data error:',e); }
}
// == Datebar ==
let availableDates = [];
async function initDatebar() {
const label = document.getElementById('mission-label');
const picker = document.getElementById('date-picker');
const dl = document.getElementById('available-dates');
try {
const resp = await fetch('http://192.168.0.83:8766/api/data-dates');
if (!resp.ok) throw new Error('data-dates HTTP ' + resp.status);
const data = await resp.json();
availableDates = data.dates || [];
if (!availableDates.length) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
// Populate datalist
dl.innerHTML = '';
availableDates.forEach(d => {
const opt = document.createElement('option');
opt.value = d.date;
dl.appendChild(opt);
});
// Min/max
const dates = availableDates.map(d => d.date).sort();
picker.min = dates[0];
picker.max = dates[dates.length - 1];
// Default: last date
picker.value = dates[dates.length - 1];
updateMissionLabel(picker.value);
picker.addEventListener('change', () => updateMissionLabel(picker.value));
} catch(e) {
label.textContent = 'API indisponible';
label.className = 'no-data';
console.warn('datebar error:', 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);
}
initDatebar();
loadData();
loadGraphData();
</script>
</body>
</html>