fix elevation

This commit is contained in:
2026-06-16 11:54:41 +03:00
parent 0e1fa15a2f
commit 6b34e75f35
4 changed files with 172 additions and 33 deletions
@@ -772,8 +772,21 @@ public class MapFragment extends Fragment {
private static int qualityArgb(double pct) { private static int qualityArgb(double pct) {
double p = Math.max(0.0, Math.min(100.0, pct)); double p = Math.max(0.0, Math.min(100.0, pct));
int r = p < 50.0 ? (int) Math.round(255.0 * (p / 50.0)) : 255; int r;
int g = p < 50.0 ? 255 : (int) Math.round(255.0 * (1.0 - (p - 50.0) / 50.0)); int g;
if (p < 40.0) {
double t = p / 40.0;
r = 255;
g = (int) Math.round(140.0 * t);
} else if (p < 85.0) {
double t = (p - 40.0) / 45.0;
r = 255;
g = (int) Math.round(140.0 + 115.0 * t);
} else {
double t = (p - 85.0) / 15.0;
r = (int) Math.round(255.0 * (1.0 - t));
g = 255;
}
return 0xFF000000 | (r << 16) | (g << 8); return 0xFF000000 | (r << 16) | (g << 8);
} }
+1 -1
View File
@@ -379,7 +379,7 @@ def health():
return { return {
"ok": status["db_ok"], "ok": status["db_ok"],
"ts": time.time(), "ts": time.time(),
"api_build": "2026-06-16d", "api_build": "2026-06-16e",
**status, **status,
**elevation_status(), **elevation_status(),
} }
+152 -28
View File
@@ -361,7 +361,7 @@
{ position: 'topright', collapsed: true } { position: 'topright', collapsed: true }
).addTo(map); ).addTo(map);
const API_BUILD = '2026-06-16d'; const API_BUILD = '2026-06-16e';
const markers = {}; const markers = {};
let selectedId = null; let selectedId = null;
@@ -406,6 +406,8 @@
let elevProfileTx = null; let elevProfileTx = null;
let elevProfileRx = null; let elevProfileRx = null;
let elevProfileSingle = null; let elevProfileSingle = null;
let elevProfileLink = null;
let elevProfileLinkKey = null;
let elevProfileMapLine = null; let elevProfileMapLine = null;
let elevationLoadState = 'idle'; let elevationLoadState = 'idle';
let mapRulerOpen = false; let mapRulerOpen = false;
@@ -621,6 +623,11 @@
}; };
} }
function mergeTelCoords(tel, pos) {
if (!tel || !pos) return tel;
return { ...tel, lat: pos.lat, lon: pos.lon };
}
function snapAtTime(track, telemetryRows, t, roleFallback) { function snapAtTime(track, telemetryRows, t, roleFallback) {
const pos = positionAt(track?.points, t); const pos = positionAt(track?.points, t);
if (pos?.meta && String(pos.meta).length > 2) { if (pos?.meta && String(pos.meta).length > 2) {
@@ -640,8 +647,21 @@
function qualityColor(pct) { function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null; if (pct == null || Number.isNaN(pct)) return null;
const p = Math.max(0, Math.min(100, Number(pct))); const p = Math.max(0, Math.min(100, Number(pct)));
const r = p < 50 ? Math.round(255 * (p / 50)) : 255; let r;
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50)); let g;
if (p < 40) {
const t = p / 40;
r = 255;
g = Math.round(140 * t);
} else if (p < 85) {
const t = (p - 40) / 45;
r = 255;
g = Math.round(140 + 115 * t);
} else {
const t = (p - 85) / 15;
r = Math.round(255 * (1 - t));
g = 255;
}
return `rgb(${r},${g},0)`; return `rgb(${r},${g},0)`;
} }
@@ -747,9 +767,50 @@
return html; return html;
} }
function formatCoords(tel) {
if (!tel || tel.lat == null || tel.lon == null || isNullIsland(tel.lat, tel.lon)) return null;
return `${Number(tel.lat).toFixed(5)}, ${Number(tel.lon).toFixed(5)}`;
}
function formatPacketTime(tel) {
if (!tel) return null;
let ts = tel.ts;
if (tel.meta) {
let o = tel.meta;
if (typeof o === 'string') {
try { o = JSON.parse(o); } catch (e) { o = null; }
}
if (o) {
if (o.packet_ts != null) ts = o.packet_ts;
else if (o.ts != null) ts = o.ts;
else if (o.fields) {
for (const [k, v] of Object.entries(o.fields)) {
if (/time/i.test(k)) return String(v);
}
}
}
}
if (ts == null || !Number.isFinite(Number(ts)) || Number(ts) <= 1) return null;
const n = Number(ts);
const ms = n < 1e12 ? n * 1000 : n;
return new Date(ms).toLocaleTimeString();
}
function enrichSnapFromTel(snap, tel) {
snap.gps = formatCoords(tel) || '—';
snap.packetTime = formatPacketTime(tel) || '—';
return snap;
}
function renderTimelineCompare(txTel, rxTel, txId, rxId) { function renderTimelineCompare(txTel, rxTel, txId, rxId) {
const txSnap = txTel ? telemetryToSnap(txTel) : RadioUI.parseRadioSnapshot(null); const txSnap = enrichSnapFromTel(
const rxSnap = rxTel ? telemetryToSnap(rxTel) : RadioUI.parseRadioSnapshot(null); txTel ? telemetryToSnap(txTel) : RadioUI.parseRadioSnapshot(null),
txTel
);
const rxSnap = enrichSnapFromTel(
rxTel ? telemetryToSnap(rxTel) : RadioUI.parseRadioSnapshot(null),
rxTel
);
const chTx = RadioUI.diffSnapshots(prevTimelineTxSnap, txSnap); const chTx = RadioUI.diffSnapshots(prevTimelineTxSnap, txSnap);
const chRx = RadioUI.diffSnapshots(prevTimelineRxSnap, rxSnap); const chRx = RadioUI.diffSnapshots(prevTimelineRxSnap, rxSnap);
prevTimelineTxSnap = txSnap; prevTimelineTxSnap = txSnap;
@@ -1435,13 +1496,27 @@
function getTimelineElevationSeries(cursors) { function getTimelineElevationSeries(cursors) {
const series = []; const series = [];
if (dualTracksActive) { if (dualTracksActive) {
if (elevationPointCount(elevProfileLink) > 0) {
const pts = elevProfileLink.points.filter(p => p.elevation_m != null);
const elevA = pts[0]?.elevation_m;
const elevB = pts[pts.length - 1]?.elevation_m;
series.push({
color: '#00ff88',
profile: elevProfileLink,
label: 'рельеф TX↔RX',
losLine: elevA != null && elevB != null ? { elevA, elevB } : null,
});
return series;
}
if (elevationPointCount(elevProfileTx) > 0) { if (elevationPointCount(elevProfileTx) > 0) {
series.push({ color: TX_COLOR, profile: elevProfileTx, cursor: cursors?.tx, label: 'TX' }); series.push({ color: TX_COLOR, profile: elevProfileTx, cursor: cursors?.tx, label: 'TX' });
} }
if (elevationPointCount(elevProfileRx) > 0) { if (elevationPointCount(elevProfileRx) > 0) {
series.push({ color: RX_COLOR, profile: elevProfileRx, cursor: cursors?.rx, label: 'RX' }); series.push({ color: RX_COLOR, profile: elevProfileRx, cursor: cursors?.rx, label: 'RX' });
} }
} else if (singleTrackActive && elevationPointCount(elevProfileSingle) > 0) { return series;
}
if (singleTrackActive && elevationPointCount(elevProfileSingle) > 0) {
const color = loadedSingleTrack?.role === 'RX' ? RX_COLOR : TX_COLOR; const color = loadedSingleTrack?.role === 'RX' ? RX_COLOR : TX_COLOR;
const label = loadedSingleTrack?.role === 'RX' ? 'RX' : 'TX'; const label = loadedSingleTrack?.role === 'RX' ? 'RX' : 'TX';
series.push({ color, profile: elevProfileSingle, cursor: cursors?.single, label }); series.push({ color, profile: elevProfileSingle, cursor: cursors?.single, label });
@@ -1532,6 +1607,23 @@
else ctx.lineTo(x, y); else ctx.lineTo(x, y);
}); });
ctx.stroke(); ctx.stroke();
if (s.losLine && pts.length >= 2) {
const x0 = margin.l;
const x1 = margin.l + plotW;
const y0 = margin.t + plotH - ((s.losLine.elevA - minE) / (maxE - minE)) * plotH;
const y1 = margin.t + plotH - ((s.losLine.elevB - minE) / (maxE - minE)) * plotH;
ctx.strokeStyle = 'rgba(255, 136, 0, 0.95)';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]);
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#ffb74d';
ctx.font = '9px system-ui';
ctx.fillText('прямая', x1 - 42, y1 - 4);
}
if (s.cursor != null && maxDist > 0) { if (s.cursor != null && maxDist > 0) {
const cx = margin.l + (s.cursor / maxDist) * plotW; const cx = margin.l + (s.cursor / maxDist) * plotW;
const elev = elevationAtDist(s.profile, s.cursor); const elev = elevationAtDist(s.profile, s.cursor);
@@ -1649,10 +1741,32 @@
drawMapRulerChart(); drawMapRulerChart();
} }
async function scheduleLinkElevation(txPos, rxPos) {
if (!txPos || !rxPos) return;
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
const key = `${txPos.lat.toFixed(5)},${txPos.lon.toFixed(5)}|${rxPos.lat.toFixed(5)},${rxPos.lon.toFixed(5)}`;
if (key === elevProfileLinkKey && elevProfileLink) return;
elevProfileLinkKey = key;
const linePts = [{ lat: txPos.lat, lon: txPos.lon }, { lat: rxPos.lat, lon: rxPos.lon }];
const profile = await fetchElevationProfile(linePts, null, {
targetPoints: getMapRulerTargetPoints(dist),
});
if (elevProfileLinkKey !== key) return;
elevProfileLink = profile;
const n = elevationPointCount(profile);
if (n > 0) {
const src = profile.source === 'elevation' ? 'высоты'
: profile.source === 'server' ? 'сервер' : (profile.source || 'данные');
setElevationStatus(`срез TX↔RX · ${dist.toFixed(0)} m · ${src} · ${n} точек · оранжевая — прямая`);
}
}
async function loadElevationProfiles() { async function loadElevationProfiles() {
elevProfileTx = null; elevProfileTx = null;
elevProfileRx = null; elevProfileRx = null;
elevProfileSingle = null; elevProfileSingle = null;
elevProfileLink = null;
elevProfileLinkKey = null;
elevationLoadState = 'loading'; elevationLoadState = 'loading';
setElevationStatus('загрузка…'); setElevationStatus('загрузка…');
drawElevationChart(); drawElevationChart();
@@ -1661,25 +1775,26 @@
elevProfileSingle = await fetchElevationProfile( elevProfileSingle = await fetchElevationProfile(
loadedSingleTrack.points, loadedSingleTrack.id); loadedSingleTrack.points, loadedSingleTrack.id);
} else if (dualTracksActive) { } else if (dualTracksActive) {
const [txProf, rxProf] = await Promise.all([ const txPos = positionAtCursor(loadedTxTrack?.points, timelineCursor());
loadedTxTrack?.points?.length const rxPos = positionAtCursor(loadedRxTrack?.points, timelineCursor());
? fetchElevationProfile(loadedTxTrack.points, loadedTxTrack.id) : null, if (txPos && rxPos) {
loadedRxTrack?.points?.length await scheduleLinkElevation(txPos, rxPos);
? fetchElevationProfile(loadedRxTrack.points, loadedRxTrack.id) : null }
]);
elevProfileTx = txProf;
elevProfileRx = rxProf;
} }
elevationLoadState = 'done'; elevationLoadState = 'done';
const hasData = elevationPointCount(elevProfileSingle) > 0 const hasData = elevationPointCount(elevProfileSingle) > 0
|| elevationPointCount(elevProfileLink) > 0
|| elevationPointCount(elevProfileTx) > 0 || elevationPointCount(elevProfileTx) > 0
|| elevationPointCount(elevProfileRx) > 0; || elevationPointCount(elevProfileRx) > 0;
if (hasData) { if (hasData) {
const ref = elevProfileSingle || elevProfileTx || elevProfileRx; const ref = elevProfileSingle || elevProfileLink || elevProfileTx || elevProfileRx;
const srcLabel = ref?.source === 'elevation' ? 'высоты' const srcLabel = ref?.source === 'elevation' ? 'высоты'
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные'); : ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
if (dualTracksActive && elevProfileTx && elevProfileRx) { if (dualTracksActive && elevProfileLink) {
const n = elevationPointCount(elevProfileLink);
setElevationStatus(`срез TX↔RX · ${srcLabel} · ${n} точек · оранжевая — прямая`);
} else if (dualTracksActive && elevProfileTx && elevProfileRx) {
const nTx = elevationPointCount(elevProfileTx); const nTx = elevationPointCount(elevProfileTx);
const nRx = elevationPointCount(elevProfileRx); const nRx = elevationPointCount(elevProfileRx);
setElevationStatus(`TX + RX · ${srcLabel} · ${nTx}/${nRx} точек`); setElevationStatus(`TX + RX · ${srcLabel} · ${nTx}/${nRx} точек`);
@@ -1687,7 +1802,8 @@
setElevationStatus(`${srcLabel} · ${elevationPointCount(ref)} точек`); setElevationStatus(`${srcLabel} · ${elevationPointCount(ref)} точек`);
} }
} else { } else {
const err = elevProfileSingle?.api_error || elevProfileTx?.api_error || elevProfileRx?.api_error; const err = elevProfileSingle?.api_error || elevProfileLink?.api_error
|| elevProfileTx?.api_error || elevProfileRx?.api_error;
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных'); setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
} }
drawElevationChart(); drawElevationChart();
@@ -1976,6 +2092,8 @@
elevProfileTx = null; elevProfileTx = null;
elevProfileRx = null; elevProfileRx = null;
elevProfileSingle = null; elevProfileSingle = null;
elevProfileLink = null;
elevProfileLinkKey = null;
drawElevationChart(); drawElevationChart();
if (playTimer) { if (playTimer) {
clearInterval(playTimer); clearInterval(playTimer);
@@ -2039,12 +2157,17 @@
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon); const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${formatTimelineClock(cursor)}</b><br>`; let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><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 = mergeTelCoords(snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'), txPos);
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'); const rxTel = mergeTelCoords(snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'), rxPos);
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
html += renderTimelineCompare( html += renderTimelineCompare(
txTel || { meta: txPos.meta, role: 'TX', rssi: null }, txTel || mergeTelCoords({
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null }, meta: txPos.meta, role: 'TX', rssi: null,
ts: timelineUseProgress ? null : cursor.t
}, txPos),
rxTel || mergeTelCoords({
meta: rxPos.meta, role: 'RX', rssi: null,
ts: timelineUseProgress ? null : cursor.t
}, rxPos),
deviceDisplayName(loadedTxTrack?.device_id), deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id) deviceDisplayName(loadedRxTrack?.device_id)
); );
@@ -2098,8 +2221,8 @@
} }
} }
const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'); const txTel = mergeTelCoords(snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'), txPos);
const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'); const rxTel = mergeTelCoords(snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'), rxPos);
const timelineStatsEl = document.getElementById('timelineStats'); const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare( setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel, txTel,
@@ -2107,10 +2230,11 @@
deviceDisplayName(loadedTxTrack?.device_id), deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id) deviceDisplayName(loadedRxTrack?.device_id)
)); ));
drawElevationChart({ if (txPos && rxPos) {
tx: trackDistanceAtCursor(loadedTxTrack, cursor), scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart());
rx: trackDistanceAtCursor(loadedRxTrack, cursor) } else {
}); drawElevationChart();
}
} }
function updateTimelineAtSingle(cursor, openModal) { function updateTimelineAtSingle(cursor, openModal) {
+4 -2
View File
@@ -77,9 +77,9 @@
function diffSnapshots(a, b) { function diffSnapshots(a, b) {
const changed = new Set(); const changed = new Set();
if (!a || !b) return changed; if (!a || !b) return changed;
const keys = ['role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent', const keys = ['gps', 'packetTime', 'role', 'rssiDbm', 'snrDb', 'rxQualityPercent', 'packet', 'payload', 'perPercent',
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm']; 'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality', const map = { gps: 'gps', packetTime: 'packetTime', role: 'role', rssiDbm: 'rssi', snrDb: 'snr', rxQualityPercent: 'rxQuality',
packet: 'packet', packet: 'packet',
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed', payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' }; frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' };
@@ -90,6 +90,8 @@
} }
const DYNAMIC_ROWS = [ const DYNAMIC_ROWS = [
{ key: 'gps', label: 'GPS', fmt: s => s.gps || '—' },
{ key: 'packetTime', label: 'Время пакета', fmt: s => s.packetTime || '—' },
{ key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' }, { key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' },
{ key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' }, { key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' }, { key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },