Files
LoraMapTester/server/static/index.html
T

883 lines
35 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; 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>