generated from Grigo/AndroidTemplate
Initial commit: LoraTester Android + server
This commit is contained in:
@@ -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> <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>
|
||||
Reference in New Issue
Block a user