Files
cosma-nav-tools/viewer/index.html
2026-04-27 23:28:05 +02:00

1168 lines
44 KiB
HTML
Raw Permalink 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 v6</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css"/>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100vh; overflow: hidden;
font-family: monospace; background: #1a1a2e; color: #e0e0e0;
display: grid;
grid-template-rows: 36px 40px 1fr 54px 1fr;
}
/* Row 0: datebar */
#datebar {
background: #0d0d20; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 8px; padding: 0 14px;
flex-shrink: 0; height: 36px; overflow: hidden;
}
#date-picker {
background: #0f3460; border: 1px solid #00b4d8; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer;
}
#date-picker::-webkit-calendar-picker-indicator { filter: invert(0.8); cursor: pointer; }
#btn-today {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-today:hover { background: #00b4d8; color: #1a1a2e; }
#mission-label { font-size: 11px; font-family: monospace; padding: 2px 8px; }
#mission-label.has-data { color: #06d6a0; }
#mission-label.no-data { color: #555; }
#load-status { font-size: 10px; color: #888; flex: 1; }
/* Row 1: header */
#header {
background: #12122a; border-bottom: 1px solid #0f3460;
display: flex; align-items: center; gap: 12px; padding: 0 14px;
flex-shrink: 0; height: 40px; overflow: hidden;
}
#title { font-size: 13px; font-weight: bold; color: #e94560; white-space: nowrap; }
#stats { font-size: 11px; color: #a0c4ff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#layer-toggles { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.layer-btn {
font-family: monospace; font-size: 10px; padding: 2px 7px; cursor: pointer;
border-radius: 2px; border: 1px solid; background: transparent;
}
.layer-btn.active { opacity: 1; }
.layer-btn.inactive { opacity: 0.35; }
#btn-usv { color: #00b4d8; border-color: #00b4d8; }
#btn-auv { color: #ff8800; border-color: #ff8800; }
#btn-vec { color: #888; border-color: #888; }
#btn-usbl-panel { color: #aaa; border-color: #444; }
/* Row 2: map */
#map { position: relative; min-height: 0; }
/* USBL panel over map */
#usbl-panel {
position: absolute; top: 10px; left: 50px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 12px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace; min-width: 180px; display: none;
}
#usbl-panel .uprow { display: flex; justify-content: space-between; gap: 16px; margin: 1px 0; }
#usbl-panel .uplabel { color: #666; }
#usbl-panel .upval { color: #ff8800; font-weight: bold; }
#usbl-panel .badge {
display: inline-block; background: #ff8800; color: #1a1a2e;
font-size: 9px; font-weight: bold; padding: 1px 4px; border-radius: 2px; margin-top: 4px;
}
/* legend over map */
#legend {
position: absolute; bottom: 10px; left: 10px; z-index: 1000;
background: rgba(22,33,62,0.92); padding: 8px 10px; border: 1px solid #0f3460;
font-size: 11px; font-family: monospace;
}
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 14px; height: 4px; border-radius: 2px; flex-shrink: 0; }
.legend-dashed { border-top: 2px dashed #888; width: 14px; flex-shrink: 0; }
/* Row 3: controls */
#controls {
background: #16213e; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; display: flex; flex-direction: column; gap: 4px;
flex-shrink: 0;
}
#ctrl-row1, #ctrl-row2 { display: flex; align-items: center; gap: 10px; }
#ctrl-label { font-size: 10px; color: #666; white-space: nowrap; }
#cursor-slider-wrap { flex: 1; padding: 4px 0; }
.noUi-target { background: #0f3460; border: none; border-radius: 2px; box-shadow: none; }
.noUi-horizontal { height: 6px; }
.noUi-connect { background: #e94560; }
.noUi-handle {
background: #e94560; border: 2px solid #fff;
border-radius: 50%; width: 14px !important; height: 14px !important;
top: -5px !important; right: -7px !important;
box-shadow: none; cursor: pointer;
}
.noUi-handle::before, .noUi-handle::after { display: none; }
#cursor-time { font-size: 11px; color: #e0e0e0; white-space: nowrap; min-width: 180px; }
#trail-select {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px; cursor: pointer;
}
#btn-viewall, #btn-play {
background: #0f3460; border: 1px solid #00b4d8; color: #00b4d8;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-viewall:hover, #btn-play:hover { background: #00b4d8; color: #1a1a2e; }
/* Row 4: 2×2 charts grid */
#graphs-section {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
background: #0a0a1a;
padding: 3px;
min-height: 0; overflow: hidden;
}
.chart-wrap {
background: #12122a;
border: 1px solid #0f3460;
display: flex; flex-direction: column;
padding: 4px 8px 3px;
min-height: 0;
overflow: hidden;
position: relative;
}
.chart-title { font-size: 10px; color: #a0c4ff; margin-bottom: 2px; flex-shrink: 0; }
.chart-wrap .plotly-wrap { flex: 1; min-height: 0; position: relative; }
.chart-wrap .plotly-wrap > div { width: 100% !important; height: 100% !important; }
/* Pipeline overlay */
#pipeline-overlay {
display: none;
position: fixed; inset: 0; z-index: 9000;
background: rgba(10,10,26,0.92);
backdrop-filter: blur(4px);
align-items: center; justify-content: center;
}
#pipeline-overlay.visible { display: flex; }
#pipeline-box {
background: #12122a; border: 1px solid #0f3460;
padding: 20px 24px; border-radius: 4px;
max-width: 95vw; max-height: 90vh;
overflow: auto; position: relative;
min-width: 320px;
}
#pipeline-close {
position: absolute; top: 8px; right: 10px;
background: none; border: none; color: #e94560;
font-size: 18px; cursor: pointer; font-family: monospace; line-height: 1;
}
#pipeline-title {
font-size: 12px; color: #a0c4ff; margin-bottom: 14px;
font-family: monospace; letter-spacing: 1px;
}
#btn-pipeline {
background: #0f3460; border: 1px solid #a855f7; color: #a855f7;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px; flex-shrink: 0;
}
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
#sortie-select {
background: #0f3460; border: 1px solid #e94560; color: #e0e0e0;
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
cursor: pointer; max-width: 160px;
}
#btn-sync {
background: #0f3460; border: 1px solid #e94560; color: #e94560;
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
border-radius: 2px;
}
#btn-sync:hover { background: #e94560; color: #1a1a2e; }
#btn-sync:disabled { opacity: 0.4; cursor: not-allowed; }
#sync-progress {
font-size: 10px; color: #06d6a0; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.panel-header {
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
padding: 4px 14px; font-size: 11px; font-weight: bold; color: #e94560;
display: flex; align-items: center; gap: 8px;
}
.auv-tab {
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
border: 1px solid #0f3460; background: transparent; color: #a0c4ff; border-radius: 2px;
}
.auv-tab.active { background: #0f3460; color: #e0e0e0; }
.graphs-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px;
padding: 4px 8px; background: #12122a;
}
.graph-cell { height: 130px; background: #1a1a2e; }
.graph-cell.wide { grid-column: span 2; height: 130px; }
</style>
</head>
<body>
<!-- Row 0: Datebar -->
<div id="datebar">
<input type="date" id="date-picker">
<button id="btn-today" onclick="datePickerToday()">Aujourd'hui</button>
<span id="mission-label" class="no-data">Chargement...</span>
<span id="load-status"></span>
<button id="btn-pipeline" onclick="togglePipeline()">Pipeline</button>
</div>
<!-- Pipeline overlay -->
<div id="pipeline-overlay" onclick="hidePipelineOnBackdrop(event)">
<div id="pipeline-box">
<button id="pipeline-close" onclick="togglePipeline()"></button>
<div id="pipeline-title">DATA PIPELINE — COSMA NAV VIEWER</div>
<div class="mermaid">
flowchart LR
subgraph SOURCES
MCAP["USV/AUV .mcap files\nROS2 bags post-Monaco"]
CSV["USV/AUV .csv files\npré-Monaco sans ROS2"]
end
subgraph PARSING
MCAP_R["MCAP Reader\nextract topics"]
CSV_R["pandas read_csv"]
end
subgraph CACHE
PQ["Parquet cache\n/cache/*.parquet"]
end
subgraph API["FastAPI 8766"]
A1["/api/data-dates/"]
A2["/api/missions/"]
A3["/api/ship/.../track/"]
A4["/api/sub/.../series/"]
A5["/api/sub/.../usbl_track/"]
end
subgraph VIEWER["NAV Viewer 8765"]
MAP["Leaflet map\nUSV arrow + AUV USBL"]
CHARTS["Plotly 4 charts\ndepth/PWM/USBL"]
SLIDER["Slider 24h cursor"]
end
MCAP --> MCAP_R --> PQ
CSV --> CSV_R --> PQ
PQ --> A1 & A2 & A3 & A4 & A5
A1 --> SLIDER
A3 --> MAP
A4 --> CHARTS
A5 --> MAP
</div>
</div>
</div>
<!-- Row 1: Header -->
<div id="header">
<span id="title">COSMA NAV v6</span>
<span id="stats">Chargement...</span>
<div id="layer-toggles">
<button class="layer-btn active" id="btn-usv" onclick="toggleLayer('usv')">USV</button>
<button class="layer-btn active" id="btn-auv" onclick="toggleLayer('auv')">AUV</button>
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
</div>
<select id="sortie-select"><option value="">— Sortie —</option></select>
<button id="btn-sync" disabled>Sync &amp; Process</button>
<span id="sync-progress"></span>
</div>
<!-- Row 2: Map -->
<div id="map">
<div id="legend"></div>
<div id="usbl-panel">
<div class="uprow"><span class="uplabel">Dist</span><span class="upval" id="up-dist"></span></div>
<div class="uprow"><span class="uplabel">Az (rel)</span><span class="upval" id="up-az"></span></div>
<div class="uprow"><span class="uplabel">Elev</span><span class="upval" id="up-elev"></span></div>
<div class="uprow"><span class="uplabel">SNR</span><span class="upval" id="up-snr"></span></div>
<div><span class="badge">REL HEADING</span></div>
</div>
</div>
<!-- Row 3: Controls -->
<div id="controls">
<div id="ctrl-row1">
<span id="ctrl-label">t</span>
<div id="cursor-slider-wrap"><div id="cursor-slider"></div></div>
<span id="cursor-time"></span>
<label for="trail-select">trail</label>
<select id="trail-select">
<option value="10000">10s</option>
<option value="30000">30s</option>
<option value="60000" selected>60s</option>
<option value="300000">5min</option>
<option value="900000">15min</option>
<option value="3600000">1h</option>
<option value="0">ALL</option>
</select>
<button id="btn-viewall" onclick="viewAll()">View all</button>
<button id="btn-play"></button>
</div>
<div id="ctrl-row2">
<span id="cursor-info" style="font-size:10px;color:#888;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
</div>
</div>
<!-- Row 4: 2×2 Plotly charts -->
<div id="graphs-section">
<div class="chart-wrap">
<div class="chart-title">Depth AUV (m)</div>
<div class="plotly-wrap"><div id="chart-depth"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors AUV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-auv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">Motors USV (PWM)</div>
<div class="plotly-wrap"><div id="chart-pwm-usv"></div></div>
</div>
<div class="chart-wrap">
<div class="chart-title">USBL Distance (m)</div>
<div class="plotly-wrap"><div id="chart-usbl"></div></div>
</div>
</div>
<div class="panel-header">USV</div>
<div class="graphs-grid" id="usv-graphs">
<div class="graph-cell" id="usv-yaw"></div>
<div class="graph-cell" id="usv-heading"></div>
<div class="graph-cell" id="usv-batt"></div>
<div class="graph-cell" id="usv-gps"></div>
<div class="graph-cell" id="usv-usbl-dist"></div>
<div class="graph-cell" id="usv-usbl-angle"></div>
<div class="graph-cell" id="usv-m1"></div>
<div class="graph-cell" id="usv-m2"></div>
<div class="graph-cell wide" id="usv-status"></div>
</div>
<div class="panel-header" id="auv-panel-header">
AUV
<span id="auv-tabs"></span>
</div>
<div class="graphs-grid" id="auv-graphs">
<div class="graph-cell" id="auv-pry"></div>
<div class="graph-cell" id="auv-depth"></div>
<div class="graph-cell" id="auv-alt"></div>
<div class="graph-cell" id="auv-obs"></div>
<div class="graph-cell" id="auv-usbl-dist"></div>
<div class="graph-cell" id="auv-usbl-angle"></div>
<div class="graph-cell" id="auv-batt"></div>
<div class="graph-cell" id="auv-status"></div>
<div class="graph-cell wide" id="auv-motors"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.js"></script>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<script>
// == Constants ==
const API = 'http://192.168.0.83:8766';
const API2 = 'http://192.168.0.83:8767';
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
const AUV_COLOR = '#ff8800';
const PLOTLY_LAYOUT = {
paper_bgcolor: '#12122a',
plot_bgcolor: '#12122a',
margin: { t: 2, r: 8, b: 24, l: 42 },
font: { color: '#a0c4ff', size: 9, family: 'monospace' },
xaxis: {
gridcolor: '#1a1a3e', zerolinecolor: '#1a1a3e',
tickformat: '%H:%M:%S', color: '#666'
},
yaxis: { gridcolor: '#1a1a3e', zerolinecolor: '#1a1a3e', color: '#666' },
showlegend: false,
autosize: true,
};
const PLOTLY_CONFIG = { responsive: true, displayModeBar: false };
// == Map init ==
const map = L.map('map', { zoomControl: true });
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '(c) OpenStreetMap contributors', maxZoom: 19
});
osm.addTo(map);
const seamarks = L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
attribution: '(c) OpenSeaMap', maxZoom: 18, opacity: 0.8
});
const gebco = L.tileLayer(
'https://tiles.arcgis.com/tiles/C8EMgrsFcRFL6LrL/arcgis/rest/services/GEBCO_basemap_NCEI/MapServer/tile/{z}/{y}/{x}',
{ attribution: 'GEBCO / NCEI', maxZoom: 13, opacity: 0.6 }
);
L.control.layers(
{ 'OpenStreetMap': osm },
{ 'OpenSeaMap (balises)': seamarks, 'GEBCO (bathymetrie)': gebco },
{ collapsed: false }
).addTo(map);
// == Helpers ==
function makeArrowIcon(heading, color) {
color = color || '#e94560';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="-16 -16 32 32">
<g transform="rotate(${heading||0})"><polygon points="0,-12 6,8 0,4 -6,8" fill="${color}" stroke="#fff" stroke-width="1.5"/></g></svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [32,32], iconAnchor: [16,16] });
}
function makeAuvIcon() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-10 -10 20 20">
<circle r="7" fill="${AUV_COLOR}" stroke="#fff" stroke-width="2"/><circle r="2" fill="#fff"/></svg>`;
return L.divIcon({ html: svg, className: '', iconSize: [20,20], iconAnchor: [10,10] });
}
function bisectLeft(arr, val) {
let lo = 0, hi = arr.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid].t_ms < val) lo = mid+1; else hi = mid; }
return lo;
}
function findNearest(arr, tms) {
if (!arr.length) return null;
let i = bisectLeft(arr, tms);
if (i === 0) return arr[0];
if (i >= arr.length) return arr[arr.length-1];
return (arr[i].t_ms - tms) < (tms - arr[i-1].t_ms) ? arr[i] : arr[i-1];
}
function filterWindow(arr, t0, t1) {
const lo = bisectLeft(arr, t0);
const res = [];
for (let i = lo; i < arr.length && arr[i].t_ms <= t1; i++) res.push(arr[i]);
return res;
}
function fmtMs(ms) {
if (!ms) return '—';
return new Date(ms).toISOString().replace('T',' ').slice(0,19)+' UTC';
}
function fmtDur(ms) {
const s = Math.floor(ms/1000), h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sc = s%60;
return h>0 ? `${h}h${String(m).padStart(2,'0')}m` : `${m}m${String(sc).padStart(2,'0')}s`;
}
// Convert ISO timestamp string to ms
function isoToMs(t) { return new Date(t).getTime(); }
// Convert Unix seconds to ms
function unixToMs(t) { return t * 1000; }
// == State ==
let allPoints = []; // {t_ms, lat, lon, heading, source}
let usblPoints = []; // {t_ms, auv_lat, auv_lon, dist, az, elev, snr}
let sessionsMeta = []; // loaded session metadata
let tMin = 0, tMax = 0, tNow = 0;
let trailMs = 60000;
let trackLayers = [], auvTrackLayer = null, cursorMarker = null, auvMarker = null, usblVector = null;
const layerVis = { usv: true, auv: true, vec: true, panel: true };
let playTimer = null;
let cursorSlider = null;
// Plotly chart data state
let depthTraces = [];
let pwmAuvTraces = [];
let pwmUsvTraces = [];
let usblDistTraces = [];
// == Layer toggles ==
function toggleLayer(name) {
layerVis[name] = !layerVis[name];
const btn = document.getElementById('btn-'+(name==='panel'?'usbl-panel':name));
btn.classList.toggle('active', layerVis[name]);
btn.classList.toggle('inactive', !layerVis[name]);
if (name==='usv') trackLayers.forEach(l => layerVis.usv ? map.addLayer(l) : map.removeLayer(l));
if (name==='auv') {
if (auvTrackLayer) layerVis.auv ? map.addLayer(auvTrackLayer) : map.removeLayer(auvTrackLayer);
if (auvMarker) layerVis.auv ? map.addLayer(auvMarker) : map.removeLayer(auvMarker);
}
if (name==='vec' && usblVector) layerVis.vec ? map.addLayer(usblVector) : map.removeLayer(usblVector);
if (name==='panel') document.getElementById('usbl-panel').style.display = layerVis.panel && usblPoints.length ? 'block' : 'none';
}
// == View All ==
function viewAll() {
document.getElementById('trail-select').value = '0';
trailMs = 0;
applyTrailAndCursor();
}
// == Plotly charts init ==
function initCharts() {
const base = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
Plotly.newPlot('chart-depth', [], { ...base, yaxis: { ...base.yaxis, autorange: 'reversed' } }, PLOTLY_CONFIG);
Plotly.newPlot('chart-pwm-auv', [], { ...base }, PLOTLY_CONFIG);
Plotly.newPlot('chart-pwm-usv', [], { ...base }, PLOTLY_CONFIG);
Plotly.newPlot('chart-usbl', [], { ...base }, PLOTLY_CONFIG);
}
function populatePlotlyCharts() {
const layout = JSON.parse(JSON.stringify(PLOTLY_LAYOUT));
const depthLayout = { ...layout, yaxis: { ...layout.yaxis, autorange: 'reversed' } };
Plotly.react('chart-depth', depthTraces, depthLayout, PLOTLY_CONFIG);
Plotly.react('chart-pwm-auv', pwmAuvTraces, { ...layout, showlegend: pwmAuvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-pwm-usv', pwmUsvTraces, { ...layout, showlegend: pwmUsvTraces.length > 1 }, PLOTLY_CONFIG);
Plotly.react('chart-usbl', usblDistTraces, layout, PLOTLY_CONFIG);
}
// Update cursor line on all Plotly charts
function updateChartsCursor() {
if (!tMin || !tMax) return;
const tNowDate = new Date(tNow);
const t0Date = new Date(trailMs === 0 ? tMin : Math.max(tMin, tNow - trailMs));
const t1Date = new Date(trailMs === 0 ? tMax : tNow);
const shapeBase = { type: 'line', x0: tNowDate, x1: tNowDate, y0: 0, y1: 1, yref: 'paper',
line: { color: '#e94560', width: 1.5, dash: 'dot' } };
const rangeUpdate = { 'xaxis.range': [t0Date, t1Date] };
['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl'].forEach(id => {
Plotly.relayout(id, { ...rangeUpdate, shapes: [shapeBase] });
});
}
// == Apply trail + cursor ==
function applyTrailAndCursor() {
const trail = +document.getElementById('trail-select').value;
trailMs = trail;
const t0 = trailMs===0 ? tMin : Math.max(tMin, tNow - trailMs);
const t1 = tNow;
const trailPtsUsv = filterWindow(allPoints, t0, t1);
const trailPtsUsbl = filterWindow(usblPoints, t0, t1);
// Rebuild USV trail layers
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
const sourceNames = [...new Set(allPoints.map(p => p.source))];
const groups = {};
trailPtsUsv.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
sourceNames.forEach((src, i) => {
const pts = groups[src] || [];
if (pts.length < 2) return;
const layer = L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:2.5, opacity:0.85 });
trackLayers.push(layer);
if (layerVis.usv) layer.addTo(map);
});
// Rebuild AUV trail layer
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
if (trailPtsUsbl.length >= 2) {
auvTrackLayer = L.polyline(trailPtsUsbl.map(p=>[p.auv_lat,p.auv_lon]), { color:AUV_COLOR, weight:2.5, opacity:0.85 });
if (layerVis.auv) auvTrackLayer.addTo(map);
}
// USV cursor marker at tNow
const pUsv = findNearest(allPoints, tNow);
if (pUsv) {
const srcIdx = sourceNames.indexOf(pUsv.source);
const color = COLORS[Math.max(0, srcIdx) % COLORS.length];
if (!cursorMarker) {
cursorMarker = L.marker([pUsv.lat,pUsv.lon], { icon:makeArrowIcon(pUsv.heading||0,color), zIndexOffset:1000 }).addTo(map);
} else {
cursorMarker.setLatLng([pUsv.lat,pUsv.lon]);
cursorMarker.setIcon(makeArrowIcon(pUsv.heading||0,color));
}
document.getElementById('cursor-info').textContent =
`${fmtMs(pUsv.t_ms)} | ${pUsv.lat.toFixed(6)}, ${pUsv.lon.toFixed(6)} | cap: ${pUsv.heading!=null?pUsv.heading.toFixed(1):'N/A'} | ${pUsv.source}`;
}
// AUV + USBL at tNow
const pUsbl = findNearest(usblPoints, tNow);
if (pUsbl) {
if (layerVis.auv) {
if (!auvMarker) {
auvMarker = L.marker([pUsbl.auv_lat,pUsbl.auv_lon], { icon:makeAuvIcon(), zIndexOffset:900 }).addTo(map);
} else {
if (!map.hasLayer(auvMarker)) map.addLayer(auvMarker);
auvMarker.setLatLng([pUsbl.auv_lat,pUsbl.auv_lon]);
}
}
const usvPos = pUsv ? [pUsv.lat,pUsv.lon] : [pUsbl.auv_lat,pUsbl.auv_lon];
if (layerVis.vec) {
if (!usblVector) {
usblVector = L.polyline([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]], { color:'#888', weight:1.5, dashArray:'6,4', opacity:0.9 }).addTo(map);
} else {
if (!map.hasLayer(usblVector)) map.addLayer(usblVector);
usblVector.setLatLngs([usvPos,[pUsbl.auv_lat,pUsbl.auv_lon]]);
}
}
if (layerVis.panel && pUsbl.dist != null) {
document.getElementById('usbl-panel').style.display = 'block';
document.getElementById('up-dist').textContent = `${pUsbl.dist.toFixed(2)} m`;
document.getElementById('up-az').textContent = `${(pUsbl.az||0).toFixed(1)} deg`;
document.getElementById('up-elev').textContent = `${(pUsbl.elev||0).toFixed(1)} deg`;
document.getElementById('up-snr').textContent = `${(pUsbl.snr||0).toFixed(1)}`;
}
}
document.getElementById('stats').textContent =
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
updateChartsCursor();
if (tNow) updateCursor(tNow / 1000);
}
// == Cursor slider ==
function initCursorSlider() {
if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider');
cursorSlider = noUiSlider.create(el, {
start: [tMin],
range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
step: 1000,
});
cursorSlider.on('update', (values) => {
tNow = Math.round(+values[0]);
document.getElementById('cursor-time').textContent = fmtMs(tNow);
applyTrailAndCursor();
});
document.getElementById('trail-select').addEventListener('change', () => applyTrailAndCursor());
}
// == Play ==
document.getElementById('btn-play').addEventListener('click', () => {
if (playTimer) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
document.getElementById('btn-play').textContent='❚❚';
playTimer = setInterval(() => {
if (tNow >= tMax) { clearInterval(playTimer); playTimer=null; document.getElementById('btn-play').textContent='▶'; return; }
tNow = Math.min(tNow + 5000, tMax);
if (cursorSlider) cursorSlider.set(tNow);
}, 100);
});
// == Legend ==
function buildLegend(sourceNames) {
let html = '';
sourceNames.forEach((src, i) => {
const name = src.replace(/_navigation_log|\.csv/g,'');
html += `<div class="legend-item"><div class="legend-dot" style="background:${COLORS[i%COLORS.length]}"></div><span>USV ${name}</span></div>`;
});
html += `<div class="legend-item"><div class="legend-dot" style="background:${AUV_COLOR}"></div><span>AUV (USBL proj.)</span></div>`;
html += `<div class="legend-item"><div class="legend-dashed"></div><span>Vecteur USBL</span></div>`;
document.getElementById('legend').innerHTML = html;
}
// == Clear map layers ==
function clearMapLayers() {
trackLayers.forEach(l => map.removeLayer(l));
trackLayers = [];
if (auvTrackLayer) { map.removeLayer(auvTrackLayer); auvTrackLayer=null; }
if (cursorMarker) { map.removeLayer(cursorMarker); cursorMarker=null; }
if (auvMarker) { map.removeLayer(auvMarker); auvMarker=null; }
if (usblVector) { map.removeLayer(usblVector); usblVector=null; }
}
// == Load data for a given date ==
async function loadDate(date) {
setStatus('Chargement...');
clearMapLayers();
allPoints = [];
usblPoints = [];
depthTraces = [];
pwmAuvTraces = [];
pwmUsvTraces = [];
usblDistTraces = [];
// Find which missions/dives match this date
try {
const mResp = await fetch(`${API}/api/missions`);
const missions = await mResp.json();
const dateStr = date.replace(/-/g,''); // YYYYMMDD
const fetches = missions.map(async mission => {
const dResp = await fetch(`${API}/api/missions/${mission.id}/dives`);
const dives = await dResp.json();
return { mission, dives: dives.filter(d => d.id.startsWith(dateStr)) };
});
const missionDives = (await Promise.all(fetches)).filter(md => md.dives.length > 0);
if (!missionDives.length) {
setStatus('Aucune donnée pour cette date');
document.getElementById('stats').textContent = 'Aucune donnée';
return;
}
// Fetch all sessions for matching dives
let totalShip = 0, totalSub = 0;
const allFetches = [];
for (const { mission, dives } of missionDives) {
for (const dive of dives) {
allFetches.push(loadDiveData(mission.id, dive.id));
totalShip += dive.ship_session_count || 0;
totalSub += dive.sub_session_count || 0;
}
}
await Promise.all(allFetches);
// Sort
allPoints.sort((a,b)=>a.t_ms-b.t_ms);
usblPoints.sort((a,b)=>a.t_ms-b.t_ms);
if (!allPoints.length && !usblPoints.length) {
setStatus('Pas de points pour cette date');
return;
}
const allTimes = [
...allPoints.map(p=>p.t_ms),
...usblPoints.map(p=>p.t_ms)
];
tMin = Math.min(...allTimes);
tMax = Math.max(...allTimes);
tNow = tMin;
// Fit map
const allLats = [
...allPoints.map(p=>p.lat),
...usblPoints.map(p=>p.auv_lat)
].filter(Boolean);
const allLons = [
...allPoints.map(p=>p.lon),
...usblPoints.map(p=>p.auv_lon)
].filter(Boolean);
if (allLats.length) {
map.fitBounds([
[Math.min(...allLats), Math.min(...allLons)],
[Math.max(...allLats), Math.max(...allLons)]
], { padding: [40,40] });
}
// Static faded USV track background
const sourceNames = [...new Set(allPoints.map(p=>p.source))];
const groups = {};
allPoints.forEach(p => { if (!groups[p.source]) groups[p.source]=[]; groups[p.source].push(p); });
sourceNames.forEach((src,i) => {
const pts = groups[src]||[];
if (pts.length < 2) return;
L.polyline(pts.map(p=>[p.lat,p.lon]), { color:COLORS[i%COLORS.length], weight:1.5, opacity:0.2 }).addTo(map);
});
buildLegend(sourceNames);
populatePlotlyCharts();
initCursorSlider();
applyTrailAndCursor();
document.getElementById('title').textContent = `COSMA v6 — ${date}`;
setStatus(`${totalShip} USV sess. | ${totalSub} AUV sess. | ${allPoints.length} pts USV | ${usblPoints.length} pts USBL`);
} catch(e) {
setStatus('Erreur: ' + e.message);
console.error(e);
}
}
async function loadDiveData(missionId, diveId) {
try {
const sResp = await fetch(`${API}/api/dives/${missionId}/${diveId}/sessions`);
const sessions = await sResp.json();
const sessionFetches = [];
// Ship sessions
if (sessions.ship) {
sessions.ship.forEach(sess => {
sessionFetches.push(loadShipSession(missionId, diveId, sess.id));
});
}
// Sub sessions
if (sessions.sub) {
sessions.sub.forEach(sess => {
sessionFetches.push(loadSubSession(missionId, diveId, sess.id));
});
}
await Promise.all(sessionFetches);
} catch(e) { console.warn('loadDiveData error', diveId, e); }
}
async function loadShipSession(missionId, diveId, sessionId) {
try {
const [trackResp, seriesResp] = await Promise.all([
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`),
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`),
]);
if (trackResp.ok) {
const d = await trackResp.json();
const pts = (d.points||[]).map(p => ({
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
heading: p.heading || null, source: sessionId
}));
allPoints.push(...pts);
}
if (seriesResp.ok) {
const d = await seriesResp.json();
// USV PWM: M1..M8
const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
if (!pts || !pts.length) return;
pwmUsvTraces.push({
x: pts.map(p => new Date(isoToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
line: { color: COLORS[i % COLORS.length], width: 1 },
});
});
}
} catch(e) { console.warn('loadShipSession error', sessionId, e); }
}
async function loadSubSession(missionId, diveId, sessionId) {
try {
const [seriesResp, usblResp] = await Promise.all([
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`),
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`),
]);
if (seriesResp.ok) {
const d = await seriesResp.json();
// Depth trace
if (d.depth && d.depth.length) {
depthTraces.push({
x: d.depth.map(p => new Date(unixToMs(p.t))),
y: d.depth.map(p => p.v),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#06d6a0', width: 1.5 },
});
}
// AUV motors: m1..m8
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
motorKeys.forEach((k, i) => {
const pts = d[k];
if (!pts || !pts.length) return;
pwmAuvTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.v),
name: k,
type: 'scatter', mode: 'lines',
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
});
});
}
if (usblResp.ok) {
const d = await usblResp.json();
const pts = (d.points||[]);
if (pts.length) {
// Build usblPoints — API fields: t(unix s), lat/lon(AUV), usv_lat/usv_lon, distance_m, azimuth_deg, snr
usblPoints.push(...pts.map(p => ({
t_ms: unixToMs(p.t),
auv_lat: p.lat, auv_lon: p.lon,
dist: p.distance_m, az: p.azimuth_deg, elev: p.elevation_deg || 0, snr: p.snr,
})));
// USBL distance trace
usblDistTraces.push({
x: pts.map(p => new Date(unixToMs(p.t))),
y: pts.map(p => p.distance_m),
name: sessionId,
type: 'scatter', mode: 'lines',
line: { color: '#a855f7', width: 1.5 },
});
}
}
} catch(e) { console.warn('loadSubSession error', sessionId, e); }
}
function setStatus(msg) {
document.getElementById('load-status').textContent = msg;
}
// == Datebar ==
let availableDates = [];
async function initDatebar() {
const label = document.getElementById('mission-label');
const picker = document.getElementById('date-picker');
try {
const resp = await fetch(`${API}/api/data-dates`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
availableDates = data.dates || [];
if (!availableDates.length) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
const dates = availableDates.map(d => d.date).sort();
picker.min = dates[0];
picker.max = dates[dates.length-1];
picker.value = dates[dates.length-1];
updateMissionLabel(picker.value);
picker.addEventListener('change', () => {
updateMissionLabel(picker.value);
loadDate(picker.value);
});
loadDate(picker.value);
} catch(e) {
label.textContent = 'API indisponible';
label.className = 'no-data';
setStatus('API 8766 inaccessible');
console.warn(e);
}
}
function updateMissionLabel(date) {
const label = document.getElementById('mission-label');
const entry = availableDates.find(d => d.date === date);
if (!entry) {
label.textContent = 'Aucune donnée';
label.className = 'no-data';
return;
}
const mission = entry.missions && entry.missions.length ? entry.missions[0] : '?';
const n = entry.session_count || 0;
label.textContent = `${mission} (${n} session${n>1?'s':''})`;
label.className = 'has-data';
}
function datePickerToday() {
const today = new Date().toISOString().slice(0,10);
const picker = document.getElementById('date-picker');
picker.value = today;
updateMissionLabel(today);
loadDate(today);
}
// == Pipeline overlay ==
let _pipelineRendered = false;
function togglePipeline() {
const ov = document.getElementById('pipeline-overlay');
ov.classList.toggle('visible');
if (ov.classList.contains('visible') && !_pipelineRendered) {
mermaid.run({ nodes: document.querySelectorAll('#pipeline-overlay .mermaid') });
_pipelineRendered = true;
}
}
function hidePipelineOnBackdrop(e) {
if (e.target === document.getElementById('pipeline-overlay')) togglePipeline();
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
// == Task 8: USV rendering helpers ==
function _pts(sig) {
if (!sig || !sig.length) return [[], []];
return [sig.map(p => new Date(p.t * 1000)), sig.map(p => p.v)];
}
const PLOTLY_LAYOUT_BASE = {
margin: {l:40, r:8, t:20, b:30},
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
font: {color: '#e0e0e0', size: 9, family: 'monospace'},
xaxis: {color: '#555', gridcolor: '#1e1e3a', type: 'date'},
yaxis: {color: '#555', gridcolor: '#1e1e3a'},
showlegend: false,
};
function _layout(title, yLabel) {
const l = JSON.parse(JSON.stringify(PLOTLY_LAYOUT_BASE));
l.title = {text: title, font: {size: 9, color: '#888'}};
if (yLabel) l.yaxis.title = {text: yLabel, font: {size: 8}};
return l;
}
function renderUSV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const [yt, yv] = _pts(signals.Yaw);
Plotly.react('usv-yaw', [{x:yt, y:yv, type:'scatter', mode:'lines', line:{color:'#00b4d8',width:1}, name:'Yaw'}], _layout('Yaw','°'), cfg);
const [ht, hv] = _pts(signals.Heading);
Plotly.react('usv-heading', [{x:ht, y:hv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1}}], _layout('Heading','°'), cfg);
const [bt, bv] = _pts(signals.BattVoltage);
Plotly.react('usv-batt', [{x:bt, y:bv, type:'scatter', mode:'lines', line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [gt, gv] = _pts(signals.gps_fix);
Plotly.react('usv-gps', [{x:gt, y:gv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('GPS fix'), cfg);
const [dt, dv] = _pts(signals.usbl_dist);
Plotly.react('usv-usbl-dist', [{x:dt, y:dv, type:'scatter', mode:'lines', line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [at, av] = _pts(signals.usbl_angle);
Plotly.react('usv-usbl-angle', [{x:at, y:av, type:'scatter', mode:'lines', line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const [m1t, m1v] = _pts(signals.M1);
Plotly.react('usv-m1', [{x:m1t, y:m1v, type:'scatter', mode:'lines', line:{color:'#ef476f',width:1}}], _layout('Motor 1','cmd'), cfg);
const [m2t, m2v] = _pts(signals.M2);
Plotly.react('usv-m2', [{x:m2t, y:m2v, type:'scatter', mode:'lines', line:{color:'#ff6b6b',width:1}}], _layout('Motor 2','cmd'), cfg);
const armPts = _pts(signals.Armed);
const modePts = _pts(signals.Mode);
const statusTraces = [];
if (armPts[0].length) statusTraces.push({x:armPts[0], y:armPts[1], name:'Armed', type:'scatter', mode:'lines', line:{color:'#ffd166',width:1,shape:'hv'}});
if (modePts[0].length) statusTraces.push({x:modePts[0], y:modePts[1], name:'Mode', type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}});
Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg);
}
// == Task 9: AUV rendering + tabs ==
let _currentSortieId = null;
async function loadAuvTabs(sortieId) {
const resp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auvs`);
const auvs = await resp.json();
const tabsEl = document.getElementById('auv-tabs');
tabsEl.innerHTML = '';
auvs.forEach((auv, i) => {
const btn = document.createElement('button');
btn.className = 'auv-tab' + (i === 0 ? ' active' : '');
btn.textContent = auv;
btn.onclick = async () => {
document.querySelectorAll('.auv-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auv}`);
const data = await r.json();
renderAUV(data.signals);
};
tabsEl.appendChild(btn);
});
if (auvs.length > 0) {
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auvs[0]}`);
const data = await r.json();
renderAUV(data.signals);
}
}
function renderAUV(signals) {
const cfg = {responsive: true, displayModeBar: false};
const pryTraces = [
['pitch', '#ef476f'], ['roll', '#06d6a0'], ['yaw', '#00b4d8']
].map(([k, c]) => { const [t,v]=_pts(signals[k]); return {x:t,y:v,name:k,type:'scatter',mode:'lines',line:{color:c,width:1}}; });
Plotly.react('auv-pry', pryTraces, Object.assign(_layout('Pitch/Roll/Yaw','°'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',x:0,y:1}}), cfg);
const [dpt, dpv] = _pts(signals.depth);
Plotly.react('auv-depth', [{x:dpt,y:dpv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('Depth','m'), cfg);
const [alt, alv] = _pts(signals.altitude);
Plotly.react('auv-alt', [{x:alt,y:alv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Altitude','m'), cfg);
const [obt, obv] = _pts(signals.obstacle_dist);
Plotly.react('auv-obs', [{x:obt,y:obv,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('Obstacle','m'), cfg);
const [udt, udv] = _pts(signals.usbl_dist);
Plotly.react('auv-usbl-dist', [{x:udt,y:udv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
const [uat, uav] = _pts(signals.usbl_angle);
Plotly.react('auv-usbl-angle', [{x:uat,y:uav,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
const [bbt, bbv] = _pts(signals.battery_v);
Plotly.react('auv-batt', [{x:bbt,y:bbv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
const [stt, stv] = _pts(signals.arm_status);
Plotly.react('auv-status', [{x:stt,y:stv,type:'scatter',mode:'lines',line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('Arm/Mode'), cfg);
const motorColors = ['#ef476f','#ffd166','#06d6a0','#00b4d8','#a0c4ff','#c77dff'];
const motorTraces = ['m1','m2','m3','m4','m5','m6'].map((mk,i) => {
const [t,v] = _pts(signals[mk]);
return {x:t,y:v,type:'scatter',mode:'lines',name:`M${i+1}`,line:{color:motorColors[i],width:1}};
});
Plotly.react('auv-motors', motorTraces,
Object.assign(_layout('Motors x6 PWM','µs'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',orientation:'h',x:0,y:1}}), cfg);
}
// == Task 10: Slider cursor sync ==
const ALL_GRAPH_IDS = [
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps',
'usv-usbl-dist', 'usv-usbl-angle', 'usv-m1', 'usv-m2', 'usv-status',
'auv-pry', 'auv-depth', 'auv-alt', 'auv-obs',
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
];
function updateCursor(epochSec) {
const ts = new Date(epochSec * 1000).toISOString();
const shape = {
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
};
ALL_GRAPH_IDS.forEach(id => {
const el = document.getElementById(id);
if (el && el._fullLayout) {
Plotly.relayout(id, {'shapes': [shape]});
}
});
}
// == Task 11: loadSortieData + sorties loading + wiring ==
async function loadSortieData(sortieId) {
const prog = document.getElementById('sync-progress');
try {
prog.textContent = 'Chargement USV…';
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`);
if (usvResp.ok) {
const usvData = await usvResp.json();
renderUSV(usvData.signals);
}
prog.textContent = 'Chargement AUV…';
await loadAuvTabs(sortieId);
prog.textContent = `${sortieId} chargé`;
} catch(e) {
prog.textContent = `Erreur: ${e.message}`;
}
}
async function loadSorties() {
try {
const resp = await fetch(`${API2}/sorties`);
if (!resp.ok) return;
const sorties = await resp.json();
const sel = document.getElementById('sortie-select');
sorties.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.id + (s.processed ? ' ✓' : '');
sel.appendChild(opt);
});
sel.addEventListener('change', () => {
const btn = document.getElementById('btn-sync');
btn.disabled = !sel.value;
if (sel.value) {
const opt = sel.options[sel.selectedIndex];
if (opt.textContent.includes('✓')) {
loadSortieData(sel.value);
}
}
});
} catch(e) { console.warn('pipeline-runner unavailable', e); }
}
document.getElementById('btn-sync').addEventListener('click', async () => {
const sortieId = document.getElementById('sortie-select').value;
if (!sortieId) return;
const btn = document.getElementById('btn-sync');
const prog = document.getElementById('sync-progress');
btn.disabled = true;
prog.textContent = 'Démarrage…';
const encoded = encodeURIComponent(sortieId);
await fetch(`${API2}/run/${encoded}`, {method: 'POST'});
const es = new EventSource(`${API2}/events/${encoded}`);
es.onmessage = async (e) => {
const evt = JSON.parse(e.data);
if (evt.step === 'ping') return;
prog.textContent = `[${evt.step}] ${evt.pct}% ${evt.msg}`;
if (evt.step === 'write' && evt.pct === 100) {
es.close();
btn.disabled = false;
prog.textContent = 'Terminé — chargement…';
await loadSortieData(sortieId);
}
if (evt.step === 'error') {
es.close();
btn.disabled = false;
prog.textContent = `Erreur: ${evt.msg}`;
}
};
});
loadSorties();
// == Init ==
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
initCharts();
initDatebar();
</script>
</body>
</html>