generated from Grigo/AndroidTemplate
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ae5fc341 |
@@ -5,6 +5,7 @@ import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import android.util.Printer;
|
||||
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.BottomSheetsBinder;
|
||||
import com.grigowashere.aismap.ui.PermissionsBinder;
|
||||
import com.grigowashere.aismap.utils.AismapLocalHttpsProbe;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
import com.grigowashere.aismap.utils.LogSender;
|
||||
import com.grigowashere.aismap.utils.MIDToCountry;
|
||||
@@ -54,6 +56,9 @@ import org.maplibre.android.maps.MapView;
|
||||
import org.maplibre.android.MapLibre;
|
||||
import java.util.List;
|
||||
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.DefaultControllersFactory;
|
||||
@@ -119,6 +124,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
private TextView tvBleRssi;
|
||||
private TextView tvBleBatt;
|
||||
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 long lastFpsTs = 0L;
|
||||
private final android.view.Choreographer.FrameCallback fpsCallback = new android.view.Choreographer.FrameCallback() {
|
||||
@@ -487,6 +498,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
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());
|
||||
messageAgeRunnable = new Runnable() {
|
||||
@Override
|
||||
@@ -506,13 +525,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
if (tvBleRssi != null) {
|
||||
Integer rssi = appCoordinator.getLastBleRssi();
|
||||
String bleLine;
|
||||
int bleColor;
|
||||
if (rssi != null) {
|
||||
tvBleRssi.setText("BLE RSSI: " + rssi);
|
||||
tvBleRssi.setTextColor(getRssiColor(rssi));
|
||||
bleLine = "BLE RSSI: " + rssi;
|
||||
bleColor = getRssiColor(rssi);
|
||||
} else {
|
||||
tvBleRssi.setText("BLE RSSI: --");
|
||||
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
|
||||
bleLine = "BLE RSSI: --";
|
||||
bleColor = android.graphics.Color.parseColor("#F44336");
|
||||
}
|
||||
tvBleRssi.setText(bleLine + "\n" + localHttpsProbeStatusLine);
|
||||
tvBleRssi.setTextColor(bleColor);
|
||||
}
|
||||
if (tvBleBatt != null) {
|
||||
Integer batt = appCoordinator.getLastBleBattery();
|
||||
@@ -521,6 +544,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
updateBleLinkLostBanner();
|
||||
updateDangerWidget();
|
||||
}
|
||||
if (tvBleRssi != null && appCoordinator == null) {
|
||||
tvBleRssi.setText("BLE RSSI: --\n" + localHttpsProbeStatusLine);
|
||||
tvBleRssi.setTextColor(android.graphics.Color.parseColor("#F44336"));
|
||||
}
|
||||
triggerLocalHttpsProbeIfDue();
|
||||
} catch (Exception ignored) {}
|
||||
messageAgeHandler.postDelayed(this, 1000);
|
||||
}
|
||||
@@ -529,6 +557,35 @@ public class MainActivity extends AppCompatActivity {
|
||||
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.
|
||||
* Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя,
|
||||
@@ -571,19 +628,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет таблицу «Опасные цели» каждую секунду из AppCoordinator.
|
||||
* Если опасных целей нет (или функция отключена в настройках) — виджет
|
||||
* полностью скрывается, чтобы не занимать место на карте.
|
||||
* Обновляет таблицу «Ближайшие цели» каждую секунду из AppCoordinator.
|
||||
* Цели берутся из жёлтой зоны предупреждения, а не из красного круга
|
||||
* опасности. Если целей нет (или кольца отключены) — виджет полностью
|
||||
* скрывается, чтобы не занимать место на карте.
|
||||
*/
|
||||
private void updateDangerWidget() {
|
||||
if (dangerWidget == null || appCoordinator == null || settingsManager == null) return;
|
||||
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 =
|
||||
new java.util.ArrayList<>();
|
||||
if (enabled && dangerR > 0.0) {
|
||||
if (enabled && nearestR > 0.0) {
|
||||
java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> entries =
|
||||
appCoordinator.getDangerTargets(dangerR, 5);
|
||||
appCoordinator.getDangerTargets(nearestR, 5);
|
||||
if (entries != null) {
|
||||
for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) {
|
||||
if (e == null || e.vessel == null) continue;
|
||||
@@ -592,7 +650,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—";
|
||||
}
|
||||
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);
|
||||
Log.i(TAG, "messageAgeHandler остановлен");
|
||||
}
|
||||
if (localHttpsProbeExecutor != null) {
|
||||
try {
|
||||
localHttpsProbeExecutor.shutdownNow();
|
||||
} catch (Throwable ignore) {}
|
||||
localHttpsProbeExecutor = null;
|
||||
}
|
||||
|
||||
// Останавливаем UI watchdog
|
||||
if (uiWatchdogHandler != null && uiWatchdogRunnable != null) {
|
||||
|
||||
@@ -1265,11 +1265,24 @@ public class AppCoordinator implements
|
||||
public final double distanceMeters;
|
||||
/** Пеленг от собственного судна на цель, °. */
|
||||
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) {
|
||||
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.distanceMeters = distanceMeters;
|
||||
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 oLon = ownVessel.getLongitude();
|
||||
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) {
|
||||
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);
|
||||
if (d <= maxRadiusMeters) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
java.util.Collections.sort(result, (a, b) -> Double.compare(a.distanceMeters, b.distanceMeters));
|
||||
if (limit > 0 && result.size() > limit) {
|
||||
return new ArrayList<>(result.subList(0, limit));
|
||||
|
||||
@@ -649,6 +649,11 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
} catch (Exception ignore) {}
|
||||
|
||||
idToFeature.put(vessel.getMmsi(), feature);
|
||||
if (PATH_FEATURES_ENABLED) {
|
||||
String mmsi = vessel.getMmsi();
|
||||
updateAISPathSource(mmsi);
|
||||
updateAISVesselPredictionSource(mmsi, vessel);
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
@@ -2456,6 +2461,9 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
// Обновляем путь этого AIS судна
|
||||
updateAISVesselPath(mmsi, aisPathController);
|
||||
}
|
||||
if (PATH_FEATURES_ENABLED) {
|
||||
updateAISVesselPredictionSource(mmsi, vessel);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка обновления путей AIS судов: " + e.getMessage(), e);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.grigowashere.aismap.maps;
|
||||
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
|
||||
import com.grigowashere.aismap.utils.NavigatorZoomMath;
|
||||
|
||||
import org.maplibre.android.camera.CameraPosition;
|
||||
import org.maplibre.android.camera.CameraUpdateFactory;
|
||||
import org.maplibre.android.geometry.LatLng;
|
||||
@@ -17,10 +21,24 @@ public class RadarMapHelper {
|
||||
private static final String TAG = "RadarMapHelper";
|
||||
private static final String STYLE_URL =
|
||||
"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 Handler handler = new Handler(Looper.getMainLooper());
|
||||
private MapLibreMap map;
|
||||
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) {
|
||||
this.mapView = mapView;
|
||||
@@ -58,6 +76,7 @@ public class RadarMapHelper {
|
||||
}
|
||||
|
||||
public void onPause() {
|
||||
stopFollowLoop();
|
||||
mapView.onPause();
|
||||
}
|
||||
|
||||
@@ -66,6 +85,7 @@ public class RadarMapHelper {
|
||||
}
|
||||
|
||||
public void onDestroy() {
|
||||
stopFollowLoop();
|
||||
mapView.onDestroy();
|
||||
}
|
||||
|
||||
@@ -79,20 +99,64 @@ public class RadarMapHelper {
|
||||
|
||||
/**
|
||||
* Центрирует карту на собственном судне; bearing задаёт режим «курс вверх».
|
||||
* Поворот и смещение камеры сглаживаются так же, как в режиме навигатора.
|
||||
*
|
||||
* @param rangeMeters радиус PPI для подбора зума
|
||||
*/
|
||||
public void centerOnOwnShip(double lat, double lon, float bearingDeg, double rangeMeters) {
|
||||
if (map == null || !styleLoaded) 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()
|
||||
.target(new LatLng(lat, lon))
|
||||
.zoom(zoom)
|
||||
.bearing(bearingDeg)
|
||||
.target(new LatLng(newLat, newLon))
|
||||
.zoom(newZoom)
|
||||
.bearing(newBearing)
|
||||
.tilt(0.0)
|
||||
.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 помещался в круговой области. */
|
||||
|
||||
@@ -191,6 +191,7 @@ public class BottomSheetsManager {
|
||||
TextView tvSignal = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_signal);
|
||||
TextView tvDistance = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_distance);
|
||||
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 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 (tvDistance != null || tvBearing != null) {
|
||||
if (tvDistance != null || tvBearing != null || tvCpa != null) {
|
||||
Vessel ourVessel = appCoordinator.getOwnVessel();
|
||||
if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
||||
double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(ourVessel.getLatitude(), ourVessel.getLongitude(), 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));
|
||||
String cpaNa = context.getString(R.string.cpa_na);
|
||||
if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0
|
||||
&& vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
||||
double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(
|
||||
ourVessel.getLatitude(), ourVessel.getLongitude(),
|
||||
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 {
|
||||
if (tvDistance != null) tvDistance.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));
|
||||
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.controllers.AppCoordinator;
|
||||
import com.grigowashere.aismap.utils.RangeMath;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -17,10 +18,12 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Виджет «Опасные цели» — таблица ближайших AIS-целей в зоне опасности.
|
||||
* Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в зависимости от настроек).
|
||||
* Виджет «Ближайшие цели» — таблица AIS-целей в радиусе жёлтого кольца
|
||||
* предупреждения. Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в
|
||||
* зависимости от настроек).
|
||||
* <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 {
|
||||
|
||||
@@ -29,11 +32,22 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
public final String name;
|
||||
public final double bearingDeg;
|
||||
public final double distanceMeters;
|
||||
public final boolean cpaValid;
|
||||
public final double cpaMeters;
|
||||
public final double tcpaMinutes;
|
||||
|
||||
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.bearingDeg = bearingDeg;
|
||||
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 LABEL_COLOR = 0xFFE0B0B0;
|
||||
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 titlePaint;
|
||||
@@ -78,7 +92,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
|
||||
@Override
|
||||
protected boolean getDefaultDockTop() {
|
||||
// Виджет «Опасные цели» по умолчанию сидит ВНИЗУ над координатами —
|
||||
// Виджет «Ближайшие цели» по умолчанию сидит ВНИЗУ над координатами —
|
||||
// это самая безболезненная для карты позиция: при отсутствии целей
|
||||
// он вообще скрыт, а когда появляется, не разрывает обзор по центру.
|
||||
return false;
|
||||
@@ -217,7 +231,7 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
snapshot = new ArrayList<>(entries);
|
||||
}
|
||||
|
||||
// === Заголовок: «Опасные цели · N» ===
|
||||
// === Заголовок: «Ближайшие цели · N» ===
|
||||
float titleBaseline = top + dp(4) + titlePaint.getTextSize();
|
||||
String titleBase = getResources().getString(R.string.danger_widget_title);
|
||||
String title = snapshot.isEmpty()
|
||||
@@ -233,14 +247,15 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
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 bearingMaxWidth = textPaint.measureText("000\u00B0");
|
||||
float colDistanceRight = innerRight;
|
||||
float colBearingRight = colDistanceRight - distMaxWidth - dp(10);
|
||||
float nameRight = colBearingRight - bearingMaxWidth - dp(10);
|
||||
float colCpaRight = innerRight;
|
||||
float colDistanceRight = colCpaRight - cpaMaxWidth - dp(6);
|
||||
float colBearingRight = colDistanceRight - distMaxWidth - dp(6);
|
||||
float nameRight = colBearingRight - bearingMaxWidth - dp(6);
|
||||
|
||||
boolean useNm = settingsManager == null
|
||||
|| 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 bearing = String.format(Locale.US, "%03.0f\u00B0", e.bearingDeg);
|
||||
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);
|
||||
// Пеленг и дистанция — выравнивание справа по своим колонкам.
|
||||
float bearingWidth = textPaint.measureText(bearing);
|
||||
canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint);
|
||||
float distanceWidth = accentPaint.measureText(distance);
|
||||
canvas.drawText(distance, colDistanceRight - distanceWidth, y, accentPaint);
|
||||
float distanceWidth = textPaint.measureText(distance);
|
||||
canvas.drawText(distance, colDistanceRight - distanceWidth, y, textPaint);
|
||||
float cpaWidth = accentPaint.measureText(cpa);
|
||||
canvas.drawText(cpa, colCpaRight - cpaWidth, y, accentPaint);
|
||||
|
||||
y += rowH;
|
||||
}
|
||||
@@ -310,8 +333,13 @@ public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
centerY - dp(28), subPaint);
|
||||
|
||||
if (nearest != null) {
|
||||
String nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s",
|
||||
nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm));
|
||||
String cpaPart = nearest.cpaValid
|
||||
? 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();
|
||||
subPaint.getTextBounds(nearestStr, 0, nearestStr.length(), b);
|
||||
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.controllers.AppCoordinator;
|
||||
import com.grigowashere.aismap.utils.RangeMath;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -28,11 +29,22 @@ public class PlotterTargetsTableView extends View {
|
||||
public final String name;
|
||||
public final double bearingDeg;
|
||||
public final double distanceMeters;
|
||||
public final boolean cpaValid;
|
||||
public final double cpaMeters;
|
||||
public final double tcpaMinutes;
|
||||
|
||||
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.bearingDeg = bearingDeg;
|
||||
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()) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -156,7 +169,13 @@ public class PlotterTargetsTableView extends View {
|
||||
canvas.drawText(name, colName, 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(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;
|
||||
if (y > h - pad) break;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:maxLines="8"
|
||||
android:text="BLE RSSI: --"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="10sp" />
|
||||
|
||||
@@ -285,6 +285,18 @@
|
||||
android:background="@android:color/transparent"
|
||||
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
|
||||
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_km">Километры (км)</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_helper">Цели в этой зоне подсвечиваются на карте</string>
|
||||
<string name="settings_range_filter_hint">Радиус зоны фильтра</string>
|
||||
@@ -131,11 +131,13 @@
|
||||
<string name="banner_pairing_required">BLE требует сопряжения. Проверьте устройство в настройках Bluetooth.</string>
|
||||
<string name="banner_icon_description">Иконка предупреждения связи</string>
|
||||
|
||||
<string name="danger_widget_title">Опасные цели</string>
|
||||
<string name="danger_widget_empty">В зоне опасности нет целей</string>
|
||||
<string name="danger_widget_title">Ближайшие цели</string>
|
||||
<string name="danger_widget_empty">Ближайших целей нет</string>
|
||||
<string name="danger_widget_column_target">Цель</string>
|
||||
<string name="danger_widget_column_bearing">Пел.</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>
|
||||
|
||||
@@ -86,4 +86,60 @@ public class RangeMathTest {
|
||||
assertTrue("Distance Moscow-SPB ~ 633km, got=" + d,
|
||||
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