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 5b96da3..92a88a2 100644 --- a/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java +++ b/app/src/main/java/com/grigowashere/loratester/api/ServerApi.java @@ -229,7 +229,7 @@ public class ServerApi { } } - public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM) + public ElevationGridResult getElevationGrid(double lat, double lon, int radiusM, int stepM) throws IOException { String path = "/api/elevation/grid?lat=" + lat @@ -237,7 +237,8 @@ public class ServerApi { + lon + "&radius_m=" + radiusM - + "&step_m=0"; + + "&step_m=" + + stepM; Request request = new Request.Builder() .url(baseUrl + path) .get() diff --git a/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLegendView.java b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLegendView.java new file mode 100644 index 0000000..c442fd2 --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/ui/ElevationHeatmapLegendView.java @@ -0,0 +1,125 @@ +package com.grigowashere.loratester.ui; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.grigowashere.loratester.R; + +/** Color scale for elevation heatmap (relative to GPS center). */ +public class ElevationHeatmapLegendView extends View { + + private static final double DELTA_MIN = -10.0; + private static final double DELTA_MAX = 10.0; + + private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF barRect = new RectF(); + private final RectF bgRect = new RectF(); + + public ElevationHeatmapLegendView(Context context) { + super(context); + init(); + } + + public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ElevationHeatmapLegendView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + bgPaint.setColor(0xCC0F3460); + labelPaint.setColor(0xFFEEEEEE); + labelPaint.setTextSize(sp(9)); + titlePaint.setColor(0xFFFFFFFF); + titlePaint.setTextSize(sp(9)); + titlePaint.setFakeBoldText(true); + setLayerType(LAYER_TYPE_SOFTWARE, null); + } + + private float sp(float value) { + return value * getResources().getDisplayMetrics().scaledDensity; + } + + private float dp(float value) { + return value * getResources().getDisplayMetrics().density; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int w = (int) dp(118); + int h = (int) dp(118); + setMeasuredDimension( + resolveSize(w, widthMeasureSpec), + resolveSize(h, heightMeasureSpec)); + } + + @Override + protected void onDraw(Canvas canvas) { + float pad = dp(6); + bgRect.set(pad, pad, getWidth() - pad, getHeight() - pad); + canvas.drawRoundRect(bgRect, dp(4), dp(4), bgPaint); + + float titleY = bgRect.top + dp(12); + canvas.drawText(getContext().getString(R.string.map_heatmap_legend_title), + bgRect.left + dp(6), titleY, titlePaint); + + float barLeft = bgRect.left + dp(8); + float barTop = titleY + dp(6); + float barBottom = bgRect.bottom - dp(8); + float barRight = barLeft + dp(14); + barRect.set(barLeft, barTop, barRight, barBottom); + + int[] colors = sampleRampColors(24); + float[] positions = new float[colors.length]; + for (int i = 0; i < colors.length; i++) { + positions[i] = i / (float) (colors.length - 1); + } + barPaint.setShader(new LinearGradient( + barRect.left, barRect.top, barRect.left, barRect.bottom, + colors, positions, Shader.TileMode.CLAMP)); + canvas.drawRoundRect(barRect, dp(2), dp(2), barPaint); + barPaint.setShader(null); + + float textX = barRect.right + dp(6); + drawLegendRow(canvas, textX, barRect.top + dp(4), + getContext().getString(R.string.map_heatmap_legend_high), + "+8 m"); + drawLegendRow(canvas, textX, (barRect.top + barRect.bottom) / 2f, + getContext().getString(R.string.map_heatmap_legend_level), + "0 m"); + drawLegendRow(canvas, textX, barRect.bottom - dp(4), + getContext().getString(R.string.map_heatmap_legend_low), + "-8 m"); + } + + private void drawLegendRow(Canvas canvas, float x, float centerY, String label, String meters) { + float lineH = labelPaint.getTextSize(); + canvas.drawText(label, x, centerY - dp(1), labelPaint); + canvas.drawText(meters, x, centerY + lineH - dp(2), labelPaint); + } + + private static int[] sampleRampColors(int steps) { + int[] colors = new int[steps]; + for (int i = 0; i < steps; i++) { + double t = i / (double) (steps - 1); + double delta = DELTA_MAX + t * (DELTA_MIN - DELTA_MAX); + colors[i] = ElevationColorRamp.deltaToArgb(delta) | 0xFF000000; + } + return colors; + } +} 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 54cf809..dfa3ad2 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/MapFragment.java @@ -69,6 +69,8 @@ public class MapFragment extends Fragment { private static final class MapSessionState { static boolean initialFitDone; + static LatLong savedCenter; + static byte savedZoom = 12; } private static final int TILE_SIZE_PX = 256; @@ -118,6 +120,7 @@ public class MapFragment extends Fragment { private Spinner trackSpinner; private Spinner mapHeatmapRadius; private TextView mapHeatmapStatus; + private View mapHeatmapLegend; private List savedTracks = new ArrayList<>(); private boolean mapResumed; private boolean mapInitialized; @@ -146,6 +149,7 @@ public class MapFragment extends Fragment { private Runnable heatmapReloadRunnable; private float touchDownX; private float touchDownY; + private Runnable pendingFitRunnable; @Override public void onAttach(@NonNull Context context) { @@ -183,6 +187,7 @@ public class MapFragment extends Fragment { btnHeatmap = view.findViewById(R.id.btnHeatmap); mapHeatmapRadius = view.findViewById(R.id.mapHeatmapRadius); mapHeatmapStatus = view.findViewById(R.id.mapHeatmapStatus); + mapHeatmapLegend = view.findViewById(R.id.mapHeatmapLegend); btnCenterMe = view.findViewById(R.id.btnCenterMe); btnCenterTx = view.findViewById(R.id.btnCenterTx); btnCenterRx = view.findViewById(R.id.btnCenterRx); @@ -215,9 +220,6 @@ public class MapFragment extends Fragment { if (isAdded() && mapStatus != null) { requireActivity().runOnUiThread(this::updateNetworkStatusLine); } - if (online && downloadLayer != null && mapResumed) { - downloadLayer.onResume(); - } }; if (networkMonitor != null) { networkMonitor.addListener(networkListener); @@ -268,6 +270,9 @@ public class MapFragment extends Fragment { } } else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { v.getParent().requestDisallowInterceptTouchEvent(false); + if (userMovedMap) { + saveCameraState(); + } } return false; }); @@ -296,8 +301,12 @@ public class MapFragment extends Fragment { downloadLayer.start(); MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; - position.setCenter(new LatLong(55.75, 37.62)); - position.setZoomLevel((byte) 10); + if (MapSessionState.savedCenter != null) { + position.setCenter(MapSessionState.savedCenter); + position.setZoomLevel(MapSessionState.savedZoom); + } else { + position.setZoomLevel((byte) 12); + } bitmapTx = MapsforgeBitmaps.dot(ARGB_TX, 20); bitmapRx = MapsforgeBitmaps.dot(ARGB_RX, 20); @@ -326,6 +335,7 @@ public class MapFragment extends Fragment { @Override public void onPause() { mapResumed = false; + saveCameraState(); if (pollHelper != null) { pollHelper.stop(); } @@ -339,6 +349,7 @@ public class MapFragment extends Fragment { public void onDestroyView() { mapResumed = false; mapInitialized = false; + cancelPendingFit(); if (pollHelper != null) { pollHelper.stop(); } @@ -375,6 +386,7 @@ public class MapFragment extends Fragment { btnHeatmap = null; mapHeatmapRadius = null; mapHeatmapStatus = null; + mapHeatmapLegend = null; mapHillStatus = null; heatmapLayer = null; heatmapActive = false; @@ -938,11 +950,18 @@ public class MapFragment extends Fragment { } } + private void updateHeatmapLegendVisibility() { + if (mapHeatmapLegend != null) { + mapHeatmapLegend.setVisibility(heatmapActive ? View.VISIBLE : View.GONE); + } + } + private void toggleHeatmap() { heatmapActive = !heatmapActive; if (btnHeatmap != null) { btnHeatmap.setActivated(heatmapActive); } + updateHeatmapLegendVisibility(); if (!heatmapActive) { cancelHeatmapReload(); removeHeatmapLayer(); @@ -956,6 +975,7 @@ public class MapFragment extends Fragment { LatLong self = resolveSelfPosition(); if (self == null) { heatmapActive = false; + updateHeatmapLegendVisibility(); if (btnHeatmap != null) { btnHeatmap.setActivated(false); } @@ -965,10 +985,22 @@ public class MapFragment extends Fragment { loadHeatmap(self.latitude, self.longitude, heatmapRadiusM); } + /** Finer step for small radius, coarser for large (meters). */ + private static int heatmapStepForRadius(int radiusM) { + if (radiusM <= 100) { + return 8; + } + if (radiusM <= 200) { + return 12; + } + return 18; + } + private void loadHeatmap(double lat, double lon, int radiusM) { if (uploader == null || !isAdded()) { return; } + final int stepM = heatmapStepForRadius(radiusM); if (mapHeatmapStatus != null) { mapHeatmapStatus.setVisibility(View.VISIBLE); mapHeatmapStatus.setText(R.string.map_heatmap_loading); @@ -978,7 +1010,7 @@ public class MapFragment extends Fragment { } executor.execute(() -> { try { - ElevationGridResult grid = uploader.getServerApi().getElevationGrid(lat, lon, radiusM); + ElevationGridResult grid = uploader.getServerApi().getElevationGrid(lat, lon, radiusM, stepM); if (!isAdded()) { return; } @@ -1007,6 +1039,7 @@ public class MapFragment extends Fragment { R.string.map_heatmap_result, grid.min_delta_m, grid.max_delta_m, + grid.step_m > 0 ? grid.step_m : stepM, grid.points.size() )); } @@ -1217,52 +1250,88 @@ public class MapFragment extends Fragment { MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; position.setCenter(point); position.setZoomLevel(zoom); + saveCameraState(); mapView.invalidate(); } + private void saveCameraState() { + if (mapView == null || !mapInitialized) { + return; + } + MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; + LatLong center = position.getCenter(); + if (center != null) { + MapSessionState.savedCenter = center; + MapSessionState.savedZoom = position.getZoomLevel(); + } + } + + private void cancelPendingFit() { + if (pendingFitRunnable != null && mapView != null) { + mapView.removeCallbacks(pendingFitRunnable); + } + pendingFitRunnable = null; + } + + private boolean isMapLayoutReady() { + return mapView != null && mapView.getWidth() > 0 && mapView.getHeight() > 0; + } + /** Adjust camera only on first device load or when user picks a saved track. */ private void fitBoundsOnce(List points, boolean singlePoint, boolean force) { if (!isMapReady() || points.isEmpty() || (!force && userMovedMap)) { return; } - MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; - Runnable apply = () -> { - if (!isMapReady()) { + cancelPendingFit(); + final List fitPoints = new ArrayList<>(points); + pendingFitRunnable = () -> { + pendingFitRunnable = null; + if (!isMapReady() || fitPoints.isEmpty()) { return; } - if (singlePoint) { - position.setCenter(points.get(0)); - position.setZoomLevel((byte) 14); - return; - } - double minLat = Double.MAX_VALUE; - double maxLat = -Double.MAX_VALUE; - double minLon = Double.MAX_VALUE; - double maxLon = -Double.MAX_VALUE; - for (LatLong p : points) { - minLat = Math.min(minLat, p.latitude); - maxLat = Math.max(maxLat, p.latitude); - minLon = Math.min(minLon, p.longitude); - maxLon = Math.max(maxLon, p.longitude); - } - double padLat = Math.max((maxLat - minLat) * 0.2, 0.003); - double padLon = Math.max((maxLon - minLon) * 0.2, 0.003); - BoundingBox box = new BoundingBox( - minLat - padLat, minLon - padLon, maxLat + padLat, maxLon + padLon); - position.setCenter(box.getCenterPoint()); - int w = Math.max(mapView.getWidth(), 1); - int h = Math.max(mapView.getHeight(), 1); - double latSpan = Math.max(box.maxLatitude - box.minLatitude, 0.001); - double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001); - double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2); - double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2); - byte zoom = (byte) Math.max(12, Math.min(16, Math.floor(Math.min(latZoom, lonZoom)))); - position.setZoomLevel(zoom); + applyFitBounds(fitPoints, singlePoint); }; - if (mapView.getWidth() > 0 && mapView.getHeight() > 0) { - apply.run(); + if (isMapLayoutReady()) { + pendingFitRunnable.run(); } else { - mapView.post(apply); + mapView.post(pendingFitRunnable); } } + + private void applyFitBounds(List points, boolean singlePoint) { + if (!isMapReady() || !isMapLayoutReady()) { + return; + } + MapViewPosition position = (MapViewPosition) mapView.getModel().mapViewPosition; + if (singlePoint) { + position.setCenter(points.get(0)); + position.setZoomLevel((byte) 14); + saveCameraState(); + return; + } + double minLat = Double.MAX_VALUE; + double maxLat = -Double.MAX_VALUE; + double minLon = Double.MAX_VALUE; + double maxLon = -Double.MAX_VALUE; + for (LatLong p : points) { + minLat = Math.min(minLat, p.latitude); + maxLat = Math.max(maxLat, p.latitude); + minLon = Math.min(minLon, p.longitude); + maxLon = Math.max(maxLon, p.longitude); + } + double padLat = Math.max((maxLat - minLat) * 0.2, 0.003); + double padLon = Math.max((maxLon - minLon) * 0.2, 0.003); + BoundingBox box = new BoundingBox( + minLat - padLat, minLon - padLon, maxLat + padLat, maxLon + padLon); + position.setCenter(box.getCenterPoint()); + int w = mapView.getWidth(); + int h = mapView.getHeight(); + double latSpan = Math.max(box.maxLatitude - box.minLatitude, 0.001); + double lonSpan = Math.max(box.maxLongitude - box.minLongitude, 0.001); + double latZoom = Math.log(h / (double) TILE_SIZE_PX / latSpan) / Math.log(2); + double lonZoom = Math.log(w / (double) TILE_SIZE_PX / lonSpan) / Math.log(2); + byte zoom = (byte) Math.max(12, Math.min(16, Math.floor(Math.min(latZoom, lonZoom)))); + position.setZoomLevel(zoom); + saveCameraState(); + } } diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index fd4fcbc..6bac69e 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -8,6 +8,18 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + Радиус рельефа %1$d m Загрузка рельефа… - Δ %1$.0f…%2$.0f m · %3$d точек + Δ %1$.0f…%2$.0f m · шаг %3$.0f m · %4$d точек Рельеф: %1$s Нужен GPS для рельефа + Относ. высота + возвышенность + уровень + низина Вы