generated from Grigo/AndroidTemplate
added subprox
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
package com.grigowashere.loratester.api;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ElevationGridResult {
|
||||||
|
public boolean ok;
|
||||||
|
public String error;
|
||||||
|
public Center center;
|
||||||
|
public double radius_m;
|
||||||
|
public double step_m;
|
||||||
|
public double min_delta_m;
|
||||||
|
public double max_delta_m;
|
||||||
|
public List<GridPoint> points;
|
||||||
|
|
||||||
|
public static class Center {
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public double elevation_m;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class GridPoint {
|
||||||
|
public int i;
|
||||||
|
public int j;
|
||||||
|
public double lat;
|
||||||
|
public double lon;
|
||||||
|
public double dist_m;
|
||||||
|
public Double elevation_m;
|
||||||
|
public double delta_m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,27 @@ public class ServerApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM)
|
||||||
|
throws IOException {
|
||||||
|
String path = "/api/elevation/grid?lat="
|
||||||
|
+ lat
|
||||||
|
+ "&lon="
|
||||||
|
+ lon
|
||||||
|
+ "&radius_m="
|
||||||
|
+ radiusM
|
||||||
|
+ "&step_m=0";
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(baseUrl + path)
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
throw new IOException("HTTP " + response.code());
|
||||||
|
}
|
||||||
|
return GSON.fromJson(response.body().string(), ElevationGridResult.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
/** Diverging color ramp: blue = below, green = level, brown = above. */
|
||||||
|
final class ElevationColorRamp {
|
||||||
|
|
||||||
|
private static final int ALPHA = 0x8C;
|
||||||
|
|
||||||
|
private ElevationColorRamp() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static int deltaToArgb(double deltaM) {
|
||||||
|
if (deltaM <= -8.0) {
|
||||||
|
return argb(0x1A, 0x4A, 0x8C);
|
||||||
|
}
|
||||||
|
if (deltaM <= -2.0) {
|
||||||
|
return lerp(argb(0x1A, 0x4A, 0x8C), argb(0x4F, 0xC3, 0xF7), (deltaM + 8.0) / 6.0);
|
||||||
|
}
|
||||||
|
if (deltaM <= 2.0) {
|
||||||
|
return lerp(argb(0x4F, 0xC3, 0xF7), argb(0x00, 0xFF, 0x88), (deltaM + 2.0) / 4.0);
|
||||||
|
}
|
||||||
|
if (deltaM <= 8.0) {
|
||||||
|
return lerp(argb(0x00, 0xFF, 0x88), argb(0xFF, 0xC1, 0x07), (deltaM - 2.0) / 6.0);
|
||||||
|
}
|
||||||
|
return argb(0x8B, 0x5A, 0x2B);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int argb(int r, int g, int b) {
|
||||||
|
return (ALPHA << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int lerp(int from, int to, double t) {
|
||||||
|
t = Math.max(0.0, Math.min(1.0, t));
|
||||||
|
int a = (int) Math.round(((from >>> 24) & 0xFF) + t * (((to >>> 24) & 0xFF) - ((from >>> 24) & 0xFF)));
|
||||||
|
int r = (int) Math.round(((from >> 16) & 0xFF) + t * (((to >> 16) & 0xFF) - ((from >> 16) & 0xFF)));
|
||||||
|
int g = (int) Math.round(((from >> 8) & 0xFF) + t * (((to >> 8) & 0xFF) - ((from >> 8) & 0xFF)));
|
||||||
|
int b = (int) Math.round((from & 0xFF) + t * ((to & 0xFF) - (from & 0xFF)));
|
||||||
|
return (a << 24) | (r << 16) | (g << 8) | b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||||
|
|
||||||
|
import org.mapsforge.core.model.LatLong;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Builds a geo-referenced raster from elevation grid API response. */
|
||||||
|
final class ElevationHeatmapBitmap {
|
||||||
|
|
||||||
|
static final class Raster {
|
||||||
|
final Bitmap bitmap;
|
||||||
|
final LatLong northWest;
|
||||||
|
final LatLong southEast;
|
||||||
|
|
||||||
|
Raster(Bitmap bitmap, LatLong northWest, LatLong southEast) {
|
||||||
|
this.bitmap = bitmap;
|
||||||
|
this.northWest = northWest;
|
||||||
|
this.southEast = southEast;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElevationHeatmapBitmap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
static Raster build(ElevationGridResult grid) {
|
||||||
|
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int minI = Integer.MAX_VALUE;
|
||||||
|
int maxI = Integer.MIN_VALUE;
|
||||||
|
int minJ = Integer.MAX_VALUE;
|
||||||
|
int maxJ = Integer.MIN_VALUE;
|
||||||
|
double minLat = Double.MAX_VALUE;
|
||||||
|
double maxLat = -Double.MAX_VALUE;
|
||||||
|
double minLon = Double.MAX_VALUE;
|
||||||
|
double maxLon = -Double.MAX_VALUE;
|
||||||
|
|
||||||
|
Map<Long, ElevationGridResult.GridPoint> byIndex = new HashMap<>();
|
||||||
|
for (ElevationGridResult.GridPoint p : grid.points) {
|
||||||
|
minI = Math.min(minI, p.i);
|
||||||
|
maxI = Math.max(maxI, p.i);
|
||||||
|
minJ = Math.min(minJ, p.j);
|
||||||
|
maxJ = Math.max(maxJ, p.j);
|
||||||
|
minLat = Math.min(minLat, p.lat);
|
||||||
|
maxLat = Math.max(maxLat, p.lat);
|
||||||
|
minLon = Math.min(minLon, p.lon);
|
||||||
|
maxLon = Math.max(maxLon, p.lon);
|
||||||
|
byIndex.put(pack(p.i, p.j), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
int cols = maxJ - minJ + 1;
|
||||||
|
int rows = maxI - minI + 1;
|
||||||
|
if (cols < 1 || rows < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int[] pixels = new int[cols * rows];
|
||||||
|
double radiusM = grid.radius_m > 0 ? grid.radius_m : 200.0;
|
||||||
|
double stepM = grid.step_m > 0 ? grid.step_m : 15.0;
|
||||||
|
|
||||||
|
for (int row = 0; row < rows; row++) {
|
||||||
|
int i = maxI - row;
|
||||||
|
for (int col = 0; col < cols; col++) {
|
||||||
|
int j = minJ + col;
|
||||||
|
double dist = Math.hypot(i * stepM, j * stepM);
|
||||||
|
if (dist > radiusM + stepM * 0.5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ElevationGridResult.GridPoint p = byIndex.get(pack(i, j));
|
||||||
|
if (p != null && p.elevation_m != null) {
|
||||||
|
pixels[row * cols + col] = ElevationColorRamp.deltaToArgb(p.delta_m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Bitmap bitmap = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888);
|
||||||
|
bitmap.setPixels(pixels, 0, cols, 0, 0, cols, rows);
|
||||||
|
|
||||||
|
double halfStepLat = (stepM / 2.0) / 111_320.0;
|
||||||
|
double halfStepLon = (stepM / 2.0)
|
||||||
|
/ (111_320.0 * Math.max(Math.cos(Math.toRadians((minLat + maxLat) / 2.0)), 1e-6));
|
||||||
|
|
||||||
|
LatLong northWest = new LatLong(maxLat + halfStepLat, minLon - halfStepLon);
|
||||||
|
LatLong southEast = new LatLong(minLat - halfStepLat, maxLon + halfStepLon);
|
||||||
|
return new Raster(bitmap, northWest, southEast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long pack(int i, int j) {
|
||||||
|
return ((long) i << 32) ^ (j & 0xFFFFFFFFL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.grigowashere.loratester.ui;
|
||||||
|
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
|
||||||
|
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||||
|
|
||||||
|
import org.mapsforge.core.graphics.Canvas;
|
||||||
|
import org.mapsforge.core.model.BoundingBox;
|
||||||
|
import org.mapsforge.core.model.LatLong;
|
||||||
|
import org.mapsforge.core.model.Point;
|
||||||
|
import org.mapsforge.core.util.MercatorProjection;
|
||||||
|
import org.mapsforge.map.android.graphics.AndroidBitmap;
|
||||||
|
import org.mapsforge.map.layer.Layer;
|
||||||
|
|
||||||
|
/** Geo-referenced elevation heatmap overlay for Mapsforge. */
|
||||||
|
public class ElevationHeatmapLayer extends Layer {
|
||||||
|
|
||||||
|
private static final int TILE_SIZE = 256;
|
||||||
|
|
||||||
|
private Bitmap sourceBitmap;
|
||||||
|
private org.mapsforge.core.graphics.Bitmap mapsforgeBitmap;
|
||||||
|
private LatLong northWest;
|
||||||
|
private LatLong southEast;
|
||||||
|
|
||||||
|
public void setGrid(ElevationGridResult grid) {
|
||||||
|
sourceBitmap = null;
|
||||||
|
mapsforgeBitmap = null;
|
||||||
|
|
||||||
|
ElevationHeatmapBitmap.Raster raster = ElevationHeatmapBitmap.build(grid);
|
||||||
|
if (raster == null) {
|
||||||
|
northWest = null;
|
||||||
|
southEast = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sourceBitmap = raster.bitmap;
|
||||||
|
northWest = raster.northWest;
|
||||||
|
southEast = raster.southEast;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasData() {
|
||||||
|
return sourceBitmap != null && northWest != null && southEast != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void draw(BoundingBox boundingBox, byte zoomLevel, Canvas canvas, Point topLeftPoint) {
|
||||||
|
if (!hasData()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BoundingBox rasterBox = new BoundingBox(
|
||||||
|
southEast.latitude,
|
||||||
|
northWest.longitude,
|
||||||
|
northWest.latitude,
|
||||||
|
southEast.longitude
|
||||||
|
);
|
||||||
|
if (!boundingBox.intersects(rasterBox)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long mapSize = MercatorProjection.getMapSize(zoomLevel, TILE_SIZE);
|
||||||
|
int left = (int) Math.round(
|
||||||
|
MercatorProjection.longitudeToPixelX(northWest.longitude, mapSize) - topLeftPoint.x);
|
||||||
|
int top = (int) Math.round(
|
||||||
|
MercatorProjection.latitudeToPixelY(northWest.latitude, mapSize) - topLeftPoint.y);
|
||||||
|
int right = (int) Math.round(
|
||||||
|
MercatorProjection.longitudeToPixelX(southEast.longitude, mapSize) - topLeftPoint.x);
|
||||||
|
int bottom = (int) Math.round(
|
||||||
|
MercatorProjection.latitudeToPixelY(southEast.latitude, mapSize) - topLeftPoint.y);
|
||||||
|
|
||||||
|
int width = right - left;
|
||||||
|
int height = bottom - top;
|
||||||
|
if (width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapsforgeBitmap == null) {
|
||||||
|
mapsforgeBitmap = new AndroidBitmap(sourceBitmap);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawBitmap(
|
||||||
|
mapsforgeBitmap,
|
||||||
|
0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight(),
|
||||||
|
left, top, right, bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import com.grigowashere.loratester.PeerStatsCache;
|
|||||||
import com.grigowashere.loratester.R;
|
import com.grigowashere.loratester.R;
|
||||||
import com.grigowashere.loratester.TelemetryUploader;
|
import com.grigowashere.loratester.TelemetryUploader;
|
||||||
import com.grigowashere.loratester.api.DeviceInfo;
|
import com.grigowashere.loratester.api.DeviceInfo;
|
||||||
|
import com.grigowashere.loratester.api.ElevationGridResult;
|
||||||
import com.grigowashere.loratester.api.NearestHillResult;
|
import com.grigowashere.loratester.api.NearestHillResult;
|
||||||
import com.grigowashere.loratester.api.ServerApi;
|
import com.grigowashere.loratester.api.ServerApi;
|
||||||
import com.grigowashere.loratester.api.TrackDetail;
|
import com.grigowashere.loratester.api.TrackDetail;
|
||||||
@@ -82,6 +83,9 @@ public class MapFragment extends Fragment {
|
|||||||
private static final int ARGB_HILL = 0xFFFFC107;
|
private static final int ARGB_HILL = 0xFFFFC107;
|
||||||
private static final int HILL_SEARCH_RADIUS_M = 5000;
|
private static final int HILL_SEARCH_RADIUS_M = 5000;
|
||||||
private static final long LORA_STATS_FRESH_MS = 120_000;
|
private static final long LORA_STATS_FRESH_MS = 120_000;
|
||||||
|
private static final int HEATMAP_GPS_FOLLOW_M = 50;
|
||||||
|
private static final long HEATMAP_GPS_DEBOUNCE_MS = 2000L;
|
||||||
|
private static final int[] HEATMAP_RADIUS_OPTIONS = {100, 200, 500};
|
||||||
|
|
||||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||||
private final DateFormat timeFormat =
|
private final DateFormat timeFormat =
|
||||||
@@ -106,11 +110,14 @@ public class MapFragment extends Fragment {
|
|||||||
private Button btnTrack;
|
private Button btnTrack;
|
||||||
private Button btnPairedTrack;
|
private Button btnPairedTrack;
|
||||||
private Button btnFindHill;
|
private Button btnFindHill;
|
||||||
|
private Button btnHeatmap;
|
||||||
private Button btnCenterMe;
|
private Button btnCenterMe;
|
||||||
private Button btnCenterTx;
|
private Button btnCenterTx;
|
||||||
private Button btnCenterRx;
|
private Button btnCenterRx;
|
||||||
private Button btnCenterBoth;
|
private Button btnCenterBoth;
|
||||||
private Spinner trackSpinner;
|
private Spinner trackSpinner;
|
||||||
|
private Spinner mapHeatmapRadius;
|
||||||
|
private TextView mapHeatmapStatus;
|
||||||
private List<TrackInfo> savedTracks = new ArrayList<>();
|
private List<TrackInfo> savedTracks = new ArrayList<>();
|
||||||
private boolean mapResumed;
|
private boolean mapResumed;
|
||||||
private boolean mapInitialized;
|
private boolean mapInitialized;
|
||||||
@@ -130,6 +137,13 @@ public class MapFragment extends Fragment {
|
|||||||
private Bitmap bitmapHill;
|
private Bitmap bitmapHill;
|
||||||
private Marker hillMarker;
|
private Marker hillMarker;
|
||||||
private Polyline hillPathLine;
|
private Polyline hillPathLine;
|
||||||
|
private ElevationHeatmapLayer heatmapLayer;
|
||||||
|
private boolean heatmapActive;
|
||||||
|
private int heatmapRadiusM = 200;
|
||||||
|
private double lastHeatmapLat = Double.NaN;
|
||||||
|
private double lastHeatmapLon = Double.NaN;
|
||||||
|
private boolean suppressHeatmapSpinner;
|
||||||
|
private Runnable heatmapReloadRunnable;
|
||||||
private float touchDownX;
|
private float touchDownX;
|
||||||
private float touchDownY;
|
private float touchDownY;
|
||||||
|
|
||||||
@@ -166,6 +180,9 @@ public class MapFragment extends Fragment {
|
|||||||
btnTrack = view.findViewById(R.id.btnTrack);
|
btnTrack = view.findViewById(R.id.btnTrack);
|
||||||
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
btnPairedTrack = view.findViewById(R.id.btnPairedTrack);
|
||||||
btnFindHill = view.findViewById(R.id.btnFindHill);
|
btnFindHill = view.findViewById(R.id.btnFindHill);
|
||||||
|
btnHeatmap = view.findViewById(R.id.btnHeatmap);
|
||||||
|
mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius);
|
||||||
|
mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus);
|
||||||
btnCenterMe = view.findViewById(R.id.btnCenterMe);
|
btnCenterMe = view.findViewById(R.id.btnCenterMe);
|
||||||
btnCenterTx = view.findViewById(R.id.btnCenterTx);
|
btnCenterTx = view.findViewById(R.id.btnCenterTx);
|
||||||
btnCenterRx = view.findViewById(R.id.btnCenterRx);
|
btnCenterRx = view.findViewById(R.id.btnCenterRx);
|
||||||
@@ -188,6 +205,7 @@ public class MapFragment extends Fragment {
|
|||||||
if (btnFindHill != null) {
|
if (btnFindHill != null) {
|
||||||
btnFindHill.setOnClickListener(v -> findNearestHill());
|
btnFindHill.setOnClickListener(v -> findNearestHill());
|
||||||
}
|
}
|
||||||
|
setupHeatmapUi();
|
||||||
|
|
||||||
updateConnectionIcons(lastDevices, serverConnected);
|
updateConnectionIcons(lastDevices, serverConnected);
|
||||||
|
|
||||||
@@ -335,6 +353,8 @@ public class MapFragment extends Fragment {
|
|||||||
clearTrackLayers();
|
clearTrackLayers();
|
||||||
clearLiveTrackLayers();
|
clearLiveTrackLayers();
|
||||||
clearHillLayers();
|
clearHillLayers();
|
||||||
|
removeHeatmapLayer();
|
||||||
|
cancelHeatmapReload();
|
||||||
deviceMarkers.clear();
|
deviceMarkers.clear();
|
||||||
if (downloadLayer != null) {
|
if (downloadLayer != null) {
|
||||||
downloadLayer.onDestroy();
|
downloadLayer.onDestroy();
|
||||||
@@ -352,7 +372,12 @@ public class MapFragment extends Fragment {
|
|||||||
iconServer = null;
|
iconServer = null;
|
||||||
iconLora = null;
|
iconLora = null;
|
||||||
btnFindHill = null;
|
btnFindHill = null;
|
||||||
|
btnHeatmap = null;
|
||||||
|
mapHeatmapRadius = null;
|
||||||
|
mapHeatmapStatus = null;
|
||||||
mapHillStatus = null;
|
mapHillStatus = null;
|
||||||
|
heatmapLayer = null;
|
||||||
|
heatmapActive = false;
|
||||||
mapStatus = null;
|
mapStatus = null;
|
||||||
mapDistance = null;
|
mapDistance = null;
|
||||||
trackStatus = null;
|
trackStatus = null;
|
||||||
@@ -871,6 +896,196 @@ public class MapFragment extends Fragment {
|
|||||||
hillPathLine = null;
|
hillPathLine = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setupHeatmapUi() {
|
||||||
|
if (mapHeatmapRadius == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> labels = new ArrayList<>();
|
||||||
|
for (int r : HEATMAP_RADIUS_OPTIONS) {
|
||||||
|
labels.add(getString(R.string.map_heatmap_radius_m, r));
|
||||||
|
}
|
||||||
|
suppressHeatmapSpinner = true;
|
||||||
|
mapHeatmapRadius.setAdapter(new ArrayAdapter<>(
|
||||||
|
requireContext(),
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
labels
|
||||||
|
));
|
||||||
|
mapHeatmapRadius.setSelection(1, false);
|
||||||
|
suppressHeatmapSpinner = false;
|
||||||
|
|
||||||
|
mapHeatmapRadius.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(AdapterView<?> parent, View v, int pos, long id) {
|
||||||
|
if (suppressHeatmapSpinner || pos < 0 || pos >= HEATMAP_RADIUS_OPTIONS.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heatmapRadiusM = HEATMAP_RADIUS_OPTIONS[pos];
|
||||||
|
if (heatmapActive) {
|
||||||
|
LatLong self = resolveSelfPosition();
|
||||||
|
if (self != null) {
|
||||||
|
loadHeatmap(self.latitude, self.longitude, heatmapRadiusM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(AdapterView<?> parent) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setOnClickListener(v -> toggleHeatmap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleHeatmap() {
|
||||||
|
heatmapActive = !heatmapActive;
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setActivated(heatmapActive);
|
||||||
|
}
|
||||||
|
if (!heatmapActive) {
|
||||||
|
cancelHeatmapReload();
|
||||||
|
removeHeatmapLayer();
|
||||||
|
lastHeatmapLat = Double.NaN;
|
||||||
|
lastHeatmapLon = Double.NaN;
|
||||||
|
if (mapHeatmapStatus != null) {
|
||||||
|
mapHeatmapStatus.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LatLong self = resolveSelfPosition();
|
||||||
|
if (self == null) {
|
||||||
|
heatmapActive = false;
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setActivated(false);
|
||||||
|
}
|
||||||
|
Toast.makeText(requireContext(), R.string.map_heatmap_no_gps, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadHeatmap(self.latitude, self.longitude, heatmapRadiusM);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadHeatmap(double lat, double lon, int radiusM) {
|
||||||
|
if (uploader == null || !isAdded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mapHeatmapStatus != null) {
|
||||||
|
mapHeatmapStatus.setVisibility(View.VISIBLE);
|
||||||
|
mapHeatmapStatus.setText(R.string.map_heatmap_loading);
|
||||||
|
}
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setEnabled(false);
|
||||||
|
}
|
||||||
|
executor.execute(() -> {
|
||||||
|
try {
|
||||||
|
ElevationGridResult grid = uploader.getServerApi().getElevationGrid(lat, lon, radiusM);
|
||||||
|
if (!isAdded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread(() -> {
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setEnabled(true);
|
||||||
|
}
|
||||||
|
if (!heatmapActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grid == null || !grid.ok || grid.points == null || grid.points.isEmpty()) {
|
||||||
|
if (mapHeatmapStatus != null) {
|
||||||
|
String err = grid != null && grid.error != null
|
||||||
|
? grid.error
|
||||||
|
: getString(R.string.map_find_hill_none);
|
||||||
|
mapHeatmapStatus.setText(getString(R.string.map_heatmap_error, err));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureHeatmapLayer();
|
||||||
|
heatmapLayer.setGrid(grid);
|
||||||
|
lastHeatmapLat = lat;
|
||||||
|
lastHeatmapLon = lon;
|
||||||
|
if (mapHeatmapStatus != null) {
|
||||||
|
mapHeatmapStatus.setText(getString(
|
||||||
|
R.string.map_heatmap_result,
|
||||||
|
grid.min_delta_m,
|
||||||
|
grid.max_delta_m,
|
||||||
|
grid.points.size()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (mapView != null) {
|
||||||
|
mapView.invalidate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (!isAdded()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireActivity().runOnUiThread(() -> {
|
||||||
|
if (btnHeatmap != null) {
|
||||||
|
btnHeatmap.setEnabled(true);
|
||||||
|
}
|
||||||
|
if (mapHeatmapStatus != null && heatmapActive) {
|
||||||
|
mapHeatmapStatus.setText(
|
||||||
|
getString(R.string.map_heatmap_error, e.getMessage()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureHeatmapLayer() {
|
||||||
|
if (!isMapReady() || heatmapLayer != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
heatmapLayer = new ElevationHeatmapLayer();
|
||||||
|
org.mapsforge.map.layer.Layers layers = mapView.getLayerManager().getLayers();
|
||||||
|
int insertAt = downloadLayer != null ? layers.indexOf(downloadLayer) + 1 : layers.size();
|
||||||
|
if (insertAt < 0) {
|
||||||
|
insertAt = layers.size();
|
||||||
|
}
|
||||||
|
layers.add(insertAt, heatmapLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeHeatmapLayer() {
|
||||||
|
if (heatmapLayer != null && mapView != null) {
|
||||||
|
mapView.getLayerManager().getLayers().remove(heatmapLayer);
|
||||||
|
}
|
||||||
|
heatmapLayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelHeatmapReload() {
|
||||||
|
if (heatmapReloadRunnable != null && getView() != null) {
|
||||||
|
getView().removeCallbacks(heatmapReloadRunnable);
|
||||||
|
}
|
||||||
|
heatmapReloadRunnable = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkHeatmapGpsFollow() {
|
||||||
|
if (!heatmapActive || uploader == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LatLong self = resolveSelfPosition();
|
||||||
|
if (self == null || Double.isNaN(lastHeatmapLat)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double moved = GeoUtils.haversineMeters(
|
||||||
|
lastHeatmapLat, lastHeatmapLon, self.latitude, self.longitude);
|
||||||
|
if (moved <= HEATMAP_GPS_FOLLOW_M) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelHeatmapReload();
|
||||||
|
final double lat = self.latitude;
|
||||||
|
final double lon = self.longitude;
|
||||||
|
final int radius = heatmapRadiusM;
|
||||||
|
heatmapReloadRunnable = () -> {
|
||||||
|
if (heatmapActive) {
|
||||||
|
loadHeatmap(lat, lon, radius);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
View root = getView();
|
||||||
|
if (root != null) {
|
||||||
|
root.postDelayed(heatmapReloadRunnable, HEATMAP_GPS_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void updateMap(List<DeviceInfo> devices) {
|
private void updateMap(List<DeviceInfo> devices) {
|
||||||
if (!isMapReady()) {
|
if (!isMapReady()) {
|
||||||
return;
|
return;
|
||||||
@@ -927,6 +1142,7 @@ public class MapFragment extends Fragment {
|
|||||||
}
|
}
|
||||||
updateGpsDistance();
|
updateGpsDistance();
|
||||||
updateConnectionIcons(lastDevices, serverConnected);
|
updateConnectionIcons(lastDevices, serverConnected);
|
||||||
|
checkHeatmapGpsFollow();
|
||||||
|
|
||||||
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
|
if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) {
|
||||||
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
fitBoundsOnce(boundsPoints, onMap == 1, false);
|
||||||
|
|||||||
@@ -150,6 +150,31 @@
|
|||||||
android:textSize="9sp"
|
android:textSize="9sp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/mapHeatmapRadius"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnHeatmap"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:minHeight="34dp"
|
||||||
|
android:text="@string/map_heatmap"
|
||||||
|
android:textSize="10sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/mapHeatmapStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textColor="#AAAAAA"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/mapLegend"
|
android:id="@+id/mapLegend"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -91,5 +91,12 @@
|
|||||||
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
|
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
|
||||||
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
|
<string name="map_find_hill_no_gps">Нужен GPS для поиска</string>
|
||||||
<string name="map_find_hill_error">Ошибка: %1$s</string>
|
<string name="map_find_hill_error">Ошибка: %1$s</string>
|
||||||
|
<string name="map_heatmap">Рельеф (хитмапа)</string>
|
||||||
|
<string name="map_heatmap_radius">Радиус рельефа</string>
|
||||||
|
<string name="map_heatmap_radius_m">%1$d m</string>
|
||||||
|
<string name="map_heatmap_loading">Загрузка рельефа…</string>
|
||||||
|
<string name="map_heatmap_result">Δ %1$.0f…%2$.0f m · %3$d точек</string>
|
||||||
|
<string name="map_heatmap_error">Рельеф: %1$s</string>
|
||||||
|
<string name="map_heatmap_no_gps">Нужен GPS для рельефа</string>
|
||||||
<string name="chat_self_label">Вы</string>
|
<string name="chat_self_label">Вы</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ curl http://127.0.0.1:7634/api/health
|
|||||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||||
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
- `GET /api/elevation/nearest-hill?lat=&lon=&radius_m=5000` — ближайшая возвышенность (прокси Open-Meteo)
|
||||||
|
- `GET /api/elevation/grid?lat=&lon=&radius_m=200&step_m=0` — сетка высот для хитмапы (100–500 m, step_m=0 авто)
|
||||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
+112
-12
@@ -315,6 +315,117 @@ def _offset_m(lat: float, lon: float, north_m: float, east_m: float) -> tuple[fl
|
|||||||
return lat + dlat, lon + dlon
|
return lat + dlat, lon + dlon
|
||||||
|
|
||||||
|
|
||||||
|
_MAX_GRID_POINTS = 2500
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_step_m(radius_m: float) -> float:
|
||||||
|
if radius_m <= 150:
|
||||||
|
return 10.0
|
||||||
|
if radius_m <= 300:
|
||||||
|
return 15.0
|
||||||
|
return 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_circular_grid(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
radius_m: float,
|
||||||
|
step_m: float,
|
||||||
|
) -> list[tuple[int, int, float, float, float]]:
|
||||||
|
steps = int(radius_m / step_m)
|
||||||
|
cells: list[tuple[int, int, float, float, float]] = []
|
||||||
|
for i in range(-steps, steps + 1):
|
||||||
|
for j in range(-steps, steps + 1):
|
||||||
|
north = i * step_m
|
||||||
|
east = j * step_m
|
||||||
|
dist = math.hypot(north, east)
|
||||||
|
if dist > radius_m:
|
||||||
|
continue
|
||||||
|
la, lo = _offset_m(lat, lon, north, east)
|
||||||
|
cells.append((i, j, la, lo, dist))
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_grid_step(lat: float, lon: float, radius_m: float, step_m: float) -> float:
|
||||||
|
if step_m <= 0:
|
||||||
|
step_m = _auto_step_m(radius_m)
|
||||||
|
step_m = max(5.0, min(float(step_m), 100.0))
|
||||||
|
while len(_sample_circular_grid(lat, lon, radius_m, step_m)) > _MAX_GRID_POINTS:
|
||||||
|
step_m = math.ceil(step_m * 1.25)
|
||||||
|
if step_m >= radius_m:
|
||||||
|
break
|
||||||
|
return step_m
|
||||||
|
|
||||||
|
|
||||||
|
def build_elevation_grid(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
radius_m: float = 200.0,
|
||||||
|
step_m: float = 0.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Circular elevation grid for heatmap (delta relative to center)."""
|
||||||
|
probe = probe_elevation_api()
|
||||||
|
if not probe["ok"]:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"elevation API unreachable: {probe['error']}",
|
||||||
|
"elevation_url": ELEVATION_API_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
radius_m = max(100.0, min(float(radius_m), 500.0))
|
||||||
|
step_m = _resolve_grid_step(lat, lon, radius_m, step_m)
|
||||||
|
|
||||||
|
center_elev = fetch_elevation_m(lat, lon)
|
||||||
|
if center_elev is None:
|
||||||
|
return {"ok": False, "error": "no elevation at center"}
|
||||||
|
|
||||||
|
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
|
||||||
|
if not grid_cells:
|
||||||
|
return {"ok": False, "error": "empty search grid"}
|
||||||
|
|
||||||
|
lats = [c[2] for c in grid_cells]
|
||||||
|
lons = [c[3] for c in grid_cells]
|
||||||
|
elevations = fetch_elevations_batch(lats, lons)
|
||||||
|
|
||||||
|
points: list[dict[str, Any]] = []
|
||||||
|
deltas: list[float] = []
|
||||||
|
for (i, j, la, lo, dist), elev in zip(grid_cells, elevations):
|
||||||
|
if elev is None:
|
||||||
|
continue
|
||||||
|
delta = float(elev) - center_elev
|
||||||
|
deltas.append(delta)
|
||||||
|
points.append(
|
||||||
|
{
|
||||||
|
"i": i,
|
||||||
|
"j": j,
|
||||||
|
"lat": round(la, 6),
|
||||||
|
"lon": round(lo, 6),
|
||||||
|
"dist_m": round(dist, 1),
|
||||||
|
"elevation_m": float(elev),
|
||||||
|
"delta_m": round(delta, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
return {"ok": False, "error": "no elevation values in grid"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"center": {
|
||||||
|
"lat": round(lat, 6),
|
||||||
|
"lon": round(lon, 6),
|
||||||
|
"elevation_m": center_elev,
|
||||||
|
},
|
||||||
|
"radius_m": radius_m,
|
||||||
|
"step_m": step_m,
|
||||||
|
"points": points,
|
||||||
|
"min_delta_m": round(min(deltas), 1),
|
||||||
|
"max_delta_m": round(max(deltas), 1),
|
||||||
|
"api_source": "elevation",
|
||||||
|
"elevation_url": ELEVATION_API_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def find_nearest_hill(
|
def find_nearest_hill(
|
||||||
lat: float,
|
lat: float,
|
||||||
lon: float,
|
lon: float,
|
||||||
@@ -339,18 +450,7 @@ def find_nearest_hill(
|
|||||||
if center_elev is None:
|
if center_elev is None:
|
||||||
return {"ok": False, "error": "no elevation at center"}
|
return {"ok": False, "error": "no elevation at center"}
|
||||||
|
|
||||||
steps = int(radius_m / step_m)
|
grid_cells = _sample_circular_grid(lat, lon, radius_m, step_m)
|
||||||
grid_cells: list[tuple[int, int, float, float, float]] = []
|
|
||||||
for i in range(-steps, steps + 1):
|
|
||||||
for j in range(-steps, steps + 1):
|
|
||||||
north = i * step_m
|
|
||||||
east = j * step_m
|
|
||||||
dist = math.hypot(north, east)
|
|
||||||
if dist > radius_m:
|
|
||||||
continue
|
|
||||||
la, lo = _offset_m(lat, lon, north, east)
|
|
||||||
grid_cells.append((i, j, la, lo, dist))
|
|
||||||
|
|
||||||
if not grid_cells:
|
if not grid_cells:
|
||||||
return {"ok": False, "error": "empty search grid"}
|
return {"ok": False, "error": "empty search grid"}
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,18 @@ def elevation_nearest_hill(
|
|||||||
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
|
return find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/elevation/grid")
|
||||||
|
def elevation_grid(
|
||||||
|
lat: float = Query(..., ge=-90.0, le=90.0),
|
||||||
|
lon: float = Query(..., ge=-180.0, le=180.0),
|
||||||
|
radius_m: float = Query(200.0, ge=100.0, le=500.0),
|
||||||
|
step_m: float = Query(0.0, ge=0.0, le=100.0),
|
||||||
|
):
|
||||||
|
from core.elevation import build_elevation_grid
|
||||||
|
|
||||||
|
return build_elevation_grid(lat, lon, radius_m, step_m)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
from core.elevation import elevation_status
|
from core.elevation import elevation_status
|
||||||
|
|||||||
@@ -278,6 +278,19 @@ def elevation_nearest_hill():
|
|||||||
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
|
return jsonify(find_nearest_hill(lat, lon, radius_m, step_m, min_prominence_m))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/elevation/grid")
|
||||||
|
def elevation_grid():
|
||||||
|
from core.elevation import build_elevation_grid
|
||||||
|
|
||||||
|
lat = request.args.get("lat", type=float)
|
||||||
|
lon = request.args.get("lon", type=float)
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({"ok": False, "error": "lat and lon required"}), 400
|
||||||
|
radius_m = request.args.get("radius_m", 200, type=float)
|
||||||
|
step_m = request.args.get("step_m", 0, type=float)
|
||||||
|
return jsonify(build_elevation_grid(lat, lon, radius_m, step_m))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
def health():
|
||||||
from core.elevation import elevation_status
|
from core.elevation import elevation_status
|
||||||
|
|||||||
Binary file not shown.
@@ -93,3 +93,36 @@ def test_find_nearest_hill_picks_nearest_peak(monkeypatch):
|
|||||||
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
|
result = elev.find_nearest_hill(55.75, 37.62, radius_m=2000, step_m=300, min_prominence_m=8)
|
||||||
assert result["ok"] is True
|
assert result["ok"] is True
|
||||||
assert result["hill"]["elevation_m"] >= 120.0
|
assert result["hill"]["elevation_m"] >= 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_elevation_grid_delta(monkeypatch):
|
||||||
|
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||||
|
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||||
|
|
||||||
|
def fake_batch(lats, lons):
|
||||||
|
return [100.0 + (la - 55.75) * 1000.0 for la, lo in zip(lats, lons)]
|
||||||
|
|
||||||
|
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 100.0)
|
||||||
|
monkeypatch.setattr(elev, "fetch_elevations_batch", fake_batch)
|
||||||
|
|
||||||
|
result = elev.build_elevation_grid(55.75, 37.62, radius_m=100, step_m=10)
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["step_m"] == 10
|
||||||
|
assert len(result["points"]) > 0
|
||||||
|
assert result["min_delta_m"] <= 0 <= result["max_delta_m"]
|
||||||
|
assert all("delta_m" in p for p in result["points"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_elevation_grid_limits_points(monkeypatch):
|
||||||
|
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||||
|
monkeypatch.setattr(elev, "probe_elevation_api", lambda force=False: {"ok": True, "error": None})
|
||||||
|
monkeypatch.setattr(elev, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
elev,
|
||||||
|
"fetch_elevations_batch",
|
||||||
|
lambda lats, lons: [50.0] * len(lats),
|
||||||
|
)
|
||||||
|
|
||||||
|
step = elev._resolve_grid_step(55.75, 37.62, 500.0, 5.0)
|
||||||
|
cells = elev._sample_circular_grid(55.75, 37.62, 500.0, step)
|
||||||
|
assert len(cells) <= elev._MAX_GRID_POINTS
|
||||||
|
|||||||
Reference in New Issue
Block a user