added subprox

This commit is contained in:
2026-06-11 09:32:33 +03:00
parent 94e2b772e8
commit c2f26c8ec3
15 changed files with 694 additions and 12 deletions
@@ -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")
private Map<String, Object> postJsonMap(String path, Map<String, Object> body, boolean android)
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.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<TrackInfo> 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<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) {
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);
+25
View File
@@ -150,6 +150,31 @@
android:textSize="9sp"
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
android:id="@+id/mapLegend"
android:layout_width="match_parent"
+7
View File
@@ -91,5 +91,12 @@
<string name="map_find_hill_none">В радиусе 5 км возвышенностей не найдено</string>
<string name="map_find_hill_no_gps">Нужен GPS для поиска</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>
</resources>