Files
LoraMapTester/server/static/index.html
T
2026-06-16 12:04:23 +03:00

3099 lines
120 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LoraTester</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; }
header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 1.2rem; flex: 1; }
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr auto; height: calc(100vh - 52px); }
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
grid-template-rows: minmax(200px, 38vh) auto minmax(160px, 1fr);
overflow-y: auto;
}
#mapWrap { grid-row: 1; }
#trackTimeline { grid-row: 2; }
aside { grid-row: 3; }
}
#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;
}
#trackTimeline.visible { display: block; min-height: 200px; flex-shrink: 0; }
.timeline-bar { display: flex; flex-direction: column; gap: 6px; }
.timeline-bar-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.timeline-bar-title { font-size: 0.85rem; font-weight: 600; }
.timeline-bar .timeline-labels { width: 100%; margin: 0; }
.timeline-bar #timeSlider { width: 100%; margin: 0; }
#elevationPanel {
margin-top: 8px; padding: 8px 10px;
background: #0f3460; border: 1px solid #00ff8844; border-radius: 6px;
}
#elevationPanelTitle {
font-size: 0.8rem; color: #00ff88; margin-bottom: 6px;
display: flex; justify-content: space-between; align-items: center; font-weight: 600;
}
#elevationStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; }
#elevationCanvas { width: 100%; height: 130px; display: block; background: #0a0a14; border-radius: 4px; }
.elev-legend { font-size: 0.7rem; }
#timelineStatsPanel {
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
}
#timelineStatsPanel.visible { display: block; }
#timelineStatsPanel.timeline-single #distanceNow { display: none; }
#timelineNote { font-size: 0.75rem; color: #aaa; margin: 4px 0 8px; }
#timeSlider { width: 100%; margin: 6px 0; }
.timeline-labels { display: flex; justify-content: space-between; font-size: 0.75rem; color: #aaa; }
#timelineStats { margin-top: 8px; font-size: 0.8rem; }
.radio-compare-grid { background: #0a0a14; padding: 8px; border-radius: 4px; }
.radio-compare-head { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 6px; font-weight: 600; }
.radio-row { display: grid; grid-template-columns: 72px 1fr 1fr; gap: 6px; padding: 2px 0; }
.radio-label { color: #aaa; }
.radio-tx { color: #e94560; min-width: 0; word-break: break-all; }
.radio-rx { color: #4fc3f7; min-width: 0; word-break: break-all; }
.radio-row .changed, .changed { background: #e9456033; border-radius: 3px; padding: 0 2px; }
.radio-static summary { cursor: pointer; color: #aaa; margin: 4px 0; }
#stats .changed { background: #e9456033; border-radius: 3px; }
#distanceNow { font-size: 0.9rem; margin-top: 4px; color: #00ff88; }
aside {
grid-column: 2; grid-row: 1;
overflow: auto; padding: 12px; border-left: 1px solid #333;
display: flex; flex-direction: column; gap: 12px;
}
@media (max-width: 900px) {
aside { grid-column: 1; grid-row: 2; border-left: none; border-top: 1px solid #333; }
}
.panel { background: #0f3460; border-radius: 8px; padding: 10px; }
.panel h2 { margin: 0 0 8px; font-size: 0.95rem; }
#deviceList { list-style: none; padding: 0; margin: 0; max-height: 140px; overflow: auto; }
#deviceList li { padding: 6px 8px; cursor: pointer; border-radius: 4px; font-size: 0.85rem; }
#deviceList li:hover, #deviceList li.active { background: #e94560; }
#stats { font-size: 0.85rem; line-height: 1.5; }
#history { font-size: 0.75rem; max-height: 100px; overflow: auto; }
#chatLog { height: 140px; overflow: auto; font-size: 0.8rem; background: #0a0a14; padding: 8px; border-radius: 4px; display: flex; flex-direction: column; gap: 4px; }
.chat-msg { max-width: 85%; padding: 6px 8px; border-radius: 8px; line-height: 1.35; }
.chat-self { align-self: flex-end; background: #16213e; text-align: right; }
.chat-other { align-self: flex-start; background: #1a4a6e; }
.chat-new { box-shadow: inset 0 0 0 1px #e94560; }
.chat-meta { font-size: 0.7rem; color: #aaa; margin-bottom: 2px; }
#chatForm { display: flex; gap: 6px; margin-top: 6px; }
#chatForm input { flex: 1; padding: 6px; border: none; border-radius: 4px; }
#chatForm button { padding: 6px 12px; background: #e94560; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.track-row { margin-bottom: 6px; }
.track-row label { font-size: 0.75rem; color: #aaa; display: block; margin-bottom: 2px; }
.track-row select { width: 100%; padding: 4px; }
.track-actions { display: flex; gap: 6px; margin-top: 4px; }
.track-actions button { flex: 1; padding: 6px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.8rem; }
#btnShowTracks { background: #00ff88; color: #111; }
#btnHideTracks { background: #333; color: #eee; }
#btnHideTracks:disabled { opacity: 0.4; cursor: not-allowed; }
.track-mode { display: flex; gap: 4px; margin-bottom: 8px; }
.track-mode button { flex: 1; padding: 4px; font-size: 0.75rem; border: 1px solid #444; background: #0a0a14; color: #ccc; border-radius: 4px; cursor: pointer; }
.track-mode button.active { background: #e94560; color: #fff; border-color: #e94560; }
#controlPanel input, #controlPanel select { width: 100%; padding: 4px; margin-top: 2px; border-radius: 4px; border: none; font-size: 0.8rem; }
#controlPanel .cmd-row { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
#controlPanel .cmd-row button { padding: 4px 8px; font-size: 0.75rem; border: none; border-radius: 4px; cursor: pointer; background: #16213e; color: #eee; }
#pairedStatus { font-size: 0.75rem; color: #aaa; margin-top: 4px; }
.muted { color: #aaa; font-size: 0.75rem; }
.legend { font-size: 0.75rem; color: #ccc; }
.legend-tx { color: #e94560; }
.legend-rx { color: #4fc3f7; }
#mapModal {
display: none; position: fixed; z-index: 2000;
min-width: 260px; max-width: 360px; max-height: 70vh; overflow: auto;
background: #0f3460; border: 1px solid #444; border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
#mapModal.open { display: block; }
#mapModalHeader {
padding: 8px 10px; background: #16213e; cursor: move;
display: flex; justify-content: space-between; align-items: center;
user-select: none; border-radius: 8px 8px 0 0;
}
#mapModalHeader span { font-size: 0.85rem; font-weight: 600; }
#mapModalClose { background: none; border: none; color: #eee; font-size: 1.2rem; cursor: pointer; padding: 0 4px; }
#mapModalBody { padding: 10px; font-size: 0.85rem; line-height: 1.45; }
#mapCenterBar {
position: absolute; top: 10px; right: 10px; left: auto; z-index: 1000;
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;
box-shadow: 0 1px 4px rgba(0,0,0,0.35);
}
#mapCenterBar button:hover { background: #1f3460; }
#mapCenterBar button.active { background: #00ff88; color: #111; border-color: #00ff88; }
.leaflet-top.leaflet-left { margin-top: 0; margin-left: 0; }
#gpsDistance { color: #00ff88; font-size: 0.8rem; }
#mapRulerPanel {
display: none; position: absolute; left: 8px; right: 8px; bottom: 8px; z-index: 1000;
background: #16213ef0; border: 1px solid #00ff8866; border-radius: 8px;
padding: 8px 10px; pointer-events: auto;
}
#mapRulerPanel.open { display: block; }
#mapRulerHead {
display: flex; justify-content: space-between; align-items: center;
font-size: 0.8rem; color: #00ff88; font-weight: 600; margin-bottom: 6px;
}
#mapRulerStatus { font-size: 0.7rem; color: #aaa; font-weight: 400; }
#btnMapRulerClose {
background: none; border: none; color: #eee; font-size: 1.1rem;
cursor: pointer; padding: 0 4px; line-height: 1;
}
#mapRulerCanvas { width: 100%; height: 120px; display: block; background: #0a0a14; border-radius: 4px; cursor: crosshair; }
#mapRulerTools { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
#mapRulerTools button {
padding: 3px 8px; font-size: 0.7rem; border: 1px solid #444; border-radius: 4px;
background: #0a0a14; color: #ccc; cursor: pointer;
}
#mapRulerTools button.active { background: #00ff88; color: #111; border-color: #00ff88; }
#mapRulerHint { font-size: 0.7rem; color: #aaa; margin-bottom: 4px; min-height: 1em; }
#mapRulerPointsRow {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap;
font-size: 0.7rem; color: #ccc;
}
#mapRulerPointsRow label { display: flex; align-items: center; gap: 4px; cursor: pointer; white-space: nowrap; }
#mapRulerPointsSlider { flex: 1; min-width: 120px; accent-color: #00ff88; }
#mapRulerPointsSlider:disabled { opacity: 0.45; }
#mapRulerPointsLabel { min-width: 7em; color: #aaa; white-space: nowrap; }
</style>
</head>
<body>
<header>
<h1>LoraTester</h1>
<span class="muted" id="status">загрузка…</span>
<span class="muted" id="pollStatus" title="Автообновление">⟳ 1 с</span>
<span class="legend"><span class="legend-tx">● TX</span> &nbsp; <span class="legend-rx">● RX</span></span>
<span id="gpsDistance" class="muted">GPS: —</span>
<input type="text" id="webDeviceId" placeholder="ник в чате" style="padding:6px;border-radius:4px;border:none;max-width:160px" />
</header>
<main>
<div id="mapWrap">
<div id="mapCenterBar">
<button type="button" id="btnMapTx">TX</button>
<button type="button" id="btnMapRx">RX</button>
<button type="button" id="btnMapBoth">Оба</button>
<button type="button" id="btnMapRuler">Линейка</button>
</div>
<div id="mapRulerPanel">
<div id="mapRulerHead">
<span>Линейка высот <span id="mapRulerStatus"></span></span>
<button type="button" id="btnMapRulerClose" aria-label="Закрыть">×</button>
</div>
<div id="mapRulerTools">
<button type="button" id="btnRulerPick" class="active">Точки</button>
<button type="button" id="btnRulerAutoTxRx">TX→RX</button>
<button type="button" id="btnRulerClear">Сброс</button>
</div>
<div id="mapRulerHint">Клик на карте — точка A, затем точка B</div>
<div id="mapRulerPointsRow">
<label><input type="checkbox" id="mapRulerPointsAuto" checked /> Авто</label>
<input type="range" id="mapRulerPointsSlider" min="20" max="500" value="100" disabled />
<span id="mapRulerPointsLabel">100 точек</span>
</div>
<canvas id="mapRulerCanvas" width="800" height="120"></canvas>
</div>
<div id="map"></div>
</div>
<aside>
<div class="panel" style="flex:1;display:flex;flex-direction:column">
<h2>Чат</h2>
<div id="chatLog"></div>
<form id="chatForm">
<input type="text" id="chatInput" placeholder="Сообщение…" autocomplete="off" />
<button type="submit"></button>
</form>
</div>
<div class="panel">
<h2>Устройства</h2>
<ul id="deviceList"></ul>
</div>
<div class="panel">
<h2>Статистика</h2>
<div id="stats">Выберите устройство</div>
<div id="history" class="muted" style="margin-top:8px"></div>
</div>
<div class="panel" id="controlPanel">
<h2>Управление</h2>
<label class="muted">Целевое устройство</label>
<select id="cmdTargetSelect"><option value=""></option></select>
<div id="cmdCurrentValues" class="muted" style="margin-top:4px;font-size:0.75rem"></div>
<div id="cmdDraftHint" class="muted" style="display:none;margin-top:4px;font-size:0.7rem;color:#ffc107">Черновик — значения с устройства не подставляются</div>
<div class="cmd-row" style="margin-top:6px">
<label class="muted" style="flex:1">FQ MHz<input type="number" id="cmdFq" step="0.001" placeholder="433" /></label>
<label class="muted" style="flex:1">PW dBm<input type="number" id="cmdPw" min="-9" max="22" placeholder="22" /></label>
</div>
<div class="cmd-row">
<label class="muted" style="flex:1">SF<input type="number" id="cmdSf" min="5" max="12" placeholder="7" /></label>
<label class="muted" style="flex:1">PL<input type="number" id="cmdPl" min="1" max="64" placeholder="8" /></label>
</div>
<label class="muted">BW kHz</label>
<select id="cmdBw">
<option value=""></option>
<option>7.81</option><option>10.42</option><option>15.63</option><option>20.83</option>
<option>31.25</option><option>41.67</option><option>62.5</option>
<option selected>125</option><option>250</option><option>500</option>
</select>
<label class="muted">CR</label>
<select id="cmdCr">
<option value=""></option><option>4/5</option><option>4/6</option><option>4/7</option><option>4/8</option>
</select>
<label class="muted">TM ms</label>
<input type="number" id="cmdTm" min="0" max="60000" placeholder="0" />
<label class="muted">Роль</label>
<select id="cmdRole"><option value=""></option><option value="TX">TX</option><option value="RX">RX</option></select>
<button type="button" id="btnCmdApply" style="width:100%;margin-top:6px;padding:6px;background:#e94560;color:#fff;border:none;border-radius:4px;font-weight:600;cursor:pointer">Применить</button>
<button type="button" id="btnPairedStart" style="width:100%;margin-top:8px;padding:6px;background:#00ff88;color:#111;border:none;border-radius:4px;font-weight:600;cursor:pointer">Старт трека (оба)</button>
<div id="pairedStatus"></div>
</div>
<div class="panel">
<h2>Треки</h2>
<div class="track-mode">
<button type="button" id="btnModeSingle" class="active">Один трек</button>
<button type="button" id="btnModeDual">Сравнение TX+RX</button>
</div>
<div id="trackPanelSingle">
<div class="track-row">
<label>Трек</label>
<select id="trackSingleSelect"><option value=""></option></select>
</div>
</div>
<div id="trackPanelDual" style="display:none">
<div class="track-row">
<label class="legend-tx">Трек TX</label>
<select id="trackTxSelect"><option value=""></option></select>
</div>
<div class="track-row">
<label class="legend-rx">Трек RX</label>
<select id="trackRxSelect"><option value=""></option></select>
</div>
</div>
<div class="track-actions">
<button type="button" id="btnShowTracks">Показать на карте</button>
<button type="button" id="btnHideTracks" disabled>Скрыть треки</button>
</div>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите трек</div>
<div id="timelineStatsPanel">
<h3 style="margin:0 0 6px;font-size:0.9rem">Статистика по времени</h3>
<div id="timelineNote"></div>
<div id="distanceNow">Расстояние GPS: —</div>
<div id="timelineStats"></div>
</div>
</div>
</aside>
<div id="trackTimeline">
<div class="timeline-bar">
<div class="timeline-bar-header">
<span class="timeline-bar-title">Время теста</span>
<button type="button" id="btnPlay" class="muted" style="padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<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>
<span class="elev-legend"><span class="legend-tx">TX</span> <span class="legend-rx">RX</span></span>
</div>
<canvas id="elevationCanvas" width="800" height="130"></canvas>
</div>
</div>
</div>
</main>
<div id="mapModal">
<div id="mapModalHeader">
<span>Детали</span>
<button type="button" id="mapModalClose" aria-label="Закрыть">×</button>
</div>
<div id="mapModalBody"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="/static/radio-ui.js"></script>
<script>
if (typeof RadioUI === 'undefined') {
console.error('radio-ui.js not loaded — check /static/radio-ui.js');
window.RadioUI = {
roleLabel: (role) => role || '—',
parseRadioSnapshot: () => ({}),
diffSnapshots: () => new Set(),
formatRadioPanel: () => '—',
renderCompareGrid: () => '—'
};
}
</script>
<script>
const map = L.map('map').setView([55.75, 37.62], 10);
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 API_BUILD = '2026-06-16f';
const markers = {};
let selectedId = null;
let chatSince = 0;
let chatLastReadTs = 0;
let prevDeviceSnap = null;
let prevTimelineTxSnap = null;
let prevTimelineRxSnap = null;
let mapInitialFitDone = false;
let userMovedMap = false;
let programmaticMove = false;
let cmdFormDirty = false;
const CMD_INPUT_IDS = ['cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdTm', 'cmdRole'];
let trackTxLayers = [];
let trackRxLayers = [];
let trackTxMarkers = [];
let trackRxMarkers = [];
let linkLine = null;
let ghostTx = null;
let ghostRx = null;
let loadedTxTrack = null;
let loadedRxTrack = null;
let loadedSingleTrack = null;
let telemetryTx = [];
let telemetryRx = [];
let telemetrySingle = [];
let overlapMin = 0;
let overlapMax = 0;
let playTimer = null;
let pollTimer = null;
let pollTick = 0;
let trackViewMode = 'single';
let dualTracksActive = false;
let singleTrackActive = false;
let lastDevices = [];
const deviceLabelCache = {};
let timelineSpanMs = 1000;
let timelineUseProgress = false;
let elevProfileTx = null;
let elevProfileRx = null;
let elevProfileSingle = null;
let elevProfileLink = null;
let elevProfileLinkKey = null;
let elevProfileMapLine = null;
let elevationLoadState = 'idle';
let mapRulerOpen = false;
let mapRulerMode = 'pick';
let mapRulerPtA = null;
let mapRulerPtB = null;
let mapRulerLoadState = 'idle';
let mapRulerLineLayer = null;
let mapRulerHitLayer = null;
let mapRulerMarkerA = null;
let mapRulerMarkerB = null;
let mapRulerCursorDist = null;
let mapRulerCursorMarker = null;
let mapRulerBaseStatus = '';
let mapRulerChartHover = false;
let mapRulerLineHover = false;
let mapRulerLeaveTimer = null;
const RULER_POINTS_MIN = 20;
const RULER_POINTS_MAX = 500;
let mapRulerPointsAuto = true;
let mapRulerManualPoints = 100;
let mapRulerReloadTimer = null;
const DEVICE_POLL_MS = 1000;
const CHAT_POLL_MS = 2500;
const TRACKS_POLL_MS = 10000;
const TELEMETRY_POLL_MS = 2000;
const TX_COLOR = '#e94560';
const RX_COLOR = '#4fc3f7';
map.on('zoomend moveend', () => {
if (!programmaticMove) userMovedMap = true;
});
map.on('click', onMapRulerClick);
function isNullIsland(lat, lon) {
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
function rememberDeviceLabels(devices) {
(devices || []).forEach(d => {
const lbl = d.device_label || d.label;
if (lbl && lbl !== d.device_id) deviceLabelCache[d.device_id] = lbl;
});
}
function deviceDisplayName(d) {
if (!d) return '—';
if (typeof d === 'object') {
const direct = d.device_label || d.label;
if (direct && direct !== d.device_id) return direct;
}
const id = typeof d === 'string' ? d : d.device_id;
if (deviceLabelCache[id]) return deviceLabelCache[id];
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === id) : d;
const label = dev?.device_label || dev?.label;
if (label && label !== id) return label;
return id || '—';
}
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,
pointTs: Number(one.ts),
};
}
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,
pointTs: Number(a.ts),
};
}
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,
pointTs: Number(frac < 0.5 ? a.ts : b.ts),
};
}
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) {
const pos = trackPointAt(track, cursor);
if (pos?.meta && String(pos.meta).length > 2) {
const ts = pos.pointTs ?? (timelineUseProgress ? null : cursor.t);
return snapFromTrackPoint(pos, ts, roleFallback || track?.role);
}
if (timelineUseProgress) {
return snapFromTrackPoint(pos, null, roleFallback || track?.role);
}
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 => ({
...p,
ts: Number(p.ts),
lat: Number(p.lat),
lon: Number(p.lon),
}));
const maxTs = Math.max(...points.map(p => p.ts));
if (maxTs > 1e11) {
points.forEach(p => { p.ts /= 1000; });
}
points.sort((a, b) => a.ts - b.ts);
return { ...track, points };
}
function normalizeTelemetry(rows) {
if (!rows?.length) return [];
return rows
.map(r => ({ ...r, ts: Number(r.ts) }))
.sort((a, b) => a.ts - b.ts);
}
function trackPointAt(track, cursor) {
if (!track?.points?.length) return null;
return timelineUseProgress
? positionAtProgress(track.points, cursor.progress)
: positionAt(track.points, cursor.t);
}
function snapFromTrackPoint(pos, t, roleFallback) {
if (!pos) return null;
const ts = pos.pointTs ?? t;
return {
meta: pos.meta,
role: roleFallback,
rssi: pos.rssi,
ts,
lat: pos.lat,
lon: pos.lon,
};
}
function mergeTelCoords(tel, pos) {
if (!tel || !pos) return tel;
return {
...tel,
lat: pos.lat,
lon: pos.lon,
ts: tel.ts ?? pos.pointTs ?? null,
};
}
function snapAtTime(track, telemetryRows, t, roleFallback) {
const pos = positionAt(track?.points, t);
if (pos?.meta && String(pos.meta).length > 2) {
return snapFromTrackPoint(pos, t, roleFallback || track?.role);
}
const tel = telemetryAtTime(telemetryRows, t);
if (tel) return tel;
return snapFromTrackPoint(pos, t, roleFallback || track?.role);
}
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, Number(pct)));
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)`;
}
function roleColor(role) {
return role === 'RX' ? RX_COLOR : TX_COLOR;
}
function roleLabel(role) {
if (role === 'TX') return 'Передатчик (TX)';
if (role === 'RX') return 'Приёмник (RX)';
return role || '—';
}
function roleFromDevice(d) {
if (d.role) return d.role;
try {
if (d.meta) return JSON.parse(d.meta).role;
} catch (e) {}
return null;
}
function makeRoleIcon(role) {
const color = roleColor(role);
return L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 4px #000"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7]
});
}
function haversineM(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function setMapViewProgrammatically(fn) {
programmaticMove = true;
fn();
map.once('moveend', () => { programmaticMove = false; });
}
function fitAllMarkers(bounds) {
if (!bounds.length || userMovedMap) return;
if (bounds.length === 1) {
setMapViewProgrammatically(() => map.setView(bounds[0], 13));
} else {
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }));
}
mapInitialFitDone = true;
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
const RADIO_STATIC_KEY = 'radioStaticOpen';
function isRadioStaticOpen(container) {
if (container) {
const d = container.querySelector('details');
if (d) return d.open;
}
try {
return sessionStorage.getItem(RADIO_STATIC_KEY) === '1';
} catch (e) {
return false;
}
}
function setPanelHtml(container, html) {
if (!container) return;
const wasOpen = isRadioStaticOpen(container);
container.innerHTML = html;
const d = container.querySelector('details');
if (d && wasOpen) d.open = true;
}
document.addEventListener('toggle', e => {
if (!(e.target instanceof HTMLDetailsElement)) return;
if (!e.target.closest('#stats, #mapModalBody, #timelineStats, #cmdCurrentValues')) return;
try {
sessionStorage.setItem(RADIO_STATIC_KEY, e.target.open ? '1' : '0');
} catch (err) {}
}, true);
function telemetryToSnap(r) {
return RadioUI.parseRadioSnapshot(r.meta, r.role, r.rssi);
}
function formatTelemetryRow(r, changed) {
const snap = telemetryToSnap(r);
let html = RadioUI.formatRadioPanel(snap, changed || new Set());
if (r.range_m != null) html += `<div><b>Range:</b> ${r.range_m} m</div>`;
if (r.lat != null && r.lon != null && !isNullIsland(r.lat, r.lon)) {
html += `<div><b>GPS:</b> ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}</div>`;
}
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 formatTsValue(ts) {
if (ts == null || !Number.isFinite(Number(ts))) return null;
const n = Number(ts);
if (n <= 1e8) return null;
const ms = n < 1e12 ? n * 1000 : n;
return new Date(ms).toLocaleTimeString();
}
function packetTimeFromMetaFields(fields) {
if (!fields) return null;
const skip = /timeout|on\s*air|speed|airtime/i;
for (const [k, v] of Object.entries(fields)) {
const key = String(k).trim();
if (skip.test(key)) continue;
if (!/^(time|timestamp|packet.?time)$/i.test(key)) continue;
const text = String(v).trim();
if (/^\d+(\.\d+)?\s*ms$/i.test(text)) continue;
const parsed = formatTsValue(text);
if (parsed) return parsed;
if (text) return text;
}
return null;
}
function formatPacketTime(tel) {
if (!tel) return null;
const direct = formatTsValue(tel.ts);
if (direct) return direct;
if (!tel.meta) return null;
let o = tel.meta;
if (typeof o === 'string') {
try { o = JSON.parse(o); } catch (e) { return null; }
}
if (!o) return null;
const fromMeta = formatTsValue(o.stats_at) || formatTsValue(o.packet_ts) || formatTsValue(o.ts);
if (fromMeta) return fromMeta;
return packetTimeFromMetaFields(o.fields);
}
function enrichSnapFromTel(snap, tel) {
snap.gps = formatCoords(tel) || '—';
snap.packetTime = formatPacketTime(tel) || '—';
return snap;
}
function renderTimelineCompare(txTel, rxTel, txId, rxId) {
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;
prevTimelineRxSnap = rxSnap;
return RadioUI.renderCompareGrid(
txSnap, rxSnap, txId, rxId, chTx, chRx,
isRadioStaticOpen(document.getElementById('timelineStats'))
);
}
function setCmdFormDirty(dirty) {
cmdFormDirty = dirty;
const hint = document.getElementById('cmdDraftHint');
if (hint) hint.style.display = dirty ? 'block' : 'none';
}
function setupCmdFormDirtyTracking() {
const panel = document.getElementById('controlPanel');
if (!panel) return;
const markDirty = () => setCmdFormDirty(true);
CMD_INPUT_IDS.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('input', markDirty);
el.addEventListener('change', markDirty);
});
}
function refreshCmdDeviceStatus(d) {
if (!d) return;
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
setPanelHtml(
document.getElementById('cmdCurrentValues'),
RadioUI.formatRadioPanel(
snap,
new Set(),
isRadioStaticOpen(document.getElementById('cmdCurrentValues'))
)
);
}
function fillCmdInputsFromDevice(d) {
if (!d) return;
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
if (snap.frequencyMhz != null) {
document.getElementById('cmdFq').value = snap.frequencyMhz.toFixed(3);
}
if (snap.powerDbm != null) document.getElementById('cmdPw').value = snap.powerDbm;
if (snap.sf != null) document.getElementById('cmdSf').value = snap.sf;
if (snap.bwKhz != null) document.getElementById('cmdBw').value = String(snap.bwKhz);
if (snap.role) document.getElementById('cmdRole').value = snap.role;
}
function fillCmdFormFromDevice(d, opts = {}) {
if (!d) return;
const force = opts.force === true;
refreshCmdDeviceStatus(d);
if (!force && cmdFormDirty) return;
fillCmdInputsFromDevice(d);
}
function buildMacroLines() {
const lines = ['S'];
const fq = document.getElementById('cmdFq').value.trim();
const pw = document.getElementById('cmdPw').value.trim();
const sf = document.getElementById('cmdSf').value.trim();
const bw = document.getElementById('cmdBw').value.trim();
const cr = document.getElementById('cmdCr').value.trim();
const pl = document.getElementById('cmdPl').value.trim();
const tm = document.getElementById('cmdTm').value.trim();
const role = document.getElementById('cmdRole').value;
if (fq) {
const hz = Math.round(parseFloat(fq) * 1e6);
if (hz >= 430000000 && hz <= 470000000) lines.push(`AT+FQ=${hz}`);
}
if (pw) lines.push(`AT+PW=${pw}`);
if (sf) lines.push(`AT+SF=${sf}`);
if (bw) lines.push(`AT+BW=${bw}`);
if (cr) lines.push(`AT+CR=${cr}`);
if (pl) lines.push(`AT+PL=${pl}`);
if (tm) lines.push(`AT+TM=${tm}`);
if (role === 'TX') lines.push('AT+TX');
if (role === 'RX') lines.push('AT+RX');
return lines;
}
function setElevationStatus(text) {
const el = document.getElementById('elevationStatus');
if (el) el.textContent = text ? `· ${text}` : '';
}
function setMapRulerStatus(text) {
const el = document.getElementById('mapRulerStatus');
if (el) el.textContent = text ? `· ${text}` : '';
}
function getTxRxDevices(devices) {
let tx = null;
let rx = null;
(devices || lastDevices).forEach(d => {
if (d.lat == null || d.lon == null || isNullIsland(d.lat, d.lon)) return;
if (d.role === 'TX') tx = d;
if (d.role === 'RX') rx = d;
});
return { tx, rx };
}
function lineLengthM(a, b) {
return haversineM(a.lat, a.lon, b.lat, b.lon);
}
function latLonAtLineDist(a, b, distM) {
const total = lineLengthM(a, b);
if (total < 1e-3) return { lat: a.lat, lon: a.lon };
const f = Math.min(1, Math.max(0, distM / total));
return {
lat: a.lat + (b.lat - a.lat) * f,
lon: a.lon + (b.lon - a.lon) * f
};
}
function projectPointToLineDist(a, b, lat, lon) {
const total = lineLengthM(a, b);
if (total < 1e-3) return 0;
const ax = a.lon;
const ay = a.lat;
const bx = b.lon;
const by = b.lat;
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-15) return 0;
let t = ((lon - ax) * dx + (lat - ay) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const projLat = ay + t * dy;
const projLon = ax + t * dx;
return haversineM(a.lat, a.lon, projLat, projLon);
}
function elevationAtDist(profile, distM) {
if (!profile?.points?.length || distM == null) return null;
const pts = profile.points.filter(p => p.elevation_m != null);
if (!pts.length) return null;
if (distM <= pts[0].dist_m) return pts[0].elevation_m;
const last = pts[pts.length - 1];
if (distM >= last.dist_m) return last.elevation_m;
for (let i = 1; i < pts.length; i++) {
const p0 = pts[i - 1];
const p1 = pts[i];
if (distM <= p1.dist_m) {
const span = p1.dist_m - p0.dist_m;
const t = span <= 0 ? 0 : (distM - p0.dist_m) / span;
return p0.elevation_m + (p1.elevation_m - p0.elevation_m) * t;
}
}
return null;
}
function buildDirectLinePoints(tx, rx, stepM = 10) {
const total = haversineM(tx.lat, tx.lon, rx.lat, rx.lon);
if (total < 1) {
return [{ lat: tx.lat, lon: tx.lon }];
}
const pts = [];
for (let d = 0; d <= total + 1e-6; d += stepM) {
const f = Math.min(1, d / total);
pts.push({
lat: tx.lat + (rx.lat - tx.lat) * f,
lon: tx.lon + (rx.lon - tx.lon) * f
});
if (d >= total) break;
}
const last = pts[pts.length - 1];
if (haversineM(last.lat, last.lon, rx.lat, rx.lon) > 1) {
pts.push({ lat: rx.lat, lon: rx.lon });
}
return pts;
}
function autoRulerTargetPoints(distM) {
if (distM < 1) return RULER_POINTS_MIN;
if (distM <= 200) return Math.max(RULER_POINTS_MIN, Math.round(distM / 4));
if (distM <= 1000) return Math.max(50, Math.round(distM / 8));
if (distM <= 5000) return Math.max(80, Math.round(distM / 15));
return Math.min(RULER_POINTS_MAX, Math.max(100, Math.round(distM / 20)));
}
function getMapRulerTargetPoints(distM) {
if (mapRulerPointsAuto) return autoRulerTargetPoints(distM);
return Math.max(RULER_POINTS_MIN, Math.min(RULER_POINTS_MAX, mapRulerManualPoints));
}
function updateMapRulerPointsUi(distM) {
const autoEl = document.getElementById('mapRulerPointsAuto');
const slider = document.getElementById('mapRulerPointsSlider');
const label = document.getElementById('mapRulerPointsLabel');
if (!autoEl || !slider || !label) return;
const effective = getMapRulerTargetPoints(distM || 0);
const autoVal = autoRulerTargetPoints(distM || 0);
autoEl.checked = mapRulerPointsAuto;
slider.disabled = mapRulerPointsAuto;
slider.value = String(mapRulerPointsAuto ? autoVal : mapRulerManualPoints);
if (mapRulerPointsAuto && distM > 0) {
const step = effective > 1 ? distM / (effective - 1) : distM;
label.textContent = `${effective} точек · ~${step.toFixed(1)} м`;
} else if (mapRulerPointsAuto) {
label.textContent = `${autoVal} точек · авто`;
} else {
label.textContent = `${effective} точек`;
}
}
function scheduleMapRulerProfileReload() {
clearTimeout(mapRulerReloadTimer);
mapRulerReloadTimer = setTimeout(() => {
if (mapRulerPtA && mapRulerPtB) {
loadMapRulerProfileFromPoints(mapRulerPtA, mapRulerPtB);
}
}, 350);
}
function makeRulerPointIcon(label, color) {
return L.divIcon({
className: '',
html: `<div style="width:22px;height:22px;border-radius:50%;background:${color};border:2px solid #fff;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;box-shadow:0 0 4px #000">${label}</div>`,
iconSize: [22, 22],
iconAnchor: [11, 11]
});
}
function setMapRulerHint(text) {
const el = document.getElementById('mapRulerHint');
if (el) el.textContent = text || '';
}
function clearMapRulerLineLayer() {
if (mapRulerLineLayer) {
map.removeLayer(mapRulerLineLayer);
mapRulerLineLayer = null;
}
if (mapRulerHitLayer) {
map.removeLayer(mapRulerHitLayer);
mapRulerHitLayer = null;
}
}
function clearMapRulerCursorMarker() {
if (mapRulerCursorMarker) {
map.removeLayer(mapRulerCursorMarker);
mapRulerCursorMarker = null;
}
}
function scheduleClearMapRulerCursor() {
clearTimeout(mapRulerLeaveTimer);
mapRulerLeaveTimer = setTimeout(() => {
if (mapRulerChartHover || mapRulerLineHover) return;
mapRulerCursorDist = null;
clearMapRulerCursorMarker();
setMapRulerStatus(mapRulerBaseStatus);
drawMapRulerChart();
}, 60);
}
function updateMapRulerCursorMarker(pt) {
clearMapRulerCursorMarker();
mapRulerCursorMarker = L.circleMarker([pt.lat, pt.lon], {
radius: 8,
color: '#fff',
weight: 2,
fillColor: '#00ff88',
fillOpacity: 0.95,
interactive: false
}).addTo(map);
}
function setMapRulerCursor(distM) {
if (!mapRulerPtA || !mapRulerPtB || elevationPointCount(elevProfileMapLine) === 0) return;
const total = elevProfileMapLine.total_m || lineLengthM(mapRulerPtA, mapRulerPtB);
mapRulerCursorDist = Math.max(0, Math.min(distM, total));
const pt = latLonAtLineDist(mapRulerPtA, mapRulerPtB, mapRulerCursorDist);
updateMapRulerCursorMarker(pt);
const elev = elevationAtDist(elevProfileMapLine, mapRulerCursorDist);
if (elev != null) {
setMapRulerStatus(`${mapRulerCursorDist.toFixed(0)} m · ${elev.toFixed(1)} m`);
}
drawMapRulerChart();
}
function clearMapRulerCursor() {
mapRulerCursorDist = null;
clearMapRulerCursorMarker();
}
function updateMapRulerLineLayer(a, b) {
clearMapRulerLineLayer();
if (!mapRulerOpen || !a || !b) return;
mapRulerLineLayer = L.polyline(
[[a.lat, a.lon], [b.lat, b.lon]],
{ color: '#00ff88', weight: 3, dashArray: '8,6', opacity: 0.85, interactive: false }
).addTo(map);
mapRulerHitLayer = L.polyline(
[[a.lat, a.lon], [b.lat, b.lon]],
{ color: '#000', weight: 22, opacity: 0, interactive: true }
).addTo(map);
mapRulerHitLayer.on('mousemove', e => {
mapRulerLineHover = true;
clearTimeout(mapRulerLeaveTimer);
setMapRulerCursor(projectPointToLineDist(a, b, e.latlng.lat, e.latlng.lng));
});
mapRulerHitLayer.on('mouseout', () => {
mapRulerLineHover = false;
scheduleClearMapRulerCursor();
});
}
function clearMapRulerPickPoints() {
mapRulerPtA = null;
mapRulerPtB = null;
if (mapRulerMarkerA) {
map.removeLayer(mapRulerMarkerA);
mapRulerMarkerA = null;
}
if (mapRulerMarkerB) {
map.removeLayer(mapRulerMarkerB);
mapRulerMarkerB = null;
}
clearMapRulerLineLayer();
clearMapRulerCursor();
mapRulerBaseStatus = '';
elevProfileMapLine = null;
}
function setMapRulerMode(mode) {
mapRulerMode = mode;
document.getElementById('btnRulerPick').classList.toggle('active', mode === 'pick');
document.getElementById('btnRulerAutoTxRx').classList.toggle('active', mode === 'auto');
if (mode === 'pick') {
setMapRulerHint(mapRulerPtB && elevationPointCount(elevProfileMapLine) > 0
? 'Наведите на линию или график — высота и позиция'
: mapRulerPtB
? 'A и B заданы — клик сбрасывает и задаёт новую A'
: mapRulerPtA
? 'Клик на карте — точка B'
: 'Клик на карте — точка A, затем точка B');
} else {
setMapRulerHint(elevationPointCount(elevProfileMapLine) > 0
? 'Наведите на линию или график — высота и позиция'
: 'Линия между устройствами TX и RX (обновляется при poll)');
}
}
function placeMapRulerMarker(which, pt, color) {
const marker = L.marker([pt.lat, pt.lon], {
icon: makeRulerPointIcon(which, color),
draggable: true,
zIndexOffset: 900
}).addTo(map);
marker.on('dragend', () => {
const ll = marker.getLatLng();
const updated = { lat: ll.lat, lon: ll.lng };
if (which === 'A') mapRulerPtA = updated;
else mapRulerPtB = updated;
if (mapRulerPtA && mapRulerPtB) {
loadMapRulerProfileFromPoints(mapRulerPtA, mapRulerPtB);
}
});
if (which === 'A') {
if (mapRulerMarkerA) map.removeLayer(mapRulerMarkerA);
mapRulerMarkerA = marker;
} else {
if (mapRulerMarkerB) map.removeLayer(mapRulerMarkerB);
mapRulerMarkerB = marker;
}
}
async function loadMapRulerProfileFromPoints(a, b) {
if (!a || !b) return;
mapRulerLoadState = 'loading';
clearMapRulerCursor();
setMapRulerStatus('загрузка…');
updateMapRulerLineLayer(a, b);
drawMapRulerChart();
const dist = haversineM(a.lat, a.lon, b.lat, b.lon);
const targetPoints = getMapRulerTargetPoints(dist);
updateMapRulerPointsUi(dist);
const linePts = [{ lat: a.lat, lon: a.lon }, { lat: b.lat, lon: b.lon }];
elevProfileMapLine = await fetchElevationProfile(linePts, null, { targetPoints });
mapRulerLoadState = 'done';
const n = elevationPointCount(elevProfileMapLine);
if (n > 0) {
const src = elevProfileMapLine.source === 'elevation' ? 'высоты'
: elevProfileMapLine.source === 'server' ? 'сервер'
: elevProfileMapLine.source || 'данные';
const step = elevProfileMapLine.step_m != null
? elevProfileMapLine.step_m
: (n > 1 ? dist / (n - 1) : dist);
mapRulerBaseStatus = `${dist.toFixed(0)} m · ${src} · ${n} точек · ~${Number(step).toFixed(1)} m`;
setMapRulerStatus(mapRulerBaseStatus);
setMapRulerHint('Наведите на линию или график — высота и позиция');
} else {
mapRulerBaseStatus = '';
setMapRulerStatus(`${dist.toFixed(0)} m · ${elevProfileMapLine?.api_error || 'нет данных'}`);
}
drawMapRulerChart();
}
function onMapRulerClick(e) {
if (!mapRulerOpen || mapRulerMode !== 'pick') return;
const pt = { lat: e.latlng.lat, lon: e.latlng.lng };
if (!mapRulerPtA) {
mapRulerPtA = pt;
placeMapRulerMarker('A', pt, TX_COLOR);
setMapRulerHint('Клик на карте — точка B');
setMapRulerStatus('точка A');
drawMapRulerChart();
return;
}
if (!mapRulerPtB) {
mapRulerPtB = pt;
placeMapRulerMarker('B', pt, RX_COLOR);
setMapRulerHint('A и B заданы — клик сбрасывает и задаёт новую A');
loadMapRulerProfileFromPoints(mapRulerPtA, mapRulerPtB);
return;
}
clearMapRulerPickPoints();
mapRulerPtA = pt;
placeMapRulerMarker('A', pt, TX_COLOR);
setMapRulerHint('Клик на карте — точка B');
setMapRulerStatus('точка A');
drawMapRulerChart();
}
function interpTrackAtDist(cleaned, cum, distM) {
if (distM <= 0) return cleaned[0];
if (distM >= cum[cum.length - 1]) return cleaned[cleaned.length - 1];
for (let i = 1; i < cum.length; i++) {
if (distM <= cum[i]) {
const seg = cum[i] - cum[i - 1];
const t = seg <= 0 ? 0 : (distM - cum[i - 1]) / seg;
const [lat1, lon1] = cleaned[i - 1];
const [lat2, lon2] = cleaned[i];
return [lat1 + (lat2 - lat1) * t, lon1 + (lon2 - lon1) * t];
}
}
return cleaned[cleaned.length - 1];
}
function resampleTrackPath(points, stepM = 10) {
const cleaned = [];
for (const p of points) {
if (p.lat == null || p.lon == null) continue;
const lat = Number(p.lat);
const lon = Number(p.lon);
if (!cleaned.length || haversineM(cleaned[cleaned.length - 1][0], cleaned[cleaned.length - 1][1], lat, lon) > 0.5) {
cleaned.push([lat, lon]);
}
}
if (!cleaned.length) return [];
if (cleaned.length === 1) return [{ lat: cleaned[0][0], lon: cleaned[0][1], dist_m: 0 }];
const cum = [0];
for (let i = 1; i < cleaned.length; i++) {
cum.push(cum[i - 1] + haversineM(cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]));
}
const total = cum[cum.length - 1];
const samples = [];
for (let dist = 0; dist <= total + 1e-6; dist += stepM) {
const [lat, lon] = interpTrackAtDist(cleaned, cum, dist);
samples.push({ lat, lon, dist_m: Math.round(dist * 10) / 10 });
if (dist >= total) break;
}
return samples;
}
function resampleTrackPathCount(points, count) {
const cleaned = [];
for (const p of points) {
if (p.lat == null || p.lon == null) continue;
const lat = Number(p.lat);
const lon = Number(p.lon);
if (!cleaned.length || haversineM(cleaned[cleaned.length - 1][0], cleaned[cleaned.length - 1][1], lat, lon) > 0.5) {
cleaned.push([lat, lon]);
}
}
if (!cleaned.length || count < 2) return [];
if (cleaned.length === 1) return [{ lat: cleaned[0][0], lon: cleaned[0][1], dist_m: 0 }];
const cum = [0];
for (let i = 1; i < cleaned.length; i++) {
cum.push(cum[i - 1] + haversineM(cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]));
}
const total = cum[cum.length - 1];
if (total < 1e-6) return [{ lat: cleaned[0][0], lon: cleaned[0][1], dist_m: 0 }];
const n = Math.max(2, Math.min(RULER_POINTS_MAX, Math.round(count)));
const samples = [];
for (let i = 0; i < n; i++) {
const dist = (total * i) / (n - 1);
const [lat, lon] = interpTrackAtDist(cleaned, cum, dist);
samples.push({ lat, lon, dist_m: Math.round(dist * 10) / 10 });
}
return samples;
}
function nearestElevation(points, lat, lon) {
let best = null;
let bestD = Infinity;
for (const p of points) {
const d = haversineM(lat, lon, p.lat, p.lon);
if (d < bestD) {
bestD = d;
best = p.elevation_m ?? p.altitude_gps ?? null;
}
}
return best != null ? Number(best) : null;
}
function elevationPointCount(profile) {
if (!profile?.points?.length) return 0;
return profile.points.filter(p => p.elevation_m != null && !Number.isNaN(p.elevation_m)).length;
}
function buildLocalElevationProfile(points, stepM = 10, targetPoints = null) {
const samples = targetPoints != null
? resampleTrackPathCount(points, targetPoints)
: resampleTrackPath(points, stepM);
if (!samples.length) return null;
const profilePts = samples.map(s => ({
dist_m: s.dist_m,
lat: s.lat,
lon: s.lon,
elevation_m: nearestElevation(points, s.lat, s.lon)
}));
const elevVals = profilePts.map(p => p.elevation_m).filter(v => v != null);
if (!elevVals.length) return null;
const effStep = targetPoints != null && profilePts.length > 1
? (profilePts[profilePts.length - 1].dist_m - profilePts[0].dist_m) / (profilePts.length - 1)
: stepM;
return {
step_m: Math.round(effStep * 10) / 10,
total_m: profilePts[profilePts.length - 1].dist_m,
min_elevation_m: Math.min(...elevVals),
max_elevation_m: Math.max(...elevVals),
points: profilePts,
source: 'track-cache'
};
}
let elevationHealthCache = null;
async function ensureElevationApi() {
if (elevationHealthCache && Date.now() - elevationHealthCache.ts < 60000) {
return elevationHealthCache;
}
try {
const res = await fetch('/api/health', { cache: 'no-store' });
if (!res.ok) {
elevationHealthCache = {
ok: false,
error: `health HTTP ${res.status}`,
ts: Date.now()
};
return elevationHealthCache;
}
const data = await res.json();
elevationHealthCache = {
ok: data.elevation_ok === true,
error: data.elevation_error || null,
url: data.elevation_url || null,
ts: Date.now()
};
return elevationHealthCache;
} catch (e) {
elevationHealthCache = {
ok: false,
error: String(e.message || e),
ts: Date.now()
};
return elevationHealthCache;
}
}
function normalizeServerProfile(data) {
if (!data?.points?.length) return null;
const profile = {
...data,
source: data.api_source || 'server',
api_error: data.api_error || null
};
return elevationPointCount(profile) > 0 ? profile : profile;
}
async function fetchElevationProfile(points, trackId, options = {}) {
if (!points || !points.length) return null;
const { targetPoints = null, stepM = 10 } = options;
let lastError = null;
const health = await ensureElevationApi();
if (!health.ok) {
const cached = buildLocalElevationProfile(points, stepM, targetPoints);
if (cached) return cached;
return {
points: [],
source: 'error',
api_error: health.error
? `сервис высот недоступен: ${health.error}`
: 'сервис высот недоступен'
};
}
if (trackId) {
try {
const res = await fetch(`/api/tracks/${trackId}/elevation-profile?step_m=10`, { cache: 'no-store' });
if (res.ok) {
const data = normalizeServerProfile(await res.json());
if (data && elevationPointCount(data) > 0) return data;
lastError = data?.api_error || 'server track profile empty';
} else {
lastError = `server track HTTP ${res.status}`;
}
} catch (e) {
lastError = String(e.message || e);
}
}
try {
const body = targetPoints != null
? { points, target_points: targetPoints }
: { points, step_m: stepM };
const res = await fetch('/api/elevation/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
const data = normalizeServerProfile(await res.json());
if (data && elevationPointCount(data) > 0) return data;
lastError = data?.api_error || lastError || 'server profile empty';
} else {
lastError = lastError || `server HTTP ${res.status}`;
}
} catch (e) {
lastError = lastError || String(e.message || e);
}
const cached = buildLocalElevationProfile(points, stepM, targetPoints);
if (cached) return cached;
return {
points: [],
source: 'error',
api_error: lastError || 'no elevation data'
};
}
function trackDistanceAtTime(track, t) {
if (!track?.points?.length) return 0;
const pts = track.points;
if (t <= pts[0].ts) return 0;
const last = pts[pts.length - 1];
if (t >= last.ts) {
let dist = 0;
for (let i = 1; i < pts.length; i++) {
dist += haversineM(pts[i-1].lat, pts[i-1].lon, pts[i].lat, pts[i].lon);
}
return dist;
}
let dist = 0;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
if (t <= b.ts) {
const span = Math.max(b.ts - a.ts, 1e-6);
const f = (t - a.ts) / span;
const lat = a.lat + (b.lat - a.lat) * f;
const lon = a.lon + (b.lon - a.lon) * f;
dist += haversineM(a.lat, a.lon, lat, lon);
return dist;
}
dist += haversineM(a.lat, a.lon, b.lat, b.lon);
}
return dist;
}
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' });
}
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 });
}
return series;
}
function renderElevationCanvas(canvas, series, loadState, emptyIdleMsg) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.clientWidth || 800;
const h = canvas.clientHeight || 100;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
if (!series.length) {
ctx.fillStyle = '#888';
ctx.font = '12px system-ui';
ctx.fillText(loadState === 'loading' ? 'Загрузка…' : emptyIdleMsg, 12, h / 2);
return;
}
let maxDist = 0;
let minE = Infinity;
let maxE = -Infinity;
series.forEach(s => {
maxDist = Math.max(maxDist, s.profile.total_m || 0);
s.profile.points.forEach(p => {
if (p.elevation_m != null) {
minE = Math.min(minE, p.elevation_m);
maxE = Math.max(maxE, p.elevation_m);
}
});
});
if (!isFinite(minE)) {
ctx.fillStyle = '#888';
ctx.font = '12px system-ui';
ctx.fillText('Нет данных высот', 12, h / 2);
return;
}
const padE = Math.max((maxE - minE) * 0.1, 5);
minE -= padE;
maxE += padE;
const margin = { l: 36, r: 8, t: 8, b: 18 };
const plotW = w - margin.l - margin.r;
const plotH = h - margin.t - margin.b;
ctx.strokeStyle = '#333';
ctx.beginPath();
ctx.moveTo(margin.l, margin.t);
ctx.lineTo(margin.l, margin.t + plotH);
ctx.lineTo(margin.l + plotW, margin.t + plotH);
ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '9px system-ui';
ctx.fillText(`${Math.round(minE)}m`, 2, margin.t + plotH);
ctx.fillText(`${Math.round(maxE)}m`, 2, margin.t + 8);
ctx.fillText('0', margin.l, h - 2);
ctx.fillText(`${Math.round(maxDist)}m`, margin.l + plotW - 20, h - 2);
if (series.length > 1 && canvas.id === 'elevationCanvas') {
let lx = margin.l + plotW;
ctx.font = '9px system-ui';
series.slice().reverse().forEach(s => {
if (!s.label) return;
const tw = ctx.measureText(s.label).width;
lx -= tw + 14;
ctx.fillStyle = s.color;
ctx.fillRect(lx, margin.t + 1, 8, 8);
ctx.fillStyle = '#ccc';
ctx.fillText(s.label, lx + 10, margin.t + 9);
});
}
series.forEach(s => {
const pts = s.profile.points.filter(p => p.elevation_m != null);
if (pts.length < 2) return;
ctx.strokeStyle = s.color;
ctx.lineWidth = 2;
ctx.beginPath();
pts.forEach((p, i) => {
const x = margin.l + (p.dist_m / Math.max(maxDist, 1)) * plotW;
const y = margin.t + plotH - ((p.elevation_m - minE) / (maxE - minE)) * plotH;
if (i === 0) ctx.moveTo(x, y);
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);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
ctx.moveTo(cx, margin.t);
ctx.lineTo(cx, margin.t + plotH);
ctx.stroke();
ctx.setLineDash([]);
if (elev != null && isFinite(elev)) {
const cy = margin.t + plotH - ((elev - minE) / (maxE - minE)) * plotH;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(cx, cy, 4.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = s.color;
ctx.lineWidth = 2;
ctx.stroke();
const label = `${Math.round(s.cursor)} m · ${elev.toFixed(1)} m`;
ctx.font = '10px system-ui';
const tw = ctx.measureText(label).width;
let lx = cx - tw / 2;
lx = Math.max(margin.l, Math.min(lx, margin.l + plotW - tw));
ctx.fillStyle = 'rgba(10,10,20,0.92)';
ctx.fillRect(lx - 3, margin.t - 1, tw + 6, 14);
ctx.fillStyle = '#00ff88';
ctx.fillText(label, lx, margin.t + 10);
}
}
});
canvas._elevLayout = { margin, plotW, plotH, maxDist, minE, maxE };
}
function drawElevationChart(cursors) {
renderElevationCanvas(
document.getElementById('elevationCanvas'),
getTimelineElevationSeries(cursors),
elevationLoadState,
'Покажите трек на карте'
);
}
function drawMapRulerChart() {
const series = elevationPointCount(elevProfileMapLine) > 0
? [{
color: '#00ff88',
profile: elevProfileMapLine,
cursor: mapRulerCursorDist
}]
: [];
const idleMsg = elevProfileMapLine?.api_error
? elevProfileMapLine.api_error
: 'Выберите точки A и B на карте';
renderElevationCanvas(
document.getElementById('mapRulerCanvas'),
series,
mapRulerLoadState,
idleMsg
);
}
async function loadMapRulerProfileAuto() {
const { tx, rx } = getTxRxDevices(lastDevices);
if (!tx || !rx) {
elevProfileMapLine = null;
mapRulerLoadState = 'done';
setMapRulerStatus('нет TX/RX GPS');
clearMapRulerLineLayer();
drawMapRulerChart();
return;
}
clearMapRulerPickPoints();
mapRulerPtA = { lat: tx.lat, lon: tx.lon };
mapRulerPtB = { lat: rx.lat, lon: rx.lon };
placeMapRulerMarker('A', mapRulerPtA, TX_COLOR);
placeMapRulerMarker('B', mapRulerPtB, RX_COLOR);
await loadMapRulerProfileFromPoints(mapRulerPtA, mapRulerPtB);
}
function setMapRulerOpen(open) {
mapRulerOpen = open;
document.getElementById('mapRulerPanel').classList.toggle('open', open);
document.getElementById('btnMapRuler').classList.toggle('active', open);
if (!open) {
clearMapRulerPickPoints();
setMapRulerStatus('');
setMapRulerHint('');
} else {
setMapRulerMode(mapRulerMode);
updateMapRulerPointsUi(0);
}
setTimeout(() => map.invalidateSize(), 80);
}
async function toggleMapRuler() {
const willOpen = !mapRulerOpen;
setMapRulerOpen(willOpen);
if (willOpen) {
mapRulerMode = 'pick';
setMapRulerMode('pick');
drawMapRulerChart();
}
}
function resetMapRulerPick() {
clearMapRulerPickPoints();
mapRulerLoadState = 'idle';
mapRulerChartHover = false;
mapRulerLineHover = false;
setMapRulerStatus('');
setMapRulerMode('pick');
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();
if (singleTrackActive && loadedSingleTrack?.points?.length) {
elevProfileSingle = await fetchElevationProfile(
loadedSingleTrack.points, loadedSingleTrack.id);
} else if (dualTracksActive) {
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 || elevProfileLink || elevProfileTx || elevProfileRx;
const srcLabel = ref?.source === 'elevation' ? 'высоты'
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
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} точек`);
} else {
setElevationStatus(`${srcLabel} · ${elevationPointCount(ref)} точек`);
}
} else {
const err = elevProfileSingle?.api_error || elevProfileLink?.api_error
|| elevProfileTx?.api_error || elevProfileRx?.api_error;
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
}
drawElevationChart();
requestAnimationFrame(() => drawElevationChart(
singleTrackActive
? { single: trackDistanceAtCursor(loadedSingleTrack, timelineCursor()) }
: dualTracksActive
? {
tx: trackDistanceAtCursor(loadedTxTrack, timelineCursor()),
rx: trackDistanceAtCursor(loadedRxTrack, timelineCursor())
}
: null
));
}
function updateGpsDistanceHeader(devices) {
const el = document.getElementById('gpsDistance');
let tx = null, rx = null;
devices.forEach(d => {
if (!isNullIsland(d.lat, d.lon) && d.lat != null) {
if (d.role === 'TX') tx = d;
if (d.role === 'RX') rx = d;
}
});
if (tx && rx) {
const dist = haversineM(tx.lat, tx.lon, rx.lat, rx.lon);
el.textContent = `GPS: ${dist.toFixed(0)} m между устройствами`;
} else {
el.textContent = 'GPS: —';
}
}
function centerMapOnRole(role) {
const bounds = [];
lastDevices.forEach(d => {
if (d.role === role && d.lat != null && !isNullIsland(d.lat, d.lon)) {
bounds.push([d.lat, d.lon]);
}
});
if (bounds.length === 1) setMapViewProgrammatically(() => map.setView(bounds[0], 14));
else if (bounds.length > 1) setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [40, 40], maxZoom: 16 }));
}
function centerMapOnBoth() {
const bounds = [];
lastDevices.forEach(d => {
if (d.lat != null && !isNullIsland(d.lat, d.lon)) bounds.push([d.lat, d.lon]);
});
if (!bounds.length) return;
userMovedMap = false;
if (bounds.length === 1) setMapViewProgrammatically(() => map.setView(bounds[0], 14));
else setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }));
}
/* --- Draggable modal --- */
const mapModal = document.getElementById('mapModal');
const mapModalBody = document.getElementById('mapModalBody');
const mapModalHeader = document.getElementById('mapModalHeader');
let modalDrag = null;
/** null | 'device' | 'timeline' */
let modalMode = null;
function isModalOpen() {
return mapModal.classList.contains('open');
}
function loadModalPosition() {
try {
const x = sessionStorage.getItem('modalX');
const y = sessionStorage.getItem('modalY');
if (x != null && y != null) {
mapModal.style.left = x + 'px';
mapModal.style.top = y + 'px';
} else {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
} catch (e) {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
}
function openMapModal(html, mode) {
if (mode) modalMode = mode;
setPanelHtml(mapModalBody, html);
mapModal.classList.add('open');
loadModalPosition();
}
function syncModalHtml(html) {
if (!isModalOpen()) return;
setPanelHtml(mapModalBody, html);
}
function closeMapModal() {
mapModal.classList.remove('open');
modalMode = null;
}
document.getElementById('mapModalClose').onclick = closeMapModal;
mapModalHeader.addEventListener('pointerdown', e => {
if (e.target.id === 'mapModalClose') return;
modalDrag = {
startX: e.clientX,
startY: e.clientY,
left: mapModal.offsetLeft,
top: mapModal.offsetTop
};
mapModalHeader.setPointerCapture(e.pointerId);
});
mapModalHeader.addEventListener('pointermove', e => {
if (!modalDrag) return;
const left = modalDrag.left + (e.clientX - modalDrag.startX);
const top = modalDrag.top + (e.clientY - modalDrag.startY);
mapModal.style.left = Math.max(0, left) + 'px';
mapModal.style.top = Math.max(0, top) + 'px';
});
mapModalHeader.addEventListener('pointerup', e => {
if (!modalDrag) return;
try {
sessionStorage.setItem('modalX', String(mapModal.offsetLeft));
sessionStorage.setItem('modalY', String(mapModal.offsetTop));
} catch (err) {}
modalDrag = null;
mapModalHeader.releasePointerCapture(e.pointerId);
});
/* --- Track helpers --- */
function positionAt(points, t) {
if (!points || !points.length) return null;
const tNum = Number(t);
const first = points[0];
const last = points[points.length - 1];
const t0 = Number(first.ts);
const t1 = Number(last.ts);
if (tNum <= t0) {
return {
lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi,
pointTs: t0,
};
}
if (tNum >= t1) {
return {
lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi,
pointTs: t1,
};
}
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
const ta = Number(a.ts);
const tb = Number(b.ts);
if (tNum >= ta && tNum <= tb) {
const span = Math.max(tb - ta, 1e-9);
const f = (tNum - ta) / span;
return {
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f,
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f,
meta: tNum - ta < tb - tNum ? a.meta : b.meta,
rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi,
pointTs: tNum - ta < tb - tNum ? ta : tb,
};
}
}
return {
lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi,
pointTs: t1,
};
}
function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const min = Math.max(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.min(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'overlap' };
}
/** Timeline range: prefer overlap; else full union of both tracks. */
function timelineRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap;
const min = Math.min(Number(txPts[0].ts), Number(rxPts[0].ts));
const max = Math.max(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
if (min >= max) return null;
return { min, max, mode: 'union' };
}
function telemetryAtTime(rows, t) {
if (!rows?.length) return null;
const tNum = Number(t);
let best = null;
let bestD = Infinity;
for (const r of rows) {
const d = Math.abs(Number(r.ts) - tNum);
if (d < bestD) {
best = r;
bestD = d;
}
}
return best;
}
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 trackMetaDiversity(points) {
const packets = new Set();
let withMeta = 0;
for (const p of points || []) {
if (!p.meta || String(p.meta).length < 3) continue;
withMeta++;
const snap = RadioUI.parseRadioSnapshot(p.meta);
if (snap.packet != null) packets.add(snap.packet);
}
return { withMeta, uniquePackets: packets.size, total: points?.length || 0 };
}
function pairedTelemetryAtTime(txTrack, rxTrack, telemetryTx, telemetryRx, t) {
let txTel = snapAtTime(txTrack, telemetryTx, t, 'TX');
let rxTel = snapAtTime(rxTrack, telemetryRx, 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() {
[...trackTxLayers, ...trackRxLayers].forEach(l => map.removeLayer(l));
trackTxLayers = [];
trackRxLayers = [];
trackTxMarkers.forEach(m => map.removeLayer(m));
trackRxMarkers.forEach(m => map.removeLayer(m));
trackTxMarkers = [];
trackRxMarkers = [];
if (linkLine) { map.removeLayer(linkLine); linkLine = null; }
if (ghostTx) { map.removeLayer(ghostTx); ghostTx = null; }
if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; }
}
function updateTrackButtons() {
const active = dualTracksActive || singleTrackActive;
const hideBtn = document.getElementById('btnHideTracks');
if (hideBtn) hideBtn.disabled = !active;
}
function exitTrackMode() {
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
loadedSingleTrack = null;
telemetryTx = [];
telemetryRx = [];
telemetrySingle = [];
elevProfileTx = null;
elevProfileRx = null;
elevProfileSingle = null;
elevProfileLink = null;
elevProfileLinkKey = null;
drawElevationChart();
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
setTimelineVisible(false);
if (isModalOpen() && modalMode === 'timeline') {
closeMapModal();
}
document.getElementById('trackInfo').textContent =
trackViewMode === 'dual' ? 'Выберите TX и RX треки' : 'Выберите трек';
updateTrackButtons();
}
function drawTrackLine(track, color, store, colorByQuality) {
const pts = track.points;
const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
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) || '#ff8800') : 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, pointIdx) => {
const q = rxQualityFromMeta(p.meta);
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
const m = L.circleMarker([p.lat, p.lon], {
radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8
});
m.addTo(map);
m.on('click', () => {
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';
});
markerList.push(m);
});
}
function buildTimelineModalHtml(cursor, txPos, rxPos) {
if (!txPos || !rxPos) return '';
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
const txTel = mergeTelCoords(snapAtCursor(loadedTxTrack, telemetryTx, cursor, 'TX'), txPos);
const rxTel = mergeTelCoords(snapAtCursor(loadedRxTrack, telemetryRx, cursor, 'RX'), rxPos);
html += renderTimelineCompare(
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)
);
return html;
}
function singleTrackRange(points) {
if (!points || !points.length) return null;
return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
}
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(cursor, openModal);
return;
}
if (!loadedTxTrack || !loadedRxTrack) return;
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);
if (linkLine) map.removeLayer(linkLine);
if (txPos) {
ghostTx = L.circleMarker([txPos.lat, txPos.lon], {
radius: 10, color: TX_COLOR, fillColor: TX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (rxPos) {
ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], {
radius: 10, color: RX_COLOR, fillColor: RX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (txPos && rxPos) {
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
document.getElementById('distanceNow').textContent =
`Расстояние GPS: ${dist.toFixed(0)} m`;
linkLine = L.polyline(
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
{ color: '#00ff88', weight: 3, dashArray: '6,6' }
).addTo(map);
const modalHtml = buildTimelineModalHtml(cursor, txPos, rxPos);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(modalHtml, 'timeline');
}
}
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,
rxTel,
deviceDisplayName(loadedTxTrack?.device_id),
deviceDisplayName(loadedRxTrack?.device_id)
));
if (txPos && rxPos) {
scheduleLinkElevation(txPos, rxPos).then(() => drawElevationChart());
} else {
drawElevationChart();
}
}
function updateTimelineAtSingle(cursor, openModal) {
const track = loadedSingleTrack;
if (!track) return;
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);
ghostTx = null;
ghostRx = null;
linkLine = null;
if (pos) {
const color = track.role === 'RX' ? RX_COLOR : TX_COLOR;
ghostTx = L.circleMarker([pos.lat, pos.lon], {
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
}).addTo(map);
let html = `<b>${formatTimelineClock(cursor)}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
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());
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(html, 'timeline');
}
}
const tel = snapAtCursor(track, telemetrySingle, cursor, track.role);
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(
timelineStatsEl,
tel
? RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(timelineStatsEl))
: '<span class="muted">нет данных</span>'
);
drawElevationChart({ single: trackDistanceAtTime(track, t) });
}
function setTimelineVisible(visible) {
document.getElementById('trackTimeline').classList.toggle('visible', visible);
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
setTimeout(() => {
map.invalidateSize();
if (visible) drawElevationChart();
}, 80);
}
function setTimelineMode(single) {
const statsPanel = document.getElementById('timelineStatsPanel');
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);
timelineSpanMs = Math.max(1, Math.round(spanSec * 1000));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = String(timelineSpanMs);
slider.step = timelineSpanMs > 300000 ? 1000 : (timelineSpanMs > 60000 ? 500 : 100);
slider.value = '0';
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
document.getElementById('timeCurrent').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
if (noteText != null) note.textContent = noteText;
}
function setupTimelineSingle() {
const diag = analyzeTrackTiming(loadedSingleTrack.points);
setTimelineMode(true);
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)}`
);
}
setTimelineVisible(true);
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);
return;
}
let noteText = 'Общий интервал записи обоих треков.';
if (range.mode === 'union') {
noteText =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
}
const txDiv = trackMetaDiversity(loadedTxTrack.points);
const rxDiv = trackMetaDiversity(loadedRxTrack.points);
if (txDiv.uniquePackets <= 1 && rxDiv.uniquePackets <= 1
&& (txDiv.withMeta > 1 || rxDiv.withMeta > 1)) {
noteText += ' Радио-статистика в точках трека не менялась при записи.';
}
applyTimelineRange(range, noteText);
}
setTimelineVisible(true);
updateTimelineAt(timelineCursor());
loadElevationProfiles();
}
async function refreshTimelineTelemetry() {
if (singleTrackActive && loadedSingleTrack) {
if (!timelineUseProgress) {
const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return;
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = normalizeTelemetry(await res.json());
}
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([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = normalizeTelemetry(await telTx.json());
if (telRx.ok) telemetryRx = normalizeTelemetry(await telRx.json());
}
updateTimelineAt(timelineCursor());
}
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
async function loadAllTracks() {
const txSel = document.getElementById('trackTxSelect');
const rxSel = document.getElementById('trackRxSelect');
const singleSel = document.getElementById('trackSingleSelect');
const prevTx = txSel.value;
const prevRx = rxSel.value;
const prevSingle = singleSel.value;
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
if (!res.ok) throw new Error('tracks ' + res.status);
const tracks = await res.json();
rememberDeviceLabels(tracks);
const fill = (sel, hint) => {
sel.innerHTML = `<option value="">${hint}</option>`;
tracks.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = trackOptionLabel(t);
sel.appendChild(opt);
});
};
fill(singleSel, '— трек —');
fill(txSel, '— TX трек —');
fill(rxSel, '— RX трек —');
if (prevSingle) singleSel.value = prevSingle;
if (prevTx) txSel.value = prevTx;
if (prevRx) rxSel.value = prevRx;
if (!singleTrackActive && !dualTracksActive) {
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
}
}
async function showSingleTrack() {
const id = document.getElementById('trackSingleSelect').value;
if (!id) {
document.getElementById('trackInfo').textContent = 'Выберите трек';
return;
}
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
loadedSingleTrack = normalizeTrack(await res.json());
rememberDeviceLabels([loadedSingleTrack]);
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role;
}
if (!loadedSingleTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
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 }));
}
singleTrackActive = true;
setupTimelineSingle();
const range = singleTrackRange(loadedSingleTrack.points);
if (range && loadedSingleTrack.device_id) {
const telRes = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (telRes.ok) telemetrySingle = normalizeTelemetry(await telRes.json());
updateTimelineAtSingle(timelineCursor());
}
document.getElementById('trackInfo').textContent =
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
updateTrackButtons();
}
function showTracksOnMap() {
if (trackViewMode === 'single') showSingleTrack();
else showDualTracks();
}
async function showDualTracks() {
const txId = document.getElementById('trackTxSelect').value;
const rxId = document.getElementById('trackRxSelect').value;
if (!txId || !rxId) {
document.getElementById('trackInfo').textContent = 'Выберите оба трека';
return;
}
if (txId === rxId) {
document.getElementById('trackInfo').textContent = 'Выберите разные треки';
return;
}
clearTrackLayers();
singleTrackActive = false;
loadedSingleTrack = null;
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`)
]);
loadedTxTrack = normalizeTrack(await txRes.json());
loadedRxTrack = normalizeTrack(await rxRes.json());
rememberDeviceLabels([loadedTxTrack, loadedRxTrack]);
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx', false);
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx', true);
if (!userMovedMap) {
const bounds = L.latLngBounds([]);
[...loadedTxTrack.points, ...loadedRxTrack.points].forEach(p => bounds.extend([p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }));
}
dualTracksActive = true;
setupTimeline();
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (range) {
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = normalizeTelemetry(await telTx.json());
if (telRx.ok) telemetryRx = normalizeTelemetry(await telRx.json());
updateTimelineAt(timelineCursor());
}
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
document.getElementById('trackInfo').textContent =
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
updateTrackButtons();
}
document.getElementById('btnShowTracks').onclick = showTracksOnMap;
document.getElementById('btnHideTracks').onclick = exitTrackMode;
document.getElementById('btnModeSingle').onclick = () => {
trackViewMode = 'single';
document.getElementById('btnModeSingle').classList.add('active');
document.getElementById('btnModeDual').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = '';
document.getElementById('trackPanelDual').style.display = 'none';
document.getElementById('trackInfo').textContent = 'Выберите трек';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
document.getElementById('btnModeDual').onclick = () => {
trackViewMode = 'dual';
document.getElementById('btnModeDual').classList.add('active');
document.getElementById('btnModeSingle').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = 'none';
document.getElementById('trackPanelDual').style.display = '';
document.getElementById('trackInfo').textContent = 'Выберите TX и RX треки';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
async function postCommand(toDeviceId, kind, payload) {
if (!toDeviceId) {
alert('Выберите устройство');
return;
}
const res = await fetch('/api/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_device_id: 'web', to_device_id: toDeviceId, kind, payload })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка команды');
}
}
document.getElementById('btnCmdApply').onclick = () => {
const lines = buildMacroLines();
postCommand(document.getElementById('cmdTargetSelect').value, 'at', { lines });
setCmdFormDirty(false);
};
document.getElementById('btnMapTx').onclick = () => centerMapOnRole('TX');
document.getElementById('btnMapRx').onclick = () => centerMapOnRole('RX');
document.getElementById('btnMapBoth').onclick = () => centerMapOnBoth();
document.getElementById('btnMapRuler').onclick = () => toggleMapRuler();
document.getElementById('btnMapRulerClose').onclick = () => setMapRulerOpen(false);
document.getElementById('btnRulerPick').onclick = () => {
setMapRulerMode('pick');
drawMapRulerChart();
};
document.getElementById('btnRulerAutoTxRx').onclick = async () => {
setMapRulerMode('auto');
await loadMapRulerProfileAuto();
};
document.getElementById('btnRulerClear').onclick = () => resetMapRulerPick();
(function bindMapRulerPointsControls() {
const autoEl = document.getElementById('mapRulerPointsAuto');
const slider = document.getElementById('mapRulerPointsSlider');
if (!autoEl || !slider) return;
autoEl.addEventListener('change', () => {
mapRulerPointsAuto = autoEl.checked;
if (!mapRulerPointsAuto) {
mapRulerManualPoints = Number(slider.value) || 100;
}
const dist = mapRulerPtA && mapRulerPtB
? haversineM(mapRulerPtA.lat, mapRulerPtA.lon, mapRulerPtB.lat, mapRulerPtB.lon)
: 0;
updateMapRulerPointsUi(dist);
scheduleMapRulerProfileReload();
});
slider.addEventListener('input', () => {
if (mapRulerPointsAuto) return;
mapRulerManualPoints = Number(slider.value) || RULER_POINTS_MIN;
updateMapRulerPointsUi(
mapRulerPtA && mapRulerPtB
? haversineM(mapRulerPtA.lat, mapRulerPtA.lon, mapRulerPtB.lat, mapRulerPtB.lon)
: 0
);
});
slider.addEventListener('change', () => {
if (mapRulerPointsAuto) return;
mapRulerManualPoints = Number(slider.value) || RULER_POINTS_MIN;
scheduleMapRulerProfileReload();
});
})();
(function bindMapRulerChartProbe() {
const canvas = document.getElementById('mapRulerCanvas');
if (!canvas) return;
canvas.addEventListener('mousemove', e => {
if (!mapRulerOpen || elevationPointCount(elevProfileMapLine) === 0) return;
const layout = canvas._elevLayout;
if (!layout || layout.plotW <= 0) return;
mapRulerChartHover = true;
clearTimeout(mapRulerLeaveTimer);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const dist = ((x - layout.margin.l) / layout.plotW) * layout.maxDist;
setMapRulerCursor(dist);
});
canvas.addEventListener('mouseleave', () => {
mapRulerChartHover = false;
scheduleClearMapRulerCursor();
});
})();
async function refreshPairedStatus() {
try {
const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const el = document.getElementById('pairedStatus');
if (!data.active || !data.session) {
el.textContent = 'Синхр. трек: нет активной сессии';
return;
}
const s = data.session;
el.textContent = `Сессия #${s.id} · ${s.status} · старт ${new Date(s.start_at * 1000).toLocaleTimeString()}`;
} catch (e) {
console.warn('paired status', e);
}
}
document.getElementById('btnPairedStart').onclick = async () => {
const ids = lastDevices.filter(d => d.device_id && d.device_id.startsWith('android-')).map(d => d.device_id);
const body = ids.length === 2 ? { device_ids: ids, initiator: 'web' } : { initiator: 'web' };
const res = await fetch('/api/paired-tracks/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Не удалось запустить');
return;
}
refreshPairedStatus();
};
function setupTimelineControls() {
const slider = document.getElementById('timeSlider');
const onSlider = () => {
if (!singleTrackActive && !dualTracksActive) return;
modalMode = 'timeline';
updateTimelineAt(timelineCursor());
};
slider.addEventListener('input', onSlider);
slider.addEventListener('change', onSlider);
document.getElementById('btnPlay').onclick = () => {
if (!singleTrackActive && !dualTracksActive) return;
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
return;
}
document.getElementById('btnPlay').textContent = '⏸ Pause';
const step = parseInt(slider.step, 10) || 100;
playTimer = setInterval(() => {
let ms = parseInt(slider.value, 10) + step;
if (ms > timelineSpanMs) ms = 0;
slider.value = String(ms);
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>`;
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 => {
if (id !== d.device_id && markers[id].getLatLng) {
const o = markers[id].getLatLng();
html += `<br>До ${escapeHtml(id)}: ${haversineM(d.lat, d.lon, o.lat, o.lng).toFixed(0)} m (GPS)`;
}
});
} else {
html += `GPS: —<br>`;
}
return html;
}
function updateStatsPanel(d, openModal) {
const html = buildDeviceStatsHtml(d);
setPanelHtml(document.getElementById('stats'), html);
if (openModal) {
openMapModal(html, 'device');
} else if (isModalOpen() && modalMode === 'device' && selectedId === d.device_id) {
syncModalHtml(html);
}
}
async function fetchDevices() {
const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json();
rememberDeviceLabels(devices);
lastDevices = devices;
let tx = 0, rx = 0;
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 = [];
const seen = new Set();
devices.forEach(d => {
const li = document.createElement('li');
let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`;
if (d.rssi != null) label += ` · ${d.rssi} dBm`;
try {
if (d.meta) {
const m = JSON.parse(d.meta);
if (!d.role && m.role) label += ` · ${m.role}`;
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);
list.appendChild(li);
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
seen.add(d.device_id);
bounds.push([d.lat, d.lon]);
const role = roleFromDevice(d);
if (!markers[d.device_id]) {
markers[d.device_id] = L.marker([d.lat, d.lon], { icon: makeRoleIcon(role) }).addTo(map);
} else {
markers[d.device_id].setLatLng([d.lat, d.lon]);
markers[d.device_id].setIcon(makeRoleIcon(role));
}
markers[d.device_id].off('click');
markers[d.device_id].on('click', () => selectDevice(d));
}
});
Object.keys(markers).forEach(id => {
if (!seen.has(id)) {
map.removeLayer(markers[id]);
delete markers[id];
}
});
if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds);
updateGpsDistanceHeader(devices);
if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto();
updateCmdTargetSelect(devices);
if (selectedId && !devices.find(d => d.device_id === selectedId)) {
selectedId = null;
setPanelHtml(document.getElementById('stats'), '<span class="muted">—</span>');
document.getElementById('history').innerHTML = '';
}
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
updateStatsPanel(sel, false);
loadTelemetryHistory(sel.device_id);
}
}
return devices;
}
document.getElementById('cmdTargetSelect').onchange = () => {
const id = document.getElementById('cmdTargetSelect').value;
const d = lastDevices.find(x => x.device_id === id);
setCmdFormDirty(false);
if (d) fillCmdFormFromDevice(d, { force: true });
};
function selectDevice(d) {
selectedId = d.device_id;
setCmdFormDirty(false);
fillCmdFormFromDevice(d, { force: true });
document.querySelectorAll('#deviceList li').forEach(li => {
li.classList.toggle('active', li.dataset.deviceId === d.device_id);
});
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
setMapViewProgrammatically(() => {
map.setView([d.lat, d.lon], Math.max(map.getZoom(), 13));
});
}
updateStatsPanel(d, true);
loadTelemetryHistory(d.device_id);
}
async function loadTelemetryHistory(deviceId) {
const el = document.getElementById('history');
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(deviceId)}&limit=30`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const rows = await res.json();
if (!rows.length) {
el.innerHTML = '<b>История</b>: пуста';
return;
}
let html = '<b>История</b><ul style="margin:4px 0;padding-left:16px">';
rows.forEach(r => {
const role = r.role ? ` ${r.role}` : '';
let pkt = '';
try {
if (r.meta) {
const m = JSON.parse(r.meta);
if (m.packet != null) pkt = ` #${m.packet}`;
}
} catch (e) {}
html += `<li>${new Date(r.ts * 1000).toLocaleTimeString()}${role}${pkt} ${r.rssi ?? '—'} dBm</li>`;
});
el.innerHTML = html + '</ul>';
}
async function pollChat() {
const res = await fetch(`/api/chat?since=${chatSince}`, { cache: 'no-store' });
if (!res.ok) throw new Error('chat ' + res.status);
const msgs = await res.json();
const log = document.getElementById('chatLog');
const myId = document.getElementById('webDeviceId').value.trim() || 'web';
msgs.forEach(m => {
chatSince = Math.max(chatSince, m.ts);
const self = m.device_id === myId;
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(deviceDisplayName(m.device_label ? m : 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) {
setTimeout(() => div.classList.remove('chat-new'), 3000);
}
});
if (msgs.length) {
log.scrollTop = log.scrollHeight;
chatLastReadTs = Math.max(chatLastReadTs, chatSince);
}
}
document.getElementById('chatForm').onsubmit = async e => {
e.preventDefault();
const text = document.getElementById('chatInput').value.trim();
const device_id = document.getElementById('webDeviceId').value.trim() || 'web';
if (!text) return;
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id, text })
});
document.getElementById('chatInput').value = '';
pollChat();
};
function setPollStatus(ok, detail) {
const el = document.getElementById('pollStatus');
if (!el) return;
const time = new Date().toLocaleTimeString();
el.textContent = ok ? `${time}` : `${time}`;
el.title = detail || (ok ? 'Данные обновляются автоматически' : 'Ошибка опроса');
el.style.color = ok ? '#aaa' : '#e94560';
}
async function pollOnce() {
if (document.hidden) return;
try {
await fetchDevices();
setPollStatus(true);
} catch (e) {
console.warn('poll devices', e);
setPollStatus(false, String(e.message || e));
}
pollTick++;
if (pollTick % Math.round(CHAT_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await pollChat();
} catch (e) {
console.warn('poll chat', e);
}
}
if (pollTick % Math.round(TRACKS_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await loadAllTracks();
} catch (e) {
console.warn('poll tracks', e);
}
}
if ((dualTracksActive || singleTrackActive) && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await refreshTimelineTelemetry();
} catch (e) {
console.warn('poll timeline telemetry', e);
}
}
if (pollTick % Math.round(2000 / DEVICE_POLL_MS) === 0) {
try {
await refreshPairedStatus();
} catch (e) {
console.warn('poll paired', e);
}
}
}
function updateCmdTargetSelect(devices) {
lastDevices = devices;
const sel = document.getElementById('cmdTargetSelect');
const prev = sel.value;
sel.innerHTML = '<option value="">— устройство —</option>';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`;
opt.textContent = label;
sel.appendChild(opt);
});
if (prev) sel.value = prev;
const target = devices.find(d => d.device_id === sel.value);
if (target) fillCmdFormFromDevice(target);
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await pollOnce();
schedulePoll();
}, DEVICE_POLL_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollOnce();
}
});
window.addEventListener('resize', () => {
drawElevationChart();
if (mapRulerOpen) drawMapRulerChart();
});
schedulePoll();
setupCmdFormDirtyTracking();
setupTimelineControls();
loadAllTracks();
refreshPairedStatus();
</script>
</body>
</html>