1 Commits
v5 ... v6

Author SHA1 Message Date
Grigo c8ae5fc341 Added radar 2026-05-21 12:38:18 +03:00
13 changed files with 630 additions and 50 deletions
@@ -5,6 +5,7 @@ import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.os.Bundle; import android.os.Bundle;
import android.os.Looper; import android.os.Looper;
import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import android.util.Printer; import android.util.Printer;
import android.view.Menu; import android.view.Menu;
@@ -43,6 +44,7 @@ import com.grigowashere.aismap.view.BaseDockWidget;
import com.grigowashere.aismap.ui.MenuBinder; import com.grigowashere.aismap.ui.MenuBinder;
import com.grigowashere.aismap.ui.BottomSheetsBinder; import com.grigowashere.aismap.ui.BottomSheetsBinder;
import com.grigowashere.aismap.ui.PermissionsBinder; import com.grigowashere.aismap.ui.PermissionsBinder;
import com.grigowashere.aismap.utils.AismapLocalHttpsProbe;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import com.grigowashere.aismap.utils.LogSender; import com.grigowashere.aismap.utils.LogSender;
import com.grigowashere.aismap.utils.MIDToCountry; import com.grigowashere.aismap.utils.MIDToCountry;
@@ -54,6 +56,9 @@ import org.maplibre.android.maps.MapView;
import org.maplibre.android.MapLibre; import org.maplibre.android.MapLibre;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
import com.grigowashere.aismap.controllers.ControllersFactory; import com.grigowashere.aismap.controllers.ControllersFactory;
import com.grigowashere.aismap.controllers.DefaultControllersFactory; import com.grigowashere.aismap.controllers.DefaultControllersFactory;
@@ -119,6 +124,12 @@ public class MainActivity extends AppCompatActivity {
private TextView tvBleRssi; private TextView tvBleRssi;
private TextView tvBleBatt; private TextView tvBleBatt;
private TextView tvFps; private TextView tvFps;
/** GET {@link AismapLocalHttpsProbe#PROBE_URL} on a worker; line shown under BLE RSSI. */
private ExecutorService localHttpsProbeExecutor;
private final AtomicBoolean localHttpsProbeInFlight = new AtomicBoolean(false);
private volatile String localHttpsProbeStatusLine = "HTTPS aismap.local: —";
private long nextLocalHttpsProbeElapsedMs = 0L;
private static final long LOCAL_HTTPS_PROBE_INTERVAL_MS = 12_000L;
private int frameCount = 0; private int frameCount = 0;
private long lastFpsTs = 0L; private long lastFpsTs = 0L;
private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() { private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() {
@@ -487,6 +498,14 @@ public class MainActivity extends AppCompatActivity {
} }
private void setupMessageAgesUpdater() { private void setupMessageAgesUpdater() {
if (localHttpsProbeExecutor == null) {
localHttpsProbeExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "aismap-local-https-probe");
t.setDaemon(true);
return t;
});
nextLocalHttpsProbeElapsedMs = 0L;
}
messageAgeHandler = new android.os.Handler(android.os.Looper.getMainLooper()); messageAgeHandler = new android.os.Handler(android.os.Looper.getMainLooper());
messageAgeRunnable = new Runnable() { messageAgeRunnable = new Runnable() {
@Override @Override
@@ -506,13 +525,17 @@ public class MainActivity extends AppCompatActivity {
} }
if (tvBleRssi != null) { if (tvBleRssi != null) {
Integer rssi = appCoordinator.getLastBleRssi(); Integer rssi = appCoordinator.getLastBleRssi();
String bleLine;
int bleColor;
if (rssi != null) { if (rssi != null) {
tvBleRssi.setText("BLE RSSI: " + rssi); bleLine = "BLE RSSI: " + rssi;
tvBleRssi.setTextColor(getRssiColor(rssi)); bleColor = getRssiColor(rssi);
} else { } else {
tvBleRssi.setText("BLE RSSI: --"); bleLine = "BLE RSSI: --";
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336")); bleColor = android.graphics.Color.parseColor("#F44336");
} }
tvBleRssi.setText(bleLine + "\n" + localHttpsProbeStatusLine);
tvBleRssi.setTextColor(bleColor);
} }
if (tvBleBatt != null) { if (tvBleBatt != null) {
Integer batt = appCoordinator.getLastBleBattery(); Integer batt = appCoordinator.getLastBleBattery();
@@ -521,6 +544,11 @@ public class MainActivity extends AppCompatActivity {
updateBleLinkLostBanner(); updateBleLinkLostBanner();
updateDangerWidget(); updateDangerWidget();
} }
if (tvBleRssi != null && appCoordinator == null) {
tvBleRssi.setText("BLE RSSI: --\n" + localHttpsProbeStatusLine);
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
}
triggerLocalHttpsProbeIfDue();
} catch (Exception ignored) {} } catch (Exception ignored) {}
messageAgeHandler.postDelayed(this, 1000); messageAgeHandler.postDelayed(this, 1000);
} }
@@ -529,6 +557,35 @@ public class MainActivity extends AppCompatActivity {
messageAgeHandler.postDelayed(messageAgeRunnable, 1000); messageAgeHandler.postDelayed(messageAgeRunnable, 1000);
} }
/**
* Throttled HTTPS GET to {@code aismap.local} on a worker; updates {@link #localHttpsProbeStatusLine}
* on the UI thread (shown with BLE RSSI).
*/
private void triggerLocalHttpsProbeIfDue() {
if (localHttpsProbeInFlight.get()) return;
long now = SystemClock.elapsedRealtime();
if (now < nextLocalHttpsProbeElapsedMs) return;
ExecutorService ex = localHttpsProbeExecutor;
if (ex == null || ex.isShutdown()) return;
if (!localHttpsProbeInFlight.compareAndSet(false, true)) return;
nextLocalHttpsProbeElapsedMs = now + LOCAL_HTTPS_PROBE_INTERVAL_MS;
ex.execute(() -> {
try {
final String line = AismapLocalHttpsProbe.probeOnce();
runOnUiThread(() -> {
localHttpsProbeStatusLine = line;
localHttpsProbeInFlight.set(false);
});
} catch (Throwable t) {
Log.w(TAG, "local HTTPS probe worker", t);
runOnUiThread(() -> {
localHttpsProbeStatusLine = "HTTPS aismap.local: " + t.getClass().getSimpleName();
localHttpsProbeInFlight.set(false);
});
}
});
}
/** /**
* Баннер «нет связи с устройством» — только при потере BLE GATT-сессии с AIS Hub. * Баннер «нет связи с устройством» — только при потере BLE GATT-сессии с AIS Hub.
* Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя, * Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя,
@@ -571,19 +628,20 @@ public class MainActivity extends AppCompatActivity {
} }
/** /**
* Обновляет таблицу «Опасные цели» каждую секунду из AppCoordinator. * Обновляет таблицу «Ближайшие цели» каждую секунду из AppCoordinator.
* Если опасных целей нет (или функция отключена в настройках) — виджет * Цели берутся из жёлтой зоны предупреждения, а не из красного круга
* полностью скрывается, чтобы не занимать место на карте. * опасности. Если целей нет (или кольца отключены) — виджет полностью
* скрывается, чтобы не занимать место на карте.
*/ */
private void updateDangerWidget() { private void updateDangerWidget() {
if (dangerWidget == null || appCoordinator == null || settingsManager == null) return; if (dangerWidget == null || appCoordinator == null || settingsManager == null) return;
boolean enabled = settingsManager.isRangeRingsEnabled(); boolean enabled = settingsManager.isRangeRingsEnabled();
double dangerR = enabled ? settingsManager.getDangerRadiusMeters() : 0.0; double nearestR = enabled ? settingsManager.getWarningRadiusMeters() : 0.0;
java.util.List<com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry> uiEntries = java.util.List<com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry> uiEntries =
new java.util.ArrayList<>(); new java.util.ArrayList<>();
if (enabled && dangerR > 0.0) { if (enabled && nearestR > 0.0) {
java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> entries = java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> entries =
appCoordinator.getDangerTargets(dangerR, 5); appCoordinator.getDangerTargets(nearestR, 5);
if (entries != null) { if (entries != null) {
for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) { for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) {
if (e == null || e.vessel == null) continue; if (e == null || e.vessel == null) continue;
@@ -592,7 +650,8 @@ public class MainActivity extends AppCompatActivity {
label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : ""; label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "";
} }
uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry( uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry(
label, e.bearingDegrees, e.distanceMeters)); label, e.bearingDegrees, e.distanceMeters,
e.cpaValid, e.cpaMeters, e.tcpaMinutes));
} }
} }
} }
@@ -1868,6 +1927,12 @@ public class MainActivity extends AppCompatActivity {
messageAgeHandler.removeCallbacks(messageAgeRunnable); messageAgeHandler.removeCallbacks(messageAgeRunnable);
Log.i(TAG, "messageAgeHandler остановлен"); Log.i(TAG, "messageAgeHandler остановлен");
} }
if (localHttpsProbeExecutor != null) {
try {
localHttpsProbeExecutor.shutdownNow();
} catch (Throwable ignore) {}
localHttpsProbeExecutor = null;
}
// Останавливаем UI watchdog // Останавливаем UI watchdog
if (uiWatchdogHandler != null && uiWatchdogRunnable != null) { if (uiWatchdogHandler != null && uiWatchdogRunnable != null) {
@@ -1265,11 +1265,24 @@ public class AppCoordinator implements
public final double distanceMeters; public final double distanceMeters;
/** Пеленг от собственного судна на цель, °. */ /** Пеленг от собственного судна на цель, °. */
public final double bearingDegrees; public final double bearingDegrees;
/** CPA, м; {@link Double#NaN} если расчёт невозможен. */
public final double cpaMeters;
/** TCPA, мин; {@link Double#NaN} если расчёт невозможен. */
public final double tcpaMinutes;
public final boolean cpaValid;
public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) { public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) {
this(vessel, distanceMeters, bearingDegrees, false, Double.NaN, Double.NaN);
}
public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees,
boolean cpaValid, double cpaMeters, double tcpaMinutes) {
this.vessel = vessel; this.vessel = vessel;
this.distanceMeters = distanceMeters; this.distanceMeters = distanceMeters;
this.bearingDegrees = bearingDegrees; this.bearingDegrees = bearingDegrees;
this.cpaValid = cpaValid;
this.cpaMeters = cpaMeters;
this.tcpaMinutes = tcpaMinutes;
} }
} }
@@ -1288,6 +1301,9 @@ public class AppCoordinator implements
double oLat = ownVessel.getLatitude(); double oLat = ownVessel.getLatitude();
double oLon = ownVessel.getLongitude(); double oLon = ownVessel.getLongitude();
if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(oLat, oLon)) return result; if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(oLat, oLon)) return result;
double ownSog = ownVessel.getSpeed();
double ownCog = ownVessel.getCourse();
double ownHdg = ownVessel.getHeading();
synchronized (aisVessels) { synchronized (aisVessels) {
for (AISVessel vessel : aisVessels.values()) { for (AISVessel vessel : aisVessels.values()) {
@@ -1298,10 +1314,18 @@ public class AppCoordinator implements
double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon); double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon);
if (d <= maxRadiusMeters) { if (d <= maxRadiusMeters) {
double b = com.grigowashere.aismap.utils.GeoUtils.calculateBearing(oLat, oLon, lat, lon); double b = com.grigowashere.aismap.utils.GeoUtils.calculateBearing(oLat, oLon, lat, lon);
com.grigowashere.aismap.utils.RangeMath.CpaResult cpa =
com.grigowashere.aismap.utils.RangeMath.calculateCpa(
oLat, oLon, ownSog, ownCog, ownHdg,
lat, lon, vessel.getSpeed(), vessel.getCourse(), vessel.getHeading());
if (cpa.valid) {
result.add(new DangerEntry(vessel, d, b, true, cpa.cpaMeters, cpa.tcpaMinutes));
} else {
result.add(new DangerEntry(vessel, d, b)); result.add(new DangerEntry(vessel, d, b));
} }
} }
} }
}
java.util.Collections.sort(result, (a, b) -> Double.compare(a.distanceMeters, b.distanceMeters)); java.util.Collections.sort(result, (a, b) -> Double.compare(a.distanceMeters, b.distanceMeters));
if (limit > 0 && result.size() > limit) { if (limit > 0 && result.size() > limit) {
return new ArrayList<>(result.subList(0, limit)); return new ArrayList<>(result.subList(0, limit));
@@ -649,6 +649,11 @@ public class MapLibreMapImpl implements MapInterface {
} catch (Exception ignore) {} } catch (Exception ignore) {}
idToFeature.put(vessel.getMmsi(), feature); idToFeature.put(vessel.getMmsi(), feature);
if (PATH_FEATURES_ENABLED) {
String mmsi = vessel.getMmsi();
updateAISPathSource(mmsi);
updateAISVesselPredictionSource(mmsi, vessel);
}
updated++; updated++;
} }
@@ -2456,6 +2461,9 @@ public class MapLibreMapImpl implements MapInterface {
// Обновляем путь этого AIS судна // Обновляем путь этого AIS судна
updateAISVesselPath(mmsi, aisPathController); updateAISVesselPath(mmsi, aisPathController);
} }
if (PATH_FEATURES_ENABLED) {
updateAISVesselPredictionSource(mmsi, vessel);
}
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Ошибка обновления путей AIS судов: " + e.getMessage(), e); Log.e(TAG, "Ошибка обновления путей AIS судов: " + e.getMessage(), e);
@@ -1,7 +1,11 @@
package com.grigowashere.aismap.maps; package com.grigowashere.aismap.maps;
import android.os.Handler;
import android.os.Looper;
import android.util.Log; import android.util.Log;
import com.grigowashere.aismap.utils.NavigatorZoomMath;
import org.maplibre.android.camera.CameraPosition; import org.maplibre.android.camera.CameraPosition;
import org.maplibre.android.camera.CameraUpdateFactory; import org.maplibre.android.camera.CameraUpdateFactory;
import org.maplibre.android.geometry.LatLng; import org.maplibre.android.geometry.LatLng;
@@ -17,10 +21,24 @@ public class RadarMapHelper {
private static final String TAG = "RadarMapHelper"; private static final String TAG = "RadarMapHelper";
private static final String STYLE_URL = private static final String STYLE_URL =
"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
private static final long FRAME_MS = 16L;
private static final float POSITION_ALPHA = 0.20f;
private static final float ZOOM_ALPHA = 0.14f;
/** Как в {@link com.grigowashere.aismap.controllers.NavigatorCameraController}. */
private static final float BEARING_ALPHA = 0.09f;
private final MapView mapView; private final MapView mapView;
private final Handler handler = new Handler(Looper.getMainLooper());
private MapLibreMap map; private MapLibreMap map;
private boolean styleLoaded; private boolean styleLoaded;
private boolean followLoopRunning;
private double targetLat = Double.NaN;
private double targetLon = Double.NaN;
private float targetBearing;
private double targetRangeMeters = 1000.0;
private final Runnable followLoopRunnable = this::onFollowFrame;
public RadarMapHelper(MapView mapView) { public RadarMapHelper(MapView mapView) {
this.mapView = mapView; this.mapView = mapView;
@@ -58,6 +76,7 @@ public class RadarMapHelper {
} }
public void onPause() { public void onPause() {
stopFollowLoop();
mapView.onPause(); mapView.onPause();
} }
@@ -66,6 +85,7 @@ public class RadarMapHelper {
} }
public void onDestroy() { public void onDestroy() {
stopFollowLoop();
mapView.onDestroy(); mapView.onDestroy();
} }
@@ -79,20 +99,64 @@ public class RadarMapHelper {
/** /**
* Центрирует карту на собственном судне; bearing задаёт режим «курс вверх». * Центрирует карту на собственном судне; bearing задаёт режим «курс вверх».
* Поворот и смещение камеры сглаживаются так же, как в режиме навигатора.
* *
* @param rangeMeters радиус PPI для подбора зума * @param rangeMeters радиус PPI для подбора зума
*/ */
public void centerOnOwnShip(double lat, double lon, float bearingDeg, double rangeMeters) { public void centerOnOwnShip(double lat, double lon, float bearingDeg, double rangeMeters) {
if (map == null || !styleLoaded) return; if (map == null || !styleLoaded) return;
if (Double.isNaN(lat) || Double.isNaN(lon)) return; if (Double.isNaN(lat) || Double.isNaN(lon)) return;
double zoom = zoomForRangeMeters(rangeMeters); targetLat = lat;
targetLon = lon;
targetBearing = bearingDeg;
if (rangeMeters > 0.0) {
targetRangeMeters = rangeMeters;
}
startFollowLoop();
}
private void startFollowLoop() {
if (followLoopRunning) return;
followLoopRunning = true;
handler.removeCallbacks(followLoopRunnable);
handler.post(followLoopRunnable);
}
private void stopFollowLoop() {
followLoopRunning = false;
handler.removeCallbacks(followLoopRunnable);
}
private void onFollowFrame() {
if (!followLoopRunning || map == null || !styleLoaded || Double.isNaN(targetLat)) {
followLoopRunning = false;
return;
}
try {
CameraPosition current = map.getCameraPosition();
double curLat = current.target.getLatitude();
double curLon = current.target.getLongitude();
float curBearing = (float) current.bearing;
double targetZoom = zoomForRangeMeters(targetRangeMeters);
double newLat = NavigatorZoomMath.lerp(curLat, targetLat, POSITION_ALPHA);
double newLon = NavigatorZoomMath.lerp(curLon, targetLon, POSITION_ALPHA);
float newBearing = NavigatorZoomMath.lerpBearing(curBearing, targetBearing, BEARING_ALPHA);
double newZoom = NavigatorZoomMath.lerp(current.zoom, targetZoom, ZOOM_ALPHA);
CameraPosition position = new CameraPosition.Builder() CameraPosition position = new CameraPosition.Builder()
.target(new LatLng(lat, lon)) .target(new LatLng(newLat, newLon))
.zoom(zoom) .zoom(newZoom)
.bearing(bearingDeg) .bearing(newBearing)
.tilt(0.0) .tilt(0.0)
.build(); .build();
map.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400); map.moveCamera(CameraUpdateFactory.newCameraPosition(position));
} catch (Exception e) {
Log.w(TAG, "onFollowFrame: " + e.getMessage());
}
handler.postDelayed(followLoopRunnable, FRAME_MS);
} }
/** Подбирает зум так, чтобы весь радиус PPI помещался в круговой области. */ /** Подбирает зум так, чтобы весь радиус PPI помещался в круговой области. */
@@ -191,6 +191,7 @@ public class BottomSheetsManager {
TextView tvSignal = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_signal); TextView tvSignal = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_signal);
TextView tvDistance = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_distance); TextView tvDistance = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_distance);
TextView tvBearing = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_bearing); TextView tvBearing = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_bearing);
TextView tvCpa = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_cpa);
TextView tvLastUpdate = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_last_update); TextView tvLastUpdate = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_last_update);
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago); TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
@@ -233,17 +234,52 @@ public class BottomSheetsManager {
} }
if (tvLastUpdate != null) tvLastUpdate.setText(vessel.getLastUpdate() != null ? String.format("Обновлено: %s", vessel.getLastUpdate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))) : "Обновлено: --"); if (tvLastUpdate != null) tvLastUpdate.setText(vessel.getLastUpdate() != null ? String.format("Обновлено: %s", vessel.getLastUpdate().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))) : "Обновлено: --");
if (tvDistance != null || tvBearing != null) { if (tvDistance != null || tvBearing != null || tvCpa != null) {
Vessel ourVessel = appCoordinator.getOwnVessel(); Vessel ourVessel = appCoordinator.getOwnVessel();
if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) { String cpaNa = context.getString(R.string.cpa_na);
double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0
if (tvDistance != null) tvDistance.setText("Расстояние: " + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance)); && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
double bearing = com.grigowashere.aismap.utils.NavigationUtils.calculateBearing(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude()); double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(
double relativeBearing = com.grigowashere.aismap.utils.NavigationUtils.calculateRelativeBearing(ourVessel.getCourse(), bearing); ourVessel.getLatitude(), ourVessel.getLongitude(),
if (tvBearing != null) tvBearing.setText("Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing)); vessel.getLatitude(), vessel.getLongitude());
if (tvDistance != null) {
tvDistance.setText("Расстояние: "
+ com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance));
}
double bearing = com.grigowashere.aismap.utils.NavigationUtils.calculateBearing(
ourVessel.getLatitude(), ourVessel.getLongitude(),
vessel.getLatitude(), vessel.getLongitude());
double relativeBearing = com.grigowashere.aismap.utils.NavigationUtils.calculateRelativeBearing(
ourVessel.getCourse(), bearing);
if (tvBearing != null) {
tvBearing.setText("Пеленг: "
+ com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing));
}
if (tvCpa != null) {
com.grigowashere.aismap.utils.SettingsManager sm =
new com.grigowashere.aismap.utils.SettingsManager(context);
boolean useNm = com.grigowashere.aismap.utils.SettingsManager.RANGE_UNIT_NM
.equals(sm.getRangeUnit());
com.grigowashere.aismap.utils.RangeMath.CpaResult cpa =
com.grigowashere.aismap.utils.RangeMath.calculateCpa(
ourVessel.getLatitude(), ourVessel.getLongitude(),
ourVessel.getSpeed(), ourVessel.getCourse(), ourVessel.getHeading(),
vessel.getLatitude(), vessel.getLongitude(),
vessel.getSpeed(), vessel.getCourse(), vessel.getHeading());
if (cpa.valid) {
String cpaDist = com.grigowashere.aismap.utils.RangeMath.formatCpaDistance(
cpa.cpaMeters, useNm, java.util.Locale.getDefault());
String tcpa = com.grigowashere.aismap.utils.RangeMath.formatTcpa(
cpa.tcpaMinutes, java.util.Locale.getDefault());
tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaDist, tcpa));
} else {
tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaNa, cpaNa));
}
}
} else { } else {
if (tvDistance != null) tvDistance.setText("Расстояние: --"); if (tvDistance != null) tvDistance.setText("Расстояние: --");
if (tvBearing != null) tvBearing.setText("Пеленг: --"); if (tvBearing != null) tvBearing.setText("Пеленг: --");
if (tvCpa != null) tvCpa.setText(context.getString(R.string.bottom_sheet_ais_cpa, cpaNa, cpaNa));
} }
} }
@@ -0,0 +1,110 @@
package com.grigowashere.aismap.utils;
import android.util.Log;
import java.io.InputStream;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.UnknownHostException;
import java.security.cert.CertificateException;
import java.util.Locale;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Lightweight reachability check for {@code https://aismap.local/} (feasibility / diagnostics).
* Runs on a background thread; callers must not invoke from the UI thread.
*/
public final class AismapLocalHttpsProbe {
private static final String TAG = "AismapLocalHttpsProbe";
/** Hub HTTPS root — user-requested feasibility target. */
public static final String PROBE_URL = "https://aismap.local/";
private static final int CONNECT_TIMEOUT_MS = 2500;
private static final int READ_TIMEOUT_MS = 2500;
private AismapLocalHttpsProbe() {}
/**
* Performs a GET with short timeouts. Returns a short UI-safe line (no stack traces).
*/
public static String probeOnce() {
HttpURLConnection conn = null;
try {
URL url = new URL(PROBE_URL);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setRequestMethod("GET");
conn.setInstanceFollowRedirects(true);
conn.setUseCaches(false);
int code = conn.getResponseCode();
InputStream body = code >= HttpURLConnection.HTTP_BAD_REQUEST
? conn.getErrorStream() : conn.getInputStream();
drain(body);
if (code >= 200 && code < 400) {
return "HTTPS aismap.local: OK HTTP " + code;
}
return "HTTPS aismap.local: HTTP " + code;
} catch (Throwable t) {
Log.w(TAG, "probe failed: " + t.getClass().getSimpleName() + ": " + t.getMessage(), t);
return "HTTPS aismap.local: " + classify(t);
} finally {
if (conn != null) {
try {
conn.disconnect();
} catch (Throwable ignore) {}
}
}
}
private static void drain(InputStream in) {
if (in == null) return;
byte[] buf = new byte[512];
try (InputStream stream = in) {
while (stream.read(buf) != -1) { /* discard */ }
} catch (Throwable ignore) {}
}
static String classify(Throwable t) {
if (t instanceof SSLHandshakeException || t instanceof SSLPeerUnverifiedException) {
return "TLS/cert issue";
}
if (t instanceof CertificateException) {
return "TLS/cert issue";
}
if (t instanceof SSLException) {
String m = t.getMessage();
if (m != null && m.toLowerCase(Locale.US).contains("cert")) {
return "TLS/cert issue";
}
return "TLS: " + t.getClass().getSimpleName();
}
if (t instanceof SocketTimeoutException) {
return "timeout";
}
if (t instanceof UnknownHostException) {
return "no DNS (aismap.local)";
}
if (t instanceof ConnectException || t instanceof NoRouteToHostException) {
return "no route / refused";
}
Throwable c = t.getCause();
if (c != null && c != t) {
return classify(c);
}
String m = t.getMessage();
if (m != null && !m.isEmpty()) {
String shortM = m.length() > 48 ? m.substring(0, 45) + "" : m;
return t.getClass().getSimpleName() + ": " + shortM;
}
return t.getClass().getSimpleName();
}
}
@@ -69,4 +69,159 @@ public final class RangeMath {
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; return R * c;
} }
/** AIS SOG «not available» (102.3 kn). */
public static final double AIS_SOG_NOT_AVAILABLE = 102.3;
/** AIS COG «not available» (360°). */
public static final double AIS_COG_NOT_AVAILABLE = 360.0;
/** AIS HDG «not available» (511°). */
public static final double AIS_HDG_NOT_AVAILABLE = 511.0;
/** Минимальная относительная скорость (м/с) для расчёта TCPA. */
private static final double MIN_REL_SPEED_MS = 0.02;
/**
* Результат расчёта CPA/TCPA.
* <p>{@link #tcpaMinutes} отрицательный CPA уже пройден или сближение невозможно.
*/
public static final class CpaResult {
public final boolean valid;
/** Минимальная дистанция сближения, м. */
public final double cpaMeters;
/** Время до CPA, мин (отрицательное — в прошлом / расхождение). */
public final double tcpaMinutes;
public CpaResult(boolean valid, double cpaMeters, double tcpaMinutes) {
this.valid = valid;
this.cpaMeters = cpaMeters;
this.tcpaMinutes = tcpaMinutes;
}
public static final CpaResult INVALID = new CpaResult(false, Double.NaN, Double.NaN);
}
public static boolean isValidAisSpeed(double sogKn) {
if (Double.isNaN(sogKn) || Double.isInfinite(sogKn)) return false;
return sogKn >= 0.0 && sogKn < AIS_SOG_NOT_AVAILABLE;
}
public static boolean isValidAisCourse(double cogDeg) {
if (Double.isNaN(cogDeg) || Double.isInfinite(cogDeg)) return false;
return cogDeg >= 0.0 && cogDeg < AIS_COG_NOT_AVAILABLE;
}
public static boolean isValidAisHeading(double hdgDeg) {
if (Double.isNaN(hdgDeg) || Double.isInfinite(hdgDeg)) return false;
int h = (int) Math.round(hdgDeg);
if (h == (int) AIS_HDG_NOT_AVAILABLE) return false;
return h >= 0 && h <= 359;
}
/**
* Курс для вектора движения: валидный HDG, иначе COG.
*/
public static double resolveMotionCourse(double cogDeg, double headingDeg) {
if (isValidAisHeading(headingDeg)) {
return normalizeCourse360(headingDeg);
}
if (isValidAisCourse(cogDeg)) {
return normalizeCourse360(cogDeg);
}
return Double.NaN;
}
public static double normalizeCourse360(double courseDeg) {
if (Double.isNaN(courseDeg) || Double.isInfinite(courseDeg)) return Double.NaN;
double c = courseDeg % 360.0;
if (c < 0) c += 360.0;
return c;
}
public static double knotsToMetersPerSecond(double knots) {
return knots * METERS_PER_NM / 3600.0;
}
/**
* CPA/TCPA в локальной плоскости (север/восток), стандартный относительный вектор.
*/
public static CpaResult calculateCpa(double ownLat, double ownLon,
double ownSogKn, double ownCogDeg, double ownHdgDeg,
double tgtLat, double tgtLon,
double tgtSogKn, double tgtCogDeg, double tgtHdgDeg) {
if (Double.isNaN(ownLat) || Double.isNaN(ownLon)
|| Double.isNaN(tgtLat) || Double.isNaN(tgtLon)) {
return CpaResult.INVALID;
}
if (!isValidAisSpeed(ownSogKn) || !isValidAisSpeed(tgtSogKn)) {
return CpaResult.INVALID;
}
double ownCourse = resolveMotionCourse(ownCogDeg, ownHdgDeg);
double tgtCourse = resolveMotionCourse(tgtCogDeg, tgtHdgDeg);
if (Double.isNaN(ownCourse) || Double.isNaN(tgtCourse)) {
return CpaResult.INVALID;
}
double[] pr = relativePositionNeMeters(ownLat, ownLon, tgtLat, tgtLon);
double[] vo = velocityNeMetersPerSecond(ownSogKn, ownCourse);
double[] vt = velocityNeMetersPerSecond(tgtSogKn, tgtCourse);
double vrN = vt[0] - vo[0];
double vrE = vt[1] - vo[1];
double vr2 = vrN * vrN + vrE * vrE;
if (vr2 < MIN_REL_SPEED_MS * MIN_REL_SPEED_MS) {
return CpaResult.INVALID;
}
double dot = pr[0] * vrN + pr[1] * vrE;
double tcpaSec = -dot / vr2;
double cpaN = pr[0] + vrN * tcpaSec;
double cpaE = pr[1] + vrE * tcpaSec;
double cpaMeters = Math.hypot(cpaN, cpaE);
double tcpaMinutes = tcpaSec / 60.0;
return new CpaResult(true, cpaMeters, tcpaMinutes);
}
/** NE-смещение цели относительно своего судна, м (север, восток). */
static double[] relativePositionNeMeters(double ownLat, double ownLon,
double tgtLat, double tgtLon) {
double latMidRad = Math.toRadians((ownLat + tgtLat) * 0.5);
double mPerDegLat = 111_320.0;
double mPerDegLon = 111_320.0 * Math.cos(latMidRad);
double north = (tgtLat - ownLat) * mPerDegLat;
double east = (tgtLon - ownLon) * mPerDegLon;
return new double[] { north, east };
}
/** Скорость по курсу (0° = север), м/с. */
static double[] velocityNeMetersPerSecond(double sogKn, double cogDeg) {
double speedMs = knotsToMetersPerSecond(sogKn);
double rad = Math.toRadians(cogDeg);
return new double[] {
speedMs * Math.cos(rad),
speedMs * Math.sin(rad)
};
}
/** Краткая строка CPA + TCPA для таблиц (nm/km + мин). */
public static String formatCpaTcpaShort(CpaResult result, boolean useNm,
String na, java.util.Locale locale) {
if (result == null || !result.valid) return na;
return formatCpaDistance(result.cpaMeters, useNm, locale)
+ " " + formatTcpa(result.tcpaMinutes, locale);
}
public static String formatCpaDistance(double meters, boolean useNm, java.util.Locale locale) {
if (useNm) {
return String.format(locale, "%.2f nm", meters / METERS_PER_NM);
}
if (meters >= 1000.0) {
return String.format(locale, "%.2f km", meters / METERS_PER_KM);
}
return String.format(locale, "%.0f m", meters);
}
public static String formatTcpa(double tcpaMinutes, java.util.Locale locale) {
if (tcpaMinutes >= 0.0) {
return String.format(locale, "+%.0f мин", tcpaMinutes);
}
return String.format(locale, "%.0f мин", tcpaMinutes);
}
} }
@@ -9,6 +9,7 @@ import android.util.AttributeSet;
import com.grigowashere.aismap.R; import com.grigowashere.aismap.R;
import com.grigowashere.aismap.controllers.AppCoordinator; import com.grigowashere.aismap.controllers.AppCoordinator;
import com.grigowashere.aismap.utils.RangeMath;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import java.util.ArrayList; import java.util.ArrayList;
@@ -17,10 +18,12 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
/** /**
* Виджет «Опасные цели» таблица ближайших AIS-целей в зоне опасности. * Виджет «Ближайшие цели» таблица AIS-целей в радиусе жёлтого кольца
* Колонки: имя/MMSI, пеленг (°), дистанция (nm/km в зависимости от настроек). * предупреждения. Колонки: имя/MMSI, пеленг (°), дистанция (nm/km в
* зависимости от настроек).
* <p>Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из * <p>Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из
* MainActivity, источник данных {@link AppCoordinator#getDangerTargets(double, int)}. * MainActivity, источник данных {@link AppCoordinator#getDangerTargets(double, int)}
* с радиусом {@link SettingsManager#getWarningRadiusMeters()}.
*/ */
public class DangerTargetsDockWidget extends BaseDockWidget { public class DangerTargetsDockWidget extends BaseDockWidget {
@@ -29,11 +32,22 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
public final String name; public final String name;
public final double bearingDeg; public final double bearingDeg;
public final double distanceMeters; public final double distanceMeters;
public final boolean cpaValid;
public final double cpaMeters;
public final double tcpaMinutes;
public DangerEntry(String name, double bearingDeg, double distanceMeters) { public DangerEntry(String name, double bearingDeg, double distanceMeters) {
this(name, bearingDeg, distanceMeters, false, Double.NaN, Double.NaN);
}
public DangerEntry(String name, double bearingDeg, double distanceMeters,
boolean cpaValid, double cpaMeters, double tcpaMinutes) {
this.name = name; this.name = name;
this.bearingDeg = bearingDeg; this.bearingDeg = bearingDeg;
this.distanceMeters = distanceMeters; this.distanceMeters = distanceMeters;
this.cpaValid = cpaValid;
this.cpaMeters = cpaMeters;
this.tcpaMinutes = tcpaMinutes;
} }
} }
@@ -49,7 +63,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
private static final int BACKGROUND_COLOR = 0xD92A1A1A; private static final int BACKGROUND_COLOR = 0xD92A1A1A;
private static final int LABEL_COLOR = 0xFFE0B0B0; private static final int LABEL_COLOR = 0xFFE0B0B0;
private static final int TEXT_COLOR = 0xFFFFFFFF; private static final int TEXT_COLOR = 0xFFFFFFFF;
private static final int ACCENT_COLOR = 0xFFFF6B6B; private static final int ACCENT_COLOR = 0xFFFFA000;
private Paint backgroundPaint; private Paint backgroundPaint;
private Paint titlePaint; private Paint titlePaint;
@@ -78,7 +92,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
@Override @Override
protected boolean getDefaultDockTop() { protected boolean getDefaultDockTop() {
// Виджет «Опасные цели» по умолчанию сидит ВНИЗУ над координатами // Виджет «Ближайшие цели» по умолчанию сидит ВНИЗУ над координатами
// это самая безболезненная для карты позиция: при отсутствии целей // это самая безболезненная для карты позиция: при отсутствии целей
// он вообще скрыт, а когда появляется, не разрывает обзор по центру. // он вообще скрыт, а когда появляется, не разрывает обзор по центру.
return false; return false;
@@ -217,7 +231,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
snapshot = new ArrayList<>(entries); snapshot = new ArrayList<>(entries);
} }
// === Заголовок: «Опасные цели · N» === // === Заголовок: «Ближайшие цели · N» ===
float titleBaseline = top + dp(4) + titlePaint.getTextSize(); float titleBaseline = top + dp(4) + titlePaint.getTextSize();
String titleBase = getResources().getString(R.string.danger_widget_title); String titleBase = getResources().getString(R.string.danger_widget_title);
String title = snapshot.isEmpty() String title = snapshot.isEmpty()
@@ -233,14 +247,15 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
return; return;
} }
// === Строки целей. Колонки: имя | пеленг | дистанция === // === Строки целей. Колонки: имя | пеленг | дистанция | CPA ===
// Имя широкая колонка слева, пеленг и дистанция справа выровнены по String cpaNa = getResources().getString(R.string.cpa_na);
// фиксированной ширине, чтобы цифры не «прыгали» между строками. float cpaMaxWidth = accentPaint.measureText("9.99 nm +99 мин");
float distMaxWidth = textPaint.measureText("999.99 nm"); float distMaxWidth = textPaint.measureText("999.99 nm");
float bearingMaxWidth = textPaint.measureText("000\u00B0"); float bearingMaxWidth = textPaint.measureText("000\u00B0");
float colDistanceRight = innerRight; float colCpaRight = innerRight;
float colBearingRight = colDistanceRight - distMaxWidth - dp(10); float colDistanceRight = colCpaRight - cpaMaxWidth - dp(6);
float nameRight = colBearingRight - bearingMaxWidth - dp(10); float colBearingRight = colDistanceRight - distMaxWidth - dp(6);
float nameRight = colBearingRight - bearingMaxWidth - dp(6);
boolean useNm = settingsManager == null boolean useNm = settingsManager == null
|| SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit()); || SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
@@ -254,13 +269,21 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
String name = ellipsize(rawName, textPaint, nameRight - innerLeft - dp(4)); String name = ellipsize(rawName, textPaint, nameRight - innerLeft - dp(4));
String bearing = String.format(Locale.US, "%03.0f\u00B0", e.bearingDeg); String bearing = String.format(Locale.US, "%03.0f\u00B0", e.bearingDeg);
String distance = formatDistance(e.distanceMeters, useNm); String distance = formatDistance(e.distanceMeters, useNm);
String cpa;
if (e.cpaValid) {
cpa = RangeMath.formatCpaDistance(e.cpaMeters, useNm, Locale.US)
+ " " + RangeMath.formatTcpa(e.tcpaMinutes, Locale.US);
} else {
cpa = cpaNa;
}
canvas.drawText(name, innerLeft, y, textPaint); canvas.drawText(name, innerLeft, y, textPaint);
// Пеленг и дистанция выравнивание справа по своим колонкам.
float bearingWidth = textPaint.measureText(bearing); float bearingWidth = textPaint.measureText(bearing);
canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint); canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint);
float distanceWidth = accentPaint.measureText(distance); float distanceWidth = textPaint.measureText(distance);
canvas.drawText(distance, colDistanceRight - distanceWidth, y, accentPaint); canvas.drawText(distance, colDistanceRight - distanceWidth, y, textPaint);
float cpaWidth = accentPaint.measureText(cpa);
canvas.drawText(cpa, colCpaRight - cpaWidth, y, accentPaint);
y += rowH; y += rowH;
} }
@@ -310,8 +333,13 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
centerY - dp(28), subPaint); centerY - dp(28), subPaint);
if (nearest != null) { if (nearest != null) {
String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s", String cpaPart = nearest.cpaValid
nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm)); ? RangeMath.formatCpaTcpaShort(
new RangeMath.CpaResult(true, nearest.cpaMeters, nearest.tcpaMinutes),
useNm, getResources().getString(R.string.cpa_na), Locale.US)
: getResources().getString(R.string.cpa_na);
String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s %s",
nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm), cpaPart);
b = new Rect(); b = new Rect();
subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b); subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b);
canvas.drawText(nearestStr, centerX - b.width() / 2f - b.left, canvas.drawText(nearestStr, centerX - b.width() / 2f - b.left,
@@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat;
import com.grigowashere.aismap.R; import com.grigowashere.aismap.R;
import com.grigowashere.aismap.controllers.AppCoordinator; import com.grigowashere.aismap.controllers.AppCoordinator;
import com.grigowashere.aismap.utils.RangeMath;
import com.grigowashere.aismap.utils.SettingsManager; import com.grigowashere.aismap.utils.SettingsManager;
import java.util.ArrayList; import java.util.ArrayList;
@@ -28,11 +29,22 @@ public class PlotterTargetsTableView extends View {
public final String name; public final String name;
public final double bearingDeg; public final double bearingDeg;
public final double distanceMeters; public final double distanceMeters;
public final boolean cpaValid;
public final double cpaMeters;
public final double tcpaMinutes;
public Row(String name, double bearingDeg, double distanceMeters) { public Row(String name, double bearingDeg, double distanceMeters) {
this(name, bearingDeg, distanceMeters, false, Double.NaN, Double.NaN);
}
public Row(String name, double bearingDeg, double distanceMeters,
boolean cpaValid, double cpaMeters, double tcpaMinutes) {
this.name = name; this.name = name;
this.bearingDeg = bearingDeg; this.bearingDeg = bearingDeg;
this.distanceMeters = distanceMeters; this.distanceMeters = distanceMeters;
this.cpaValid = cpaValid;
this.cpaMeters = cpaMeters;
this.tcpaMinutes = tcpaMinutes;
} }
} }
@@ -100,7 +112,8 @@ public class PlotterTargetsTableView extends View {
if (label == null || label.trim().isEmpty()) { if (label == null || label.trim().isEmpty()) {
label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : ""; label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "";
} }
next.add(new Row(label, e.bearingDegrees, e.distanceMeters)); next.add(new Row(label, e.bearingDegrees, e.distanceMeters,
e.cpaValid, e.cpaMeters, e.tcpaMinutes));
if (next.size() >= MAX_ROWS) break; if (next.size() >= MAX_ROWS) break;
} }
} }
@@ -156,7 +169,13 @@ public class PlotterTargetsTableView extends View {
canvas.drawText(name, colName, y, rowPaint); canvas.drawText(name, colName, y, rowPaint);
canvas.drawText(String.format(Locale.US, "%03.0f\u00B0", r.bearingDeg), colBrg, y, rowPaint); canvas.drawText(String.format(Locale.US, "%03.0f\u00B0", r.bearingDeg), colBrg, y, rowPaint);
canvas.drawText(formatDistance(r.distanceMeters, useNm), colRng, y, rowPaint); canvas.drawText(formatDistance(r.distanceMeters, useNm), colRng, y, rowPaint);
canvas.drawText(cpaNa, colCpa, y, accentPaint); String cpaText;
if (r.cpaValid) {
cpaText = RangeMath.formatCpaDistance(r.cpaMeters, useNm, Locale.US);
} else {
cpaText = cpaNa;
}
canvas.drawText(cpaText, colCpa, y, accentPaint);
y += rowH; y += rowH;
if (y > h - pad) break; if (y > h - pad) break;
} }
@@ -199,6 +199,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:maxLines="8"
android:text="BLE RSSI: --" android:text="BLE RSSI: --"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:textSize="10sp" /> android:textSize="10sp" />
@@ -285,6 +285,18 @@
android:background="@android:color/transparent" android:background="@android:color/transparent"
android:padding="8dp" /> android:padding="8dp" />
<!-- CPA / TCPA -->
<TextView
android:id="@+id/bottom_sheet_ais_cpa"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="CPA: —, TCPA: —"
android:textSize="14sp"
android:textColor="@android:color/black"
android:layout_marginBottom="8dp"
android:background="@android:color/transparent"
android:padding="8dp" />
<!-- Последнее обновление --> <!-- Последнее обновление -->
<TextView <TextView
android:id="@+id/bottom_sheet_ais_last_update" android:id="@+id/bottom_sheet_ais_last_update"
+5 -3
View File
@@ -35,7 +35,7 @@
<string name="settings_range_unit_nm">Морские мили (nm)</string> <string name="settings_range_unit_nm">Морские мили (nm)</string>
<string name="settings_range_unit_km">Километры (км)</string> <string name="settings_range_unit_km">Километры (км)</string>
<string name="settings_range_danger_hint">Радиус зоны опасности</string> <string name="settings_range_danger_hint">Радиус зоны опасности</string>
<string name="settings_range_danger_helper">Цели в этой зоне отображаются в виджете и считаются опасными</string> <string name="settings_range_danger_helper">Красное кольцо опасности на карте</string>
<string name="settings_range_warning_hint">Радиус зоны предупреждения</string> <string name="settings_range_warning_hint">Радиус зоны предупреждения</string>
<string name="settings_range_warning_helper">Цели в этой зоне подсвечиваются на карте</string> <string name="settings_range_warning_helper">Цели в этой зоне подсвечиваются на карте</string>
<string name="settings_range_filter_hint">Радиус зоны фильтра</string> <string name="settings_range_filter_hint">Радиус зоны фильтра</string>
@@ -131,11 +131,13 @@
<string name="banner_pairing_required">BLE требует сопряжения. Проверьте устройство в настройках Bluetooth.</string> <string name="banner_pairing_required">BLE требует сопряжения. Проверьте устройство в настройках Bluetooth.</string>
<string name="banner_icon_description">Иконка предупреждения связи</string> <string name="banner_icon_description">Иконка предупреждения связи</string>
<string name="danger_widget_title">Опасные цели</string> <string name="danger_widget_title">Ближайшие цели</string>
<string name="danger_widget_empty">В зоне опасности нет целей</string> <string name="danger_widget_empty">Ближайших целей нет</string>
<string name="danger_widget_column_target">Цель</string> <string name="danger_widget_column_target">Цель</string>
<string name="danger_widget_column_bearing">Пел.</string> <string name="danger_widget_column_bearing">Пел.</string>
<string name="danger_widget_column_distance">Дист.</string> <string name="danger_widget_column_distance">Дист.</string>
<string name="cpa_na"></string>
<string name="bottom_sheet_ais_cpa">CPA: %1$s, TCPA: %2$s</string>
<!-- ===== Подписи в навигационных виджетах (компас + координаты) ===== --> <!-- ===== Подписи в навигационных виджетах (компас + координаты) ===== -->
<string name="compass_label_heading">КУРС</string> <string name="compass_label_heading">КУРС</string>
@@ -86,4 +86,60 @@ public class RangeMathTest {
assertTrue("Distance Moscow-SPB ~ 633km, got=" + d, assertTrue("Distance Moscow-SPB ~ 633km, got=" + d,
Math.abs(d - 633_000.0) < 10_000.0); Math.abs(d - 633_000.0) < 10_000.0);
} }
@Test
public void testAisValidityHelpers() {
assertTrue(RangeMath.isValidAisSpeed(0.0));
assertTrue(RangeMath.isValidAisSpeed(10.5));
assertFalse(RangeMath.isValidAisSpeed(102.3));
assertFalse(RangeMath.isValidAisSpeed(Double.NaN));
assertTrue(RangeMath.isValidAisCourse(359.9));
assertFalse(RangeMath.isValidAisCourse(360.0));
assertFalse(RangeMath.isValidAisHeading(511.0));
assertTrue(RangeMath.isValidAisHeading(90.0));
}
@Test
public void testCpaHeadOnClosure() {
double lat = 55.0;
double ownLon = 37.0;
double tgtLon = 37.0;
double nm = RangeMath.METERS_PER_NM;
double tgtLat = lat + (nm / 111_320.0);
RangeMath.CpaResult r = RangeMath.calculateCpa(
lat, ownLon, 10.0, 0.0, Double.NaN,
tgtLat, tgtLon, 10.0, 180.0, Double.NaN);
assertTrue(r.valid);
assertTrue("CPA head-on should be small, got " + r.cpaMeters, r.cpaMeters < 200.0);
assertTrue("TCPA head-on should be positive, got " + r.tcpaMinutes, r.tcpaMinutes > 0.0);
assertTrue("TCPA head-on ~3 min, got " + r.tcpaMinutes,
Math.abs(r.tcpaMinutes - 3.0) < 0.5);
}
@Test
public void testCpaParallelSameVectorsInvalidTcpa() {
RangeMath.CpaResult r = RangeMath.calculateCpa(
55.0, 37.0, 10.0, 90.0, Double.NaN,
55.0, 37.01, 10.0, 90.0, Double.NaN);
assertFalse(r.valid);
}
@Test
public void testCpaMissingCourseInvalid() {
RangeMath.CpaResult r = RangeMath.calculateCpa(
55.0, 37.0, 5.0, 360.0, Double.NaN,
55.01, 37.0, 5.0, 90.0, Double.NaN);
assertFalse(r.valid);
}
@Test
public void testCpaUsesHeadingOverCog() {
RangeMath.CpaResult withHdg = RangeMath.calculateCpa(
55.0, 37.0, 10.0, 0.0, 90.0,
55.0, 37.01, 10.0, 0.0, 270.0);
assertTrue(withHdg.valid);
assertTrue(withHdg.cpaMeters < 500.0);
}
} }