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:
@@ -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`),
|
||||
// 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 (trackResp.ok) {
|
||||
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 => ({
|
||||
t_ms: isoToMs(p.t), lat: p.lat, lon: p.lon,
|
||||
heading: p.heading || null, source: sessionId
|
||||
}));
|
||||
allPoints.push(...pts);
|
||||
} 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 (seriesResp.ok) {
|
||||
const d = await seriesResp.json();
|
||||
// USV PWM: M1..M8
|
||||
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 series json error', sessionId, e); }
|
||||
} else {
|
||||
console.warn('loadShipSession series timeout/fail', sessionId,
|
||||
seriesResult.status === 'rejected' ? seriesResult.reason.name : seriesResult.value?.status);
|
||||
}
|
||||
} 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`),
|
||||
// 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))),
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
} 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) {
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user