This commit is contained in:
2026-06-16 10:36:18 +03:00
parent c5805eaa5c
commit 0571291b69
15 changed files with 447 additions and 105 deletions
+192 -79
View File
@@ -23,6 +23,8 @@
}
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
.leaflet-control-layers { background: #16213e; color: #eee; border: 1px solid #444; }
.leaflet-control-layers label { color: #eee; }
#trackTimeline {
display: none; grid-column: 1 / -1; grid-row: 2;
background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
@@ -302,7 +304,7 @@
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
<input type="range" id="timeSlider" min="0" max="100" value="0" step="0.1" />
<div id="elevationPanel">
<div id="elevationPanelTitle">
<span>Линейка высот <span id="elevationStatus" class="muted"></span></span>
@@ -336,9 +338,20 @@
</script>
<script>
const map = L.map('map').setView([55.75, 37.62], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
const mapLayerScheme = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 19
});
const mapLayerSatellite = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: '© Esri', maxZoom: 19 }
);
mapLayerScheme.addTo(map);
L.control.layers(
{ 'Схема': mapLayerScheme, 'Спутник': mapLayerSatellite },
null,
{ position: 'topright', collapsed: true }
).addTo(map);
const markers = {};
let selectedId = null;
@@ -354,8 +367,8 @@
const CMD_INPUT_IDS = ['cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdTm', 'cmdRole'];
let trackTxLayer = null;
let trackRxLayer = null;
let trackTxLayers = [];
let trackRxLayers = [];
let trackTxMarkers = [];
let trackRxMarkers = [];
let linkLine = null;
@@ -420,6 +433,33 @@
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
function deviceDisplayName(d) {
if (!d) return '—';
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === d) : d;
const id = typeof d === 'string' ? d : d.device_id;
const label = dev?.label;
if (label && label !== id) return label;
return id || '—';
}
function sliderTime() {
return overlapMin + parseFloat(document.getElementById('timeSlider').value || '0');
}
function rxQualityFromMeta(meta) {
if (!meta) return null;
const snap = RadioUI.parseRadioSnapshot(meta);
return snap.rxQualityPercent != null ? snap.rxQualityPercent : null;
}
function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null;
const p = Math.max(0, Math.min(100, 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)`;
}
function roleColor(role) {
return role === 'RX' ? RX_COLOR : TX_COLOR;
}
@@ -1468,11 +1508,11 @@
drawElevationChart();
requestAnimationFrame(() => drawElevationChart(
singleTrackActive
? { single: trackDistanceAtTime(loadedSingleTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)) }
? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) }
: dualTracksActive
? {
tx: trackDistanceAtTime(loadedTxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)),
rx: trackDistanceAtTime(loadedRxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10))
tx: trackDistanceAtTime(loadedTxTrack, sliderTime()),
rx: trackDistanceAtTime(loadedRxTrack, sliderTime())
}
: null
));
@@ -1638,20 +1678,74 @@
return { min, max, mode: 'union' };
}
function nearestTelemetry(rows, t) {
if (!rows.length) return null;
let best = rows[0];
let bestD = Math.abs(best.ts - t);
for (const r of rows) {
const d = Math.abs(r.ts - t);
if (d < bestD) { best = r; bestD = d; }
function telemetryAtTime(rows, t) {
if (!rows?.length) return null;
const first = rows[0];
const last = rows[rows.length - 1];
if (t <= first.ts) return first;
if (t >= last.ts) return last;
for (let i = 0; i < rows.length - 1; i++) {
const a = rows[i];
const b = rows[i + 1];
if (t >= a.ts && t <= b.ts) {
return t - a.ts <= b.ts - t ? a : b;
}
}
return best;
return last;
}
function telemetryFromTrackPoint(track, t, roleFallback) {
const pos = positionAt(track?.points, t);
if (!pos) return null;
return {
meta: pos.meta,
role: roleFallback,
rssi: pos.rssi,
ts: t,
lat: pos.lat,
lon: pos.lon
};
}
function findTelemetryByPacket(rows, packet) {
if (packet == null || !rows?.length) return null;
let best = null;
let bestD = Infinity;
for (const r of rows) {
const snap = telemetryToSnap(r);
if (snap.packet == null) continue;
const d = Math.abs(snap.packet - packet);
if (d < bestD) {
best = r;
bestD = d;
}
}
return bestD === 0 ? best : null;
}
function pairedTelemetryAtTime(txTrack, rxTrack, telemetryTx, telemetryRx, t) {
let txTel = telemetryAtTime(telemetryTx, t) || telemetryFromTrackPoint(txTrack, t, 'TX');
let rxTel = telemetryAtTime(telemetryRx, t) || telemetryFromTrackPoint(rxTrack, t, 'RX');
const txSnap = txTel ? telemetryToSnap(txTel) : null;
const rxSnap = rxTel ? telemetryToSnap(rxTel) : null;
if (txSnap?.packet != null) {
const matched = findTelemetryByPacket(telemetryRx, txSnap.packet);
if (matched) rxTel = matched;
} else if (rxSnap?.packet != null) {
const matched = findTelemetryByPacket(telemetryTx, rxSnap.packet);
if (matched) txTel = matched;
}
return { txTel, rxTel };
}
function nearestTelemetry(rows, t) {
return telemetryAtTime(rows, t);
}
function clearTrackLayers() {
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; }
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; }
[...trackTxLayers, ...trackRxLayers].forEach(l => map.removeLayer(l));
trackTxLayers = [];
trackRxLayers = [];
trackTxMarkers.forEach(m => map.removeLayer(m));
trackRxMarkers.forEach(m => map.removeLayer(m));
trackTxMarkers = [];
@@ -1695,20 +1789,38 @@
updateTrackButtons();
}
function drawTrackLine(track, color, store) {
const latlngs = track.points.map(p => [p.lat, p.lon]);
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
if (store === 'tx') trackTxLayer = layer;
else trackRxLayer = layer;
function drawTrackLine(track, color, store, colorByQuality) {
const pts = track.points;
const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
track.points.forEach(p => {
const m = L.circleMarker([p.lat, p.lon], { radius: 3, color, fillColor: color, fillOpacity: 0.8 });
const layerList = store === 'tx' ? trackTxLayers : trackRxLayers;
const useQuality = colorByQuality !== false
&& (store === 'rx' || track.role === 'RX');
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
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 seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
color: segColor, weight: 4, opacity: 0.85
}).addTo(map);
layerList.push(seg);
}
pts.forEach(p => {
const q = rxQualityFromMeta(p.meta);
const ptColor = useQuality && q != null ? (qualityColor(q) || color) : 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(Math.round(p.ts - overlapMin), parseInt(document.getElementById('timeSlider').max, 10)));
document.getElementById('timeSlider').value = rel;
const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
document.getElementById('timeSlider').value = String(rel);
modalMode = 'timeline';
updateTimelineAt(overlapMin + rel, { openModal: true });
updateTimelineAt(p.ts, { openModal: true });
});
markerList.push(m);
});
@@ -1720,13 +1832,14 @@
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</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 = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
const { txTel, rxTel } = pairedTelemetryAtTime(
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
html += renderTimelineCompare(
txTel || { meta: txPos.meta, role: 'TX', rssi: null },
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
loadedTxTrack?.device_id,
loadedRxTrack?.device_id
deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id)
);
return html;
}
@@ -1775,14 +1888,15 @@
}
}
const txTel = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
const { txTel, rxTel } = pairedTelemetryAtTime(
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel,
rxTel,
loadedTxTrack?.device_id,
loadedRxTrack?.device_id
deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id)
));
drawElevationChart({
tx: trackDistanceAtTime(loadedTxTrack, t),
@@ -1842,24 +1956,32 @@
statsPanel.classList.toggle('timeline-single', single);
}
function applyTimelineRange(range, noteText) {
const note = document.getElementById('timelineNote');
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(0.1, overlapMax - overlapMin);
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = String(span);
slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1');
slider.value = '0';
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
if (noteText != null) note.textContent = noteText;
}
function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points);
const note = document.getElementById('timelineNote');
setTimelineMode(true);
if (!range) {
setTimelineVisible(false);
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
applyTimelineRange(
range,
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}`
);
setTimelineVisible(true);
updateTimelineAtSingle(overlapMin);
loadElevationProfiles();
@@ -1868,26 +1990,16 @@
function setupTimeline() {
setTimelineMode(false);
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const note = document.getElementById('timelineNote');
if (!range) {
setTimelineVisible(false);
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
let noteText = 'Общий интервал записи обоих треков.';
if (range.mode === 'union') {
note.textContent =
noteText =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
} else {
note.textContent = 'Общий интервал записи обоих треков.';
}
applyTimelineRange(range, noteText);
setTimelineVisible(true);
updateTimelineAt(overlapMin);
loadElevationProfiles();
@@ -1902,7 +2014,7 @@
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = await res.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
const t = sliderTime();
updateTimelineAtSingle(t);
return;
}
@@ -1915,14 +2027,13 @@
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAt(t);
updateTimelineAt(sliderTime());
}
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
const dev = t.device_id ? ` · ${deviceDisplayName(t.device_id)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
@@ -1980,7 +2091,7 @@
return;
}
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
drawTrackLine(loadedSingleTrack, color, 'tx');
drawTrackLine(loadedSingleTrack, color, 'tx', loadedSingleTrack.role === 'RX');
if (!userMovedMap) {
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }));
@@ -2033,8 +2144,8 @@
return;
}
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx');
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx');
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx', false);
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx', true);
if (!userMovedMap) {
const bounds = L.latLngBounds([]);
@@ -2052,7 +2163,7 @@
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
const t = sliderTime();
updateTimelineAt(t);
}
@@ -2204,7 +2315,7 @@
};
document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline';
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
};
document.getElementById('btnPlay').onclick = () => {
if (playTimer) {
@@ -2216,11 +2327,12 @@
const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause';
playTimer = setInterval(() => {
let v = parseInt(slider.value, 10) + 1;
if (v > parseInt(slider.max, 10)) v = 0;
slider.value = v;
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' });
}, 1000);
}, Math.max(200, Math.round(step * 1000)));
};
function buildDeviceStatsHtml(d) {
@@ -2229,7 +2341,7 @@
prevDeviceSnap = snap;
const statsEl = document.getElementById('stats');
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl));
html += `<b>${escapeHtml(d.device_id)}</b><br>Range: ${d.range_m ?? '—'} m<br>`;
html += `<b>${escapeHtml(deviceDisplayName(d))}</b><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 => {
@@ -2268,7 +2380,7 @@
const seen = new Set();
devices.forEach(d => {
const li = document.createElement('li');
let label = d.device_id;
let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`;
if (d.rssi != null) label += ` · ${d.rssi} dBm`;
try {
@@ -2278,6 +2390,7 @@
if (m.packet != null) label += ` #${m.packet}`;
}
} catch (e) {}
li.dataset.deviceId = d.device_id;
li.textContent = label;
li.className = d.device_id === selectedId ? 'active' : '';
li.onclick = () => selectDevice(d);
@@ -2328,7 +2441,7 @@
setCmdFormDirty(false);
fillCmdFormFromDevice(d, { force: true });
document.querySelectorAll('#deviceList li').forEach(li => {
li.classList.toggle('active', li.textContent.startsWith(d.device_id));
li.classList.toggle('active', li.dataset.deviceId === d.device_id);
});
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
setMapViewProgrammatically(() => {
@@ -2378,7 +2491,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(m.device_id);
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(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) {
@@ -2462,7 +2575,7 @@
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
let label = d.device_id;
let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`;
opt.textContent = label;
sel.appendChild(opt);