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 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 06:15:26 +00:00
parent 63270beeff
commit f5debc8afc

View File

@@ -580,6 +580,7 @@ function populatePlotlyCharts() {
const el = document.getElementById(id); const el = document.getElementById(id);
if (el && el._fullLayout) Plotly.Plots.resize(el); 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; } if (cursorSlider) { cursorSlider.destroy(); cursorSlider = null; }
const el = document.getElementById('cursor-slider'); const el = document.getElementById('cursor-slider');
cursorSlider = noUiSlider.create(el, { 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 }, range: { min: tMin, max: tMax > tMin ? tMax : tMin + 1000 },
step: 1000, step: 1000,
}); });
@@ -804,7 +805,7 @@ async function loadDate(date) {
]; ];
tMin = Math.min(...allTimes); tMin = Math.min(...allTimes);
tMax = Math.max(...allTimes); tMax = Math.max(...allTimes);
tNow = tMin; tNow = tMax; // BUG1 FIX: start at end so trail window [tMax-trailMs, tMax] contains data
// Fit map // Fit map
const allLats = [ const allLats = [
@@ -870,22 +871,29 @@ async function loadDiveData(missionId, diveId) {
} }
async function loadShipSession(missionId, diveId, sessionId) { async function loadShipSession(missionId, diveId, sessionId) {
try { // Parallel fetch: track + series with 20s timeout each — neither blocks the other
const [trackResp, seriesResp] = await Promise.all([ const [trackResult, seriesResult] = await Promise.allSettled([
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`), fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/track`,
fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`), { signal: AbortSignal.timeout(20000) }),
]); fetch(`${API}/api/ship/${missionId}/${diveId}/${sessionId}/series`,
if (trackResp.ok) { { signal: AbortSignal.timeout(20000) }),
const d = await trackResp.json(); ]);
if (trackResult.status === 'fulfilled' && trackResult.value.ok) {
try {
const d = await trackResult.value.json();
const pts = (d.points||[]).map(p => ({ const pts = (d.points||[]).map(p => ({
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon, t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
heading: p.heading || null, source: sessionId heading: p.heading || null, source: sessionId
})); }));
allPoints.push(...pts); allPoints.push(...pts);
} } catch(e) { console.warn('loadShipSession track json error', sessionId, e); }
if (seriesResp.ok) { } else {
const d = await seriesResp.json(); console.warn('loadShipSession track timeout/fail', sessionId,
// USV PWM: M1..M8 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)); const motorKeys = Object.keys(d).filter(k => /^M\d+$/.test(k));
motorKeys.forEach((k, i) => { motorKeys.forEach((k, i) => {
const pts = d[k]; const pts = d[k];
@@ -898,19 +906,47 @@ async function loadShipSession(missionId, diveId, sessionId) {
line: { color: COLORS[i % COLORS.length], width: 1 }, line: { color: COLORS[i % COLORS.length], width: 1 },
}); });
}); });
} } catch(e) { console.warn('loadShipSession series json error', sessionId, e); }
} catch(e) { console.warn('loadShipSession 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) { async function loadSubSession(missionId, diveId, sessionId) {
try { // Both usbl_track and series can hang — use parallel with timeout, non-blocking
const [seriesResp, usblResp] = await Promise.all([ const [usblResult, seriesResult] = await Promise.allSettled([
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`), fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`,
fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/usbl_track`), { signal: AbortSignal.timeout(25000) }),
]); fetch(`${API}/api/sub/${missionId}/${diveId}/${sessionId}/series`,
if (seriesResp.ok) { { signal: AbortSignal.timeout(25000) }),
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) { if (d.depth && d.depth.length) {
depthTraces.push({ depthTraces.push({
x: d.depth.map(p => new Date(unixToMs(p.t))), 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 }, line: { color: '#06d6a0', width: 1.5 },
}); });
} }
// AUV motors: m1..m8
const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k)); const motorKeys = Object.keys(d).filter(k => /^m\d+$/.test(k));
motorKeys.forEach((k, i) => { motorKeys.forEach((k, i) => {
const pts = d[k]; const pts = d[k];
@@ -933,28 +968,11 @@ async function loadSubSession(missionId, diveId, sessionId) {
line: { color: COLORS[(i + 4) % COLORS.length], width: 1 }, line: { color: COLORS[(i + 4) % COLORS.length], width: 1 },
}); });
}); });
} } catch(e) { console.warn('loadSubSession series json error', sessionId, e); }
if (usblResp.ok) { } else {
const d = await usblResp.json(); console.warn('loadSubSession series timeout/fail', sessionId,
const pts = (d.points||[]); seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status);
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) { 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'}}); 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:[]}], Plotly.react('usv-status', statusTraces.length ? statusTraces : [{x:[],y:[]}],
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg); 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 == // == Task 9: AUV rendering + tabs ==
@@ -1187,6 +1208,9 @@ function renderAUV(signals) {
}); });
Plotly.react('auv-motors', motorTraces, 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); 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 == // == Task 10: Slider cursor sync ==
@@ -1220,6 +1244,34 @@ document.getElementById('window-select').addEventListener('change', () => {
if (tNow) updateCursor(tNow / 1000); 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 == // == Task 11: loadSortieData + sorties loading + wiring ==
async function loadSortieData(sortieId) { async function loadSortieData(sortieId) {
const prog = document.getElementById('sync-progress'); const prog = document.getElementById('sync-progress');