Files
LoraMapTester/server/static/index.html
T

1228 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LoraTester</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; }
header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 1.2rem; flex: 1; }
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr auto; height: calc(100vh - 52px); }
@media (max-width: 900px) {
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(180px, 1fr) auto; }
}
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
#trackTimeline {
display: none; grid-column: 1 / -1; grid-row: 2;
background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
}
#trackTimeline.visible { display: block; }
.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; }
#timelineStatsPanel {
display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333;
}
#timelineStatsPanel.visible { display: block; }
#timelineStatsPanel.timeline-single #timelineStats { grid-template-columns: 1fr; }
#timelineStatsPanel.timeline-single .timeline-col.rx { display: none; }
#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 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; font-size: 0.8rem; }
.timeline-col { background: #0a0a14; padding: 8px; border-radius: 4px; }
.timeline-col h3 { margin: 0 0 6px; font-size: 0.85rem; }
.timeline-col.tx h3 { color: #e94560; }
.timeline-col.rx h3 { color: #4fc3f7; }
#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; }
#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; }
</style>
</head>
<body>
<header>
<h1>LoraTester</h1>
<span class="muted" id="status">загрузка…</span>
<span class="muted" id="pollStatus" title="Автообновление">⟳ 1 с</span>
<span class="legend"><span class="legend-tx">● TX</span> &nbsp; <span class="legend-rx">● RX</span></span>
<input type="text" id="webDeviceId" placeholder="ник в чате" style="padding:6px;border-radius:4px;border:none;max-width:160px" />
</header>
<main>
<div id="mapWrap">
<div id="map"></div>
</div>
<aside>
<div class="panel">
<h2>Устройства</h2>
<ul id="deviceList"></ul>
</div>
<div class="panel">
<h2>Статистика</h2>
<div id="stats">Выберите устройство</div>
<div id="history" class="muted" style="margin-top:8px"></div>
</div>
<div class="panel" id="controlPanel">
<h2>Управление</h2>
<label class="muted">Целевое устройство</label>
<select id="cmdTargetSelect"><option value=""></option></select>
<input type="text" id="cmdAtInput" placeholder="AT+SF=7 …" />
<div class="cmd-row">
<button type="button" id="btnCmdAt">AT</button>
<button type="button" id="btnCmdTx">AT+TX</button>
<button type="button" id="btnCmdRx">AT+RX</button>
</div>
<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 class="timeline-col tx"><h3 id="timelineCol1Label">TX</h3><div id="statsTx"></div></div>
<div class="timeline-col rx"><h3>RX</h3><div id="statsRx"></div></div>
</div>
</div>
</div>
<div class="panel" style="flex:1;display:flex;flex-direction:column">
<h2>Чат</h2>
<div id="chatLog"></div>
<form id="chatForm">
<input type="text" id="chatInput" placeholder="Сообщение…" autocomplete="off" />
<button type="submit"></button>
</form>
</div>
</aside>
<div id="trackTimeline">
<div class="timeline-bar">
<div class="timeline-bar-header">
<span class="timeline-bar-title">Время теста</span>
<button type="button" id="btnPlay" class="muted" style="padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
</div>
</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>
const map = L.map('map').setView([55.75, 37.62], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap'
}).addTo(map);
const markers = {};
let selectedId = null;
let chatSince = 0;
let mapInitialFitDone = false;
let userMovedMap = false;
let programmaticMove = false;
let trackTxLayer = null;
let trackRxLayer = null;
let trackTxMarkers = [];
let trackRxMarkers = [];
let linkLine = null;
let ghostTx = null;
let ghostRx = null;
let loadedTxTrack = null;
let loadedRxTrack = null;
let loadedSingleTrack = null;
let telemetryTx = [];
let telemetryRx = [];
let telemetrySingle = [];
let overlapMin = 0;
let overlapMax = 0;
let playTimer = null;
let pollTimer = null;
let pollTick = 0;
let trackViewMode = 'single';
let dualTracksActive = false;
let singleTrackActive = false;
let lastDevices = [];
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;
});
function isNullIsland(lat, lon) {
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
}
function roleColor(role) {
return role === 'RX' ? RX_COLOR : TX_COLOR;
}
function roleLabel(role) {
if (role === 'TX') return 'Передатчик (TX)';
if (role === 'RX') return 'Приёмник (RX)';
return role || '—';
}
function roleFromDevice(d) {
if (d.role) return d.role;
try {
if (d.meta) return JSON.parse(d.meta).role;
} catch (e) {}
return null;
}
function makeRoleIcon(role) {
const color = roleColor(role);
return L.divIcon({
className: '',
html: `<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 4px #000"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7]
});
}
function haversineM(lat1, lon1, lat2, lon2) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1))*Math.cos(toRad(lat2))*Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
function setMapViewProgrammatically(fn) {
programmaticMove = true;
fn();
setTimeout(() => { programmaticMove = false; }, 0);
}
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: 14 }));
}
mapInitialFitDone = true;
}
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function formatMeta(meta) {
if (!meta) return '';
try {
const m = typeof meta === 'string' ? JSON.parse(meta) : meta;
const lines = [];
const skip = new Set(['send', 'receive']);
const shown = new Set();
const addLine = (label, value) => {
if (value == null || value === '') return;
const key = label.toLowerCase();
if (shown.has(key)) return;
shown.add(key);
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(value))}`);
};
if (m.fields && typeof m.fields === 'object') {
for (const [k, v] of Object.entries(m.fields)) {
if (skip.has(k.toLowerCase())) continue;
addLine(k, v);
}
}
addLine('Роль', m.role ? roleLabel(m.role) : null);
addLine('Кадр', m.frame);
addLine('Мощность TX', m.power_dbm != null ? `${m.power_dbm} dBm` : null);
addLine('RSSI', m.rssi_dbm != null ? `${m.rssi_dbm} dBm` : null);
addLine('SNR', m.snr_db != null ? `${m.snr_db} dB` : null);
addLine('Частота', m.frequency_hz ? `${(m.frequency_hz/1e6).toFixed(3)} MHz` : null);
addLine('SF', m.spreading_factor);
addLine('BW', m.bandwidth_khz != null ? `${m.bandwidth_khz} kHz` : null);
addLine('Пакет', m.packet);
addLine('Payload', m.payload);
addLine('On Air', m.on_air_ms != null ? `${m.on_air_ms} ms` : null);
addLine('TX Speed', m.tx_pkt_per_s != null ? `${m.tx_pkt_per_s} pkt/s` : null);
addLine('RX Speed', m.rx_pkt_per_s != null ? `${m.rx_pkt_per_s} pkt/s` : null);
addLine('PER', m.per_percent != null ? `${m.per_percent} %` : null);
return lines.length ? lines.join('<br>') : '';
} catch (e) {
return '';
}
}
function formatTelemetryRow(r) {
let html = formatMeta(r.meta);
html += `RSSI: ${r.rssi ?? '—'} dBm<br>`;
html += `Range: ${r.range_m ?? '—'} m<br>`;
if (r.lat != null && r.lon != null && !isNullIsland(r.lat, r.lon)) {
html += `GPS: ${r.lat.toFixed(5)}, ${r.lon.toFixed(5)}`;
}
return html;
}
/* --- Draggable modal --- */
const mapModal = document.getElementById('mapModal');
const mapModalBody = document.getElementById('mapModalBody');
const mapModalHeader = document.getElementById('mapModalHeader');
let modalDrag = null;
/** null | 'device' | 'timeline' */
let modalMode = null;
function isModalOpen() {
return mapModal.classList.contains('open');
}
function loadModalPosition() {
try {
const x = sessionStorage.getItem('modalX');
const y = sessionStorage.getItem('modalY');
if (x != null && y != null) {
mapModal.style.left = x + 'px';
mapModal.style.top = y + 'px';
} else {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
} catch (e) {
mapModal.style.left = '24px';
mapModal.style.top = '80px';
}
}
function openMapModal(html, mode) {
if (mode) modalMode = mode;
mapModalBody.innerHTML = html;
mapModal.classList.add('open');
loadModalPosition();
}
function syncModalHtml(html) {
if (!isModalOpen()) return;
mapModalBody.innerHTML = html;
}
function closeMapModal() {
mapModal.classList.remove('open');
modalMode = null;
}
document.getElementById('mapModalClose').onclick = closeMapModal;
mapModalHeader.addEventListener('pointerdown', e => {
if (e.target.id === 'mapModalClose') return;
modalDrag = {
startX: e.clientX,
startY: e.clientY,
left: mapModal.offsetLeft,
top: mapModal.offsetTop
};
mapModalHeader.setPointerCapture(e.pointerId);
});
mapModalHeader.addEventListener('pointermove', e => {
if (!modalDrag) return;
const left = modalDrag.left + (e.clientX - modalDrag.startX);
const top = modalDrag.top + (e.clientY - modalDrag.startY);
mapModal.style.left = Math.max(0, left) + 'px';
mapModal.style.top = Math.max(0, top) + 'px';
});
mapModalHeader.addEventListener('pointerup', e => {
if (!modalDrag) return;
try {
sessionStorage.setItem('modalX', String(mapModal.offsetLeft));
sessionStorage.setItem('modalY', String(mapModal.offsetTop));
} catch (err) {}
modalDrag = null;
mapModalHeader.releasePointerCapture(e.pointerId);
});
/* --- Track helpers --- */
function positionAt(points, t) {
if (!points || !points.length) return null;
const first = points[0];
const last = points[points.length - 1];
if (t <= first.ts) {
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
}
if (t >= last.ts) {
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
}
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
if (t >= a.ts && t <= b.ts) {
const f = (t - a.ts) / (b.ts - a.ts);
return {
lat: a.lat + (b.lat - a.lat) * f,
lon: a.lon + (b.lon - a.lon) * f,
meta: t - a.ts < b.ts - t ? a.meta : b.meta,
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
};
}
}
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
}
function overlapRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const min = Math.max(txPts[0].ts, rxPts[0].ts);
const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
if (min >= max) return null;
return { min, max, mode: 'overlap' };
}
/** Timeline range: prefer overlap; else full union of both tracks. */
function timelineRange(txPts, rxPts) {
if (!txPts.length || !rxPts.length) return null;
const overlap = overlapRange(txPts, rxPts);
if (overlap) return overlap;
const min = Math.min(txPts[0].ts, rxPts[0].ts);
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
if (min >= max) return null;
return { min, max, mode: 'union' };
}
function nearestTelemetry(rows, t) {
if (!rows.length) return null;
let best = rows[0];
let bestD = Math.abs(best.ts - t);
for (const r of rows) {
const d = Math.abs(r.ts - t);
if (d < bestD) { best = r; bestD = d; }
}
return best;
}
function clearTrackLayers() {
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; }
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; }
trackTxMarkers.forEach(m => map.removeLayer(m));
trackRxMarkers.forEach(m => map.removeLayer(m));
trackTxMarkers = [];
trackRxMarkers = [];
if (linkLine) { map.removeLayer(linkLine); linkLine = null; }
if (ghostTx) { map.removeLayer(ghostTx); ghostTx = null; }
if (ghostRx) { map.removeLayer(ghostRx); ghostRx = null; }
}
function updateTrackButtons() {
const active = dualTracksActive || singleTrackActive;
const hideBtn = document.getElementById('btnHideTracks');
if (hideBtn) hideBtn.disabled = !active;
}
function exitTrackMode() {
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
loadedSingleTrack = null;
telemetryTx = [];
telemetryRx = [];
telemetrySingle = [];
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
}
setTimelineVisible(false);
if (isModalOpen() && modalMode === 'timeline') {
closeMapModal();
}
document.getElementById('trackInfo').textContent =
trackViewMode === 'dual' ? 'Выберите TX и RX треки' : 'Выберите трек';
updateTrackButtons();
}
function drawTrackLine(track, color, store) {
const latlngs = track.points.map(p => [p.lat, p.lon]);
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
if (store === 'tx') trackTxLayer = layer;
else trackRxLayer = layer;
const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
track.points.forEach(p => {
const m = L.circleMarker([p.lat, p.lon], { radius: 3, color, fillColor: color, fillOpacity: 0.8 });
m.addTo(map);
m.on('click', () => {
const rel = Math.max(0, Math.min(Math.round(p.ts - overlapMin), parseInt(document.getElementById('timeSlider').max, 10)));
document.getElementById('timeSlider').value = rel;
modalMode = 'timeline';
updateTimelineAt(overlapMin + rel, { openModal: true });
});
markerList.push(m);
});
}
function buildTimelineModalHtml(t, txPos, rxPos) {
if (!txPos || !rxPos) return '';
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><br>`;
html += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
html += formatMeta(txPos.meta);
html += `<br><span class="legend-rx">RX</span> ${rxPos.lat.toFixed(5)}, ${rxPos.lon.toFixed(5)}<br>`;
html += formatMeta(rxPos.meta);
const txTel = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
if (txTel || rxTel) {
html += '<br><br>';
if (txTel) html += `<span class="legend-tx">TX stats</span><br>${formatTelemetryRow(txTel)}<br>`;
if (rxTel) html += `<span class="legend-rx">RX stats</span><br>${formatTelemetryRow(rxTel)}`;
}
return html;
}
function singleTrackRange(points) {
if (!points || !points.length) return null;
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
}
function updateTimelineAt(t, opts) {
const openModal = opts && opts.openModal;
if (singleTrackActive && loadedSingleTrack) {
updateTimelineAtSingle(t, openModal);
return;
}
if (!loadedTxTrack || !loadedRxTrack) return;
const txPos = positionAt(loadedTxTrack.points, t);
const rxPos = positionAt(loadedRxTrack.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine);
if (txPos) {
ghostTx = L.circleMarker([txPos.lat, txPos.lon], {
radius: 10, color: TX_COLOR, fillColor: TX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (rxPos) {
ghostRx = L.circleMarker([rxPos.lat, rxPos.lon], {
radius: 10, color: RX_COLOR, fillColor: RX_COLOR, fillOpacity: 0.9, weight: 3
}).addTo(map);
}
if (txPos && rxPos) {
const dist = haversineM(txPos.lat, txPos.lon, rxPos.lat, rxPos.lon);
document.getElementById('distanceNow').textContent =
`Расстояние GPS: ${dist.toFixed(0)} m`;
linkLine = L.polyline(
[[txPos.lat, txPos.lon], [rxPos.lat, rxPos.lon]],
{ color: '#00ff88', weight: 3, dashArray: '6,6' }
).addTo(map);
const modalHtml = buildTimelineModalHtml(t, txPos, rxPos);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(modalHtml, 'timeline');
}
}
const txTel = nearestTelemetry(telemetryTx, t);
const rxTel = nearestTelemetry(telemetryRx, t);
document.getElementById('statsTx').innerHTML = txTel
? formatTelemetryRow(txTel) : '<span class="muted">нет данных</span>';
document.getElementById('statsRx').innerHTML = rxTel
? formatTelemetryRow(rxTel) : '<span class="muted">нет данных</span>';
}
function updateTimelineAtSingle(t, openModal) {
const track = loadedSingleTrack;
if (!track) return;
const pos = positionAt(track.points, t);
document.getElementById('timeCurrent').textContent = new Date(t * 1000).toLocaleTimeString();
if (ghostTx) map.removeLayer(ghostTx);
if (ghostRx) map.removeLayer(ghostRx);
if (linkLine) map.removeLayer(linkLine);
ghostTx = null;
ghostRx = null;
linkLine = null;
if (pos) {
const color = track.role === 'RX' ? RX_COLOR : TX_COLOR;
ghostTx = L.circleMarker([pos.lat, pos.lon], {
radius: 10, color, fillColor: color, fillOpacity: 0.9, weight: 3
}).addTo(map);
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
html += formatMeta(pos.meta);
const tel = nearestTelemetry(telemetrySingle, t);
if (tel) html += '<br>' + formatTelemetryRow(tel);
if (openModal || (isModalOpen() && modalMode === 'timeline')) {
openMapModal(html, 'timeline');
}
}
const tel = nearestTelemetry(telemetrySingle, t);
document.getElementById('statsTx').innerHTML = tel
? formatTelemetryRow(tel) : '<span class="muted">нет данных</span>';
}
function setTimelineVisible(visible) {
document.getElementById('trackTimeline').classList.toggle('visible', visible);
document.getElementById('timelineStatsPanel').classList.toggle('visible', visible);
}
function setTimelineMode(single) {
const statsPanel = document.getElementById('timelineStatsPanel');
statsPanel.classList.toggle('timeline-single', single);
}
function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points);
const note = document.getElementById('timelineNote');
setTimelineMode(true);
document.getElementById('timelineCol1Label').textContent =
loadedSingleTrack.role === 'RX' ? 'RX' : 'TX';
if (!range) {
setTimelineVisible(false);
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
setTimelineVisible(true);
updateTimelineAtSingle(overlapMin);
}
function setupTimeline() {
setTimelineMode(false);
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const note = document.getElementById('timelineNote');
if (!range) {
setTimelineVisible(false);
return;
}
overlapMin = range.min;
overlapMax = range.max;
const span = Math.max(1, Math.round(overlapMax - overlapMin));
const slider = document.getElementById('timeSlider');
slider.min = 0;
slider.max = span;
slider.value = 0;
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
if (range.mode === 'union') {
note.textContent =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
} else {
note.textContent = 'Общий интервал записи обоих треков.';
}
setTimelineVisible(true);
updateTimelineAt(overlapMin);
}
async function refreshTimelineTelemetry() {
if (singleTrackActive && loadedSingleTrack) {
const range = singleTrackRange(loadedSingleTrack.points);
if (!range) return;
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (res.ok) telemetrySingle = await res.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAtSingle(t);
return;
}
if (!dualTracksActive || !loadedTxTrack || !loadedRxTrack) return;
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (!range) return;
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAt(t);
}
function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : '';
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
}
async function loadAllTracks() {
const txSel = document.getElementById('trackTxSelect');
const rxSel = document.getElementById('trackRxSelect');
const singleSel = document.getElementById('trackSingleSelect');
const prevTx = txSel.value;
const prevRx = rxSel.value;
const prevSingle = singleSel.value;
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
if (!res.ok) throw new Error('tracks ' + res.status);
const tracks = await res.json();
const fill = (sel, hint) => {
sel.innerHTML = `<option value="">${hint}</option>`;
tracks.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = trackOptionLabel(t);
sel.appendChild(opt);
});
};
fill(singleSel, '— трек —');
fill(txSel, '— TX трек —');
fill(rxSel, '— RX трек —');
if (prevSingle) singleSel.value = prevSingle;
if (prevTx) txSel.value = prevTx;
if (prevRx) rxSel.value = prevRx;
if (!singleTrackActive && !dualTracksActive) {
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
}
}
async function showSingleTrack() {
const id = document.getElementById('trackSingleSelect').value;
if (!id) {
document.getElementById('trackInfo').textContent = 'Выберите трек';
return;
}
clearTrackLayers();
dualTracksActive = false;
singleTrackActive = false;
loadedTxTrack = null;
loadedRxTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
loadedSingleTrack = await res.json();
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
const p = loadedSingleTrack.points.find(x => x.role);
if (p) loadedSingleTrack.role = p.role;
}
if (!loadedSingleTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
drawTrackLine(loadedSingleTrack, color, 'tx');
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] }));
singleTrackActive = true;
setupTimelineSingle();
const range = singleTrackRange(loadedSingleTrack.points);
if (range && loadedSingleTrack.device_id) {
const telRes = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(loadedSingleTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`,
{ cache: 'no-store' }
);
if (telRes.ok) telemetrySingle = await telRes.json();
updateTimelineAtSingle(overlapMin);
}
document.getElementById('trackInfo').textContent =
`Трек #${loadedSingleTrack.id} (${loadedSingleTrack.points.length} точек)`;
updateTrackButtons();
}
function showTracksOnMap() {
if (trackViewMode === 'single') showSingleTrack();
else showDualTracks();
}
async function showDualTracks() {
const txId = document.getElementById('trackTxSelect').value;
const rxId = document.getElementById('trackRxSelect').value;
if (!txId || !rxId) {
document.getElementById('trackInfo').textContent = 'Выберите оба трека';
return;
}
if (txId === rxId) {
document.getElementById('trackInfo').textContent = 'Выберите разные треки';
return;
}
clearTrackLayers();
singleTrackActive = false;
loadedSingleTrack = null;
if (playTimer) { clearInterval(playTimer); playTimer = null; }
const [txRes, rxRes] = await Promise.all([
fetch(`/api/tracks/${txId}`),
fetch(`/api/tracks/${rxId}`)
]);
loadedTxTrack = await txRes.json();
loadedRxTrack = await rxRes.json();
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
document.getElementById('trackInfo').textContent = 'Пустой трек';
return;
}
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx');
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx');
const bounds = L.latLngBounds([]);
[...loadedTxTrack.points, ...loadedRxTrack.points].forEach(p => bounds.extend([p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50] }));
dualTracksActive = true;
setupTimeline();
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
if (range) {
const [telTx, telRx] = await Promise.all([
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedTxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' }),
fetch(`/api/telemetry?device_id=${encodeURIComponent(loadedRxTrack.device_id)}&since=${range.min}&until=${range.max}&limit=500`, { cache: 'no-store' })
]);
if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
updateTimelineAt(t);
}
const modeHint = range && range.mode === 'union' ? ' · без пересечения по времени' : '';
document.getElementById('trackInfo').textContent =
`TX #${loadedTxTrack.id} (${loadedTxTrack.points.length}) + RX #${loadedRxTrack.id} (${loadedRxTrack.points.length})${modeHint}`;
updateTrackButtons();
}
document.getElementById('btnShowTracks').onclick = showTracksOnMap;
document.getElementById('btnHideTracks').onclick = exitTrackMode;
document.getElementById('btnModeSingle').onclick = () => {
trackViewMode = 'single';
document.getElementById('btnModeSingle').classList.add('active');
document.getElementById('btnModeDual').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = '';
document.getElementById('trackPanelDual').style.display = 'none';
document.getElementById('trackInfo').textContent = 'Выберите трек';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
document.getElementById('btnModeDual').onclick = () => {
trackViewMode = 'dual';
document.getElementById('btnModeDual').classList.add('active');
document.getElementById('btnModeSingle').classList.remove('active');
document.getElementById('trackPanelSingle').style.display = 'none';
document.getElementById('trackPanelDual').style.display = '';
document.getElementById('trackInfo').textContent = 'Выберите TX и RX треки';
if (singleTrackActive || dualTracksActive) exitTrackMode();
};
async function postCommand(toDeviceId, kind, payload) {
if (!toDeviceId) {
alert('Выберите устройство');
return;
}
const res = await fetch('/api/commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_device_id: 'web', to_device_id: toDeviceId, kind, payload })
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Ошибка команды');
}
}
document.getElementById('btnCmdAt').onclick = () => {
const line = document.getElementById('cmdAtInput').value.trim();
if (!line) return;
postCommand(document.getElementById('cmdTargetSelect').value, 'at', { line });
};
document.getElementById('btnCmdTx').onclick = () => {
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'TX' });
};
document.getElementById('btnCmdRx').onclick = () => {
postCommand(document.getElementById('cmdTargetSelect').value, 'mode', { role: 'RX' });
};
async function refreshPairedStatus() {
try {
const res = await fetch('/api/paired-tracks/active', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
const el = document.getElementById('pairedStatus');
if (!data.active || !data.session) {
el.textContent = 'Синхр. трек: нет активной сессии';
return;
}
const s = data.session;
el.textContent = `Сессия #${s.id} · ${s.status} · старт ${new Date(s.start_at * 1000).toLocaleTimeString()}`;
} catch (e) {
console.warn('paired status', e);
}
}
document.getElementById('btnPairedStart').onclick = async () => {
const ids = lastDevices.filter(d => d.device_id && d.device_id.startsWith('android-')).map(d => d.device_id);
const body = ids.length === 2 ? { device_ids: ids, initiator: 'web' } : { initiator: 'web' };
const res = await fetch('/api/paired-tracks/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Не удалось запустить');
return;
}
refreshPairedStatus();
};
document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline';
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
};
document.getElementById('btnPlay').onclick = () => {
if (playTimer) {
clearInterval(playTimer);
playTimer = null;
document.getElementById('btnPlay').textContent = '▶ Play';
return;
}
const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause';
playTimer = setInterval(() => {
let v = parseInt(slider.value, 10) + 1;
if (v > parseInt(slider.max, 10)) v = 0;
slider.value = v;
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
}, 1000);
};
function buildDeviceStatsHtml(d) {
let html = formatMeta(d.meta);
html += `<b>${escapeHtml(d.device_id)}</b><br>Сигнал: ${d.rssi ?? '—'} dBm<br>Range: ${d.range_m ?? '—'} m<br>`;
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
Object.keys(markers).forEach(id => {
if (id !== d.device_id && markers[id].getLatLng) {
const o = markers[id].getLatLng();
html += `<br>До ${escapeHtml(id)}: ${haversineM(d.lat, d.lon, o.lat, o.lng).toFixed(0)} m (GPS)`;
}
});
} else {
html += `GPS: —<br>`;
}
return html;
}
function updateStatsPanel(d, openModal) {
const html = buildDeviceStatsHtml(d);
document.getElementById('stats').innerHTML = html;
if (openModal) {
openMapModal(html, 'device');
} else if (isModalOpen() && modalMode === 'device' && selectedId === d.device_id) {
syncModalHtml(html);
}
}
async function fetchDevices() {
const res = await fetch('/api/devices', { cache: 'no-store' });
if (!res.ok) throw new Error('devices ' + res.status);
const devices = await res.json();
let tx = 0, rx = 0;
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
document.getElementById('status').textContent =
`${devices.length} устр. · TX:${tx} RX:${rx} · ${new Date().toLocaleTimeString()}`;
const list = document.getElementById('deviceList');
list.innerHTML = '';
const bounds = [];
const seen = new Set();
devices.forEach(d => {
const li = document.createElement('li');
let label = d.device_id;
if (d.role) label += ` · ${d.role}`;
if (d.rssi != null) label += ` · ${d.rssi} dBm`;
try {
if (d.meta) {
const m = JSON.parse(d.meta);
if (!d.role && m.role) label += ` · ${m.role}`;
if (m.packet != null) label += ` #${m.packet}`;
}
} catch (e) {}
li.textContent = label;
li.className = d.device_id === selectedId ? 'active' : '';
li.onclick = () => selectDevice(d);
list.appendChild(li);
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
seen.add(d.device_id);
bounds.push([d.lat, d.lon]);
const role = roleFromDevice(d);
if (!markers[d.device_id]) {
markers[d.device_id] = L.marker([d.lat, d.lon], { icon: makeRoleIcon(role) }).addTo(map);
} else {
markers[d.device_id].setLatLng([d.lat, d.lon]);
markers[d.device_id].setIcon(makeRoleIcon(role));
}
markers[d.device_id].off('click');
markers[d.device_id].on('click', () => selectDevice(d));
}
});
Object.keys(markers).forEach(id => {
if (!seen.has(id)) {
map.removeLayer(markers[id]);
delete markers[id];
}
});
if (!mapInitialFitDone && bounds.length) fitAllMarkers(bounds);
updateCmdTargetSelect(devices);
if (selectedId) {
const sel = devices.find(d => d.device_id === selectedId);
if (sel) {
updateStatsPanel(sel, false);
loadTelemetryHistory(sel.device_id);
}
}
return devices;
}
function selectDevice(d) {
selectedId = d.device_id;
document.querySelectorAll('#deviceList li').forEach(li => {
li.classList.toggle('active', li.textContent.startsWith(d.device_id));
});
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
setMapViewProgrammatically(() => {
map.setView([d.lat, d.lon], Math.max(map.getZoom(), 13));
});
}
updateStatsPanel(d, true);
loadTelemetryHistory(d.device_id);
}
async function loadTelemetryHistory(deviceId) {
const el = document.getElementById('history');
const res = await fetch(
`/api/telemetry?device_id=${encodeURIComponent(deviceId)}&limit=30`,
{ cache: 'no-store' }
);
if (!res.ok) return;
const rows = await res.json();
if (!rows.length) {
el.innerHTML = '<b>История</b>: пуста';
return;
}
let html = '<b>История</b><ul style="margin:4px 0;padding-left:16px">';
rows.forEach(r => {
const role = r.role ? ` ${r.role}` : '';
let pkt = '';
try {
if (r.meta) {
const m = JSON.parse(r.meta);
if (m.packet != null) pkt = ` #${m.packet}`;
}
} catch (e) {}
html += `<li>${new Date(r.ts * 1000).toLocaleTimeString()}${role}${pkt} ${r.rssi ?? '—'} dBm</li>`;
});
el.innerHTML = html + '</ul>';
}
async function pollChat() {
const res = await fetch(`/api/chat?since=${chatSince}`, { cache: 'no-store' });
if (!res.ok) throw new Error('chat ' + res.status);
const msgs = await res.json();
const log = document.getElementById('chatLog');
msgs.forEach(m => {
chatSince = Math.max(chatSince, m.ts);
const div = document.createElement('div');
div.innerHTML = `<span class="muted">${new Date(m.ts*1000).toLocaleTimeString()}</span> <b>${escapeHtml(m.device_id)}</b>: ${escapeHtml(m.text)}`;
log.appendChild(div);
});
if (msgs.length) log.scrollTop = log.scrollHeight;
}
document.getElementById('chatForm').onsubmit = async e => {
e.preventDefault();
const text = document.getElementById('chatInput').value.trim();
const device_id = document.getElementById('webDeviceId').value.trim() || 'web';
if (!text) return;
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id, text })
});
document.getElementById('chatInput').value = '';
pollChat();
};
function setPollStatus(ok, detail) {
const el = document.getElementById('pollStatus');
if (!el) return;
const time = new Date().toLocaleTimeString();
el.textContent = ok ? `${time}` : `${time}`;
el.title = detail || (ok ? 'Данные обновляются автоматически' : 'Ошибка опроса');
el.style.color = ok ? '#aaa' : '#e94560';
}
async function pollOnce() {
if (document.hidden) return;
try {
await fetchDevices();
setPollStatus(true);
} catch (e) {
console.warn('poll devices', e);
setPollStatus(false, String(e.message || e));
}
pollTick++;
if (pollTick % Math.round(CHAT_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await pollChat();
} catch (e) {
console.warn('poll chat', e);
}
}
if (pollTick % Math.round(TRACKS_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await loadAllTracks();
} catch (e) {
console.warn('poll tracks', e);
}
}
if ((dualTracksActive || singleTrackActive) && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await refreshTimelineTelemetry();
} catch (e) {
console.warn('poll timeline telemetry', e);
}
}
if (pollTick % Math.round(2000 / DEVICE_POLL_MS) === 0) {
try {
await refreshPairedStatus();
} catch (e) {
console.warn('poll paired', e);
}
}
}
function updateCmdTargetSelect(devices) {
lastDevices = devices;
const sel = document.getElementById('cmdTargetSelect');
const prev = sel.value;
sel.innerHTML = '<option value="">— устройство —</option>';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.device_id;
let label = d.device_id;
if (d.role) label += ` · ${d.role}`;
opt.textContent = label;
sel.appendChild(opt);
});
if (prev) sel.value = prev;
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await pollOnce();
schedulePoll();
}, DEVICE_POLL_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollOnce();
}
});
schedulePoll();
loadAllTracks();
refreshPairedStatus();
</script>
</body>
</html>