generated from Grigo/AndroidTemplate
2271 lines
89 KiB
HTML
2271 lines
89 KiB
HTML
<!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%; }
|
||
#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; }
|
||
.radio-rx { color: #4fc3f7; }
|
||
.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;
|
||
}
|
||
#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; }
|
||
</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> <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>
|
||
<canvas id="mapRulerCanvas" width="800" height="120"></canvas>
|
||
</div>
|
||
<div id="map"></div>
|
||
</div>
|
||
<aside>
|
||
<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 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>
|
||
<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>
|
||
</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="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>
|
||
const map = L.map('map').setView([55.75, 37.62], 10);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap'
|
||
}).addTo(map);
|
||
|
||
const 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 trackTxLayer = null;
|
||
let trackRxLayer = null;
|
||
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 = [];
|
||
let elevProfileTx = null;
|
||
let elevProfileRx = null;
|
||
let elevProfileSingle = 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 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 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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 renderTimelineCompare(txTel, rxTel, txId, rxId) {
|
||
const txSnap = txTel ? telemetryToSnap(txTel) : RadioUI.parseRadioSnapshot(null);
|
||
const rxSnap = rxTel ? telemetryToSnap(rxTel) : RadioUI.parseRadioSnapshot(null);
|
||
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);
|
||
}
|
||
|
||
function fillCmdFormFromDevice(d) {
|
||
if (!d) return;
|
||
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
|
||
document.getElementById('cmdCurrentValues').innerHTML =
|
||
RadioUI.formatRadioPanel(snap, new Set());
|
||
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 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 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 linePts = buildDirectLinePoints(a, b, 10);
|
||
elevProfileMapLine = await fetchElevationProfile(linePts);
|
||
mapRulerLoadState = 'done';
|
||
const dist = haversineM(a.lat, a.lon, b.lat, b.lon);
|
||
const n = elevationPointCount(elevProfileMapLine);
|
||
if (n > 0) {
|
||
const src = elevProfileMapLine.source === 'elevation' ? 'высоты'
|
||
: elevProfileMapLine.source === 'server' ? 'сервер'
|
||
: elevProfileMapLine.source || 'данные';
|
||
mapRulerBaseStatus = `${dist.toFixed(0)} m · ${src} · ${n} точек`;
|
||
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 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) {
|
||
const samples = 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;
|
||
return {
|
||
step_m: stepM,
|
||
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) {
|
||
if (!points || !points.length) return null;
|
||
let lastError = null;
|
||
|
||
const health = await ensureElevationApi();
|
||
if (!health.ok) {
|
||
const cached = buildLocalElevationProfile(points, 10);
|
||
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 res = await fetch('/api/elevation/profile', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ points, step_m: 10 })
|
||
});
|
||
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, 10);
|
||
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(elevProfileTx) > 0) {
|
||
series.push({ color: TX_COLOR, profile: elevProfileTx, cursor: cursors?.tx, label: 'TX' });
|
||
}
|
||
if (elevationPointCount(elevProfileRx) > 0) {
|
||
series.push({ color: RX_COLOR, profile: elevProfileRx, cursor: cursors?.rx, label: 'RX' });
|
||
}
|
||
} else if (singleTrackActive && elevationPointCount(elevProfileSingle) > 0) {
|
||
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.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);
|
||
}
|
||
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 loadElevationProfiles() {
|
||
elevProfileTx = null;
|
||
elevProfileRx = null;
|
||
elevProfileSingle = null;
|
||
elevationLoadState = 'loading';
|
||
setElevationStatus('загрузка…');
|
||
drawElevationChart();
|
||
|
||
if (singleTrackActive && loadedSingleTrack?.points?.length) {
|
||
elevProfileSingle = await fetchElevationProfile(
|
||
loadedSingleTrack.points, loadedSingleTrack.id);
|
||
} else if (dualTracksActive) {
|
||
const [txProf, rxProf] = await Promise.all([
|
||
loadedTxTrack?.points?.length
|
||
? fetchElevationProfile(loadedTxTrack.points, loadedTxTrack.id) : null,
|
||
loadedRxTrack?.points?.length
|
||
? fetchElevationProfile(loadedRxTrack.points, loadedRxTrack.id) : null
|
||
]);
|
||
elevProfileTx = txProf;
|
||
elevProfileRx = rxProf;
|
||
}
|
||
|
||
elevationLoadState = 'done';
|
||
const hasData = elevationPointCount(elevProfileSingle) > 0
|
||
|| elevationPointCount(elevProfileTx) > 0
|
||
|| elevationPointCount(elevProfileRx) > 0;
|
||
if (hasData) {
|
||
const ref = elevProfileSingle || elevProfileTx || elevProfileRx;
|
||
const srcLabel = ref?.source === 'elevation' ? 'высоты'
|
||
: ref?.source === 'server' ? 'сервер' : (ref?.source || 'данные');
|
||
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 || elevProfileTx?.api_error || elevProfileRx?.api_error;
|
||
setElevationStatus(err ? `ошибка: ${err}` : 'нет данных');
|
||
}
|
||
drawElevationChart();
|
||
requestAnimationFrame(() => drawElevationChart(
|
||
singleTrackActive
|
||
? { single: trackDistanceAtTime(loadedSingleTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)) }
|
||
: dualTracksActive
|
||
? {
|
||
tx: trackDistanceAtTime(loadedTxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)),
|
||
rx: trackDistanceAtTime(loadedRxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10))
|
||
}
|
||
: 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;
|
||
mapModalBody.innerHTML = html;
|
||
mapModal.classList.add('open');
|
||
loadModalPosition();
|
||
}
|
||
|
||
function syncModalHtml(html) {
|
||
if (!isModalOpen()) return;
|
||
mapModalBody.innerHTML = 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 first = points[0];
|
||
const last = points[points.length - 1];
|
||
if (t <= first.ts) {
|
||
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
|
||
}
|
||
if (t >= last.ts) {
|
||
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
|
||
}
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const a = points[i];
|
||
const b = points[i + 1];
|
||
if (t >= a.ts && t <= b.ts) {
|
||
const f = (t - a.ts) / (b.ts - a.ts);
|
||
return {
|
||
lat: a.lat + (b.lat - a.lat) * f,
|
||
lon: a.lon + (b.lon - a.lon) * f,
|
||
meta: t - a.ts < b.ts - t ? a.meta : b.meta,
|
||
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
|
||
};
|
||
}
|
||
}
|
||
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
|
||
}
|
||
|
||
function overlapRange(txPts, rxPts) {
|
||
if (!txPts.length || !rxPts.length) return null;
|
||
const min = Math.max(txPts[0].ts, rxPts[0].ts);
|
||
const max = Math.min(txPts[txPts.length - 1].ts, 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(txPts[0].ts, rxPts[0].ts);
|
||
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
|
||
if (min >= max) return null;
|
||
return { min, max, mode: 'union' };
|
||
}
|
||
|
||
function nearestTelemetry(rows, t) {
|
||
if (!rows.length) return null;
|
||
let best = rows[0];
|
||
let bestD = Math.abs(best.ts - t);
|
||
for (const r of rows) {
|
||
const d = Math.abs(r.ts - t);
|
||
if (d < bestD) { best = r; bestD = d; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function clearTrackLayers() {
|
||
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; }
|
||
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; }
|
||
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;
|
||
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) {
|
||
const latlngs = track.points.map(p => [p.lat, p.lon]);
|
||
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
|
||
if (store === 'tx') trackTxLayer = layer;
|
||
else trackRxLayer = layer;
|
||
const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
|
||
track.points.forEach(p => {
|
||
const m = L.circleMarker([p.lat, p.lon], { radius: 3, color, fillColor: color, fillOpacity: 0.8 });
|
||
m.addTo(map);
|
||
m.on('click', () => {
|
||
const rel = Math.max(0, Math.min(Math.round(p.ts - overlapMin), parseInt(document.getElementById('timeSlider').max, 10)));
|
||
document.getElementById('timeSlider').value = rel;
|
||
modalMode = 'timeline';
|
||
updateTimelineAt(overlapMin + rel, { openModal: true });
|
||
});
|
||
markerList.push(m);
|
||
});
|
||
}
|
||
|
||
function buildTimelineModalHtml(t, txPos, rxPos) {
|
||
if (!txPos || !rxPos) return '';
|
||
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
|
||
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
||
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
|
||
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
|
||
const txTel = nearestTelemetry(telemetryTx, t);
|
||
const rxTel = nearestTelemetry(telemetryRx, t);
|
||
html += renderTimelineCompare(
|
||
txTel || { meta: txPos.meta, role: 'TX', rssi: null },
|
||
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
|
||
loadedTxTrack?.device_id,
|
||
loadedRxTrack?.device_id
|
||
);
|
||
return html;
|
||
}
|
||
|
||
function singleTrackRange(points) {
|
||
if (!points || !points.length) return null;
|
||
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
|
||
}
|
||
|
||
function updateTimelineAt(t, opts) {
|
||
const openModal = opts && opts.openModal;
|
||
if (singleTrackActive && loadedSingleTrack) {
|
||
updateTimelineAtSingle(t, openModal);
|
||
return;
|
||
}
|
||
if (!loadedTxTrack || !loadedRxTrack) return;
|
||
const txPos = positionAt(loadedTxTrack.points, t);
|
||
const rxPos = positionAt(loadedRxTrack.points, t);
|
||
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
|
||
|
||
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(t, txPos, rxPos);
|
||
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
|
||
openMapModal(modalHtml, 'timeline');
|
||
}
|
||
}
|
||
|
||
const txTel = nearestTelemetry(telemetryTx, t);
|
||
const rxTel = nearestTelemetry(telemetryRx, t);
|
||
document.getElementById('timelineStats').innerHTML = renderTimelineCompare(
|
||
txTel,
|
||
rxTel,
|
||
loadedTxTrack?.device_id,
|
||
loadedRxTrack?.device_id
|
||
);
|
||
drawElevationChart({
|
||
tx: trackDistanceAtTime(loadedTxTrack, t),
|
||
rx: trackDistanceAtTime(loadedRxTrack, t)
|
||
});
|
||
}
|
||
|
||
function updateTimelineAtSingle(t, openModal) {
|
||
const track = loadedSingleTrack;
|
||
if (!track) return;
|
||
const pos = positionAt(track.points, t);
|
||
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
|
||
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>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
||
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
|
||
const tel = nearestTelemetry(telemetrySingle, t);
|
||
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
|
||
html += RadioUI.formatRadioPanel(snap, new Set());
|
||
if (tel) html += '<br>' + formatTelemetryRow(tel, new Set());
|
||
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
|
||
openMapModal(html, 'timeline');
|
||
}
|
||
}
|
||
const tel = nearestTelemetry(telemetrySingle, t);
|
||
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
|
||
document.getElementById('timelineStats').innerHTML = tel
|
||
? RadioUI.formatRadioPanel(snap, new Set())
|
||
: '<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 setupTimelineSingle() {
|
||
const range = singleTrackRange(loadedSingleTrack.points);
|
||
const note = document.getElementById('timelineNote');
|
||
setTimelineMode(true);
|
||
if (!range) {
|
||
setTimelineVisible(false);
|
||
return;
|
||
}
|
||
overlapMin = range.min;
|
||
overlapMax = range.max;
|
||
const span = Math.max(1, Math.round(overlapMax - overlapMin));
|
||
const slider = document.getElementById('timeSlider');
|
||
slider.min = 0;
|
||
slider.max = span;
|
||
slider.value = 0;
|
||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
|
||
setTimelineVisible(true);
|
||
updateTimelineAtSingle(overlapMin);
|
||
loadElevationProfiles();
|
||
}
|
||
|
||
function setupTimeline() {
|
||
setTimelineMode(false);
|
||
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
||
const note = document.getElementById('timelineNote');
|
||
if (!range) {
|
||
setTimelineVisible(false);
|
||
return;
|
||
}
|
||
overlapMin = range.min;
|
||
overlapMax = range.max;
|
||
const span = Math.max(1, Math.round(overlapMax - overlapMin));
|
||
const slider = document.getElementById('timeSlider');
|
||
slider.min = 0;
|
||
slider.max = span;
|
||
slider.value = 0;
|
||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||
if (range.mode === 'union') {
|
||
note.textContent =
|
||
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
|
||
} else {
|
||
note.textContent = 'Общий интервал записи обоих треков.';
|
||
}
|
||
setTimelineVisible(true);
|
||
updateTimelineAt(overlapMin);
|
||
loadElevationProfiles();
|
||
}
|
||
|
||
async function refreshTimelineTelemetry() {
|
||
if (singleTrackActive && loadedSingleTrack) {
|
||
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 = await res.json();
|
||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||
updateTimelineAtSingle(t);
|
||
return;
|
||
}
|
||
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
|
||
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 = await telTx.json();
|
||
if (telRx.ok) telemetryRx = await telRx.json();
|
||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||
updateTimelineAt(t);
|
||
}
|
||
|
||
function trackOptionLabel(t) {
|
||
const start = new Date(t.started_at * 1000).toLocaleString();
|
||
const role = t.role ? ` · ${t.role}` : '';
|
||
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
|
||
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();
|
||
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; }
|
||
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
|
||
loadedSingleTrack = await res.json();
|
||
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');
|
||
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 = await telRes.json();
|
||
updateTimelineAtSingle(overlapMin);
|
||
}
|
||
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; }
|
||
|
||
const [txRes, rxRes] = await Promise.all([
|
||
fetch(`/api/tracks/${txId}`),
|
||
fetch(`/api/tracks/${rxId}`)
|
||
]);
|
||
loadedTxTrack = await txRes.json();
|
||
loadedRxTrack = await rxRes.json();
|
||
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
|
||
document.getElementById('trackInfo').textContent = 'Пустой трек';
|
||
return;
|
||
}
|
||
|
||
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx');
|
||
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx');
|
||
|
||
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 = await telTx.json();
|
||
if (telRx.ok) telemetryRx = await telRx.json();
|
||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||
updateTimelineAt(t);
|
||
}
|
||
|
||
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 });
|
||
};
|
||
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 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();
|
||
};
|
||
document.getElementById('timeSlider').oninput = e => {
|
||
modalMode = 'timeline';
|
||
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
|
||
};
|
||
document.getElementById('btnPlay').onclick = () => {
|
||
if (playTimer) {
|
||
clearInterval(playTimer);
|
||
playTimer = null;
|
||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||
return;
|
||
}
|
||
const slider = document.getElementById('timeSlider');
|
||
document.getElementById('btnPlay').textContent = '⏸ Pause';
|
||
playTimer = setInterval(() => {
|
||
let v = parseInt(slider.value, 10) + 1;
|
||
if (v > parseInt(slider.max, 10)) v = 0;
|
||
slider.value = v;
|
||
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
|
||
}, 1000);
|
||
};
|
||
|
||
function buildDeviceStatsHtml(d) {
|
||
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
|
||
const changed = RadioUI.diffSnapshots(prevDeviceSnap, snap);
|
||
prevDeviceSnap = snap;
|
||
let html = RadioUI.formatRadioPanel(snap, changed);
|
||
html += `<b>${escapeHtml(d.device_id)}</b><br>Range: ${d.range_m ?? '—'} m<br>`;
|
||
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
|
||
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
|
||
Object.keys(markers).forEach(id => {
|
||
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);
|
||
document.getElementById('stats').innerHTML = 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();
|
||
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()}`;
|
||
const list = document.getElementById('deviceList');
|
||
list.innerHTML = '';
|
||
const bounds = [];
|
||
const seen = new Set();
|
||
devices.forEach(d => {
|
||
const li = document.createElement('li');
|
||
let label = d.device_id;
|
||
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.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) {
|
||
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);
|
||
if (d) fillCmdFormFromDevice(d);
|
||
};
|
||
|
||
function selectDevice(d) {
|
||
selectedId = d.device_id;
|
||
fillCmdFormFromDevice(d);
|
||
document.querySelectorAll('#deviceList li').forEach(li => {
|
||
li.classList.toggle('active', li.textContent.startsWith(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(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 = d.device_id;
|
||
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();
|
||
loadAllTracks();
|
||
refreshPairedStatus();
|
||
</script>
|
||
</body>
|
||
</html>
|