This commit is contained in:
2026-06-16 10:36:18 +03:00
parent c5805eaa5c
commit 0571291b69
15 changed files with 447 additions and 105 deletions
@@ -1,6 +1,7 @@
package com.grigowashere.loratester; package com.grigowashere.loratester;
import android.content.Context; import android.content.Context;
import android.os.Build;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
@@ -285,6 +286,7 @@ public class TelemetryUploader implements TelnetClient.Listener {
} }
TelemetryPayload payload = new TelemetryPayload( TelemetryPayload payload = new TelemetryPayload(
settings.getOrCreateDeviceId(), settings.getOrCreateDeviceId(),
phoneLabel(),
validLat(), validLat(),
validLon(), validLon(),
stats.rssi, stats.rssi,
@@ -297,6 +299,13 @@ public class TelemetryUploader implements TelnetClient.Listener {
uploadExecutor.execute(() -> uploadTelemetry(payload)); uploadExecutor.execute(() -> uploadTelemetry(payload));
} }
private static String phoneLabel() {
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER : "";
String model = Build.MODEL != null ? Build.MODEL : "";
String label = (manufacturer + " " + model).trim();
return label.isEmpty() ? null : label;
}
private void uploadTelemetry(TelemetryPayload payload) { private void uploadTelemetry(TelemetryPayload payload) {
if (networkMonitor.isOnline()) { if (networkMonitor.isOnline()) {
try { try {
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class DeviceInfo { public class DeviceInfo {
public String device_id; public String device_id;
public String label;
public double last_seen; public double last_seen;
public Double lat; public Double lat;
public Double lon; public Double lon;
@@ -51,6 +51,9 @@ public class ServerApi {
public void postTelemetry(TelemetryPayload payload) throws IOException { public void postTelemetry(TelemetryPayload payload) throws IOException {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("device_id", payload.deviceId); body.put("device_id", payload.deviceId);
if (payload.deviceLabel != null && !payload.deviceLabel.isBlank()) {
body.put("device_label", payload.deviceLabel);
}
if (payload.lat != null) body.put("lat", payload.lat); if (payload.lat != null) body.put("lat", payload.lat);
if (payload.lon != null) body.put("lon", payload.lon); if (payload.lon != null) body.put("lon", payload.lon);
if (payload.rssi != null) body.put("rssi", payload.rssi); if (payload.rssi != null) body.put("rssi", payload.rssi);
@@ -2,6 +2,7 @@ package com.grigowashere.loratester.api;
public class TelemetryPayload { public class TelemetryPayload {
public final String deviceId; public final String deviceId;
public final String deviceLabel;
public final Double lat; public final Double lat;
public final Double lon; public final Double lon;
public final Double rssi; public final Double rssi;
@@ -22,8 +23,24 @@ public class TelemetryPayload {
String meta, String meta,
String role, String role,
Double ts Double ts
) {
this(deviceId, null, lat, lon, rssi, rangeM, rawFrame, meta, role, ts);
}
public TelemetryPayload(
String deviceId,
String deviceLabel,
Double lat,
Double lon,
Double rssi,
Double rangeM,
String rawFrame,
String meta,
String role,
Double ts
) { ) {
this.deviceId = deviceId; this.deviceId = deviceId;
this.deviceLabel = deviceLabel;
this.lat = lat; this.lat = lat;
this.lon = lon; this.lon = lon;
this.rssi = rssi; this.rssi = rssi;
@@ -0,0 +1,37 @@
package com.grigowashere.loratester.map;
import org.mapsforge.core.model.Tile;
import org.mapsforge.map.layer.download.tilesource.OnlineTileSource;
import java.net.MalformedURLException;
import java.net.URL;
/** Esri World Imagery — tile path is zoom/y/x (not OSM zoom/x/y). */
public final class EsriWorldImagery extends OnlineTileSource {
public static final EsriWorldImagery INSTANCE = new EsriWorldImagery();
private EsriWorldImagery() {
super(new String[]{"server.arcgisonline.com"}, 443);
setName("Esri.WorldImagery")
.setAlpha(false)
.setBaseUrl("/ArcGIS/rest/services/World_Imagery/MapServer/tile/")
.setExtension("png")
.setParallelRequestsLimit(4)
.setProtocol("https")
.setTileSize(256)
.setZoomLevelMax((byte) 18)
.setZoomLevelMin((byte) 0);
setUserAgent("LoraTester/1.0");
}
@Override
public URL getTileUrl(Tile tile) throws MalformedURLException {
StringBuilder path = new StringBuilder(48);
path.append(getBaseUrl());
path.append(tile.zoomLevel).append('/');
path.append(tile.tileY).append('/');
path.append(tile.tileX).append('.').append(getExtension());
return new URL(getProtocol(), getHostName(), 443, path.toString());
}
}
@@ -37,6 +37,8 @@ import com.grigowashere.loratester.api.TrackDetail;
import com.grigowashere.loratester.api.TrackInfo; import com.grigowashere.loratester.api.TrackInfo;
import com.grigowashere.loratester.location.GeoUtils; import com.grigowashere.loratester.location.GeoUtils;
import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.net.NetworkMonitor;
import com.grigowashere.loratester.map.EsriWorldImagery;
import com.grigowashere.loratester.model.RadioSnapshot;
import com.grigowashere.loratester.telnet.StatsExtractor; import com.grigowashere.loratester.telnet.StatsExtractor;
import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.track.TrackRecorder;
@@ -51,6 +53,7 @@ import org.mapsforge.map.layer.Layer;
import org.mapsforge.map.layer.cache.TileCache; import org.mapsforge.map.layer.cache.TileCache;
import org.mapsforge.map.layer.download.TileDownloadLayer; import org.mapsforge.map.layer.download.TileDownloadLayer;
import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik; import org.mapsforge.map.layer.download.tilesource.OpenStreetMapMapnik;
import org.mapsforge.map.layer.download.tilesource.TileSource;
import org.mapsforge.map.layer.overlay.Marker; import org.mapsforge.map.layer.overlay.Marker;
import org.mapsforge.map.layer.overlay.Polyline; import org.mapsforge.map.layer.overlay.Polyline;
import org.mapsforge.map.model.MapViewPosition; import org.mapsforge.map.model.MapViewPosition;
@@ -122,6 +125,7 @@ public class MapFragment extends Fragment {
private MaterialButtonToggleGroup mapCenterMode; private MaterialButtonToggleGroup mapCenterMode;
private Spinner trackSpinner; private Spinner trackSpinner;
private Spinner mapHeatmapRadius; private Spinner mapHeatmapRadius;
private Spinner mapBasemap;
private TextView mapHeatmapStatus; private TextView mapHeatmapStatus;
private View mapHeatmapLegend; private View mapHeatmapLegend;
private List<TrackInfo> savedTracks = new ArrayList<>(); private List<TrackInfo> savedTracks = new ArrayList<>();
@@ -150,6 +154,8 @@ public class MapFragment extends Fragment {
private double lastHeatmapLat = Double.NaN; private double lastHeatmapLat = Double.NaN;
private double lastHeatmapLon = Double.NaN; private double lastHeatmapLon = Double.NaN;
private boolean suppressHeatmapSpinner; private boolean suppressHeatmapSpinner;
private boolean suppressBasemapSpinner;
private TileSource currentTileSource = OpenStreetMapMapnik.INSTANCE;
private Runnable heatmapReloadRunnable; private Runnable heatmapReloadRunnable;
private boolean suppressCenterToggle; private boolean suppressCenterToggle;
private boolean mapGestureActive; private boolean mapGestureActive;
@@ -196,6 +202,7 @@ public class MapFragment extends Fragment {
mapToolDrawer = view.findViewById(R.id.mapToolDrawer); mapToolDrawer = view.findViewById(R.id.mapToolDrawer);
mapCenterMode = view.findViewById(R.id.mapCenterMode); mapCenterMode = view.findViewById(R.id.mapCenterMode);
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
mapBasemap = view.findViewById(R.id.mapBasemap);
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus); mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend); mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend);
trackSpinner = view.findViewById(R.id.trackSpinner); trackSpinner = view.findViewById(R.id.trackSpinner);
@@ -208,6 +215,7 @@ public class MapFragment extends Fragment {
btnFindHill.setOnClickListener(v -> toggleHill()); btnFindHill.setOnClickListener(v -> toggleHill());
} }
setupHeatmapUi(); setupHeatmapUi();
setupBasemapUi();
updateConnectionIcons(lastDevices, serverConnected); updateConnectionIcons(lastDevices, serverConnected);
@@ -347,18 +355,18 @@ public class MapFragment extends Fragment {
mapView.getModel().frameBufferModel.getOverdrawFactor() mapView.getModel().frameBufferModel.getOverdrawFactor()
); );
OpenStreetMapMapnik tileSource = OpenStreetMapMapnik.INSTANCE; OpenStreetMapMapnik.INSTANCE.setUserAgent("LoraTester/1.0");
tileSource.setUserAgent("LoraTester/1.0"); currentTileSource = OpenStreetMapMapnik.INSTANCE;
downloadLayer = new TileDownloadLayer( downloadLayer = new TileDownloadLayer(
tileCache, tileCache,
mapView.getModel().mapViewPosition, mapView.getModel().mapViewPosition,
tileSource, currentTileSource,
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
mapView.getLayerManager().getLayers().add(downloadLayer); mapView.getLayerManager().getLayers().add(downloadLayer);
mapView.setZoomLevelMin(tileSource.getZoomLevelMin()); mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(tileSource.getZoomLevelMax()); mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start(); downloadLayer.start();
MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition;
@@ -707,29 +715,76 @@ public class MapFragment extends Fragment {
} }
clearTrackLayers(); clearTrackLayers();
List<LatLong> line = new ArrayList<>();
List<LatLong> boundsPoints = new ArrayList<>(); List<LatLong> boundsPoints = new ArrayList<>();
boolean colorByQuality = isRxTrack(detail);
int defaultColor = colorByQuality ? ARGB_RX : ARGB_TRACK;
for (TrackDetail.TrackPoint p : detail.points) { for (int i = 0; i < detail.points.size(); i++) {
TrackDetail.TrackPoint p = detail.points.get(i);
LatLong latLong = new LatLong(p.lat, p.lon); LatLong latLong = new LatLong(p.lat, p.lon);
line.add(latLong);
boundsPoints.add(latLong); boundsPoints.add(latLong);
Marker marker = new Marker(latLong, bitmapTrackPoint, 0, 0); int pointColor = trackPointColor(p.meta, colorByQuality, defaultColor);
Marker marker = new Marker(latLong, MapsforgeBitmaps.dot(pointColor, 12), 0, 0);
addTrackLayer(marker); addTrackLayer(marker);
}
if (line.size() >= 2) { if (i > 0) {
Polyline polyline = new Polyline( TrackDetail.TrackPoint prev = detail.points.get(i - 1);
MapsforgeBitmaps.linePaint(Color.GREEN, 4f), Double qa = rxQualityFromMeta(prev.meta);
Double qb = rxQualityFromMeta(p.meta);
Double q = qa != null && qb != null ? (qa + qb) / 2.0 : (qa != null ? qa : qb);
int segColor = colorByQuality && q != null ? qualityArgb(q) : defaultColor;
Polyline segment = new Polyline(
MapsforgeBitmaps.linePaint(segColor, 4f),
AndroidGraphicFactory.INSTANCE AndroidGraphicFactory.INSTANCE
); );
polyline.getLatLongs().addAll(line); segment.getLatLongs().add(new LatLong(prev.lat, prev.lon));
addTrackLayer(polyline); segment.getLatLongs().add(latLong);
addTrackLayer(segment);
}
} }
fitBoundsOnce(boundsPoints, detail.points.size() == 1, true); fitBoundsOnce(boundsPoints, detail.points.size() == 1, true);
} }
private static boolean isRxTrack(TrackDetail detail) {
if (detail.points == null) {
return false;
}
for (TrackDetail.TrackPoint p : detail.points) {
if (StatsExtractor.ROLE_RX.equals(p.role)) {
return true;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(p.meta, null, null);
if (StatsExtractor.ROLE_RX.equals(snap.role)) {
return true;
}
}
return false;
}
private static Double rxQualityFromMeta(String meta) {
if (meta == null || meta.isBlank()) {
return null;
}
RadioSnapshot snap = RadioSnapshot.fromMeta(meta, null, null);
return snap.rxQualityPercent;
}
private static int qualityArgb(double pct) {
double p = Math.max(0.0, Math.min(100.0, pct));
int r = p < 50.0 ? (int) Math.round(255.0 * (p / 50.0)) : 255;
int g = p < 50.0 ? 255 : (int) Math.round(255.0 * (1.0 - (p - 50.0) / 50.0));
return 0xFF000000 | (r << 16) | (g << 8);
}
private static int trackPointColor(String meta, boolean colorByQuality, int fallback) {
if (!colorByQuality) {
return fallback;
}
Double q = rxQualityFromMeta(meta);
return q != null ? qualityArgb(q) : fallback;
}
private void addTrackLayer(Layer layer) { private void addTrackLayer(Layer layer) {
mapView.getLayerManager().getLayers().add(layer); mapView.getLayerManager().getLayers().add(layer);
trackLayers.add(layer); trackLayers.add(layer);
@@ -1015,6 +1070,68 @@ public class MapFragment extends Fragment {
hillPathLine = null; hillPathLine = null;
} }
private void setupBasemapUi() {
if (mapBasemap == null) {
return;
}
List<String> labels = List.of(
getString(R.string.map_layer_scheme),
getString(R.string.map_layer_satellite)
);
suppressBasemapSpinner = true;
mapBasemap.setAdapter(new ArrayAdapter<>(
requireContext(),
android.R.layout.simple_spinner_dropdown_item,
labels
));
mapBasemap.setSelection(0, false);
suppressBasemapSpinner = false;
mapBasemap.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
if (suppressBasemapSpinner) {
return;
}
TileSource next = pos == 1 ? EsriWorldImagery.INSTANCE : OpenStreetMapMapnik.INSTANCE;
if (next != currentTileSource) {
switchBasemap(next);
}
}
@Override
public void onNothingSelected(AdapterView<?> parent) {
}
});
}
private void switchBasemap(TileSource tileSource) {
if (!isMapReady() || tileSource == null || tileSource == currentTileSource) {
return;
}
if (tileSource instanceof OpenStreetMapMapnik osm) {
osm.setUserAgent("LoraTester/1.0");
}
if (downloadLayer != null) {
downloadLayer.onPause();
mapView.getLayerManager().getLayers().remove(downloadLayer);
}
currentTileSource = tileSource;
downloadLayer = new TileDownloadLayer(
tileCache,
mapView.getModel().mapViewPosition,
currentTileSource,
AndroidGraphicFactory.INSTANCE
);
mapView.getLayerManager().getLayers().add(0, downloadLayer);
mapView.setZoomLevelMin(currentTileSource.getZoomLevelMin());
mapView.setZoomLevelMax(currentTileSource.getZoomLevelMax());
downloadLayer.start();
if (mapResumed) {
downloadLayer.onResume();
}
requestMapInvalidate();
}
private void setupHeatmapUi() { private void setupHeatmapUi() {
if (mapHeatmapRadius == null) { if (mapHeatmapRadius == null) {
return; return;
@@ -35,4 +35,12 @@ final class MapsforgeBitmaps {
paint.setStyle(org.mapsforge.core.graphics.Style.STROKE); paint.setStyle(org.mapsforge.core.graphics.Style.STROKE);
return paint; return paint;
} }
static org.mapsforge.core.graphics.Paint linePaint(int argb, float strokeWidth) {
int a = (argb >> 24) & 0xFF;
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return linePaint(AndroidGraphicFactory.INSTANCE.createColor(r, g, b, a), strokeWidth);
}
} }
+14
View File
@@ -222,6 +222,20 @@
android:textSize="10sp" /> android:textSize="10sp" />
</com.google.android.material.button.MaterialButtonToggleGroup> </com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/map_layer"
android:textColor="#CCCCCC"
android:textSize="9sp" />
<Spinner
android:id="@+id/mapBasemap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
+3
View File
@@ -81,6 +81,9 @@
<string name="map_center_rx">RX</string> <string name="map_center_rx">RX</string>
<string name="map_center_both">Оба</string> <string name="map_center_both">Оба</string>
<string name="map_center_mode">Центр карты</string> <string name="map_center_mode">Центр карты</string>
<string name="map_layer">Слой карты</string>
<string name="map_layer_scheme">Схема</string>
<string name="map_layer_satellite">Спутник</string>
<string name="map_center_unavailable">Нет координат для выбранного режима</string> <string name="map_center_unavailable">Нет координат для выбранного режима</string>
<string name="map_tool_center">Центрировать карту</string> <string name="map_tool_center">Центрировать карту</string>
<string name="map_tool_track">Трекинг пути</string> <string name="map_tool_track">Трекинг пути</string>
Binary file not shown.
Binary file not shown.
+1
View File
@@ -14,6 +14,7 @@ class TelemetryIn:
role: Optional[str] = None role: Optional[str] = None
ts: Optional[float] = None ts: Optional[float] = None
source: str = "android" source: str = "android"
device_label: Optional[str] = None
@dataclass @dataclass
+15 -1
View File
@@ -88,6 +88,19 @@ def record_telemetry(data: TelemetryIn) -> dict[str, Any]:
ts = data.ts if data.ts is not None else time.time() ts = data.ts if data.ts is not None else time.time()
lat, lon = _sanitize_coords(data.lat, data.lon) lat, lon = _sanitize_coords(data.lat, data.lon)
with _db() as conn: with _db() as conn:
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( conn.execute(
""" """
INSERT INTO devices (device_id, label, last_seen) INSERT INTO devices (device_id, label, last_seen)
@@ -138,7 +151,7 @@ def list_devices() -> list[dict[str, Any]]:
with _db() as conn: with _db() as conn:
rows = conn.execute( 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 t.lat, t.lon, t.rssi, t.range_m, t.raw_frame, t.meta, t.role, t.ts, t.source
FROM devices d FROM devices d
INNER JOIN telemetry t ON t.id = ( 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]: def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
return { return {
"device_id": row["device_id"], "device_id": row["device_id"],
"label": row["label"] if "label" in row.keys() else None,
"last_seen": row["last_seen"], "last_seen": row["last_seen"],
"lat": row["lat"], "lat": row["lat"],
"lon": row["lon"], "lon": row["lon"],
+5
View File
@@ -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: def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta, role = merge_meta(body) 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( return TelemetryIn(
device_id=str(body["device_id"]), device_id=str(body["device_id"]),
lat=_float_or_none(body.get("lat")), lat=_float_or_none(body.get("lat")),
@@ -58,4 +62,5 @@ def telemetry_from_body(body: dict[str, Any]) -> TelemetryIn:
meta=meta, meta=meta,
role=role, role=role,
ts=_float_or_none(body.get("ts")), ts=_float_or_none(body.get("ts")),
device_label=device_label,
) )
+192 -79
View File
@@ -23,6 +23,8 @@
} }
#mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; } #mapWrap { grid-column: 1; grid-row: 1; position: relative; min-height: 0; }
#map { width: 100%; height: 100%; } #map { width: 100%; height: 100%; }
.leaflet-control-layers { background: #16213e; color: #eee; border: 1px solid #444; }
.leaflet-control-layers label { color: #eee; }
#trackTimeline { #trackTimeline {
display: none; grid-column: 1 / -1; grid-row: 2; display: none; grid-column: 1 / -1; grid-row: 2;
background: #16213e; padding: 8px 16px; border-top: 1px solid #333; background: #16213e; padding: 8px 16px; border-top: 1px solid #333;
@@ -302,7 +304,7 @@
<span id="timeCurrent"></span> <span id="timeCurrent"></span>
<span id="timeEnd"></span> <span id="timeEnd"></span>
</div> </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="elevationPanel">
<div id="elevationPanelTitle"> <div id="elevationPanelTitle">
<span>Линейка высот <span id="elevationStatus" class="muted"></span></span> <span>Линейка высот <span id="elevationStatus" class="muted"></span></span>
@@ -336,9 +338,20 @@
</script> </script>
<script> <script>
const map = L.map('map').setView([55.75, 37.62], 10); const map = L.map('map').setView([55.75, 37.62], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const mapLayerScheme = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap' attribution: '© OpenStreetMap',
}).addTo(map); 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 = {}; const markers = {};
let selectedId = null; let selectedId = null;
@@ -354,8 +367,8 @@
const CMD_INPUT_IDS = ['cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdTm', 'cmdRole']; const CMD_INPUT_IDS = ['cmdFq', 'cmdPw', 'cmdSf', 'cmdPl', 'cmdBw', 'cmdCr', 'cmdTm', 'cmdRole'];
let trackTxLayer = null; let trackTxLayers = [];
let trackRxLayer = null; let trackRxLayers = [];
let trackTxMarkers = []; let trackTxMarkers = [];
let trackRxMarkers = []; let trackRxMarkers = [];
let linkLine = null; let linkLine = null;
@@ -420,6 +433,33 @@
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5; 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) { function roleColor(role) {
return role === 'RX' ? RX_COLOR : TX_COLOR; return role === 'RX' ? RX_COLOR : TX_COLOR;
} }
@@ -1468,11 +1508,11 @@
drawElevationChart(); drawElevationChart();
requestAnimationFrame(() => drawElevationChart( requestAnimationFrame(() => drawElevationChart(
singleTrackActive singleTrackActive
? { single: trackDistanceAtTime(loadedSingleTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)) } ? { single: trackDistanceAtTime(loadedSingleTrack, sliderTime()) }
: dualTracksActive : dualTracksActive
? { ? {
tx: trackDistanceAtTime(loadedTxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)), tx: trackDistanceAtTime(loadedTxTrack, sliderTime()),
rx: trackDistanceAtTime(loadedRxTrack, overlapMin + parseInt(document.getElementById('timeSlider').value, 10)) rx: trackDistanceAtTime(loadedRxTrack, sliderTime())
} }
: null : null
)); ));
@@ -1638,20 +1678,74 @@
return { min, max, mode: 'union' }; return { min, max, mode: 'union' };
} }
function nearestTelemetry(rows, t) { function telemetryAtTime(rows, t) {
if (!rows.length) return null; if (!rows?.length) return null;
let best = rows[0]; const first = rows[0];
let bestD = Math.abs(best.ts - t); const last = rows[rows.length - 1];
for (const r of rows) { if (t <= first.ts) return first;
const d = Math.abs(r.ts - t); if (t >= last.ts) return last;
if (d < bestD) { best = r; bestD = d; } 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() { function clearTrackLayers() {
if (trackTxLayer) { map.removeLayer(trackTxLayer); trackTxLayer = null; } [...trackTxLayers, ...trackRxLayers].forEach(l => map.removeLayer(l));
if (trackRxLayer) { map.removeLayer(trackRxLayer); trackRxLayer = null; } trackTxLayers = [];
trackRxLayers = [];
trackTxMarkers.forEach(m => map.removeLayer(m)); trackTxMarkers.forEach(m => map.removeLayer(m));
trackRxMarkers.forEach(m => map.removeLayer(m)); trackRxMarkers.forEach(m => map.removeLayer(m));
trackTxMarkers = []; trackTxMarkers = [];
@@ -1695,20 +1789,38 @@
updateTrackButtons(); updateTrackButtons();
} }
function drawTrackLine(track, color, store) { function drawTrackLine(track, color, store, colorByQuality) {
const latlngs = track.points.map(p => [p.lat, p.lon]); const pts = track.points;
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; const markerList = store === 'tx' ? trackTxMarkers : trackRxMarkers;
track.points.forEach(p => { const layerList = store === 'tx' ? trackTxLayers : trackRxLayers;
const m = L.circleMarker([p.lat, p.lon], { radius: 3, color, fillColor: color, fillOpacity: 0.8 }); 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.addTo(map);
m.on('click', () => { m.on('click', () => {
const rel = Math.max(0, Math.min(Math.round(p.ts - overlapMin), parseInt(document.getElementById('timeSlider').max, 10))); const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
document.getElementById('timeSlider').value = rel; document.getElementById('timeSlider').value = String(rel);
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(overlapMin + rel, { openModal: true }); updateTimelineAt(p.ts, { openModal: true });
}); });
markerList.push(m); markerList.push(m);
}); });
@@ -1720,13 +1832,14 @@
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`; let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
html += `Расстояние: ${dist.toFixed(0)} m (GPS)<br><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 += `<span class="legend-tx">TX</span> ${txPos.lat.toFixed(5)}, ${txPos.lon.toFixed(5)}<br>`;
const txTel = nearestTelemetry(telemetryTx, t); const { txTel, rxTel } = pairedTelemetryAtTime(
const rxTel = nearestTelemetry(telemetryRx, t); loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
html += renderTimelineCompare( html += renderTimelineCompare(
txTel || { meta: txPos.meta, role: 'TX', rssi: null }, txTel || { meta: txPos.meta, role: 'TX', rssi: null },
rxTel || { meta: rxPos.meta, role: 'RX', rssi: null }, rxTel || { meta: rxPos.meta, role: 'RX', rssi: null },
loadedTxTrack?.device_id, deviceDisplayName(loadedTxTrack?.device_id),
loadedRxTrack?.device_id deviceDisplayName(loadedRxTrack?.device_id)
); );
return html; return html;
} }
@@ -1775,14 +1888,15 @@
} }
} }
const txTel = nearestTelemetry(telemetryTx, t); const { txTel, rxTel } = pairedTelemetryAtTime(
const rxTel = nearestTelemetry(telemetryRx, t); loadedTxTrack, loadedRxTrack, telemetryTx, telemetryRx, t
);
const timelineStatsEl = document.getElementById('timelineStats'); const timelineStatsEl = document.getElementById('timelineStats');
setPanelHtml(timelineStatsEl, renderTimelineCompare( setPanelHtml(timelineStatsEl, renderTimelineCompare(
txTel, txTel,
rxTel, rxTel,
loadedTxTrack?.device_id, deviceDisplayName(loadedTxTrack?.device_id),
loadedRxTrack?.device_id deviceDisplayName(loadedRxTrack?.device_id)
)); ));
drawElevationChart({ drawElevationChart({
tx: trackDistanceAtTime(loadedTxTrack, t), tx: trackDistanceAtTime(loadedTxTrack, t),
@@ -1842,24 +1956,32 @@
statsPanel.classList.toggle('timeline-single', single); 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() { function setupTimelineSingle() {
const range = singleTrackRange(loadedSingleTrack.points); const range = singleTrackRange(loadedSingleTrack.points);
const note = document.getElementById('timelineNote');
setTimelineMode(true); setTimelineMode(true);
if (!range) { if (!range) {
setTimelineVisible(false); setTimelineVisible(false);
return; return;
} }
overlapMin = range.min; applyTimelineRange(
overlapMax = range.max; range,
const span = Math.max(1, Math.round(overlapMax - overlapMin)); `Трек #${loadedSingleTrack.id} · ${deviceDisplayName(loadedSingleTrack.device_id)}`
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 || ''}`;
setTimelineVisible(true); setTimelineVisible(true);
updateTimelineAtSingle(overlapMin); updateTimelineAtSingle(overlapMin);
loadElevationProfiles(); loadElevationProfiles();
@@ -1868,26 +1990,16 @@
function setupTimeline() { function setupTimeline() {
setTimelineMode(false); setTimelineMode(false);
const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points); const range = timelineRange(loadedTxTrack.points, loadedRxTrack.points);
const note = document.getElementById('timelineNote');
if (!range) { if (!range) {
setTimelineVisible(false); setTimelineVisible(false);
return; return;
} }
overlapMin = range.min; let noteText = 'Общий интервал записи обоих треков.';
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') { if (range.mode === 'union') {
note.textContent = noteText =
'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.'; 'Треки не пересекаются по времени — шкала на полном диапазоне; вне записи позиция удерживается на краю.';
} else {
note.textContent = 'Общий интервал записи обоих треков.';
} }
applyTimelineRange(range, noteText);
setTimelineVisible(true); setTimelineVisible(true);
updateTimelineAt(overlapMin); updateTimelineAt(overlapMin);
loadElevationProfiles(); loadElevationProfiles();
@@ -1902,7 +2014,7 @@
{ cache: 'no-store' } { cache: 'no-store' }
); );
if (res.ok) telemetrySingle = await res.json(); if (res.ok) telemetrySingle = await res.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10); const t = sliderTime();
updateTimelineAtSingle(t); updateTimelineAtSingle(t);
return; return;
} }
@@ -1915,14 +2027,13 @@
]); ]);
if (telTx.ok) telemetryTx = await telTx.json(); if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json(); if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10); updateTimelineAt(sliderTime());
updateTimelineAt(t);
} }
function trackOptionLabel(t) { function trackOptionLabel(t) {
const start = new Date(t.started_at * 1000).toLocaleString(); const start = new Date(t.started_at * 1000).toLocaleString();
const role = t.role ? ` · ${t.role}` : ''; 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})`; return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
} }
@@ -1980,7 +2091,7 @@
return; return;
} }
const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR; const color = loadedSingleTrack.role === 'RX' ? RX_COLOR : TX_COLOR;
drawTrackLine(loadedSingleTrack, color, 'tx'); drawTrackLine(loadedSingleTrack, color, 'tx', loadedSingleTrack.role === 'RX');
if (!userMovedMap) { if (!userMovedMap) {
const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon])); const bounds = L.latLngBounds(loadedSingleTrack.points.map(p => [p.lat, p.lon]));
setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 })); setMapViewProgrammatically(() => map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }));
@@ -2033,8 +2144,8 @@
return; return;
} }
drawTrackLine(loadedTxTrack, TX_COLOR, 'tx'); drawTrackLine(loadedTxTrack, TX_COLOR, 'tx', false);
drawTrackLine(loadedRxTrack, RX_COLOR, 'rx'); drawTrackLine(loadedRxTrack, RX_COLOR, 'rx', true);
if (!userMovedMap) { if (!userMovedMap) {
const bounds = L.latLngBounds([]); const bounds = L.latLngBounds([]);
@@ -2052,7 +2163,7 @@
]); ]);
if (telTx.ok) telemetryTx = await telTx.json(); if (telTx.ok) telemetryTx = await telTx.json();
if (telRx.ok) telemetryRx = await telRx.json(); if (telRx.ok) telemetryRx = await telRx.json();
const t = overlapMin + parseInt(document.getElementById('timeSlider').value, 10); const t = sliderTime();
updateTimelineAt(t); updateTimelineAt(t);
} }
@@ -2204,7 +2315,7 @@
}; };
document.getElementById('timeSlider').oninput = e => { document.getElementById('timeSlider').oninput = e => {
modalMode = 'timeline'; modalMode = 'timeline';
updateTimelineAt(overlapMin + parseInt(e.target.value, 10), { openModal: true }); updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
}; };
document.getElementById('btnPlay').onclick = () => { document.getElementById('btnPlay').onclick = () => {
if (playTimer) { if (playTimer) {
@@ -2216,11 +2327,12 @@
const slider = document.getElementById('timeSlider'); const slider = document.getElementById('timeSlider');
document.getElementById('btnPlay').textContent = '⏸ Pause'; document.getElementById('btnPlay').textContent = '⏸ Pause';
playTimer = setInterval(() => { playTimer = setInterval(() => {
let v = parseInt(slider.value, 10) + 1; const step = parseFloat(slider.step) || 1;
if (v > parseInt(slider.max, 10)) v = 0; let v = parseFloat(slider.value) + step;
slider.value = v; if (v > parseFloat(slider.max)) v = 0;
slider.value = String(v);
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' }); updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
}, 1000); }, Math.max(200, Math.round(step * 1000)));
}; };
function buildDeviceStatsHtml(d) { function buildDeviceStatsHtml(d) {
@@ -2229,7 +2341,7 @@
prevDeviceSnap = snap; prevDeviceSnap = snap;
const statsEl = document.getElementById('stats'); const statsEl = document.getElementById('stats');
let html = RadioUI.formatRadioPanel(snap, changed, isRadioStaticOpen(statsEl)); 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)) { if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`; html += `GPS: ${d.lat.toFixed(5)}, ${d.lon.toFixed(5)}<br>`;
Object.keys(markers).forEach(id => { Object.keys(markers).forEach(id => {
@@ -2268,7 +2380,7 @@
const seen = new Set(); const seen = new Set();
devices.forEach(d => { devices.forEach(d => {
const li = document.createElement('li'); const li = document.createElement('li');
let label = d.device_id; let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`; if (d.role) label += ` · ${d.role}`;
if (d.rssi != null) label += ` · ${d.rssi} dBm`; if (d.rssi != null) label += ` · ${d.rssi} dBm`;
try { try {
@@ -2278,6 +2390,7 @@
if (m.packet != null) label += ` #${m.packet}`; if (m.packet != null) label += ` #${m.packet}`;
} }
} catch (e) {} } catch (e) {}
li.dataset.deviceId = d.device_id;
li.textContent = label; li.textContent = label;
li.className = d.device_id === selectedId ? 'active' : ''; li.className = d.device_id === selectedId ? 'active' : '';
li.onclick = () => selectDevice(d); li.onclick = () => selectDevice(d);
@@ -2328,7 +2441,7 @@
setCmdFormDirty(false); setCmdFormDirty(false);
fillCmdFormFromDevice(d, { force: true }); fillCmdFormFromDevice(d, { force: true });
document.querySelectorAll('#deviceList li').forEach(li => { 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)) { if (d.lat != null && d.lon != null && !isNullIsland(d.lat, d.lon)) {
setMapViewProgrammatically(() => { setMapViewProgrammatically(() => {
@@ -2378,7 +2491,7 @@
const isNew = m.ts > chatLastReadTs; const isNew = m.ts > chatLastReadTs;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : ''); 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)}`; div.innerHTML = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`;
log.appendChild(div); log.appendChild(div);
if (isNew) { if (isNew) {
@@ -2462,7 +2575,7 @@
devices.forEach(d => { devices.forEach(d => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = d.device_id; opt.value = d.device_id;
let label = d.device_id; let label = deviceDisplayName(d);
if (d.role) label += ` · ${d.role}`; if (d.role) label += ` · ${d.role}`;
opt.textContent = label; opt.textContent = label;
sel.appendChild(opt); sel.appendChild(opt);