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');