|
|
|
@@ -128,6 +128,14 @@
|
|
|
|
|
display: flex; gap: 4px; flex-wrap: wrap;
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
}
|
|
|
|
|
#mapWrap .leaflet-top.leaflet-right {
|
|
|
|
|
top: 46px;
|
|
|
|
|
right: 10px;
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
}
|
|
|
|
|
#mapWrap .leaflet-control-layers-toggle {
|
|
|
|
|
width: 30px; height: 30px; line-height: 30px;
|
|
|
|
|
}
|
|
|
|
|
#mapCenterBar button {
|
|
|
|
|
padding: 5px 10px; font-size: 0.75rem; border: 1px solid #444; border-radius: 4px;
|
|
|
|
|
background: #16213ee6; color: #eee; cursor: pointer;
|
|
|
|
@@ -353,6 +361,8 @@
|
|
|
|
|
{ position: 'topright', collapsed: true }
|
|
|
|
|
).addTo(map);
|
|
|
|
|
|
|
|
|
|
const API_BUILD = '2026-06-16c';
|
|
|
|
|
|
|
|
|
|
const markers = {};
|
|
|
|
|
let selectedId = null;
|
|
|
|
|
let chatSince = 0;
|
|
|
|
@@ -392,6 +402,7 @@
|
|
|
|
|
let lastDevices = [];
|
|
|
|
|
const deviceLabelCache = {};
|
|
|
|
|
let timelineSpanMs = 1000;
|
|
|
|
|
let timelineUseProgress = false;
|
|
|
|
|
let elevProfileTx = null;
|
|
|
|
|
let elevProfileRx = null;
|
|
|
|
|
let elevProfileSingle = null;
|
|
|
|
@@ -458,9 +469,113 @@
|
|
|
|
|
|
|
|
|
|
function sliderTime() {
|
|
|
|
|
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
|
|
|
|
|
if (timelineUseProgress) return ms / timelineSpanMs;
|
|
|
|
|
return overlapMin + ms / 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function timelineCursor() {
|
|
|
|
|
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
|
|
|
|
|
if (timelineUseProgress) return { progress: ms / timelineSpanMs };
|
|
|
|
|
const t = overlapMin + ms / 1000;
|
|
|
|
|
return { t };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function analyzeTrackTiming(points) {
|
|
|
|
|
if (!points || points.length < 2) {
|
|
|
|
|
return { useProgress: true, issues: ['мало точек'], withMeta: 0, total: points?.length || 0 };
|
|
|
|
|
}
|
|
|
|
|
const ts = points.map(p => Number(p.ts));
|
|
|
|
|
const unique = new Set(ts.map(v => Math.round(v * 1000))).size;
|
|
|
|
|
const span = Math.max(...ts) - Math.min(...ts);
|
|
|
|
|
const withMeta = points.filter(p => p.meta && String(p.meta).length > 2).length;
|
|
|
|
|
const issues = [];
|
|
|
|
|
if (!Number.isFinite(span) || span < 0.05) issues.push('одинаковое время у точек');
|
|
|
|
|
if (unique < 2) issues.push('нет различимых меток времени');
|
|
|
|
|
if (withMeta < Math.max(1, Math.floor(points.length * 0.15))) {
|
|
|
|
|
issues.push(`радио-meta только у ${withMeta}/${points.length} точек`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
useProgress: issues.some(x => x.includes('время') || x.includes('меток')),
|
|
|
|
|
issues,
|
|
|
|
|
withMeta,
|
|
|
|
|
total: points.length,
|
|
|
|
|
span,
|
|
|
|
|
unique,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positionAtProgress(points, progress) {
|
|
|
|
|
if (!points?.length) return null;
|
|
|
|
|
const p = Math.max(0, Math.min(1, progress));
|
|
|
|
|
if (points.length === 1) {
|
|
|
|
|
const one = points[0];
|
|
|
|
|
return { lat: Number(one.lat), lon: Number(one.lon), meta: one.meta, rssi: one.rssi };
|
|
|
|
|
}
|
|
|
|
|
const f = p * (points.length - 1);
|
|
|
|
|
const i = Math.floor(f);
|
|
|
|
|
const j = Math.min(i + 1, points.length - 1);
|
|
|
|
|
const frac = f - i;
|
|
|
|
|
const a = points[i];
|
|
|
|
|
const b = points[j];
|
|
|
|
|
if (frac <= 0 || i === j) {
|
|
|
|
|
return { lat: Number(a.lat), lon: Number(a.lon), meta: a.meta, rssi: a.rssi };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac,
|
|
|
|
|
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac,
|
|
|
|
|
meta: frac < 0.5 ? a.meta : b.meta,
|
|
|
|
|
rssi: frac < 0.5 ? a.rssi : b.rssi,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positionAtCursor(points, cursor) {
|
|
|
|
|
if (timelineUseProgress) return positionAtProgress(points, cursor.progress);
|
|
|
|
|
return positionAt(points, cursor.t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackDistanceAtCursor(track, cursor) {
|
|
|
|
|
if (timelineUseProgress) return trackDistanceAtProgress(track, cursor.progress);
|
|
|
|
|
return trackDistanceAtTime(track, cursor.t);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackDistanceAtProgress(track, progress) {
|
|
|
|
|
if (!track?.points?.length) return 0;
|
|
|
|
|
const pts = track.points;
|
|
|
|
|
if (pts.length === 1) return 0;
|
|
|
|
|
const f = Math.max(0, Math.min(1, progress)) * (pts.length - 1);
|
|
|
|
|
const idx = Math.floor(f);
|
|
|
|
|
const frac = f - idx;
|
|
|
|
|
let dist = 0;
|
|
|
|
|
for (let i = 1; i <= idx && i < pts.length; i++) {
|
|
|
|
|
dist += haversineM(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon);
|
|
|
|
|
}
|
|
|
|
|
if (frac > 0 && idx + 1 < pts.length) {
|
|
|
|
|
const a = pts[idx];
|
|
|
|
|
const b = pts[idx + 1];
|
|
|
|
|
const lat = Number(a.lat) + (Number(b.lat) - Number(a.lat)) * frac;
|
|
|
|
|
const lon = Number(a.lon) + (Number(b.lon) - Number(a.lon)) * frac;
|
|
|
|
|
dist += haversineM(a.lat, a.lon, lat, lon);
|
|
|
|
|
}
|
|
|
|
|
return dist;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function snapAtCursor(track, telemetryRows, cursor, roleFallback) {
|
|
|
|
|
if (timelineUseProgress) {
|
|
|
|
|
const pos = positionAtProgress(track?.points, cursor.progress);
|
|
|
|
|
if (!pos) return null;
|
|
|
|
|
return { meta: pos.meta, role: roleFallback, rssi: pos.rssi, ts: cursor.progress };
|
|
|
|
|
}
|
|
|
|
|
return snapAtTime(track, telemetryRows, cursor.t, roleFallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTimelineClock(cursor) {
|
|
|
|
|
if (timelineUseProgress) {
|
|
|
|
|
const pct = Math.round(cursor.progress * 100);
|
|
|
|
|
return `${pct}% пути`;
|
|
|
|
|
}
|
|
|
|
|
return new Date(cursor.t * 1000).toLocaleTimeString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeTrack(track) {
|
|
|
|
|
if (!track?.points?.length) return track;
|
|
|
|
|
const points = track.points.map(p => ({
|
|
|
|
@@ -1554,11 +1669,11 @@
|
|
|
|
|
drawElevationChart();
|
|
|
|
|
requestAnimationFrame(() => drawElevationChart(
|
|
|
|
|
singleTrackActive
|
|
|
|
|
? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) }
|
|
|
|
|
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
|
|
|
|
|
: dualTracksActive
|
|
|
|
|
? {
|
|
|
|
|
tx: trackDistanceAtTime(loadedTxTrack, sliderTime()),
|
|
|
|
|
rx: trackDistanceAtTime(loadedRxTrack, sliderTime())
|
|
|
|
|
tx: trackDistanceAtCursor(loadedTxTrack, timelineCursor()),
|
|
|
|
|
rx: trackDistanceAtCursor(loadedRxTrack, timelineCursor())
|
|
|
|
|
}
|
|
|
|
|
: null
|
|
|
|
|
));
|
|
|
|
@@ -1861,7 +1976,7 @@
|
|
|
|
|
layerList.push(seg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pts.forEach(p => {
|
|
|
|
|
pts.forEach((p, pointIdx) => {
|
|
|
|
|
const q = rxQualityFromMeta(p.meta);
|
|
|
|
|
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
|
|
|
|
|
const m = L.circleMarker([p.lat, p.lon], {
|
|
|
|
@@ -1869,24 +1984,29 @@
|
|
|
|
|
});
|
|
|
|
|
m.addTo(map);
|
|
|
|
|
m.on('click', () => {
|
|
|
|
|
const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
|
|
|
|
|
if (timelineUseProgress) {
|
|
|
|
|
const relMs = Math.round((pointIdx / Math.max(1, pts.length - 1)) * timelineSpanMs);
|
|
|
|
|
document.getElementById('timeSlider').value = String(relMs);
|
|
|
|
|
updateTimelineAt(timelineCursor(), { openModal: true });
|
|
|
|
|
} else {
|
|
|
|
|
const relMs = Math.max(0, Math.min(Math.round((Number(p.ts) - overlapMin) * 1000), timelineSpanMs));
|
|
|
|
|
document.getElementById('timeSlider').value = String(relMs);
|
|
|
|
|
updateTimelineAt(timelineCursor(), { openModal: true });
|
|
|
|
|
}
|
|
|
|
|
modalMode = 'timeline';
|
|
|
|
|
updateTimelineAt(Number(p.ts), { openModal: true });
|
|
|
|
|
});
|
|
|
|
|
markerList.push(m);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTimelineModalHtml(t, txPos, rxPos) {
|
|
|
|
|
function buildTimelineModalHtml(cursor, txPos, rxPos) {
|
|
|
|
|
if (!txPos || !rxPos) return '';
|
|
|
|
|
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
|
|
|
|
|
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
|
|
|
|
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
|
|
|
|
|
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
|
|
|
|
|
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
|
|
|
|
|
const { txTel, rxTel } = pairedTelemetryAtTime(
|
|
|
|
|
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
|
|
|
|
|
);
|
|
|
|
|
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
|
|
|
|
|
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
|
|
|
|
|
html += renderTimelineCompare(
|
|
|
|
|
txTel || { meta: txPos.meta, role: 'TX', rssi: null },
|
|
|
|
|
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
|
|
|
|
@@ -1901,16 +2021,19 @@
|
|
|
|
|
return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateTimelineAt(t, opts) {
|
|
|
|
|
function updateTimelineAt(tOrCursor, opts) {
|
|
|
|
|
const openModal = opts && opts.openModal;
|
|
|
|
|
const cursor = (tOrCursor && typeof tOrCursor === 'object')
|
|
|
|
|
? tOrCursor
|
|
|
|
|
: (timelineUseProgress ? { progress: Number(tOrCursor) } : { t: Number(tOrCursor) });
|
|
|
|
|
if (singleTrackActive && loadedSingleTrack) {
|
|
|
|
|
updateTimelineAtSingle(t, openModal);
|
|
|
|
|
updateTimelineAtSingle(cursor, openModal);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!loadedTxTrack || !loadedRxTrack) return;
|
|
|
|
|
const txPos = positionAt(loadedTxTrack.points, t);
|
|
|
|
|
const rxPos = positionAt(loadedRxTrack.points, t);
|
|
|
|
|
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
|
|
|
|
|
const txPos = positionAtCursor(loadedTxTrack.points, cursor);
|
|
|
|
|
const rxPos = positionAtCursor(loadedRxTrack.points, cursor);
|
|
|
|
|
document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
|
|
|
|
|
|
|
|
|
|
if (ghostTx) map.removeLayer(ghostTx);
|
|
|
|
|
if (ghostRx) map.removeLayer(ghostRx);
|
|
|
|
@@ -1934,15 +2057,14 @@
|
|
|
|
|
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
|
|
|
|
|
{ color: '#00ff88', weight: 3, dashArray: '6,6' }
|
|
|
|
|
).addTo(map);
|
|
|
|
|
const modalHtml = buildTimelineModalHtml(t, txPos, rxPos);
|
|
|
|
|
const modalHtml = buildTimelineModalHtml(cursor, txPos, rxPos);
|
|
|
|
|
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
|
|
|
|
|
openMapModal(modalHtml, 'timeline');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { txTel, rxTel } = pairedTelemetryAtTime(
|
|
|
|
|
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
|
|
|
|
|
);
|
|
|
|
|
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
|
|
|
|
|
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
|
|
|
|
|
const timelineStatsEl = document.getElementById('timelineStats');
|
|
|
|
|
setPanelHtml(timelineStatsEl, renderTimelineCompare(
|
|
|
|
|
txTel,
|
|
|
|
@@ -1951,16 +2073,16 @@
|
|
|
|
|
deviceDisplayName(loadedRxTrack?.device_id)
|
|
|
|
|
));
|
|
|
|
|
drawElevationChart({
|
|
|
|
|
tx: trackDistanceAtTime(loadedTxTrack, t),
|
|
|
|
|
rx: trackDistanceAtTime(loadedRxTrack, t)
|
|
|
|
|
tx: trackDistanceAtCursor(loadedTxTrack, cursor),
|
|
|
|
|
rx: trackDistanceAtCursor(loadedRxTrack, cursor)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateTimelineAtSingle(t, openModal) {
|
|
|
|
|
function updateTimelineAtSingle(cursor, openModal) {
|
|
|
|
|
const track = loadedSingleTrack;
|
|
|
|
|
if (!track) return;
|
|
|
|
|
const pos = positionAt(track.points, t);
|
|
|
|
|
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
|
|
|
|
|
const pos = positionAtCursor(track.points, cursor);
|
|
|
|
|
document.getElementById('timeCurrent').textContent = formatTimelineClock(cursor);
|
|
|
|
|
if (ghostTx) map.removeLayer(ghostTx);
|
|
|
|
|
if (ghostRx) map.removeLayer(ghostRx);
|
|
|
|
|
if (linkLine) map.removeLayer(linkLine);
|
|
|
|
@@ -1972,9 +2094,9 @@
|
|
|
|
|
ghostTx = L.circleMarker([pos.lat, pos.lon], {
|
|
|
|
|
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
|
|
|
|
|
}).addTo(map);
|
|
|
|
|
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
|
|
|
|
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
|
|
|
|
|
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
|
|
|
|
|
const tel = snapAtTime(track, telemetrySingle, t, track.role);
|
|
|
|
|
const tel = snapAtCursor(track, telemetrySingle, cursor, 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());
|
|
|
|
@@ -1982,7 +2104,7 @@
|
|
|
|
|
openMapModal(html, 'timeline');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const tel = snapAtTime(track, telemetrySingle, t, track.role);
|
|
|
|
|
const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
|
|
|
|
|
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
|
|
|
|
|
const timelineStatsEl = document.getElementById('timelineStats');
|
|
|
|
|
setPanelHtml(
|
|
|
|
@@ -2008,8 +2130,31 @@
|
|
|
|
|
statsPanel.classList.toggle('timeline-single', single);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyProgressTimeline(diagnosis, extraNote) {
|
|
|
|
|
const note = document.getElementById('timelineNote');
|
|
|
|
|
timelineUseProgress = true;
|
|
|
|
|
overlapMin = 0;
|
|
|
|
|
overlapMax = 1;
|
|
|
|
|
timelineSpanMs = 1000;
|
|
|
|
|
const slider = document.getElementById('timeSlider');
|
|
|
|
|
slider.min = 0;
|
|
|
|
|
slider.max = String(timelineSpanMs);
|
|
|
|
|
slider.step = 10;
|
|
|
|
|
slider.value = '0';
|
|
|
|
|
document.getElementById('timeStart').textContent = '0%';
|
|
|
|
|
document.getElementById('timeEnd').textContent = '100%';
|
|
|
|
|
document.getElementById('timeCurrent').textContent = '0% пути';
|
|
|
|
|
const issues = (diagnosis?.issues || []).join('; ');
|
|
|
|
|
note.textContent = (extraNote ? extraNote + ' ' : '')
|
|
|
|
|
+ 'Старый/битый трек: время записи ненадёжно — шкала по прогрессу пути.'
|
|
|
|
|
+ (issues ? ` (${issues})` : '');
|
|
|
|
|
note.style.color = '#ffb74d';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyTimelineRange(range, noteText) {
|
|
|
|
|
const note = document.getElementById('timelineNote');
|
|
|
|
|
timelineUseProgress = false;
|
|
|
|
|
note.style.color = '#aaa';
|
|
|
|
|
overlapMin = range.min;
|
|
|
|
|
overlapMax = range.max;
|
|
|
|
|
const spanSec = Math.max(0.001, overlapMax - overlapMin);
|
|
|
|
@@ -2026,23 +2171,36 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupTimelineSingle() {
|
|
|
|
|
const range = singleTrackRange(loadedSingleTrack.points);
|
|
|
|
|
const diag = analyzeTrackTiming(loadedSingleTrack.points);
|
|
|
|
|
setTimelineMode(true);
|
|
|
|
|
if (!range) {
|
|
|
|
|
if (!loadedSingleTrack.points?.length) {
|
|
|
|
|
setTimelineVisible(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (diag.useProgress) {
|
|
|
|
|
applyProgressTimeline(diag, `Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`);
|
|
|
|
|
} else {
|
|
|
|
|
const range = singleTrackRange(loadedSingleTrack.points);
|
|
|
|
|
applyTimelineRange(
|
|
|
|
|
range,
|
|
|
|
|
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}`
|
|
|
|
|
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack)}`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
setTimelineVisible(true);
|
|
|
|
|
updateTimelineAtSingle(overlapMin);
|
|
|
|
|
updateTimelineAtSingle(timelineCursor());
|
|
|
|
|
loadElevationProfiles();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setupTimeline() {
|
|
|
|
|
setTimelineMode(false);
|
|
|
|
|
const txDiag = analyzeTrackTiming(loadedTxTrack.points);
|
|
|
|
|
const rxDiag = analyzeTrackTiming(loadedRxTrack.points);
|
|
|
|
|
if (txDiag.useProgress || rxDiag.useProgress) {
|
|
|
|
|
applyProgressTimeline(
|
|
|
|
|
{ issues: [...new Set([...(txDiag.issues || []), ...(rxDiag.issues || [])])] },
|
|
|
|
|
'Сравнение TX/RX по прогрессу пути.'
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
|
|
|
|
if (!range) {
|
|
|
|
|
setTimelineVisible(false);
|
|
|
|
@@ -2054,13 +2212,15 @@
|
|
|
|
|
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
|
|
|
|
|
}
|
|
|
|
|
applyTimelineRange(range, noteText);
|
|
|
|
|
}
|
|
|
|
|
setTimelineVisible(true);
|
|
|
|
|
updateTimelineAt(overlapMin);
|
|
|
|
|
updateTimelineAt(timelineCursor());
|
|
|
|
|
loadElevationProfiles();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshTimelineTelemetry() {
|
|
|
|
|
if (singleTrackActive && loadedSingleTrack) {
|
|
|
|
|
if (!timelineUseProgress) {
|
|
|
|
|
const range = singleTrackRange(loadedSingleTrack.points);
|
|
|
|
|
if (!range) return;
|
|
|
|
|
const res = await fetch(
|
|
|
|
@@ -2068,11 +2228,12 @@
|
|
|
|
|
{ cache: 'no-store' }
|
|
|
|
|
);
|
|
|
|
|
if (res.ok) telemetrySingle = await res.json();
|
|
|
|
|
const t = sliderTime();
|
|
|
|
|
updateTimelineAtSingle(t);
|
|
|
|
|
}
|
|
|
|
|
updateTimelineAtSingle(timelineCursor());
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
|
|
|
|
|
if (!timelineUseProgress) {
|
|
|
|
|
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
|
|
|
|
if (!range) return;
|
|
|
|
|
const [telTx, telRx] = await Promise.all([
|
|
|
|
@@ -2081,7 +2242,8 @@
|
|
|
|
|
]);
|
|
|
|
|
if (telTx.ok) telemetryTx = await telTx.json();
|
|
|
|
|
if (telRx.ok) telemetryRx = await telRx.json();
|
|
|
|
|
updateTimelineAt(sliderTime());
|
|
|
|
|
}
|
|
|
|
|
updateTimelineAt(timelineCursor());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackOptionLabel(t) {
|
|
|
|
@@ -2165,7 +2327,7 @@
|
|
|
|
|
{ cache: 'no-store' }
|
|
|
|
|
);
|
|
|
|
|
if (telRes.ok) telemetrySingle = await telRes.json();
|
|
|
|
|
updateTimelineAtSingle(overlapMin);
|
|
|
|
|
updateTimelineAtSingle(timelineCursor());
|
|
|
|
|
}
|
|
|
|
|
document.getElementById('trackInfo').textContent =
|
|
|
|
|
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
|
|
|
|
@@ -2227,8 +2389,7 @@
|
|
|
|
|
]);
|
|
|
|
|
if (telTx.ok) telemetryTx = await telTx.json();
|
|
|
|
|
if (telRx.ok) telemetryRx = await telRx.json();
|
|
|
|
|
const t = sliderTime();
|
|
|
|
|
updateTimelineAt(t);
|
|
|
|
|
updateTimelineAt(timelineCursor());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
|
|
|
|
@@ -2382,7 +2543,7 @@
|
|
|
|
|
const onSlider = () => {
|
|
|
|
|
if (!singleTrackActive && !dualTracksActive) return;
|
|
|
|
|
modalMode = 'timeline';
|
|
|
|
|
updateTimelineAt(sliderTime());
|
|
|
|
|
updateTimelineAt(timelineCursor());
|
|
|
|
|
};
|
|
|
|
|
slider.addEventListener('input', onSlider);
|
|
|
|
|
slider.addEventListener('change', onSlider);
|
|
|
|
@@ -2401,18 +2562,48 @@
|
|
|
|
|
let ms = parseInt(slider.value, 10) + step;
|
|
|
|
|
if (ms > timelineSpanMs) ms = 0;
|
|
|
|
|
slider.value = String(ms);
|
|
|
|
|
updateTimelineAt(sliderTime());
|
|
|
|
|
updateTimelineAt(timelineCursor());
|
|
|
|
|
}, Math.max(100, step));
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveDeviceLabel(deviceId) {
|
|
|
|
|
const input = document.getElementById('deviceLabelInput');
|
|
|
|
|
const label = input ? input.value.trim() : '';
|
|
|
|
|
if (!deviceId || !label) return;
|
|
|
|
|
const res = await fetch(`/api/devices/${encodeURIComponent(deviceId)}/label`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ label })
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
alert(data.detail || data.error || 'Не удалось сохранить имя');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
deviceLabelCache[deviceId] = label;
|
|
|
|
|
await fetchDevices();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('stats').addEventListener('click', e => {
|
|
|
|
|
if (e.target && e.target.id === 'btnSaveDeviceLabel' && selectedId) {
|
|
|
|
|
saveDeviceLabel(selectedId);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function buildDeviceStatsHtml(d) {
|
|
|
|
|
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
|
|
|
|
|
const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap);
|
|
|
|
|
prevDeviceSnap = snap;
|
|
|
|
|
const statsEl = document.getElementById('stats');
|
|
|
|
|
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl));
|
|
|
|
|
html += `<b>${escapeHtml(deviceDisplayName(d))}</b><br>Range: ${d.range_m ?? '—'} m<br>`;
|
|
|
|
|
html += `<b>${escapeHtml(deviceDisplayName(d))}</b>`;
|
|
|
|
|
html += `<div class="muted" style="font-size:0.75rem">${escapeHtml(d.device_id)}</div>`;
|
|
|
|
|
html += `<div style="margin-top:6px;display:flex;gap:4px">`;
|
|
|
|
|
html += `<input id="deviceLabelInput" type="text" value="${escapeHtml(deviceDisplayName(d))}" placeholder="Имя устройства" style="flex:1;padding:4px 6px;border-radius:4px;border:1px solid #444;background:#0a0a14;color:#eee;font-size:0.8rem" />`;
|
|
|
|
|
html += `<button type="button" id="btnSaveDeviceLabel" style="padding:4px 8px;border-radius:4px;border:1px solid #444;background:#16213e;color:#eee;cursor:pointer;font-size:0.75rem">OK</button>`;
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
html += `<br>Range: ${d.range_m ?? '—'} m<br>`;
|
|
|
|
|
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
|
|
|
|
|
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
|
|
|
|
|
Object.keys(markers).forEach(id => {
|
|
|
|
@@ -2447,6 +2638,18 @@
|
|
|
|
|
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
|
|
|
|
|
document.getElementById('status').textContent =
|
|
|
|
|
`${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`;
|
|
|
|
|
try {
|
|
|
|
|
const hres = await fetch('/api/health', { cache: 'no-store' });
|
|
|
|
|
if (hres.ok) {
|
|
|
|
|
const h = await hres.json();
|
|
|
|
|
if (h.api_build && h.api_build !== API_BUILD) {
|
|
|
|
|
document.getElementById('status').title =
|
|
|
|
|
`UI ${API_BUILD} · сервер ${h.api_build} — обновите образ Docker`;
|
|
|
|
|
} else if (h.api_build) {
|
|
|
|
|
document.getElementById('status').title = `build ${h.api_build}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
const list = document.getElementById('deviceList');
|
|
|
|
|
list.innerHTML = '';
|
|
|
|
|
const bounds = [];
|
|
|
|
|