generated from Grigo/AndroidTemplate
3061 lines
119 KiB
HTML
3061 lines
119 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%; }
|
||
.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; }
|
||
.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;
|
||
}
|
||
#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> <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-16e';
|
||
|
||
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 };
|
||
}
|
||
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 };
|
||
}
|
||
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,
|
||
};
|
||
}
|
||
|
||
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 = 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;
|
||
return {
|
||
meta: pos.meta,
|
||
role: roleFallback,
|
||
rssi: pos.rssi,
|
||
ts: t,
|
||
lat: pos.lat,
|
||
lon: pos.lon,
|
||
};
|
||
}
|
||
|
||
function mergeTelCoords(tel, pos) {
|
||
if (!tel || !pos) return tel;
|
||
return { ...tel, lat: pos.lat, lon: pos.lon };
|
||
}
|
||
|
||
function snapAtTime(track, telemetryRows, t, roleFallback) {
|
||
const pos = positionAt(track?.points, t);
|
||
if (pos?.meta && String(pos.meta).length > 2) {
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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 formatPacketTime(tel) {
|
||
if (!tel) return null;
|
||
let ts = tel.ts;
|
||
if (tel.meta) {
|
||
let o = tel.meta;
|
||
if (typeof o === 'string') {
|
||
try { o = JSON.parse(o); } catch (e) { o = null; }
|
||
}
|
||
if (o) {
|
||
if (o.packet_ts != null) ts = o.packet_ts;
|
||
else if (o.ts != null) ts = o.ts;
|
||
else if (o.fields) {
|
||
for (const [k, v] of Object.entries(o.fields)) {
|
||
if (/time/i.test(k)) return String(v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (ts == null || !Number.isFinite(Number(ts)) || Number(ts) <= 1) return null;
|
||
const n = Number(ts);
|
||
const ms = n < 1e12 ? n * 1000 : n;
|
||
return new Date(ms).toLocaleTimeString();
|
||
}
|
||
|
||
function enrichSnapFromTel(snap, tel) {
|
||
snap.gps = formatCoords(tel) || '—';
|
||
snap.packetTime = formatPacketTime(tel) || '—';
|
||
return snap;
|
||
}
|
||
|
||
function renderTimelineCompare(txTel, rxTel, txId, rxId) {
|
||
const txSnap = 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 };
|
||
}
|
||
if (tNum >= t1) {
|
||
return { lat: Number(last.lat), lon: Number(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];
|
||
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
|
||
};
|
||
}
|
||
}
|
||
return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
|
||
}
|
||
|
||
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>
|