From f5debc8afcb66fc23c580e3a579f809923989005 Mon Sep 17 00:00:00 2001 From: Poulpe Date: Tue, 28 Apr 2026 06:15:26 +0000 Subject: [PATCH] fix(viewer): tNow=tMax so trail shows data on init + synced zoom across all Plotly charts Bug 1: cursor slider started at tMin (no data in 60s window at start of data). Fix: start slider at tMax so trail window covers the last trailMs of actual data. Feature 2 (Alexandre Larribau): setupSyncedZoom() propagates xaxis.range to all sibling charts on plotly_relayout, with _syncing guard vs infinite loop. Applied to global charts (depth/PWM/USBL), USV tab, and AUV tab independently. Co-Authored-By: Claude Sonnet 4.6 --- viewer/index.html | 144 +++++++++++++++++++++++++++++++--------------- 1 file changed, 98 insertions(+), 46 deletions(-) diff --git a/viewer/index.html b/viewer/index.html index 2242c3b..a40b905 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -580,6 +580,7 @@ function populatePlotlyCharts() { const el = document.getElementById(id); if (el && el._fullLayout) Plotly.Plots.resize(el); }); + setupSyncedZoom(['chart-depth','chart-pwm-auv','chart-pwm-usv','chart-usbl']); }); } @@ -684,7 +685,7 @@ 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, }); @@ -804,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 = [ @@ -870,22 +871,29 @@ async function loadDiveData(missionId, diveId) { } 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(); + // Parallel fetch: track + series with 20s timeout each — neither blocks the other + const [trackResult, seriesResult] = await Promise.allSettled([ + fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`, + { signal: AbortSignal.timeout(20000) }), + fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`, + { signal: AbortSignal.timeout(20000) }), + ]); + if (trackResult.status === 'fulfilled' && trackResult.value.ok) { + try { + const d = await trackResult.value.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('loadShipSession track json error', sessionId, e); } + } else { + console.warn('loadShipSession track timeout/fail', sessionId, + trackResult.status === 'rejected' ? trackResult.reason.name : trackResult.value?.status); + } + if (seriesResult.status === 'fulfilled' && seriesResult.value.ok) { + try { + const d = await seriesResult.value.json(); const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k)); motorKeys.forEach((k, i) => { const pts = d[k]; @@ -898,19 +906,47 @@ async function loadShipSession(missionId, diveId, sessionId) { line: { color: COLORS[i % COLORS.length], width: 1 }, }); }); - } - } catch(e) { console.warn('loadShipSession error', sessionId, e); } + } catch(e) { console.warn('loadShipSession series json error', sessionId, e); } + } else { + console.warn('loadShipSession series timeout/fail', sessionId, + seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status); + } } 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 + // 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 (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))), @@ -920,7 +956,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]; @@ -933,28 +968,11 @@ async function loadSubSession(missionId, diveId, sessionId) { 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); } + } 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); + } } function setStatus(msg) { @@ -1121,6 +1139,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 == @@ -1187,6 +1208,9 @@ 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 == @@ -1220,6 +1244,34 @@ 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');