Compare commits

..

6 Commits

Author SHA1 Message Date
Flag
07df61cbc4 feat(viewer): v6 - date-picker fonctionnel + Plotly charts depuis API 8766
- 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
2026-04-27 14:14:57 +00:00
Poulpe
8a5ed6174c feat(viewer): v5 grid 2x2 + trail length + headless screenshot 2026-04-25 22:29:39 +00:00
Poulpe
103bf1cedd feat(viewer): v4 time-series graphs (depth, PWM USV/AUV, status) 2026-04-25 22:15:43 +00:00
Poulpe
3198164aff feat(viewer): v3 AUV track + USBL vector overlay 2026-04-25 21:40:05 +00:00
Poulpe
be2cd1d156 feat: Kogger USBL decoder + nav merge
- tools/parse_kogger_usbl.py: decode SBP protocol (ID=0x65 USBL_SOLUTION)
  from raw *_usbl.csv files, output combined_usbl.csv with Dist/Az/Elev/SNR
- tools/merge_nav_usbl.py: merge USBL data with navigation_log.csv,
  interpolate USV lat/lon/heading, compute AUV absolute position
  (azimuth relative to USV heading convention)
- vendor/Kogger-Protocol: SBP spec reference (submodule)
- 69-sttropez: 13986 USBL records decoded, avg USV-AUV dist 39m
2026-04-25 21:24:00 +00:00
Poulpe
b46f136b76 feat: v2 multi-session parser + timeline range viewer 2026-04-25 20:31:17 +00:00
8 changed files with 349 additions and 236 deletions

5
.gitignore vendored
View File

@@ -2,8 +2,3 @@ data/
output/ output/
__pycache__/ __pycache__/
*.pyc *.pyc
.venv/
data/
viewer/data/
viewer/screenshots/
screenshots/

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

1
vendor/Kogger-Protocol vendored Submodule

Submodule vendor/Kogger-Protocol added at d62576fee5

Binary file not shown.

Binary file not shown.

View File

@@ -1,51 +0,0 @@
# Open Serial Binary Protocol (SBP) specification
## Protocol frame structure
SYNC1 | SYNC2 | ROUTE | MODE | ID | LENGTH | PAYLOAD | CHECK1 | CHECK2|
|----------|----------|----------|----------|----------|----------|----------|----------|----------|
U1 | U1 | U1 | U1 | U1 | U1 | BYTE[LENGTH] | U1 | U1 |
0xBB | 0x55 | BITFIELD | BITFIELD | 1 … 255 | 0 … 128 | BYTEARRAY | 0 … 0xFF | 0 … 0xFF |
### ROUTE
Name | Bits | Description
|----------|----------|----------|
DEV_ADDRESS | 0:3 bit | Device address. Default and broadcast address is 0x0.
RESERVED | 2 bit | Reserved
### MODE
Name | Bits | Description
|----------|----------|----------|
TYPE | 0:1 bit | 0 — Reserved, 1 — CONTENT: DEVICE → HOST, 2 — SETTING: HOST → DEVICE, 3 —GETTING: HOST → DEVICE
RESERVED | 2 bit | Reserved
VERSION | 3:5 bit | Field defines the payload data version
MARK | 6 bit | Once device is switched on, this flag is always in reset state (ZERO). It can be set to active state (ONE) by the host (see the CMD_MARK command) and the slave device keeps the flag in active state in every frame until hardware reset occurs or is reset by the host. Therefore the host monitors the device's actual settings.
RESPONSE | 7 bit | HOST → DEVICE: Set the flag to active state (ONE) in order to get the result of processing the command. The flag doesn't affect the response if one is provided by the TYPE field. DEVICE → HOST: The flag is in reset state (ZERO) by default. Payload goes according to the command specification. If flag is set, the payload contains the result of command processing (see CMD_RESP command).
## Checksum
The checksum algorithm used is the Fletcher-16.
Example source code for calculating the checksum:
```
uint8_t CHECK1 = 0;
uint8_t CHECK2 = 0;
void CheckSumUpdate(uint8_t byte) {
CHECK1 += byte;
CHECK2 += CHECK1;
}
```
## Number Formats
- Multi-byte values are ordered in Little Endian format
- Floating point values are transmitted in IEEE754 single or double precision
- bit-field in LSB format
Name | Type | Size (Bytes) | Range
|----------|----------|----------|----------|
S1 | int8_t | 1 | -128 ... 127
U1 | uint8_t | 1 | 0 … 255
S2 | int16_t | 2 | -32768 … 32767
U2 | uint16_t | 2 | 0 … 65535
S4 | int32_t | 4 | -2'147'483'648 ... 2'147'483'647
U4 | uint32_t | 4 | 0 … 4'294'967'295
F4 | float | 4 | -1*2^+127 ... 2^+127
D8 | double | 8 | -1*2^+1023 ... 2^+1023

View File

@@ -2,9 +2,10 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>COSMA — NAV Viewer v5</title> <title>COSMA — NAV Viewer v6</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://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css"/>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
html, body { html, body {
@@ -31,11 +32,10 @@
border-radius: 2px; border-radius: 2px;
} }
#btn-today:hover { background: #00b4d8; color: #1a1a2e; } #btn-today:hover { background: #00b4d8; color: #1a1a2e; }
#mission-label { #mission-label { font-size: 11px; font-family: monospace; padding: 2px 8px; }
font-size: 11px; font-family: monospace; padding: 2px 8px;
}
#mission-label.has-data { color: #06d6a0; } #mission-label.has-data { color: #06d6a0; }
#mission-label.no-data { color: #555; } #mission-label.no-data { color: #555; }
#load-status { font-size: 10px; color: #888; flex: 1; }
/* Row 1: header */ /* Row 1: header */
#header { #header {
@@ -114,8 +114,6 @@
border-radius: 2px; border-radius: 2px;
} }
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; } #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 */ /* Row 4: 2×2 charts grid */
#graphs-section { #graphs-section {
@@ -134,26 +132,26 @@
padding: 4px 8px 3px; padding: 4px 8px 3px;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
position: relative;
} }
.chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; } .chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; }
.chart-wrap canvas { flex: 1; min-height: 0; display: block; } .chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
</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/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
</head> </head>
<body> <body>
<!-- Row 0: Datebar --> <!-- Row 0: Datebar -->
<div id="datebar"> <div id="datebar">
<input type="date" id="date-picker" list="available-dates"> <input type="date" id="date-picker">
<datalist id="available-dates"></datalist>
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button> <button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span> <span id="mission-label" class="no-data">Chargement...</span>
<span id="load-status"></span>
</div> </div>
<!-- Row 1: Header --> <!-- Row 1: Header -->
<div id="header"> <div id="header">
<span id="title">COSMA NAV v5</span> <span id="title">COSMA NAV v6</span>
<span id="stats">Chargement...</span> <span id="stats">Chargement...</span>
<div id="layer-toggles"> <div id="layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV</button> <button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV</button>
@@ -199,23 +197,48 @@
</div> </div>
</div> </div>
<!-- Row 4: 2×2 Charts --> <!-- Row 4: 2×2 Plotly charts -->
<div id="graphs-section"> <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-wrap"><div class="chart-title">PWM AUV (canaux)</div><canvas id="chart-pwm-auv"></canvas></div> <div class="chart-title">Depth AUV (m)</div>
<div class="chart-wrap"><div class="chart-title">PWM USV (M1-M8)</div><canvas id="chart-pwm-usv"></canvas></div> <div class="plotly-wrap"><div id="chart-depth"></div></div>
<div class="chart-wrap"><div class="chart-title">USBL Distance (m)</div><canvas id="chart-usbl"></canvas></div> </div>
<div class="chart-wrap">
<div class="chart-title">Motors AUV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-auv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors USV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-usv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div>
</div> </div>
<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://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/15.7.1/nouislider.min.css"/> <script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<script> <script>
// == Constants == // == Constants ==
const COLORS = ['#00b4d8','#e94560','#06d6a0','#ffd166','#a855f7','#f97316']; const API = 'http://192.168.0.83:8766';
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
const AUV_COLOR = '#ff8800'; const AUV_COLOR = '#ff8800';
const PWM_COLORS_AUV = ['#ff8800','#ffd166','#06d6a0','#00b4d8','#a855f7','#f97316','#e94560','#88c0d0']; const PLOTLY_LAYOUT = {
const PWM_COLORS_USV = ['#00b4d8','#0096b4','#0077a0','#005f8c','#ffd166','#e6b800','#c49a00','#a07d00']; 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 == // == Map init ==
const map = L.map('map', { zoomControl: true }); const map = L.map('map', { zoomControl: true });
@@ -274,17 +297,27 @@ function fmtDur(ms) {
const s = Math.floor(ms/1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sc = s%60; 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`; 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 == // == State ==
let allPoints=[], usblPoints=[], manifest=null, usblMeta=null; let allPoints = []; // {t_ms, lat, lon, heading, source}
let mcapSignals=null, usvPwm=null; let usblPoints = []; // {t_ms, auv_lat, auv_lon, dist, az, elev, snr}
let tMin=0, tMax=0; let sessionsMeta = []; // loaded session metadata
let tNow=0; let tMin = 0, tMax = 0, tNow = 0;
let trailMs = 60000; let trailMs = 60000;
let charts={};
let trackLayers = [], auvTrackLayer = null, cursorMarker = null, auvMarker = null, usblVector = null; let trackLayers = [], auvTrackLayer = null, cursorMarker = null, auvMarker = null, usblVector = null;
const layerVis = { usv: true, auv: true, vec: true, panel: true }; const layerVis = { usv: true, auv: true, vec: true, panel: true };
let playTimer = null; let playTimer = null;
let cursorSlider = null;
// Plotly chart data state
let depthTraces = [];
let pwmAuvTraces = [];
let pwmUsvTraces = [];
let usblDistTraces = [];
// == Layer toggles == // == Layer toggles ==
function toggleLayer(name) { function toggleLayer(name) {
@@ -298,7 +331,7 @@ function toggleLayer(name) {
if (auvMarker) layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker); if (auvMarker) layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker);
} }
if (name==='vec' && usblVector) layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector); 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'; if (name==='panel') document.getElementById('usbl-panel').style.display = layerVis.panel && usblPoints.length ? 'block' : 'none';
} }
// == View All == // == View All ==
@@ -308,54 +341,37 @@ function viewAll() {
applyTrailAndCursor(); applyTrailAndCursor();
} }
// == Charts == // == Plotly charts init ==
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() { function initCharts() {
const mkDs = (lbl, col) => ({ label:lbl, data:[], borderColor:col, borderWidth:1.5, pointRadius:0, tension:0 }); const base = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
charts.depth = new Chart(document.getElementById('chart-depth'), { type:'line', data:{datasets:[mkDs('depth','#06d6a0')]}, options:makeChartOptions() }); Plotly.newPlot('chart-depth', [], { ...base, yaxis: { ...base.yaxis, autorange: 'reversed' } }, PLOTLY_CONFIG);
charts.pwmAuv = new Chart(document.getElementById('chart-pwm-auv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() }); Plotly.newPlot('chart-pwm-auv', [], { ...base }, PLOTLY_CONFIG);
charts.pwmUsv = new Chart(document.getElementById('chart-pwm-usv'), { type:'line', data:{datasets:[]}, options:makeChartOptions() }); Plotly.newPlot('chart-pwm-usv', [], { ...base }, PLOTLY_CONFIG);
charts.usbl = new Chart(document.getElementById('chart-usbl'), { type:'line', data:{datasets:[mkDs('dist','#a855f7')]}, options:makeChartOptions() }); Plotly.newPlot('chart-usbl', [], { ...base }, PLOTLY_CONFIG);
} }
function populateCharts() {
if (mcapSignals) { function populatePlotlyCharts() {
if (mcapSignals.depth) { charts.depth.data.datasets[0].data = mcapSignals.depth.map(p=>({x:p.t,y:p.v})); charts.depth.update('none'); } const layout = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
if (mcapSignals.pwm_auv) { const depthLayout = { ...layout, yaxis: { ...layout.yaxis, autorange: 'reversed' } };
const {channels,samples} = mcapSignals.pwm_auv;
charts.pwmAuv.data.datasets = channels.map((ch,i)=>({ Plotly.react('chart-depth', depthTraces, depthLayout, PLOTLY_CONFIG);
label:'Ch'+ch, data:samples.map(s=>({x:s.t,y:s.v[i]})), Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
borderColor:PWM_COLORS_AUV[i%8], borderWidth:1, pointRadius:0, tension:0 Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
})); Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
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');
} }
// 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 == // == Apply trail + cursor ==
@@ -365,35 +381,22 @@ function applyTrailAndCursor() {
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs); const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
const t1 = tNow; 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 trailPtsUsv = filterWindow(allPoints, t0, t1);
const trailPtsUsbl = filterWindow(usblPoints, t0, t1); const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
// Rebuild USV trail layers // Rebuild USV trail layers
trackLayers.forEach(l => map.removeLayer(l)); trackLayers.forEach(l => map.removeLayer(l));
trackLayers = []; trackLayers = [];
if (manifest) { const sourceNames = [...new Set(allPoints.map(p => p.source))];
const groups = {}; const groups = {};
trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); }); trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
manifest.sessions.forEach((sess, i) => { sourceNames.forEach((src, i) => {
const pts = groups[sess.source_name]||[]; const pts = groups[src] || [];
if (pts.length < 2) return; 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 }); 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); trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map); if (layerVis.usv) layer.addTo(map);
}); });
}
// Rebuild AUV trail layer // Rebuild AUV trail layer
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; } if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
@@ -405,8 +408,8 @@ function applyTrailAndCursor() {
// USV cursor marker at tNow // USV cursor marker at tNow
const pUsv = findNearest(allPoints, tNow); const pUsv = findNearest(allPoints, tNow);
if (pUsv) { if (pUsv) {
const sessIdx = manifest ? manifest.sessions.findIndex(s=>s.source_name===pUsv.source) : 0; const srcIdx = sourceNames.indexOf(pUsv.source);
const color = COLORS[Math.max(0,sessIdx)%COLORS.length]; const color = COLORS[Math.max(0, srcIdx) % COLORS.length];
if (!cursorMarker) { if (!cursorMarker) {
cursorMarker = L.marker([pUsv.lat,pUsv.lon], { icon:makeArrowIcon(pUsv.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 {
@@ -414,7 +417,7 @@ function applyTrailAndCursor() {
cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color)); cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color));
} }
document.getElementById('cursor-info').textContent = 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}`; `${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 // AUV + USBL at tNow
@@ -428,7 +431,7 @@ function applyTrailAndCursor() {
auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]); auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]);
} }
} }
const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.usv_lat,pUsbl.usv_lon]; const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.auv_lat,pUsbl.auv_lon];
if (layerVis.vec) { if (layerVis.vec) {
if (!usblVector) { 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); usblVector = L.polyline([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]], { color:'#888', weight:1.5, dashArray:'6,4', opacity:0.9 }).addTo(map);
@@ -437,27 +440,28 @@ function applyTrailAndCursor() {
usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]); usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]);
} }
} }
if (layerVis.panel) { if (layerVis.panel && pUsbl.dist != null) {
document.getElementById('usbl-panel').style.display = 'block'; document.getElementById('usbl-panel').style.display = 'block';
document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`; document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${pUsbl.az.toFixed(1)} deg`; document.getElementById('up-az').textContent = `${(pUsbl.az||0).toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${pUsbl.elev.toFixed(1)} deg`; document.getElementById('up-elev').textContent = `${(pUsbl.elev||0).toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${pUsbl.snr.toFixed(1)}`; document.getElementById('up-snr').textContent = `${(pUsbl.snr||0).toFixed(1)}`;
} }
} }
// stats
const dur = tNow - (trailMs===0?tMin:t0);
document.getElementById('stats').textContent = document.getElementById('stats').textContent =
`trail: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`; `t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
updateChartsCursor();
} }
// == Cursor slider == // == Cursor slider ==
let cursorSlider = null;
function initCursorSlider() { function initCursorSlider() {
cursorSlider = noUiSlider.create(document.getElementById('cursor-slider'), { if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider');
cursorSlider = noUiSlider.create(el, {
start: [tMin], start: [tMin],
range: { min: tMin, max: tMax }, range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
step: 1000, step: 1000,
}); });
cursorSlider.on('update', (values) => { cursorSlider.on('update', (values) => {
@@ -465,7 +469,6 @@ function initCursorSlider() {
document.getElementById('cursor-time').textContent = fmtMs(tNow); document.getElementById('cursor-time').textContent = fmtMs(tNow);
applyTrailAndCursor(); applyTrailAndCursor();
}); });
// trail select
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor()); document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
} }
@@ -481,72 +484,241 @@ document.getElementById('btn-play').addEventListener('click', () => {
}); });
// == Legend == // == Legend ==
function buildLegend() { function buildLegend(sourceNames) {
let html = ''; let html = '';
if (manifest) { sourceNames.forEach((src, i) => {
manifest.sessions.forEach((s,i) => { const name = src.replace(/_navigation_log|\.csv/g,'');
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:${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>`;
document.getElementById('legend').innerHTML = html; document.getElementById('legend').innerHTML = html;
} }
// == Load == // == Clear map layers ==
async function loadData() { function clearMapLayers() {
try { trackLayers.forEach(l => map.removeLayer(l));
const [trackResp, pointsResp, manifestResp, usblResp] = await Promise.all([ trackLayers = [];
fetch('data/track.geojson'), fetch('data/points.json'), if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
fetch('data/manifest.json'), fetch('data/usbl.json'), if (cursorMarker) { map.removeLayer(cursorMarker); cursorMarker=null; }
]); if (auvMarker) { map.removeLayer(auvMarker); auvMarker=null; }
if (!trackResp.ok) throw new Error('track.geojson not found'); if (usblVector) { map.removeLayer(usblVector); usblVector=null; }
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)); // == Load data for a given date ==
tMax = manifest.t_max_ms || Math.max(...allPoints.map(p=>p.t_ms||-Infinity)); async function loadDate(date) {
if (tMin===tMax) tMax=tMin+1000; 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; tNow = tMin;
document.getElementById('title').textContent = // Fit map
`COSMA v5 — USV ${manifest.n_sessions} sess. ${manifest.n_points_sampled} pts | AUV ${usblMeta.n_points} pts`; 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] });
}
buildLegend(); // 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(); initCursorSlider();
applyTrailAndCursor(); 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) { } catch(e) {
document.getElementById('stats').textContent = 'Erreur: '+e.message; setStatus('Erreur: ' + e.message);
console.error(e); console.error(e);
} }
} }
async function loadGraphData() { async function loadDiveData(missionId, diveId) {
initCharts();
try { try {
const [mcapResp, usvPwmResp] = await Promise.allSettled([ const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
fetch('data/mcap_signals.json'), fetch('data/usv_pwm.json'), 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 (mcapResp.status==='fulfilled' && mcapResp.value.ok) mcapSignals = await mcapResp.value.json(); if (trackResp.ok) {
if (usvPwmResp.status==='fulfilled' && usvPwmResp.value.ok) usvPwm = await usvPwmResp.value.json(); const d = await trackResp.json();
populateCharts(); const pts = (d.points||[]).map(p => ({
} catch(e) { console.warn('Graph data error:',e); } 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 == // == Datebar ==
@@ -555,10 +727,9 @@ let availableDates = [];
async function initDatebar() { async function initDatebar() {
const label = document.getElementById('mission-label'); const label = document.getElementById('mission-label');
const picker = document.getElementById('date-picker'); const picker = document.getElementById('date-picker');
const dl = document.getElementById('available-dates');
try { try {
const resp = await fetch('http://192.168.0.83:8766/api/data-dates'); const resp = await fetch(`${API}/api/data-dates`);
if (!resp.ok) throw new Error('data-dates HTTP ' + resp.status); if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json(); const data = await resp.json();
availableDates = data.dates || []; availableDates = data.dates || [];
if (!availableDates.length) { if (!availableDates.length) {
@@ -566,25 +737,21 @@ async function initDatebar() {
label.className = 'no-data'; label.className = 'no-data';
return; 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(); const dates = availableDates.map(d => d.date).sort();
picker.min = dates[0]; picker.min = dates[0];
picker.max = dates[dates.length-1]; picker.max = dates[dates.length-1];
// Default: last date
picker.value = dates[dates.length-1]; picker.value = dates[dates.length-1];
updateMissionLabel(picker.value); updateMissionLabel(picker.value);
picker.addEventListener('change', () => updateMissionLabel(picker.value)); picker.addEventListener('change', () => {
updateMissionLabel(picker.value);
loadDate(picker.value);
});
loadDate(picker.value);
} catch(e) { } catch(e) {
label.textContent = 'API indisponible'; label.textContent = 'API indisponible';
label.className = 'no-data'; label.className = 'no-data';
console.warn('datebar error:', e); setStatus('API 8766 inaccessible');
console.warn(e);
} }
} }
@@ -607,11 +774,12 @@ function datePickerToday() {
const picker = document.getElementById('date-picker'); const picker = document.getElementById('date-picker');
picker.value = today; picker.value = today;
updateMissionLabel(today); updateMissionLabel(today);
loadDate(today);
} }
// == Init ==
initCharts();
initDatebar(); initDatebar();
loadData();
loadGraphData();
</script> </script>
</body> </body>
</html> </html>