Initial commit: LoraTester Android + server

This commit is contained in:
2026-06-04 13:05:21 +03:00
commit 83d0353754
124 changed files with 7892 additions and 0 deletions
+882
View File
@@ -0,0 +1,882 @@
<!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; height: calc(100vh - 52px); }
@media (max-width: 900px) {
main { grid-template-columns: 1fr; grid-template-rows: 45vh minmax(200px, 1fr); }
}
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; }
#trackTimeline { display: none; margin-top: 10px; padding-top: 10px; border-top: 1px solid #333; }
#trackTimeline.visible { display: block; }
#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; }
#btnShowTracks { width: 100%; padding: 6px; margin-top: 4px; background: #00ff88; color: #111; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; }
.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">
<h2>Сравнение треков</h2>
<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>
<button type="button" id="btnShowTracks">Показать на карте</button>
<div id="trackInfo" class="muted" style="margin-top:6px">Выберите TX и RX треки</div>
<div id="trackTimeline">
<h3 style="margin:0 0 6px;font-size:0.9rem">Время теста</h3>
<div id="timelineNote"></div>
<div class="timeline-labels">
<span id="timeStart"></span>
<span id="timeCurrent"></span>
<span id="timeEnd"></span>
</div>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
<div id="distanceNow">Расстояние GPS: —</div>
<div id="timelineStats">
<div class="timeline-col tx"><h3>TX</h3><div id="statsTx"></div></div>
<div class="timeline-col rx"><h3>RX</h3><div id="statsRx"></div></div>
</div>
<button type="button" id="btnPlay" class="muted" style="margin-top:6px;padding:4px 10px;border:none;border-radius:4px;cursor:pointer;background:#0a0a14;color:#eee">▶ Play</button>
</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>
</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 telemetryTx = [];
let telemetryRx = [];
let overlapMin = 0;
let overlapMax = 0;
let playTimer = null;
let pollTimer = null;
let pollTick = 0;
let dualTracksActive = false;
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 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 updateTimelineAt(t, opts) {
const openModal = opts && opts.openModal;
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 setupTimeline() {
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const panel = document.getElementById('trackTimeline');
const note = document.getElementById('timelineNote');
if (!range) {
panel.classList.remove('visible');
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 = 'Общий интервал записи обоих треков.';
}
panel.classList.add('visible');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
updateTimelineAt(overlapMin);
}
async function refreshTimelineTelemetry() {
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);
}
async function loadAllTracks() {
const txSel = document.getElementById('trackTxSelect');
const rxSel = document.getElementById('trackRxSelect');
const prevTx = txSel.value;
const prevRx = rxSel.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;
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)}` : '';
opt.textContent = `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
sel.appendChild(opt);
});
};
fill(txSel, '— TX трек —');
fill(rxSel, '— RX трек —');
if (prevTx) txSel.value = prevTx;
if (prevRx) rxSel.value = prevRx;
document.getElementById('trackInfo').textContent =
tracks.length ? `${tracks.length} трек(ов) на сервере` : 'Треки записываются с телефона';
}
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();
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}`;
}
document.getElementById('btnShowTracks').onclick = showDualTracks;
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);
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 && pollTick % Math.round(TELEMETRY_POLL_MS / DEVICE_POLL_MS) === 0) {
try {
await refreshTimelineTelemetry();
} catch (e) {
console.warn('poll timeline telemetry', e);
}
}
}
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await pollOnce();
schedulePoll();
}, DEVICE_POLL_MS);
}
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
pollOnce();
}
});
schedulePoll();
loadAllTracks();
</script>
</body>
</html>