diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java
index 2341030..a5d1f3e 100644
--- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java
+++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java
@@ -772,8 +772,21 @@ public class MapFragment extends Fragment {
private static int qualityArgb(double 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 g = p < 50.0 ? 255 : (int) Math.round(255.0 * (1.0 - (p - 50.0) / 50.0));
+ int r;
+ 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);
}
diff --git a/server/fastapi_app.py b/server/fastapi_app.py
index ba73e8c..45e01c8 100644
--- a/server/fastapi_app.py
+++ b/server/fastapi_app.py
@@ -379,7 +379,7 @@ def health():
return {
"ok": status["db_ok"],
"ts": time.time(),
- "api_build": "2026-06-16d",
+ "api_build": "2026-06-16e",
**status,
**elevation_status(),
}
diff --git a/server/static/index.html b/server/static/index.html
index c210b9e..b9b1a85 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -361,7 +361,7 @@
{ position: 'topright', collapsed: true }
).addTo(map);
- const API_BUILD = '2026-06-16d';
+ const API_BUILD = '2026-06-16e';
const markers = {};
let selectedId = null;
@@ -406,6 +406,8 @@
let elevProfileTx = null;
let elevProfileRx = null;
let elevProfileSingle = null;
+ let elevProfileLink = null;
+ let elevProfileLinkKey = null;
let elevProfileMapLine = null;
let elevationLoadState = 'idle';
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) {
const pos = positionAt(track?.points, t);
if (pos?.meta && String(pos.meta).length > 2) {
@@ -640,8 +647,21 @@
function qualityColor(pct) {
if (pct == null || Number.isNaN(pct)) return null;
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));
+ let r;
+ 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)`;
}
@@ -747,9 +767,50 @@
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) {
- const txSnap = txTel ? telemetryToSnap(txTel) : RadioUI.parseRadioSnapshot(null);
- const rxSnap = rxTel ? telemetryToSnap(rxTel) : RadioUI.parseRadioSnapshot(null);
+ const txSnap = enrichSnapFromTel(
+ txTel ? telemetryToSnap(txTel) : RadioUI.parseRadioSnapshot(null),
+ txTel
+ );
+ const rxSnap = enrichSnapFromTel(
+ rxTel ? telemetryToSnap(rxTel) : RadioUI.parseRadioSnapshot(null),
+ rxTel
+ );
const chTx = RadioUI.diffSnapshots(prevTimelineTxSnap, txSnap);
const chRx = RadioUI.diffSnapshots(prevTimelineRxSnap, rxSnap);
prevTimelineTxSnap = txSnap;
@@ -1435,13 +1496,27 @@
function getTimelineElevationSeries(cursors) {
const series = [];
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) {
series.push({ color: TX_COLOR, profile: elevProfileTx, cursor: cursors?.tx, label: 'TX' });
}
if (elevationPointCount(elevProfileRx) > 0) {
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 label = loadedSingleTrack?.role === 'RX' ? 'RX' : 'TX';
series.push({ color, profile: elevProfileSingle, cursor: cursors?.single, label });
@@ -1532,6 +1607,23 @@
else ctx.lineTo(x, y);
});
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) {
const cx = margin.l + (s.cursor / maxDist) * plotW;
const elev = elevationAtDist(s.profile, s.cursor);
@@ -1649,10 +1741,32 @@
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() {
elevProfileTx = null;
elevProfileRx = null;
elevProfileSingle = null;
+ elevProfileLink = null;
+ elevProfileLinkKey = null;
elevationLoadState = 'loading';
setElevationStatus('загрузка…');
drawElevationChart();
@@ -1661,25 +1775,26 @@
elevProfileSingle = await fetchElevationProfile(
loadedSingleTrack.points, loadedSingleTrack.id);
} else if (dualTracksActive) {
- const [txProf, rxProf] = await Promise.all([
- loadedTxTrack?.points?.length
- ? fetchElevationProfile(loadedTxTrack.points, loadedTxTrack.id) : null,
- loadedRxTrack?.points?.length
- ? fetchElevationProfile(loadedRxTrack.points, loadedRxTrack.id) : null
- ]);
- elevProfileTx = txProf;
- elevProfileRx = rxProf;
+ const txPos = positionAtCursor(loadedTxTrack?.points, timelineCursor());
+ const rxPos = positionAtCursor(loadedRxTrack?.points, timelineCursor());
+ if (txPos && rxPos) {
+ await scheduleLinkElevation(txPos, rxPos);
+ }
}
elevationLoadState = 'done';
const hasData = elevationPointCount(elevProfileSingle) > 0
+ || elevationPointCount(elevProfileLink) > 0
|| elevationPointCount(elevProfileTx) > 0
|| elevationPointCount(elevProfileRx) > 0;
if (hasData) {
- const ref = elevProfileSingle || elevProfileTx || elevProfileRx;
+ const ref = elevProfileSingle || elevProfileLink || elevProfileTx || elevProfileRx;
const srcLabel = ref?.source === 'elevation' ? 'высоты'
: 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 nRx = elevationPointCount(elevProfileRx);
setElevationStatus(`TX + RX · ${srcLabel} · ${nTx}/${nRx} точек`);
@@ -1687,7 +1802,8 @@
setElevationStatus(`${srcLabel} · ${elevationPointCount(ref)} точек`);
}
} 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}` : 'нет данных');
}
drawElevationChart();
@@ -1976,6 +2092,8 @@
elevProfileTx = null;
elevProfileRx = null;
elevProfileSingle = null;
+ elevProfileLink = null;
+ elevProfileLinkKey = null;
drawElevationChart();
if (playTimer) {
clearInterval(playTimer);
@@ -2039,12 +2157,17 @@
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `${formatTimelineClock(cursor)}
`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)
`;
- html += `TX ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}
`;
- const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
- const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
+ const txTel = mergeTelCoords(snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'), txPos);
+ const rxTel = mergeTelCoords(snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'), rxPos);
html += renderTimelineCompare(
- txTel || { meta: txPos.meta, role: 'TX', rssi: null },
- rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
+ txTel || mergeTelCoords({
+ 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(loadedRxTrack?.device_id)
);
@@ -2098,8 +2221,8 @@
}
}
- const txTel = snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX');
- const rxTel = snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX');
+ const txTel = mergeTelCoords(snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'), txPos);
+ const rxTel = mergeTelCoords(snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'), rxPos);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel,
@@ -2107,10 +2230,11 @@
deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id)
));
- drawElevationChart({
- tx: trackDistanceAtCursor(loadedTxTrack, cursor),
- rx: trackDistanceAtCursor(loadedRxTrack, cursor)
- });
+ if (txPos && rxPos) {
+ scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart());
+ } else {
+ drawElevationChart();
+ }
}
function updateTimelineAtSingle(cursor, openModal) {
diff --git a/server/static/radio-ui.js b/server/static/radio-ui.js
index 3bdeea6..c0194ec 100644
--- a/server/static/radio-ui.js
+++ b/server/static/radio-ui.js
@@ -77,9 +77,9 @@
function diffSnapshots(a, b) {
const changed = new Set();
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'];
- 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',
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' };
@@ -90,6 +90,8 @@
}
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: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
{ key: 'rxQuality', label: 'RX Quality', fmt: s => s.rxQualityPercent != null ? `${s.rxQualityPercent} %` : '—' },