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} %` : '—' },