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);
|
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`,
|
||||||
|
{ signal: AbortSignal.timeout(20000) }),
|
||||||
]);
|
]);
|
||||||
if (trackResp.ok) {
|
if (trackResult.status === 'fulfilled' && trackResult.value.ok) {
|
||||||
const d = await trackResp.json();
|
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); }
|
||||||
|
} else {
|
||||||
|
console.warn('loadShipSession track timeout/fail', sessionId,
|
||||||
|
trackResult.status === 'rejected' ? trackResult.reason.name : trackResult.value?.status);
|
||||||
}
|
}
|
||||||
if (seriesResp.ok) {
|
if (seriesResult.status === 'fulfilled' && seriesResult.value.ok) {
|
||||||
const d = await seriesResp.json();
|
try {
|
||||||
// USV PWM: M1..M8
|
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); }
|
||||||
|
} 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) {
|
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`,
|
||||||
|
{ signal: AbortSignal.timeout(25000) }),
|
||||||
]);
|
]);
|
||||||
if (seriesResp.ok) {
|
if (usblResult.status === 'fulfilled' && usblResult.value.ok) {
|
||||||
const d = await seriesResp.json();
|
try {
|
||||||
// Depth trace
|
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); }
|
||||||
|
} 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) {
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user