This commit is contained in:
2026-06-16 11:10:15 +03:00
parent 0571291b69
commit 3399e81447
6 changed files with 190 additions and 70 deletions
+137 -58
View File
@@ -390,6 +390,8 @@
let dualTracksActive = false;
let singleTrackActive = false;
let lastDevices = [];
const deviceLabelCache = {};
let timelineSpanMs = 1000;
let elevProfileTx = null;
let elevProfileRx = null;
let elevProfileSingle = null;
@@ -433,17 +435,61 @@
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
function rememberDeviceLabels(devices) {
(devices || []).forEach(d => {
const lbl = d.device_label || d.label;
if (lbl && lbl !== d.device_id) deviceLabelCache[d.device_id] = lbl;
});
}
function deviceDisplayName(d) {
if (!d) return '—';
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === d) : d;
if (typeof d === 'object') {
const direct = d.device_label || d.label;
if (direct && direct !== d.device_id) return direct;
}
const id = typeof d === 'string' ? d : d.device_id;
const label = dev?.label;
if (deviceLabelCache[id]) return deviceLabelCache[id];
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === id) : d;
const label = dev?.device_label || dev?.label;
if (label && label !== id) return label;
return id || '—';
}
function sliderTime() {
return overlapMin + parseFloat(document.getElementById('timeSlider').value || '0');
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
return overlapMin + ms / 1000;
}
function normalizeTrack(track) {
if (!track?.points?.length) return track;
const points = track.points.map(p => ({
...p,
ts: Number(p.ts),
lat: Number(p.lat),
lon: Number(p.lon),
}));
const maxTs = Math.max(...points.map(p => p.ts));
if (maxTs > 1e11) {
points.forEach(p => { p.ts /= 1000; });
}
points.sort((a, b) => a.ts - b.ts);
return { ...track, points };
}
function snapAtTime(track, telemetryRows, t, roleFallback) {
const tel = telemetryAtTime(telemetryRows, t);
if (tel) return tel;
const pos = positionAt(track?.points, t);
if (!pos) return null;
return {
meta: pos.meta,
role: roleFallback || track?.role,
rssi: pos.rssi,
ts: t,
lat: pos.lat,
lon: pos.lon
};
}
function rxQualityFromMeta(meta) {
@@ -454,7 +500,7 @@
function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null;
const p = Math.max(0, Math.min(100, pct));
const p = Math.max(0, Math.min(100, Number(pct)));
const r = p < 50 ? Math.round(255 * (p / 50)) : 255;
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50));
return `rgb(${r},${g},0)`;
@@ -1635,34 +1681,40 @@
/* --- Track helpers --- */
function positionAt(points, t) {
if (!points || !points.length) return null;
const tNum = Number(t);
const first = points[0];
const last = points[points.length - 1];
if (t <= first.ts) {
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
const t0 = Number(first.ts);
const t1 = Number(last.ts);
if (tNum <= t0) {
return { lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi };
}
if (t >= last.ts) {
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
if (tNum >= t1) {
return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
}
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
if (t >= a.ts && t <= b.ts) {
const f = (t - a.ts) / (b.ts - a.ts);
const ta = Number(a.ts);
const tb = Number(b.ts);
if (tNum >= ta && tNum <= tb) {
const span = Math.max(tb - ta, 1e-9);
const f = (tNum - ta) / span;
return {
lat: a.lat + (b.lat - a.lat) * f,
lon: a.lon + (b.lon - a.lon) * f,
meta: t - a.ts < b.ts - t ? a.meta : b.meta,
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f,
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f,
meta: tNum - ta < tb - tNum ? a.meta : b.meta,
rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi
};
}
}
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
}
function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const min = Math.max(txPts[0].ts, rxPts[0].ts);
const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
const min = Math.max(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.min(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'overlap' };
}
@@ -1672,8 +1724,8 @@
if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap;
const min = Math.min(txPts[0].ts, rxPts[0].ts);
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
const min = Math.min(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.max(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'union' };
}
@@ -1802,7 +1854,7 @@
const qa = rxQualityFromMeta(a.meta);
const qb = rxQualityFromMeta(b.meta);
const q = qa != null && qb != null ? (qa + qb) / 2 : (qa ?? qb);
const segColor = useQuality && q != null ? (qualityColor(q) || color) : color;
const segColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: segColor, weight: 4, opacity: 0.85
}).addTo(map);
@@ -1811,16 +1863,16 @@
pts.forEach(p => {
const q = rxQualityFromMeta(p.meta);
const ptColor = useQuality && q != null ? (qualityColor(q) || color) : color;
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const m = L.circleMarker([p.lat, p.lon], {
radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8
});
m.addTo(map);
m.on('click', () => {
const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
document.getElementById('timeSlider').value = String(rel);
const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
document.getElementById('timeSlider').value = String(relMs);
modalMode = 'timeline';
updateTimelineAt(p.ts, { openModal: true });
updateTimelineAt(Number(p.ts), { openModal: true });
});
markerList.push(m);
});
@@ -1846,7 +1898,7 @@
function singleTrackRange(points) {
if (!points || !points.length) return null;
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
}
function updateTimelineAt(t, opts) {
@@ -1922,7 +1974,7 @@
}).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
const tel = nearestTelemetry(telemetrySingle, t);
const tel = snapAtTime(track, telemetrySingle, t, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody));
if (tel) html += '<br>' + formatTelemetryRow(tel, new Set());
@@ -1930,7 +1982,7 @@
openMapModal(html, 'timeline');
}
}
const tel = nearestTelemetry(telemetrySingle, t);
const tel = snapAtTime(track, telemetrySingle, t, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(
@@ -1960,14 +2012,16 @@
const note = document.getElementById('timelineNote');
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(0.1, overlapMax - overlapMin);
const spanSec = Math.max(0.001, overlapMax - overlapMin);
timelineSpanMs = Math.max(1, Math.round(spanSec * 1000));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = String(span);
slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1');
slider.max = String(timelineSpanMs);
slider.step = timelineSpanMs > 300000 ? 1000 : (timelineSpanMs > 60000 ? 500 : 100);
slider.value = '0';
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
document.getElementById('timeCurrent').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
if (noteText != null) note.textContent = noteText;
}
@@ -2033,7 +2087,7 @@
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${deviceDisplayName(t.device_id)}` : '';
const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
@@ -2047,6 +2101,7 @@
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
if (!res.ok) throw new Error('tracks ' + res.status);
const tracks = await res.json();
rememberDeviceLabels(tracks);
const fill = (sel, hint) => {
sel.innerHTML = `<option value="">${hint}</option>`;
tracks.forEach(t => {
@@ -2079,9 +2134,14 @@
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
loadedSingleTrack = await res.json();
loadedSingleTrack = normalizeTrack(await res.json());
rememberDeviceLabels([loadedSingleTrack]);
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role;
@@ -2131,14 +2191,18 @@
clearTrackLayers();
singleTrackActive = false;
loadedSingleTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`)
]);
loadedTxTrack = await txRes.json();
loadedRxTrack = await rxRes.json();
loadedTxTrack = normalizeTrack(await txRes.json());
loadedRxTrack = normalizeTrack(await rxRes.json());
rememberDeviceLabels([loadedTxTrack, loadedRxTrack]);
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
@@ -2313,27 +2377,34 @@
}
refreshPairedStatus();
};
document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline';
updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
};
document.getElementById('btnPlay').onclick = () => {
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
return;
}
function setupTimelineControls() {
const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause';
playTimer = setInterval(() => {
const step = parseFloat(slider.step) || 1;
let v = parseFloat(slider.value) + step;
if (v > parseFloat(slider.max)) v = 0;
slider.value = String(v);
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
}, Math.max(200, Math.round(step * 1000)));
};
const onSlider = () => {
if (!singleTrackActive && !dualTracksActive) return;
modalMode = 'timeline';
updateTimelineAt(sliderTime());
};
slider.addEventListener('input', onSlider);
slider.addEventListener('change', onSlider);
document.getElementById('btnPlay').onclick = () => {
if (!singleTrackActive && !dualTracksActive) return;
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
return;
}
document.getElementById('btnPlay').textContent = '⏸ Pause';
const step = parseInt(slider.step, 10) || 100;
playTimer = setInterval(() => {
let ms = parseInt(slider.value, 10) + step;
if (ms > timelineSpanMs) ms = 0;
slider.value = String(ms);
updateTimelineAt(sliderTime());
}, Math.max(100, step));
};
}
function buildDeviceStatsHtml(d) {
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
@@ -2370,6 +2441,8 @@
const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json();
rememberDeviceLabels(devices);
lastDevices = devices;
let tx = 0, rx = 0;
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent =
@@ -2419,6 +2492,11 @@
updateGpsDistanceHeader(devices);
if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto();
updateCmdTargetSelect(devices);
if (selectedId && !devices.find(d => d.device_id === selectedId)) {
selectedId = null;
setPanelHtml(document.getElementById('stats'), '<span class="muted">—</span>');
document.getElementById('history').innerHTML = '';
}
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
@@ -2491,7 +2569,7 @@
const isNew = m.ts > chatLastReadTs;
const div = document.createElement('div');
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : '');
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_id));
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_label ? m : m.device_id));
div.innerHTML = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`;
log.appendChild(div);
if (isNew) {
@@ -2606,6 +2684,7 @@
schedulePoll();
setupCmdFormDirtyTracking();
setupTimelineControls();
loadAllTracks();
refreshPairedStatus();
</script>