generated from Grigo/AndroidTemplate
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ae5fc341 | |||
| b22cdd93eb | |||
| 2fbeae26a6 |
Binary file not shown.
@@ -81,6 +81,13 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
android:theme="@style/Theme.AISMap" />
|
android:theme="@style/Theme.AISMap" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".RadarPlotterActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:theme="@style/Theme.AISMap.RadarPlotter"
|
||||||
|
android:keepScreenOn="true" />
|
||||||
|
|
||||||
<!-- Foreground Service для фоновых обновлений AIS/GPS -->
|
<!-- Foreground Service для фоновых обновлений AIS/GPS -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||||||
import com.grigowashere.aismap.data.Repository;
|
import com.grigowashere.aismap.data.Repository;
|
||||||
import com.grigowashere.aismap.data.entity.AISVesselEntity;
|
import com.grigowashere.aismap.data.entity.AISVesselEntity;
|
||||||
import com.grigowashere.aismap.data.entity.VesselEntity;
|
import com.grigowashere.aismap.data.entity.VesselEntity;
|
||||||
|
import com.grigowashere.aismap.utils.GeoUtils;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -23,6 +25,7 @@ import java.util.List;
|
|||||||
public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener {
|
public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener {
|
||||||
|
|
||||||
private Repository repository;
|
private Repository repository;
|
||||||
|
private SettingsManager settingsManager;
|
||||||
private RecyclerView recyclerView;
|
private RecyclerView recyclerView;
|
||||||
private AisTargetsAdapter adapter;
|
private AisTargetsAdapter adapter;
|
||||||
private android.os.Handler tickerHandler;
|
private android.os.Handler tickerHandler;
|
||||||
@@ -44,6 +47,7 @@ public class AisTargetsActivity extends AppCompatActivity implements AisTargetsA
|
|||||||
setContentView(R.layout.activity_ais_targets);
|
setContentView(R.layout.activity_ais_targets);
|
||||||
|
|
||||||
repository = new Repository(this);
|
repository = new Repository(this);
|
||||||
|
settingsManager = new SettingsManager(this);
|
||||||
|
|
||||||
// Загружаем данные нашего корабля
|
// Загружаем данные нашего корабля
|
||||||
loadOurVesselData();
|
loadOurVesselData();
|
||||||
@@ -118,6 +122,28 @@ public class AisTargetsActivity extends AppCompatActivity implements AisTargetsA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Дистанционный фильтр (range_filter): отбрасываем цели за пределами
|
||||||
|
// настроенного радиуса, если фильтр включён и известна позиция.
|
||||||
|
if (settingsManager != null && settingsManager.isRangeFilterEnabled()
|
||||||
|
&& GeoUtils.isValidCoordinates(ourLatitude, ourLongitude)) {
|
||||||
|
double maxDistanceM = settingsManager.getFilterRadiusMeters();
|
||||||
|
if (maxDistanceM > 0.0) {
|
||||||
|
java.util.List<AISVesselEntity> distanceFiltered = new java.util.ArrayList<>(filtered.size());
|
||||||
|
for (AISVesselEntity e : filtered) {
|
||||||
|
if (e == null) continue;
|
||||||
|
if (!GeoUtils.isValidCoordinates(e.latitude, e.longitude)) {
|
||||||
|
distanceFiltered.add(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double d = GeoUtils.calculateDistance(
|
||||||
|
ourLatitude, ourLongitude, e.latitude, e.longitude);
|
||||||
|
if (d <= maxDistanceM) {
|
||||||
|
distanceFiltered.add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filtered = distanceFiltered;
|
||||||
|
}
|
||||||
|
}
|
||||||
adapter.submitList(filtered);
|
adapter.submitList(filtered);
|
||||||
int targetCount = filtered.size();
|
int targetCount = filtered.size();
|
||||||
textTargetCount.setText("AIS цели: " + targetCount);
|
textTargetCount.setText("AIS цели: " + targetCount);
|
||||||
|
|||||||
@@ -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,13 +56,24 @@ 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;
|
||||||
|
|
||||||
public class MainActivity extends AppCompatActivity {
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
/** Живой координатор для вторичных экранов (радар), пока MainActivity в стеке. */
|
||||||
|
private static volatile AppCoordinator sAppCoordinator;
|
||||||
|
|
||||||
private static final String TAG = "MainActivity";
|
private static final String TAG = "MainActivity";
|
||||||
|
|
||||||
|
/** @return координатор приложения или {@code null}, если карта ещё не инициализирована */
|
||||||
|
public static AppCoordinator getAppCoordinator() {
|
||||||
|
return sAppCoordinator;
|
||||||
|
}
|
||||||
private static final int PERMISSION_REQUEST_CODE = 1001;
|
private static final int PERMISSION_REQUEST_CODE = 1001;
|
||||||
private static final int SETTINGS_REQUEST_CODE = 1002;
|
private static final int SETTINGS_REQUEST_CODE = 1002;
|
||||||
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003;
|
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003;
|
||||||
@@ -83,14 +96,19 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private SettingsManager settingsManager;
|
private SettingsManager settingsManager;
|
||||||
|
|
||||||
private ImageButton btnCenterOnVessel;
|
private ImageButton btnCenterOnVessel;
|
||||||
|
private ImageButton btnNavigatorFollow;
|
||||||
private ImageButton btnMapOrientation;
|
private ImageButton btnMapOrientation;
|
||||||
private ImageButton btnCursorToggle;
|
private ImageButton btnCursorToggle;
|
||||||
private ImageButton btnSettings;
|
private ImageButton btnSettings;
|
||||||
private ImageButton btnAisTargets;
|
private ImageButton btnAisTargets;
|
||||||
|
private ImageButton btnRadarPlotter;
|
||||||
private ImageButton btnGpsSource;
|
private ImageButton btnGpsSource;
|
||||||
private LinearLayout controlPanel;
|
private LinearLayout controlPanel;
|
||||||
private CompassView compassView;
|
private CompassView compassView;
|
||||||
private CoordinatesDockWidget coordinatesWidget;
|
private CoordinatesDockWidget coordinatesWidget;
|
||||||
|
private com.grigowashere.aismap.view.DangerTargetsDockWidget dangerWidget;
|
||||||
|
private android.widget.LinearLayout bannerConnectionLost;
|
||||||
|
private TextView tvBannerConnectionLost;
|
||||||
|
|
||||||
// Троттлинг для UI обновлений
|
// Троттлинг для UI обновлений
|
||||||
private android.os.Handler uiThrottleHandler;
|
private android.os.Handler uiThrottleHandler;
|
||||||
@@ -106,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() {
|
||||||
@@ -214,14 +238,19 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
private void initializeViews() {
|
private void initializeViews() {
|
||||||
mapView = findViewById(R.id.map_view);
|
mapView = findViewById(R.id.map_view);
|
||||||
btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
|
btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
|
||||||
|
btnNavigatorFollow = findViewById(R.id.btn_navigator_follow);
|
||||||
btnMapOrientation = findViewById(R.id.btn_map_orientation);
|
btnMapOrientation = findViewById(R.id.btn_map_orientation);
|
||||||
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
||||||
btnSettings = findViewById(R.id.btn_settings);
|
btnSettings = findViewById(R.id.btn_settings);
|
||||||
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
||||||
|
btnRadarPlotter = findViewById(R.id.btn_radar_plotter);
|
||||||
btnGpsSource = findViewById(R.id.btn_gps_source);
|
btnGpsSource = findViewById(R.id.btn_gps_source);
|
||||||
controlPanel = findViewById(R.id.control_panel);
|
controlPanel = findViewById(R.id.control_panel);
|
||||||
compassView = findViewById(R.id.compass_view);
|
compassView = findViewById(R.id.compass_view);
|
||||||
coordinatesWidget = findViewById(R.id.coordinates_widget);
|
coordinatesWidget = findViewById(R.id.coordinates_widget);
|
||||||
|
dangerWidget = findViewById(R.id.danger_targets_widget);
|
||||||
|
bannerConnectionLost = findViewById(R.id.banner_connection_lost);
|
||||||
|
tvBannerConnectionLost = findViewById(R.id.tv_banner_connection_lost);
|
||||||
installMainUiInsets();
|
installMainUiInsets();
|
||||||
|
|
||||||
// Инициализируем троттлинг
|
// Инициализируем троттлинг
|
||||||
@@ -292,7 +321,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void setupButtonListeners() {
|
private void setupButtonListeners() {
|
||||||
if (btnCenterOnVessel != null) btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
|
if (btnCenterOnVessel != null) {
|
||||||
|
btnCenterOnVessel.setOnClickListener(v -> centerOnVessel());
|
||||||
|
}
|
||||||
|
if (btnNavigatorFollow != null) {
|
||||||
|
btnNavigatorFollow.setOnClickListener(v -> toggleNavigatorCamera());
|
||||||
|
}
|
||||||
if (btnMapOrientation != null) {
|
if (btnMapOrientation != null) {
|
||||||
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
|
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
|
||||||
}
|
}
|
||||||
@@ -300,6 +334,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
||||||
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
||||||
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
||||||
|
if (btnRadarPlotter != null) btnRadarPlotter.setOnClickListener(v -> openRadarPlotter());
|
||||||
if (btnGpsSource != null) {
|
if (btnGpsSource != null) {
|
||||||
refreshGpsSourceButtonIcon();
|
refreshGpsSourceButtonIcon();
|
||||||
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
|
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
|
||||||
@@ -316,13 +351,16 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Устанавливаем начальный азимут (например, север)
|
// Устанавливаем начальный азимут (например, север)
|
||||||
compassView.setAzimuth(0);
|
compassView.setAzimuth(0);
|
||||||
|
|
||||||
// Устанавливаем компас в dock-режим вверху экрана
|
// Компас уже стартует в dock-state=true, dockTop=true (см.
|
||||||
|
// BaseDockWidget.init() + getDefaultDockTop() по умолчанию). Позиция
|
||||||
|
// задаётся layout_alignParentTop в activity_main.xml. Раньше тут стоял
|
||||||
|
// post(() -> setDocked(true, true, 0, 0)) — для top-дока он чаще всего
|
||||||
|
// был no-op'ом, но для симметрии с координатами оставляем тут только
|
||||||
|
// переприменение insets и обновление контрол-панели.
|
||||||
compassView.post(() -> {
|
compassView.post(() -> {
|
||||||
compassView.setDocked(true, true, 0, 0);
|
compassView.invalidate();
|
||||||
compassView.invalidate(); // Принудительная отрисовка
|
|
||||||
// Выровнять паддинги под статус-бар/вырез камеры сразу после
|
|
||||||
// первого dock-позиционирования (до этого сторона неизвестна).
|
|
||||||
reapplyInsetsToDocks();
|
reapplyInsetsToDocks();
|
||||||
|
updateControlPanelPosition();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Настраиваем слушатель изменения размера док-виджета
|
// Настраиваем слушатель изменения размера док-виджета
|
||||||
@@ -441,19 +479,33 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
reapplyInsetsToDocks();
|
reapplyInsetsToDocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Устанавливаем виджет координат в dock-режим внизу экрана без тестовых данных
|
// Виджет координат уже стартует в dock-state=true, dockTop=false
|
||||||
|
// (см. CoordinatesDockWidget.getDefaultDockTop() и BaseDockWidget.init()),
|
||||||
|
// а позиция определяется layout_alignParentBottom в activity_main.xml.
|
||||||
|
// Раньше тут стоял coordinatesWidget.post(() -> setDocked(true, false, 0, 0)),
|
||||||
|
// и из-за того, что post-колбэк выполнялся уже после первого layout-а,
|
||||||
|
// ранний return в setDocked не срабатывал, а calculateDockPosition в
|
||||||
|
// момент колбэка часто получал parent.getHeight() == 0 → endY уходило
|
||||||
|
// в отрицательную область, и анимация уезжала translation-ом ВВЕРХ за
|
||||||
|
// экран. Виджеты «мигали в центре, улетали вверх и появлялись снизу
|
||||||
|
// только после ресайза». Теперь мы просто переприменяем insets, чтобы
|
||||||
|
// виджет сразу получил bottom-padding под нав-бар.
|
||||||
coordinatesWidget.post(() -> {
|
coordinatesWidget.post(() -> {
|
||||||
Log.d(TAG, "Setting coordinates widget to dock mode");
|
coordinatesWidget.invalidate();
|
||||||
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
|
|
||||||
coordinatesWidget.invalidate(); // Принудительная отрисовка
|
|
||||||
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
|
|
||||||
// инсеты, чтобы виджет получил bottom padding под нав-бар
|
|
||||||
// сразу, а не только после первого ресайза пользователем.
|
|
||||||
reapplyInsetsToDocks();
|
reapplyInsetsToDocks();
|
||||||
|
updateControlPanelPosition();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -473,19 +525,30 @@ 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();
|
||||||
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
|
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
|
||||||
}
|
}
|
||||||
|
updateBleLinkLostBanner();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -494,6 +557,117 @@ 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.
|
||||||
|
* Возраст GPS/AIS здесь не используется: при обрыве линка данным доверять нельзя,
|
||||||
|
* после reconnect клиент сам запрашивает snapshot.
|
||||||
|
*/
|
||||||
|
private void updateBleLinkLostBanner() {
|
||||||
|
if (bannerConnectionLost == null || tvBannerConnectionLost == null) return;
|
||||||
|
if (appCoordinator == null) return;
|
||||||
|
boolean linkLost = appCoordinator.isBleHubLinkLost();
|
||||||
|
if (!linkLost) {
|
||||||
|
if (bannerConnectionLost.getVisibility() != View.GONE) {
|
||||||
|
bannerConnectionLost.animate().alpha(0f).setDuration(200)
|
||||||
|
.withEndAction(() -> {
|
||||||
|
bannerConnectionLost.setVisibility(View.GONE);
|
||||||
|
onBannerVisibilityChanged();
|
||||||
|
})
|
||||||
|
.start();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tvBannerConnectionLost.setText(R.string.banner_connection_lost_ble);
|
||||||
|
if (bannerConnectionLost.getVisibility() != View.VISIBLE) {
|
||||||
|
bannerConnectionLost.setAlpha(0f);
|
||||||
|
bannerConnectionLost.setVisibility(View.VISIBLE);
|
||||||
|
onBannerVisibilityChanged();
|
||||||
|
bannerConnectionLost.animate().alpha(1f).setDuration(200).start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пересчёт инсетов и layout после показа/скрытия баннера связи. */
|
||||||
|
private void onBannerVisibilityChanged() {
|
||||||
|
applyInsetsToDocks(lastSysInsets);
|
||||||
|
View root = findViewById(R.id.main_root);
|
||||||
|
if (root != null) {
|
||||||
|
root.requestLayout();
|
||||||
|
}
|
||||||
|
if (compassView != null) {
|
||||||
|
compassView.requestLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет таблицу «Ближайшие цели» каждую секунду из AppCoordinator.
|
||||||
|
* Цели берутся из жёлтой зоны предупреждения, а не из красного круга
|
||||||
|
* опасности. Если целей нет (или кольца отключены) — виджет полностью
|
||||||
|
* скрывается, чтобы не занимать место на карте.
|
||||||
|
*/
|
||||||
|
private void updateDangerWidget() {
|
||||||
|
if (dangerWidget == null || appCoordinator == null || settingsManager == null) return;
|
||||||
|
boolean enabled = settingsManager.isRangeRingsEnabled();
|
||||||
|
double nearestR = enabled ? settingsManager.getWarningRadiusMeters() : 0.0;
|
||||||
|
java.util.List<com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry> uiEntries =
|
||||||
|
new java.util.ArrayList<>();
|
||||||
|
if (enabled && nearestR > 0.0) {
|
||||||
|
java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> entries =
|
||||||
|
appCoordinator.getDangerTargets(nearestR, 5);
|
||||||
|
if (entries != null) {
|
||||||
|
for (com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry e : entries) {
|
||||||
|
if (e == null || e.vessel == null) continue;
|
||||||
|
String label = e.vessel.getVesselName();
|
||||||
|
if (label == null || label.trim().isEmpty()) {
|
||||||
|
label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—";
|
||||||
|
}
|
||||||
|
uiEntries.add(new com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry(
|
||||||
|
label, e.bearingDegrees, e.distanceMeters,
|
||||||
|
e.cpaValid, e.cpaMeters, e.tcpaMinutes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uiEntries.isEmpty()) {
|
||||||
|
if (dangerWidget.getVisibility() != View.GONE) {
|
||||||
|
dangerWidget.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
dangerWidget.setEntries(uiEntries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dangerWidget.setEntries(uiEntries);
|
||||||
|
if (dangerWidget.getVisibility() != View.VISIBLE) {
|
||||||
|
dangerWidget.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private int getAgeColor(int seconds) {
|
private int getAgeColor(int seconds) {
|
||||||
if (seconds < 0) {
|
if (seconds < 0) {
|
||||||
// Нет данных
|
// Нет данных
|
||||||
@@ -842,6 +1016,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Инициализация главного координатора
|
// Инициализация главного координатора
|
||||||
ControllersFactory controllersFactory = new DefaultControllersFactory();
|
ControllersFactory controllersFactory = new DefaultControllersFactory();
|
||||||
appCoordinator = controllersFactory.createAppCoordinator(this);
|
appCoordinator = controllersFactory.createAppCoordinator(this);
|
||||||
|
sAppCoordinator = appCoordinator;
|
||||||
|
|
||||||
// Init UI binders
|
// Init UI binders
|
||||||
menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() {
|
menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() {
|
||||||
@@ -910,6 +1085,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
refreshMapRotationButtonDescription();
|
refreshMapRotationButtonDescription();
|
||||||
|
refreshNavigatorButtonState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startControllers() {
|
private void startControllers() {
|
||||||
@@ -1007,7 +1183,30 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void centerOnVessel() {
|
private void centerOnVessel() {
|
||||||
appCoordinator.centerOnOwnVessel();
|
appCoordinator.centerOnOwnVessel();
|
||||||
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
|
if (!appCoordinator.isNavigatorCameraEnabled()) {
|
||||||
|
Toast.makeText(this, "Карта центрирована на судне", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void toggleNavigatorCamera() {
|
||||||
|
if (appCoordinator == null) return;
|
||||||
|
appCoordinator.toggleNavigatorCamera();
|
||||||
|
refreshNavigatorButtonState();
|
||||||
|
int msg = appCoordinator.isNavigatorCameraEnabled()
|
||||||
|
? R.string.main_navigator_on
|
||||||
|
: R.string.main_navigator_off;
|
||||||
|
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshNavigatorButtonState() {
|
||||||
|
if (appCoordinator == null) return;
|
||||||
|
boolean on = appCoordinator.isNavigatorCameraEnabled();
|
||||||
|
if (btnNavigatorFollow != null) {
|
||||||
|
btnNavigatorFollow.setAlpha(on ? 1f : 0.65f);
|
||||||
|
btnNavigatorFollow.setSelected(on);
|
||||||
|
btnNavigatorFollow.setContentDescription(getString(
|
||||||
|
on ? R.string.main_navigator_on : R.string.main_navigator_button));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static float normalizeBearingTo360(double deg) {
|
private static float normalizeBearingTo360(double deg) {
|
||||||
@@ -1085,6 +1284,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void applyAutoMapBearingIfNeeded(MapInterface map) {
|
private void applyAutoMapBearingIfNeeded(MapInterface map) {
|
||||||
if (settingsManager == null || appCoordinator == null || map == null) return;
|
if (settingsManager == null || appCoordinator == null || map == null) return;
|
||||||
|
// В режиме навигатора bearing сглаживает NavigatorCameraController.
|
||||||
|
if (appCoordinator.isNavigatorCameraEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String mode = settingsManager.getMapRotationMode();
|
String mode = settingsManager.getMapRotationMode();
|
||||||
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||||
return;
|
return;
|
||||||
@@ -1173,6 +1376,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openRadarPlotter() {
|
||||||
|
startActivity(new Intent(this, RadarPlotterActivity.class));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
|
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
|
||||||
* обновляет иконку кнопки и уведомляет AppCoordinator.
|
* обновляет иконку кнопки и уведомляет AppCoordinator.
|
||||||
@@ -1239,9 +1446,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
android.widget.RelativeLayout.LayoutParams lp =
|
android.widget.RelativeLayout.LayoutParams lp =
|
||||||
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||||
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
|
int dp8 = Math.round(getResources().getDisplayMetrics().density * 8);
|
||||||
int compassH = compassView != null ? compassView.getHeight() : 0;
|
int compassBottom = compassView != null ? compassView.getBottom() : 0;
|
||||||
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
|
int coordsH = coordinatesWidget != null ? coordinatesWidget.getHeight() : 0;
|
||||||
int newTop = compassH + dp8;
|
int newTop = compassBottom + dp8;
|
||||||
int newBottom = coordsH + dp8;
|
int newBottom = coordsH + dp8;
|
||||||
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
|
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
|
||||||
lp.topMargin = newTop;
|
lp.topMargin = newTop;
|
||||||
@@ -1261,16 +1468,29 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
* Боковые паддинги даём всегда (landscape-камеры).
|
* Боковые паддинги даём всегда (landscape-камеры).
|
||||||
*/
|
*/
|
||||||
private void applyInsetsToDocks(Insets sys) {
|
private void applyInsetsToDocks(Insets sys) {
|
||||||
|
boolean bannerVisible = bannerConnectionLost != null
|
||||||
|
&& bannerConnectionLost.getVisibility() == View.VISIBLE;
|
||||||
|
if (bannerConnectionLost != null) {
|
||||||
|
int bottomPad = Math.round(getResources().getDisplayMetrics().density * 10);
|
||||||
|
bannerConnectionLost.setPadding(sys.left, sys.top, sys.right, bottomPad);
|
||||||
|
}
|
||||||
if (compassView != null) {
|
if (compassView != null) {
|
||||||
boolean top = compassView.isDockTop();
|
boolean top = compassView.isDockTop();
|
||||||
compassView.setPadding(sys.left, top ? sys.top : 0,
|
// Верхний inset на компасе только когда баннера нет — иначе отступ уже в баннере.
|
||||||
sys.right, top ? 0 : sys.bottom);
|
int topPad = top && !bannerVisible ? sys.top : 0;
|
||||||
|
compassView.setPadding(sys.left, topPad, sys.right, top ? 0 : sys.bottom);
|
||||||
}
|
}
|
||||||
if (coordinatesWidget != null) {
|
if (coordinatesWidget != null) {
|
||||||
boolean top = coordinatesWidget.isDockTop();
|
boolean top = coordinatesWidget.isDockTop();
|
||||||
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
|
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
|
||||||
sys.right, top ? 0 : sys.bottom);
|
sys.right, top ? 0 : sys.bottom);
|
||||||
}
|
}
|
||||||
|
// Danger сидит МЕЖДУ компасом и координатами — статус-/нав-бар его
|
||||||
|
// не касаются, но боковые displayCutout (landscape) могут перекрыть
|
||||||
|
// текст таблицы. Так что прокидываем только left/right.
|
||||||
|
if (dangerWidget != null) {
|
||||||
|
dangerWidget.setPadding(sys.left, 0, sys.right, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
|
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
|
||||||
@@ -1429,6 +1649,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (mapInterface != null) {
|
if (mapInterface != null) {
|
||||||
// Сначала создаем UI Coordinator
|
// Сначала создаем UI Coordinator
|
||||||
uiCoordinator = new UIRenderingCoordinator(mapInterface);
|
uiCoordinator = new UIRenderingCoordinator(mapInterface);
|
||||||
|
uiCoordinator.setSettingsManager(getApplicationContext(), settingsManager);
|
||||||
Log.i(TAG, "UIRenderingCoordinator создан");
|
Log.i(TAG, "UIRenderingCoordinator создан");
|
||||||
|
|
||||||
// Подписываем UIRenderingCoordinator на изменения MapInterface
|
// Подписываем UIRenderingCoordinator на изменения MapInterface
|
||||||
@@ -1438,6 +1659,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// Устанавливаем UI Coordinator как notifier для AppCoordinator
|
// Устанавливаем UI Coordinator как notifier для AppCoordinator
|
||||||
appCoordinator.setUIDataChangeNotifier(uiCoordinator);
|
appCoordinator.setUIDataChangeNotifier(uiCoordinator);
|
||||||
Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator");
|
Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator");
|
||||||
|
|
||||||
|
appCoordinator.onMapInterfaceReady(mapInterface);
|
||||||
|
|
||||||
// AppCoordinator уже подключен к MapController при инициализации
|
// AppCoordinator уже подключен к MapController при инициализации
|
||||||
// setMapInterface больше не нужен, так как стратегия карты централизована
|
// setMapInterface больше не нужен, так как стратегия карты централизована
|
||||||
@@ -1675,7 +1898,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
if (isFinishing()) {
|
||||||
|
sAppCoordinator = null;
|
||||||
|
}
|
||||||
|
|
||||||
// MapLibre lifecycle
|
// MapLibre lifecycle
|
||||||
if (mapView != null) {
|
if (mapView != null) {
|
||||||
mapView.onDestroy();
|
mapView.onDestroy();
|
||||||
@@ -1701,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) {
|
||||||
@@ -1832,6 +2064,10 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
applySettings();
|
applySettings();
|
||||||
}
|
}
|
||||||
refreshGpsSourceButtonIcon();
|
refreshGpsSourceButtonIcon();
|
||||||
|
refreshNavigatorButtonState();
|
||||||
|
if (appCoordinator != null) {
|
||||||
|
appCoordinator.setNavigatorCameraEnabled(settingsManager.isNavigatorCameraEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Настройки применены", Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package com.grigowashere.aismap;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.ViewTreeObserver;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.controllers.AppCoordinator;
|
||||||
|
import com.grigowashere.aismap.maps.RadarMapHelper;
|
||||||
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
|
import com.grigowashere.aismap.utils.GeoUtils;
|
||||||
|
import com.grigowashere.aismap.utils.RangeMath;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
import com.grigowashere.aismap.utils.UiInsetsUtils;
|
||||||
|
import com.grigowashere.aismap.view.PlotterHeadingView;
|
||||||
|
import com.grigowashere.aismap.view.PlotterSpeedometerView;
|
||||||
|
import com.grigowashere.aismap.view.PlotterTargetsTableView;
|
||||||
|
import com.grigowashere.aismap.view.RadarGraticuleOverlay;
|
||||||
|
|
||||||
|
import org.maplibre.android.MapLibre;
|
||||||
|
import org.maplibre.android.maps.MapView;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Альтернативный UI в стиле картплоттера с PPI-радаром поверх картовых тайлов.
|
||||||
|
* Данные AIS/GPS берутся из {@link MainActivity#getAppCoordinator()}.
|
||||||
|
*/
|
||||||
|
public class RadarPlotterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final long UPDATE_INTERVAL_MS = 1000L;
|
||||||
|
private static final int TABLE_LIMIT = 8;
|
||||||
|
|
||||||
|
private View radarContentLayout;
|
||||||
|
private View radarViewportFrame;
|
||||||
|
private View radarInstrumentsPanel;
|
||||||
|
private int lastSquareLayoutContentW = -1;
|
||||||
|
private int lastSquareLayoutContentH = -1;
|
||||||
|
|
||||||
|
private final ViewTreeObserver.OnGlobalLayoutListener squareViewportLayoutListener =
|
||||||
|
this::applySquareRadarViewport;
|
||||||
|
|
||||||
|
private AppCoordinator appCoordinator;
|
||||||
|
private SettingsManager settingsManager;
|
||||||
|
private RadarMapHelper mapHelper;
|
||||||
|
private MapView mapView;
|
||||||
|
private RadarGraticuleOverlay graticuleOverlay;
|
||||||
|
private PlotterHeadingView headingView;
|
||||||
|
private PlotterSpeedometerView speedometerView;
|
||||||
|
private PlotterTargetsTableView targetsTableView;
|
||||||
|
private TextView tvRange;
|
||||||
|
|
||||||
|
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Runnable updateRunnable = this::tickUi;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
appCoordinator = MainActivity.getAppCoordinator();
|
||||||
|
if (appCoordinator == null) {
|
||||||
|
Toast.makeText(this, R.string.radar_plotter_no_coordinator, Toast.LENGTH_LONG).show();
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
MapLibre.getInstance(getApplicationContext());
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_radar_plotter);
|
||||||
|
settingsManager = new SettingsManager(this);
|
||||||
|
|
||||||
|
mapView = findViewById(R.id.radar_map_view);
|
||||||
|
graticuleOverlay = findViewById(R.id.radar_graticule);
|
||||||
|
headingView = findViewById(R.id.plotter_heading);
|
||||||
|
speedometerView = findViewById(R.id.plotter_speedometer);
|
||||||
|
targetsTableView = findViewById(R.id.plotter_targets_table);
|
||||||
|
tvRange = findViewById(R.id.tv_radar_range);
|
||||||
|
|
||||||
|
ImageButton btnBack = findViewById(R.id.btn_radar_back);
|
||||||
|
if (btnBack != null) {
|
||||||
|
btnBack.setOnClickListener(v -> finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapView != null) {
|
||||||
|
mapView.setAlpha(0.58f);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graticuleOverlay != null) {
|
||||||
|
graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit());
|
||||||
|
}
|
||||||
|
|
||||||
|
mapHelper = new RadarMapHelper(mapView);
|
||||||
|
mapHelper.initialize(() -> handler.post(this::tickUi));
|
||||||
|
|
||||||
|
applyPlotterInsets();
|
||||||
|
setupSquareRadarViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inscribes the PPI viewport in a square (max side = min(contentW, contentH)) so tall phones
|
||||||
|
* leave more room for compass, speedometer, and targets table.
|
||||||
|
*/
|
||||||
|
private void setupSquareRadarViewport() {
|
||||||
|
radarViewportFrame = findViewById(R.id.radar_viewport_frame);
|
||||||
|
radarInstrumentsPanel = findViewById(R.id.radar_instruments_panel);
|
||||||
|
radarContentLayout = findViewById(R.id.radar_plotter_content);
|
||||||
|
if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
radarContentLayout.getViewTreeObserver().addOnGlobalLayoutListener(squareViewportLayoutListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySquareRadarViewport() {
|
||||||
|
if (radarContentLayout == null || radarViewportFrame == null || radarInstrumentsPanel == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int contentW = radarContentLayout.getWidth()
|
||||||
|
- radarContentLayout.getPaddingLeft() - radarContentLayout.getPaddingRight();
|
||||||
|
int contentH = radarContentLayout.getHeight()
|
||||||
|
- radarContentLayout.getPaddingTop() - radarContentLayout.getPaddingBottom();
|
||||||
|
if (contentW <= 0 || contentH <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contentW == lastSquareLayoutContentW && contentH == lastSquareLayoutContentH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSquareLayoutContentW = contentW;
|
||||||
|
lastSquareLayoutContentH = contentH;
|
||||||
|
|
||||||
|
int squareSize = Math.min(contentW, contentH);
|
||||||
|
|
||||||
|
ViewGroup.LayoutParams vpRaw = radarViewportFrame.getLayoutParams();
|
||||||
|
ViewGroup.LayoutParams panelRaw = radarInstrumentsPanel.getLayoutParams();
|
||||||
|
if (!(vpRaw instanceof LinearLayout.LayoutParams) || !(panelRaw instanceof LinearLayout.LayoutParams)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(radarContentLayout instanceof LinearLayout)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearLayout content = (LinearLayout) radarContentLayout;
|
||||||
|
LinearLayout.LayoutParams vpLp = (LinearLayout.LayoutParams) vpRaw;
|
||||||
|
LinearLayout.LayoutParams panelLp = (LinearLayout.LayoutParams) panelRaw;
|
||||||
|
boolean vertical = content.getOrientation() == LinearLayout.VERTICAL;
|
||||||
|
|
||||||
|
if (vertical) {
|
||||||
|
if (vpLp.width == ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
&& vpLp.height == squareSize
|
||||||
|
&& vpLp.weight == 0f
|
||||||
|
&& panelLp.height == 0
|
||||||
|
&& panelLp.weight == 1f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vpLp.width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
vpLp.height = squareSize;
|
||||||
|
vpLp.weight = 0f;
|
||||||
|
panelLp.width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
panelLp.height = 0;
|
||||||
|
panelLp.weight = 1f;
|
||||||
|
} else {
|
||||||
|
if (vpLp.width == squareSize
|
||||||
|
&& vpLp.height == ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
&& vpLp.weight == 0f
|
||||||
|
&& panelLp.width == 0
|
||||||
|
&& panelLp.weight == 1f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vpLp.width = squareSize;
|
||||||
|
vpLp.height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
vpLp.weight = 0f;
|
||||||
|
panelLp.width = 0;
|
||||||
|
panelLp.height = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
panelLp.weight = 1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
radarViewportFrame.setLayoutParams(vpLp);
|
||||||
|
radarInstrumentsPanel.setLayoutParams(panelLp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyPlotterInsets() {
|
||||||
|
View panel = findViewById(R.id.radar_instruments_panel);
|
||||||
|
if (panel != null) {
|
||||||
|
int pad = Math.round(getResources().getDisplayMetrics().density * 8);
|
||||||
|
UiInsetsUtils.applySystemBarPadding(panel, pad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void tickUi() {
|
||||||
|
if (appCoordinator == null) return;
|
||||||
|
|
||||||
|
double ppiRangeM = resolvePpiRangeMeters();
|
||||||
|
double dangerM = settingsManager.isRangeRingsEnabled()
|
||||||
|
? settingsManager.getDangerRadiusMeters() : 0.0;
|
||||||
|
|
||||||
|
if (tvRange != null) {
|
||||||
|
tvRange.setText(getString(R.string.radar_plotter_range_label) + ": "
|
||||||
|
+ formatRangeLabel(ppiRangeM));
|
||||||
|
}
|
||||||
|
if (graticuleOverlay != null) {
|
||||||
|
graticuleOverlay.setRangeMeters(ppiRangeM);
|
||||||
|
graticuleOverlay.setRangeUnit(settingsManager.getRangeUnit());
|
||||||
|
}
|
||||||
|
|
||||||
|
Vessel own = appCoordinator.getOwnVessel();
|
||||||
|
float heading = 0f;
|
||||||
|
double speedKn = 0.0;
|
||||||
|
if (own != null) {
|
||||||
|
heading = (float) (own.getCourse() > 0 ? own.getCourse() : own.getHeading());
|
||||||
|
speedKn = own.getSpeed();
|
||||||
|
if (graticuleOverlay != null) {
|
||||||
|
graticuleOverlay.setHeadingUpDeg(heading);
|
||||||
|
}
|
||||||
|
if (headingView != null) {
|
||||||
|
float mag = (float) own.getMagneticCompass();
|
||||||
|
headingView.setHeading(heading, mag > 0 ? mag : Float.NaN);
|
||||||
|
}
|
||||||
|
if (speedometerView != null) {
|
||||||
|
speedometerView.setSpeedKnots(speedKn);
|
||||||
|
}
|
||||||
|
if (mapHelper != null && GeoUtils.isValidCoordinates(own.getLatitude(), own.getLongitude())) {
|
||||||
|
mapHelper.centerOnOwnShip(own.getLatitude(), own.getLongitude(), heading, ppiRangeM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AppCoordinator.DangerEntry> nearest =
|
||||||
|
appCoordinator.getDangerTargets(ppiRangeM, TABLE_LIMIT);
|
||||||
|
if (graticuleOverlay != null) {
|
||||||
|
graticuleOverlay.setAllTargetsInRange(nearest, dangerM);
|
||||||
|
}
|
||||||
|
if (targetsTableView != null) {
|
||||||
|
targetsTableView.setRowsFromCoordinatorEntries(nearest);
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.removeCallbacks(updateRunnable);
|
||||||
|
handler.postDelayed(updateRunnable, UPDATE_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double resolvePpiRangeMeters() {
|
||||||
|
if (settingsManager.isRangeFilterEnabled()) {
|
||||||
|
double f = settingsManager.getFilterRadiusMeters();
|
||||||
|
if (f > 0) return f;
|
||||||
|
}
|
||||||
|
if (settingsManager.isRangeRingsEnabled()) {
|
||||||
|
double w = settingsManager.getWarningRadiusMeters();
|
||||||
|
if (w > 0) return w;
|
||||||
|
}
|
||||||
|
return RangeMath.toMeters(5.0, settingsManager.getRangeUnit());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatRangeLabel(double meters) {
|
||||||
|
if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) {
|
||||||
|
if (meters >= 1000.0) {
|
||||||
|
return String.format(Locale.US, "%.1f km", meters / 1000.0);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.0f m", meters);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
if (mapHelper != null) mapHelper.onStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (mapHelper != null) mapHelper.onResume();
|
||||||
|
handler.post(updateRunnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onPause() {
|
||||||
|
handler.removeCallbacks(updateRunnable);
|
||||||
|
if (mapHelper != null) mapHelper.onPause();
|
||||||
|
super.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStop() {
|
||||||
|
if (mapHelper != null) mapHelper.onStop();
|
||||||
|
super.onStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
handler.removeCallbacks(updateRunnable);
|
||||||
|
if (radarContentLayout != null) {
|
||||||
|
radarContentLayout.getViewTreeObserver()
|
||||||
|
.removeOnGlobalLayoutListener(squareViewportLayoutListener);
|
||||||
|
}
|
||||||
|
if (mapHelper != null) mapHelper.onDestroy();
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSaveInstanceState(Bundle outState) {
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
if (mapHelper != null) mapHelper.onSaveInstanceState(outState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLowMemory() {
|
||||||
|
super.onLowMemory();
|
||||||
|
if (mapHelper != null) mapHelper.onLowMemory();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import com.google.android.material.switchmaterial.SwitchMaterial;
|
|||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
import com.grigowashere.aismap.utils.SettingsManager;
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
import com.grigowashere.aismap.utils.UiInsetsUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Экран настроек приложения
|
* Экран настроек приложения
|
||||||
@@ -54,6 +55,22 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
|
private com.google.android.material.textfield.TextInputLayout tilOpenInterfaces;
|
||||||
private EditText etOpenInterfaces;
|
private EditText etOpenInterfaces;
|
||||||
|
|
||||||
|
// Range rings
|
||||||
|
private SwitchMaterial switchRangeRingsEnabled;
|
||||||
|
private SwitchMaterial switchRangeFilterEnabled;
|
||||||
|
private RadioGroup radioGroupRangeUnit;
|
||||||
|
private RadioButton radioRangeUnitNm;
|
||||||
|
private RadioButton radioRangeUnitKm;
|
||||||
|
private EditText etRangeDanger;
|
||||||
|
private EditText etRangeWarning;
|
||||||
|
private EditText etRangeFilter;
|
||||||
|
|
||||||
|
// Navigator camera
|
||||||
|
private SwitchMaterial switchNavigatorCameraEnabled;
|
||||||
|
private EditText etNavigatorMaxSpeed;
|
||||||
|
private EditText etNavigatorZoomZero;
|
||||||
|
private EditText etNavigatorZoomMax;
|
||||||
|
|
||||||
// Path/prediction
|
// Path/prediction
|
||||||
private EditText etPathMaxPoints;
|
private EditText etPathMaxPoints;
|
||||||
private EditText etPathWidth;
|
private EditText etPathWidth;
|
||||||
@@ -79,7 +96,8 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_settings);
|
setContentView(R.layout.activity_settings);
|
||||||
|
applySettingsInsets();
|
||||||
|
|
||||||
// Инициализируем менеджер настроек
|
// Инициализируем менеджер настроек
|
||||||
settingsManager = new SettingsManager(this);
|
settingsManager = new SettingsManager(this);
|
||||||
|
|
||||||
@@ -97,6 +115,14 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
Log.i(TAG, "SettingsActivity создан");
|
Log.i(TAG, "SettingsActivity создан");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applySettingsInsets() {
|
||||||
|
View scroll = findViewById(R.id.settings_scroll);
|
||||||
|
if (scroll != null) {
|
||||||
|
int pad = Math.round(getResources().getDisplayMetrics().density * 16);
|
||||||
|
UiInsetsUtils.applySystemBarPadding(scroll, pad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Инициализирует UI элементы
|
* Инициализирует UI элементы
|
||||||
@@ -136,6 +162,23 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
etPredictionWidth = findViewById(R.id.et_prediction_width);
|
etPredictionWidth = findViewById(R.id.et_prediction_width);
|
||||||
etPredictionColor = findViewById(R.id.et_prediction_color);
|
etPredictionColor = findViewById(R.id.et_prediction_color);
|
||||||
etPredictionHorizon = findViewById(R.id.et_prediction_horizon_sec);
|
etPredictionHorizon = findViewById(R.id.et_prediction_horizon_sec);
|
||||||
|
|
||||||
|
// Range rings
|
||||||
|
switchRangeRingsEnabled = findViewById(R.id.switch_range_rings_enabled);
|
||||||
|
switchRangeFilterEnabled = findViewById(R.id.switch_range_filter_enabled);
|
||||||
|
radioGroupRangeUnit = findViewById(R.id.radio_group_range_unit);
|
||||||
|
radioRangeUnitNm = findViewById(R.id.radio_range_unit_nm);
|
||||||
|
radioRangeUnitKm = findViewById(R.id.radio_range_unit_km);
|
||||||
|
etRangeDanger = findViewById(R.id.et_range_danger);
|
||||||
|
etRangeWarning = findViewById(R.id.et_range_warning);
|
||||||
|
etRangeFilter = findViewById(R.id.et_range_filter);
|
||||||
|
|
||||||
|
switchNavigatorCameraEnabled = findViewById(R.id.switch_navigator_camera_enabled);
|
||||||
|
etNavigatorMaxSpeed = findViewById(R.id.et_navigator_max_speed);
|
||||||
|
etNavigatorZoomZero = findViewById(R.id.et_navigator_zoom_zero);
|
||||||
|
etNavigatorZoomMax = findViewById(R.id.et_navigator_zoom_max);
|
||||||
|
|
||||||
|
// Connection thresholds
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,7 +238,38 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
etPredictionWidth.setText(String.valueOf(settingsManager.getPredictionWidth()));
|
etPredictionWidth.setText(String.valueOf(settingsManager.getPredictionWidth()));
|
||||||
etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor())));
|
etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor())));
|
||||||
etPredictionHorizon.setText(String.valueOf(settingsManager.getPredictionHorizonSec()));
|
etPredictionHorizon.setText(String.valueOf(settingsManager.getPredictionHorizonSec()));
|
||||||
|
|
||||||
|
// Кольца дальности
|
||||||
|
if (switchRangeRingsEnabled != null) {
|
||||||
|
switchRangeRingsEnabled.setChecked(settingsManager.isRangeRingsEnabled());
|
||||||
|
}
|
||||||
|
if (switchRangeFilterEnabled != null) {
|
||||||
|
switchRangeFilterEnabled.setChecked(settingsManager.isRangeFilterEnabled());
|
||||||
|
}
|
||||||
|
if (radioRangeUnitNm != null && radioRangeUnitKm != null) {
|
||||||
|
if (SettingsManager.RANGE_UNIT_KM.equals(settingsManager.getRangeUnit())) {
|
||||||
|
radioRangeUnitKm.setChecked(true);
|
||||||
|
} else {
|
||||||
|
radioRangeUnitNm.setChecked(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (etRangeDanger != null) etRangeDanger.setText(String.valueOf(settingsManager.getRangeDanger()));
|
||||||
|
if (etRangeWarning != null) etRangeWarning.setText(String.valueOf(settingsManager.getRangeWarning()));
|
||||||
|
if (etRangeFilter != null) etRangeFilter.setText(String.valueOf(settingsManager.getRangeFilter()));
|
||||||
|
|
||||||
|
if (switchNavigatorCameraEnabled != null) {
|
||||||
|
switchNavigatorCameraEnabled.setChecked(settingsManager.isNavigatorCameraEnabled());
|
||||||
|
}
|
||||||
|
if (etNavigatorMaxSpeed != null) {
|
||||||
|
etNavigatorMaxSpeed.setText(String.valueOf(settingsManager.getNavigatorMaxSpeedKnots()));
|
||||||
|
}
|
||||||
|
if (etNavigatorZoomZero != null) {
|
||||||
|
etNavigatorZoomZero.setText(String.valueOf(settingsManager.getNavigatorZoomAtZeroSpeed()));
|
||||||
|
}
|
||||||
|
if (etNavigatorZoomMax != null) {
|
||||||
|
etNavigatorZoomMax.setText(String.valueOf(settingsManager.getNavigatorZoomAtMaxSpeed()));
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Настройки загружены в UI");
|
Log.i(TAG, "Настройки загружены в UI");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +471,16 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
try { settingsManager.setPredictionWidth(Float.parseFloat(etPredictionWidth.getText().toString().trim())); } catch (Exception ignored) {}
|
try { settingsManager.setPredictionWidth(Float.parseFloat(etPredictionWidth.getText().toString().trim())); } catch (Exception ignored) {}
|
||||||
try { settingsManager.setPredictionColor(parseColor(etPredictionColor.getText().toString().trim(), settingsManager.getPredictionColor())); } catch (Exception ignored) {}
|
try { settingsManager.setPredictionColor(parseColor(etPredictionColor.getText().toString().trim(), settingsManager.getPredictionColor())); } catch (Exception ignored) {}
|
||||||
try { settingsManager.setPredictionHorizonSec(Integer.parseInt(etPredictionHorizon.getText().toString().trim())); } catch (Exception ignored) {}
|
try { settingsManager.setPredictionHorizonSec(Integer.parseInt(etPredictionHorizon.getText().toString().trim())); } catch (Exception ignored) {}
|
||||||
|
|
||||||
|
// Кольца дальности
|
||||||
|
if (!saveRangeRingsSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveNavigatorCameraSettings()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
|
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
|
||||||
|
|
||||||
// Проверяем, нужно ли уведомить MainActivity об изменениях
|
// Проверяем, нужно ли уведомить MainActivity об изменениях
|
||||||
@@ -525,6 +608,100 @@ public class SettingsActivity extends AppCompatActivity {
|
|||||||
settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode);
|
settingsManager.shouldRestartNMEA(originalAndroidNMEAEnabled, originalUDPNMEAEnabled, originalDataMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет настройки колец дальности с валидацией danger < warning < filter.
|
||||||
|
* Возвращает {@code false}, если данные некорректны, и не закрывает экран.
|
||||||
|
*/
|
||||||
|
private boolean saveRangeRingsSettings() {
|
||||||
|
if (etRangeDanger == null || etRangeWarning == null || etRangeFilter == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
float danger = parseRange(etRangeDanger.getText().toString().trim(), settingsManager.getRangeDanger());
|
||||||
|
float warning = parseRange(etRangeWarning.getText().toString().trim(), settingsManager.getRangeWarning());
|
||||||
|
float filter = parseRange(etRangeFilter.getText().toString().trim(), settingsManager.getRangeFilter());
|
||||||
|
if (danger <= 0f || warning <= 0f || filter <= 0f) {
|
||||||
|
Toast.makeText(this, R.string.settings_range_validation_positive, Toast.LENGTH_LONG).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(danger < warning && warning < filter)) {
|
||||||
|
Toast.makeText(this, R.string.settings_range_validation_order, Toast.LENGTH_LONG).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String unit = SettingsManager.RANGE_UNIT_NM;
|
||||||
|
if (radioGroupRangeUnit != null) {
|
||||||
|
int checked = radioGroupRangeUnit.getCheckedRadioButtonId();
|
||||||
|
if (checked == R.id.radio_range_unit_km) {
|
||||||
|
unit = SettingsManager.RANGE_UNIT_KM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsManager.setRangeUnit(unit);
|
||||||
|
settingsManager.setRangeDanger(danger);
|
||||||
|
settingsManager.setRangeWarning(warning);
|
||||||
|
settingsManager.setRangeFilter(filter);
|
||||||
|
if (switchRangeRingsEnabled != null) {
|
||||||
|
settingsManager.setRangeRingsEnabled(switchRangeRingsEnabled.isChecked());
|
||||||
|
}
|
||||||
|
if (switchRangeFilterEnabled != null) {
|
||||||
|
settingsManager.setRangeFilterEnabled(switchRangeFilterEnabled.isChecked());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "saveRangeRingsSettings: " + e.getMessage(), e);
|
||||||
|
return true; // фейл валидации не должен блокировать всё сохранение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float parseRange(String text, float fallback) {
|
||||||
|
try {
|
||||||
|
if (text == null || text.isEmpty()) return fallback;
|
||||||
|
return Float.parseFloat(text.replace(',', '.'));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean saveNavigatorCameraSettings() {
|
||||||
|
try {
|
||||||
|
if (switchNavigatorCameraEnabled != null) {
|
||||||
|
settingsManager.setNavigatorCameraEnabled(switchNavigatorCameraEnabled.isChecked());
|
||||||
|
}
|
||||||
|
if (etNavigatorMaxSpeed != null) {
|
||||||
|
float maxSpeed = parseRange(
|
||||||
|
etNavigatorMaxSpeed.getText().toString().trim(),
|
||||||
|
settingsManager.getNavigatorMaxSpeedKnots());
|
||||||
|
if (maxSpeed < 1f) {
|
||||||
|
Toast.makeText(this, "Макс. скорость должна быть не меньше 1 уз", Toast.LENGTH_SHORT).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
settingsManager.setNavigatorMaxSpeedKnots(maxSpeed);
|
||||||
|
}
|
||||||
|
if (etNavigatorZoomZero != null) {
|
||||||
|
settingsManager.setNavigatorZoomAtZeroSpeed(parseRange(
|
||||||
|
etNavigatorZoomZero.getText().toString().trim(),
|
||||||
|
settingsManager.getNavigatorZoomAtZeroSpeed()));
|
||||||
|
}
|
||||||
|
if (etNavigatorZoomMax != null) {
|
||||||
|
float zoomMax = parseRange(
|
||||||
|
etNavigatorZoomMax.getText().toString().trim(),
|
||||||
|
settingsManager.getNavigatorZoomAtMaxSpeed());
|
||||||
|
float zoomZero = settingsManager.getNavigatorZoomAtZeroSpeed();
|
||||||
|
if (zoomMax >= zoomZero) {
|
||||||
|
Toast.makeText(this, "Зум при макс. скорости должен быть меньше зума при 0 уз",
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
settingsManager.setNavigatorZoomAtMaxSpeed(zoomMax);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "saveNavigatorCameraSettings: " + e.getMessage(), e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Очищает трекер пути собственного судна
|
* Очищает трекер пути собственного судна
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ public class AisHubGattClient {
|
|||||||
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
|
private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
|
||||||
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
|
private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;
|
||||||
|
|
||||||
|
/** Чтение Battery (0x180F/0x2A19) включено пользователем. По умолчанию off. */
|
||||||
|
private volatile boolean batteryReadEnabled = false;
|
||||||
|
/** Залогировали один раз, чтобы не спамить, если стек продолжает возвращать auth-error. */
|
||||||
|
private volatile boolean authWarningLogged = false;
|
||||||
|
|
||||||
public AisHubGattClient(@NonNull Context context) {
|
public AisHubGattClient(@NonNull Context context) {
|
||||||
this.appContext = context.getApplicationContext();
|
this.appContext = context.getApplicationContext();
|
||||||
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||||
@@ -118,10 +123,32 @@ public class AisHubGattClient {
|
|||||||
this.deviceMac = mac;
|
this.deviceMac = mac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Включает/выключает периодическое чтение характеристики Battery
|
||||||
|
* ({@code 0x180F/0x2A19}). На некоторых устройствах эта характеристика
|
||||||
|
* требует шифрования, что приводит к появлению системного диалога
|
||||||
|
* сопряжения. По умолчанию выключено.
|
||||||
|
*/
|
||||||
|
public void setBatteryReadEnabled(boolean enabled) {
|
||||||
|
this.batteryReadEnabled = enabled;
|
||||||
|
if (!enabled) {
|
||||||
|
batteryLoop.set(false);
|
||||||
|
if (batteryTask != null) {
|
||||||
|
try { batteryTask.cancel(false); } catch (Throwable ignore) {}
|
||||||
|
batteryTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isRunning() {
|
public boolean isRunning() {
|
||||||
return running.get();
|
return running.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** {@code true}, если GATT-сессия в состоянии {@link BluetoothProfile#STATE_CONNECTED}. */
|
||||||
|
public boolean isConnected() {
|
||||||
|
return connected;
|
||||||
|
}
|
||||||
|
|
||||||
public void start() {
|
public void start() {
|
||||||
if (running.get()) {
|
if (running.get()) {
|
||||||
Log.w(TAG, "AIS Hub GATT already running");
|
Log.w(TAG, "AIS Hub GATT already running");
|
||||||
@@ -175,7 +202,12 @@ public class AisHubGattClient {
|
|||||||
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
||||||
if (!running.get()) return;
|
if (!running.get()) return;
|
||||||
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
|
if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
|
if (isAuthStatus(status)) {
|
||||||
|
logAuthOnce("onConnectionStateChange status=" + status);
|
||||||
|
// ВАЖНО: BLE — основной источник данных. Auth-статус мы НЕ считаем
|
||||||
|
// фатальным: продолжаем reconnect-цикл, а проблемные операции
|
||||||
|
// (read battery и т.п.) сами по себе уже отключены/опциональны.
|
||||||
|
} else if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
|
||||||
postError("BLE connect status: " + status);
|
postError("BLE connect status: " + status);
|
||||||
} else if (status == 133) {
|
} else if (status == 133) {
|
||||||
lastErrorWasDbFull = true;
|
lastErrorWasDbFull = true;
|
||||||
@@ -197,6 +229,7 @@ public class AisHubGattClient {
|
|||||||
connectionStartTimeMs = 0L;
|
connectionStartTimeMs = 0L;
|
||||||
isConnecting.set(false);
|
isConnecting.set(false);
|
||||||
lastErrorWasDbFull = false;
|
lastErrorWasDbFull = false;
|
||||||
|
authWarningLogged = false;
|
||||||
reconnectLoop.set(false);
|
reconnectLoop.set(false);
|
||||||
notifReady.set(false);
|
notifReady.set(false);
|
||||||
mtuRequested.set(false);
|
mtuRequested.set(false);
|
||||||
@@ -314,13 +347,24 @@ public class AisHubGattClient {
|
|||||||
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
|
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
|
||||||
gattBusy.set(false);
|
gattBusy.set(false);
|
||||||
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
|
if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
|
||||||
|
if (isAuthStatus(st)) {
|
||||||
|
// Не валим соединение и не показываем плашку — просто логируем
|
||||||
|
// один раз и пропускаем эту необязательную операцию (CCCD
|
||||||
|
// на доп.характеристиках, например STATUS).
|
||||||
|
logAuthOnce("onDescriptorWrite status=" + st + " uuid=" + descriptor.getUuid());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
|
if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
|
||||||
notifReady.set(true);
|
notifReady.set(true);
|
||||||
lastDataAtMs = System.currentTimeMillis();
|
lastDataAtMs = System.currentTimeMillis();
|
||||||
postState("notifying");
|
postState("notifying");
|
||||||
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
|
if (batteryReadEnabled) {
|
||||||
readBatteryOnce(g);
|
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
|
||||||
startBatteryLoop(g);
|
readBatteryOnce(g);
|
||||||
|
startBatteryLoop(g);
|
||||||
|
} else if (BLE_LOG) {
|
||||||
|
Log.d(TAG, "Battery read disabled by settings (skipping resolve/read/loop)");
|
||||||
|
}
|
||||||
enqueueControlJson(buildHello());
|
enqueueControlJson(buildHello());
|
||||||
|
|
||||||
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
|
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
|
||||||
@@ -358,6 +402,17 @@ public class AisHubGattClient {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||||
|
if (isAuthStatus(status)) {
|
||||||
|
gattBusy.set(false);
|
||||||
|
logAuthOnce("onCharacteristicRead status=" + status + " uuid=" + ch.getUuid());
|
||||||
|
// Если это была попытка чтения Battery — на всякий случай гасим
|
||||||
|
// её, чтобы не провоцировать повторный системный диалог.
|
||||||
|
if (BATTERY_LEVEL.equals(ch.getUuid())
|
||||||
|
|| (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
||||||
|
setBatteryReadEnabled(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
||||||
byte[] v = ch.getValue();
|
byte[] v = ch.getValue();
|
||||||
@@ -371,6 +426,13 @@ public class AisHubGattClient {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
|
||||||
|
if (isAuthStatus(status)) {
|
||||||
|
gattBusy.set(false);
|
||||||
|
logAuthOnce("onCharacteristicWrite status=" + status + " uuid=" + ch.getUuid());
|
||||||
|
// Просто допускаем дренаж очереди дальше — соединение НЕ рвём.
|
||||||
|
mainHandler.post(AisHubGattClient.this::drainControlQueue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
|
if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
|
||||||
gattBusy.set(false);
|
gattBusy.set(false);
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
@@ -778,6 +840,31 @@ public class AisHubGattClient {
|
|||||||
}, 2000, 10_000, TimeUnit.MILLISECONDS);
|
}, 2000, 10_000, TimeUnit.MILLISECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает {@code true} для GATT-статусов, требующих сопряжения:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code 5} — {@code GATT_INSUF_AUTHENTICATION}</li>
|
||||||
|
* <li>{@code 8} — {@code GATT_INSUF_ENCRYPTION}</li>
|
||||||
|
* <li>{@code 137} — {@code GATT_AUTH_FAIL}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
private static boolean isAuthStatus(int status) {
|
||||||
|
return status == 5 || status == 8 || status == 137;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Лог auth-status. Один раз пишем в логи, чтобы не спамить, но соединение
|
||||||
|
* НЕ рвём и reconnect-loop не глушим — BLE является основным источником
|
||||||
|
* данных, отключать его по auth-ошибке нельзя. Само сопряжение — это
|
||||||
|
* системный диалог Android, который пользователь может проигнорировать.
|
||||||
|
*/
|
||||||
|
private void logAuthOnce(String reason) {
|
||||||
|
if (authWarningLogged) return;
|
||||||
|
authWarningLogged = true;
|
||||||
|
Log.w(TAG, "BLE auth status (suppressed, connection kept): " + reason);
|
||||||
|
LogSender.logBLEError("auth status (suppressed): " + reason, deviceMac, "AisHub");
|
||||||
|
}
|
||||||
|
|
||||||
private void postState(String s) {
|
private void postState(String s) {
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
mainHandler.post(() -> callback.onState(s));
|
mainHandler.post(() -> callback.onState(s));
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class AppCoordinator implements
|
|||||||
private NotificationController notificationController;
|
private NotificationController notificationController;
|
||||||
private CompassController compassController;
|
private CompassController compassController;
|
||||||
private MapController mapController;
|
private MapController mapController;
|
||||||
|
private NavigatorCameraController navigatorCameraController;
|
||||||
private AisHubGattClient aisHubGattClient;
|
private AisHubGattClient aisHubGattClient;
|
||||||
|
|
||||||
// Состояние приложения
|
// Состояние приложения
|
||||||
@@ -108,6 +109,7 @@ public class AppCoordinator implements
|
|||||||
this.aisPathControllers = new HashMap<>();
|
this.aisPathControllers = new HashMap<>();
|
||||||
this.settingsManager = new SettingsManager(context);
|
this.settingsManager = new SettingsManager(context);
|
||||||
this.pathController = new VesselPathController(context, settingsManager);
|
this.pathController = new VesselPathController(context, settingsManager);
|
||||||
|
this.navigatorCameraController = new NavigatorCameraController(settingsManager);
|
||||||
this.uiHandler = new Handler(Looper.getMainLooper());
|
this.uiHandler = new Handler(Looper.getMainLooper());
|
||||||
|
|
||||||
initializeControllers();
|
initializeControllers();
|
||||||
@@ -193,6 +195,39 @@ public class AppCoordinator implements
|
|||||||
if (mapController != null) {
|
if (mapController != null) {
|
||||||
mapController.addMapInterfaceChangeListener(this);
|
mapController.addMapInterfaceChangeListener(this);
|
||||||
Log.i(TAG, "AppCoordinator подключен к MapController");
|
Log.i(TAG, "AppCoordinator подключен к MapController");
|
||||||
|
syncNavigatorMapInterface();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public NavigatorCameraController getNavigatorCameraController() {
|
||||||
|
return navigatorCameraController;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNavigatorCameraEnabled() {
|
||||||
|
return navigatorCameraController != null && navigatorCameraController.isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorCameraEnabled(boolean enabled) {
|
||||||
|
if (navigatorCameraController == null) return;
|
||||||
|
navigatorCameraController.setEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
syncNavigatorMapInterface();
|
||||||
|
updateNavigatorCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggleNavigatorCamera() {
|
||||||
|
setNavigatorCameraEnabled(!isNavigatorCameraEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается из MainActivity после инициализации карты — подключает навигатор к MapInterface.
|
||||||
|
*/
|
||||||
|
public void onMapInterfaceReady(MapInterface mapInterface) {
|
||||||
|
if (navigatorCameraController == null) return;
|
||||||
|
navigatorCameraController.setMapInterface(mapInterface);
|
||||||
|
if (navigatorCameraController.isEnabled()) {
|
||||||
|
updateNavigatorCamera();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +251,12 @@ public class AppCoordinator implements
|
|||||||
networkController.startUDPListener();
|
networkController.startUDPListener();
|
||||||
compassController.startCompass();
|
compassController.startCompass();
|
||||||
dataController.startDatabaseCleanup();
|
dataController.startDatabaseCleanup();
|
||||||
// BLE старт по настройкам
|
// BLE: применяем настройки (MAC + batteryRead) до старта клиента,
|
||||||
|
// иначе клиент стартует «голым» и любые опциональные операции
|
||||||
|
// (например, чтение Battery 0x2A19) могут спровоцировать системный
|
||||||
|
// диалог сопряжения. Конфигурация ставит batteryReadEnabled=false
|
||||||
|
// по умолчанию — это убирает основной триггер pairing.
|
||||||
|
configureBleFromSettings();
|
||||||
tryStartBleIfEnabled();
|
tryStartBleIfEnabled();
|
||||||
|
|
||||||
// Восстанавливаем данные из БД
|
// Восстанавливаем данные из БД
|
||||||
@@ -266,7 +306,9 @@ public class AppCoordinator implements
|
|||||||
if (aisHubGattClient == null) return;
|
if (aisHubGattClient == null) return;
|
||||||
String mac = settingsManager.getBLEDeviceMac();
|
String mac = settingsManager.getBLEDeviceMac();
|
||||||
aisHubGattClient.setDeviceMac(mac);
|
aisHubGattClient.setDeviceMac(mac);
|
||||||
Log.i(TAG, "BLE AIS Hub: mac=" + mac);
|
boolean batteryEnabled = settingsManager.isBleReadBatteryEnabled();
|
||||||
|
aisHubGattClient.setBatteryReadEnabled(batteryEnabled);
|
||||||
|
Log.i(TAG, "BLE AIS Hub: mac=" + mac + " batteryRead=" + batteryEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tryStartBleIfEnabled() {
|
private void tryStartBleIfEnabled() {
|
||||||
@@ -476,16 +518,56 @@ public class AppCoordinator implements
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final List<AISVessel> copy = new ArrayList<>(vessels);
|
final List<AISVessel> copy = new ArrayList<>(vessels);
|
||||||
for (int start = 0; start < copy.size(); start += AIS_UI_BATCH_SIZE) {
|
// Если включён фильтр-круг — отбрасываем цели за его пределами и
|
||||||
|
// одновременно «снимаем» их с карты (publishAisRemovalsToUiBatched).
|
||||||
|
final boolean filterEnabled = settingsManager != null
|
||||||
|
&& settingsManager.isRangeFilterEnabled();
|
||||||
|
final double filterRadiusM = filterEnabled
|
||||||
|
? settingsManager.getFilterRadiusMeters()
|
||||||
|
: Double.POSITIVE_INFINITY;
|
||||||
|
final boolean ownValid = ownVessel != null
|
||||||
|
&& com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(
|
||||||
|
ownVessel.getLatitude(), ownVessel.getLongitude());
|
||||||
|
final List<AISVessel> inRange;
|
||||||
|
final List<String> filteredOutMmsis;
|
||||||
|
if (filterEnabled && ownValid && filterRadiusM > 0.0
|
||||||
|
&& filterRadiusM != Double.POSITIVE_INFINITY) {
|
||||||
|
inRange = new ArrayList<>(copy.size());
|
||||||
|
filteredOutMmsis = new ArrayList<>();
|
||||||
|
for (AISVessel v : copy) {
|
||||||
|
if (v == null) continue;
|
||||||
|
if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(
|
||||||
|
v.getLatitude(), v.getLongitude())) {
|
||||||
|
inRange.add(v);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(
|
||||||
|
ownVessel.getLatitude(), ownVessel.getLongitude(),
|
||||||
|
v.getLatitude(), v.getLongitude());
|
||||||
|
if (d <= filterRadiusM) {
|
||||||
|
inRange.add(v);
|
||||||
|
} else if (v.getMmsi() != null) {
|
||||||
|
filteredOutMmsis.add(v.getMmsi());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
inRange = copy;
|
||||||
|
filteredOutMmsis = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int start = 0; start < inRange.size(); start += AIS_UI_BATCH_SIZE) {
|
||||||
final int from = start;
|
final int from = start;
|
||||||
final int to = Math.min(start + AIS_UI_BATCH_SIZE, copy.size());
|
final int to = Math.min(start + AIS_UI_BATCH_SIZE, inRange.size());
|
||||||
uiHandler.post(() -> {
|
uiHandler.post(() -> {
|
||||||
if (uiDataNotifier == null) return;
|
if (uiDataNotifier == null) return;
|
||||||
for (int i = from; i < to; i++) {
|
for (int i = from; i < to; i++) {
|
||||||
uiDataNotifier.onAISVesselChanged(copy.get(i));
|
uiDataNotifier.onAISVesselChanged(inRange.get(i));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (filteredOutMmsis != null && !filteredOutMmsis.isEmpty()) {
|
||||||
|
publishAisRemovalsToUiBatched(filteredOutMmsis);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void publishAisRemovalsToUiBatched(List<String> mmsis) {
|
private void publishAisRemovalsToUiBatched(List<String> mmsis) {
|
||||||
@@ -559,6 +641,8 @@ public class AppCoordinator implements
|
|||||||
" mode=" + settingsManager.getDataMode());
|
" mode=" + settingsManager.getDataMode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateNavigatorCamera();
|
||||||
|
|
||||||
// Важно: ownship.update может приходить очень часто (десятки раз в секунду).
|
// Важно: ownship.update может приходить очень часто (десятки раз в секунду).
|
||||||
// Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) — с throttling,
|
// Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) — с throttling,
|
||||||
// чтобы не забивать главный поток и не провоцировать нестабильность BLE.
|
// чтобы не забивать главный поток и не провоцировать нестабильность BLE.
|
||||||
@@ -674,6 +758,7 @@ public class AppCoordinator implements
|
|||||||
ownVessel.setActiveSatellites(vessel.getActiveSatellites());
|
ownVessel.setActiveSatellites(vessel.getActiveSatellites());
|
||||||
|
|
||||||
markRecentGpsActivity();
|
markRecentGpsActivity();
|
||||||
|
updateNavigatorCamera();
|
||||||
if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) {
|
if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) {
|
||||||
pathController.addPathPoint(
|
pathController.addPathPoint(
|
||||||
ownVessel.getLongitude(),
|
ownVessel.getLongitude(),
|
||||||
@@ -729,6 +814,7 @@ public class AppCoordinator implements
|
|||||||
|
|
||||||
// Обновляем компас после изменения курса
|
// Обновляем компас после изменения курса
|
||||||
updateCompass();
|
updateCompass();
|
||||||
|
updateNavigatorCamera();
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
dataController.saveVesselPosition(ownVessel);
|
dataController.saveVesselPosition(ownVessel);
|
||||||
@@ -1055,7 +1141,8 @@ public class AppCoordinator implements
|
|||||||
// Устанавливаем MarkerClickListener на новую карту
|
// Устанавливаем MarkerClickListener на новую карту
|
||||||
newMapInterface.setMarkerClickListener(this);
|
newMapInterface.setMarkerClickListener(this);
|
||||||
Log.i(TAG, "MarkerClickListener установлен на новую карту");
|
Log.i(TAG, "MarkerClickListener установлен на новую карту");
|
||||||
|
syncNavigatorMapInterface();
|
||||||
|
updateNavigatorCamera();
|
||||||
// Восстанавливаем состояние на новой карте
|
// Восстанавливаем состояние на новой карте
|
||||||
restoreMapStateOnNewInterface();
|
restoreMapStateOnNewInterface();
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1254,89 @@ public class AppCoordinator implements
|
|||||||
|
|
||||||
return nearby;
|
return nearby;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контейнер «цель + дистанция/пеленг до собственного судна».
|
||||||
|
* Используется виджетом ближайших целей в зоне опасности.
|
||||||
|
*/
|
||||||
|
public static final class DangerEntry {
|
||||||
|
public final AISVessel vessel;
|
||||||
|
/** Дистанция в метрах. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает цели в зоне опасности (по {@code maxRadiusMeters}) с
|
||||||
|
* дистанцией и пеленгом, отсортированные по возрастанию дистанции.
|
||||||
|
* Если собственная позиция неизвестна — возвращает пустой список.
|
||||||
|
*
|
||||||
|
* @param maxRadiusMeters максимальный радиус, м
|
||||||
|
* @param limit максимальное число записей (>=1)
|
||||||
|
*/
|
||||||
|
public List<DangerEntry> getDangerTargets(double maxRadiusMeters, int limit) {
|
||||||
|
List<DangerEntry> result = new ArrayList<>();
|
||||||
|
if (!(maxRadiusMeters > 0.0)) return result;
|
||||||
|
if (ownVessel == null) return result;
|
||||||
|
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()) {
|
||||||
|
if (vessel == null) continue;
|
||||||
|
double lat = vessel.getLatitude();
|
||||||
|
double lon = vessel.getLongitude();
|
||||||
|
if (!com.grigowashere.aismap.utils.GeoUtils.isValidCoordinates(lat, lon)) continue;
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает {@code ownVessel} (может быть {@code null} до первой фиксации). */
|
||||||
|
public Vessel getOwnVesselSnapshot() {
|
||||||
|
return ownVessel;
|
||||||
|
}
|
||||||
|
|
||||||
private void updateCompass() {
|
private void updateCompass() {
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
@@ -1347,21 +1517,52 @@ public class AppCoordinator implements
|
|||||||
public Integer getLastBleBattery() {
|
public Integer getLastBleBattery() {
|
||||||
return lastBleBattery;
|
return lastBleBattery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code true}, если BLE включён, MAC задан, клиент запущен, но GATT не подключён
|
||||||
|
* (в т.ч. идёт переподключение). В этом режиме данным с хаба доверять нельзя.
|
||||||
|
*/
|
||||||
|
public boolean isBleHubLinkLost() {
|
||||||
|
if (!settingsManager.isBLEEnabled()) return false;
|
||||||
|
String mac = settingsManager.getBLEDeviceMac();
|
||||||
|
if (mac == null || mac.trim().isEmpty()) return false;
|
||||||
|
if (aisHubGattClient == null || !aisHubGattClient.isRunning()) return false;
|
||||||
|
return !aisHubGattClient.isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Центрирует карту на позиции нашего судна
|
* Центрирует карту на позиции нашего судна
|
||||||
*/
|
*/
|
||||||
public void centerOnOwnVessel() {
|
public void centerOnOwnVessel() {
|
||||||
if (ownVessel != null) {
|
if (ownVessel == null) return;
|
||||||
Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
|
Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
|
||||||
|
|
||||||
// Уведомляем UI Coordinator о необходимости центрирования карты
|
syncNavigatorMapInterface();
|
||||||
if (uiDataNotifier != null) {
|
if (navigatorCameraController != null && navigatorCameraController.isEnabled()) {
|
||||||
uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude());
|
navigatorCameraController.onOwnVesselUpdated(ownVessel);
|
||||||
} else {
|
return;
|
||||||
Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (navigatorCameraController != null && mapController != null
|
||||||
|
&& mapController.getCurrentMapInterface() != null) {
|
||||||
|
navigatorCameraController.centerOnOwnVesselNow(ownVessel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uiDataNotifier != null) {
|
||||||
|
uiDataNotifier.onRequestCenterMap(ownVessel.getLatitude(), ownVessel.getLongitude());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "uiDataNotifier не установлен, центрирование карты пропущено");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncNavigatorMapInterface() {
|
||||||
|
if (navigatorCameraController == null || mapController == null) return;
|
||||||
|
navigatorCameraController.setMapInterface(mapController.getCurrentMapInterface());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateNavigatorCamera() {
|
||||||
|
if (navigatorCameraController == null || !navigatorCameraController.isEnabled()) return;
|
||||||
|
syncNavigatorMapInterface();
|
||||||
|
navigatorCameraController.onOwnVesselUpdated(ownVessel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package com.grigowashere.aismap.controllers;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.maps.MapInterface;
|
||||||
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
|
import com.grigowashere.aismap.utils.GeoUtils;
|
||||||
|
import com.grigowashere.aismap.utils.NavigatorZoomMath;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Следит за собственным судном: центр карты на позиции, зум от скорости,
|
||||||
|
* плавные переходы камеры и bearing (если включён режим компас/курс).
|
||||||
|
*/
|
||||||
|
public class NavigatorCameraController {
|
||||||
|
|
||||||
|
private static final String TAG = "NavigatorCamera";
|
||||||
|
private static final long FRAME_MS = 16L;
|
||||||
|
/** Пауза следования после последнего жеста пользователя на карте. */
|
||||||
|
public static final long USER_OVERRIDE_RESUME_MS = 5000L;
|
||||||
|
private static final float POSITION_ALPHA = 0.20f;
|
||||||
|
private static final float ZOOM_ALPHA = 0.14f;
|
||||||
|
/** Меньше alpha — плавнее поворот карты с небольшой задержкой. */
|
||||||
|
private static final float BEARING_ALPHA = 0.09f;
|
||||||
|
|
||||||
|
private final SettingsManager settingsManager;
|
||||||
|
private final Handler handler;
|
||||||
|
|
||||||
|
private MapInterface map;
|
||||||
|
private Vessel lastVessel;
|
||||||
|
private boolean followLoopRunning;
|
||||||
|
private boolean userOverrideActive;
|
||||||
|
private long lastUserInteractionUptimeMs;
|
||||||
|
|
||||||
|
private double targetLat = Double.NaN;
|
||||||
|
private double targetLon = Double.NaN;
|
||||||
|
private float targetZoom = 14f;
|
||||||
|
|
||||||
|
private final Runnable followLoopRunnable = this::onFollowFrame;
|
||||||
|
private final Runnable overrideResumeRunnable = this::onOverrideResumeTimeout;
|
||||||
|
private final MapInterface.MapUserInteractionListener userInteractionListener =
|
||||||
|
this::onUserMapInteraction;
|
||||||
|
|
||||||
|
public NavigatorCameraController(SettingsManager settingsManager) {
|
||||||
|
this.settingsManager = settingsManager;
|
||||||
|
this.handler = new Handler(Looper.getMainLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMapInterface(MapInterface map) {
|
||||||
|
if (this.map != null) {
|
||||||
|
this.map.setMapUserInteractionListener(null);
|
||||||
|
}
|
||||||
|
this.map = map;
|
||||||
|
if (map != null) {
|
||||||
|
map.setMapUserInteractionListener(userInteractionListener);
|
||||||
|
}
|
||||||
|
if (isEnabled()) {
|
||||||
|
ensureFollowLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUserOverrideActive() {
|
||||||
|
return userOverrideActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пользователь сдвинул/масштабировал/повернул карту — временно отключаем авто-камеру.
|
||||||
|
*/
|
||||||
|
public void onUserMapInteraction() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUserInteractionUptimeMs = SystemClock.uptimeMillis();
|
||||||
|
if (!userOverrideActive) {
|
||||||
|
userOverrideActive = true;
|
||||||
|
Log.d(TAG, "Follow paused: user map interaction");
|
||||||
|
}
|
||||||
|
scheduleOverrideResumeCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return settingsManager != null && settingsManager.isNavigatorCameraEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
if (settingsManager == null) return;
|
||||||
|
settingsManager.setNavigatorCameraEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
ensureFollowLoop();
|
||||||
|
if (lastVessel != null) {
|
||||||
|
applyTargetsFromVessel(lastVessel);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearUserOverride();
|
||||||
|
stopFollowLoop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вызывается при каждом обновлении координат/скорости собственного судна.
|
||||||
|
*/
|
||||||
|
public void onOwnVesselUpdated(Vessel vessel) {
|
||||||
|
if (!isEnabled() || vessel == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastVessel = vessel;
|
||||||
|
applyTargetsFromVessel(vessel);
|
||||||
|
if (map == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureFollowLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Однократное центрирование с зумом по скорости (кнопка «на судно»).
|
||||||
|
*/
|
||||||
|
public void centerOnOwnVesselNow(Vessel vessel) {
|
||||||
|
if (vessel == null || map == null) return;
|
||||||
|
lastVessel = vessel;
|
||||||
|
double lat = vessel.getLatitude();
|
||||||
|
double lon = vessel.getLongitude();
|
||||||
|
if (!GeoUtils.isValidCoordinates(lat, lon)) return;
|
||||||
|
|
||||||
|
float zoom = zoomForVessel(vessel);
|
||||||
|
long duration = settingsManager.getNavigatorCameraTransitionMs();
|
||||||
|
float bearing = resolveTargetBearing(vessel);
|
||||||
|
if (Float.isNaN(bearing)) {
|
||||||
|
bearing = map.getBearing();
|
||||||
|
}
|
||||||
|
map.moveCameraSmooth(lat, lon, zoom, duration);
|
||||||
|
targetLat = lat;
|
||||||
|
targetLon = lon;
|
||||||
|
targetZoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyTargetsFromVessel(Vessel vessel) {
|
||||||
|
targetLat = vessel.getLatitude();
|
||||||
|
targetLon = vessel.getLongitude();
|
||||||
|
targetZoom = zoomForVessel(vessel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float zoomForVessel(Vessel vessel) {
|
||||||
|
double speed = vessel != null ? vessel.getSpeed() : 0.0;
|
||||||
|
return NavigatorZoomMath.zoomForSpeed(
|
||||||
|
speed,
|
||||||
|
settingsManager.getNavigatorZoomAtZeroSpeed(),
|
||||||
|
settingsManager.getNavigatorZoomAtMaxSpeed(),
|
||||||
|
settingsManager.getNavigatorMaxSpeedKnots());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureFollowLoop() {
|
||||||
|
if (!isEnabled() || followLoopRunning) return;
|
||||||
|
followLoopRunning = true;
|
||||||
|
handler.post(followLoopRunnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopFollowLoop() {
|
||||||
|
followLoopRunning = false;
|
||||||
|
handler.removeCallbacks(followLoopRunnable);
|
||||||
|
handler.removeCallbacks(overrideResumeRunnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearUserOverride() {
|
||||||
|
userOverrideActive = false;
|
||||||
|
handler.removeCallbacks(overrideResumeRunnable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleOverrideResumeCheck() {
|
||||||
|
handler.removeCallbacks(overrideResumeRunnable);
|
||||||
|
handler.postDelayed(overrideResumeRunnable, USER_OVERRIDE_RESUME_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onOverrideResumeTimeout() {
|
||||||
|
if (!isEnabled() || !userOverrideActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long idle = SystemClock.uptimeMillis() - lastUserInteractionUptimeMs;
|
||||||
|
if (idle < USER_OVERRIDE_RESUME_MS) {
|
||||||
|
scheduleOverrideResumeCheck();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userOverrideActive = false;
|
||||||
|
Log.d(TAG, "Follow resumed after user idle");
|
||||||
|
if (lastVessel != null) {
|
||||||
|
applyTargetsFromVessel(lastVessel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onFollowFrame() {
|
||||||
|
if (!isEnabled() || map == null) {
|
||||||
|
followLoopRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userOverrideActive) {
|
||||||
|
handler.postDelayed(followLoopRunnable, FRAME_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!GeoUtils.isValidCoordinates(targetLat, targetLon)) {
|
||||||
|
handler.postDelayed(followLoopRunnable, FRAME_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double curLat = map.getCenterLatitude();
|
||||||
|
double curLon = map.getCenterLongitude();
|
||||||
|
float curZoom = map.getZoom();
|
||||||
|
float curBearing = map.getBearing();
|
||||||
|
|
||||||
|
if (!GeoUtils.isValidCoordinates(curLat, curLon)) {
|
||||||
|
curLat = targetLat;
|
||||||
|
curLon = targetLon;
|
||||||
|
curZoom = targetZoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
double newLat = NavigatorZoomMath.lerp(curLat, targetLat, POSITION_ALPHA);
|
||||||
|
double newLon = NavigatorZoomMath.lerp(curLon, targetLon, POSITION_ALPHA);
|
||||||
|
float newZoom = NavigatorZoomMath.lerp(curZoom, targetZoom, ZOOM_ALPHA);
|
||||||
|
|
||||||
|
float bearingArg = Float.NaN;
|
||||||
|
float targetBearing = resolveTargetBearing(lastVessel);
|
||||||
|
if (!Float.isNaN(targetBearing)) {
|
||||||
|
bearingArg = NavigatorZoomMath.lerpBearing(curBearing, targetBearing, BEARING_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
map.setCameraView(newLat, newLon, newZoom, bearingArg);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "onFollowFrame: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.postDelayed(followLoopRunnable, FRAME_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearing для карты по настройке вращения; {@link Float#NaN} — не менять (ручной режим).
|
||||||
|
*/
|
||||||
|
float resolveTargetBearing(Vessel vessel) {
|
||||||
|
if (settingsManager == null || vessel == null) {
|
||||||
|
return Float.NaN;
|
||||||
|
}
|
||||||
|
String mode = settingsManager.getMapRotationMode();
|
||||||
|
if (SettingsManager.MAP_ROTATION_COMPASS.equals(mode)) {
|
||||||
|
double c = vessel.getMagneticCompass();
|
||||||
|
if (!Double.isNaN(c)) {
|
||||||
|
return NavigatorZoomMath.normalizeBearing360((float) c);
|
||||||
|
}
|
||||||
|
} else if (SettingsManager.MAP_ROTATION_COURSE.equals(mode)) {
|
||||||
|
double cog = vessel.getCourse();
|
||||||
|
if (!Double.isNaN(cog)) {
|
||||||
|
return NavigatorZoomMath.normalizeBearing360((float) cog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Float.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плавный переход к цели за фиксированное время (разовые вызовы).
|
||||||
|
*/
|
||||||
|
public static void runSmoothTransition(MapInterface map,
|
||||||
|
double fromLat, double fromLon, float fromZoom,
|
||||||
|
double toLat, double toLon, float toZoom,
|
||||||
|
long durationMs,
|
||||||
|
Handler handler,
|
||||||
|
Runnable onComplete) {
|
||||||
|
if (map == null || handler == null) return;
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
map.setCameraView(toLat, toLon, toZoom, Float.NaN);
|
||||||
|
if (onComplete != null) onComplete.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final long startMs = SystemClock.uptimeMillis();
|
||||||
|
final float fromBearing = map.getBearing();
|
||||||
|
Runnable frame = new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
float t = (SystemClock.uptimeMillis() - startMs) / (float) durationMs;
|
||||||
|
if (t >= 1f) t = 1f;
|
||||||
|
float eased = NavigatorZoomMath.easeOutCubic(t);
|
||||||
|
double lat = NavigatorZoomMath.lerp(fromLat, toLat, eased);
|
||||||
|
double lon = NavigatorZoomMath.lerp(fromLon, toLon, eased);
|
||||||
|
float zoom = NavigatorZoomMath.lerp(fromZoom, toZoom, eased);
|
||||||
|
try {
|
||||||
|
map.setCameraView(lat, lon, zoom, Float.NaN);
|
||||||
|
} catch (Exception ignore) { }
|
||||||
|
if (t < 1f) {
|
||||||
|
handler.postDelayed(this, FRAME_MS);
|
||||||
|
} else if (onComplete != null) {
|
||||||
|
onComplete.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handler.post(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ package com.grigowashere.aismap.maps;
|
|||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.controllers.NavigatorCameraController;
|
||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
import com.grigowashere.aismap.view.CursorOverlay;
|
import com.grigowashere.aismap.view.CursorOverlay;
|
||||||
@@ -33,6 +37,8 @@ public class MapForgeImpl implements MapInterface {
|
|||||||
private Marker ownVesselMarker;
|
private Marker ownVesselMarker;
|
||||||
private CursorOverlay cursorOverlay;
|
private CursorOverlay cursorOverlay;
|
||||||
private Vessel ownVessel;
|
private Vessel ownVessel;
|
||||||
|
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private MapUserInteractionListener mapUserInteractionListener;
|
||||||
|
|
||||||
public MapForgeImpl(Context context, MapView mapView) {
|
public MapForgeImpl(Context context, MapView mapView) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -165,6 +171,53 @@ public class MapForgeImpl implements MapInterface {
|
|||||||
public float getZoom() {
|
public float getZoom() {
|
||||||
return mapView.getModel().mapViewPosition.getZoomLevel();
|
return mapView.getModel().mapViewPosition.getZoomLevel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLatitude() {
|
||||||
|
if (mapView == null) return Double.NaN;
|
||||||
|
try {
|
||||||
|
LatLong center = mapView.getModel().mapViewPosition.getCenter();
|
||||||
|
return center != null ? center.latitude : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLongitude() {
|
||||||
|
if (mapView == null) return Double.NaN;
|
||||||
|
try {
|
||||||
|
LatLong center = mapView.getModel().mapViewPosition.getCenter();
|
||||||
|
return center != null ? center.longitude : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
|
||||||
|
if (mapView == null) return;
|
||||||
|
LatLong position = new LatLong(latitude, longitude);
|
||||||
|
mapView.getModel().mapViewPosition.setCenter(position);
|
||||||
|
mapView.getModel().mapViewPosition.setZoomLevel((byte) zoom);
|
||||||
|
// MapForge: bearing не поддерживается
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
|
||||||
|
if (mapView == null) return;
|
||||||
|
double fromLat = getCenterLatitude();
|
||||||
|
double fromLon = getCenterLongitude();
|
||||||
|
float fromZoom = getZoom();
|
||||||
|
if (Double.isNaN(fromLat) || Double.isNaN(fromLon)) {
|
||||||
|
fromLat = latitude;
|
||||||
|
fromLon = longitude;
|
||||||
|
fromZoom = zoom;
|
||||||
|
}
|
||||||
|
NavigatorCameraController.runSmoothTransition(
|
||||||
|
this, fromLat, fromLon, fromZoom,
|
||||||
|
latitude, longitude, zoom, durationMs, uiHandler, null);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setBearing(float bearing) {
|
public void setBearing(float bearing) {
|
||||||
@@ -246,19 +299,26 @@ public class MapForgeImpl implements MapInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||||
|
this.mapUserInteractionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настраивает слушатель движения карты для обновления курсора
|
* Настраивает слушатель движения карты для обновления курсора
|
||||||
*/
|
*/
|
||||||
private void setupMapMovementListener() {
|
private void setupMapMovementListener() {
|
||||||
if (mapView != null) {
|
if (mapView == null) return;
|
||||||
// mapView.getModel().mapViewPosition.addObserver(new org.mapsforge.map.model.Observer() {
|
mapView.setOnTouchListener((v, event) -> {
|
||||||
// @Override
|
int action = event.getActionMasked();
|
||||||
// public void onChange() {
|
if (mapUserInteractionListener != null
|
||||||
// // Обновляем координаты курсора при движении карты
|
&& (action == MotionEvent.ACTION_DOWN
|
||||||
// updateCursorFromMapCenter();
|
|| action == MotionEvent.ACTION_MOVE
|
||||||
// }
|
|| action == MotionEvent.ACTION_POINTER_DOWN)) {
|
||||||
// });
|
mapUserInteractionListener.onUserMapInteraction();
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -70,6 +70,40 @@ public interface MapInterface {
|
|||||||
* Получение текущего зума
|
* Получение текущего зума
|
||||||
*/
|
*/
|
||||||
float getZoom();
|
float getZoom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Широта центра карты (видимой области). Если неизвестна — {@link Double#NaN}.
|
||||||
|
*/
|
||||||
|
default double getCenterLatitude() {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Долгота центра карты. Если неизвестна — {@link Double#NaN}.
|
||||||
|
*/
|
||||||
|
default double getCenterLongitude() {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Атомарно задаёт центр, зум и (опционально) bearing одним обновлением камеры.
|
||||||
|
* {@code bearingDegrees == Float.NaN} — bearing не меняется.
|
||||||
|
*/
|
||||||
|
default void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
|
||||||
|
centerOnPosition(latitude, longitude);
|
||||||
|
setZoom(zoom);
|
||||||
|
if (!Float.isNaN(bearingDegrees)) {
|
||||||
|
setBearing(bearingDegrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плавно перемещает камеру к позиции с заданным зумом.
|
||||||
|
* {@code durationMs == 0} — мгновенно через {@link #setCameraView}.
|
||||||
|
*/
|
||||||
|
default void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
|
||||||
|
setCameraView(latitude, longitude, zoom, Float.NaN);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установка курса (bearing) карты в градусах (0 = север вверх)
|
* Установка курса (bearing) карты в градусах (0 = север вверх)
|
||||||
@@ -135,7 +169,46 @@ public interface MapInterface {
|
|||||||
* Очистить информацию об AIS судне
|
* Очистить информацию об AIS судне
|
||||||
*/
|
*/
|
||||||
void clearAisVesselInfo();
|
void clearAisVesselInfo();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рисует/обновляет до трёх колец вокруг собственного судна.
|
||||||
|
* Все массивы должны быть одной длины (обычно 3: опасность/предупреждение/фильтр).
|
||||||
|
* Если карта ещё не готова или координаты невалидны — реализация молча игнорирует вызов.
|
||||||
|
*
|
||||||
|
* @param lat широта центра в градусах (собственное судно)
|
||||||
|
* @param lon долгота центра в градусах
|
||||||
|
* @param radiiMeters массив радиусов в метрах
|
||||||
|
* @param strokeColors массив цветов обводки (ARGB)
|
||||||
|
* @param fillColors массив цветов заливки (ARGB; 0 = без заливки)
|
||||||
|
* @param visible массив флагов видимости (false = пропустить кольцо)
|
||||||
|
*/
|
||||||
|
default void setOwnShipRangeRings(double lat, double lon,
|
||||||
|
double[] radiiMeters, int[] strokeColors, int[] fillColors,
|
||||||
|
boolean[] visible) {
|
||||||
|
// Карты, не поддерживающие кольца, безопасно игнорируют вызов.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью убирает все кольца дальности с карты.
|
||||||
|
*/
|
||||||
|
default void clearOwnShipRangeRings() {
|
||||||
|
// no-op для неподдерживающих реализаций
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слушатель жестов пользователя на карте (пан, зум, поворот).
|
||||||
|
*/
|
||||||
|
interface MapUserInteractionListener {
|
||||||
|
void onUserMapInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписка на жесты пользователя; {@code null} — отписаться.
|
||||||
|
*/
|
||||||
|
default void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||||
|
// no-op для реализаций без поддержки
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Интерфейс для обработки кликов по меткам
|
* Интерфейс для обработки кликов по меткам
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
private static final String LAYER_SEAMARKS = "seamarks_layer";
|
private static final String LAYER_SEAMARKS = "seamarks_layer";
|
||||||
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
|
private static final String SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
|
||||||
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
|
private static final String LAYER_NAVIGATION_AIDS = "navigation_aids_layer";
|
||||||
|
/** Подсветка целей в зоне предупреждения (CircleLayer под основным слоем судов). */
|
||||||
|
private static final String LAYER_VESSELS_WARNING_HALO = "vessels_warning_halo";
|
||||||
|
// Range rings around own ship (3 zones: danger / warning / filter).
|
||||||
|
private static final String[] RANGE_RING_SOURCES = {
|
||||||
|
"range_ring_danger_source",
|
||||||
|
"range_ring_warning_source",
|
||||||
|
"range_ring_filter_source"
|
||||||
|
};
|
||||||
|
private static final String[] RANGE_RING_FILL_LAYERS = {
|
||||||
|
"range_ring_danger_fill",
|
||||||
|
"range_ring_warning_fill",
|
||||||
|
"range_ring_filter_fill"
|
||||||
|
};
|
||||||
|
private static final String[] RANGE_RING_LINE_LAYERS = {
|
||||||
|
"range_ring_danger_line",
|
||||||
|
"range_ring_warning_line",
|
||||||
|
"range_ring_filter_line"
|
||||||
|
};
|
||||||
private static final String IMAGE_VESSEL_OWN = "ownship";
|
private static final String IMAGE_VESSEL_OWN = "ownship";
|
||||||
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
|
private static final String IMAGE_VESSEL_A = "vessel_icon_a";
|
||||||
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
|
private static final String IMAGE_VESSEL_B = "vessel_icon_b";
|
||||||
@@ -233,11 +251,18 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
private final Map<String, JSONObject> aisPredictionFeatures = new HashMap<>();
|
private final Map<String, JSONObject> aisPredictionFeatures = new HashMap<>();
|
||||||
|
|
||||||
private MarkerClickListener markerClickListener;
|
private MarkerClickListener markerClickListener;
|
||||||
|
private MapUserInteractionListener mapUserInteractionListener;
|
||||||
|
|
||||||
// Pending центрирование до готовности карты/стиля
|
// Pending центрирование до готовности карты/стиля
|
||||||
private Double pendingCenterLat = null;
|
private Double pendingCenterLat = null;
|
||||||
private Double pendingCenterLon = null;
|
private Double pendingCenterLon = null;
|
||||||
|
|
||||||
|
// ----- Warning-zone подсветка целей -----
|
||||||
|
/** Радиус зоны предупреждения в метрах; 0 = подсветка отключена. */
|
||||||
|
private volatile double warningRadiusMeters = 0.0;
|
||||||
|
private volatile double warningOwnLat = Double.NaN;
|
||||||
|
private volatile double warningOwnLon = Double.NaN;
|
||||||
|
|
||||||
public MapLibreMapImpl(Context context, MapView mapView) {
|
public MapLibreMapImpl(Context context, MapView mapView) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.mapView = mapView;
|
this.mapView = mapView;
|
||||||
@@ -556,6 +581,7 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
String iconName = pickIconNameFor(vessel);
|
String iconName = pickIconNameFor(vessel);
|
||||||
props.put("icon", iconName);
|
props.put("icon", iconName);
|
||||||
props.put("stale", stale);
|
props.put("stale", stale);
|
||||||
|
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
|
||||||
// Проставим статусную иконку, если статус поддержан
|
// Проставим статусную иконку, если статус поддержан
|
||||||
String status = vessel.getNavigationalStatus();
|
String status = vessel.getNavigationalStatus();
|
||||||
String statusIcon = mapStatusToIcon(status);
|
String statusIcon = mapStatusToIcon(status);
|
||||||
@@ -613,6 +639,7 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
JSONObject props = feature.getJSONObject("properties");
|
JSONObject props = feature.getJSONObject("properties");
|
||||||
props.put("icon", pickIconNameFor(vessel));
|
props.put("icon", pickIconNameFor(vessel));
|
||||||
props.put("stale", stale);
|
props.put("stale", stale);
|
||||||
|
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
|
||||||
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
|
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
|
||||||
if (statusIcon != null) {
|
if (statusIcon != null) {
|
||||||
props.put("status_icon", statusIcon);
|
props.put("status_icon", statusIcon);
|
||||||
@@ -622,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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,6 +665,51 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет координаты собственного судна и радиус зоны предупреждения для
|
||||||
|
* data-driven подсветки целей. Передайте {@code warningRadiusMeters <= 0}
|
||||||
|
* чтобы выключить подсветку.
|
||||||
|
*/
|
||||||
|
public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) {
|
||||||
|
this.warningOwnLat = ownLat;
|
||||||
|
this.warningOwnLon = ownLon;
|
||||||
|
this.warningRadiusMeters = warningRadiusMeters;
|
||||||
|
// Перепроставим warning_zone у уже известных судов и обновим источник.
|
||||||
|
try {
|
||||||
|
for (java.util.Map.Entry<String, JSONObject> e : idToFeature.entrySet()) {
|
||||||
|
if ("own_vessel".equals(e.getKey())) continue;
|
||||||
|
AISVessel v = idToAisVessel.get(e.getKey());
|
||||||
|
if (v == null) continue;
|
||||||
|
JSONObject feature = e.getValue();
|
||||||
|
if (feature == null) continue;
|
||||||
|
try {
|
||||||
|
JSONObject props = feature.getJSONObject("properties");
|
||||||
|
props.put("warning_zone", isInWarningZone(v.getLatitude(), v.getLongitude()));
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
uiHandler.post(this::refreshGeoJson);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, попадает ли точка в зону предупреждения вокруг собственного
|
||||||
|
* судна. Если параметры зоны не заданы — возвращает {@code false}.
|
||||||
|
*/
|
||||||
|
private boolean isInWarningZone(double lat, double lon) {
|
||||||
|
double r = warningRadiusMeters;
|
||||||
|
if (!(r > 0.0)) return false;
|
||||||
|
double oLat = warningOwnLat;
|
||||||
|
double oLon = warningOwnLon;
|
||||||
|
if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false;
|
||||||
|
if (!GeoUtils.isValidCoordinates(lat, lon)) return false;
|
||||||
|
try {
|
||||||
|
double d = GeoUtils.calculateDistance(oLat, oLon, lat, lon);
|
||||||
|
return d <= r;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeAISVesselMarker(String mmsi) {
|
public void removeAISVesselMarker(String mmsi) {
|
||||||
if (mmsi == null) return;
|
if (mmsi == null) return;
|
||||||
@@ -858,6 +935,74 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLatitude() {
|
||||||
|
if (maplibreMap == null) return Double.NaN;
|
||||||
|
try {
|
||||||
|
org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target;
|
||||||
|
return t != null ? t.getLatitude() : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLongitude() {
|
||||||
|
if (maplibreMap == null) return Double.NaN;
|
||||||
|
try {
|
||||||
|
org.maplibre.android.geometry.LatLng t = maplibreMap.getCameraPosition().target;
|
||||||
|
return t != null ? t.getLongitude() : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
|
||||||
|
if (maplibreMap == null || mapView == null) {
|
||||||
|
pendingCenterLat = latitude;
|
||||||
|
pendingCenterLon = longitude;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
|
||||||
|
float bearing = Float.isNaN(bearingDegrees) ? (float) current.bearing : bearingDegrees;
|
||||||
|
maplibreMap.setCameraPosition(new org.maplibre.android.camera.CameraPosition.Builder()
|
||||||
|
.target(new LatLng(latitude, longitude))
|
||||||
|
.zoom(zoom)
|
||||||
|
.bearing(bearing)
|
||||||
|
.tilt(current.tilt)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "setCameraView: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
|
||||||
|
if (maplibreMap == null) return;
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
setCameraView(latitude, longitude, zoom, Float.NaN);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
org.maplibre.android.camera.CameraPosition current = maplibreMap.getCameraPosition();
|
||||||
|
org.maplibre.android.camera.CameraPosition target =
|
||||||
|
new org.maplibre.android.camera.CameraPosition.Builder()
|
||||||
|
.target(new org.maplibre.android.geometry.LatLng(latitude, longitude))
|
||||||
|
.zoom(zoom)
|
||||||
|
.bearing(current.bearing)
|
||||||
|
.tilt(current.tilt)
|
||||||
|
.build();
|
||||||
|
maplibreMap.animateCamera(
|
||||||
|
org.maplibre.android.camera.CameraUpdateFactory.newCameraPosition(target),
|
||||||
|
(int) durationMs);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "moveCameraSmooth: " + e.getMessage());
|
||||||
|
setCameraView(latitude, longitude, zoom, Float.NaN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addLayer(String layerId, Object layerData) {
|
public void addLayer(String layerId, Object layerData) {
|
||||||
if (style == null || !isStyleValid()) {
|
if (style == null || !isStyleValid()) {
|
||||||
@@ -949,6 +1094,39 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
|
|
||||||
// Отладочные линии удалены
|
// Отладочные линии удалены
|
||||||
|
|
||||||
|
// Подсветка целей в зоне предупреждения (под основным слоем судов).
|
||||||
|
// Виден только для feature.properties.warning_zone == true.
|
||||||
|
if (style.getLayer(LAYER_VESSELS_WARNING_HALO) == null) {
|
||||||
|
try {
|
||||||
|
int haloColor = androidx.core.content.ContextCompat.getColor(
|
||||||
|
context, com.grigowashere.aismap.R.color.range_target_warning_halo);
|
||||||
|
org.maplibre.android.style.layers.CircleLayer haloLayer =
|
||||||
|
new org.maplibre.android.style.layers.CircleLayer(LAYER_VESSELS_WARNING_HALO, SOURCE_VESSELS)
|
||||||
|
.withFilter(Expression.eq(Expression.get("warning_zone"), true))
|
||||||
|
.withProperties(
|
||||||
|
PropertyFactory.circleColor(haloColor),
|
||||||
|
PropertyFactory.circleOpacity(0.55f),
|
||||||
|
PropertyFactory.circleStrokeColor(haloColor),
|
||||||
|
PropertyFactory.circleStrokeOpacity(0.95f),
|
||||||
|
PropertyFactory.circleStrokeWidth(2.0f),
|
||||||
|
PropertyFactory.circleRadius(
|
||||||
|
Expression.interpolate(
|
||||||
|
Expression.linear(),
|
||||||
|
Expression.zoom(),
|
||||||
|
Expression.stop(5, 6.0f),
|
||||||
|
Expression.stop(8, 9.0f),
|
||||||
|
Expression.stop(12, 14.0f),
|
||||||
|
Expression.stop(15, 20.0f),
|
||||||
|
Expression.stop(17, 26.0f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
style.addLayer(haloLayer);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "Failed to add warning halo layer: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Слой символов (основные иконки)
|
// Слой символов (основные иконки)
|
||||||
if (style.getLayer(LAYER_VESSELS) == null) {
|
if (style.getLayer(LAYER_VESSELS) == null) {
|
||||||
SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS)
|
SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS)
|
||||||
@@ -2283,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);
|
||||||
@@ -3161,11 +3342,19 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
|
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@Override
|
||||||
|
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||||
|
this.mapUserInteractionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
private void setupMapMovementListener() {
|
private void setupMapMovementListener() {
|
||||||
if (maplibreMap != null) {
|
if (maplibreMap != null) {
|
||||||
maplibreMap.addOnCameraMoveListener(() -> {
|
maplibreMap.addOnCameraMoveListener(() -> updateCursorFromMapCenter());
|
||||||
// Обновляем координаты курсора при движении карты
|
maplibreMap.addOnCameraMoveStartedListener(reason -> {
|
||||||
updateCursorFromMapCenter();
|
if (reason == org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
|
||||||
|
&& mapUserInteractionListener != null) {
|
||||||
|
mapUserInteractionListener.onUserMapInteraction();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3198,4 +3387,145 @@ public class MapLibreMapImpl implements MapInterface {
|
|||||||
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
|
Log.e(TAG, "removeSeamarksLayer: ошибка удаления слоя морских знаков", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Range rings around own ship =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Количество вершин в полигоне-аппроксимации круга. 64 — компромисс
|
||||||
|
* между плавностью контура на низком зуме и стоимостью обновления
|
||||||
|
* GeoJsonSource на 1 Hz.
|
||||||
|
*/
|
||||||
|
private static final int RANGE_RING_VERTICES = 64;
|
||||||
|
/** Радиус Земли (м) — тот же, что и в GeoUtils, держим локально, чтобы избежать межмодульной зависимости. */
|
||||||
|
private static final double RANGE_EARTH_RADIUS_M = 6371000.0;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOwnShipRangeRings(double lat, double lon,
|
||||||
|
double[] radiiMeters, int[] strokeColors, int[] fillColors,
|
||||||
|
boolean[] visible) {
|
||||||
|
if (style == null || !isStyleValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Координаты должны быть в валидном диапазоне; иначе круги превратятся в мусор.
|
||||||
|
if (Double.isNaN(lat) || Double.isNaN(lon) ||
|
||||||
|
lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int n = Math.min(RANGE_RING_SOURCES.length,
|
||||||
|
Math.min(radiiMeters.length, Math.min(strokeColors.length,
|
||||||
|
Math.min(fillColors.length, visible.length))));
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
String sourceId = RANGE_RING_SOURCES[i];
|
||||||
|
String fillId = RANGE_RING_FILL_LAYERS[i];
|
||||||
|
String lineId = RANGE_RING_LINE_LAYERS[i];
|
||||||
|
|
||||||
|
if (!visible[i] || radiiMeters[i] <= 0.0) {
|
||||||
|
removeRangeRingLayers(sourceId, fillId, lineId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
org.maplibre.geojson.Polygon polygon = buildCirclePolygon(lat, lon, radiiMeters[i]);
|
||||||
|
org.maplibre.geojson.Feature feature = org.maplibre.geojson.Feature.fromGeometry(polygon);
|
||||||
|
|
||||||
|
GeoJsonSource source = (GeoJsonSource) style.getSource(sourceId);
|
||||||
|
if (source == null) {
|
||||||
|
source = new GeoJsonSource(sourceId, feature);
|
||||||
|
style.addSource(source);
|
||||||
|
} else {
|
||||||
|
source.setGeoJson(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.getLayer(fillId) == null) {
|
||||||
|
org.maplibre.android.style.layers.FillLayer fillLayer =
|
||||||
|
new org.maplibre.android.style.layers.FillLayer(fillId, sourceId);
|
||||||
|
fillLayer.setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i]),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.fillOpacity(1.0f)
|
||||||
|
);
|
||||||
|
if (style.getLayer(LAYER_VESSELS) != null) {
|
||||||
|
style.addLayerBelow(fillLayer, LAYER_VESSELS);
|
||||||
|
} else {
|
||||||
|
style.addLayer(fillLayer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style.getLayer(fillId).setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.fillColor(fillColors[i])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.getLayer(lineId) == null) {
|
||||||
|
org.maplibre.android.style.layers.LineLayer lineLayer =
|
||||||
|
new org.maplibre.android.style.layers.LineLayer(lineId, sourceId);
|
||||||
|
lineLayer.setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i]),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.lineWidth(2f),
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.lineOpacity(0.95f)
|
||||||
|
);
|
||||||
|
if (style.getLayer(LAYER_VESSELS) != null) {
|
||||||
|
style.addLayerBelow(lineLayer, LAYER_VESSELS);
|
||||||
|
} else {
|
||||||
|
style.addLayer(lineLayer);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
style.getLayer(lineId).setProperties(
|
||||||
|
org.maplibre.android.style.layers.PropertyFactory.lineColor(strokeColors[i])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "setOwnShipRangeRings: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearOwnShipRangeRings() {
|
||||||
|
if (style == null || !isStyleValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < RANGE_RING_SOURCES.length; i++) {
|
||||||
|
removeRangeRingLayers(RANGE_RING_SOURCES[i], RANGE_RING_FILL_LAYERS[i], RANGE_RING_LINE_LAYERS[i]);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "clearOwnShipRangeRings: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeRangeRingLayers(String sourceId, String fillId, String lineId) {
|
||||||
|
try {
|
||||||
|
if (style.getLayer(fillId) != null) style.removeLayer(fillId);
|
||||||
|
if (style.getLayer(lineId) != null) style.removeLayer(lineId);
|
||||||
|
if (style.getSource(sourceId) != null) style.removeSource(sourceId);
|
||||||
|
} catch (Exception ignore) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает полигон-аппроксимацию окружности радиуса {@code radiusMeters}
|
||||||
|
* вокруг точки ({@code centerLat}, {@code centerLon}) с {@link #RANGE_RING_VERTICES} вершинами.
|
||||||
|
*/
|
||||||
|
private static org.maplibre.geojson.Polygon buildCirclePolygon(double centerLat, double centerLon, double radiusMeters) {
|
||||||
|
double latRad = Math.toRadians(centerLat);
|
||||||
|
double lonRad = Math.toRadians(centerLon);
|
||||||
|
double angularDistance = radiusMeters / RANGE_EARTH_RADIUS_M;
|
||||||
|
|
||||||
|
java.util.List<org.maplibre.geojson.Point> ring = new java.util.ArrayList<>(RANGE_RING_VERTICES + 1);
|
||||||
|
for (int i = 0; i <= RANGE_RING_VERTICES; i++) {
|
||||||
|
double bearing = Math.toRadians((360.0 / RANGE_RING_VERTICES) * i);
|
||||||
|
double lat2 = Math.asin(Math.sin(latRad) * Math.cos(angularDistance)
|
||||||
|
+ Math.cos(latRad) * Math.sin(angularDistance) * Math.cos(bearing));
|
||||||
|
double lon2 = lonRad + Math.atan2(
|
||||||
|
Math.sin(bearing) * Math.sin(angularDistance) * Math.cos(latRad),
|
||||||
|
Math.cos(angularDistance) - Math.sin(latRad) * Math.sin(lat2));
|
||||||
|
ring.add(org.maplibre.geojson.Point.fromLngLat(Math.toDegrees(lon2), Math.toDegrees(lat2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.List<java.util.List<org.maplibre.geojson.Point>> outer = new java.util.ArrayList<>(1);
|
||||||
|
outer.add(ring);
|
||||||
|
return org.maplibre.geojson.Polygon.fromLngLats(outer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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;
|
||||||
|
import org.maplibre.android.maps.MapLibreMap;
|
||||||
|
import org.maplibre.android.maps.MapView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальная инициализация MapLibre для режима картплоттера:
|
||||||
|
* только береговые тайлы, без маркеров AIS и без жестов.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void initialize(Runnable onReady) {
|
||||||
|
mapView.getMapAsync(loadedMap -> {
|
||||||
|
map = loadedMap;
|
||||||
|
try {
|
||||||
|
if (map.getUiSettings() != null) {
|
||||||
|
map.getUiSettings().setCompassEnabled(false);
|
||||||
|
map.getUiSettings().setAttributionEnabled(false);
|
||||||
|
map.getUiSettings().setLogoEnabled(false);
|
||||||
|
map.getUiSettings().setRotateGesturesEnabled(false);
|
||||||
|
map.getUiSettings().setScrollGesturesEnabled(false);
|
||||||
|
map.getUiSettings().setZoomGesturesEnabled(false);
|
||||||
|
map.getUiSettings().setTiltGesturesEnabled(false);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "UI settings: " + e.getMessage());
|
||||||
|
}
|
||||||
|
map.setStyle(STYLE_URL, style -> {
|
||||||
|
styleLoaded = true;
|
||||||
|
if (onReady != null) onReady.run();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onStart() {
|
||||||
|
mapView.onStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onResume() {
|
||||||
|
mapView.onResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onPause() {
|
||||||
|
stopFollowLoop();
|
||||||
|
mapView.onPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onStop() {
|
||||||
|
mapView.onStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onDestroy() {
|
||||||
|
stopFollowLoop();
|
||||||
|
mapView.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onSaveInstanceState(android.os.Bundle outState) {
|
||||||
|
mapView.onSaveInstanceState(outState);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onLowMemory() {
|
||||||
|
mapView.onLowMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Центрирует карту на собственном судне; 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;
|
||||||
|
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(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 помещался в круговой области. */
|
||||||
|
static double zoomForRangeMeters(double rangeMeters) {
|
||||||
|
double nm = Math.max(0.25, rangeMeters / 1852.0);
|
||||||
|
return 14.8 - Math.log10(nm) * 2.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
|
|
||||||
// Слушатель поворота карты
|
// Слушатель поворота карты
|
||||||
private com.yandex.mapkit.map.InputListener inputListener;
|
private com.yandex.mapkit.map.InputListener inputListener;
|
||||||
|
private MapUserInteractionListener mapUserInteractionListener;
|
||||||
private float lastMapAzimuth = 0.0f;
|
private float lastMapAzimuth = 0.0f;
|
||||||
|
|
||||||
// Курсор overlay
|
// Курсор overlay
|
||||||
@@ -134,6 +135,7 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
if (markerManager != null) {
|
if (markerManager != null) {
|
||||||
markerManager.updateAISVesselMarker(vessel);
|
markerManager.updateAISVesselMarker(vessel);
|
||||||
}
|
}
|
||||||
|
updateWarningHaloForVessel(vessel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -141,6 +143,7 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
if (markerManager != null) {
|
if (markerManager != null) {
|
||||||
markerManager.updateAISVesselMarker(vessel);
|
markerManager.updateAISVesselMarker(vessel);
|
||||||
}
|
}
|
||||||
|
updateWarningHaloForVessel(vessel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -148,6 +151,7 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
if (vessels == null || markerManager == null) return;
|
if (vessels == null || markerManager == null) return;
|
||||||
for (AISVessel vessel : vessels) {
|
for (AISVessel vessel : vessels) {
|
||||||
markerManager.updateAISVesselMarker(vessel);
|
markerManager.updateAISVesselMarker(vessel);
|
||||||
|
updateWarningHaloForVessel(vessel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +160,12 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
if (markerManager != null) {
|
if (markerManager != null) {
|
||||||
markerManager.removeAISVesselMarker(mmsi);
|
markerManager.removeAISVesselMarker(mmsi);
|
||||||
}
|
}
|
||||||
|
if (mmsi != null) {
|
||||||
|
com.yandex.mapkit.map.CircleMapObject halo = warningHalos.remove(mmsi);
|
||||||
|
if (halo != null && mapObjects != null) {
|
||||||
|
try { mapObjects.remove(halo); } catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -163,6 +173,7 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
if (markerManager != null) {
|
if (markerManager != null) {
|
||||||
markerManager.clearAISVesselMarkers();
|
markerManager.clearAISVesselMarkers();
|
||||||
}
|
}
|
||||||
|
clearAllWarningHalos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -185,6 +196,56 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
return mapView.getMap().getCameraPosition().getZoom();
|
return mapView.getMap().getCameraPosition().getZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLatitude() {
|
||||||
|
try {
|
||||||
|
com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget();
|
||||||
|
return p != null ? p.getLatitude() : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getCenterLongitude() {
|
||||||
|
try {
|
||||||
|
com.yandex.mapkit.geometry.Point p = mapView.getMap().getCameraPosition().getTarget();
|
||||||
|
return p != null ? p.getLongitude() : Double.NaN;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Double.NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCameraView(double latitude, double longitude, float zoom, float bearingDegrees) {
|
||||||
|
try {
|
||||||
|
Point point = new Point(latitude, longitude);
|
||||||
|
CameraPosition current = mapView.getMap().getCameraPosition();
|
||||||
|
float azimuth = Float.isNaN(bearingDegrees) ? current.getAzimuth() : bearingDegrees;
|
||||||
|
CameraPosition pos = new CameraPosition(point, zoom, azimuth, current.getTilt());
|
||||||
|
mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, 0.35f), null);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
centerOnPosition(latitude, longitude);
|
||||||
|
setZoom(zoom);
|
||||||
|
if (!Float.isNaN(bearingDegrees)) {
|
||||||
|
setBearing(bearingDegrees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void moveCameraSmooth(double latitude, double longitude, float zoom, long durationMs) {
|
||||||
|
try {
|
||||||
|
Point point = new Point(latitude, longitude);
|
||||||
|
float durationSec = durationMs <= 0 ? 0.1f : Math.min(3f, durationMs / 1000f);
|
||||||
|
CameraPosition current = mapView.getMap().getCameraPosition();
|
||||||
|
CameraPosition pos = new CameraPosition(point, zoom, current.getAzimuth(), current.getTilt());
|
||||||
|
mapView.getMap().move(pos, new Animation(Animation.Type.SMOOTH, durationSec), null);
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
setCameraView(latitude, longitude, zoom, Float.NaN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setBearing(float bearing) {
|
public void setBearing(float bearing) {
|
||||||
try {
|
try {
|
||||||
@@ -325,6 +386,173 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
// В YandexMapImpl VesselPathController не используется напрямую,
|
// В YandexMapImpl VesselPathController не используется напрямую,
|
||||||
// но если в будущем будет использоваться, нужно добавить очистку
|
// но если в будущем будет использоваться, нужно добавить очистку
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Range rings around own ship =====
|
||||||
|
|
||||||
|
/** Ссылки на нарисованные кольца (3 зоны). */
|
||||||
|
private final com.yandex.mapkit.map.CircleMapObject[] rangeRingObjects =
|
||||||
|
new com.yandex.mapkit.map.CircleMapObject[3];
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setOwnShipRangeRings(double lat, double lon,
|
||||||
|
double[] radiiMeters, int[] strokeColors, int[] fillColors,
|
||||||
|
boolean[] visible) {
|
||||||
|
if (mapObjects == null) return;
|
||||||
|
if (radiiMeters == null || strokeColors == null || fillColors == null || visible == null) return;
|
||||||
|
if (Double.isNaN(lat) || Double.isNaN(lon)) return;
|
||||||
|
|
||||||
|
int n = Math.min(rangeRingObjects.length,
|
||||||
|
Math.min(radiiMeters.length, Math.min(strokeColors.length,
|
||||||
|
Math.min(fillColors.length, visible.length))));
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
if (!visible[i] || radiiMeters[i] <= 0.0) {
|
||||||
|
if (rangeRingObjects[i] != null) {
|
||||||
|
try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
|
||||||
|
rangeRingObjects[i] = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle(
|
||||||
|
new com.yandex.mapkit.geometry.Point(lat, lon),
|
||||||
|
(float) radiiMeters[i]);
|
||||||
|
if (rangeRingObjects[i] == null) {
|
||||||
|
rangeRingObjects[i] = mapObjects.addCircle(circle);
|
||||||
|
try {
|
||||||
|
rangeRingObjects[i].setStrokeColor(strokeColors[i]);
|
||||||
|
rangeRingObjects[i].setStrokeWidth(2f);
|
||||||
|
rangeRingObjects[i].setFillColor(fillColors[i]);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
rangeRingObjects[i].setGeometry(circle);
|
||||||
|
rangeRingObjects[i].setStrokeColor(strokeColors[i]);
|
||||||
|
rangeRingObjects[i].setStrokeWidth(2f);
|
||||||
|
rangeRingObjects[i].setFillColor(fillColors[i]);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
// Если объект финализирован — пересоздаём.
|
||||||
|
try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
|
||||||
|
rangeRingObjects[i] = mapObjects.addCircle(circle);
|
||||||
|
try {
|
||||||
|
rangeRingObjects[i].setStrokeColor(strokeColors[i]);
|
||||||
|
rangeRingObjects[i].setStrokeWidth(2f);
|
||||||
|
rangeRingObjects[i].setFillColor(fillColors[i]);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
android.util.Log.w("YandexMapImpl", "setOwnShipRangeRings: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearOwnShipRangeRings() {
|
||||||
|
if (mapObjects == null) return;
|
||||||
|
for (int i = 0; i < rangeRingObjects.length; i++) {
|
||||||
|
if (rangeRingObjects[i] != null) {
|
||||||
|
try { mapObjects.remove(rangeRingObjects[i]); } catch (Throwable ignore) {}
|
||||||
|
rangeRingObjects[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Warning-zone подсветка целей =====
|
||||||
|
|
||||||
|
private final Map<String, com.yandex.mapkit.map.CircleMapObject> warningHalos = new HashMap<>();
|
||||||
|
private volatile double warningRadiusMeters = 0.0;
|
||||||
|
private volatile double warningOwnLat = Double.NaN;
|
||||||
|
private volatile double warningOwnLon = Double.NaN;
|
||||||
|
/**
|
||||||
|
* Радиус halo-кольца вокруг цели (в метрах). Подобран небольшим, чтобы
|
||||||
|
* не загромождать карту, и виден на средних/больших зумах.
|
||||||
|
*/
|
||||||
|
private static final double WARNING_HALO_RADIUS_M = 250.0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет параметры зоны предупреждения для подсветки целей.
|
||||||
|
* При {@code warningRadiusMeters <= 0} подсветка очищается.
|
||||||
|
*/
|
||||||
|
public void setWarningZoneParams(double ownLat, double ownLon, double warningRadiusMeters) {
|
||||||
|
this.warningOwnLat = ownLat;
|
||||||
|
this.warningOwnLon = ownLon;
|
||||||
|
this.warningRadiusMeters = warningRadiusMeters;
|
||||||
|
if (!(warningRadiusMeters > 0.0)) {
|
||||||
|
clearAllWarningHalos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInWarningZone(double lat, double lon) {
|
||||||
|
double r = warningRadiusMeters;
|
||||||
|
if (!(r > 0.0)) return false;
|
||||||
|
double oLat = warningOwnLat;
|
||||||
|
double oLon = warningOwnLon;
|
||||||
|
if (Double.isNaN(oLat) || Double.isNaN(oLon)) return false;
|
||||||
|
try {
|
||||||
|
double d = com.grigowashere.aismap.utils.GeoUtils.calculateDistance(oLat, oLon, lat, lon);
|
||||||
|
return d <= r;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создаёт/обновляет/удаляет halo для одной цели в зависимости от попадания в зону. */
|
||||||
|
private void updateWarningHaloForVessel(AISVessel vessel) {
|
||||||
|
if (vessel == null || vessel.getMmsi() == null || mapObjects == null) return;
|
||||||
|
String mmsi = vessel.getMmsi();
|
||||||
|
boolean inZone = isInWarningZone(vessel.getLatitude(), vessel.getLongitude());
|
||||||
|
com.yandex.mapkit.map.CircleMapObject existing = warningHalos.get(mmsi);
|
||||||
|
try {
|
||||||
|
if (!inZone) {
|
||||||
|
if (existing != null) {
|
||||||
|
try { mapObjects.remove(existing); } catch (Throwable ignore) {}
|
||||||
|
warningHalos.remove(mmsi);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int strokeColor = androidx.core.content.ContextCompat.getColor(context, R.color.range_target_warning_halo);
|
||||||
|
int fillColor = (strokeColor & 0x00FFFFFF) | 0x55000000;
|
||||||
|
com.yandex.mapkit.geometry.Circle circle = new com.yandex.mapkit.geometry.Circle(
|
||||||
|
new com.yandex.mapkit.geometry.Point(vessel.getLatitude(), vessel.getLongitude()),
|
||||||
|
(float) WARNING_HALO_RADIUS_M);
|
||||||
|
if (existing == null) {
|
||||||
|
com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle);
|
||||||
|
try {
|
||||||
|
created.setStrokeColor(strokeColor);
|
||||||
|
created.setStrokeWidth(2f);
|
||||||
|
created.setFillColor(fillColor);
|
||||||
|
} catch (Throwable ignore) {}
|
||||||
|
warningHalos.put(mmsi, created);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
existing.setGeometry(circle);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
try { mapObjects.remove(existing); } catch (Throwable ignore) {}
|
||||||
|
com.yandex.mapkit.map.CircleMapObject created = mapObjects.addCircle(circle);
|
||||||
|
try {
|
||||||
|
created.setStrokeColor(strokeColor);
|
||||||
|
created.setStrokeWidth(2f);
|
||||||
|
created.setFillColor(fillColor);
|
||||||
|
} catch (Throwable ignore2) {}
|
||||||
|
warningHalos.put(mmsi, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
android.util.Log.w("YandexMapImpl", "updateWarningHalo: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearAllWarningHalos() {
|
||||||
|
if (mapObjects == null) {
|
||||||
|
warningHalos.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (com.yandex.mapkit.map.CircleMapObject obj : warningHalos.values()) {
|
||||||
|
if (obj == null) continue;
|
||||||
|
try { mapObjects.remove(obj); } catch (Throwable ignore) {}
|
||||||
|
}
|
||||||
|
warningHalos.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновление всех путей судов на карте (заглушка для Yandex)
|
* Обновление всех путей судов на карте (заглушка для Yandex)
|
||||||
@@ -487,13 +715,21 @@ public class YandexMapImpl implements MapInterface {
|
|||||||
/**
|
/**
|
||||||
* Настраивает слушатель движения карты для обновления курсора
|
* Настраивает слушатель движения карты для обновления курсора
|
||||||
*/
|
*/
|
||||||
|
@Override
|
||||||
|
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||||
|
this.mapUserInteractionListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
private void setupMapMovementListener() {
|
private void setupMapMovementListener() {
|
||||||
if (mapView != null) {
|
if (mapView != null) {
|
||||||
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
|
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) {
|
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) {
|
||||||
// Обновляем координаты курсора при движении карты
|
|
||||||
updateCursorFromMapCenter();
|
updateCursorFromMapCenter();
|
||||||
|
if (cameraUpdateReason == com.yandex.mapkit.map.CameraUpdateReason.GESTURES
|
||||||
|
&& mapUserInteractionListener != null) {
|
||||||
|
mapUserInteractionListener.onUserMapInteraction();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||||
import com.grigowashere.aismap.R;
|
import com.grigowashere.aismap.R;
|
||||||
import com.grigowashere.aismap.utils.SettingsManager;
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
import com.grigowashere.aismap.utils.UiInsetsUtils;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -55,6 +56,9 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
private EditText etBleBridgeHost;
|
private EditText etBleBridgeHost;
|
||||||
private EditText etBleBridgePort;
|
private EditText etBleBridgePort;
|
||||||
|
|
||||||
|
// BLE optional battery read (system pairing trigger on some devices)
|
||||||
|
private SwitchMaterial swBleBatteryEnabled;
|
||||||
|
|
||||||
private Button btnSave;
|
private Button btnSave;
|
||||||
private Button btnCancel;
|
private Button btnCancel;
|
||||||
|
|
||||||
@@ -72,6 +76,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_interfaces_settings);
|
setContentView(R.layout.activity_interfaces_settings);
|
||||||
|
applySettingsInsets();
|
||||||
settingsManager = new SettingsManager(this);
|
settingsManager = new SettingsManager(this);
|
||||||
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
|
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
|
||||||
btAdapter = bm != null ? bm.getAdapter() : null;
|
btAdapter = bm != null ? bm.getAdapter() : null;
|
||||||
@@ -82,6 +87,14 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
setupRecycler();
|
setupRecycler();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void applySettingsInsets() {
|
||||||
|
View scroll = findViewById(R.id.settings_scroll);
|
||||||
|
if (scroll != null) {
|
||||||
|
int pad = Math.round(getResources().getDisplayMetrics().density * 16);
|
||||||
|
UiInsetsUtils.applySystemBarPadding(scroll, pad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void initViews() {
|
private void initViews() {
|
||||||
etUdpPort = findViewById(R.id.et_udp_port);
|
etUdpPort = findViewById(R.id.et_udp_port);
|
||||||
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
|
swUdpEnabled = findViewById(R.id.switch_udp_enabled);
|
||||||
@@ -90,6 +103,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
|
swBleBridgeEnabled = findViewById(R.id.switch_ble_udp_bridge_enabled);
|
||||||
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
|
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
|
||||||
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
|
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
|
||||||
|
swBleBatteryEnabled = findViewById(R.id.switch_ble_battery_enabled);
|
||||||
btnSave = findViewById(R.id.btn_save);
|
btnSave = findViewById(R.id.btn_save);
|
||||||
btnCancel = findViewById(R.id.btn_cancel);
|
btnCancel = findViewById(R.id.btn_cancel);
|
||||||
btnBleScan = findViewById(R.id.btn_ble_scan);
|
btnBleScan = findViewById(R.id.btn_ble_scan);
|
||||||
@@ -107,6 +121,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
|
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
|
||||||
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
|
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
|
||||||
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
|
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
|
||||||
|
|
||||||
|
if (swBleBatteryEnabled != null) {
|
||||||
|
swBleBatteryEnabled.setChecked(settingsManager.isBleReadBatteryEnabled());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupHandlers() {
|
private void setupHandlers() {
|
||||||
@@ -202,6 +220,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
|||||||
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
|
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
|
||||||
settingsManager.setBleUdpBridgePort(brPort);
|
settingsManager.setBleUdpBridgePort(brPort);
|
||||||
|
|
||||||
|
if (swBleBatteryEnabled != null) {
|
||||||
|
settingsManager.setBleReadBatteryEnabled(swBleBatteryEnabled.isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
|
||||||
finish();
|
finish();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -191,65 +191,104 @@ 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);
|
||||||
|
|
||||||
if (tvTitle != null) {
|
if (tvTitle != null) {
|
||||||
String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО";
|
String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО";
|
||||||
|
// Флаг страны по MMSI оставляем — это единственный визуальный
|
||||||
|
// маркер, который тут реально несёт смысл. Остальные эмодзи в
|
||||||
|
// карточке цели убраны, чтобы текст не выглядел как чат.
|
||||||
String flag = getFlagEmojiForMMSI(vessel.getMmsi());
|
String flag = getFlagEmojiForMMSI(vessel.getMmsi());
|
||||||
tvTitle.setText((flag != null ? flag + " " : "") + "🚢 " + name);
|
tvTitle.setText((flag != null ? flag + " " : "") + name);
|
||||||
}
|
}
|
||||||
if (tvMmsi != null) tvMmsi.setText("🆔 MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
|
if (tvMmsi != null) tvMmsi.setText("MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
|
||||||
if (tvCallsign != null) tvCallsign.setText("📻 Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
|
if (tvCallsign != null) tvCallsign.setText("Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
|
||||||
if (tvImo != null) tvImo.setText("🏷️ IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
|
if (tvImo != null) tvImo.setText("IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
|
||||||
if (tvType != null) tvType.setText("🚢 Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
|
if (tvType != null) tvType.setText("Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
|
||||||
|
|
||||||
if (tvPosition != null) {
|
if (tvPosition != null) {
|
||||||
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
if (vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
||||||
tvPosition.setText(String.format("📍 Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude()));
|
tvPosition.setText(String.format("Координаты: %.6f, %.6f", vessel.getLatitude(), vessel.getLongitude()));
|
||||||
} else {
|
} else {
|
||||||
tvPosition.setText("📍 Координаты: --");
|
tvPosition.setText("Координаты: --");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("🧭 COG: %.1f°", vessel.getCourse()) : "🧭 COG: --°");
|
if (tvCourse != null) tvCourse.setText(vessel.getCourse() > 0 ? String.format("COG: %.1f°", vessel.getCourse()) : "COG: --°");
|
||||||
if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("🔄 ROT: %.1f°/мин", vessel.getRateOfTurn()) : "🔄 ROT: --°/мин");
|
if (tvRot != null) tvRot.setText(vessel.getRateOfTurn() != 0 ? String.format("ROT: %.1f°/мин", vessel.getRateOfTurn()) : "ROT: --°/мин");
|
||||||
if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("🧭 HDG: %.1f°", vessel.getHeading()) : "🧭 HDG: --°");
|
if (tvHeading != null) tvHeading.setText(vessel.getHeading() > 0 ? String.format("HDG: %.1f°", vessel.getHeading()) : "HDG: --°");
|
||||||
if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("⚡ Скорость: %.1f узлов", vessel.getSpeed()) : "⚡ Скорость: -- узлов");
|
if (tvSpeed != null) tvSpeed.setText(vessel.getSpeed() > 0 ? String.format("Скорость: %.1f узлов", vessel.getSpeed()) : "Скорость: -- узлов");
|
||||||
if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("📏 Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "📏 Размеры: --");
|
if (tvDimensions != null) tvDimensions.setText((vessel.getLength() > 0 && vessel.getWidth() > 0) ? String.format("Размеры: %.1f x %.1f м", vessel.getLength(), vessel.getWidth()) : "Размеры: --");
|
||||||
if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("🌊 Осадка: %.1f м", vessel.getDraft()) : "🌊 Осадка: -- м");
|
if (tvDraft != null) tvDraft.setText(vessel.getDraft() > 0 ? String.format("Осадка: %.1f м", vessel.getDraft()) : "Осадка: -- м");
|
||||||
if (tvDestination != null) tvDestination.setText("🎯 Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--"));
|
if (tvDestination != null) tvDestination.setText("Назначение: " + (vessel.getDestination() != null ? vessel.getDestination() : "--"));
|
||||||
if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("⏰ ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "⏰ ETA: --");
|
if (tvEta != null) tvEta.setText(vessel.getEta() != null ? String.format("ETA: %s", vessel.getEta().format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))) : "ETA: --");
|
||||||
if (tvNavStatus != null) tvNavStatus.setText("🚦 Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
|
if (tvNavStatus != null) tvNavStatus.setText("Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
|
||||||
if (tvClass != null) tvClass.setText("📋 Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
|
if (tvClass != null) tvClass.setText("Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
|
||||||
if (tvSignal != null) {
|
if (tvSignal != null) {
|
||||||
if (vessel.getSignalStrength() > 0) {
|
if (vessel.getSignalStrength() > 0) {
|
||||||
tvSignal.setText(String.format("📶 Сигнал: %d", vessel.getSignalStrength()));
|
tvSignal.setText(String.format("Сигнал: %d", vessel.getSignalStrength()));
|
||||||
} else {
|
} else {
|
||||||
tvSignal.setText(vessel.isPositionAccuracy() ? "📶 Точность: высокая" : "📶 Точность: низкая");
|
tvSignal.setText(vessel.isPositionAccuracy() ? "Точность: высокая" : "Точность: низкая");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tvTimeAgo != null) {
|
if (tvTimeAgo != null) {
|
||||||
if (vessel.getLastUpdate() != null) {
|
if (vessel.getLastUpdate() != null) {
|
||||||
long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
|
long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
|
||||||
tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo));
|
tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
|
||||||
} else {
|
} else {
|
||||||
tvTimeAgo.setText("⏱️ Время назад: --");
|
tvTimeAgo.setText("Время назад: --");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,7 +326,7 @@ public class BottomSheetsManager {
|
|||||||
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
|
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
|
||||||
if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) {
|
if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) {
|
||||||
long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
|
long secondsAgo = java.time.Duration.between(currentAISVessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
|
||||||
tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo));
|
tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import android.os.Handler;
|
|||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.R;
|
||||||
import com.grigowashere.aismap.maps.MapInterface;
|
import com.grigowashere.aismap.maps.MapInterface;
|
||||||
import com.grigowashere.aismap.maps.MapInterfaceChangeListener;
|
import com.grigowashere.aismap.maps.MapInterfaceChangeListener;
|
||||||
|
import com.grigowashere.aismap.maps.MapLibreMapImpl;
|
||||||
|
import com.grigowashere.aismap.maps.YandexMapImpl;
|
||||||
import com.grigowashere.aismap.models.Vessel;
|
import com.grigowashere.aismap.models.Vessel;
|
||||||
import com.grigowashere.aismap.models.AISVessel;
|
import com.grigowashere.aismap.models.AISVessel;
|
||||||
|
import com.grigowashere.aismap.utils.GeoUtils;
|
||||||
|
import com.grigowashere.aismap.utils.SettingsManager;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -32,6 +39,8 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
|||||||
|
|
||||||
private MapInterface mapInterface;
|
private MapInterface mapInterface;
|
||||||
private Handler uiHandler;
|
private Handler uiHandler;
|
||||||
|
private SettingsManager settingsManager;
|
||||||
|
private android.content.Context appContext;
|
||||||
|
|
||||||
// Pending операции для батчинга
|
// Pending операции для батчинга
|
||||||
private Vessel pendingVesselUpdate;
|
private Vessel pendingVesselUpdate;
|
||||||
@@ -55,6 +64,16 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
|||||||
setupThrottling();
|
setupThrottling();
|
||||||
Log.i(TAG, "UIRenderingCoordinator инициализирован");
|
Log.i(TAG, "UIRenderingCoordinator инициализирован");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Передаёт {@link SettingsManager}, чтобы координатор смог отрисовать
|
||||||
|
* кольца дальности вокруг собственного судна и halo предупреждения у
|
||||||
|
* целей. Если не вызвать — отрисовка колец не выполняется.
|
||||||
|
*/
|
||||||
|
public void setSettingsManager(android.content.Context context, SettingsManager sm) {
|
||||||
|
this.appContext = context != null ? context.getApplicationContext() : null;
|
||||||
|
this.settingsManager = sm;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Настройка throttling механизмов
|
* Настройка throttling механизмов
|
||||||
@@ -137,6 +156,7 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
|||||||
try {
|
try {
|
||||||
Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude());
|
Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude());
|
||||||
mapInterface.updateOwnVesselPosition(pendingVesselUpdate);
|
mapInterface.updateOwnVesselPosition(pendingVesselUpdate);
|
||||||
|
applyRangeRingsAround(pendingVesselUpdate);
|
||||||
Log.d(TAG, "Vessel update выполнен успешно");
|
Log.d(TAG, "Vessel update выполнен успешно");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e);
|
Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e);
|
||||||
@@ -144,6 +164,57 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
|||||||
|
|
||||||
pendingVesselUpdate = null;
|
pendingVesselUpdate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перерисовывает 3 кольца дальности (опасность/предупреждение/фильтр)
|
||||||
|
* вокруг собственного судна и сообщает картам параметры warning-зоны для
|
||||||
|
* подсветки целей. Если кольца отключены в настройках или координаты
|
||||||
|
* невалидны — кольца очищаются.
|
||||||
|
*/
|
||||||
|
private void applyRangeRingsAround(Vessel vessel) {
|
||||||
|
if (mapInterface == null) return;
|
||||||
|
if (settingsManager == null || appContext == null) return;
|
||||||
|
try {
|
||||||
|
double lat = vessel != null ? vessel.getLatitude() : Double.NaN;
|
||||||
|
double lon = vessel != null ? vessel.getLongitude() : Double.NaN;
|
||||||
|
boolean ringsOn = settingsManager.isRangeRingsEnabled()
|
||||||
|
&& GeoUtils.isValidCoordinates(lat, lon);
|
||||||
|
if (!ringsOn) {
|
||||||
|
mapInterface.clearOwnShipRangeRings();
|
||||||
|
if (mapInterface instanceof MapLibreMapImpl) {
|
||||||
|
((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0);
|
||||||
|
} else if (mapInterface instanceof YandexMapImpl) {
|
||||||
|
((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, 0.0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
double danger = settingsManager.getDangerRadiusMeters();
|
||||||
|
double warning = settingsManager.getWarningRadiusMeters();
|
||||||
|
double filter = settingsManager.getFilterRadiusMeters();
|
||||||
|
int dangerStroke = ContextCompat.getColor(appContext, R.color.range_ring_danger_stroke);
|
||||||
|
int dangerFill = ContextCompat.getColor(appContext, R.color.range_ring_danger_fill);
|
||||||
|
int warningStroke = ContextCompat.getColor(appContext, R.color.range_ring_warning_stroke);
|
||||||
|
int warningFill = ContextCompat.getColor(appContext, R.color.range_ring_warning_fill);
|
||||||
|
int filterStroke = ContextCompat.getColor(appContext, R.color.range_ring_filter_stroke);
|
||||||
|
int filterFill = ContextCompat.getColor(appContext, R.color.range_ring_filter_fill);
|
||||||
|
double[] radii = new double[] { danger, warning, filter };
|
||||||
|
int[] strokes = new int[] { dangerStroke, warningStroke, filterStroke };
|
||||||
|
int[] fills = new int[] { dangerFill, warningFill, filterFill };
|
||||||
|
boolean[] visible = new boolean[] {
|
||||||
|
danger > 0.0,
|
||||||
|
warning > 0.0,
|
||||||
|
filter > 0.0 && settingsManager.isRangeFilterEnabled()
|
||||||
|
};
|
||||||
|
mapInterface.setOwnShipRangeRings(lat, lon, radii, strokes, fills, visible);
|
||||||
|
if (mapInterface instanceof MapLibreMapImpl) {
|
||||||
|
((MapLibreMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning);
|
||||||
|
} else if (mapInterface instanceof YandexMapImpl) {
|
||||||
|
((YandexMapImpl) mapInterface).setWarningZoneParams(lat, lon, warning);
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "applyRangeRingsAround: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выполнение обновлений AIS судов
|
* Выполнение обновлений AIS судов
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чистая логика зума навигаторской камеры от скорости судна (узлы).
|
||||||
|
* Не зависит от Android — покрывается unit-тестами без Robolectric.
|
||||||
|
*/
|
||||||
|
public final class NavigatorZoomMath {
|
||||||
|
|
||||||
|
private NavigatorZoomMath() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Линейная интерполяция зума: при {@code speedKnots == 0} — {@code zoomAtZeroSpeed}
|
||||||
|
* (максимальное приближение), при {@code speedKnots >= maxSpeedKnots} —
|
||||||
|
* {@code zoomAtMaxSpeed} (максимальное отдаление).
|
||||||
|
*
|
||||||
|
* @param speedKnots скорость в узлах (отрицательные трактуются как 0)
|
||||||
|
* @param zoomAtZeroSpeed зум при нулевой скорости (обычно больше)
|
||||||
|
* @param zoomAtMaxSpeed зум при максимальной скорости (обычно меньше)
|
||||||
|
* @param maxSpeedKnots скорость, при которой достигается {@code zoomAtMaxSpeed}
|
||||||
|
*/
|
||||||
|
public static float zoomForSpeed(double speedKnots,
|
||||||
|
float zoomAtZeroSpeed,
|
||||||
|
float zoomAtMaxSpeed,
|
||||||
|
float maxSpeedKnots) {
|
||||||
|
float z0 = clampZoom(zoomAtZeroSpeed);
|
||||||
|
float zMax = clampZoom(zoomAtMaxSpeed);
|
||||||
|
if (maxSpeedKnots <= 0f) {
|
||||||
|
return z0;
|
||||||
|
}
|
||||||
|
double speed = speedKnots;
|
||||||
|
if (speed < 0.0 || Double.isNaN(speed) || Double.isInfinite(speed)) {
|
||||||
|
speed = 0.0;
|
||||||
|
}
|
||||||
|
double t = Math.min(1.0, speed / maxSpeedKnots);
|
||||||
|
return (float) (z0 + t * (zMax - z0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ограничивает зум допустимым диапазоном карт (2…20).
|
||||||
|
*/
|
||||||
|
public static float clampZoom(float zoom) {
|
||||||
|
if (Float.isNaN(zoom) || Float.isInfinite(zoom)) {
|
||||||
|
return 14f;
|
||||||
|
}
|
||||||
|
if (zoom < 2f) return 2f;
|
||||||
|
if (zoom > 20f) return 20f;
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Линейная интерполяция между {@code a} и {@code b} при {@code t} в [0, 1].
|
||||||
|
*/
|
||||||
|
public static double lerp(double a, double b, double t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float lerp(float a, float b, float t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ease-out cubic: быстрый старт, плавное завершение.
|
||||||
|
*/
|
||||||
|
public static float easeOutCubic(float t) {
|
||||||
|
if (t <= 0f) return 0f;
|
||||||
|
if (t >= 1f) return 1f;
|
||||||
|
float u = 1f - t;
|
||||||
|
return 1f - u * u * u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Плавный поворот по кратчайшей дуге (градусы 0…360).
|
||||||
|
*/
|
||||||
|
public static float lerpBearing(float fromDeg, float toDeg, float alpha) {
|
||||||
|
if (alpha <= 0f) return normalizeBearing360(fromDeg);
|
||||||
|
if (alpha >= 1f) return normalizeBearing360(toDeg);
|
||||||
|
float from = normalizeBearing360(fromDeg);
|
||||||
|
float to = normalizeBearing360(toDeg);
|
||||||
|
float delta = to - from;
|
||||||
|
if (delta > 180f) delta -= 360f;
|
||||||
|
if (delta < -180f) delta += 360f;
|
||||||
|
return normalizeBearing360(from + delta * alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float normalizeBearing360(float deg) {
|
||||||
|
float x = deg % 360f;
|
||||||
|
if (x < 0f) x += 360f;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чистые статические утилиты для логики колец дальности.
|
||||||
|
* <p>Не зависят от Android — это упрощает покрытие unit-тестами без Robolectric.
|
||||||
|
*/
|
||||||
|
public final class RangeMath {
|
||||||
|
|
||||||
|
/** 1 морская миля в метрах. */
|
||||||
|
public static final double METERS_PER_NM = 1852.0;
|
||||||
|
/** 1 километр в метрах. */
|
||||||
|
public static final double METERS_PER_KM = 1000.0;
|
||||||
|
|
||||||
|
/** Идентификаторы единиц измерения, совместимые с {@link SettingsManager}. */
|
||||||
|
public static final String UNIT_NM = "nm";
|
||||||
|
public static final String UNIT_KM = "km";
|
||||||
|
|
||||||
|
private RangeMath() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует значение в выбранной единице измерения в метры.
|
||||||
|
* <p>Любое неизвестное значение единицы трактуется как {@link #UNIT_NM}.
|
||||||
|
*/
|
||||||
|
public static double toMeters(double value, String unit) {
|
||||||
|
if (UNIT_KM.equals(unit)) {
|
||||||
|
return value * METERS_PER_KM;
|
||||||
|
}
|
||||||
|
return value * METERS_PER_NM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает {@code true}, если радиусы колец в порядке возрастания
|
||||||
|
* и все строго положительны: {@code 0 < danger < warning < filter}.
|
||||||
|
*/
|
||||||
|
public static boolean isValidRingOrder(double danger, double warning, double filter) {
|
||||||
|
return danger > 0.0 && warning > 0.0 && filter > 0.0
|
||||||
|
&& danger < warning && warning < filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает {@code true}, если цель находится внутри радиуса фильтра
|
||||||
|
* относительно собственного судна. Если фильтр выключен или координаты
|
||||||
|
* собственного/цели некорректны — возвращает {@code true} (цель остаётся).
|
||||||
|
*/
|
||||||
|
public static boolean isInsideFilter(boolean filterEnabled,
|
||||||
|
double filterRadiusMeters,
|
||||||
|
double ownLat, double ownLon,
|
||||||
|
double targetLat, double targetLon) {
|
||||||
|
if (!filterEnabled) return true;
|
||||||
|
if (!(filterRadiusMeters > 0.0)) return true;
|
||||||
|
if (Double.isNaN(ownLat) || Double.isNaN(ownLon)) return true;
|
||||||
|
if (Double.isNaN(targetLat) || Double.isNaN(targetLon)) return true;
|
||||||
|
double d = haversineMeters(ownLat, ownLon, targetLat, targetLon);
|
||||||
|
return d <= filterRadiusMeters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Расстояние по большому кругу (формула гаверсинуса), м.
|
||||||
|
* Совпадает с {@link GeoUtils#calculateDistance(double, double, double, double)}
|
||||||
|
* с точностью до выбора радиуса Земли (используется WGS-84 mean ≈ 6_371_000 m).
|
||||||
|
*/
|
||||||
|
public static double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
final double R = 6_371_000.0;
|
||||||
|
double dLat = Math.toRadians(lat2 - lat1);
|
||||||
|
double dLon = Math.toRadians(lon2 - lon1);
|
||||||
|
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||||
|
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||||
|
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,13 +45,30 @@ public class SettingsManager {
|
|||||||
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
|
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
|
||||||
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
|
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
|
||||||
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
|
private static final String KEY_MAP_ROTATION_MODE = "map_rotation_mode";
|
||||||
|
// Navigator camera (follow own ship + speed-based zoom)
|
||||||
|
private static final String KEY_NAVIGATOR_CAMERA_ENABLED = "navigator_camera_enabled";
|
||||||
|
private static final String KEY_NAVIGATOR_MAX_SPEED_KNOTS = "navigator_max_speed_knots";
|
||||||
|
private static final String KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED = "navigator_zoom_at_zero_speed";
|
||||||
|
private static final String KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED = "navigator_zoom_at_max_speed";
|
||||||
|
private static final String KEY_NAVIGATOR_CAMERA_TRANSITION_MS = "navigator_camera_transition_ms";
|
||||||
// BLE/NMEA settings
|
// BLE/NMEA settings
|
||||||
private static final String KEY_BLE_ENABLED = "ble_enabled";
|
private static final String KEY_BLE_ENABLED = "ble_enabled";
|
||||||
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
|
private static final String KEY_BLE_DEVICE_MAC = "ble_device_mac";
|
||||||
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
|
private static final String KEY_BLE_UDP_BRIDGE_ENABLED = "ble_udp_bridge_enabled";
|
||||||
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
|
private static final String KEY_BLE_UDP_BRIDGE_HOST = "ble_udp_bridge_host";
|
||||||
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
|
private static final String KEY_BLE_UDP_BRIDGE_PORT = "ble_udp_bridge_port";
|
||||||
|
/** Включает чтение battery 0x2A19. По умолчанию выключено: на ряде хабов
|
||||||
|
* чтение этой характеристики триггерит запрос сопряжения каждые 10 секунд. */
|
||||||
|
private static final String KEY_BLE_READ_BATTERY_ENABLED = "ble_read_battery_enabled";
|
||||||
|
|
||||||
|
// ===== Range rings around own ship =====
|
||||||
|
private static final String KEY_RANGE_RINGS_ENABLED = "range_rings_enabled";
|
||||||
|
private static final String KEY_RANGE_UNIT = "range_unit";
|
||||||
|
private static final String KEY_RANGE_DANGER = "range_danger";
|
||||||
|
private static final String KEY_RANGE_WARNING = "range_warning";
|
||||||
|
private static final String KEY_RANGE_FILTER = "range_filter";
|
||||||
|
private static final String KEY_RANGE_FILTER_ENABLED = "range_filter_enabled";
|
||||||
|
|
||||||
// Значения по умолчанию
|
// Значения по умолчанию
|
||||||
private static final int DEFAULT_UDP_PORT = 10110;
|
private static final int DEFAULT_UDP_PORT = 10110;
|
||||||
private static final boolean DEFAULT_UDP_ENABLED = true;
|
private static final boolean DEFAULT_UDP_ENABLED = true;
|
||||||
@@ -84,6 +101,24 @@ public class SettingsManager {
|
|||||||
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
|
private static final boolean DEFAULT_BLE_UDP_BRIDGE_ENABLED = false;
|
||||||
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
|
private static final String DEFAULT_BLE_UDP_BRIDGE_HOST = "255.255.255.255";
|
||||||
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
|
private static final int DEFAULT_BLE_UDP_BRIDGE_PORT = 10110;
|
||||||
|
private static final boolean DEFAULT_BLE_READ_BATTERY_ENABLED = false;
|
||||||
|
|
||||||
|
// Range rings defaults
|
||||||
|
private static final boolean DEFAULT_RANGE_RINGS_ENABLED = true;
|
||||||
|
private static final String DEFAULT_RANGE_UNIT = "nm"; // "nm" | "km"
|
||||||
|
private static final float DEFAULT_RANGE_DANGER = 0.5f;
|
||||||
|
private static final float DEFAULT_RANGE_WARNING = 1.5f;
|
||||||
|
private static final float DEFAULT_RANGE_FILTER = 5.0f;
|
||||||
|
private static final boolean DEFAULT_RANGE_FILTER_ENABLED = true;
|
||||||
|
|
||||||
|
// Range unit constants
|
||||||
|
public static final String RANGE_UNIT_NM = "nm";
|
||||||
|
public static final String RANGE_UNIT_KM = "km";
|
||||||
|
|
||||||
|
/** 1 морская миля в метрах. */
|
||||||
|
private static final double METERS_PER_NM = 1852.0;
|
||||||
|
/** 1 километр в метрах. */
|
||||||
|
private static final double METERS_PER_KM = 1000.0;
|
||||||
|
|
||||||
// Режимы работы с данными
|
// Режимы работы с данными
|
||||||
public static final String DATA_MODE_HYBRID = "hybrid";
|
public static final String DATA_MODE_HYBRID = "hybrid";
|
||||||
@@ -107,6 +142,11 @@ public class SettingsManager {
|
|||||||
/** Как курс (COG / GPS bearing). */
|
/** Как курс (COG / GPS bearing). */
|
||||||
public static final String MAP_ROTATION_COURSE = "course";
|
public static final String MAP_ROTATION_COURSE = "course";
|
||||||
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
|
private static final String DEFAULT_MAP_ROTATION_MODE = MAP_ROTATION_MANUAL;
|
||||||
|
private static final boolean DEFAULT_NAVIGATOR_CAMERA_ENABLED = false;
|
||||||
|
private static final float DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS = 20f;
|
||||||
|
private static final float DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED = 18f;
|
||||||
|
private static final float DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED = 10f;
|
||||||
|
private static final int DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS = 600;
|
||||||
|
|
||||||
private Context context;
|
private Context context;
|
||||||
private SharedPreferences prefs;
|
private SharedPreferences prefs;
|
||||||
@@ -454,6 +494,63 @@ public class SettingsManager {
|
|||||||
setMapRotationMode(next);
|
setMapRotationMode(next);
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Navigator camera =====
|
||||||
|
|
||||||
|
public boolean isNavigatorCameraEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, DEFAULT_NAVIGATOR_CAMERA_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorCameraEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_NAVIGATOR_CAMERA_ENABLED, enabled).apply();
|
||||||
|
Log.i(TAG, "Навигаторская камера: " + (enabled ? "включена" : "выключена"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getNavigatorMaxSpeedKnots() {
|
||||||
|
float v = prefs.getFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS);
|
||||||
|
if (v < 1f) v = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorMaxSpeedKnots(float knots) {
|
||||||
|
if (knots < 1f) knots = DEFAULT_NAVIGATOR_MAX_SPEED_KNOTS;
|
||||||
|
prefs.edit().putFloat(KEY_NAVIGATOR_MAX_SPEED_KNOTS, knots).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getNavigatorZoomAtZeroSpeed() {
|
||||||
|
return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_ZERO_SPEED));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorZoomAtZeroSpeed(float zoom) {
|
||||||
|
prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_ZERO_SPEED, clampNavigatorZoom(zoom)).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getNavigatorZoomAtMaxSpeed() {
|
||||||
|
return clampNavigatorZoom(prefs.getFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, DEFAULT_NAVIGATOR_ZOOM_AT_MAX_SPEED));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorZoomAtMaxSpeed(float zoom) {
|
||||||
|
prefs.edit().putFloat(KEY_NAVIGATOR_ZOOM_AT_MAX_SPEED, clampNavigatorZoom(zoom)).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getNavigatorCameraTransitionMs() {
|
||||||
|
int ms = prefs.getInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, DEFAULT_NAVIGATOR_CAMERA_TRANSITION_MS);
|
||||||
|
if (ms < 0) ms = 0;
|
||||||
|
if (ms > 5000) ms = 5000;
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNavigatorCameraTransitionMs(int ms) {
|
||||||
|
if (ms < 0) ms = 0;
|
||||||
|
if (ms > 5000) ms = 5000;
|
||||||
|
prefs.edit().putInt(KEY_NAVIGATOR_CAMERA_TRANSITION_MS, ms).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float clampNavigatorZoom(float zoom) {
|
||||||
|
if (zoom < 2f) return 2f;
|
||||||
|
if (zoom > 20f) return 20f;
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет, нужно ли перезапустить UDP слушатель
|
* Проверяет, нужно ли перезапустить UDP слушатель
|
||||||
@@ -696,5 +793,87 @@ public class SettingsManager {
|
|||||||
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
|
prefs.edit().putBoolean(KEY_SEAMARKS_ENABLED, enabled).apply();
|
||||||
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
|
Log.i(TAG, "Морские знаки OpenSeaMap: " + (enabled ? "включены" : "выключены"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== BLE battery opt-in =====
|
||||||
|
public boolean isBleReadBatteryEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_BLE_READ_BATTERY_ENABLED, DEFAULT_BLE_READ_BATTERY_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBleReadBatteryEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_BLE_READ_BATTERY_ENABLED, enabled).apply();
|
||||||
|
Log.i(TAG, "BLE read battery: " + (enabled ? "включено" : "выключено"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Range rings =====
|
||||||
|
public boolean isRangeRingsEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_RANGE_RINGS_ENABLED, DEFAULT_RANGE_RINGS_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeRingsEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_RANGE_RINGS_ENABLED, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRangeUnit() {
|
||||||
|
String v = prefs.getString(KEY_RANGE_UNIT, DEFAULT_RANGE_UNIT);
|
||||||
|
if (!RANGE_UNIT_NM.equals(v) && !RANGE_UNIT_KM.equals(v)) return DEFAULT_RANGE_UNIT;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeUnit(String unit) {
|
||||||
|
if (!RANGE_UNIT_NM.equals(unit) && !RANGE_UNIT_KM.equals(unit)) unit = DEFAULT_RANGE_UNIT;
|
||||||
|
prefs.edit().putString(KEY_RANGE_UNIT, unit).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getRangeDanger() {
|
||||||
|
return prefs.getFloat(KEY_RANGE_DANGER, DEFAULT_RANGE_DANGER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeDanger(float v) {
|
||||||
|
prefs.edit().putFloat(KEY_RANGE_DANGER, v).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getRangeWarning() {
|
||||||
|
return prefs.getFloat(KEY_RANGE_WARNING, DEFAULT_RANGE_WARNING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeWarning(float v) {
|
||||||
|
prefs.edit().putFloat(KEY_RANGE_WARNING, v).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getRangeFilter() {
|
||||||
|
return prefs.getFloat(KEY_RANGE_FILTER, DEFAULT_RANGE_FILTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeFilter(float v) {
|
||||||
|
prefs.edit().putFloat(KEY_RANGE_FILTER, v).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRangeFilterEnabled() {
|
||||||
|
return prefs.getBoolean(KEY_RANGE_FILTER_ENABLED, DEFAULT_RANGE_FILTER_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeFilterEnabled(boolean enabled) {
|
||||||
|
prefs.edit().putBoolean(KEY_RANGE_FILTER_ENABLED, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конвертирует значение в выбранной единице ({@link #getRangeUnit()}) в метры. */
|
||||||
|
public double convertRangeToMeters(float value) {
|
||||||
|
if (RANGE_UNIT_KM.equals(getRangeUnit())) {
|
||||||
|
return value * METERS_PER_KM;
|
||||||
|
}
|
||||||
|
return value * METERS_PER_NM;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getDangerRadiusMeters() {
|
||||||
|
return convertRangeToMeters(getRangeDanger());
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getWarningRadiusMeters() {
|
||||||
|
return convertRangeToMeters(getRangeWarning());
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getFilterRadiusMeters() {
|
||||||
|
return convertRangeToMeters(getRangeFilter());
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.core.graphics.Insets;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Паддинги под системные бары для экранов с edge-to-edge (targetSdk 35+).
|
||||||
|
*/
|
||||||
|
public final class UiInsetsUtils {
|
||||||
|
|
||||||
|
private UiInsetsUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет к {@code basePaddingPx} отступы status/nav-bar и display cutout.
|
||||||
|
*/
|
||||||
|
public static void applySystemBarPadding(View view, int basePaddingPx) {
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> {
|
||||||
|
Insets sys = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars()
|
||||||
|
| WindowInsetsCompat.Type.displayCutout());
|
||||||
|
v.setPadding(
|
||||||
|
basePaddingPx + sys.left,
|
||||||
|
basePaddingPx + sys.top,
|
||||||
|
basePaddingPx + sys.right,
|
||||||
|
basePaddingPx + sys.bottom);
|
||||||
|
return insets;
|
||||||
|
});
|
||||||
|
ViewCompat.requestApplyInsets(view);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,52 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
protected static final float MIN_SCALE = 0.5f;
|
protected static final float MIN_SCALE = 0.5f;
|
||||||
protected static final float MAX_SCALE = 2.0f;
|
protected static final float MAX_SCALE = 2.0f;
|
||||||
protected static final float SCALE_STEP = 0.1f;
|
protected static final float SCALE_STEP = 0.1f;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Высота в dock-режиме «по умолчанию» (в dp). Используется как fallback,
|
||||||
|
* если наследник НЕ переопределил {@link #measureDockContentHeightPx(int)}.
|
||||||
|
* Большинству виджетов достаточно переопределить только measure-метод.
|
||||||
|
*/
|
||||||
|
protected int getDefaultDockHeightDp() {
|
||||||
|
return DEFAULT_DOCK_HEIGHT_DP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сколько пикселей ПОЛЕЗНОГО КОНТЕНТА (без учёта системных паддингов под
|
||||||
|
* статус-/нав-бар) нужно этому виджету при данной ширине, чтобы корректно
|
||||||
|
* нарисоваться в dock-режиме. Возвращаемое значение используется в
|
||||||
|
* {@link #onMeasure(int, int)} как высота content-области.
|
||||||
|
*
|
||||||
|
* <p>Наследники переопределяют этот метод и считают высоту по своим
|
||||||
|
* реальным метрикам отрисовки (размер шрифта, число строк, и т.п.), чтобы
|
||||||
|
* не быть привязанными к магической константе.
|
||||||
|
*
|
||||||
|
* <p>По умолчанию возвращает {@code dp(getDefaultDockHeightDp())} — для
|
||||||
|
* обратной совместимости с виджетами, которые ещё не реализовали measure.
|
||||||
|
*/
|
||||||
|
protected int measureDockContentHeightPx(int widthPx) {
|
||||||
|
return (int) dp(getDefaultDockHeightDp());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Куда виджет «прикипает» по умолчанию: {@code true} — к верху экрана,
|
||||||
|
* {@code false} — к низу. Влияет на:
|
||||||
|
* <ul>
|
||||||
|
* <li>зону resize (верх/низ виджета),</li>
|
||||||
|
* <li>сторону, к которой подъезжают другие dock-виджеты при стакинге,</li>
|
||||||
|
* <li>позицию docking после ручного перетаскивания (если пользователь
|
||||||
|
* отпустил виджет в середине, мы возвращаем его на «домашнюю» сторону).</li>
|
||||||
|
* </ul>
|
||||||
|
* XML-якорь ({@code layout_alignParentBottom} / {@code layout_above}) задаёт
|
||||||
|
* стартовое положение визуально, а этот метод — внутреннюю модель.
|
||||||
|
*/
|
||||||
|
protected boolean getDefaultDockTop() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Состояние виджета
|
// Состояние виджета
|
||||||
protected boolean isDocked = true; // По умолчанию в dock-режиме
|
protected boolean isDocked = true; // По умолчанию в dock-режиме
|
||||||
protected boolean dockTop = true;
|
protected boolean dockTop = true; // Инициализируется в init() через getDefaultDockTop()
|
||||||
protected boolean isMorphing = false;
|
protected boolean isMorphing = false;
|
||||||
protected float morphProgress = 0.0f; // 0 = dock, 1 = circle
|
protected float morphProgress = 0.0f; // 0 = dock, 1 = circle
|
||||||
|
|
||||||
@@ -72,22 +114,19 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
private void init() {
|
private void init() {
|
||||||
setClickable(true);
|
setClickable(true);
|
||||||
setFocusable(true);
|
setFocusable(true);
|
||||||
|
|
||||||
// Инициализируем в dock-режиме
|
// Стартовая сторона дока (top/bottom) определяется наследником. Само
|
||||||
post(() -> {
|
// фактическое положение задаёт RelativeLayout (alignParentTop / Bottom /
|
||||||
if (isDocked) {
|
// layout_above), а этот флаг — внутренняя модель для resize-зоны и
|
||||||
ViewGroup parent = (ViewGroup) getParent();
|
// стакинга других dock-виджетов.
|
||||||
if (parent != null) {
|
this.dockTop = getDefaultDockTop();
|
||||||
setX(0);
|
// Высота view в dock-режиме считается в onMeasure через
|
||||||
setY(0);
|
// measureDockContentHeightPx(...) при lp.height=WRAP_CONTENT (это
|
||||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
// прописано в activity_main.xml). А переход dock<->circle сам выставляет
|
||||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
|
// правильные lp в конце анимации (см. setDocked). Намеренно НЕ дёргаем
|
||||||
lp.height = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
// setLayoutParams() из init().post() — это вызывало второй проход layout
|
||||||
dockHeightPx = 0; // Сбрасываем сохраненную высоту
|
// ПОСЛЕ первого measure и оставляло координатный/danger виджет с нулевой
|
||||||
setLayoutParams(lp);
|
// высотой на первый кадр, пока не приходил какой-нибудь size-update.
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -232,7 +271,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
|
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
|
||||||
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
|
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
|
||||||
// под системный бар даже при минимальном размере.
|
// под системный бар даже при минимальном размере.
|
||||||
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(getDefaultDockHeightDp());
|
||||||
int newHeight = currentContent;
|
int newHeight = currentContent;
|
||||||
|
|
||||||
if (dockTop) {
|
if (dockTop) {
|
||||||
@@ -309,7 +348,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
// При докинге всегда устанавливаем размер по умолчанию
|
// При докинге всегда устанавливаем размер по умолчанию
|
||||||
dockHeightPx = 0; // Сбрасываем сохраненную высоту
|
dockHeightPx = 0; // Сбрасываем сохраненную высоту
|
||||||
|
|
||||||
setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(DEFAULT_DOCK_HEIGHT_DP));
|
setDocked(true, dockToTop, 0f, dockToTop ? 0f : screenHeight - dp(getDefaultDockHeightDp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private float getDistance(MotionEvent event) {
|
private float getDistance(MotionEvent event) {
|
||||||
@@ -324,10 +363,16 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
if (isDocked) {
|
if (isDocked) {
|
||||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||||
// dockHeightPx/DEFAULT — это высота полезного контента; к ней
|
// Высота content-области:
|
||||||
// прибавляем padding от WindowInsets, чтобы виджет фактически
|
// * если пользователь ВРУЧНУЮ растянул виджет (dockHeightPx>0) —
|
||||||
// расширялся под статус-бар или нав-бар и не прятал контент.
|
// используем эту фиксированную высоту;
|
||||||
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
// * иначе спрашиваем у конкретного виджета через measure-метод,
|
||||||
|
// сколько ему нужно для отрисовки на данной ширине.
|
||||||
|
// Системные паддинги (статус-бар/нав-бар) прибавляются СВЕРХУ
|
||||||
|
// content-области, чтобы карта не пряталась под бары.
|
||||||
|
int content = dockHeightPx > 0
|
||||||
|
? dockHeightPx
|
||||||
|
: measureDockContentHeightPx(width);
|
||||||
int height = content + getPaddingTop() + getPaddingBottom();
|
int height = content + getPaddingTop() + getPaddingBottom();
|
||||||
setMeasuredDimension(width, height);
|
setMeasuredDimension(width, height);
|
||||||
} else {
|
} else {
|
||||||
@@ -353,7 +398,15 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void setDocked(boolean docked, boolean top, float targetX, float targetY) {
|
public void setDocked(boolean docked, boolean top, float targetX, float targetY) {
|
||||||
if (this.isDocked == docked && this.dockTop == top && getX() == targetX && getY() == targetY) {
|
// Раннее завершение опираем ТОЛЬКО на isDocked/dockTop. Позицию dock-виджета
|
||||||
|
// задаёт RelativeLayout (alignParentTop / alignParentBottom / layout_above),
|
||||||
|
// а не translationX/Y. Сравнение с getX()/getY() сюда подмешивало проблему:
|
||||||
|
// если кто-то делал coordinatesWidget.post(() -> setDocked(true, false, 0, 0))
|
||||||
|
// ПОСЛЕ первого layout, getY() уже был = parent.bottom-h ≠ 0, ранний return
|
||||||
|
// не срабатывал, и анимация уезжала к computed-position через setX/setY,
|
||||||
|
// оставляя translation-ом виджет за экраном. Теперь повторный setDocked
|
||||||
|
// в ту же сторону — это явный no-op.
|
||||||
|
if (this.isDocked == docked && this.dockTop == top) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +425,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
ViewGroup parent = (ViewGroup) getParent();
|
ViewGroup parent = (ViewGroup) getParent();
|
||||||
int parentWidth = parent.getWidth();
|
int parentWidth = parent.getWidth();
|
||||||
int parentHeight = parent.getHeight();
|
int parentHeight = parent.getHeight();
|
||||||
int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
int dockHeight = (int) dp(getDefaultDockHeightDp());
|
||||||
int circleSize = (int) dp(CIRCLE_SIZE_DP);
|
int circleSize = (int) dp(CIRCLE_SIZE_DP);
|
||||||
|
|
||||||
int endW = docked ? parentWidth : circleSize;
|
int endW = docked ? parentWidth : circleSize;
|
||||||
@@ -422,16 +475,34 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
@Override
|
@Override
|
||||||
public void onAnimationEnd(Animator animation) {
|
public void onAnimationEnd(Animator animation) {
|
||||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||||
lp.width = endW;
|
if (docked) {
|
||||||
lp.height = endH;
|
// В dock-режиме высоту контролирует measureDockContentHeightPx,
|
||||||
setLayoutParams(lp);
|
// ширину — родитель. Если оставить фиксированные endW/endH
|
||||||
|
// от анимации, виджет навсегда «застрянет» в этом размере
|
||||||
setX(finalEndX);
|
// и нарушит формулу контент+паддинги.
|
||||||
setY(finalEndY);
|
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
lp.height = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||||
|
setLayoutParams(lp);
|
||||||
|
// Финальную позицию dock-виджета задаёт RelativeLayout
|
||||||
|
// (alignParentTop/Bottom/layout_above). Translation от
|
||||||
|
// анимации тут лишний — он бы «прибил» виджет к
|
||||||
|
// animation-target, ломая layout-правила (например, после
|
||||||
|
// ресайза/вставки insets) и мог увести его за экран.
|
||||||
|
setTranslationX(0f);
|
||||||
|
setTranslationY(0f);
|
||||||
|
} else {
|
||||||
|
lp.width = endW;
|
||||||
|
lp.height = endH;
|
||||||
|
setLayoutParams(lp);
|
||||||
|
// В circle-режиме виджет «свободно плавает», его позицию
|
||||||
|
// мы держим именно через translation (setX/setY).
|
||||||
|
setX(finalEndX);
|
||||||
|
setY(finalEndY);
|
||||||
|
}
|
||||||
morphProgress = endMorph;
|
morphProgress = endMorph;
|
||||||
|
|
||||||
postInvalidateOnAnimation();
|
postInvalidateOnAnimation();
|
||||||
|
|
||||||
isMorphing = false;
|
isMorphing = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -477,7 +548,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
ViewGroup parent = (ViewGroup) getParent();
|
ViewGroup parent = (ViewGroup) getParent();
|
||||||
if (parent == null) return 0;
|
if (parent == null) return 0;
|
||||||
|
|
||||||
int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
int dockHeight = (int) dp(getDefaultDockHeightDp());
|
||||||
float y = 0;
|
float y = 0;
|
||||||
|
|
||||||
if (dockTop) {
|
if (dockTop) {
|
||||||
@@ -518,43 +589,30 @@ public abstract class BaseDockWidget extends FrameLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Перепозиционирует все docked виджеты, чтобы они прижались к краям
|
* Сбрасывает translation у всех dock-виджетов, чтобы их положение
|
||||||
|
* определялось layout-правилами родителя (alignParentTop / alignParentBottom /
|
||||||
|
* layout_above), а не остаточной анимационной трансляцией.
|
||||||
|
*
|
||||||
|
* Раньше этот метод сам считал Y по getHeight() и звал setY(...) — это
|
||||||
|
* генерировало translationY ≠ 0 поверх RelativeLayout-ов, и виджеты после
|
||||||
|
* dock-state-change «прилипали» к старым координатам (вплоть до ухода за
|
||||||
|
* экран, если parent ещё не успел измериться). Теперь мы доверяем layout-у
|
||||||
|
* и только обнуляем translation, чтобы visual position == layout position.
|
||||||
*/
|
*/
|
||||||
public static void repositionAllDockedWidgets(ViewGroup parent) {
|
public static void repositionAllDockedWidgets(ViewGroup parent) {
|
||||||
if (parent == null) return;
|
if (parent == null) return;
|
||||||
|
|
||||||
// Собираем все docked виджеты сверху
|
|
||||||
java.util.List<BaseDockWidget> topWidgets = new java.util.ArrayList<>();
|
|
||||||
java.util.List<BaseDockWidget> bottomWidgets = new java.util.ArrayList<>();
|
|
||||||
|
|
||||||
for (int i = 0; i < parent.getChildCount(); i++) {
|
for (int i = 0; i < parent.getChildCount(); i++) {
|
||||||
View child = parent.getChildAt(i);
|
View child = parent.getChildAt(i);
|
||||||
if (child instanceof BaseDockWidget) {
|
if (child instanceof BaseDockWidget) {
|
||||||
BaseDockWidget widget = (BaseDockWidget) child;
|
BaseDockWidget widget = (BaseDockWidget) child;
|
||||||
if (widget.isDocked()) {
|
if (widget.isDocked() && !widget.isMorphing) {
|
||||||
if (widget.isDockTop()) {
|
widget.setTranslationX(0f);
|
||||||
topWidgets.add(widget);
|
widget.setTranslationY(0f);
|
||||||
} else {
|
|
||||||
bottomWidgets.add(widget);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
parent.requestLayout();
|
||||||
// Перепозиционируем виджеты сверху
|
|
||||||
float currentY = 0;
|
|
||||||
for (BaseDockWidget widget : topWidgets) {
|
|
||||||
widget.setY(currentY);
|
|
||||||
currentY += widget.getHeight();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Перепозиционируем виджеты снизу
|
|
||||||
currentY = parent.getHeight();
|
|
||||||
for (int i = bottomWidgets.size() - 1; i >= 0; i--) {
|
|
||||||
BaseDockWidget widget = bottomWidgets.get(i);
|
|
||||||
currentY -= widget.getHeight();
|
|
||||||
widget.setY(currentY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Абстрактные методы для переопределения в наследниках
|
// Абстрактные методы для переопределения в наследниках
|
||||||
|
|||||||
@@ -65,6 +65,29 @@ public class CompassView extends BaseDockWidget {
|
|||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Минимальная высота контента, при которой шкала компаса и её буквы N/S/W/E
|
||||||
|
* гарантированно помещаются в видимую область.
|
||||||
|
*
|
||||||
|
* <p>Считаем по факту отрисовки:
|
||||||
|
* <ul>
|
||||||
|
* <li>header (HEADING/MAG label+value+divider) ≈ 38dp,</li>
|
||||||
|
* <li>шкала с буквами по краям ≈ 56dp.</li>
|
||||||
|
* </ul>
|
||||||
|
* Итого ≈ 94dp полезного контента; ставим 96dp с запасом на baselines.
|
||||||
|
*/
|
||||||
|
private static final int CONTENT_HEIGHT_DP = 96;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int getDefaultDockHeightDp() {
|
||||||
|
return CONTENT_HEIGHT_DP;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int measureDockContentHeightPx(int widthPx) {
|
||||||
|
return (int) dp(CONTENT_HEIGHT_DP);
|
||||||
|
}
|
||||||
|
|
||||||
private void init() {
|
private void init() {
|
||||||
paint.setColor(TICK_COLOR);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setTextAlign(Paint.Align.CENTER);
|
paint.setTextAlign(Paint.Align.CENTER);
|
||||||
@@ -156,40 +179,36 @@ public class CompassView extends BaseDockWidget {
|
|||||||
// чтобы под статус-бар/бровь тоже уходил единый тон.
|
// чтобы под статус-бар/бровь тоже уходил единый тон.
|
||||||
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
|
canvas.drawRect(0, 0, totalW, totalH, bgPaint);
|
||||||
|
|
||||||
// Масштабируем размеры в зависимости от высоты контентной области.
|
|
||||||
float baseHeight = dp(80);
|
|
||||||
float scaleFactor = Math.max(0.8f, Math.min(2.0f, h / baseHeight));
|
|
||||||
|
|
||||||
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
|
// Шапка в стиле LABEL + значение (как POSITION/SOG/COG/ACC в
|
||||||
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
|
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
|
||||||
float cx = left + w / 2f;
|
// Размеры шапки фиксированы и не зависят от высоты виджета — это
|
||||||
|
// обычные строчки текста, они и так хорошо смотрятся при любой высоте.
|
||||||
float padInner = dp(10);
|
float padInner = dp(10);
|
||||||
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
|
float labelY = top + dp(12);
|
||||||
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
|
float valueY = labelY + dp(16);
|
||||||
|
|
||||||
labelPaint.setTextAlign(Paint.Align.LEFT);
|
labelPaint.setTextAlign(Paint.Align.LEFT);
|
||||||
valuePaint.setTextAlign(Paint.Align.LEFT);
|
valuePaint.setTextAlign(Paint.Align.LEFT);
|
||||||
accentPaint.setTextAlign(Paint.Align.LEFT);
|
accentPaint.setTextAlign(Paint.Align.LEFT);
|
||||||
|
|
||||||
canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
|
canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
|
||||||
canvas.drawText(((int) currentAzimuth) + "°",
|
left + padInner, labelY, labelPaint);
|
||||||
|
canvas.drawText(((int) currentAzimuth) + "\u00B0",
|
||||||
left + padInner, valueY, accentPaint);
|
left + padInner, valueY, accentPaint);
|
||||||
|
|
||||||
labelPaint.setTextAlign(Paint.Align.RIGHT);
|
labelPaint.setTextAlign(Paint.Align.RIGHT);
|
||||||
valuePaint.setTextAlign(Paint.Align.RIGHT);
|
valuePaint.setTextAlign(Paint.Align.RIGHT);
|
||||||
canvas.drawText("MAG", right - padInner, labelY, labelPaint);
|
canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_mag),
|
||||||
canvas.drawText(((int) magneticCompass) + "°",
|
right - padInner, labelY, labelPaint);
|
||||||
|
canvas.drawText(((int) magneticCompass) + "\u00B0",
|
||||||
right - padInner, valueY, valuePaint);
|
right - padInner, valueY, valuePaint);
|
||||||
|
|
||||||
// Разделитель под шапкой — такой же, как в координатах.
|
|
||||||
float dividerY = valueY + dp(6);
|
float dividerY = valueY + dp(6);
|
||||||
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
|
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
|
||||||
|
|
||||||
// Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры.
|
|
||||||
paint.setColor(TICK_COLOR);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setTextSize(24 * scaleFactor);
|
|
||||||
paint.setTextAlign(Paint.Align.CENTER);
|
paint.setTextAlign(Paint.Align.CENTER);
|
||||||
|
|
||||||
// Плавное обновление азимута
|
// Плавное обновление азимута
|
||||||
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
float diff = getShortestRotation(currentAzimuth, targetAzimuth);
|
||||||
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
if (Math.abs(diff) > AZIMUTH_DRAW_EPS) {
|
||||||
@@ -199,59 +218,67 @@ public class CompassView extends BaseDockWidget {
|
|||||||
currentAzimuth = normalizeAngle(currentAzimuth);
|
currentAzimuth = normalizeAngle(currentAzimuth);
|
||||||
postInvalidateOnAnimation();
|
postInvalidateOnAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
|
// === Шкала компаса ===
|
||||||
// не наезжала на label-строку HEADING/MAG.
|
// ВСЕ метрики шкалы выражены в долях от фактической высоты scaleH —
|
||||||
|
// тогда буквы N/S/W/E и градусные подписи никогда не вылезают за
|
||||||
|
// нижнюю границу виджета, даже если пользователь сжал его ручкой.
|
||||||
float centerX = left + w / 2f;
|
float centerX = left + w / 2f;
|
||||||
float scaleTop = dividerY + dp(4);
|
float scaleTop = dividerY + dp(4);
|
||||||
float centerY = scaleTop + (bottom - scaleTop) * 0.5f;
|
float scaleH = Math.max(dp(28), bottom - scaleTop);
|
||||||
|
float centerY = scaleTop + scaleH * 0.5f;
|
||||||
|
|
||||||
|
// Размеры тиков и подписей — фракции от scaleH.
|
||||||
|
float majorTickH = scaleH * 0.18f;
|
||||||
|
float minorTickH = scaleH * 0.09f;
|
||||||
|
float degreeTextY = centerY - scaleH * 0.32f; // подпись 0/30/60... над центром
|
||||||
|
float letterBaseY = centerY + scaleH * 0.40f; // буквы N/E/S/W под центром
|
||||||
|
float degreeTextSize = Math.max(dp(8), scaleH * 0.22f);
|
||||||
|
|
||||||
float visibleDegrees = 120;
|
float visibleDegrees = 120;
|
||||||
|
|
||||||
// Рисуем деления шкалы
|
|
||||||
for (int degree = 0; degree < 360; degree += 15) {
|
for (int degree = 0; degree < 360; degree += 15) {
|
||||||
// Вычисляем относительное положение деления
|
|
||||||
float relativeDegree = getShortestRotation(currentAzimuth, degree);
|
float relativeDegree = getShortestRotation(currentAzimuth, degree);
|
||||||
|
if (Math.abs(relativeDegree) > visibleDegrees / 2) continue;
|
||||||
// Рисуем только видимые деления
|
|
||||||
if (Math.abs(relativeDegree) <= visibleDegrees / 2) {
|
float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2);
|
||||||
float x = centerX + (relativeDegree / (visibleDegrees / 2)) * (w / 2);
|
float lineHeight = (degree % 30 == 0) ? majorTickH : minorTickH;
|
||||||
float lineHeight = (degree % 30 == 0) ? 20 * scaleFactor : 10 * scaleFactor;
|
canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
|
||||||
canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
|
|
||||||
|
if (degree % 30 == 0) {
|
||||||
if (degree % 30 == 0) {
|
paint.setTextSize(degreeTextSize);
|
||||||
String degreeText = String.valueOf(degree);
|
canvas.drawText(String.valueOf(degree), x, degreeTextY, paint);
|
||||||
paint.setTextSize(16 * scaleFactor);
|
}
|
||||||
canvas.drawText(degreeText, x, centerY - 30 * scaleFactor, paint);
|
if (degree % 45 == 0) {
|
||||||
}
|
int directionIndex = (degree / 45) % 8;
|
||||||
if (degree % 45 == 0) {
|
if (directionIndex < directions.length) {
|
||||||
int directionIndex = (degree / 45) % 8;
|
// Буква стороны света увеличивается при приближении к центру.
|
||||||
if (directionIndex < directions.length) {
|
float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
|
||||||
// Буква стороны света увеличивается при приближении к центру
|
// На краях ~0.35*scaleH, в центре ~0.7*scaleH — никогда не больше scaleH.
|
||||||
float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
|
float letterSize = scaleH * (0.35f + 0.35f * proximity);
|
||||||
float letterSize = (24f + 36f * proximity) * scaleFactor; // 24..48
|
paint.setTextSize(letterSize);
|
||||||
paint.setTextSize(letterSize);
|
canvas.drawText(directions[directionIndex], x, letterBaseY, paint);
|
||||||
canvas.drawText(directions[directionIndex], x, centerY + 50 * scaleFactor, paint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рисуем суда
|
// Рисуем суда: размер тоже скейлим по scaleH.
|
||||||
|
float vesselScale = scaleH / dp(60); // 1.0 при scaleH=60dp
|
||||||
|
vesselScale = Math.max(0.6f, Math.min(1.6f, vesselScale));
|
||||||
for (AISVessel vessel : nearbyVessels) {
|
for (AISVessel vessel : nearbyVessels) {
|
||||||
float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse());
|
float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse());
|
||||||
if (Math.abs(relativeBearing) <= visibleDegrees / 2) {
|
if (Math.abs(relativeBearing) <= visibleDegrees / 2) {
|
||||||
float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2);
|
float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2);
|
||||||
double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0;
|
double distance = ourVessel != null ? GeoUtils.calculateDistance(ourVessel, vessel) : 0;
|
||||||
float size = calculateVesselSize((float) distance) * scaleFactor;
|
float size = calculateVesselSize((float) distance) * vesselScale;
|
||||||
vesselPaint.setColor(getVesselColor(vessel));
|
vesselPaint.setColor(getVesselColor(vessel));
|
||||||
drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth));
|
drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Центральная линия (направление вперёд) — только в области шкалы,
|
// Центральная линия (направление вперёд) — только в области шкалы,
|
||||||
// чтобы не пересекать шапку HEADING/MAG.
|
// чтобы не пересекать шапку HEADING/MAG.
|
||||||
paint.setColor(Color.RED);
|
paint.setColor(Color.RED);
|
||||||
paint.setStrokeWidth(3 * scaleFactor);
|
paint.setStrokeWidth(Math.max(2f, scaleH * 0.05f));
|
||||||
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
|
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
|
||||||
paint.setColor(TICK_COLOR);
|
paint.setColor(TICK_COLOR);
|
||||||
paint.setStrokeWidth(1);
|
paint.setStrokeWidth(1);
|
||||||
@@ -360,7 +387,8 @@ public class CompassView extends BaseDockWidget {
|
|||||||
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
|
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
|
||||||
labelPaint.setTextAlign(Paint.Align.CENTER);
|
labelPaint.setTextAlign(Paint.Align.CENTER);
|
||||||
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
labelPaint.setTextSize(dp(9) * Math.max(0.7f, Math.min(1.4f, scaleFactor)));
|
||||||
canvas.drawText("HEADING", cx, cy + dp(14), labelPaint);
|
canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
|
||||||
|
cx, cy + dp(14), labelPaint);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,38 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
super(context, attrs);
|
super(context, attrs);
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int getDefaultDockHeightDp() {
|
||||||
|
// Fallback на случай, если по какой-то причине measureDockContentHeightPx
|
||||||
|
// не сработает. Реальная высота считается через measureDockContentHeightPx.
|
||||||
|
return 88;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean getDefaultDockTop() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int measureDockContentHeightPx(int widthPx) {
|
||||||
|
// Контент состоит из 2 строк «label + value»:
|
||||||
|
// 1) POSITION + координаты,
|
||||||
|
// 2) SOG | COG | ACC.
|
||||||
|
// Каждая строка — labelH + valueH, плюс паддинги 8dp сверху/снизу и
|
||||||
|
// зазор 10dp между строками. Размеры берём ровно из тех Paint'ов,
|
||||||
|
// которыми отрисовка пользуется — это гарантирует, что любая правка
|
||||||
|
// размера шрифта автоматически подстроит высоту виджета.
|
||||||
|
float labelH = (labelPaint != null ? labelPaint.getTextSize() : dp(11)) * 1.1f;
|
||||||
|
float valueH = (textPaint != null ? textPaint.getTextSize() : dp(16)) * 1.15f;
|
||||||
|
float total = dp(8) // верхний внутренний отступ
|
||||||
|
+ labelH + valueH // строка 1: POSITION
|
||||||
|
+ dp(10) // зазор между блоками
|
||||||
|
+ labelH + valueH // строка 2: SOG/COG/ACC
|
||||||
|
+ dp(8); // нижний внутренний отступ
|
||||||
|
return (int) Math.ceil(total);
|
||||||
|
}
|
||||||
|
|
||||||
private void init() {
|
private void init() {
|
||||||
backgroundPaint = new Paint();
|
backgroundPaint = new Paint();
|
||||||
backgroundPaint.setColor(BACKGROUND_COLOR);
|
backgroundPaint.setColor(BACKGROUND_COLOR);
|
||||||
@@ -125,7 +156,7 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
|
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
|
||||||
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
|
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
|
||||||
} else {
|
} else {
|
||||||
coordinatesText = "нет фикса";
|
coordinatesText = getResources().getString(com.grigowashere.aismap.R.string.coords_value_no_fix);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vessel.getSpeed() > 0.05) {
|
if (vessel.getSpeed() > 0.05) {
|
||||||
@@ -195,34 +226,40 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
float innerTop = top + dp(8);
|
float innerTop = top + dp(8);
|
||||||
float innerBottom = bottom - dp(8);
|
float innerBottom = bottom - dp(8);
|
||||||
|
|
||||||
// Строка 1: координаты (с подписью "POSITION").
|
// Строка 1: координаты (с подписью "КООРДИНАТЫ").
|
||||||
Paint posPaint = getCoordinatesPaint();
|
Paint posPaint = getCoordinatesPaint();
|
||||||
float labelH = labelPaint.getTextSize() * 1.1f;
|
float labelH = labelPaint.getTextSize() * 1.1f;
|
||||||
float valueH = posPaint.getTextSize() * 1.15f;
|
float valueH = posPaint.getTextSize() * 1.15f;
|
||||||
|
|
||||||
|
android.content.res.Resources res = getResources();
|
||||||
|
|
||||||
float y = innerTop + labelH;
|
float y = innerTop + labelH;
|
||||||
canvas.drawText("POSITION", innerLeft, y, labelPaint);
|
canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_position),
|
||||||
|
innerLeft, y, labelPaint);
|
||||||
y += valueH;
|
y += valueH;
|
||||||
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
|
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
|
||||||
|
|
||||||
// Строка 2: SOG | COG | ACC в три колонки.
|
// Строка 2: SOG/COG/ACC в три колонки.
|
||||||
float colTop = y + dp(10);
|
float colTop = y + dp(10);
|
||||||
float colW = (innerRight - innerLeft) / 3f;
|
float colW = (innerRight - innerLeft) / 3f;
|
||||||
float colLabelY = colTop + labelH;
|
float colLabelY = colTop + labelH;
|
||||||
float colValueY = colLabelY + valueH;
|
float colValueY = colLabelY + valueH;
|
||||||
|
|
||||||
// SOG
|
// SOG (скорость).
|
||||||
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
|
canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_sog),
|
||||||
|
innerLeft, colLabelY, labelPaint);
|
||||||
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
|
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
|
||||||
|
|
||||||
// COG
|
// COG (курс по земле).
|
||||||
float cogX = innerLeft + colW;
|
float cogX = innerLeft + colW;
|
||||||
canvas.drawText("COG", cogX, colLabelY, labelPaint);
|
canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_cog),
|
||||||
|
cogX, colLabelY, labelPaint);
|
||||||
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
|
canvas.drawText(cogText, cogX, colValueY, getCOGPaint());
|
||||||
|
|
||||||
// ACC
|
// ACC (точность).
|
||||||
float accX = innerLeft + colW * 2f;
|
float accX = innerLeft + colW * 2f;
|
||||||
canvas.drawText("ACC", accX, colLabelY, labelPaint);
|
canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_acc),
|
||||||
|
accX, colLabelY, labelPaint);
|
||||||
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
|
canvas.drawText(accuracyText, accX, colValueY, getAccuracyPaint());
|
||||||
|
|
||||||
if (colValueY > innerBottom) {
|
if (colValueY > innerBottom) {
|
||||||
@@ -278,7 +315,8 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
|
|
||||||
float y = centerY - totalH / 2f + smallLabel;
|
float y = centerY - totalH / 2f + smallLabel;
|
||||||
|
|
||||||
drawCentered(canvas, "POSITION", centerX, y, labelPaint);
|
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_position),
|
||||||
|
centerX, y, labelPaint);
|
||||||
y += lineH;
|
y += lineH;
|
||||||
drawCentered(canvas, latLine, centerX, y, posPaint);
|
drawCentered(canvas, latLine, centerX, y, posPaint);
|
||||||
y += lineH;
|
y += lineH;
|
||||||
@@ -291,14 +329,17 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
|||||||
// SOG / COG бок о бок.
|
// SOG / COG бок о бок.
|
||||||
float colCenterL = centerX - radius * 0.45f;
|
float colCenterL = centerX - radius * 0.45f;
|
||||||
float colCenterR = centerX + radius * 0.45f;
|
float colCenterR = centerX + radius * 0.45f;
|
||||||
drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
|
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_sog),
|
||||||
drawCentered(canvas, "COG", colCenterR, y, labelPaint);
|
colCenterL, y, labelPaint);
|
||||||
|
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_cog),
|
||||||
|
colCenterR, y, labelPaint);
|
||||||
y += bigValue + lineGap;
|
y += bigValue + lineGap;
|
||||||
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
|
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
|
||||||
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
|
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
|
||||||
y += dp(6);
|
y += dp(6);
|
||||||
|
|
||||||
drawCentered(canvas, "ACC", centerX, y, labelPaint);
|
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_acc),
|
||||||
|
centerX, y, labelPaint);
|
||||||
y += smallValue + lineGap;
|
y += smallValue + lineGap;
|
||||||
drawCentered(canvas, accuracyText, centerX, y, accPaint);
|
drawCentered(canvas, accuracyText, centerX, y, accPaint);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package com.grigowashere.aismap.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Rect;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
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;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Виджет «Ближайшие цели» — таблица AIS-целей в радиусе жёлтого кольца
|
||||||
|
* предупреждения. Колонки: имя/MMSI, пеленг (°), дистанция (nm/km — в
|
||||||
|
* зависимости от настроек).
|
||||||
|
* <p>Обновление выполняется через {@link #setEntries(List)} с частотой 1 Hz из
|
||||||
|
* MainActivity, источник данных — {@link AppCoordinator#getDangerTargets(double, int)}
|
||||||
|
* с радиусом {@link SettingsManager#getWarningRadiusMeters()}.
|
||||||
|
*/
|
||||||
|
public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||||
|
|
||||||
|
/** Запись таблицы: имя/MMSI + пеленг + дистанция в метрах. */
|
||||||
|
public static final class DangerEntry {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int MAX_ROWS = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Высота dock-режима по умолчанию (dp). Достаточная, чтобы вместить заголовок
|
||||||
|
* и 2 строки целей мелким шрифтом. Пользователь может растянуть ручкой за
|
||||||
|
* нижний край, если хочет увидеть больше строк.
|
||||||
|
*/
|
||||||
|
private static final int DEFAULT_DOCK_HEIGHT_DP_DANGER = 72;
|
||||||
|
|
||||||
|
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 = 0xFFFFA000;
|
||||||
|
|
||||||
|
private Paint backgroundPaint;
|
||||||
|
private Paint titlePaint;
|
||||||
|
private Paint labelPaint;
|
||||||
|
private Paint textPaint;
|
||||||
|
private Paint accentPaint;
|
||||||
|
private Paint dividerPaint;
|
||||||
|
|
||||||
|
private final List<DangerEntry> entries = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
private SettingsManager settingsManager;
|
||||||
|
|
||||||
|
public DangerTargetsDockWidget(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DangerTargetsDockWidget(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int getDefaultDockHeightDp() {
|
||||||
|
return DEFAULT_DOCK_HEIGHT_DP_DANGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean getDefaultDockTop() {
|
||||||
|
// Виджет «Ближайшие цели» по умолчанию сидит ВНИЗУ над координатами —
|
||||||
|
// это самая безболезненная для карты позиция: при отсутствии целей
|
||||||
|
// он вообще скрыт, а когда появляется, не разрывает обзор по центру.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected int measureDockContentHeightPx(int widthPx) {
|
||||||
|
// Высота зависит от количества опасных целей:
|
||||||
|
// * 1 строка → ~52dp
|
||||||
|
// * 2 строки → ~70dp
|
||||||
|
// * 3 строки → ~88dp
|
||||||
|
// * 4 строки → ~106dp
|
||||||
|
// * 5 строк → ~124dp
|
||||||
|
// Когда целей нет — виджет вообще GONE (см. MainActivity.updateDangerWidget),
|
||||||
|
// но если попадём сюда — считаем минимальную высоту с пустой строкой.
|
||||||
|
int count;
|
||||||
|
synchronized (entries) {
|
||||||
|
count = entries.size();
|
||||||
|
}
|
||||||
|
float titleH = (titlePaint != null ? titlePaint.getTextSize() : dp(11));
|
||||||
|
float rowH = (textPaint != null ? textPaint.getTextSize() : dp(12)) + dp(6);
|
||||||
|
float emptyH = (labelPaint != null ? labelPaint.getTextSize() : dp(10));
|
||||||
|
|
||||||
|
// top pad + title + (rowsH ИЛИ empty-строка) + bottom pad
|
||||||
|
float total;
|
||||||
|
if (count <= 0) {
|
||||||
|
total = dp(4) + titleH + dp(6) + emptyH + dp(8);
|
||||||
|
} else {
|
||||||
|
total = dp(4) + titleH + dp(6) + rowH * count + dp(8);
|
||||||
|
}
|
||||||
|
// Гарантируем минимум 52dp — иначе на 1 цель текст касается рамки.
|
||||||
|
return (int) Math.ceil(Math.max(dp(52), total));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
settingsManager = new SettingsManager(getContext());
|
||||||
|
|
||||||
|
backgroundPaint = new Paint();
|
||||||
|
backgroundPaint.setColor(BACKGROUND_COLOR);
|
||||||
|
backgroundPaint.setStyle(Paint.Style.FILL);
|
||||||
|
backgroundPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
titlePaint = new Paint();
|
||||||
|
titlePaint.setColor(ACCENT_COLOR);
|
||||||
|
titlePaint.setTextSize(dp(11));
|
||||||
|
titlePaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
titlePaint.setLetterSpacing(0.08f);
|
||||||
|
titlePaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
labelPaint = new Paint();
|
||||||
|
labelPaint.setColor(LABEL_COLOR);
|
||||||
|
labelPaint.setTextSize(dp(10));
|
||||||
|
labelPaint.setTypeface(Typeface.DEFAULT);
|
||||||
|
labelPaint.setLetterSpacing(0.04f);
|
||||||
|
labelPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
textPaint = new Paint();
|
||||||
|
textPaint.setColor(TEXT_COLOR);
|
||||||
|
textPaint.setTextSize(dp(12));
|
||||||
|
textPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
textPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
accentPaint = new Paint();
|
||||||
|
accentPaint.setColor(ACCENT_COLOR);
|
||||||
|
accentPaint.setTextSize(dp(12));
|
||||||
|
accentPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
accentPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
dividerPaint = new Paint();
|
||||||
|
dividerPaint.setColor(0x33FFFFFF);
|
||||||
|
dividerPaint.setStrokeWidth(dp(1));
|
||||||
|
dividerPaint.setAntiAlias(true);
|
||||||
|
|
||||||
|
setBackgroundColor(android.graphics.Color.TRANSPARENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет список опасных целей, пересчитывает высоту виджета
|
||||||
|
* (через requestLayout — measureDockContentHeightPx зависит от числа целей)
|
||||||
|
* и перерисовывает контент.
|
||||||
|
*/
|
||||||
|
public void setEntries(List<DangerEntry> newEntries) {
|
||||||
|
int prevSize;
|
||||||
|
int newSize;
|
||||||
|
synchronized (entries) {
|
||||||
|
prevSize = entries.size();
|
||||||
|
entries.clear();
|
||||||
|
if (newEntries != null) {
|
||||||
|
for (DangerEntry e : newEntries) {
|
||||||
|
if (e != null) entries.add(e);
|
||||||
|
if (entries.size() >= MAX_ROWS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newSize = entries.size();
|
||||||
|
}
|
||||||
|
// requestLayout только если число строк реально изменилось — лишний
|
||||||
|
// measure-pass на 1Hz обновлении содержимого ни к чему.
|
||||||
|
if (prevSize != newSize) {
|
||||||
|
requestLayout();
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDrawDock(Canvas canvas) {
|
||||||
|
int width = getWidth();
|
||||||
|
int height = getHeight();
|
||||||
|
if (width <= 0 || height <= 0) return;
|
||||||
|
|
||||||
|
canvas.drawRect(0, 0, width, height, backgroundPaint);
|
||||||
|
|
||||||
|
float left = getPaddingLeft();
|
||||||
|
float top = getPaddingTop();
|
||||||
|
float right = width - getPaddingRight();
|
||||||
|
float bottom = height - getPaddingBottom();
|
||||||
|
if (right - left <= 0 || bottom - top <= 0) return;
|
||||||
|
|
||||||
|
// Тонкая красная полоска вдоль края, ближайшего к карте — она работает как
|
||||||
|
// акцент и одновременно как resize-зона (см. BaseDockWidget).
|
||||||
|
Paint edgePaint = new Paint(dividerPaint);
|
||||||
|
edgePaint.setColor(ACCENT_COLOR);
|
||||||
|
edgePaint.setStrokeWidth(dp(2));
|
||||||
|
if (isDockTop()) {
|
||||||
|
canvas.drawLine(left, bottom - dp(1), right, bottom - dp(1), edgePaint);
|
||||||
|
} else {
|
||||||
|
canvas.drawLine(left, top + dp(1), right, top + dp(1), edgePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
float padX = dp(12);
|
||||||
|
float innerLeft = left + padX;
|
||||||
|
float innerRight = right - padX;
|
||||||
|
|
||||||
|
// Снапшот текущих записей.
|
||||||
|
List<DangerEntry> snapshot;
|
||||||
|
synchronized (entries) {
|
||||||
|
snapshot = new ArrayList<>(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Заголовок: «Ближайшие цели · N» ===
|
||||||
|
float titleBaseline = top + dp(4) + titlePaint.getTextSize();
|
||||||
|
String titleBase = getResources().getString(R.string.danger_widget_title);
|
||||||
|
String title = snapshot.isEmpty()
|
||||||
|
? titleBase
|
||||||
|
: titleBase + " \u00B7 " + snapshot.size();
|
||||||
|
canvas.drawText(title, innerLeft, titleBaseline, titlePaint);
|
||||||
|
|
||||||
|
// Пустое состояние: рендерим короткое сообщение тонким шрифтом и выходим.
|
||||||
|
if (snapshot.isEmpty()) {
|
||||||
|
float emptyBaseline = titleBaseline + labelPaint.getTextSize() + dp(4);
|
||||||
|
canvas.drawText(getResources().getString(R.string.danger_widget_empty),
|
||||||
|
innerLeft, emptyBaseline, labelPaint);
|
||||||
|
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 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());
|
||||||
|
|
||||||
|
// Высоту строки считаем по реальному текстовому шрифту.
|
||||||
|
float rowH = textPaint.getTextSize() + dp(6);
|
||||||
|
float y = titleBaseline + dp(6) + textPaint.getTextSize();
|
||||||
|
for (DangerEntry e : snapshot) {
|
||||||
|
if (y > bottom - dp(2)) break;
|
||||||
|
String rawName = (e.name == null || e.name.isEmpty()) ? "\u2014" : e.name;
|
||||||
|
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 = textPaint.measureText(distance);
|
||||||
|
canvas.drawText(distance, colDistanceRight - distanceWidth, y, textPaint);
|
||||||
|
float cpaWidth = accentPaint.measureText(cpa);
|
||||||
|
canvas.drawText(cpa, colCpaRight - cpaWidth, y, accentPaint);
|
||||||
|
|
||||||
|
y += rowH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDrawCircle(Canvas canvas) {
|
||||||
|
int width = getWidth();
|
||||||
|
int height = getHeight();
|
||||||
|
int centerX = width / 2;
|
||||||
|
int centerY = height / 2;
|
||||||
|
int radius = Math.min(width, height) / 2 - (int) dp(4);
|
||||||
|
|
||||||
|
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
|
||||||
|
|
||||||
|
Paint borderPaint = new Paint();
|
||||||
|
borderPaint.setColor(ACCENT_COLOR);
|
||||||
|
borderPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
borderPaint.setStrokeWidth(dp(2));
|
||||||
|
borderPaint.setAntiAlias(true);
|
||||||
|
canvas.drawCircle(centerX, centerY, radius, borderPaint);
|
||||||
|
|
||||||
|
// В compact-режиме показываем только число опасных целей и ближайшую.
|
||||||
|
int count;
|
||||||
|
DangerEntry nearest;
|
||||||
|
synchronized (entries) {
|
||||||
|
count = entries.size();
|
||||||
|
nearest = entries.isEmpty() ? null : entries.get(0);
|
||||||
|
}
|
||||||
|
boolean useNm = settingsManager == null
|
||||||
|
|| SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
|
||||||
|
|
||||||
|
Paint countPaint = new Paint(accentPaint);
|
||||||
|
countPaint.setTextSize(dp(28));
|
||||||
|
Paint subPaint = new Paint(textPaint);
|
||||||
|
subPaint.setTextSize(dp(11));
|
||||||
|
|
||||||
|
String countStr = String.valueOf(count);
|
||||||
|
Rect b = new Rect();
|
||||||
|
countPaint.getTextBounds(countStr, 0, countStr.length(), b);
|
||||||
|
canvas.drawText(countStr, centerX - b.width() / 2f - b.left, centerY, countPaint);
|
||||||
|
|
||||||
|
String label = getResources().getString(R.string.danger_widget_title);
|
||||||
|
b = new Rect();
|
||||||
|
subPaint.getTextBounds(label, 0, label.length(), b);
|
||||||
|
canvas.drawText(label, centerX - b.width() / 2f - b.left,
|
||||||
|
centerY - dp(28), subPaint);
|
||||||
|
|
||||||
|
if (nearest != null) {
|
||||||
|
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,
|
||||||
|
centerY + dp(20), subPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDistance(double meters, boolean useNm) {
|
||||||
|
if (useNm) {
|
||||||
|
double nm = meters / 1852.0;
|
||||||
|
return String.format(Locale.US, "%.2f nm", nm);
|
||||||
|
}
|
||||||
|
if (meters >= 1000.0) {
|
||||||
|
return String.format(Locale.US, "%.2f km", meters / 1000.0);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.0f m", meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ellipsize(String text, Paint paint, float maxWidth) {
|
||||||
|
if (text == null) return "";
|
||||||
|
if (maxWidth <= 0) return text;
|
||||||
|
if (paint.measureText(text) <= maxWidth) return text;
|
||||||
|
String ellipsis = "\u2026";
|
||||||
|
int len = text.length();
|
||||||
|
while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) {
|
||||||
|
len--;
|
||||||
|
}
|
||||||
|
if (len <= 0) return ellipsis;
|
||||||
|
return text.substring(0, len) + ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.grigowashere.aismap.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.R;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компактная роза курса для режима картплоттера (без dock-поведения).
|
||||||
|
*/
|
||||||
|
public class PlotterHeadingView extends View {
|
||||||
|
|
||||||
|
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint ringPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Path needle = new Path();
|
||||||
|
|
||||||
|
private float headingDeg = 0f;
|
||||||
|
private float magneticDeg = Float.NaN;
|
||||||
|
|
||||||
|
public PlotterHeadingView(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlotterHeadingView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
|
||||||
|
int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
|
||||||
|
int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
|
||||||
|
|
||||||
|
labelPaint.setColor(label);
|
||||||
|
labelPaint.setTextSize(dp(9));
|
||||||
|
labelPaint.setLetterSpacing(0.06f);
|
||||||
|
|
||||||
|
valuePaint.setColor(text);
|
||||||
|
valuePaint.setTextSize(dp(16));
|
||||||
|
valuePaint.setFakeBoldText(true);
|
||||||
|
|
||||||
|
ringPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
ringPaint.setStrokeWidth(dp(1.5f));
|
||||||
|
ringPaint.setColor(accent);
|
||||||
|
|
||||||
|
tickPaint.set(ringPaint);
|
||||||
|
tickPaint.setStrokeWidth(dp(1));
|
||||||
|
|
||||||
|
needlePaint.setStyle(Paint.Style.FILL);
|
||||||
|
needlePaint.setColor(accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHeading(float headingDeg, float magneticDeg) {
|
||||||
|
this.headingDeg = headingDeg;
|
||||||
|
this.magneticDeg = magneticDeg;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
int w = getWidth();
|
||||||
|
int h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
String hdgLabel = getContext().getString(R.string.radar_plotter_heading_label);
|
||||||
|
canvas.drawText(hdgLabel, dp(8), dp(12), labelPaint);
|
||||||
|
|
||||||
|
String hdgVal = String.format(Locale.US, "%03.0f\u00B0", normalize(headingDeg));
|
||||||
|
canvas.drawText(hdgVal, dp(8), dp(32), valuePaint);
|
||||||
|
|
||||||
|
String mag = null;
|
||||||
|
if (!Float.isNaN(magneticDeg)) {
|
||||||
|
mag = String.format(Locale.US, "MAG %03.0f\u00B0", normalize(magneticDeg));
|
||||||
|
canvas.drawText(mag, dp(8), dp(48), labelPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
float textBlockW = Math.max(labelPaint.measureText(hdgLabel), valuePaint.measureText(hdgVal));
|
||||||
|
if (mag != null) {
|
||||||
|
textBlockW = Math.max(textBlockW, labelPaint.measureText(mag));
|
||||||
|
}
|
||||||
|
float pad = dp(8);
|
||||||
|
float roseLeft = pad + textBlockW + pad;
|
||||||
|
float roseRight = w - pad;
|
||||||
|
float roseTop = pad;
|
||||||
|
float roseBottom = h - pad;
|
||||||
|
float availW = Math.max(0f, roseRight - roseLeft);
|
||||||
|
float availH = Math.max(0f, roseBottom - roseTop);
|
||||||
|
float cx = roseLeft + availW * 0.5f;
|
||||||
|
float cy = roseTop + availH * 0.5f;
|
||||||
|
float r = Math.min(availW, availH) * 0.45f - dp(2);
|
||||||
|
if (r < dp(8)) {
|
||||||
|
r = dp(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.drawCircle(cx, cy, r, ringPaint);
|
||||||
|
String[] dirs = {"N", "E", "S", "W"};
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
double ang = Math.toRadians(i * 90 - headingDeg);
|
||||||
|
float tx = cx + (float) (Math.sin(ang) * (r + dp(10)));
|
||||||
|
float ty = cy - (float) (Math.cos(ang) * (r + dp(10)));
|
||||||
|
String d = dirs[i];
|
||||||
|
canvas.drawText(d, tx - labelPaint.measureText(d) / 2f, ty + dp(4), labelPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int deg = 0; deg < 360; deg += 30) {
|
||||||
|
double ang = Math.toRadians(deg - headingDeg);
|
||||||
|
float x1 = cx + (float) (Math.sin(ang) * (r - dp(4)));
|
||||||
|
float y1 = cy - (float) (Math.cos(ang) * (r - dp(4)));
|
||||||
|
float x2 = cx + (float) (Math.sin(ang) * r);
|
||||||
|
float y2 = cy - (float) (Math.cos(ang) * r);
|
||||||
|
canvas.drawLine(x1, y1, x2, y2, tickPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
double needleAng = Math.toRadians(-headingDeg);
|
||||||
|
float nx = cx + (float) (Math.sin(needleAng) * (r - dp(6)));
|
||||||
|
float ny = cy - (float) (Math.cos(needleAng) * (r - dp(6)));
|
||||||
|
needle.reset();
|
||||||
|
needle.moveTo(cx, cy);
|
||||||
|
needle.lineTo(nx, ny);
|
||||||
|
needle.lineTo(cx + dp(4), cy);
|
||||||
|
needle.close();
|
||||||
|
canvas.drawPath(needle, needlePaint);
|
||||||
|
canvas.drawCircle(cx, cy, dp(3), needlePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float normalize(float deg) {
|
||||||
|
float d = deg % 360f;
|
||||||
|
return d < 0 ? d + 360f : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float dp(float v) {
|
||||||
|
return v * getResources().getDisplayMetrics().density;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.grigowashere.aismap.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.grigowashere.aismap.R;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Простой аналоговый спидометр (узлы) в стиле картплоттера.
|
||||||
|
*/
|
||||||
|
public class PlotterSpeedometerView extends View {
|
||||||
|
|
||||||
|
private static final float MAX_SPEED_KNOTS = 30f;
|
||||||
|
|
||||||
|
private final Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint arcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint tickPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint needlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint valuePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final RectF arcRect = new RectF();
|
||||||
|
|
||||||
|
private float speedKnots = 0f;
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
public PlotterSpeedometerView(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlotterSpeedometerView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
title = getContext().getString(R.string.radar_plotter_sog_label);
|
||||||
|
int labelColor = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
|
||||||
|
int textColor = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
|
||||||
|
int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
|
||||||
|
|
||||||
|
bgPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
bgPaint.setStrokeWidth(dp(2));
|
||||||
|
bgPaint.setColor(0x44FFFFFF);
|
||||||
|
|
||||||
|
arcPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
arcPaint.setStrokeWidth(dp(6));
|
||||||
|
arcPaint.setColor(accent);
|
||||||
|
arcPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
|
||||||
|
tickPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
tickPaint.setStrokeWidth(dp(1));
|
||||||
|
tickPaint.setColor(labelColor);
|
||||||
|
|
||||||
|
needlePaint.setStyle(Paint.Style.STROKE);
|
||||||
|
needlePaint.setStrokeWidth(dp(2.5f));
|
||||||
|
needlePaint.setColor(accent);
|
||||||
|
needlePaint.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
|
||||||
|
labelPaint.setColor(labelColor);
|
||||||
|
labelPaint.setTextSize(dp(10));
|
||||||
|
labelPaint.setLetterSpacing(0.08f);
|
||||||
|
|
||||||
|
valuePaint.setColor(textColor);
|
||||||
|
valuePaint.setTextSize(dp(18));
|
||||||
|
valuePaint.setFakeBoldText(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSpeedKnots(double knots) {
|
||||||
|
float v = (float) Math.max(0.0, Math.min(MAX_SPEED_KNOTS, knots));
|
||||||
|
if (Math.abs(v - speedKnots) > 0.05f) {
|
||||||
|
speedKnots = v;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
int w = getWidth();
|
||||||
|
int h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
float pad = dp(8);
|
||||||
|
float titleY = dp(14);
|
||||||
|
canvas.drawText(title, w * 0.5f - labelPaint.measureText(title) / 2f, titleY, labelPaint);
|
||||||
|
|
||||||
|
float cx = w * 0.5f;
|
||||||
|
float bottom = h - pad;
|
||||||
|
float maxRadius = Math.min((w - 2f * pad) * 0.5f, bottom - titleY - pad);
|
||||||
|
float radius = Math.max(dp(12), maxRadius * 0.88f);
|
||||||
|
float cy = bottom;
|
||||||
|
arcRect.set(cx - radius, cy - radius, cx + radius, cy + radius);
|
||||||
|
|
||||||
|
canvas.drawArc(arcRect, 180f, 180f, false, bgPaint);
|
||||||
|
|
||||||
|
float sweep = 180f * (speedKnots / MAX_SPEED_KNOTS);
|
||||||
|
canvas.drawArc(arcRect, 180f, sweep, false, arcPaint);
|
||||||
|
|
||||||
|
for (int k = 0; k <= 30; k += 5) {
|
||||||
|
float frac = k / MAX_SPEED_KNOTS;
|
||||||
|
double ang = Math.toRadians(180 + 180 * frac);
|
||||||
|
float x1 = cx + (float) (Math.cos(ang) * (radius - dp(4)));
|
||||||
|
float y1 = cy + (float) (Math.sin(ang) * (radius - dp(4)));
|
||||||
|
float x2 = cx + (float) (Math.cos(ang) * radius);
|
||||||
|
float y2 = cy + (float) (Math.sin(ang) * radius);
|
||||||
|
canvas.drawLine(x1, y1, x2, y2, tickPaint);
|
||||||
|
if (k % 10 == 0) {
|
||||||
|
String t = String.valueOf(k);
|
||||||
|
float tw = labelPaint.measureText(t);
|
||||||
|
canvas.drawText(t,
|
||||||
|
cx + (float) (Math.cos(ang) * (radius - dp(16))) - tw / 2f,
|
||||||
|
cy + (float) (Math.sin(ang) * (radius - dp(16))) + dp(4),
|
||||||
|
labelPaint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double needleAng = Math.toRadians(180 + 180 * (speedKnots / MAX_SPEED_KNOTS));
|
||||||
|
float nx = cx + (float) (Math.cos(needleAng) * (radius - dp(10)));
|
||||||
|
float ny = cy + (float) (Math.sin(needleAng) * (radius - dp(10)));
|
||||||
|
canvas.drawLine(cx, cy, nx, ny, needlePaint);
|
||||||
|
canvas.drawCircle(cx, cy, dp(4), needlePaint);
|
||||||
|
|
||||||
|
String val = String.format(Locale.US, "%.1f", speedKnots);
|
||||||
|
canvas.drawText(val, cx - valuePaint.measureText(val) / 2f, cy - dp(6), valuePaint);
|
||||||
|
String unit = "kn";
|
||||||
|
canvas.drawText(unit, cx - labelPaint.measureText(unit) / 2f, cy + dp(12), labelPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float dp(float v) {
|
||||||
|
return v * getResources().getDisplayMetrics().density;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.grigowashere.aismap.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Typeface;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
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;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Таблица ближайших AIS-целей для режима картплоттера.
|
||||||
|
*/
|
||||||
|
public class PlotterTargetsTableView extends View {
|
||||||
|
|
||||||
|
public static final class Row {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int MAX_ROWS = 8;
|
||||||
|
|
||||||
|
private final Paint titlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint headerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint rowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint accentPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint emptyPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
|
||||||
|
private final List<Row> rows = Collections.synchronizedList(new ArrayList<>());
|
||||||
|
private SettingsManager settingsManager;
|
||||||
|
private String title;
|
||||||
|
private String emptyText;
|
||||||
|
|
||||||
|
public PlotterTargetsTableView(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlotterTargetsTableView(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
settingsManager = new SettingsManager(getContext());
|
||||||
|
title = getContext().getString(R.string.radar_plotter_table_title);
|
||||||
|
emptyText = getContext().getString(R.string.radar_plotter_table_empty);
|
||||||
|
|
||||||
|
int label = ContextCompat.getColor(getContext(), R.color.plotter_text_label);
|
||||||
|
int text = ContextCompat.getColor(getContext(), R.color.plotter_text_primary);
|
||||||
|
int accent = ContextCompat.getColor(getContext(), R.color.plotter_text_accent);
|
||||||
|
|
||||||
|
titlePaint.setColor(accent);
|
||||||
|
titlePaint.setTextSize(dp(10));
|
||||||
|
titlePaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
titlePaint.setLetterSpacing(0.06f);
|
||||||
|
|
||||||
|
headerPaint.setColor(label);
|
||||||
|
headerPaint.setTextSize(dp(9));
|
||||||
|
|
||||||
|
rowPaint.setColor(text);
|
||||||
|
rowPaint.setTextSize(dp(10));
|
||||||
|
rowPaint.setTypeface(Typeface.DEFAULT_BOLD);
|
||||||
|
|
||||||
|
accentPaint.set(rowPaint);
|
||||||
|
accentPaint.setColor(accent);
|
||||||
|
|
||||||
|
emptyPaint.setColor(label);
|
||||||
|
emptyPaint.setTextSize(dp(10));
|
||||||
|
|
||||||
|
dividerPaint.setColor(0x33FFFFFF);
|
||||||
|
dividerPaint.setStrokeWidth(dp(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRowsFromCoordinatorEntries(List<AppCoordinator.DangerEntry> entries) {
|
||||||
|
List<Row> next = new ArrayList<>();
|
||||||
|
if (entries != null) {
|
||||||
|
for (AppCoordinator.DangerEntry e : entries) {
|
||||||
|
if (e == null || e.vessel == null) continue;
|
||||||
|
String label = e.vessel.getVesselName();
|
||||||
|
if (label == null || label.trim().isEmpty()) {
|
||||||
|
label = e.vessel.getMmsi() != null ? e.vessel.getMmsi() : "—";
|
||||||
|
}
|
||||||
|
next.add(new Row(label, e.bearingDegrees, e.distanceMeters,
|
||||||
|
e.cpaValid, e.cpaMeters, e.tcpaMinutes));
|
||||||
|
if (next.size() >= MAX_ROWS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (rows) {
|
||||||
|
rows.clear();
|
||||||
|
rows.addAll(next);
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
int w = getWidth();
|
||||||
|
int h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
float pad = dp(8);
|
||||||
|
float y = pad + dp(12);
|
||||||
|
canvas.drawText(title, pad, y, titlePaint);
|
||||||
|
y += dp(14);
|
||||||
|
|
||||||
|
boolean useNm = settingsManager != null
|
||||||
|
&& SettingsManager.RANGE_UNIT_NM.equals(settingsManager.getRangeUnit());
|
||||||
|
String cpaNa = getContext().getString(R.string.radar_plotter_cpa_na);
|
||||||
|
|
||||||
|
float colName = pad;
|
||||||
|
float colBrg = w * 0.52f;
|
||||||
|
float colRng = w * 0.68f;
|
||||||
|
float colCpa = w * 0.84f;
|
||||||
|
|
||||||
|
canvas.drawText(getContext().getString(R.string.radar_plotter_col_name), colName, y, headerPaint);
|
||||||
|
canvas.drawText(getContext().getString(R.string.radar_plotter_col_brg), colBrg, y, headerPaint);
|
||||||
|
canvas.drawText(getContext().getString(R.string.radar_plotter_col_rng), colRng, y, headerPaint);
|
||||||
|
canvas.drawText(getContext().getString(R.string.radar_plotter_col_cpa), colCpa, y, headerPaint);
|
||||||
|
y += dp(6);
|
||||||
|
canvas.drawLine(pad, y, w - pad, y, dividerPaint);
|
||||||
|
y += dp(10);
|
||||||
|
|
||||||
|
List<Row> snapshot;
|
||||||
|
synchronized (rows) {
|
||||||
|
snapshot = new ArrayList<>(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.isEmpty()) {
|
||||||
|
canvas.drawText(emptyText, pad, y, emptyPaint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float rowH = dp(14);
|
||||||
|
float nameMax = colBrg - colName - dp(4);
|
||||||
|
for (Row r : snapshot) {
|
||||||
|
String name = ellipsize(r.name, rowPaint, nameMax);
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatDistance(double meters, boolean useNm) {
|
||||||
|
if (useNm) {
|
||||||
|
return String.format(Locale.US, "%.2f", meters / 1852.0);
|
||||||
|
}
|
||||||
|
if (meters >= 1000.0) {
|
||||||
|
return String.format(Locale.US, "%.1f", meters / 1000.0);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.0f", meters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ellipsize(String text, Paint paint, float maxWidth) {
|
||||||
|
if (text == null) return "";
|
||||||
|
if (maxWidth <= 0 || paint.measureText(text) <= maxWidth) return text;
|
||||||
|
String ellipsis = "\u2026";
|
||||||
|
int len = text.length();
|
||||||
|
while (len > 0 && paint.measureText(text.substring(0, len) + ellipsis) > maxWidth) {
|
||||||
|
len--;
|
||||||
|
}
|
||||||
|
return len <= 0 ? ellipsis : text.substring(0, len) + ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private float dp(float v) {
|
||||||
|
return v * getResources().getDisplayMetrics().density;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package com.grigowashere.aismap.view;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.graphics.Path;
|
||||||
|
import android.graphics.PorterDuff;
|
||||||
|
import android.graphics.PorterDuffXfermode;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.Choreographer;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
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;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PPI-наложение: кольца дальности, сетка пеленгов, «свип» и цели AIS.
|
||||||
|
*/
|
||||||
|
public class RadarGraticuleOverlay extends View {
|
||||||
|
|
||||||
|
public static final class Blip {
|
||||||
|
public final double bearingDeg;
|
||||||
|
/** 0..1 относительно радиуса PPI */
|
||||||
|
public final float rangeFraction;
|
||||||
|
public final boolean danger;
|
||||||
|
|
||||||
|
public Blip(double bearingDeg, float rangeFraction, boolean danger) {
|
||||||
|
this.bearingDeg = bearingDeg;
|
||||||
|
this.rangeFraction = rangeFraction;
|
||||||
|
this.danger = danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint gridBrightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint sweepPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint sweepGlowPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint sweepCorePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint blipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint dangerBlipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint vignettePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Path sweepPath = new Path();
|
||||||
|
private final RectF circleRect = new RectF();
|
||||||
|
|
||||||
|
private final List<Blip> blips = new ArrayList<>();
|
||||||
|
private final Choreographer choreographer = Choreographer.getInstance();
|
||||||
|
private final Choreographer.FrameCallback sweepFrameCallback = this::onSweepFrame;
|
||||||
|
|
||||||
|
private float sweepAngle = 0f;
|
||||||
|
private long lastSweepNanos = 0L;
|
||||||
|
private boolean sweepRunning;
|
||||||
|
private double rangeMeters = 1852.0 * 5.0;
|
||||||
|
private String rangeUnit = SettingsManager.RANGE_UNIT_NM;
|
||||||
|
private float headingUpDeg = 0f;
|
||||||
|
|
||||||
|
/** Полный оборот свипа, секунды */
|
||||||
|
private static final float SWEEP_PERIOD_SEC = 5f;
|
||||||
|
|
||||||
|
public RadarGraticuleOverlay(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public RadarGraticuleOverlay(Context context, @Nullable AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
setLayerType(LAYER_TYPE_HARDWARE, null);
|
||||||
|
|
||||||
|
int grid = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid);
|
||||||
|
int gridBright = ContextCompat.getColor(getContext(), R.color.plotter_radar_grid_bright);
|
||||||
|
int sweep = ContextCompat.getColor(getContext(), R.color.plotter_radar_sweep);
|
||||||
|
int blip = ContextCompat.getColor(getContext(), R.color.plotter_target_blip);
|
||||||
|
|
||||||
|
gridPaint.setStyle(Paint.Style.STROKE);
|
||||||
|
gridPaint.setStrokeWidth(dp(1));
|
||||||
|
gridPaint.setColor(grid);
|
||||||
|
|
||||||
|
gridBrightPaint.set(gridPaint);
|
||||||
|
gridBrightPaint.setColor(gridBright);
|
||||||
|
gridBrightPaint.setStrokeWidth(dp(1.2f));
|
||||||
|
|
||||||
|
sweepPaint.setStyle(Paint.Style.FILL);
|
||||||
|
sweepPaint.setColor(sweep);
|
||||||
|
sweepPaint.setAlpha(64);
|
||||||
|
|
||||||
|
sweepGlowPaint.set(sweepPaint);
|
||||||
|
sweepGlowPaint.setAlpha(36);
|
||||||
|
|
||||||
|
sweepCorePaint.setStyle(Paint.Style.STROKE);
|
||||||
|
sweepCorePaint.setColor(sweep);
|
||||||
|
sweepCorePaint.setStrokeWidth(dp(2.5f));
|
||||||
|
sweepCorePaint.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
sweepCorePaint.setAlpha(220);
|
||||||
|
|
||||||
|
blipPaint.setStyle(Paint.Style.FILL);
|
||||||
|
blipPaint.setColor(blip);
|
||||||
|
|
||||||
|
dangerBlipPaint.set(blipPaint);
|
||||||
|
dangerBlipPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_accent));
|
||||||
|
|
||||||
|
labelPaint.setColor(ContextCompat.getColor(getContext(), R.color.plotter_text_label));
|
||||||
|
labelPaint.setTextSize(dp(9));
|
||||||
|
labelPaint.setLetterSpacing(0.05f);
|
||||||
|
|
||||||
|
vignettePaint.setStyle(Paint.Style.FILL);
|
||||||
|
vignettePaint.setColor(0x44000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeMeters(double rangeMeters) {
|
||||||
|
if (rangeMeters > 0) {
|
||||||
|
this.rangeMeters = rangeMeters;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRangeUnit(String unit) {
|
||||||
|
if (unit != null && !unit.isEmpty()) {
|
||||||
|
rangeUnit = unit;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHeadingUpDeg(float headingUpDeg) {
|
||||||
|
this.headingUpDeg = headingUpDeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBlipsFromDangerEntries(List<AppCoordinator.DangerEntry> entries,
|
||||||
|
double dangerRadiusMeters) {
|
||||||
|
List<Blip> next = new ArrayList<>();
|
||||||
|
if (entries != null && rangeMeters > 0) {
|
||||||
|
for (AppCoordinator.DangerEntry e : entries) {
|
||||||
|
if (e == null) continue;
|
||||||
|
float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters);
|
||||||
|
boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters;
|
||||||
|
next.add(new Blip(e.bearingDegrees, frac, danger));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (blips) {
|
||||||
|
blips.clear();
|
||||||
|
blips.addAll(next);
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllTargetsInRange(List<AppCoordinator.DangerEntry> entries,
|
||||||
|
double dangerRadiusMeters) {
|
||||||
|
List<Blip> next = new ArrayList<>();
|
||||||
|
if (entries != null && rangeMeters > 0) {
|
||||||
|
for (AppCoordinator.DangerEntry e : entries) {
|
||||||
|
if (e == null) continue;
|
||||||
|
float frac = (float) Math.min(1.0, e.distanceMeters / rangeMeters);
|
||||||
|
boolean danger = dangerRadiusMeters > 0 && e.distanceMeters <= dangerRadiusMeters;
|
||||||
|
next.add(new Blip(e.bearingDegrees, frac, danger));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized (blips) {
|
||||||
|
blips.clear();
|
||||||
|
blips.addAll(next);
|
||||||
|
}
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow();
|
||||||
|
startSweepAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
stopSweepAnimation();
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startSweepAnimation() {
|
||||||
|
if (sweepRunning) return;
|
||||||
|
sweepRunning = true;
|
||||||
|
lastSweepNanos = System.nanoTime();
|
||||||
|
choreographer.postFrameCallback(sweepFrameCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopSweepAnimation() {
|
||||||
|
if (!sweepRunning) return;
|
||||||
|
sweepRunning = false;
|
||||||
|
choreographer.removeFrameCallback(sweepFrameCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onSweepFrame(long frameTimeNanos) {
|
||||||
|
if (!sweepRunning) return;
|
||||||
|
if (lastSweepNanos > 0L) {
|
||||||
|
float dtSec = (frameTimeNanos - lastSweepNanos) / 1_000_000_000f;
|
||||||
|
sweepAngle = (sweepAngle + (360f / SWEEP_PERIOD_SEC) * dtSec) % 360f;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
lastSweepNanos = frameTimeNanos;
|
||||||
|
choreographer.postFrameCallback(sweepFrameCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
int w = getWidth();
|
||||||
|
int h = getHeight();
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
|
||||||
|
float cx = w * 0.5f;
|
||||||
|
float cy = h * 0.5f;
|
||||||
|
float radius = Math.min(cx, cy) - dp(4);
|
||||||
|
circleRect.set(cx - radius, cy - radius, cx + radius, cy + radius);
|
||||||
|
|
||||||
|
canvas.saveLayer(0, 0, w, h, null);
|
||||||
|
|
||||||
|
// Затемнение за пределами круга (маска PPI)
|
||||||
|
canvas.drawRect(0, 0, w, h, vignettePaint);
|
||||||
|
Paint clear = new Paint();
|
||||||
|
clear.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
|
||||||
|
canvas.drawCircle(cx, cy, radius, clear);
|
||||||
|
clear.setXfermode(null);
|
||||||
|
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(circleRect);
|
||||||
|
|
||||||
|
// Кольца PPI (4 кольца) + метки дальности на пересечениях с осями N/E/S/W
|
||||||
|
for (int i = 1; i <= 4; i++) {
|
||||||
|
float r = radius * i / 4f;
|
||||||
|
Paint p = (i == 4) ? gridBrightPaint : gridPaint;
|
||||||
|
canvas.drawCircle(cx, cy, r, p);
|
||||||
|
drawRingRangeLabels(canvas, cx, cy, r, rangeMeters * i / 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лучи каждые 30°
|
||||||
|
for (int deg = 0; deg < 360; deg += 30) {
|
||||||
|
double rad = Math.toRadians(deg - headingUpDeg);
|
||||||
|
float x2 = cx + (float) (Math.sin(rad) * radius);
|
||||||
|
float y2 = cy - (float) (Math.cos(rad) * radius);
|
||||||
|
canvas.drawLine(cx, cy, x2, y2, deg % 90 == 0 ? gridBrightPaint : gridPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Свип с мягким свечением
|
||||||
|
double sweepRad = Math.toRadians(sweepAngle - headingUpDeg);
|
||||||
|
float tipX = cx + (float) (Math.sin(sweepRad) * radius);
|
||||||
|
float tipY = cy - (float) (Math.cos(sweepRad) * radius);
|
||||||
|
drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.22, sweepGlowPaint);
|
||||||
|
drawSweepWedge(canvas, cx, cy, radius, sweepRad, 0.10, sweepPaint);
|
||||||
|
canvas.drawLine(cx, cy, tipX, tipY, sweepCorePaint);
|
||||||
|
|
||||||
|
List<Blip> snapshot;
|
||||||
|
synchronized (blips) {
|
||||||
|
snapshot = new ArrayList<>(blips);
|
||||||
|
}
|
||||||
|
for (Blip b : snapshot) {
|
||||||
|
double rel = Math.toRadians(b.bearingDeg - headingUpDeg);
|
||||||
|
float dist = radius * b.rangeFraction;
|
||||||
|
float bx = cx + (float) (Math.sin(rel) * dist);
|
||||||
|
float by = cy - (float) (Math.cos(rel) * dist);
|
||||||
|
Paint p = b.danger ? dangerBlipPaint : blipPaint;
|
||||||
|
canvas.drawRect(bx - dp(3), by - dp(3), bx + dp(3), by + dp(3), p);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore();
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawSweepWedge(Canvas canvas, float cx, float cy, float radius,
|
||||||
|
double sweepRad, double halfAngleRad, Paint paint) {
|
||||||
|
sweepPath.reset();
|
||||||
|
sweepPath.moveTo(cx, cy);
|
||||||
|
sweepPath.lineTo(cx + (float) (Math.sin(sweepRad) * radius),
|
||||||
|
cy - (float) (Math.cos(sweepRad) * radius));
|
||||||
|
sweepPath.lineTo(cx + (float) (Math.sin(sweepRad + halfAngleRad) * radius * 0.12f),
|
||||||
|
cy - (float) (Math.cos(sweepRad + halfAngleRad) * radius * 0.12f));
|
||||||
|
sweepPath.close();
|
||||||
|
canvas.drawPath(sweepPath, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Метки дальности кольца на пересечениях с пеленгами 0°/90°/180°/270° (курс вверх). */
|
||||||
|
private void drawRingRangeLabels(Canvas canvas, float cx, float cy, float ringRadius,
|
||||||
|
double ringMeters) {
|
||||||
|
String label = formatRangeLabel(ringMeters);
|
||||||
|
float tw = labelPaint.measureText(label);
|
||||||
|
float th = labelPaint.getTextSize();
|
||||||
|
float pad = dp(3);
|
||||||
|
float northY = cy - ringRadius - pad;
|
||||||
|
canvas.drawText(label, cx - tw / 2f, northY, labelPaint);
|
||||||
|
float southY = cy + ringRadius + th + pad;
|
||||||
|
canvas.drawText(label, cx - tw / 2f, southY, labelPaint);
|
||||||
|
float eastX = cx + ringRadius + pad;
|
||||||
|
canvas.drawText(label, eastX, cy + th / 3f, labelPaint);
|
||||||
|
float westX = cx - ringRadius - pad - tw;
|
||||||
|
canvas.drawText(label, westX, cy + th / 3f, labelPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatRangeLabel(double meters) {
|
||||||
|
if (SettingsManager.RANGE_UNIT_KM.equals(rangeUnit)) {
|
||||||
|
if (meters >= RangeMath.METERS_PER_KM) {
|
||||||
|
return String.format(Locale.US, "%.1f km", meters / RangeMath.METERS_PER_KM);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.0f m", meters);
|
||||||
|
}
|
||||||
|
return String.format(Locale.US, "%.1f nm", meters / RangeMath.METERS_PER_NM);
|
||||||
|
}
|
||||||
|
|
||||||
|
private float dp(float v) {
|
||||||
|
return v * getResources().getDisplayMetrics().density;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,2a10,10 0,1 1,-0.01,0zM12,4a8,8 0,1 0,0.01,0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,6a6,6 0,1 1,-0.01,0zM12,8a4,4 0,1 0,0.01,0z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M12,12 L20,4 L18,12 L12,12z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="1.2"
|
||||||
|
android:pathData="M12,12 L12,3" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
|
||||||
|
<!-- Базовая «волновая» иконка сигнала, перечёркнутая диагональной чертой,
|
||||||
|
что обозначает потерю связи. Цветовой токен берётся из темы. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M2,22h20l-3,-3H5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M21.07,4.93l-1.41,-1.41L4.93,18.66c-0.39,0.39 -0.39,1.02 0,1.41l0.0,0.0c0.39,0.39 1.02,0.39 1.41,0L21.07,6.34C21.46,5.95 21.46,5.32 21.07,4.93z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,4l-1.5,1.5l1.5,1.5l1.5,-1.5L12,4zM7.5,8.5L9,10l3,-3l-1.5,-1.5L7.5,8.5z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="135"
|
||||||
|
android:endColor="@color/plotter_bezel_dark"
|
||||||
|
android:startColor="@color/plotter_bezel_light"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/plotter_panel_bg" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/plotter_panel_stroke" />
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
<solid android:color="@color/plotter_radar_bg" />
|
||||||
|
<stroke
|
||||||
|
android:width="3dp"
|
||||||
|
android:color="@color/plotter_bezel_highlight" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/radar_plotter_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/plotter_bezel_background"
|
||||||
|
tools:context=".RadarPlotterActivity">
|
||||||
|
|
||||||
|
<!-- Верхняя панель: заголовок + назад -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_top_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_radar_back"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/button_background"
|
||||||
|
android:contentDescription="@string/radar_plotter_back"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@android:drawable/ic_menu_revert" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_radar_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/radar_plotter_title"
|
||||||
|
android:textColor="@color/plotter_text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_radar_range"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/plotter_text_accent"
|
||||||
|
android:textSize="12sp"
|
||||||
|
tools:text="5.0 nm" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Портрет: PPI сверху, приборы снизу -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_plotter_content"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="46dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp"
|
||||||
|
android:baselineAligned="false">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/radar_viewport_frame"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1.2"
|
||||||
|
android:background="@drawable/plotter_radar_viewport_bg"
|
||||||
|
android:padding="4dp">
|
||||||
|
|
||||||
|
<org.maplibre.android.maps.MapView
|
||||||
|
android:id="@+id/radar_map_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.RadarGraticuleOverlay
|
||||||
|
android:id="@+id/radar_graticule"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_instruments_panel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/plotter_instruments_row"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:layout_weight="0.38"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterHeadingView
|
||||||
|
android:id="@+id/plotter_heading"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterSpeedometerView
|
||||||
|
android:id="@+id/plotter_speedometer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterTargetsTableView
|
||||||
|
android:id="@+id/plotter_targets_table"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/settings_scroll"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:fillViewport="true"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
@@ -13,20 +15,19 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🔌 Интерфейсы: UDP и BLE"
|
android:layout_marginBottom="24dp"
|
||||||
android:textSize="22sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/black"
|
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layout_marginBottom="24dp" />
|
android:text="@string/interfaces_title"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
<!-- UDP -->
|
<!-- UDP -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="?attr/materialCardViewElevatedStyle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
app:cardCornerRadius="8dp"
|
app:cardCornerRadius="12dp">
|
||||||
app:cardElevation="4dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -37,18 +38,17 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📡 UDP"
|
android:layout_marginBottom="12dp"
|
||||||
android:textSize="18sp"
|
android:text="@string/interfaces_section_udp"
|
||||||
android:textStyle="bold"
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="?attr/colorOnSurface" />
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:hint="UDP Порт"
|
android:hint="@string/interfaces_udp_port_hint"
|
||||||
app:helperText="Порт для прослушивания AIS данных">
|
app:helperText="@string/interfaces_udp_port_helper">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/et_udp_port"
|
android:id="@+id/et_udp_port"
|
||||||
@@ -63,20 +63,20 @@
|
|||||||
android:id="@+id/switch_udp_enabled"
|
android:id="@+id/switch_udp_enabled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Включить UDP слушатель"
|
android:checked="true"
|
||||||
android:textSize="16sp"
|
android:text="@string/interfaces_udp_enabled" />
|
||||||
android:checked="true" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- BLE -->
|
<!-- BLE -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="?attr/materialCardViewElevatedStyle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
app:cardCornerRadius="8dp"
|
app:cardCornerRadius="12dp">
|
||||||
app:cardElevation="4dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -87,33 +87,30 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📶 BLE"
|
android:layout_marginBottom="12dp"
|
||||||
android:textSize="18sp"
|
android:text="@string/interfaces_section_ble"
|
||||||
android:textStyle="bold"
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="?attr/colorOnSurface" />
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
android:id="@+id/switch_ble_enabled"
|
android:id="@+id/switch_ble_enabled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Включить BLE источник NMEA"
|
android:layout_marginBottom="8dp"
|
||||||
android:textSize="16sp"
|
android:text="@string/interfaces_ble_enabled" />
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:hint="MAC адрес BLE устройства"
|
android:hint="@string/interfaces_ble_mac_hint"
|
||||||
app:helperText="Например: 01:23:45:67:89:AB">
|
app:helperText="@string/interfaces_ble_mac_helper">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/et_ble_mac"
|
android:id="@+id/et_ble_mac"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="text"
|
android:inputType="text" />
|
||||||
android:text="" />
|
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
@@ -122,20 +119,20 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<Button
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btn_ble_scan"
|
android:id="@+id/btn_ble_scan"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Сканировать BLE"
|
android:text="@string/interfaces_ble_scan" />
|
||||||
style="@style/Widget.Material3.Button" />
|
|
||||||
|
|
||||||
<Button
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btn_ble_stop_scan"
|
android:id="@+id/btn_ble_stop_scan"
|
||||||
|
style="?attr/materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Стоп"
|
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="8dp"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
android:text="@string/interfaces_ble_stop" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
@@ -144,16 +141,33 @@
|
|||||||
android:layout_height="200dp"
|
android:layout_height="200dp"
|
||||||
android:layout_marginTop="8dp" />
|
android:layout_marginTop="8dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
|
android:id="@+id/switch_ble_battery_enabled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/interfaces_ble_battery_enabled" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="32dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/interfaces_ble_battery_helper"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- BLE UDP Bridge -->
|
<!-- BLE → UDP Bridge -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="?attr/materialCardViewElevatedStyle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
app:cardCornerRadius="8dp"
|
app:cardCornerRadius="12dp">
|
||||||
app:cardElevation="4dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -164,25 +178,23 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🔁 BLE UDP Bridge"
|
android:layout_marginBottom="12dp"
|
||||||
android:textSize="18sp"
|
android:text="@string/interfaces_section_bridge"
|
||||||
android:textStyle="bold"
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="?attr/colorOnSurface" />
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
android:id="@+id/switch_ble_udp_bridge_enabled"
|
android:id="@+id/switch_ble_udp_bridge_enabled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Включить UDP-bridge (пересылать NMEA)"
|
android:layout_marginBottom="8dp"
|
||||||
android:textSize="16sp"
|
android:text="@string/interfaces_bridge_enabled" />
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:hint="UDP Host (назначение)">
|
android:hint="@string/interfaces_bridge_host_hint">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/et_ble_udp_host"
|
android:id="@+id/et_ble_udp_host"
|
||||||
@@ -196,8 +208,7 @@
|
|||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:hint="@string/interfaces_bridge_port_hint">
|
||||||
android:hint="UDP Port (назначение)">
|
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
android:id="@+id/et_ble_udp_port"
|
android:id="@+id/et_ble_udp_port"
|
||||||
@@ -209,29 +220,33 @@
|
|||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="end"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="end">
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<Button
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btn_cancel"
|
android:id="@+id/btn_cancel"
|
||||||
|
style="?attr/materialButtonOutlinedStyle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Отмена"
|
|
||||||
android:layout_marginEnd="8dp"
|
android:layout_marginEnd="8dp"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
android:text="@string/settings_action_cancel" />
|
||||||
|
|
||||||
<Button
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btn_save"
|
android:id="@+id/btn_save"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Сохранить"
|
android:text="@string/settings_action_save" />
|
||||||
style="@style/Widget.Material3.Button" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/main_root"
|
android:id="@+id/main_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -13,29 +14,65 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<!-- Компас -->
|
<!-- Баннер потери BLE-связи. Компас привязан ниже — не перекрывает текст. -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/banner_connection_lost"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:background="@color/connection_lost_bg"
|
||||||
|
android:elevation="8dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_banner_connection_lost"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:drawablePadding="10dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/banner_connection_lost_ble"
|
||||||
|
android:textAlignment="center"
|
||||||
|
android:textColor="@color/connection_lost_text"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:drawableStartCompat="@drawable/ic_signal_off" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Компас. Высоту виджет считает сам через measureDockContentHeightPx —
|
||||||
|
contentH (~96dp) + системные паддинги (status bar/displayCutout). -->
|
||||||
<com.grigowashere.aismap.view.CompassView
|
<com.grigowashere.aismap.view.CompassView
|
||||||
android:id="@+id/compass_view"
|
android:id="@+id/compass_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="80dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentTop="true"
|
android:layout_below="@id/banner_connection_lost"
|
||||||
android:layout_marginLeft="0dp"
|
android:elevation="2dp" />
|
||||||
android:layout_marginTop="0dp"
|
|
||||||
android:layout_marginRight="0dp"
|
|
||||||
android:layout_marginBottom="0dp" />
|
|
||||||
|
|
||||||
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) -->
|
<!-- Виджет координат: contentH считает сам (от размера шрифтов),
|
||||||
|
к нему MainActivity добавляет bottom inset под нав-бар. -->
|
||||||
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
||||||
android:id="@+id/coordinates_widget"
|
android:id="@+id/coordinates_widget"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="80dp"
|
android:layout_height="wrap_content"
|
||||||
android:layout_alignParentBottom="true"
|
android:layout_alignParentBottom="true"
|
||||||
android:layout_marginLeft="0dp"
|
|
||||||
android:layout_marginTop="0dp"
|
|
||||||
android:layout_marginRight="0dp"
|
|
||||||
android:layout_marginBottom="0dp"
|
|
||||||
android:elevation="2dp" />
|
android:elevation="2dp" />
|
||||||
|
|
||||||
|
<!-- Виджет «Опасные цели»: высота зависит от числа целей в зоне опасности
|
||||||
|
(rowH × N + title + паддинги). Когда целей нет — GONE, карта чистая. -->
|
||||||
|
<com.grigowashere.aismap.view.DangerTargetsDockWidget
|
||||||
|
android:id="@+id/danger_targets_widget"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_above="@id/coordinates_widget"
|
||||||
|
android:elevation="3dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
|
<!-- Панель управления (после координат в Z-order — не перекрывается снизу) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/control_panel"
|
android:id="@+id/control_panel"
|
||||||
@@ -60,6 +97,17 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:layout_marginBottom="8dp" />
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_navigator_follow"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/button_background"
|
||||||
|
android:src="@drawable/sail"
|
||||||
|
android:contentDescription="@string/main_navigator_button"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btn_map_orientation"
|
android:id="@+id/btn_map_orientation"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
@@ -114,52 +162,65 @@
|
|||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:layout_marginTop="8dp" />
|
android:layout_marginTop="8dp" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_radar_plotter"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/button_background"
|
||||||
|
android:src="@drawable/ic_radar_plotter"
|
||||||
|
android:contentDescription="@string/radar_plotter_button"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:layout_marginTop="8dp" />
|
||||||
|
|
||||||
<!-- Строки возраста последних сообщений GPS ($) и AIS (!) -->
|
<!-- Компактный блок статусов: GPS / AIS возраст, BLE RSSI / батарея, FPS.
|
||||||
|
Уменьшены и шрифт (10sp), и отступы — на телефоне это даёт лишние
|
||||||
|
~30dp по высоте, не теряя читаемости. -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_gps_age"
|
android:id="@+id/tv_gps_age"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
android:text="GPS: --"
|
android:text="GPS: --"
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="8dp"/>
|
android:textSize="10sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_ais_age"
|
android:id="@+id/tv_ais_age"
|
||||||
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:text="AIS: --"
|
android:text="AIS: --"
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="4dp"/>
|
android:textSize="10sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_ble_rssi"
|
android:id="@+id/tv_ble_rssi"
|
||||||
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:maxLines="8"
|
||||||
android:text="BLE RSSI: --"
|
android:text="BLE RSSI: --"
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="4dp"/>
|
android:textSize="10sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_ble_batt"
|
android:id="@+id/tv_ble_batt"
|
||||||
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:text="BLE Batt: --"
|
android:text="BLE Batt: --"
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="2dp"/>
|
android:textSize="10sp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tv_fps"
|
android:id="@+id/tv_fps"
|
||||||
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:text="FPS: --"
|
android:text="FPS: --"
|
||||||
android:textSize="11sp"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:layout_marginTop="4dp"/>
|
android:textSize="10sp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/radar_plotter_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/plotter_bezel_background"
|
||||||
|
tools:context=".RadarPlotterActivity">
|
||||||
|
|
||||||
|
<!-- Верхняя панель: заголовок + назад -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_top_bar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="top"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="8dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="12dp"
|
||||||
|
android:paddingBottom="4dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_radar_back"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/button_background"
|
||||||
|
android:contentDescription="@string/radar_plotter_back"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:src="@android:drawable/ic_menu_revert" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_radar_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/radar_plotter_title"
|
||||||
|
android:textColor="@color/plotter_text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tv_radar_range"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/plotter_text_accent"
|
||||||
|
android:textSize="12sp"
|
||||||
|
tools:text="5.0 nm" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Альбом: PPI слева, приборы справа (портрет — layout-port) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_plotter_content"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginTop="46dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp"
|
||||||
|
android:baselineAligned="false">
|
||||||
|
|
||||||
|
<!-- Область PPI / радара -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/radar_viewport_frame"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1.15"
|
||||||
|
android:background="@drawable/plotter_radar_viewport_bg"
|
||||||
|
android:padding="4dp">
|
||||||
|
|
||||||
|
<org.maplibre.android.maps.MapView
|
||||||
|
android:id="@+id/radar_map_view"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.RadarGraticuleOverlay
|
||||||
|
android:id="@+id/radar_graticule"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<!-- Правая колонка: компас+спидометр в ряд, таблица -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/radar_instruments_panel"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/plotter_instruments_row"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
|
android:layout_weight="0.38"
|
||||||
|
android:baselineAligned="false"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterHeadingView
|
||||||
|
android:id="@+id/plotter_heading"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterSpeedometerView
|
||||||
|
android:id="@+id/plotter_speedometer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.grigowashere.aismap.view.PlotterTargetsTableView
|
||||||
|
android:id="@+id/plotter_targets_table"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@drawable/plotter_panel_background" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🚢 AIS СУДНО"
|
android:text="AIS СУДНО"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@android:color/black" />
|
android:textColor="@android:color/black" />
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_time_ago"
|
android:id="@+id/bottom_sheet_ais_time_ago"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="⏱️ Время назад: --"
|
android:text="Время назад: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_mmsi"
|
android:id="@+id/bottom_sheet_ais_mmsi"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🆔 MMSI: --"
|
android:text="MMSI: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_callsign"
|
android:id="@+id/bottom_sheet_ais_callsign"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📻 Позывной: --"
|
android:text="Позывной: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_imo"
|
android:id="@+id/bottom_sheet_ais_imo"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🏷️ IMO: --"
|
android:text="IMO: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_type"
|
android:id="@+id/bottom_sheet_ais_type"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🚢 Тип: --"
|
android:text="Тип: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_position"
|
android:id="@+id/bottom_sheet_ais_position"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📍 Координаты: --"
|
android:text="Координаты: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🧭 COG: --°"
|
android:text="COG: --°"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🧭 HDG: --°"
|
android:text="HDG: --°"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="🔄 ROT: --°/мин"
|
android:text="ROT: --°/мин"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_speed"
|
android:id="@+id/bottom_sheet_ais_speed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="⚡ Скорость: -- узлов"
|
android:text="Скорость: -- узлов"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_dimensions"
|
android:id="@+id/bottom_sheet_ais_dimensions"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📏 Размеры: --"
|
android:text="Размеры: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_draft"
|
android:id="@+id/bottom_sheet_ais_draft"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🌊 Осадка: -- м"
|
android:text="Осадка: -- м"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_destination"
|
android:id="@+id/bottom_sheet_ais_destination"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🎯 Назначение: --"
|
android:text="Назначение: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_eta"
|
android:id="@+id/bottom_sheet_ais_eta"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="⏰ ETA: --"
|
android:text="ETA: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_nav_status"
|
android:id="@+id/bottom_sheet_ais_nav_status"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🚦 Статус: --"
|
android:text="Статус: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_class"
|
android:id="@+id/bottom_sheet_ais_class"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📋 Класс: --"
|
android:text="Класс: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_signal"
|
android:id="@+id/bottom_sheet_ais_signal"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📶 Сигнал: --"
|
android:text="Сигнал: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_distance"
|
android:id="@+id/bottom_sheet_ais_distance"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="📏 Расстояние: --"
|
android:text="Расстояние: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -278,7 +278,19 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_bearing"
|
android:id="@+id/bottom_sheet_ais_bearing"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🧭 Пеленг: --"
|
android:text="Пеленг: --"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="@android:color/black"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
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:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
@@ -290,7 +302,7 @@
|
|||||||
android:id="@+id/bottom_sheet_ais_last_update"
|
android:id="@+id/bottom_sheet_ais_last_update"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="🕐 Обновлено: --"
|
android:text="Обновлено: --"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="@android:color/black"
|
android:textColor="@android:color/black"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
|
|||||||
@@ -2,4 +2,33 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
</resources>
|
|
||||||
|
<!-- Кольца дальности вокруг собственного судна.
|
||||||
|
Альфа-каналы подобраны так, чтобы линии и заливки не перекрывали карту. -->
|
||||||
|
<color name="range_ring_danger_stroke">#FFD32F2F</color>
|
||||||
|
<color name="range_ring_danger_fill">#22D32F2F</color>
|
||||||
|
<color name="range_ring_warning_stroke">#FFFFA000</color>
|
||||||
|
<color name="range_ring_warning_fill">#1AFFA000</color>
|
||||||
|
<color name="range_ring_filter_stroke">#FF1976D2</color>
|
||||||
|
<color name="range_ring_filter_fill">#001976D2</color>
|
||||||
|
<color name="range_target_warning_halo">#FFFFC107</color>
|
||||||
|
|
||||||
|
<!-- Баннер «нет связи» -->
|
||||||
|
<color name="connection_lost_bg">#E6B71C1C</color>
|
||||||
|
<color name="connection_lost_text">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Режим «картплоттер / радар» -->
|
||||||
|
<color name="plotter_bezel_dark">#FF0A0E12</color>
|
||||||
|
<color name="plotter_bezel_light">#FF1E2830</color>
|
||||||
|
<color name="plotter_bezel_highlight">#FF4A5A66</color>
|
||||||
|
<color name="plotter_panel_bg">#E612181C</color>
|
||||||
|
<color name="plotter_panel_stroke">#FF3D4F3A</color>
|
||||||
|
<color name="plotter_radar_bg">#88041008</color>
|
||||||
|
<color name="plotter_radar_grid">#5533FF66</color>
|
||||||
|
<color name="plotter_radar_grid_bright">#AA66FF99</color>
|
||||||
|
<color name="plotter_radar_sweep">#5533FF66</color>
|
||||||
|
<color name="plotter_text_primary">#FFE8F0E4</color>
|
||||||
|
<color name="plotter_text_label">#FF7FA88A</color>
|
||||||
|
<color name="plotter_text_accent">#FFFFB74D</color>
|
||||||
|
<color name="plotter_target_blip">#FF66FF99</color>
|
||||||
|
</resources>
|
||||||
|
|||||||
@@ -1,3 +1,166 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">AISMap</string>
|
<string name="app_name">AISMap</string>
|
||||||
</resources>
|
|
||||||
|
<!-- ===== SettingsActivity ===== -->
|
||||||
|
<string name="settings_title">Настройки AIS Map</string>
|
||||||
|
|
||||||
|
<string name="settings_section_interfaces">Интерфейсы</string>
|
||||||
|
<string name="settings_open_interfaces_hint">Интерфейсы (UDP / BLE)</string>
|
||||||
|
<string name="settings_open_interfaces_helper">Перейти к настройкам UDP, BLE и UDP-bridge</string>
|
||||||
|
<string name="settings_open_interfaces_value">Открыть настройки интерфейсов</string>
|
||||||
|
<string name="settings_open_interfaces_action">Открыть</string>
|
||||||
|
|
||||||
|
<string name="settings_section_path">Путь и предсказание</string>
|
||||||
|
<string name="settings_path_max_points_hint">Максимум точек на судно</string>
|
||||||
|
<string name="settings_path_max_points_helper">Ограничение размера истории пути</string>
|
||||||
|
<string name="settings_path_width_hint">Толщина линии пути (px)</string>
|
||||||
|
<string name="settings_path_color_hint">Цвет пути (#RRGGBB)</string>
|
||||||
|
<string name="settings_prediction_width_hint">Толщина линии предсказания (px)</string>
|
||||||
|
<string name="settings_prediction_color_hint">Цвет предсказания (#RRGGBB)</string>
|
||||||
|
<string name="settings_prediction_horizon_hint">Горизонт предсказания (сек)</string>
|
||||||
|
<string name="settings_clear_path">Очистить трекер пути</string>
|
||||||
|
<string name="settings_clear_path_helper">Удаляет все сохранённые точки пути собственного судна</string>
|
||||||
|
|
||||||
|
<string name="settings_section_gps_source">Источник координат</string>
|
||||||
|
<string name="settings_gps_source_hint">Откуда приложение берёт позицию собственного судна.</string>
|
||||||
|
<string name="settings_gps_source_hub">AIS Hub (BLE)</string>
|
||||||
|
<string name="settings_gps_source_hub_hint">Позиция и AIS-цели приходят из внешнего AIS Hub по BLE.</string>
|
||||||
|
<string name="settings_gps_source_android">Android GPS</string>
|
||||||
|
<string name="settings_gps_source_android_hint">Встроенный GPS устройства (+опциональный внешний NMEA).</string>
|
||||||
|
|
||||||
|
<string name="settings_section_range_rings">Зоны вокруг судна</string>
|
||||||
|
<string name="settings_range_rings_hint">Кольца дальности позволяют выделить опасные цели и ограничить зону отображения.</string>
|
||||||
|
<string name="settings_range_rings_enabled">Показывать кольца на карте</string>
|
||||||
|
<string name="settings_range_units">Единицы измерения</string>
|
||||||
|
<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_warning_hint">Радиус зоны предупреждения</string>
|
||||||
|
<string name="settings_range_warning_helper">Цели в этой зоне подсвечиваются на карте</string>
|
||||||
|
<string name="settings_range_filter_hint">Радиус зоны фильтра</string>
|
||||||
|
<string name="settings_range_filter_helper">Скрывает цели, расположенные дальше указанного радиуса</string>
|
||||||
|
<string name="settings_range_filter_enabled">Скрывать цели за пределами зоны фильтра</string>
|
||||||
|
<string name="settings_range_validation_order">Радиусы должны быть возрастающими: опасность < предупреждение < фильтр</string>
|
||||||
|
<string name="settings_range_validation_positive">Все радиусы должны быть положительными</string>
|
||||||
|
|
||||||
|
<string name="settings_section_advanced_nmea">Расширенные NMEA-источники</string>
|
||||||
|
<string name="settings_advanced_nmea_hint">Открыть старые настройки Android NMEA / UDP NMEA / режимы данных. Нужны только если вы работаете без AIS Hub.</string>
|
||||||
|
<string name="settings_advanced_nmea_caption">Выберите источники данных для получения координат и навигационной информации:</string>
|
||||||
|
<string name="settings_android_nmea_enabled">Android NMEA (GPS API)</string>
|
||||||
|
<string name="settings_android_nmea_helper">Использовать встроенный GPS Android для получения координат</string>
|
||||||
|
<string name="settings_udp_nmea_enabled">UDP NMEA</string>
|
||||||
|
<string name="settings_udp_nmea_helper">Получать NMEA данные через UDP (курс, скорость, спутники)</string>
|
||||||
|
<string name="settings_data_mode">Режим работы</string>
|
||||||
|
<string name="settings_data_mode_hybrid">Гибридный режим (рекомендуется)</string>
|
||||||
|
<string name="settings_data_mode_hybrid_hint">Координаты от Android GPS, остальное от NMEA</string>
|
||||||
|
<string name="settings_data_mode_nmea">Только NMEA</string>
|
||||||
|
<string name="settings_data_mode_nmea_hint">Все данные только из NMEA сообщений</string>
|
||||||
|
<string name="settings_data_mode_android">Только Android GPS</string>
|
||||||
|
<string name="settings_data_mode_android_hint">Только встроенный GPS Android</string>
|
||||||
|
|
||||||
|
<string name="settings_section_stale_data">Устаревание данных AIS</string>
|
||||||
|
<string name="settings_stale_caption">Настройте время, через которое данные о судах считаются устаревшими:</string>
|
||||||
|
<string name="settings_stale_warning_hint">Время предупреждения (минуты)</string>
|
||||||
|
<string name="settings_stale_warning_helper">Суда старше этого времени будут помечены как устаревшие</string>
|
||||||
|
<string name="settings_stale_remove_hint">Время удаления (минуты)</string>
|
||||||
|
<string name="settings_stale_remove_helper">Суда старше этого времени будут удалены с карты</string>
|
||||||
|
<string name="settings_stale_tip">Устаревшие суда отображаются с иконкой потери цели</string>
|
||||||
|
|
||||||
|
<string name="settings_section_navigator_camera">Навигаторская камера</string>
|
||||||
|
<string name="settings_navigator_camera_hint">Карта следует за судном; зум зависит от скорости (0 уз — ближе, макс. скорость — дальше).</string>
|
||||||
|
<string name="settings_navigator_camera_enabled">Следовать за судном</string>
|
||||||
|
<string name="settings_navigator_camera_helper">Долгое нажатие на кнопку судна на карте также включает и выключает режим</string>
|
||||||
|
<string name="settings_navigator_max_speed_hint">Макс. скорость (уз)</string>
|
||||||
|
<string name="settings_navigator_max_speed_helper">При этой скорости и выше используется минимальный зум</string>
|
||||||
|
<string name="settings_navigator_zoom_zero_hint">Зум при 0 уз</string>
|
||||||
|
<string name="settings_navigator_zoom_zero_helper">Максимальное приближение (большее число)</string>
|
||||||
|
<string name="settings_navigator_zoom_max_hint">Зум при макс. скорости</string>
|
||||||
|
<string name="settings_navigator_zoom_max_helper">Максимальное отдаление (меньшее число)</string>
|
||||||
|
<string name="main_navigator_on">Навигатор: следование за судном</string>
|
||||||
|
<string name="main_navigator_off">Навигатор выключен</string>
|
||||||
|
<string name="main_navigator_button">Режим навигатора</string>
|
||||||
|
|
||||||
|
<string name="settings_section_screen">Управление экраном</string>
|
||||||
|
<string name="settings_screen_hint">Настройте поведение экрана во время навигации:</string>
|
||||||
|
<string name="settings_keep_screen_on">Не давать экрану засыпать</string>
|
||||||
|
<string name="settings_keep_screen_on_helper">Экран будет оставаться включенным во время навигации (рекомендуется для навигатора)</string>
|
||||||
|
|
||||||
|
<string name="settings_section_notifications">Уведомления о новых целях AIS</string>
|
||||||
|
<string name="settings_notifications_hint">Настройте уведомления при обнаружении новых судов:</string>
|
||||||
|
<string name="settings_vibration">Вибрация</string>
|
||||||
|
<string name="settings_vibration_helper">Вибрация устройства при обнаружении нового судна</string>
|
||||||
|
<string name="settings_sound">Звуковое уведомление</string>
|
||||||
|
<string name="settings_sound_helper">Звуковой сигнал при обнаружении нового судна</string>
|
||||||
|
|
||||||
|
<string name="settings_section_debug">Режим отладки</string>
|
||||||
|
<string name="settings_debug_hint">Включает расширенное логирование и диагностические элементы UI.</string>
|
||||||
|
<string name="settings_debug_enabled">Включить режим отладки</string>
|
||||||
|
|
||||||
|
<string name="settings_section_seamarks">Морские знаки OpenSeaMap</string>
|
||||||
|
<string name="settings_seamarks_hint">Отображать морские знаки (буи, маяки, навигационные знаки) поверх карты.</string>
|
||||||
|
<string name="settings_seamarks_enabled">Показывать морские знаки</string>
|
||||||
|
<string name="settings_seamarks_tip">Источник: OpenSeaMap.org — открытая база данных морских знаков</string>
|
||||||
|
|
||||||
|
<string name="settings_action_save">Сохранить</string>
|
||||||
|
<string name="settings_action_cancel">Отмена</string>
|
||||||
|
|
||||||
|
<!-- ===== InterfacesSettingsActivity ===== -->
|
||||||
|
<string name="interfaces_title">Интерфейсы: UDP и BLE</string>
|
||||||
|
<string name="interfaces_section_udp">UDP</string>
|
||||||
|
<string name="interfaces_udp_port_hint">UDP порт</string>
|
||||||
|
<string name="interfaces_udp_port_helper">Порт для прослушивания AIS данных</string>
|
||||||
|
<string name="interfaces_udp_enabled">Включить UDP-слушатель</string>
|
||||||
|
|
||||||
|
<string name="interfaces_section_ble">BLE</string>
|
||||||
|
<string name="interfaces_ble_enabled">Включить BLE-источник NMEA</string>
|
||||||
|
<string name="interfaces_ble_mac_hint">MAC-адрес BLE устройства</string>
|
||||||
|
<string name="interfaces_ble_mac_helper">Например: 01:23:45:67:89:AB</string>
|
||||||
|
<string name="interfaces_ble_scan">Сканировать BLE</string>
|
||||||
|
<string name="interfaces_ble_stop">Стоп</string>
|
||||||
|
<string name="interfaces_ble_battery_enabled">Читать уровень батареи AIS Hub</string>
|
||||||
|
<string name="interfaces_ble_battery_helper">Может вызывать запрос сопряжения на некоторых устройствах. Рекомендуется выключить, если периодически появляется системное окно «Сопряжение не выполнено».</string>
|
||||||
|
|
||||||
|
<string name="interfaces_section_bridge">BLE → UDP мост</string>
|
||||||
|
<string name="interfaces_bridge_enabled">Включить UDP-bridge (пересылать NMEA)</string>
|
||||||
|
<string name="interfaces_bridge_host_hint">UDP host (назначение)</string>
|
||||||
|
<string name="interfaces_bridge_port_hint">UDP port (назначение)</string>
|
||||||
|
|
||||||
|
<!-- ===== MainActivity (баннер связи и виджет опасности) ===== -->
|
||||||
|
<string name="banner_connection_lost_ble">Потеряна связь с устройством</string>
|
||||||
|
<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_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>
|
||||||
|
<string name="compass_label_mag">МАГН.</string>
|
||||||
|
<string name="coords_label_position">КООРДИНАТЫ</string>
|
||||||
|
<string name="coords_label_sog">СКОР.</string>
|
||||||
|
<string name="coords_label_cog">ПУТЬ</string>
|
||||||
|
<string name="coords_label_acc">ТОЧН.</string>
|
||||||
|
<string name="coords_value_no_fix">нет фикса</string>
|
||||||
|
|
||||||
|
<!-- ===== Режим картплоттера / «тупого радара» ===== -->
|
||||||
|
<string name="radar_plotter_title">Радар / картплоттер</string>
|
||||||
|
<string name="radar_plotter_button">Режим радара</string>
|
||||||
|
<string name="radar_plotter_no_coordinator">Вернитесь на карту — данные AIS недоступны</string>
|
||||||
|
<string name="radar_plotter_range_label">Дальность</string>
|
||||||
|
<string name="radar_plotter_sog_label">СКОР.</string>
|
||||||
|
<string name="radar_plotter_heading_label">КУРС</string>
|
||||||
|
<string name="radar_plotter_table_title">Ближайшие цели</string>
|
||||||
|
<string name="radar_plotter_col_name">Цель</string>
|
||||||
|
<string name="radar_plotter_col_brg">Пел.</string>
|
||||||
|
<string name="radar_plotter_col_rng">Дист.</string>
|
||||||
|
<string name="radar_plotter_col_cpa">CPA</string>
|
||||||
|
<string name="radar_plotter_cpa_na">—</string>
|
||||||
|
<string name="radar_plotter_table_empty">Нет целей в радиусе</string>
|
||||||
|
<string name="radar_plotter_back">К карте</string>
|
||||||
|
</resources>
|
||||||
|
|||||||
@@ -18,4 +18,12 @@
|
|||||||
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
|
<item name="android:windowDrawsSystemBarBackgrounds" tools:targetApi="21">true</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Полноэкранный картплоттер: тёмный фон, без action bar -->
|
||||||
|
<style name="Theme.AISMap.RadarPlotter" parent="Theme.AISMap">
|
||||||
|
<item name="android:windowFullscreen">true</item>
|
||||||
|
<item name="android:statusBarColor">@color/plotter_bezel_dark</item>
|
||||||
|
<item name="android:navigationBarColor">@color/plotter_bezel_dark</item>
|
||||||
|
<item name="android:windowBackground">@color/plotter_bezel_dark</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-тесты зума навигаторской камеры от скорости.
|
||||||
|
*/
|
||||||
|
public class NavigatorZoomMathTest {
|
||||||
|
|
||||||
|
private static final float EPS = 1e-4f;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void zeroSpeedUsesZoomAtZero() {
|
||||||
|
assertEquals(18f, NavigatorZoomMath.zoomForSpeed(0.0, 18f, 10f, 20f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void maxSpeedUsesZoomAtMax() {
|
||||||
|
assertEquals(10f, NavigatorZoomMath.zoomForSpeed(20.0, 18f, 10f, 20f), EPS);
|
||||||
|
assertEquals(10f, NavigatorZoomMath.zoomForSpeed(25.0, 18f, 10f, 20f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void halfSpeedInterpolatesLinearly() {
|
||||||
|
assertEquals(14f, NavigatorZoomMath.zoomForSpeed(10.0, 18f, 10f, 20f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void negativeSpeedTreatedAsZero() {
|
||||||
|
assertEquals(18f, NavigatorZoomMath.zoomForSpeed(-3.0, 18f, 10f, 20f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void invalidMaxSpeedReturnsZoomAtZero() {
|
||||||
|
assertEquals(16f, NavigatorZoomMath.zoomForSpeed(5.0, 16f, 8f, 0f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void clampZoomBounds() {
|
||||||
|
assertEquals(2f, NavigatorZoomMath.clampZoom(0f), EPS);
|
||||||
|
assertEquals(20f, NavigatorZoomMath.clampZoom(99f), EPS);
|
||||||
|
assertEquals(14f, NavigatorZoomMath.clampZoom(14f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void easeOutCubicEndpoints() {
|
||||||
|
assertEquals(0f, NavigatorZoomMath.easeOutCubic(0f), EPS);
|
||||||
|
assertEquals(1f, NavigatorZoomMath.easeOutCubic(1f), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void lerpBearingShortPath() {
|
||||||
|
assertEquals(5f, NavigatorZoomMath.lerpBearing(0f, 10f, 0.5f), EPS);
|
||||||
|
assertEquals(10f, NavigatorZoomMath.lerpBearing(350f, 10f, 1f), EPS);
|
||||||
|
assertEquals(0f, NavigatorZoomMath.lerpBearing(350f, 10f, 0.5f), EPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.grigowashere.aismap.utils;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка чистой логики колец дальности.
|
||||||
|
* Тест работает без Android (только JUnit), чтобы не тащить Robolectric.
|
||||||
|
*/
|
||||||
|
public class RangeMathTest {
|
||||||
|
|
||||||
|
private static final double EPS = 1e-6;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testToMetersNm() {
|
||||||
|
assertEquals(1852.0, RangeMath.toMeters(1.0, RangeMath.UNIT_NM), EPS);
|
||||||
|
assertEquals(926.0, RangeMath.toMeters(0.5, RangeMath.UNIT_NM), EPS);
|
||||||
|
assertEquals(9260.0, RangeMath.toMeters(5.0, RangeMath.UNIT_NM), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testToMetersKm() {
|
||||||
|
assertEquals(1000.0, RangeMath.toMeters(1.0, RangeMath.UNIT_KM), EPS);
|
||||||
|
assertEquals(2500.0, RangeMath.toMeters(2.5, RangeMath.UNIT_KM), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testToMetersUnknownUnitDefaultsToNm() {
|
||||||
|
assertEquals(1852.0, RangeMath.toMeters(1.0, "miles"), EPS);
|
||||||
|
assertEquals(1852.0, RangeMath.toMeters(1.0, null), EPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRingOrderValid() {
|
||||||
|
assertTrue(RangeMath.isValidRingOrder(0.5, 1.5, 5.0));
|
||||||
|
assertTrue(RangeMath.isValidRingOrder(0.1, 0.2, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRingOrderInvalid() {
|
||||||
|
// равные значения — невалидно
|
||||||
|
assertFalse(RangeMath.isValidRingOrder(1.0, 1.0, 5.0));
|
||||||
|
assertFalse(RangeMath.isValidRingOrder(1.0, 5.0, 5.0));
|
||||||
|
// обратный порядок — невалидно
|
||||||
|
assertFalse(RangeMath.isValidRingOrder(5.0, 1.5, 0.5));
|
||||||
|
// ноль или отрицательное — невалидно
|
||||||
|
assertFalse(RangeMath.isValidRingOrder(0.0, 1.0, 2.0));
|
||||||
|
assertFalse(RangeMath.isValidRingOrder(-1.0, 1.0, 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFilterDisabledKeepsAllTargets() {
|
||||||
|
assertTrue(RangeMath.isInsideFilter(false, 100.0,
|
||||||
|
55.0, 37.0, 56.0, 38.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFilterIncludesNearbyTarget() {
|
||||||
|
// 0.001° по широте ≈ 111 м — точно ≤ 1000 м.
|
||||||
|
assertTrue(RangeMath.isInsideFilter(true, 1000.0,
|
||||||
|
55.7558, 37.6173, 55.7568, 37.6173));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFilterExcludesFarTarget() {
|
||||||
|
// ~1° по широте ≈ 111 км, очевидно > 1000 м.
|
||||||
|
assertFalse(RangeMath.isInsideFilter(true, 1000.0,
|
||||||
|
55.0, 37.0, 56.0, 37.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFilterInvalidCoordsKeepsTarget() {
|
||||||
|
assertTrue(RangeMath.isInsideFilter(true, 1000.0,
|
||||||
|
Double.NaN, Double.NaN, 55.0, 37.0));
|
||||||
|
assertTrue(RangeMath.isInsideFilter(true, 1000.0,
|
||||||
|
55.0, 37.0, Double.NaN, 37.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHaversineMeters() {
|
||||||
|
// Москва -> Санкт-Петербург ≈ 633 км. Допуск ±10 км.
|
||||||
|
double d = RangeMath.haversineMeters(55.7558, 37.6173, 59.9343, 30.3351);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-2
@@ -12,6 +12,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -1340,7 +1342,9 @@ class DataCharacteristic(Characteristic):
|
|||||||
|
|
||||||
class StatusCharacteristic(Characteristic):
|
class StatusCharacteristic(Characteristic):
|
||||||
def __init__(self, bus, index, service: Service, bridge: AisHubBridge):
|
def __init__(self, bus, index, service: Service, bridge: AisHubBridge):
|
||||||
Characteristic.__init__(self, bus, index, AIS_HUB_STATUS_UUID, ["read", "notify"], service)
|
# Только read: notify не используется клиентом; лишний notify увеличивает
|
||||||
|
# поверхность ATT без пользы. Шифрование не требуется (без encrypt-*).
|
||||||
|
Characteristic.__init__(self, bus, index, AIS_HUB_STATUS_UUID, ["read"], service)
|
||||||
self.bridge = bridge
|
self.bridge = bridge
|
||||||
self.notifying = False
|
self.notifying = False
|
||||||
|
|
||||||
@@ -1417,6 +1421,74 @@ def find_adapter(bus):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hci_index_from_adapter_path(adapter_path: str) -> str | None:
|
||||||
|
m = re.search(r'hci(\d+)$', adapter_path)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_adapter_for_open_le(bus, adapter_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Снижает вероятность системного диалога pairing на Android.
|
||||||
|
|
||||||
|
Наши характеристики без encrypt-* флагов, но bluetoothd по умолчанию:
|
||||||
|
- шлёт SMP Security Request (bonding) при LE connect;
|
||||||
|
- делает reverse GATT discovery к central (читает Battery и т.п. на
|
||||||
|
телефоне) -> Insufficient Authentication -> Android показывает pairing.
|
||||||
|
|
||||||
|
См. bluez/bluez#851, ukBaz/python-bluezero#390.
|
||||||
|
"""
|
||||||
|
props = dbus.Interface(
|
||||||
|
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
|
||||||
|
DBUS_PROP_IFACE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
props.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(False))
|
||||||
|
log_info('[Adapter] Pairable=false (no incoming pairing UI from adapter)')
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f'[Adapter] Pairable=false failed: {e}')
|
||||||
|
|
||||||
|
# btmgmt: bondable off + NoInputNoOutput — peripheral не инициирует bonding.
|
||||||
|
hci = _hci_index_from_adapter_path(adapter_path)
|
||||||
|
if hci is None:
|
||||||
|
log_warn('[Adapter] cannot parse hci index from ' + adapter_path)
|
||||||
|
return
|
||||||
|
for args in (
|
||||||
|
['bondable', 'off'],
|
||||||
|
['io-cap', '3'], # NoInputNoOutput
|
||||||
|
):
|
||||||
|
cmd = ['btmgmt', '-i', hci] + args
|
||||||
|
try:
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||||
|
if r.returncode == 0:
|
||||||
|
log_info(f'[Adapter] btmgmt {" ".join(args)}: ok')
|
||||||
|
else:
|
||||||
|
err = (r.stderr or r.stdout or '').strip()
|
||||||
|
log_warn(f'[Adapter] btmgmt {" ".join(args)} failed ({r.returncode}): {err}')
|
||||||
|
except FileNotFoundError:
|
||||||
|
log_warn('[Adapter] btmgmt not found; set ReverseServiceDiscovery=false in /etc/bluetooth/main.conf')
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
log_warn(f'[Adapter] btmgmt {" ".join(args)}: {e}')
|
||||||
|
|
||||||
|
log_info(
|
||||||
|
'[Adapter] Рекомендуется в /etc/bluetooth/main.conf: '
|
||||||
|
'ReverseServiceDiscovery=false и [GATT] Client=false (перезапуск bluetoothd)'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def trust_connected_device(bus, device_path: str) -> None:
|
||||||
|
"""Помечает central как Trusted — на части стеков убирает повторный pairing prompt."""
|
||||||
|
if not device_path or device_path == 'unknown':
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
dev = bus.get_object(BLUEZ_SERVICE_NAME, device_path)
|
||||||
|
props = dbus.Interface(dev, DBUS_PROP_IFACE)
|
||||||
|
props.Set('org.bluez.Device1', 'Trusted', dbus.Boolean(True))
|
||||||
|
log_debug(f'[BlueZ] Device Trusted=true: {device_path}')
|
||||||
|
except Exception as e:
|
||||||
|
log_debug(f'[BlueZ] Trusted=true failed for {device_path}: {e}')
|
||||||
|
|
||||||
|
|
||||||
# ============ main() ============
|
# ============ main() ============
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -1432,6 +1504,7 @@ def main():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
log_info(f'Используем адаптер: {adapter_path}')
|
log_info(f'Используем адаптер: {adapter_path}')
|
||||||
|
configure_adapter_for_open_le(bus, adapter_path)
|
||||||
|
|
||||||
service_manager = dbus.Interface(
|
service_manager = dbus.Interface(
|
||||||
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
|
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
|
||||||
@@ -1455,11 +1528,12 @@ def main():
|
|||||||
if "Connected" not in changed:
|
if "Connected" not in changed:
|
||||||
return
|
return
|
||||||
connected = bool(changed.get("Connected"))
|
connected = bool(changed.get("Connected"))
|
||||||
dev_path = str(path or "")
|
dev_path = "" if path is None else str(path)
|
||||||
if not dev_path:
|
if not dev_path:
|
||||||
return
|
return
|
||||||
if connected:
|
if connected:
|
||||||
log_info(f"[BlueZ] Device connected: {dev_path} sessions={bridge.session_count()}")
|
log_info(f"[BlueZ] Device connected: {dev_path} sessions={bridge.session_count()}")
|
||||||
|
trust_connected_device(bus, dev_path)
|
||||||
bridge.get_or_create_session(dev_path)
|
bridge.get_or_create_session(dev_path)
|
||||||
else:
|
else:
|
||||||
log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session")
|
log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session")
|
||||||
|
|||||||
Reference in New Issue
Block a user