generated from Grigo/AndroidTemplate
update
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -14,6 +14,7 @@ class TelemetryIn:
|
||||
role: Optional[str] = None
|
||||
ts: Optional[float] = None
|
||||
source: str = "android"
|
||||
device_label: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+23
-9
@@ -88,14 +88,27 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
|
||||
ts = data.ts if data.ts is not None else time.time()
|
||||
lat, lon = _sanitize_coords(data.lat, data.lon)
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen
|
||||
""",
|
||||
(data.device_id, data.device_id, ts),
|
||||
)
|
||||
phone_label = (data.device_label or "").strip()
|
||||
if phone_label:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET
|
||||
last_seen = excluded.last_seen,
|
||||
label = excluded.label
|
||||
""",
|
||||
(data.device_id, phone_label, ts),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO devices (device_id, label, last_seen)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(device_id) DO UPDATE SET last_seen = excluded.last_seen
|
||||
""",
|
||||
(data.device_id, data.device_id, ts),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO telemetry
|
||||
@@ -138,7 +151,7 @@ def list_devices() -> list[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT d.device_id, d.last_seen,
|
||||
SELECT d.device_id, d.label, d.last_seen,
|
||||
t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
|
||||
FROM devices d
|
||||
INNER JOIN telemetry t ON t.id = (
|
||||
@@ -164,6 +177,7 @@ def _is_null_island(device: dict[str, Any]) -> bool:
|
||||
def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"device_id": row["device_id"],
|
||||
"label": row["label"] if "label" in row.keys() else None,
|
||||
"last_seen": row["last_seen"],
|
||||
"lat": row["lat"],
|
||||
"lon": row["lon"],
|
||||
|
||||
@@ -48,6 +48,10 @@ def merge_meta(body: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
|
||||
|
||||
def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
|
||||
meta, role = merge_meta(body)
|
||||
label = body.get("device_label") or body.get("label")
|
||||
device_label = str(label).strip() if label else None
|
||||
if device_label == "":
|
||||
device_label = None
|
||||
return TelemetryIn(
|
||||
device_id=str(body["device_id"]),
|
||||
lat=_float_or_none(body.get("lat")),
|
||||
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
|
||||
meta=meta,
|
||||
role=role,
|
||||
ts=_float_or_none(body.get("ts")),
|
||||
device_label=device_label,
|
||||
)
|
||||
|
||||
+192
-79
@@ -23,6 +23,8 @@
|
||||
}
|
||||
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
|
||||
#map { width: 100%; height: 100%; }
|
||||
.leaflet-control-layers { background: #16213e; color: #eee; border: 1px solid #444; }
|
||||
.leaflet-control-layers label { color: #eee; }
|
||||
#trackTimeline {
|
||||
display: none; grid-column: 1 / -1; grid-row: 2;
|
||||
background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
|
||||
@@ -302,7 +304,7 @@
|
||||
<span id="timeCurrent">—</span>
|
||||
<span id="timeEnd">—</span>
|
||||
</div>
|
||||
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1" />
|
||||
<input type="range" id="timeSlider" min="0" max="100" value="0" step="0.1" />
|
||||
<div id="elevationPanel">
|
||||
<div id="elevationPanelTitle">
|
||||
<span>Линейка высот <span id="elevationStatus" class="muted">—</span></span>
|
||||
@@ -336,9 +338,20 @@
|
||||
</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 mapLayerScheme = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap',
|
||||
maxZoom: 19
|
||||
});
|
||||
const mapLayerSatellite = L.tileLayer(
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
{ attribution: '© Esri', maxZoom: 19 }
|
||||
);
|
||||
mapLayerScheme.addTo(map);
|
||||
L.control.layers(
|
||||
{ 'Схема': mapLayerScheme, 'Спутник': mapLayerSatellite },
|
||||
null,
|
||||
{ position: 'topright', collapsed: true }
|
||||
).addTo(map);
|
||||
|
||||
const markers = {};
|
||||
let selectedId = null;
|
||||
@@ -354,8 +367,8 @@
|
||||
|
||||
const CMD_INPUT_IDS = ['cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdTm', 'cmdRole'];
|
||||
|
||||
let trackTxLayer = null;
|
||||
let trackRxLayer = null;
|
||||
let trackTxLayers = [];
|
||||
let trackRxLayers = [];
|
||||
let trackTxMarkers = [];
|
||||
let trackRxMarkers = [];
|
||||
let linkLine = null;
|
||||
@@ -420,6 +433,33 @@
|
||||
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
|
||||
}
|
||||
|
||||
function deviceDisplayName(d) {
|
||||
if (!d) return '—';
|
||||
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === d) : d;
|
||||
const id = typeof d === 'string' ? d : d.device_id;
|
||||
const label = dev?.label;
|
||||
if (label && label !== id) return label;
|
||||
return id || '—';
|
||||
}
|
||||
|
||||
function sliderTime() {
|
||||
return overlapMin + parseFloat(document.getElementById('timeSlider').value || '0');
|
||||
}
|
||||
|
||||
function rxQualityFromMeta(meta) {
|
||||
if (!meta) return null;
|
||||
const snap = RadioUI.parseRadioSnapshot(meta);
|
||||
return snap.rxQualityPercent != null ? snap.rxQualityPercent : null;
|
||||
}
|
||||
|
||||
function qualityColor(pct) {
|
||||
if (pct == null || Number.isNaN(pct)) return null;
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const r = p < 50 ? Math.round(255 * (p / 50)) : 255;
|
||||
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50));
|
||||
return `rgb(${r},${g},0)`;
|
||||
}
|
||||
|
||||
function roleColor(role) {
|
||||
return role === 'RX' ? RX_COLOR : TX_COLOR;
|
||||
}
|
||||
@@ -1468,11 +1508,11 @@
|
||||
drawElevationChart();
|
||||
requestAnimationFrame(() => drawElevationChart(
|
||||
singleTrackActive
|
||||
? { single: trackDistanceAtTime(loadedSingleTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)) }
|
||||
? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) }
|
||||
: dualTracksActive
|
||||
? {
|
||||
tx: trackDistanceAtTime(loadedTxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)),
|
||||
rx: trackDistanceAtTime(loadedRxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10))
|
||||
tx: trackDistanceAtTime(loadedTxTrack, sliderTime()),
|
||||
rx: trackDistanceAtTime(loadedRxTrack, sliderTime())
|
||||
}
|
||||
: null
|
||||
));
|
||||
@@ -1638,20 +1678,74 @@
|
||||
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; }
|
||||
function telemetryAtTime(rows, t) {
|
||||
if (!rows?.length) return null;
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (t <= first.ts) return first;
|
||||
if (t >= last.ts) return last;
|
||||
for (let i = 0; i < rows.length - 1; i++) {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1];
|
||||
if (t >= a.ts && t <= b.ts) {
|
||||
return t - a.ts <= b.ts - t ? a : b;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return last;
|
||||
}
|
||||
|
||||
function telemetryFromTrackPoint(track, t, roleFallback) {
|
||||
const pos = positionAt(track?.points, t);
|
||||
if (!pos) return null;
|
||||
return {
|
||||
meta: pos.meta,
|
||||
role: roleFallback,
|
||||
rssi: pos.rssi,
|
||||
ts: t,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon
|
||||
};
|
||||
}
|
||||
|
||||
function findTelemetryByPacket(rows, packet) {
|
||||
if (packet == null || !rows?.length) return null;
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (const r of rows) {
|
||||
const snap = telemetryToSnap(r);
|
||||
if (snap.packet == null) continue;
|
||||
const d = Math.abs(snap.packet - packet);
|
||||
if (d < bestD) {
|
||||
best = r;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return bestD === 0 ? best : null;
|
||||
}
|
||||
|
||||
function pairedTelemetryAtTime(txTrack, rxTrack, telemetryTx, telemetryRx, t) {
|
||||
let txTel = telemetryAtTime(telemetryTx, t) || telemetryFromTrackPoint(txTrack, t, 'TX');
|
||||
let rxTel = telemetryAtTime(telemetryRx, t) || telemetryFromTrackPoint(rxTrack, t, 'RX');
|
||||
const txSnap = txTel ? telemetryToSnap(txTel) : null;
|
||||
const rxSnap = rxTel ? telemetryToSnap(rxTel) : null;
|
||||
if (txSnap?.packet != null) {
|
||||
const matched = findTelemetryByPacket(telemetryRx, txSnap.packet);
|
||||
if (matched) rxTel = matched;
|
||||
} else if (rxSnap?.packet != null) {
|
||||
const matched = findTelemetryByPacket(telemetryTx, rxSnap.packet);
|
||||
if (matched) txTel = matched;
|
||||
}
|
||||
return { txTel, rxTel };
|
||||
}
|
||||
|
||||
function nearestTelemetry(rows, t) {
|
||||
return telemetryAtTime(rows, t);
|
||||
}
|
||||
|
||||
function clearTrackLayers() {
|
||||
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; }
|
||||
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; }
|
||||
[...trackTxLayers, ...trackRxLayers].forEach(l => map.removeLayer(l));
|
||||
trackTxLayers = [];
|
||||
trackRxLayers = [];
|
||||
trackTxMarkers.forEach(m => map.removeLayer(m));
|
||||
trackRxMarkers.forEach(m => map.removeLayer(m));
|
||||
trackTxMarkers = [];
|
||||
@@ -1695,20 +1789,38 @@
|
||||
updateTrackButtons();
|
||||
}
|
||||
|
||||
function drawTrackLine(track, color, store) {
|
||||
const latlngs = track.points.map(p => [p.lat, p.lon]);
|
||||
const layer = L.polyline(latlngs, { color, weight: 4, opacity: 0.85 }).addTo(map);
|
||||
if (store === 'tx') trackTxLayer = layer;
|
||||
else trackRxLayer = layer;
|
||||
function drawTrackLine(track, color, store, colorByQuality) {
|
||||
const pts = track.points;
|
||||
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 });
|
||||
const layerList = store === 'tx' ? trackTxLayers : trackRxLayers;
|
||||
const useQuality = colorByQuality !== false
|
||||
&& (store === 'rx' || track.role === 'RX');
|
||||
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const a = pts[i - 1];
|
||||
const b = pts[i];
|
||||
const qa = rxQualityFromMeta(a.meta);
|
||||
const qb = rxQualityFromMeta(b.meta);
|
||||
const q = qa != null && qb != null ? (qa + qb) / 2 : (qa ?? qb);
|
||||
const segColor = useQuality && q != null ? (qualityColor(q) || color) : color;
|
||||
const seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
|
||||
color: segColor, weight: 4, opacity: 0.85
|
||||
}).addTo(map);
|
||||
layerList.push(seg);
|
||||
}
|
||||
|
||||
pts.forEach(p => {
|
||||
const q = rxQualityFromMeta(p.meta);
|
||||
const ptColor = useQuality && q != null ? (qualityColor(q) || color) : color;
|
||||
const m = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 3, color: ptColor, fillColor: ptColor, 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;
|
||||
const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
|
||||
document.getElementById('timeSlider').value = String(rel);
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(overlapMin + rel, { openModal: true });
|
||||
updateTimelineAt(p.ts, { openModal: true });
|
||||
});
|
||||
markerList.push(m);
|
||||
});
|
||||
@@ -1720,13 +1832,14 @@
|
||||
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>`;
|
||||
const txTel = nearestTelemetry(telemetryTx, t);
|
||||
const rxTel = nearestTelemetry(telemetryRx, t);
|
||||
const { txTel, rxTel } = pairedTelemetryAtTime(
|
||||
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
|
||||
);
|
||||
html += renderTimelineCompare(
|
||||
txTel || { meta: txPos.meta, role: 'TX', rssi: null },
|
||||
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
|
||||
loadedTxTrack?.device_id,
|
||||
loadedRxTrack?.device_id
|
||||
deviceDisplayName(loadedTxTrack?.device_id),
|
||||
deviceDisplayName(loadedRxTrack?.device_id)
|
||||
);
|
||||
return html;
|
||||
}
|
||||
@@ -1775,14 +1888,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const txTel = nearestTelemetry(telemetryTx, t);
|
||||
const rxTel = nearestTelemetry(telemetryRx, t);
|
||||
const { txTel, rxTel } = pairedTelemetryAtTime(
|
||||
loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
|
||||
);
|
||||
const timelineStatsEl = document.getElementById('timelineStats');
|
||||
setPanelHtml(timelineStatsEl, renderTimelineCompare(
|
||||
txTel,
|
||||
rxTel,
|
||||
loadedTxTrack?.device_id,
|
||||
loadedRxTrack?.device_id
|
||||
deviceDisplayName(loadedTxTrack?.device_id),
|
||||
deviceDisplayName(loadedRxTrack?.device_id)
|
||||
));
|
||||
drawElevationChart({
|
||||
tx: trackDistanceAtTime(loadedTxTrack, t),
|
||||
@@ -1842,24 +1956,32 @@
|
||||
statsPanel.classList.toggle('timeline-single', single);
|
||||
}
|
||||
|
||||
function applyTimelineRange(range, noteText) {
|
||||
const note = document.getElementById('timelineNote');
|
||||
overlapMin = range.min;
|
||||
overlapMax = range.max;
|
||||
const span = Math.max(0.1, overlapMax - overlapMin);
|
||||
const slider = document.getElementById('timeSlider');
|
||||
slider.min = 0;
|
||||
slider.max = String(span);
|
||||
slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1');
|
||||
slider.value = '0';
|
||||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||||
if (noteText != null) note.textContent = noteText;
|
||||
}
|
||||
|
||||
function setupTimelineSingle() {
|
||||
const range = singleTrackRange(loadedSingleTrack.points);
|
||||
const note = document.getElementById('timelineNote');
|
||||
setTimelineMode(true);
|
||||
if (!range) {
|
||||
setTimelineVisible(false);
|
||||
return;
|
||||
}
|
||||
overlapMin = range.min;
|
||||
overlapMax = range.max;
|
||||
const span = Math.max(1, Math.round(overlapMax - overlapMin));
|
||||
const slider = document.getElementById('timeSlider');
|
||||
slider.min = 0;
|
||||
slider.max = span;
|
||||
slider.value = 0;
|
||||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||||
note.textContent = `Трек #${loadedSingleTrack.id} · ${loadedSingleTrack.device_id || ''}`;
|
||||
applyTimelineRange(
|
||||
range,
|
||||
`Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}`
|
||||
);
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAtSingle(overlapMin);
|
||||
loadElevationProfiles();
|
||||
@@ -1868,26 +1990,16 @@
|
||||
function setupTimeline() {
|
||||
setTimelineMode(false);
|
||||
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
|
||||
const note = document.getElementById('timelineNote');
|
||||
if (!range) {
|
||||
setTimelineVisible(false);
|
||||
return;
|
||||
}
|
||||
overlapMin = range.min;
|
||||
overlapMax = range.max;
|
||||
const span = Math.max(1, Math.round(overlapMax - overlapMin));
|
||||
const slider = document.getElementById('timeSlider');
|
||||
slider.min = 0;
|
||||
slider.max = span;
|
||||
slider.value = 0;
|
||||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||||
let noteText = 'Общий интервал записи обоих треков.';
|
||||
if (range.mode === 'union') {
|
||||
note.textContent =
|
||||
noteText =
|
||||
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
|
||||
} else {
|
||||
note.textContent = 'Общий интервал записи обоих треков.';
|
||||
}
|
||||
applyTimelineRange(range, noteText);
|
||||
setTimelineVisible(true);
|
||||
updateTimelineAt(overlapMin);
|
||||
loadElevationProfiles();
|
||||
@@ -1902,7 +2014,7 @@
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (res.ok) telemetrySingle = await res.json();
|
||||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||||
const t = sliderTime();
|
||||
updateTimelineAtSingle(t);
|
||||
return;
|
||||
}
|
||||
@@ -1915,14 +2027,13 @@
|
||||
]);
|
||||
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);
|
||||
updateTimelineAt(sliderTime());
|
||||
}
|
||||
|
||||
function trackOptionLabel(t) {
|
||||
const start = new Date(t.started_at * 1000).toLocaleString();
|
||||
const role = t.role ? ` · ${t.role}` : '';
|
||||
const dev = t.device_id ? ` · ${t.device_id.slice(0, 12)}` : '';
|
||||
const dev = t.device_id ? ` · ${deviceDisplayName(t.device_id)}` : '';
|
||||
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
|
||||
}
|
||||
|
||||
@@ -1980,7 +2091,7 @@
|
||||
return;
|
||||
}
|
||||
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
|
||||
drawTrackLine(loadedSingleTrack, color, 'tx');
|
||||
drawTrackLine(loadedSingleTrack, color, 'tx', loadedSingleTrack.role === 'RX');
|
||||
if (!userMovedMap) {
|
||||
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
|
||||
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }));
|
||||
@@ -2033,8 +2144,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx');
|
||||
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx');
|
||||
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx', false);
|
||||
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx', true);
|
||||
|
||||
if (!userMovedMap) {
|
||||
const bounds = L.latLngBounds([]);
|
||||
@@ -2052,7 +2163,7 @@
|
||||
]);
|
||||
if (telTx.ok) telemetryTx = await telTx.json();
|
||||
if (telRx.ok) telemetryRx = await telRx.json();
|
||||
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10);
|
||||
const t = sliderTime();
|
||||
updateTimelineAt(t);
|
||||
}
|
||||
|
||||
@@ -2204,7 +2315,7 @@
|
||||
};
|
||||
document.getElementById('timeSlider').oninput = e => {
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true });
|
||||
updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
|
||||
};
|
||||
document.getElementById('btnPlay').onclick = () => {
|
||||
if (playTimer) {
|
||||
@@ -2216,11 +2327,12 @@
|
||||
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;
|
||||
const step = parseFloat(slider.step) || 1;
|
||||
let v = parseFloat(slider.value) + step;
|
||||
if (v > parseFloat(slider.max)) v = 0;
|
||||
slider.value = String(v);
|
||||
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
|
||||
}, 1000);
|
||||
}, Math.max(200, Math.round(step * 1000)));
|
||||
};
|
||||
|
||||
function buildDeviceStatsHtml(d) {
|
||||
@@ -2229,7 +2341,7 @@
|
||||
prevDeviceSnap = snap;
|
||||
const statsEl = document.getElementById('stats');
|
||||
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl));
|
||||
html += `<b>${escapeHtml(d.device_id)}</b><br>Range: ${d.range_m ?? '—'} m<br>`;
|
||||
html += `<b>${escapeHtml(deviceDisplayName(d))}</b><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 => {
|
||||
@@ -2268,7 +2380,7 @@
|
||||
const seen = new Set();
|
||||
devices.forEach(d => {
|
||||
const li = document.createElement('li');
|
||||
let label = d.device_id;
|
||||
let label = deviceDisplayName(d);
|
||||
if (d.role) label += ` · ${d.role}`;
|
||||
if (d.rssi != null) label += ` · ${d.rssi} dBm`;
|
||||
try {
|
||||
@@ -2278,6 +2390,7 @@
|
||||
if (m.packet != null) label += ` #${m.packet}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
li.dataset.deviceId = d.device_id;
|
||||
li.textContent = label;
|
||||
li.className = d.device_id === selectedId ? 'active' : '';
|
||||
li.onclick = () => selectDevice(d);
|
||||
@@ -2328,7 +2441,7 @@
|
||||
setCmdFormDirty(false);
|
||||
fillCmdFormFromDevice(d, { force: true });
|
||||
document.querySelectorAll('#deviceList li').forEach(li => {
|
||||
li.classList.toggle('active', li.textContent.startsWith(d.device_id));
|
||||
li.classList.toggle('active', li.dataset.deviceId === d.device_id);
|
||||
});
|
||||
if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
|
||||
setMapViewProgrammatically(() => {
|
||||
@@ -2378,7 +2491,7 @@
|
||||
const isNew = m.ts > chatLastReadTs;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : '');
|
||||
const author = self ? 'Вы' : escapeHtml(m.device_id);
|
||||
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_id));
|
||||
div.innerHTML = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`;
|
||||
log.appendChild(div);
|
||||
if (isNew) {
|
||||
@@ -2462,7 +2575,7 @@
|
||||
devices.forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.device_id;
|
||||
let label = d.device_id;
|
||||
let label = deviceDisplayName(d);
|
||||
if (d.role) label += ` · ${d.role}`;
|
||||
opt.textContent = label;
|
||||
sel.appendChild(opt);
|
||||
|
||||
Reference in New Issue
Block a user