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
This commit is contained in:
@@ -2,266 +2,616 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>COSMA — USV Track Viewer</title>
|
||||
<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; }
|
||||
body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; }
|
||||
#map { flex: 1; }
|
||||
|
||||
/* ── top bar: date picker ── */
|
||||
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: #0d1b2a;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
flex-wrap: wrap;
|
||||
background: #0d0d20; border-bottom: 1px solid #0f3460;
|
||||
display: flex; align-items: center; gap: 8px; padding: 0 14px;
|
||||
flex-shrink: 0; height: 36px; overflow: hidden;
|
||||
}
|
||||
#datebar label { font-size: 11px; color: #a0c4ff; white-space: nowrap; }
|
||||
#date-picker {
|
||||
background: #16213e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #0f3460;
|
||||
padding: 4px 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
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(1); }
|
||||
#date-picker::-webkit-calendar-picker-indicator { filter: invert(0.8); cursor: pointer; }
|
||||
#btn-today {
|
||||
background: #0f3460;
|
||||
color: #a0c4ff;
|
||||
border: 1px solid #1a5276;
|
||||
padding: 4px 10px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
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: #1a5276; }
|
||||
#btn-today:hover { background: #00b4d8; color: #1a1a2e; }
|
||||
#mission-label {
|
||||
font-size: 12px;
|
||||
color: #e94560;
|
||||
font-weight: bold;
|
||||
min-width: 160px;
|
||||
}
|
||||
#dates-hint {
|
||||
font-size: 10px;
|
||||
color: #556;
|
||||
white-space: nowrap;
|
||||
font-size: 11px; font-family: monospace; padding: 2px 8px;
|
||||
}
|
||||
#mission-label.has-data { color: #06d6a0; }
|
||||
#mission-label.no-data { color: #555; }
|
||||
|
||||
/* ── bottom bar: slider ── */
|
||||
#controls {
|
||||
background: #16213e;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-top: 1px solid #0f3460;
|
||||
/* 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;
|
||||
}
|
||||
#slider { flex: 1; cursor: pointer; }
|
||||
#info { font-size: 11px; min-width: 340px; color: #a0c4ff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#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>
|
||||
|
||||
<!-- ── Date picker bar ── -->
|
||||
<!-- Row 0: Datebar -->
|
||||
<div id="datebar">
|
||||
<label>Date mission:</label>
|
||||
<input type="date" id="date-picker">
|
||||
<button id="btn-today">Aujourd'hui</button>
|
||||
<span id="mission-label">—</span>
|
||||
<span id="dates-hint">Chargement calendrier…</span>
|
||||
<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>
|
||||
|
||||
<div id="map"></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>
|
||||
|
||||
<!-- ── Slider bar ── -->
|
||||
<!-- 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">
|
||||
<span id="title">USV Track</span>
|
||||
<input type="range" id="slider" min="0" value="0">
|
||||
<div id="info">Chargement…</div>
|
||||
<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>
|
||||
// ── Map init ──────────────────────────────────────────────────────────────
|
||||
const map = L.map('map', { zoomControl: true });
|
||||
// == 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: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
attribution: '(c) OpenStreetMap contributors', maxZoom: 19
|
||||
});
|
||||
osm.addTo(map);
|
||||
|
||||
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenSeaMap',
|
||||
maxZoom: 18,
|
||||
opacity: 0.8,
|
||||
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,
|
||||
}
|
||||
{ attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 }
|
||||
);
|
||||
|
||||
L.control.layers(
|
||||
{ 'OpenStreetMap': osm },
|
||||
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymétrie)': gebco },
|
||||
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymetrie)': gebco },
|
||||
{ collapsed: false }
|
||||
).addTo(map);
|
||||
|
||||
// ── SVG arrow marker factory ──────────────────────────────────────────────
|
||||
function makeArrowIcon(heading) {
|
||||
// == 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="#e94560" stroke="#fff" stroke-width="1.5"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
return L.divIcon({
|
||||
html: svg,
|
||||
className: '',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
<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());
|
||||
}
|
||||
|
||||
// ── Calendar data ─────────────────────────────────────────────────────────
|
||||
const BACKEND = 'http://192.168.0.83:8766';
|
||||
let dataMissions = {}; // date → {missions, session_count}
|
||||
|
||||
async function loadCalendar() {
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND}/api/data-dates`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = await resp.json();
|
||||
json.dates.forEach(d => {
|
||||
dataMissions[d.date] = { missions: d.missions, session_count: d.session_count };
|
||||
});
|
||||
|
||||
const availDates = Object.keys(dataMissions).sort();
|
||||
const picker = document.getElementById('date-picker');
|
||||
|
||||
if (availDates.length > 0) {
|
||||
picker.min = availDates[0];
|
||||
picker.max = availDates[availDates.length - 1];
|
||||
}
|
||||
|
||||
// Highlight available dates via custom attribute list (CSS trick)
|
||||
// We inject a datalist for browsers that support it
|
||||
const dl = document.createElement('datalist');
|
||||
dl.id = 'available-dates';
|
||||
availDates.forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
document.body.appendChild(dl);
|
||||
picker.setAttribute('list', 'available-dates');
|
||||
|
||||
document.getElementById('dates-hint').textContent =
|
||||
`${availDates.length} dates avec données`;
|
||||
|
||||
// Select most recent date by default
|
||||
if (availDates.length > 0) {
|
||||
picker.value = availDates[availDates.length - 1];
|
||||
onDateChange(picker.value);
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('dates-hint').textContent = 'Calendrier indispo: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function onDateChange(date) {
|
||||
const label = document.getElementById('mission-label');
|
||||
const info = dataMissions[date];
|
||||
if (info) {
|
||||
label.textContent = info.missions.join(', ') + ` (${info.session_count} session${info.session_count > 1 ? 's' : ''})`;
|
||||
label.style.color = '#00e676';
|
||||
} else {
|
||||
label.textContent = 'Aucune donnée pour cette date';
|
||||
label.style.color = '#666';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('date-picker').addEventListener('change', e => onDateChange(e.target.value));
|
||||
|
||||
document.getElementById('btn-today').addEventListener('click', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const picker = document.getElementById('date-picker');
|
||||
picker.value = today;
|
||||
onDateChange(today);
|
||||
// == 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);
|
||||
});
|
||||
|
||||
// ── Track data loading ────────────────────────────────────────────────────
|
||||
let points = [];
|
||||
let trackLayer = null;
|
||||
let marker = null;
|
||||
// == 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] = await Promise.all([
|
||||
fetch('track.geojson'),
|
||||
fetch('points.json'),
|
||||
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();
|
||||
points = await pointsResp.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);
|
||||
|
||||
// Draw track
|
||||
trackLayer = L.geoJSON(trackGeo, {
|
||||
style: { color: '#00b4d8', weight: 2, opacity: 0.8 },
|
||||
}).addTo(map);
|
||||
map.fitBounds(trackLayer.getBounds(), { padding: [40, 40] });
|
||||
// Static faded USV track background
|
||||
L.geoJSON(trackGeo, { style:(f)=>({ color:f.properties.color||'#00b4d8', weight:2, opacity:0.3 }) }).addTo(map);
|
||||
|
||||
// Init slider
|
||||
const slider = document.getElementById('slider');
|
||||
slider.max = points.length - 1;
|
||||
slider.value = 0;
|
||||
slider.addEventListener('input', () => updateMarker(+slider.value));
|
||||
|
||||
// Init marker
|
||||
if (points.length > 0) {
|
||||
const p = points[0];
|
||||
marker = L.marker([p.lat, p.lon], { icon: makeArrowIcon(p.heading || 0) }).addTo(map);
|
||||
updateInfo(0);
|
||||
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]});
|
||||
}
|
||||
|
||||
document.getElementById('title').textContent = `USV Track — ${points.length} pts`;
|
||||
} catch (e) {
|
||||
document.getElementById('info').textContent = 'Erreur: ' + e.message;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMarker(idx) {
|
||||
const p = points[idx];
|
||||
if (!p || !marker) return;
|
||||
marker.setLatLng([p.lat, p.lon]);
|
||||
marker.setIcon(makeArrowIcon(p.heading || 0));
|
||||
updateInfo(idx);
|
||||
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); }
|
||||
}
|
||||
|
||||
function updateInfo(idx) {
|
||||
const p = points[idx];
|
||||
if (!p) return;
|
||||
const hdg = p.heading !== null ? `${p.heading.toFixed(1)}°` : 'N/A';
|
||||
document.getElementById('info').textContent =
|
||||
`[${idx+1}/${points.length}] ${p.t} | Lat: ${p.lat.toFixed(6)} Lon: ${p.lon.toFixed(6)} | Cap: ${hdg}`;
|
||||
// == 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
loadCalendar();
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user