generated from Grigo/AndroidTemplate
883 lines
35 KiB
HTML
883 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>LoraTester</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { margin: 0; font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; }
|
||
header { padding: 12px 16px; background: #16213e; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||
header h1 { margin: 0; font-size: 1.2rem; flex: 1; }
|
||
main { display: grid; grid-template-columns: 1fr 340px; grid-template-rows: 1fr; 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> <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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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>
|