generated from Grigo/AndroidTemplate
Added radar
This commit is contained in:
@@ -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,7 +1314,15 @@ 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);
|
||||||
result.add(new DangerEntry(vessel, d, b));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
CameraPosition position = new CameraPosition.Builder()
|
targetLon = lon;
|
||||||
.target(new LatLng(lat, lon))
|
targetBearing = bearingDeg;
|
||||||
.zoom(zoom)
|
if (rangeMeters > 0.0) {
|
||||||
.bearing(bearingDeg)
|
targetRangeMeters = rangeMeters;
|
||||||
.tilt(0.0)
|
}
|
||||||
.build();
|
startFollowLoop();
|
||||||
map.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400);
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
.target(new LatLng(newLat, newLon))
|
||||||
|
.zoom(newZoom)
|
||||||
|
.bearing(newBearing)
|
||||||
|
.tilt(0.0)
|
||||||
|
.build();
|
||||||
|
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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user