@@ -12,8 +12,9 @@
height : 100 vh ; overflow : hidden ;
font-family : monospace ; background : #1a1a2e ; color : #e0e0e0 ;
display : grid ;
grid-template-rows : 36 px 40 px 1 fr 54 px 1 fr ;
grid-template-rows : 36 px 40 px minmax ( 200 px , 1 fr ) 54 px 28 px 1 fr ;
}
. hidden { display : none !important ; }
/* Row 0: datebar */
# datebar {
background : #0d0d20 ; border-bottom : 1 px solid #0f3460 ;
@@ -45,7 +46,7 @@
}
# title { font-size : 13 px ; font-weight : bold ; color : #e94560 ; white-space : nowrap ; }
# stats { font-size : 11 px ; color : #a0c4ff ; flex : 1 ; min-width : 0 ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
# layer-toggles { display : flex ; gap : 6 px ; align-items : center ; flex-shrink : 0 ; }
# layer-toggles { display : none ; }
. layer-btn {
font-family : monospace ; font-size : 10 px ; padding : 2 px 7 px ; cursor : pointer ;
border-radius : 2 px ; border : 1 px solid ; background : transparent ;
@@ -60,6 +61,20 @@
/* Row 2: map */
# map { position : relative ; min-height : 0 ; }
/* No-data overlay */
# no-data-overlay {
position : absolute ; inset : 0 ; z-index : 2000 ;
background : rgba ( 10 , 10 , 26 , 0.82 ) ;
display : flex ; align-items : center ; justify-content : center ;
pointer-events : none ;
}
# no-data-overlay . hidden { display : none ; }
# no-data-overlay . nodata-msg {
font-size : 18 px ; color : #555 ; font-family : monospace ; text-align : center ;
border : 1 px solid #1a1a3e ; padding : 20 px 32 px ; background : #0a0a1a ;
pointer-events : none ;
}
/* USBL panel over map */
# usbl-panel {
position : absolute ; top : 10 px ; left : 50 px ; z-index : 1000 ;
@@ -73,6 +88,11 @@
display : inline-block ; background : #ff8800 ; color : #1a1a2e ;
font-size : 9 px ; font-weight : bold ; padding : 1 px 4 px ; border-radius : 2 px ; margin-top : 4 px ;
}
/* layer toggles bottom-right on map */
# map-layer-toggles {
position : absolute ; bottom : 10 px ; right : 10 px ; z-index : 1000 ;
display : flex ; flex-direction : column ; gap : 4 px ; align-items : flex-end ;
}
/* legend over map */
# legend {
position : absolute ; bottom : 10 px ; left : 10 px ; z-index : 1000 ;
@@ -115,15 +135,23 @@
}
# btn-viewall : hover , # btn-play : hover { background : #00b4d8 ; color : #1a1a2e ; }
/* Row 4: 2× 2 charts grid */
/* Row 4: scroll container with 2x2 charts + USV/AUV panels */
# graphs-section {
display : flex ;
flex-direction : column ;
min-height : 0 ;
overflow-y : auto ;
overflow-x : hidden ;
background : #0a0a1a ;
}
# charts-4grid {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
grid-template-rows : 1 fr 1 fr ;
gap : 3 px ;
background : #0a0a1a ;
padding : 3 px ;
min-height : 0 ; overflow : hidden ;
flex : 1 ;
min-height : 180 px ;
}
. chart-wrap {
background : #12122a ;
@@ -138,6 +166,21 @@
. chart-wrap . plotly-wrap { flex : 1 ; min-height : 0 ; position : relative ; }
. chart-wrap . plotly-wrap > div { width : 100 % !important ; height : 100 % !important ; }
/* Tab navigation */
# panels-tabs {
background : #0d0d20 ; border-top : 1 px solid #0f3460 ; border-bottom : 1 px solid #0f3460 ;
display : flex ; gap : 0 ; flex-shrink : 0 ;
}
. panel-tab {
font-family : monospace ; font-size : 11 px ; padding : 6 px 16 px ; cursor : pointer ;
border : none ; border-right : 1 px solid #0f3460 ; background : transparent ;
color : #666 ;
}
. panel-tab . active { background : #16213e ; color : #e0e0e0 ; }
. panel-tab : hover : not ( . active ) { background : #0f3460 ; color : #a0c4ff ; }
. panel-section { display : none ; }
. panel-section . active { display : block ; }
/* Pipeline overlay */
# pipeline-overlay {
display : none ;
@@ -210,8 +253,8 @@
< 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 >
< span id = "mission-label" class = "no-data" > — < / span >
< span id = "load-status" style = "font-size:10px;color:#888;font-style:italic;flex:1;" > < / span >
< button id = "btn-pipeline" onclick = "togglePipeline()" > Pipeline< / button >
< / div >
@@ -264,20 +307,24 @@ flowchart LR
<!-- 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 < / butto n>
< 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 >
< span id = "stats" style = "flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#a0c4ff;" > < / span >
< select id = "sortie-select" > < option value = "" > Chargement Gdrive (~30s)...< / option > < / select >
< span id = "mission-label-header" style = "font-size:11px;font-family:monospace;padding:2px 8px;color:#555;white-space:nowrap; "> < / spa n>
< button id = "btn-sync" disabled > Sync & Process< / button >
< span id = "sync-progress" > < / span >
< / div >
<!-- Row 2: Map -->
< div id = "map" >
< div id = "no-data-overlay" >
< div class = "nodata-msg" > ⬇ Sélectionnez une sortie pour charger les données< / div >
< / div >
< div id = "map-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 id = "legend" > < / div >
< div id = "usbl-panel" >
< div class = "uprow" > < span class = "uplabel" > Dist< / span > < span class = "upval" id = "up-dist" > —< / span > < / div >
@@ -291,10 +338,10 @@ flowchart LR
<!-- Row 3: Controls -->
< div id = "controls" >
< div id = "ctrl-row1" >
< span id = "ctrl-label" > t< / span >
< span id = "ctrl-label" style = "font-size:10px;color:#666;white-space:nowrap;" > Heure: < / span >
< div id = "cursor-slider-wrap" > < div id = "cursor-slider" > < / div > < / div >
< span id = "cursor-time" > —< / span >
< label for = "trail-select" > t rail< / label >
< span id = "cursor-time" style = "font-size:11px;color:#e0e0e0;white-space:nowrap;min-width:70px;" > —< / span >
< label for = "trail-select" style = "font-size:10px;color:#666;white-space:nowrap;" > T rail: < / label >
< select id = "trail-select" >
< option value = "10000" > 10s< / option >
< option value = "30000" > 30s< / option >
@@ -306,14 +353,31 @@ flowchart LR
< / select >
< button id = "btn-viewall" onclick = "viewAll()" > View all< / button >
< button id = "btn-play" > ▶< / button >
< label for = "window-select" style = "font-size:10px;color:#666;white-space:nowrap;" > Window:< / label >
< select id = "window-select" style = "background:#0f3460;border:1px solid #a855f7;color:#a855f7;font-family:monospace;font-size:11px;padding:2px 6px;border-radius:2px;cursor:pointer;" >
< 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 = "0" > ALL< / option >
< / select >
< / 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 chart s -->
<!-- Row 4: tabs + panel s -->
< div id = "panels-tabs" >
< button class = "panel-tab active" onclick = "switchTab('charts')" > 🗺 Charts globaux< / button >
< button class = "panel-tab" onclick = "switchTab('usv')" > ⛵ USV< / button >
< button class = "panel-tab" onclick = "switchTab('auv')" > 🛥 AUV < span id = "auv-tabs" > < / span > < / button >
< / div >
< div id = "graphs-section" >
<!-- Tab: Charts globaux -->
< div id = "panel-charts" class = "panel-section active" >
< div id = "charts-4grid" >
< div class = "chart-wrap" >
< div class = "chart-title" > Depth AUV (m)< / div >
< div class = "plotly-wrap" > < div id = "chart-depth" > < / div > < / div >
@@ -330,25 +394,26 @@ flowchart LR
< 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 >
< / div >
<!-- Tab: USV -- >
< div id = "panel-usv" class = "panel-section hidden " >
< 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-motors " > < / 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 >
< / div >
<!-- Tab: AUV -->
< div id = "panel-auv" class = "panel-section hidden" >
< div class = "panel-header" id = "auv-panel-header" > 🛥 AUV < / 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 >
@@ -358,6 +423,8 @@ flowchart LR
< 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 >
< / div >
< / div >
< script src = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js" > < / script >
@@ -366,8 +433,12 @@ flowchart LR
< 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 API = ( location . hostname === 'laboratoire.freeboxos.fr ')
? '/cosma-pK8j876lkj-api'
: 'http://192.168.0.83:8766' ;
const API2 = ( location . hostname === 'laboratoire.freeboxos.fr' )
? '/cosma-pK8j876lkj-pipe'
: 'http://192.168.0.83:8767' ;
const COLORS = [ '#00b4d8' , '#06d6a0' , '#ffd166' , '#e94560' , '#a855f7' , '#ff8800' , '#7dc8e0' , '#b8f0d4' ] ;
const AUV _COLOR = '#ff8800' ;
const PLOTLY _LAYOUT = {
@@ -503,6 +574,14 @@ function populatePlotlyCharts() {
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 ) ;
// Force resize: Plotly can't compute size on hidden/zero-size divs at init time
requestAnimationFrame ( ( ) => {
[ 'chart-depth' , 'chart-pwm-auv' , 'chart-pwm-usv' , 'chart-usbl' ] . forEach ( id => {
const el = document . getElementById ( id ) ;
if ( el && el . _fullLayout ) Plotly . Plots . resize ( el ) ;
} ) ;
setupSyncedZoom ( [ 'chart-depth' , 'chart-pwm-auv' , 'chart-pwm-usv' , 'chart-usbl' ] ) ;
} ) ;
}
// Update cursor line on all Plotly charts
@@ -606,13 +685,16 @@ function initCursorSlider() {
if ( cursorSlider ) { cursorSlider . destroy ( ) ; cursorSlider = null ; }
const el = document . getElementById ( 'cursor-slider' ) ;
cursorSlider = noUiSlider . create ( el , {
start : [ tMin ] ,
start : [ tMax ] , // BUG1 FIX: start at end so trail window shows recent data
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 ) ;
// Show HH:MM:SS for slider label
const d = new Date ( tNow ) ;
const hms = d . toISOString ( ) . slice ( 11 , 19 ) ;
document . getElementById ( 'cursor-time' ) . textContent = ` Heure: ${ hms } ` ;
applyTrailAndCursor ( ) ;
} ) ;
document . getElementById ( 'trail-select' ) . addEventListener ( 'change' , ( ) => applyTrailAndCursor ( ) ) ;
@@ -653,7 +735,8 @@ function clearMapLayers() {
// == Load data for a given date ==
async function loadDate ( date ) {
setStatus ( 'C hargement...' ) ;
setStatus ( 'c hargement...' ) ;
showNoDataOverlay ( true ) ;
clearMapLayers ( ) ;
allPoints = [ ] ;
usblPoints = [ ] ;
@@ -669,7 +752,18 @@ async function loadDate(date) {
const dateStr = date . replace ( /-/g , '' ) ; // YYYYMMDD
const fetches = missions . map ( async mission => {
// BUG3 FIX: filter missions using availableDates to avoid fetching all missions' dives
const dateEntry = availableDates . find ( d => d . date === date ) ;
const knownMissions = dateEntry && dateEntry . missions && dateEntry . missions . length
? dateEntry . missions
: null ;
// BUG3b FIX: API data-dates uses #71-golrest but /api/missions uses _71-golrest — normalize for compare
const normalize = id => ( id || '' ) . replace ( /^[#_]/ , '' ) . toLowerCase ( ) ;
const missionsToFetch = knownMissions
? missions . filter ( m => knownMissions . some ( km => normalize ( m . id ) === normalize ( km ) ) )
: missions ;
const fetches = ( missionsToFetch . length > 0 ? missionsToFetch : 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 ) ) } ;
@@ -689,7 +783,7 @@ async function loadDate(date) {
for ( const { mission , dives } of missionDives ) {
for ( const dive of dives ) {
allFetches . push ( loadDiveData ( mission . id , dive . id ) ) ;
allFetches . push ( loadDiveData ( mission . id , dive . id , date )) ;
totalShip += dive . ship _session _count || 0 ;
totalSub += dive . sub _session _count || 0 ;
}
@@ -711,7 +805,7 @@ async function loadDate(date) {
] ;
tMin = Math . min ( ... allTimes ) ;
tMax = Math . max ( ... allTimes ) ;
tNow = tMin ;
tNow = tMax ; // BUG1 FIX: start at end so trail window [tMax-trailMs, tMax] contains data
// Fit map
const allLats = [
@@ -743,6 +837,7 @@ async function loadDate(date) {
populatePlotlyCharts ( ) ;
initCursorSlider ( ) ;
applyTrailAndCursor ( ) ;
showNoDataOverlay ( false ) ;
document . getElementById ( 'title' ) . textContent = ` COSMA v6 — ${ date } ` ;
setStatus ( ` ${ totalShip } USV sess. | ${ totalSub } AUV sess. | ${ allPoints . length } pts USV | ${ usblPoints . length } pts USBL ` ) ;
@@ -753,45 +848,60 @@ async function loadDate(date) {
}
}
async function loadDiveData ( missionId , diveId ) {
async function loadDiveData ( missionId , diveId , filterDate ) {
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 ) ) ;
// T3 FIX: filter ship sessions by date if provided (session.start = "YYYY-MM-DD_HH-MM-SS")
const shipSessions = ( sessions . ship || [ ] ) . filter ( sess => {
if ( ! filterDate ) return true ;
// sess.start: "2026-04-17_07-43-23" → compare first 10 chars "2026-04-17" to filterDate
return ! sess . start || sess . start . slice ( 0 , 10 ) === filterDate ;
} ) ;
}
// Sub sessions
if ( sessions . sub ) {
sessions . sub . forEach ( sess => {
sessionFetches . push ( loadSubSession ( missionId , diveId , sess . id ) ) ;
} ) ;
}
await Promise . all ( sessionFetches ) ;
// Phase 1: track-only fetches (needed for map polylines) — batch by 4
const trackFetches = shipSessions . map ( sess =>
fetchShipTrack ( missionId , diveId , sess . id )
) ;
await _batchedAll ( trackFetches , 4 ) ;
// Phase 2: series + sub sessions in background (charts data)
const seriesFetches = [
... shipSessions . map ( sess => fetchShipSeries ( missionId , diveId , sess . id ) ) ,
... ( sessions . sub || [ ] ) . map ( sess => loadSubSession ( missionId , diveId , sess . id ) ) ,
] ;
await _batchedAll ( seriesFetches , 4 ) ;
} catch ( e ) { console . warn ( 'loadDiveData error' , diveId , e ) ; }
}
async function loadShipSession ( missionId , diveId , sessionId ) {
// Run an array of promise-factories in batches of size n
async function _batchedAll ( fns , n ) {
for ( let i = 0 ; i < fns . length ; i += n ) {
await Promise . all ( fns . slice ( i , i + n ) ) ;
}
}
async function fetchShipTrack ( 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 resp = await fetch ( ` ${ API } /api/ship/ ${ missionId } / ${ diveId } / ${ sessionId } /track ` ,
{ signal : AbortSignal . timeout ( 20000 ) } ) ;
if ( ! resp . ok ) { console . warn ( 'fetchShipTrack fail' , sessionId , resp . status ) ; return ; }
const d = await resp . 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
} catch ( e ) { console . warn ( 'fetchShipTrack error' , sessionId , e . name ) ; }
}
async function fetchShipSeries ( missionId , diveId , sessionId ) {
try {
const resp = await fetch ( ` ${ API } /api/ship/ ${ missionId } / ${ diveId } / ${ sessionId } /series ` ,
{ signal : AbortSignal . timeout ( 20000 ) } ) ;
if ( ! resp . ok ) return ;
const d = await resp . json ( ) ;
const motorKeys = Object . keys ( d ) . filter ( k => / ^ M \ d + $ / . test ( k ) ) ;
motorKeys . forEach ( ( k , i ) => {
const pts = d [ k ] ;
@@ -799,24 +909,48 @@ async function loadShipSession(missionId, diveId, sessionId) {
pwmUsvTraces . push ( {
x : pts . map ( p => new Date ( isoToMs ( p . t ) ) ) ,
y : pts . map ( p => p . v ) ,
name : k ,
type : 'scatter' , mode : 'lines' ,
name : k , type : 'scatter' , mode : 'lines' ,
line : { color : COLORS [ i % COLORS . length ] , width : 1 } ,
} ) ;
} ) ;
}
} catch ( e ) { console . warn ( 'loadShipSession error' , sessionId , e ) ; }
} catch ( e ) { console . warn ( 'fetchShipSeries error' , sessionId , e . name ) ; }
}
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 ` ) ,
// Both usbl_track and series can hang — use parallel with timeout, non-blocking
const [ usblResult , seriesResult ] = await Promise . allSettled ( [
fetch ( ` ${ API } /api/sub/ ${ missionId } / ${ diveId } / ${ sessionId } /usbl_track ` ,
{ signal : AbortSignal . timeout ( 25000 ) } ) ,
fetch ( ` ${ API } /api/sub/ ${ missionId } / ${ diveId } / ${ sessionId } /series ` ,
{ signal : AbortSignal . timeout ( 25000 ) } ) ,
] ) ;
if ( seriesResp . ok ) {
const d = await seriesResp . json ( ) ;
// Depth trace
if ( usblResult . status === 'fulfilled' && usblResult . value . ok ) {
try {
const d = await usblResult . value . json ( ) ;
const pts = ( d . points || [ ] ) ;
if ( pts . length ) {
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 ,
} ) ) ) ;
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 usbl json error' , sessionId , e ) ; }
} else {
console . warn ( 'loadSubSession usbl timeout/fail' , sessionId ,
usblResult . status === 'rejected' ? usblResult . reason . name : usblResult . value ? . status ) ;
}
if ( seriesResult . status === 'fulfilled' && seriesResult . value . ok ) {
try {
const d = await seriesResult . value . json ( ) ;
if ( d . depth && d . depth . length ) {
depthTraces . push ( {
x : d . depth . map ( p => new Date ( unixToMs ( p . t ) ) ) ,
@@ -826,7 +960,6 @@ async function loadSubSession(missionId, diveId, sessionId) {
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 ] ;
@@ -839,28 +972,11 @@ async function loadSubSession(missionId, diveId, sessionId) {
line : { color : COLORS [ ( i + 4 ) % COLORS . length ] , width : 1 } ,
} ) ;
} ) ;
} catch ( e ) { console . warn ( 'loadSubSession series json error' , sessionId , e ) ; }
} else {
console . warn ( 'loadSubSession series timeout/fail' , sessionId ,
seriesResult . status === 'rejected' ? seriesResult . reason . name : seriesResult . value ? . status ) ;
}
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 ) {
@@ -923,6 +1039,37 @@ function datePickerToday() {
loadDate ( today ) ;
}
// == Tab switching ==
function switchTab ( name ) {
[ 'charts' , 'usv' , 'auv' ] . forEach ( t => {
const p = document . getElementById ( 'panel-' + t ) ;
if ( p ) p . classList . toggle ( 'active' , t === name ) ;
if ( p ) p . classList . toggle ( 'hidden' , t !== name ) ;
} ) ;
document . querySelectorAll ( '.panel-tab' ) . forEach ( ( btn , i ) => {
const names = [ 'charts' , 'usv' , 'auv' ] ;
btn . classList . toggle ( 'active' , names [ i ] === name ) ;
} ) ;
// BUG1 FIX: resize Plotly charts when tab becomes visible
if ( name === 'charts' ) {
requestAnimationFrame ( ( ) => {
[ 'chart-depth' , 'chart-pwm-auv' , 'chart-pwm-usv' , 'chart-usbl' ] . forEach ( id => {
const el = document . getElementById ( id ) ;
if ( el && el . _fullLayout ) Plotly . Plots . resize ( el ) ;
} ) ;
} ) ;
}
}
// == No-data overlay ==
function showNoDataOverlay ( show ) {
const el = document . getElementById ( 'no-data-overlay' ) ;
if ( el ) el . classList . toggle ( 'hidden' , ! show ) ;
// Show/hide layer toggles
const lt = document . getElementById ( 'map-layer-toggles' ) ;
if ( lt ) lt . style . display = show ? 'none' : 'flex' ;
}
// == Pipeline overlay ==
let _pipelineRendered = false ;
function togglePipeline ( ) {
@@ -981,11 +1128,13 @@ function renderUSV(signals) {
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 motorColorsUSV = [ '#ef476f' , '#ff8800' ] ;
const motorTracesUSV = [ 'M1' , 'M2' ] . map ( ( mk , i ) => {
const [ t , v ] = _pts ( signals [ mk ] ) ;
return { x : t , y : v , type : 'scatter' , mode : 'lines' , name : mk , line : { color : motorColorsUSV [ i ] , width : 1 } } ;
} ) ;
Plotly . react ( 'usv-motors' , motorTracesUSV ,
Object . assign ( _layout ( 'Motors USV' , 'cmd' ) , { showlegend : true , legend : { font : { size : 8 } , bgcolor : 'transparent' , orientation : 'h' , x : 0 , y : 1 } } ) , cfg ) ;
const armPts = _pts ( signals . Armed ) ;
const modePts = _pts ( signals . Mode ) ;
@@ -994,6 +1143,9 @@ function renderUSV(signals) {
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 ) ;
requestAnimationFrame ( ( ) => {
setupSyncedZoom ( [ 'usv-yaw' , 'usv-heading' , 'usv-batt' , 'usv-gps' , 'usv-usbl-dist' , 'usv-usbl-angle' , 'usv-motors' , 'usv-status' ] ) ;
} ) ;
}
// == Task 9: AUV rendering + tabs ==
@@ -1060,64 +1212,135 @@ function renderAUV(signals) {
} ) ;
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 ) ;
requestAnimationFrame ( ( ) => {
setupSyncedZoom ( [ 'auv-pry' , 'auv-depth' , 'auv-alt' , 'auv-obs' , 'auv-usbl-dist' , 'auv-usbl-angle' , 'auv-batt' , 'auv-status' , 'auv-motors' ] ) ;
} ) ;
}
// == 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' ,
'usv-usbl-dist' , 'usv-usbl-angle' , 'usv-motors ' , '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 tMs = epochSec * 1000 ;
const winMs = + document . getElementById ( 'window-select' ) . value ;
const t0 = winMs === 0 ? null : new Date ( tMs - winMs ) . toISOString ( ) ;
const t1 = new Date ( tMs ) . toISOString ( ) ;
const shape = {
type : 'line' , x0 : ts , x1 : ts , y0 : 0 , y1 : 1 ,
yref : 'paper' , line : { color : '#e94560' , width : 1 , dash : 'dot' } ,
} ;
const rangeUpdate = t0 ? { 'xaxis.range' : [ t0 , t1 ] } : { 'xaxis.autorange' : true } ;
ALL _GRAPH _IDS . forEach ( id => {
const el = document . getElementById ( id ) ;
if ( el && el . _fullLayout ) {
Plotly . relayout ( id , { 'shapes' : [ shape ] } ) ;
Plotly . relayout ( id , { ... rangeUpdate , 'shapes' : [ shape ] } ) ;
}
} ) ;
}
document . getElementById ( 'window-select' ) . addEventListener ( 'change' , ( ) => {
if ( tNow ) updateCursor ( tNow / 1000 ) ;
} ) ;
// == Feature: Synced zoom across all Plotly charts ==
let _syncing = false ;
function setupSyncedZoom ( chartIds ) {
chartIds . forEach ( id => {
const div = document . getElementById ( id ) ;
if ( ! div ) return ;
div . on ( 'plotly_relayout' , ( ev ) => {
if ( _syncing ) return ;
const x0 = ev [ 'xaxis.range[0]' ] || ( ev [ 'xaxis.range' ] && ev [ 'xaxis.range' ] [ 0 ] ) ;
const x1 = ev [ 'xaxis.range[1]' ] || ( ev [ 'xaxis.range' ] && ev [ 'xaxis.range' ] [ 1 ] ) ;
const reset = ev [ 'xaxis.autorange' ] ;
if ( x0 == null && x1 == null && ! reset ) return ;
_syncing = true ;
chartIds . forEach ( otherId => {
if ( otherId === id ) return ;
const otherDiv = document . getElementById ( otherId ) ;
if ( ! otherDiv || ! otherDiv . _fullLayout ) return ;
if ( reset ) {
Plotly . relayout ( otherDiv , { 'xaxis.autorange' : true } ) ;
} else {
Plotly . relayout ( otherDiv , { 'xaxis.range' : [ x0 , x1 ] } ) ;
}
} ) ;
setTimeout ( ( ) => { _syncing = false ; } , 50 ) ;
} ) ;
} ) ;
}
// == 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 ` ) ;
// Reset state for sortie mode (independent from datebar)
clearMapLayers ( ) ;
allPoints = [ ] ;
usblPoints = [ ] ;
// Phase 1: USV GPS track → polylines on map immediately (<3s target)
prog . textContent = 'Chargement track USV…' ;
const trackResp = await fetch ( ` ${ API2 } /sorties/ ${ encodeURIComponent ( sortieId ) } /usv_track ` ,
{ signal : AbortSignal . timeout ( 15000 ) } ) ;
if ( trackResp . ok ) {
const trackPts = await trackResp . json ( ) ;
allPoints . push ( ... trackPts ) ;
allPoints . sort ( ( a , b ) => a . t _ms - b . t _ms ) ;
if ( allPoints . length > 0 ) {
const times = allPoints . map ( p => p . t _ms ) ;
tMin = Math . min ( ... times ) ;
tMax = Math . max ( ... times ) ;
tNow = tMax ;
// Fit map bounds
const lats = allPoints . map ( p => p . lat ) . filter ( Boolean ) ;
const lons = allPoints . map ( p => p . lon ) . filter ( Boolean ) ;
if ( lats . length ) {
map . fitBounds ( [
[ Math . min ( ... lats ) , Math . min ( ... lons ) ] ,
[ Math . max ( ... lats ) , Math . max ( ... lons ) ] ,
] , { padding : [ 40 , 40 ] } ) ;
}
showNoDataOverlay ( false ) ;
applyTrailAndCursor ( ) ; // PERF FIX: polylines visible NOW, before charts
prog . textContent = ` Track USV ${ allPoints . length } pts — chargement charts… ` ;
}
}
// Phase 2: series + AUV (charts populate progressively)
const usvResp = await fetch ( ` ${ API2 } /sorties/ ${ encodeURIComponent ( sortieId ) } /usv ` ,
{ signal : AbortSignal . timeout ( 20000 ) } ) ;
if ( usvResp . ok ) {
const usvData = await usvResp . json ( ) ;
switchTab ( 'usv' ) ; // BUG1d FIX: switch BEFORE renderUSV so Plotly renders on visible divs
showNoDataOverlay ( false ) ; // BUG2 FIX: hide overlay when data loaded
renderUSV ( usvData . signals ) ;
}
prog . textContent = 'Chargement AUV…' ;
await loadAuvTabs ( sortieId ) ;
prog . textContent = ` ${ sortieId } chargé ` ;
// Re-apply after AUV usbl_track loaded (adds AUV trail if available)
if ( allPoints . length > 0 ) applyTrailAndCursor ( ) ;
} catch ( e ) {
prog . textContent = ` Erreur: ${ e . message } ` ;
}
}
async function load Sorties ( ) {
try {
const resp = await fetch ( ` ${ API2 } /sorties ` ) ;
if ( ! resp . ok ) return ;
const sorties = await resp . json ( ) ;
function _wire SortieSelect ( ) {
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 ) ;
} ) ;
// Evite double-binding si appelé plusieurs fois
if ( sel . _wired ) return ;
sel . _wired = true ;
sel . addEventListener ( 'change' , ( ) => {
const btn = document . getElementById ( 'btn-sync' ) ;
btn . disabled = ! sel . value ;
_updateMissionLabelHeader ( sel . value ) ;
if ( sel . value ) {
const opt = sel . options [ sel . selectedIndex ] ;
if ( opt . textContent . includes ( '✓' ) ) {
@@ -1125,7 +1348,52 @@ async function loadSorties() {
}
}
} ) ;
} catch ( e ) { console . warn ( 'pipeline-runner unavailable' , e ) ; }
}
function _populateSortieSelect ( sorties ) {
const sel = document . getElementById ( 'sortie-select' ) ;
// Vider sauf première option placeholder
while ( sel . options . length > 1 ) sel . remove ( 1 ) ;
if ( ! sorties || ! sorties . length ) {
sel . options [ 0 ] . textContent = '— Aucune sortie —' ;
return ;
}
sel . options [ 0 ] . textContent = ` ${ sorties . length } sorties dispo — sélectionnez ` ;
sorties . forEach ( s => {
const opt = document . createElement ( 'option' ) ;
opt . value = s . id ;
opt . textContent = s . id + ( s . processed ? ' ✓' : '' ) ;
sel . appendChild ( opt ) ;
} ) ;
_wireSortieSelect ( ) ;
}
function _updateMissionLabelHeader ( sortieId ) {
const el = document . getElementById ( 'mission-label-header' ) ;
if ( ! el ) return ;
el . textContent = sortieId ? ` Mission: ${ sortieId } ` : '' ;
el . style . color = sortieId ? '#06d6a0' : '#555' ;
}
function loadSorties ( ) {
const sel = document . getElementById ( 'sortie-select' ) ;
sel . options [ 0 ] . textContent = 'Sorties: chargement…' ;
const controller = new AbortController ( ) ;
const timeoutId = setTimeout ( ( ) => controller . abort ( ) , 5000 ) ;
fetch ( ` ${ API2 } /sorties ` , { signal : controller . signal } )
. then ( resp => {
clearTimeout ( timeoutId ) ;
if ( ! resp . ok ) throw new Error ( 'HTTP ' + resp . status ) ;
return resp . json ( ) ;
} )
. then ( sorties => { _populateSortieSelect ( sorties ) ; } )
. catch ( e => {
clearTimeout ( timeoutId ) ;
sel . options [ 0 ] . textContent = '— Pipeline indisponible —' ;
console . warn ( 'loadSorties:' , e . message ) ;
} ) ;
}
document . getElementById ( 'btn-sync' ) . addEventListener ( 'click' , async ( ) => {