feat: viewer — sortie selector + USV/AUV panels HTML + CSS
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,39 @@
|
|||||||
border-radius: 2px; flex-shrink: 0;
|
border-radius: 2px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
|
#btn-pipeline:hover { background: #a855f7; color: #1a1a2e; }
|
||||||
|
|
||||||
|
#sortie-select {
|
||||||
|
background: #0f3460; border: 1px solid #e94560; color: #e0e0e0;
|
||||||
|
font-family: monospace; font-size: 11px; padding: 2px 6px; border-radius: 2px;
|
||||||
|
cursor: pointer; max-width: 160px;
|
||||||
|
}
|
||||||
|
#btn-sync {
|
||||||
|
background: #0f3460; border: 1px solid #e94560; color: #e94560;
|
||||||
|
padding: 2px 9px; cursor: pointer; font-family: monospace; font-size: 11px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
#btn-sync:hover { background: #e94560; color: #1a1a2e; }
|
||||||
|
#btn-sync:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
#sync-progress {
|
||||||
|
font-size: 10px; color: #06d6a0; flex: 1;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
background: #0d0d20; border-top: 1px solid #0f3460; border-bottom: 1px solid #0f3460;
|
||||||
|
padding: 4px 14px; font-size: 11px; font-weight: bold; color: #e94560;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.auv-tab {
|
||||||
|
font-family: monospace; font-size: 10px; padding: 2px 8px; cursor: pointer;
|
||||||
|
border: 1px solid #0f3460; background: transparent; color: #a0c4ff; border-radius: 2px;
|
||||||
|
}
|
||||||
|
.auv-tab.active { background: #0f3460; color: #e0e0e0; }
|
||||||
|
.graphs-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px;
|
||||||
|
padding: 4px 8px; background: #12122a;
|
||||||
|
}
|
||||||
|
.graph-cell { height: 130px; background: #1a1a2e; }
|
||||||
|
.graph-cell.wide { grid-column: span 2; height: 130px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -238,6 +271,9 @@ flowchart LR
|
|||||||
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
|
<button class="layer-btn active" id="btn-vec" onclick="toggleLayer('vec')">USBL vec</button>
|
||||||
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
|
<button class="layer-btn active" id="btn-usbl-panel" onclick="toggleLayer('panel')">Stats</button>
|
||||||
</div>
|
</div>
|
||||||
|
<select id="sortie-select"><option value="">— Sortie —</option></select>
|
||||||
|
<button id="btn-sync" disabled>Sync & Process</button>
|
||||||
|
<span id="sync-progress"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Map -->
|
<!-- Row 2: Map -->
|
||||||
@@ -296,6 +332,34 @@ flowchart LR
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-header">USV</div>
|
||||||
|
<div class="graphs-grid" id="usv-graphs">
|
||||||
|
<div class="graph-cell" id="usv-yaw"></div>
|
||||||
|
<div class="graph-cell" id="usv-heading"></div>
|
||||||
|
<div class="graph-cell" id="usv-batt"></div>
|
||||||
|
<div class="graph-cell" id="usv-gps"></div>
|
||||||
|
<div class="graph-cell" id="usv-usbl-dist"></div>
|
||||||
|
<div class="graph-cell" id="usv-usbl-angle"></div>
|
||||||
|
<div class="graph-cell" id="usv-m1"></div>
|
||||||
|
<div class="graph-cell" id="usv-m2"></div>
|
||||||
|
<div class="graph-cell wide" id="usv-status"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-header" id="auv-panel-header">
|
||||||
|
AUV
|
||||||
|
<span id="auv-tabs"></span>
|
||||||
|
</div>
|
||||||
|
<div class="graphs-grid" id="auv-graphs">
|
||||||
|
<div class="graph-cell" id="auv-pry"></div>
|
||||||
|
<div class="graph-cell" id="auv-depth"></div>
|
||||||
|
<div class="graph-cell" id="auv-alt"></div>
|
||||||
|
<div class="graph-cell" id="auv-obs"></div>
|
||||||
|
<div class="graph-cell" id="auv-usbl-dist"></div>
|
||||||
|
<div class="graph-cell" id="auv-usbl-angle"></div>
|
||||||
|
<div class="graph-cell" id="auv-batt"></div>
|
||||||
|
<div class="graph-cell" id="auv-status"></div>
|
||||||
|
<div class="graph-cell wide" id="auv-motors"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.js"></script>
|
||||||
@@ -303,6 +367,7 @@ flowchart LR
|
|||||||
<script>
|
<script>
|
||||||
// == Constants ==
|
// == Constants ==
|
||||||
const API = 'http://192.168.0.83:8766';
|
const API = 'http://192.168.0.83:8766';
|
||||||
|
const API2 = 'http://192.168.0.83:8767';
|
||||||
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
|
const COLORS = ['#00b4d8','#06d6a0','#ffd166','#e94560','#a855f7','#ff8800','#7dc8e0','#b8f0d4'];
|
||||||
const AUV_COLOR = '#ff8800';
|
const AUV_COLOR = '#ff8800';
|
||||||
const PLOTLY_LAYOUT = {
|
const PLOTLY_LAYOUT = {
|
||||||
@@ -533,6 +598,7 @@ function applyTrailAndCursor() {
|
|||||||
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
|
`t: ${fmtMs(tNow)} | USV ${trailPtsUsv.length} pts | AUV ${trailPtsUsbl.length} pts | total ${fmtDur(tMax-tMin)}`;
|
||||||
|
|
||||||
updateChartsCursor();
|
updateChartsCursor();
|
||||||
|
if (tNow) updateCursor(tNow / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// == Cursor slider ==
|
// == Cursor slider ==
|
||||||
@@ -872,6 +938,226 @@ function hidePipelineOnBackdrop(e) {
|
|||||||
}
|
}
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { const ov = document.getElementById('pipeline-overlay'); if (ov.classList.contains('visible')) togglePipeline(); } });
|
||||||
|
|
||||||
|
// == Task 8: USV rendering helpers ==
|
||||||
|
function _pts(sig) {
|
||||||
|
if (!sig || !sig.length) return [[], []];
|
||||||
|
return [sig.map(p => new Date(p.t * 1000)), sig.map(p => p.v)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLOTLY_LAYOUT_BASE = {
|
||||||
|
margin: {l:40, r:8, t:20, b:30},
|
||||||
|
paper_bgcolor: '#1a1a2e', plot_bgcolor: '#1a1a2e',
|
||||||
|
font: {color: '#e0e0e0', size: 9, family: 'monospace'},
|
||||||
|
xaxis: {color: '#555', gridcolor: '#1e1e3a', type: 'date'},
|
||||||
|
yaxis: {color: '#555', gridcolor: '#1e1e3a'},
|
||||||
|
showlegend: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _layout(title, yLabel) {
|
||||||
|
const l = JSON.parse(JSON.stringify(PLOTLY_LAYOUT_BASE));
|
||||||
|
l.title = {text: title, font: {size: 9, color: '#888'}};
|
||||||
|
if (yLabel) l.yaxis.title = {text: yLabel, font: {size: 8}};
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUSV(signals) {
|
||||||
|
const cfg = {responsive: true, displayModeBar: false};
|
||||||
|
|
||||||
|
const [yt, yv] = _pts(signals.Yaw);
|
||||||
|
Plotly.react('usv-yaw', [{x:yt, y:yv, type:'scatter', mode:'lines', line:{color:'#00b4d8',width:1}, name:'Yaw'}], _layout('Yaw','°'), cfg);
|
||||||
|
|
||||||
|
const [ht, hv] = _pts(signals.Heading);
|
||||||
|
Plotly.react('usv-heading', [{x:ht, y:hv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1}}], _layout('Heading','°'), cfg);
|
||||||
|
|
||||||
|
const [bt, bv] = _pts(signals.BattVoltage);
|
||||||
|
Plotly.react('usv-batt', [{x:bt, y:bv, type:'scatter', mode:'lines', line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
|
||||||
|
|
||||||
|
const [gt, gv] = _pts(signals.gps_fix);
|
||||||
|
Plotly.react('usv-gps', [{x:gt, y:gv, type:'scatter', mode:'lines', line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('GPS fix'), cfg);
|
||||||
|
|
||||||
|
const [dt, dv] = _pts(signals.usbl_dist);
|
||||||
|
Plotly.react('usv-usbl-dist', [{x:dt, y:dv, type:'scatter', mode:'lines', line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
|
||||||
|
|
||||||
|
const [at, av] = _pts(signals.usbl_angle);
|
||||||
|
Plotly.react('usv-usbl-angle', [{x:at, y:av, type:'scatter', mode:'lines', line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
|
||||||
|
|
||||||
|
const [m1t, m1v] = _pts(signals.M1);
|
||||||
|
Plotly.react('usv-m1', [{x:m1t, y:m1v, type:'scatter', mode:'lines', line:{color:'#ef476f',width:1}}], _layout('Motor 1','cmd'), cfg);
|
||||||
|
|
||||||
|
const [m2t, m2v] = _pts(signals.M2);
|
||||||
|
Plotly.react('usv-m2', [{x:m2t, y:m2v, type:'scatter', mode:'lines', line:{color:'#ff6b6b',width:1}}], _layout('Motor 2','cmd'), cfg);
|
||||||
|
|
||||||
|
const armPts = _pts(signals.Armed);
|
||||||
|
const modePts = _pts(signals.Mode);
|
||||||
|
const statusTraces = [];
|
||||||
|
if (armPts[0].length) statusTraces.push({x:armPts[0], y:armPts[1], name:'Armed', type:'scatter', mode:'lines', line:{color:'#ffd166',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:[]}],
|
||||||
|
Object.assign(_layout('USV status'), {showlegend: statusTraces.length > 1}), cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 9: AUV rendering + tabs ==
|
||||||
|
let _currentSortieId = null;
|
||||||
|
|
||||||
|
async function loadAuvTabs(sortieId) {
|
||||||
|
const resp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auvs`);
|
||||||
|
const auvs = await resp.json();
|
||||||
|
const tabsEl = document.getElementById('auv-tabs');
|
||||||
|
tabsEl.innerHTML = '';
|
||||||
|
auvs.forEach((auv, i) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'auv-tab' + (i === 0 ? ' active' : '');
|
||||||
|
btn.textContent = auv;
|
||||||
|
btn.onclick = async () => {
|
||||||
|
document.querySelectorAll('.auv-tab').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auv}`);
|
||||||
|
const data = await r.json();
|
||||||
|
renderAUV(data.signals);
|
||||||
|
};
|
||||||
|
tabsEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
if (auvs.length > 0) {
|
||||||
|
const r = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/auv/${auvs[0]}`);
|
||||||
|
const data = await r.json();
|
||||||
|
renderAUV(data.signals);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAUV(signals) {
|
||||||
|
const cfg = {responsive: true, displayModeBar: false};
|
||||||
|
|
||||||
|
const pryTraces = [
|
||||||
|
['pitch', '#ef476f'], ['roll', '#06d6a0'], ['yaw', '#00b4d8']
|
||||||
|
].map(([k, c]) => { const [t,v]=_pts(signals[k]); return {x:t,y:v,name:k,type:'scatter',mode:'lines',line:{color:c,width:1}}; });
|
||||||
|
Plotly.react('auv-pry', pryTraces, Object.assign(_layout('Pitch/Roll/Yaw','°'), {showlegend:true, legend:{font:{size:8},bgcolor:'transparent',x:0,y:1}}), cfg);
|
||||||
|
|
||||||
|
const [dpt, dpv] = _pts(signals.depth);
|
||||||
|
Plotly.react('auv-depth', [{x:dpt,y:dpv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('Depth','m'), cfg);
|
||||||
|
|
||||||
|
const [alt, alv] = _pts(signals.altitude);
|
||||||
|
Plotly.react('auv-alt', [{x:alt,y:alv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Altitude','m'), cfg);
|
||||||
|
|
||||||
|
const [obt, obv] = _pts(signals.obstacle_dist);
|
||||||
|
Plotly.react('auv-obs', [{x:obt,y:obv,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('Obstacle','m'), cfg);
|
||||||
|
|
||||||
|
const [udt, udv] = _pts(signals.usbl_dist);
|
||||||
|
Plotly.react('auv-usbl-dist', [{x:udt,y:udv,type:'scatter',mode:'lines',line:{color:'#a0c4ff',width:1}}], _layout('USBL dist','m'), cfg);
|
||||||
|
|
||||||
|
const [uat, uav] = _pts(signals.usbl_angle);
|
||||||
|
Plotly.react('auv-usbl-angle', [{x:uat,y:uav,type:'scatter',mode:'lines',line:{color:'#c77dff',width:1}}], _layout('USBL angle','°'), cfg);
|
||||||
|
|
||||||
|
const [bbt, bbv] = _pts(signals.battery_v);
|
||||||
|
Plotly.react('auv-batt', [{x:bbt,y:bbv,type:'scatter',mode:'lines',line:{color:'#ffd166',width:1}}], _layout('Battery','V'), cfg);
|
||||||
|
|
||||||
|
const [stt, stv] = _pts(signals.arm_status);
|
||||||
|
Plotly.react('auv-status', [{x:stt,y:stv,type:'scatter',mode:'lines',line:{color:'#06d6a0',width:1,shape:'hv'}}], _layout('Arm/Mode'), cfg);
|
||||||
|
|
||||||
|
const motorColors = ['#ef476f','#ffd166','#06d6a0','#00b4d8','#a0c4ff','#c77dff'];
|
||||||
|
const motorTraces = ['m1','m2','m3','m4','m5','m6'].map((mk,i) => {
|
||||||
|
const [t,v] = _pts(signals[mk]);
|
||||||
|
return {x:t,y:v,type:'scatter',mode:'lines',name:`M${i+1}`,line:{color:motorColors[i],width:1}};
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 10: Slider cursor sync ==
|
||||||
|
const ALL_GRAPH_IDS = [
|
||||||
|
'chart-depth', 'chart-pwm-auv', 'chart-pwm-usv', 'chart-usbl',
|
||||||
|
'usv-yaw', 'usv-heading', 'usv-batt', 'usv-gps',
|
||||||
|
'usv-usbl-dist', 'usv-usbl-angle', 'usv-m1', 'usv-m2', 'usv-status',
|
||||||
|
'auv-pry', 'auv-depth', 'auv-alt', 'auv-obs',
|
||||||
|
'auv-usbl-dist', 'auv-usbl-angle', 'auv-batt', 'auv-status', 'auv-motors',
|
||||||
|
];
|
||||||
|
|
||||||
|
function updateCursor(epochSec) {
|
||||||
|
const ts = new Date(epochSec * 1000).toISOString();
|
||||||
|
const shape = {
|
||||||
|
type: 'line', x0: ts, x1: ts, y0: 0, y1: 1,
|
||||||
|
yref: 'paper', line: {color: '#e94560', width: 1, dash: 'dot'},
|
||||||
|
};
|
||||||
|
ALL_GRAPH_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && el._fullLayout) {
|
||||||
|
Plotly.relayout(id, {'shapes': [shape]});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// == Task 11: loadSortieData + sorties loading + wiring ==
|
||||||
|
async function loadSortieData(sortieId) {
|
||||||
|
const prog = document.getElementById('sync-progress');
|
||||||
|
try {
|
||||||
|
prog.textContent = 'Chargement USV…';
|
||||||
|
const usvResp = await fetch(`${API2}/sorties/${encodeURIComponent(sortieId)}/usv`);
|
||||||
|
if (usvResp.ok) {
|
||||||
|
const usvData = await usvResp.json();
|
||||||
|
renderUSV(usvData.signals);
|
||||||
|
}
|
||||||
|
prog.textContent = 'Chargement AUV…';
|
||||||
|
await loadAuvTabs(sortieId);
|
||||||
|
prog.textContent = `${sortieId} chargé`;
|
||||||
|
} catch(e) {
|
||||||
|
prog.textContent = `Erreur: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSorties() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API2}/sorties`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const sorties = await resp.json();
|
||||||
|
const sel = document.getElementById('sortie-select');
|
||||||
|
sorties.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
opt.textContent = s.id + (s.processed ? ' ✓' : '');
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
const btn = document.getElementById('btn-sync');
|
||||||
|
btn.disabled = !sel.value;
|
||||||
|
if (sel.value) {
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
if (opt.textContent.includes('✓')) {
|
||||||
|
loadSortieData(sel.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) { console.warn('pipeline-runner unavailable', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-sync').addEventListener('click', async () => {
|
||||||
|
const sortieId = document.getElementById('sortie-select').value;
|
||||||
|
if (!sortieId) return;
|
||||||
|
const btn = document.getElementById('btn-sync');
|
||||||
|
const prog = document.getElementById('sync-progress');
|
||||||
|
btn.disabled = true;
|
||||||
|
prog.textContent = 'Démarrage…';
|
||||||
|
const encoded = encodeURIComponent(sortieId);
|
||||||
|
await fetch(`${API2}/run/${encoded}`, {method: 'POST'});
|
||||||
|
const es = new EventSource(`${API2}/events/${encoded}`);
|
||||||
|
es.onmessage = async (e) => {
|
||||||
|
const evt = JSON.parse(e.data);
|
||||||
|
if (evt.step === 'ping') return;
|
||||||
|
prog.textContent = `[${evt.step}] ${evt.pct}% ${evt.msg}`;
|
||||||
|
if (evt.step === 'write' && evt.pct === 100) {
|
||||||
|
es.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
prog.textContent = 'Terminé — chargement…';
|
||||||
|
await loadSortieData(sortieId);
|
||||||
|
}
|
||||||
|
if (evt.step === 'error') {
|
||||||
|
es.close();
|
||||||
|
btn.disabled = false;
|
||||||
|
prog.textContent = `Erreur: ${evt.msg}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSorties();
|
||||||
|
|
||||||
// == Init ==
|
// == Init ==
|
||||||
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
mermaid.initialize({ startOnLoad: false, theme: 'dark' });
|
||||||
initCharts();
|
initCharts();
|
||||||
|
|||||||
Reference in New Issue
Block a user