From c2f26c8ec3f7df65911bfc44a082c7671b1c304a Mon Sep 17 00:00:00 2001 From: grigo Date: Thu, 11 Jun 2026 09:32:33 +0300 Subject: [PATCH] added subprox --- .../loratester/api/ElevationGridResult.java | 30 +++ .../loratester/api/ServerApi.java | 21 ++ .../loratester/ui/ElevationColorRamp.java | 39 ++++ .../loratester/ui/ElevationHeatmapBitmap.java | 100 ++++++++ .../loratester/ui/ElevationHeatmapLayer.java | 85 +++++++ .../loratester/ui/MapFragment.java | 216 ++++++++++++++++++ app/src/main/res/layout/fragment_map.xml | 25 ++ app/src/main/res/values/strings.xml | 7 + server/README.md | 1 + .../__pycache__/elevation.cpython-313.pyc | Bin 17198 -> 20490 bytes server/core/elevation.py | 124 +++++++++- server/fastapi_app.py | 12 + server/flask_app.py | 13 ++ ...est_elevation.cpython-313-pytest-9.0.3.pyc | Bin 8768 -> 15132 bytes server/tests/test_elevation.py | 33 +++ 15 files changed, 694 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/grigowashere/loratester/api/ElevationGridResult.java create mode 100644 app/src/main/java/com/grigowashere/loratester/ui/ElevationColorRamp.java create mode 100644 app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapBitmap.java create mode 100644 app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLayer.java diff --git a/app/src/main/java/com/grigowashere/loratester/api/ElevationGridResult.java b/app/src/main/java/com/grigowashere/loratester/api/ElevationGridResult.java new file mode 100644 index 0000000..440fe64 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/api/ElevationGridResult.java @@ -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 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; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java index f1f9027..5b96da3 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -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") private Map postJsonMap(String path, Map body, boolean android) throws IOException { diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ElevationColorRamp.java b/app/src/main/java/com/grigowashere/loratester/ui/ElevationColorRamp.java new file mode 100644 index 0000000..31532f2 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ElevationColorRamp.java @@ -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; + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapBitmap.java b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapBitmap.java new file mode 100644 index 0000000..3dd1453 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapBitmap.java @@ -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 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); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLayer.java b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLayer.java new file mode 100644 index 0000000..d414df6 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLayer.java @@ -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); + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java index 23b07ab..54cf809 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -27,6 +27,7 @@ import com.grigowashere.loratester.PeerStatsCache; import com.grigowashere.loratester.R; import com.grigowashere.loratester.TelemetryUploader; import com.grigowashere.loratester.api.DeviceInfo; +import com.grigowashere.loratester.api.ElevationGridResult; import com.grigowashere.loratester.api.NearestHillResult; import com.grigowashere.loratester.api.ServerApi; 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 HILL_SEARCH_RADIUS_M = 5000; 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 DateFormat timeFormat = @@ -106,11 +110,14 @@ public class MapFragment extends Fragment { private Button btnTrack; private Button btnPairedTrack; private Button btnFindHill; + private Button btnHeatmap; private Button btnCenterMe; private Button btnCenterTx; private Button btnCenterRx; private Button btnCenterBoth; private Spinner trackSpinner; + private Spinner mapHeatmapRadius; + private TextView mapHeatmapStatus; private List savedTracks = new ArrayList<>(); private boolean mapResumed; private boolean mapInitialized; @@ -130,6 +137,13 @@ public class MapFragment extends Fragment { private Bitmap bitmapHill; private Marker hillMarker; 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 touchDownY; @@ -166,6 +180,9 @@ public class MapFragment extends Fragment { btnTrack = view.findViewById(R.id.btnTrack); btnPairedTrack = view.findViewById(R.id.btnPairedTrack); 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); btnCenterTx = view.findViewById(R.id.btnCenterTx); btnCenterRx = view.findViewById(R.id.btnCenterRx); @@ -188,6 +205,7 @@ public class MapFragment extends Fragment { if (btnFindHill != null) { btnFindHill.setOnClickListener(v -> findNearestHill()); } + setupHeatmapUi(); updateConnectionIcons(lastDevices, serverConnected); @@ -335,6 +353,8 @@ public class MapFragment extends Fragment { clearTrackLayers(); clearLiveTrackLayers(); clearHillLayers(); + removeHeatmapLayer(); + cancelHeatmapReload(); deviceMarkers.clear(); if (downloadLayer != null) { downloadLayer.onDestroy(); @@ -352,7 +372,12 @@ public class MapFragment extends Fragment { iconServer = null; iconLora = null; btnFindHill = null; + btnHeatmap = null; + mapHeatmapRadius = null; + mapHeatmapStatus = null; mapHillStatus = null; + heatmapLayer = null; + heatmapActive = false; mapStatus = null; mapDistance = null; trackStatus = null; @@ -871,6 +896,196 @@ public class MapFragment extends Fragment { hillPathLine = null; } + private void setupHeatmapUi() { + if (mapHeatmapRadius == null) { + return; + } + List 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 devices) { if (!isMapReady()) { return; @@ -927,6 +1142,7 @@ public class MapFragment extends Fragment { } updateGpsDistance(); updateConnectionIcons(lastDevices, serverConnected); + checkHeatmapGpsFollow(); if (!boundsPoints.isEmpty() && !userMovedMap && !MapSessionState.initialFitDone) { fitBoundsOnce(boundsPoints, onMap == 1, false); diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 34359d7..fd4fcbc 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -150,6 +150,31 @@ android:textSize="9sp" android:visibility="gone" /> + + +