generated from Grigo/AndroidTemplate
update
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
@@ -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
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user