generated from Grigo/AndroidTemplate
Daily checkup
This commit is contained in:
Binary file not shown.
@@ -82,6 +82,13 @@
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
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 -->
|
||||
<service
|
||||
android:name=".services.AISForegroundService"
|
||||
|
||||
@@ -16,6 +16,8 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.grigowashere.aismap.data.Repository;
|
||||
import com.grigowashere.aismap.data.entity.AISVesselEntity;
|
||||
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.List;
|
||||
@@ -23,6 +25,7 @@ import java.util.List;
|
||||
public class AisTargetsActivity extends AppCompatActivity implements AisTargetsAdapter.OnItemClickListener {
|
||||
|
||||
private Repository repository;
|
||||
private SettingsManager settingsManager;
|
||||
private RecyclerView recyclerView;
|
||||
private AisTargetsAdapter adapter;
|
||||
private android.os.Handler tickerHandler;
|
||||
@@ -44,6 +47,7 @@ public class AisTargetsActivity extends AppCompatActivity implements AisTargetsA
|
||||
setContentView(R.layout.activity_ais_targets);
|
||||
|
||||
repository = new Repository(this);
|
||||
settingsManager = new SettingsManager(this);
|
||||
|
||||
// Загружаем данные нашего корабля
|
||||
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);
|
||||
int targetCount = filtered.size();
|
||||
textTargetCount.setText("AIS цели: " + targetCount);
|
||||
|
||||
@@ -60,7 +60,15 @@ import com.grigowashere.aismap.controllers.DefaultControllersFactory;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
/** Живой координатор для вторичных экранов (радар), пока MainActivity в стеке. */
|
||||
private static volatile AppCoordinator sAppCoordinator;
|
||||
|
||||
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 SETTINGS_REQUEST_CODE = 1002;
|
||||
private static final int NOTIFICATION_PERMISSION_REQUEST_CODE = 1003;
|
||||
@@ -83,14 +91,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
private SettingsManager settingsManager;
|
||||
|
||||
private ImageButton btnCenterOnVessel;
|
||||
private ImageButton btnNavigatorFollow;
|
||||
private ImageButton btnMapOrientation;
|
||||
private ImageButton btnCursorToggle;
|
||||
private ImageButton btnSettings;
|
||||
private ImageButton btnAisTargets;
|
||||
private ImageButton btnRadarPlotter;
|
||||
private ImageButton btnGpsSource;
|
||||
private LinearLayout controlPanel;
|
||||
private CompassView compassView;
|
||||
private CoordinatesDockWidget coordinatesWidget;
|
||||
private com.grigowashere.aismap.view.DangerTargetsDockWidget dangerWidget;
|
||||
private android.widget.LinearLayout bannerConnectionLost;
|
||||
private TextView tvBannerConnectionLost;
|
||||
|
||||
// Троттлинг для UI обновлений
|
||||
private android.os.Handler uiThrottleHandler;
|
||||
@@ -214,14 +227,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void initializeViews() {
|
||||
mapView = findViewById(R.id.map_view);
|
||||
btnCenterOnVessel = findViewById(R.id.btn_center_vessel);
|
||||
btnNavigatorFollow = findViewById(R.id.btn_navigator_follow);
|
||||
btnMapOrientation = findViewById(R.id.btn_map_orientation);
|
||||
btnCursorToggle = findViewById(R.id.btn_cursor_toggle);
|
||||
btnSettings = findViewById(R.id.btn_settings);
|
||||
btnAisTargets = findViewById(R.id.btn_ais_targets);
|
||||
btnRadarPlotter = findViewById(R.id.btn_radar_plotter);
|
||||
btnGpsSource = findViewById(R.id.btn_gps_source);
|
||||
controlPanel = findViewById(R.id.control_panel);
|
||||
compassView = findViewById(R.id.compass_view);
|
||||
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();
|
||||
|
||||
// Инициализируем троттлинг
|
||||
@@ -292,7 +310,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
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) {
|
||||
btnMapOrientation.setOnClickListener(v -> cycleMapRotationMode());
|
||||
}
|
||||
@@ -300,6 +323,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (btnCursorToggle != null) btnCursorToggle.setOnLongClickListener(v -> { toggleCursor(); return true; });
|
||||
if (btnSettings != null) btnSettings.setOnClickListener(v -> showSettings());
|
||||
if (btnAisTargets != null) btnAisTargets.setOnClickListener(v -> openAisTargets());
|
||||
if (btnRadarPlotter != null) btnRadarPlotter.setOnClickListener(v -> openRadarPlotter());
|
||||
if (btnGpsSource != null) {
|
||||
refreshGpsSourceButtonIcon();
|
||||
btnGpsSource.setOnClickListener(v -> toggleGpsSource());
|
||||
@@ -316,13 +340,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Устанавливаем начальный азимут (например, север)
|
||||
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.setDocked(true, true, 0, 0);
|
||||
compassView.invalidate(); // Принудительная отрисовка
|
||||
// Выровнять паддинги под статус-бар/вырез камеры сразу после
|
||||
// первого dock-позиционирования (до этого сторона неизвестна).
|
||||
compassView.invalidate();
|
||||
reapplyInsetsToDocks();
|
||||
updateControlPanelPosition();
|
||||
});
|
||||
|
||||
// Настраиваем слушатель изменения размера док-виджета
|
||||
@@ -441,15 +468,21 @@ public class MainActivity extends AppCompatActivity {
|
||||
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(() -> {
|
||||
Log.d(TAG, "Setting coordinates widget to dock mode");
|
||||
coordinatesWidget.setDocked(true, false, 0, 0); // false = dock снизу
|
||||
coordinatesWidget.invalidate(); // Принудительная отрисовка
|
||||
// Только сейчас мы знаем сторону дока (bottom) — переприменяем
|
||||
// инсеты, чтобы виджет получил bottom padding под нав-бар
|
||||
// сразу, а не только после первого ресайза пользователем.
|
||||
coordinatesWidget.invalidate();
|
||||
reapplyInsetsToDocks();
|
||||
updateControlPanelPosition();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,6 +518,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
Integer batt = appCoordinator.getLastBleBattery();
|
||||
tvBleBatt.setText(batt != null ? ("BLE Batt: " + batt + "%") : "BLE Batt: --");
|
||||
}
|
||||
updateBleLinkLostBanner();
|
||||
updateDangerWidget();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
messageAgeHandler.postDelayed(this, 1000);
|
||||
@@ -494,6 +529,86 @@ public class MainActivity extends AppCompatActivity {
|
||||
messageAgeHandler.postDelayed(messageAgeRunnable, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Баннер «нет связи с устройством» — только при потере 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 dangerR = enabled ? settingsManager.getDangerRadiusMeters() : 0.0;
|
||||
java.util.List<com.grigowashere.aismap.view.DangerTargetsDockWidget.DangerEntry> uiEntries =
|
||||
new java.util.ArrayList<>();
|
||||
if (enabled && dangerR > 0.0) {
|
||||
java.util.List<com.grigowashere.aismap.controllers.AppCoordinator.DangerEntry> entries =
|
||||
appCoordinator.getDangerTargets(dangerR, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (seconds < 0) {
|
||||
// Нет данных
|
||||
@@ -842,6 +957,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Инициализация главного координатора
|
||||
ControllersFactory controllersFactory = new DefaultControllersFactory();
|
||||
appCoordinator = controllersFactory.createAppCoordinator(this);
|
||||
sAppCoordinator = appCoordinator;
|
||||
|
||||
// Init UI binders
|
||||
menuBinder = new MenuBinder(appCoordinator, settingsManager, new MenuBinder.MenuActions() {
|
||||
@@ -910,6 +1026,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
});
|
||||
refreshMapRotationButtonDescription();
|
||||
refreshNavigatorButtonState();
|
||||
}
|
||||
|
||||
private void startControllers() {
|
||||
@@ -1007,8 +1124,31 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void centerOnVessel() {
|
||||
appCoordinator.centerOnOwnVessel();
|
||||
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) {
|
||||
double x = deg % 360.0;
|
||||
@@ -1085,6 +1225,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private void applyAutoMapBearingIfNeeded(MapInterface map) {
|
||||
if (settingsManager == null || appCoordinator == null || map == null) return;
|
||||
// В режиме навигатора bearing сглаживает NavigatorCameraController.
|
||||
if (appCoordinator.isNavigatorCameraEnabled()) {
|
||||
return;
|
||||
}
|
||||
String mode = settingsManager.getMapRotationMode();
|
||||
if (SettingsManager.MAP_ROTATION_MANUAL.equals(mode)) {
|
||||
return;
|
||||
@@ -1173,6 +1317,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRadarPlotter() {
|
||||
startActivity(new Intent(this, RadarPlotterActivity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает источник координат между BLE Hub и Android GPS «на лету»,
|
||||
* обновляет иконку кнопки и уведомляет AppCoordinator.
|
||||
@@ -1239,9 +1387,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
android.widget.RelativeLayout.LayoutParams lp =
|
||||
(android.widget.RelativeLayout.LayoutParams) rawLp;
|
||||
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 newTop = compassH + dp8;
|
||||
int newTop = compassBottom + dp8;
|
||||
int newBottom = coordsH + dp8;
|
||||
if (lp.topMargin != newTop || lp.bottomMargin != newBottom) {
|
||||
lp.topMargin = newTop;
|
||||
@@ -1261,16 +1409,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
* Боковые паддинги даём всегда (landscape-камеры).
|
||||
*/
|
||||
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) {
|
||||
boolean top = compassView.isDockTop();
|
||||
compassView.setPadding(sys.left, top ? sys.top : 0,
|
||||
sys.right, top ? 0 : sys.bottom);
|
||||
// Верхний inset на компасе только когда баннера нет — иначе отступ уже в баннере.
|
||||
int topPad = top && !bannerVisible ? sys.top : 0;
|
||||
compassView.setPadding(sys.left, topPad, sys.right, top ? 0 : sys.bottom);
|
||||
}
|
||||
if (coordinatesWidget != null) {
|
||||
boolean top = coordinatesWidget.isDockTop();
|
||||
coordinatesWidget.setPadding(sys.left, top ? sys.top : 0,
|
||||
sys.right, top ? 0 : sys.bottom);
|
||||
}
|
||||
// Danger сидит МЕЖДУ компасом и координатами — статус-/нав-бар его
|
||||
// не касаются, но боковые displayCutout (landscape) могут перекрыть
|
||||
// текст таблицы. Так что прокидываем только left/right.
|
||||
if (dangerWidget != null) {
|
||||
dangerWidget.setPadding(sys.left, 0, sys.right, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Переприменяет уже собранные инсеты (вызывать при смене стороны дока). */
|
||||
@@ -1429,6 +1590,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (mapInterface != null) {
|
||||
// Сначала создаем UI Coordinator
|
||||
uiCoordinator = new UIRenderingCoordinator(mapInterface);
|
||||
uiCoordinator.setSettingsManager(getApplicationContext(), settingsManager);
|
||||
Log.i(TAG, "UIRenderingCoordinator создан");
|
||||
|
||||
// Подписываем UIRenderingCoordinator на изменения MapInterface
|
||||
@@ -1439,6 +1601,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
appCoordinator.setUIDataChangeNotifier(uiCoordinator);
|
||||
Log.i(TAG, "UIDataChangeNotifier установлен в AppCoordinator");
|
||||
|
||||
appCoordinator.onMapInterfaceReady(mapInterface);
|
||||
|
||||
// AppCoordinator уже подключен к MapController при инициализации
|
||||
// setMapInterface больше не нужен, так как стратегия карты централизована
|
||||
Log.i(TAG, "AppCoordinator подключен к MapController");
|
||||
@@ -1675,6 +1839,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (isFinishing()) {
|
||||
sAppCoordinator = null;
|
||||
}
|
||||
|
||||
// MapLibre lifecycle
|
||||
if (mapView != null) {
|
||||
@@ -1832,6 +1999,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
applySettings();
|
||||
}
|
||||
refreshGpsSourceButtonIcon();
|
||||
refreshNavigatorButtonState();
|
||||
if (appCoordinator != null) {
|
||||
appCoordinator.setNavigatorCameraEnabled(settingsManager.isNavigatorCameraEnabled());
|
||||
}
|
||||
|
||||
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 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 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
|
||||
private EditText etPathMaxPoints;
|
||||
private EditText etPathWidth;
|
||||
@@ -79,6 +96,7 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_settings);
|
||||
applySettingsInsets();
|
||||
|
||||
// Инициализируем менеджер настроек
|
||||
settingsManager = new SettingsManager(this);
|
||||
@@ -98,6 +116,14 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
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 элементы
|
||||
*/
|
||||
@@ -136,6 +162,23 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
etPredictionWidth = findViewById(R.id.et_prediction_width);
|
||||
etPredictionColor = findViewById(R.id.et_prediction_color);
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,6 +239,37 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
etPredictionColor.setText(String.format("#%06X", (0xFFFFFF & settingsManager.getPredictionColor())));
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -398,6 +472,15 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
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) {}
|
||||
|
||||
// Кольца дальности
|
||||
if (!saveRangeRingsSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!saveNavigatorCameraSettings()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Настройки сохранены: " + settingsManager.getSettingsSummary());
|
||||
|
||||
// Проверяем, нужно ли уведомить MainActivity об изменениях
|
||||
@@ -525,6 +608,100 @@ public class SettingsActivity extends AppCompatActivity {
|
||||
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_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) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
@@ -118,10 +123,32 @@ public class AisHubGattClient {
|
||||
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() {
|
||||
return running.get();
|
||||
}
|
||||
|
||||
/** {@code true}, если GATT-сессия в состоянии {@link BluetoothProfile#STATE_CONNECTED}. */
|
||||
public boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
if (running.get()) {
|
||||
Log.w(TAG, "AIS Hub GATT already running");
|
||||
@@ -175,7 +202,12 @@ public class AisHubGattClient {
|
||||
public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
|
||||
if (!running.get()) return;
|
||||
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);
|
||||
} else if (status == 133) {
|
||||
lastErrorWasDbFull = true;
|
||||
@@ -197,6 +229,7 @@ public class AisHubGattClient {
|
||||
connectionStartTimeMs = 0L;
|
||||
isConnecting.set(false);
|
||||
lastErrorWasDbFull = false;
|
||||
authWarningLogged = false;
|
||||
reconnectLoop.set(false);
|
||||
notifReady.set(false);
|
||||
mtuRequested.set(false);
|
||||
@@ -314,13 +347,24 @@ public class AisHubGattClient {
|
||||
public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
|
||||
gattBusy.set(false);
|
||||
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())) {
|
||||
notifReady.set(true);
|
||||
lastDataAtMs = System.currentTimeMillis();
|
||||
postState("notifying");
|
||||
if (batteryReadEnabled) {
|
||||
try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
|
||||
readBatteryOnce(g);
|
||||
startBatteryLoop(g);
|
||||
} else if (BLE_LOG) {
|
||||
Log.d(TAG, "Battery read disabled by settings (skipping resolve/read/loop)");
|
||||
}
|
||||
enqueueControlJson(buildHello());
|
||||
|
||||
// Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
|
||||
@@ -358,6 +402,17 @@ public class AisHubGattClient {
|
||||
|
||||
@Override
|
||||
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 (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
|
||||
byte[] v = ch.getValue();
|
||||
@@ -371,6 +426,13 @@ public class AisHubGattClient {
|
||||
|
||||
@Override
|
||||
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())) {
|
||||
gattBusy.set(false);
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
@@ -778,6 +840,31 @@ public class AisHubGattClient {
|
||||
}, 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) {
|
||||
if (callback != null) {
|
||||
mainHandler.post(() -> callback.onState(s));
|
||||
|
||||
@@ -48,6 +48,7 @@ public class AppCoordinator implements
|
||||
private NotificationController notificationController;
|
||||
private CompassController compassController;
|
||||
private MapController mapController;
|
||||
private NavigatorCameraController navigatorCameraController;
|
||||
private AisHubGattClient aisHubGattClient;
|
||||
|
||||
// Состояние приложения
|
||||
@@ -108,6 +109,7 @@ public class AppCoordinator implements
|
||||
this.aisPathControllers = new HashMap<>();
|
||||
this.settingsManager = new SettingsManager(context);
|
||||
this.pathController = new VesselPathController(context, settingsManager);
|
||||
this.navigatorCameraController = new NavigatorCameraController(settingsManager);
|
||||
this.uiHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
initializeControllers();
|
||||
@@ -193,6 +195,39 @@ public class AppCoordinator implements
|
||||
if (mapController != null) {
|
||||
mapController.addMapInterfaceChangeListener(this);
|
||||
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();
|
||||
compassController.startCompass();
|
||||
dataController.startDatabaseCleanup();
|
||||
// BLE старт по настройкам
|
||||
// BLE: применяем настройки (MAC + batteryRead) до старта клиента,
|
||||
// иначе клиент стартует «голым» и любые опциональные операции
|
||||
// (например, чтение Battery 0x2A19) могут спровоцировать системный
|
||||
// диалог сопряжения. Конфигурация ставит batteryReadEnabled=false
|
||||
// по умолчанию — это убирает основной триггер pairing.
|
||||
configureBleFromSettings();
|
||||
tryStartBleIfEnabled();
|
||||
|
||||
// Восстанавливаем данные из БД
|
||||
@@ -266,7 +306,9 @@ public class AppCoordinator implements
|
||||
if (aisHubGattClient == null) return;
|
||||
String mac = settingsManager.getBLEDeviceMac();
|
||||
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() {
|
||||
@@ -476,16 +518,56 @@ public class AppCoordinator implements
|
||||
return;
|
||||
}
|
||||
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 to = Math.min(start + AIS_UI_BATCH_SIZE, copy.size());
|
||||
final int to = Math.min(start + AIS_UI_BATCH_SIZE, inRange.size());
|
||||
uiHandler.post(() -> {
|
||||
if (uiDataNotifier == null) return;
|
||||
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) {
|
||||
@@ -559,6 +641,8 @@ public class AppCoordinator implements
|
||||
" mode=" + settingsManager.getDataMode());
|
||||
}
|
||||
|
||||
updateNavigatorCamera();
|
||||
|
||||
// Важно: ownship.update может приходить очень часто (десятки раз в секунду).
|
||||
// Модель обновляем всегда, а тяжёлые операции (путь/БД/UI) — с throttling,
|
||||
// чтобы не забивать главный поток и не провоцировать нестабильность BLE.
|
||||
@@ -674,6 +758,7 @@ public class AppCoordinator implements
|
||||
ownVessel.setActiveSatellites(vessel.getActiveSatellites());
|
||||
|
||||
markRecentGpsActivity();
|
||||
updateNavigatorCamera();
|
||||
if (pathController != null && isValidCoordinates(ownVessel.getLatitude(), ownVessel.getLongitude())) {
|
||||
pathController.addPathPoint(
|
||||
ownVessel.getLongitude(),
|
||||
@@ -729,6 +814,7 @@ public class AppCoordinator implements
|
||||
|
||||
// Обновляем компас после изменения курса
|
||||
updateCompass();
|
||||
updateNavigatorCamera();
|
||||
|
||||
// Сохраняем в БД
|
||||
dataController.saveVesselPosition(ownVessel);
|
||||
@@ -1055,7 +1141,8 @@ public class AppCoordinator implements
|
||||
// Устанавливаем MarkerClickListener на новую карту
|
||||
newMapInterface.setMarkerClickListener(this);
|
||||
Log.i(TAG, "MarkerClickListener установлен на новую карту");
|
||||
|
||||
syncNavigatorMapInterface();
|
||||
updateNavigatorCamera();
|
||||
// Восстанавливаем состояние на новой карте
|
||||
restoreMapStateOnNewInterface();
|
||||
}
|
||||
@@ -1168,6 +1255,65 @@ public class AppCoordinator implements
|
||||
return nearby;
|
||||
}
|
||||
|
||||
/**
|
||||
* Контейнер «цель + дистанция/пеленг до собственного судна».
|
||||
* Используется виджетом ближайших целей в зоне опасности.
|
||||
*/
|
||||
public static final class DangerEntry {
|
||||
public final AISVessel vessel;
|
||||
/** Дистанция в метрах. */
|
||||
public final double distanceMeters;
|
||||
/** Пеленг от собственного судна на цель, °. */
|
||||
public final double bearingDegrees;
|
||||
|
||||
public DangerEntry(AISVessel vessel, double distanceMeters, double bearingDegrees) {
|
||||
this.vessel = vessel;
|
||||
this.distanceMeters = distanceMeters;
|
||||
this.bearingDegrees = bearingDegrees;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает цели в зоне опасности (по {@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;
|
||||
|
||||
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);
|
||||
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() {
|
||||
if (listener != null) {
|
||||
float azimuth = (float) ownVessel.getCourse();
|
||||
@@ -1348,20 +1494,51 @@ public class AppCoordinator implements
|
||||
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() {
|
||||
if (ownVessel != null) {
|
||||
if (ownVessel == null) return;
|
||||
Log.d(TAG, "Запрос центрирования карты на судне: " + ownVessel.getLatitude() + "," + ownVessel.getLongitude());
|
||||
|
||||
// Уведомляем UI Coordinator о необходимости центрирования карты
|
||||
syncNavigatorMapInterface();
|
||||
if (navigatorCameraController != null && navigatorCameraController.isEnabled()) {
|
||||
navigatorCameraController.onOwnVesselUpdated(ownVessel);
|
||||
return;
|
||||
}
|
||||
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.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.AISVessel;
|
||||
import com.grigowashere.aismap.view.CursorOverlay;
|
||||
@@ -33,6 +37,8 @@ public class MapForgeImpl implements MapInterface {
|
||||
private Marker ownVesselMarker;
|
||||
private CursorOverlay cursorOverlay;
|
||||
private Vessel ownVessel;
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
private MapUserInteractionListener mapUserInteractionListener;
|
||||
|
||||
public MapForgeImpl(Context context, MapView mapView) {
|
||||
this.context = context;
|
||||
@@ -166,6 +172,53 @@ public class MapForgeImpl implements MapInterface {
|
||||
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
|
||||
public void setBearing(float bearing) {
|
||||
// MapForge: нет прямой поддержки bearing у MapViewPosition — игнорируем
|
||||
@@ -246,19 +299,26 @@ public class MapForgeImpl implements MapInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||
this.mapUserInteractionListener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает слушатель движения карты для обновления курсора
|
||||
*/
|
||||
private void setupMapMovementListener() {
|
||||
if (mapView != null) {
|
||||
// mapView.getModel().mapViewPosition.addObserver(new org.mapsforge.map.model.Observer() {
|
||||
// @Override
|
||||
// public void onChange() {
|
||||
// // Обновляем координаты курсора при движении карты
|
||||
// updateCursorFromMapCenter();
|
||||
// }
|
||||
// });
|
||||
if (mapView == null) return;
|
||||
mapView.setOnTouchListener((v, event) -> {
|
||||
int action = event.getActionMasked();
|
||||
if (mapUserInteractionListener != null
|
||||
&& (action == MotionEvent.ACTION_DOWN
|
||||
|| action == MotionEvent.ACTION_MOVE
|
||||
|| action == MotionEvent.ACTION_POINTER_DOWN)) {
|
||||
mapUserInteractionListener.onUserMapInteraction();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -71,6 +71,40 @@ public interface MapInterface {
|
||||
*/
|
||||
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 = север вверх)
|
||||
*/
|
||||
@@ -136,6 +170,45 @@ public interface MapInterface {
|
||||
*/
|
||||
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 SOURCE_NAVIGATION_AIDS = "navigation_aids_source";
|
||||
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_A = "vessel_icon_a";
|
||||
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 MarkerClickListener markerClickListener;
|
||||
private MapUserInteractionListener mapUserInteractionListener;
|
||||
|
||||
// Pending центрирование до готовности карты/стиля
|
||||
private Double pendingCenterLat = 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) {
|
||||
this.context = context;
|
||||
this.mapView = mapView;
|
||||
@@ -556,6 +581,7 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
String iconName = pickIconNameFor(vessel);
|
||||
props.put("icon", iconName);
|
||||
props.put("stale", stale);
|
||||
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
|
||||
// Проставим статусную иконку, если статус поддержан
|
||||
String status = vessel.getNavigationalStatus();
|
||||
String statusIcon = mapStatusToIcon(status);
|
||||
@@ -613,6 +639,7 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
JSONObject props = feature.getJSONObject("properties");
|
||||
props.put("icon", pickIconNameFor(vessel));
|
||||
props.put("stale", stale);
|
||||
props.put("warning_zone", isInWarningZone(vessel.getLatitude(), vessel.getLongitude()));
|
||||
String statusIcon = mapStatusToIcon(vessel.getNavigationalStatus());
|
||||
if (statusIcon != null) {
|
||||
props.put("status_icon", statusIcon);
|
||||
@@ -633,6 +660,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
|
||||
public void removeAISVesselMarker(String mmsi) {
|
||||
if (mmsi == null) return;
|
||||
@@ -858,6 +930,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
|
||||
public void addLayer(String layerId, Object layerData) {
|
||||
if (style == null || !isStyleValid()) {
|
||||
@@ -949,6 +1089,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) {
|
||||
SymbolLayer layer = new SymbolLayer(LAYER_VESSELS, SOURCE_VESSELS)
|
||||
@@ -3161,11 +3334,19 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
Log.e(TAG, "updateAdditionalLayers: ошибка обновления слоев", e);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||
this.mapUserInteractionListener = listener;
|
||||
}
|
||||
|
||||
private void setupMapMovementListener() {
|
||||
if (maplibreMap != null) {
|
||||
maplibreMap.addOnCameraMoveListener(() -> {
|
||||
// Обновляем координаты курсора при движении карты
|
||||
updateCursorFromMapCenter();
|
||||
maplibreMap.addOnCameraMoveListener(() -> updateCursorFromMapCenter());
|
||||
maplibreMap.addOnCameraMoveStartedListener(reason -> {
|
||||
if (reason == org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE
|
||||
&& mapUserInteractionListener != null) {
|
||||
mapUserInteractionListener.onUserMapInteraction();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3198,4 +3379,145 @@ public class MapLibreMapImpl implements MapInterface {
|
||||
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,103 @@
|
||||
package com.grigowashere.aismap.maps;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
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 final MapView mapView;
|
||||
private MapLibreMap map;
|
||||
private boolean styleLoaded;
|
||||
|
||||
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() {
|
||||
mapView.onPause();
|
||||
}
|
||||
|
||||
public void onStop() {
|
||||
mapView.onStop();
|
||||
}
|
||||
|
||||
public void onDestroy() {
|
||||
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;
|
||||
double zoom = zoomForRangeMeters(rangeMeters);
|
||||
CameraPosition position = new CameraPosition.Builder()
|
||||
.target(new LatLng(lat, lon))
|
||||
.zoom(zoom)
|
||||
.bearing(bearingDeg)
|
||||
.tilt(0.0)
|
||||
.build();
|
||||
map.easeCamera(CameraUpdateFactory.newCameraPosition(position), 400);
|
||||
}
|
||||
|
||||
/** Подбирает зум так, чтобы весь радиус 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 MapUserInteractionListener mapUserInteractionListener;
|
||||
private float lastMapAzimuth = 0.0f;
|
||||
|
||||
// Курсор overlay
|
||||
@@ -134,6 +135,7 @@ public class YandexMapImpl implements MapInterface {
|
||||
if (markerManager != null) {
|
||||
markerManager.updateAISVesselMarker(vessel);
|
||||
}
|
||||
updateWarningHaloForVessel(vessel);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -141,6 +143,7 @@ public class YandexMapImpl implements MapInterface {
|
||||
if (markerManager != null) {
|
||||
markerManager.updateAISVesselMarker(vessel);
|
||||
}
|
||||
updateWarningHaloForVessel(vessel);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -148,6 +151,7 @@ public class YandexMapImpl implements MapInterface {
|
||||
if (vessels == null || markerManager == null) return;
|
||||
for (AISVessel vessel : vessels) {
|
||||
markerManager.updateAISVesselMarker(vessel);
|
||||
updateWarningHaloForVessel(vessel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +160,12 @@ public class YandexMapImpl implements MapInterface {
|
||||
if (markerManager != null) {
|
||||
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
|
||||
@@ -163,6 +173,7 @@ public class YandexMapImpl implements MapInterface {
|
||||
if (markerManager != null) {
|
||||
markerManager.clearAISVesselMarkers();
|
||||
}
|
||||
clearAllWarningHalos();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -185,6 +196,56 @@ public class YandexMapImpl implements MapInterface {
|
||||
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
|
||||
public void setBearing(float bearing) {
|
||||
try {
|
||||
@@ -326,6 +387,173 @@ public class YandexMapImpl implements MapInterface {
|
||||
// но если в будущем будет использоваться, нужно добавить очистку
|
||||
}
|
||||
|
||||
// ===== 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)
|
||||
*/
|
||||
@@ -487,13 +715,21 @@ public class YandexMapImpl implements MapInterface {
|
||||
/**
|
||||
* Настраивает слушатель движения карты для обновления курсора
|
||||
*/
|
||||
@Override
|
||||
public void setMapUserInteractionListener(MapUserInteractionListener listener) {
|
||||
this.mapUserInteractionListener = listener;
|
||||
}
|
||||
|
||||
private void setupMapMovementListener() {
|
||||
if (mapView != null) {
|
||||
mapView.getMap().addCameraListener(new com.yandex.mapkit.map.CameraListener() {
|
||||
@Override
|
||||
public void onCameraPositionChanged(com.yandex.mapkit.map.Map map, com.yandex.mapkit.map.CameraPosition cameraPosition, com.yandex.mapkit.map.CameraUpdateReason cameraUpdateReason, boolean finished) {
|
||||
// Обновляем координаты курсора при движении карты
|
||||
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.grigowashere.aismap.R;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
import com.grigowashere.aismap.utils.UiInsetsUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -55,6 +56,9 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
private EditText etBleBridgeHost;
|
||||
private EditText etBleBridgePort;
|
||||
|
||||
// BLE optional battery read (system pairing trigger on some devices)
|
||||
private SwitchMaterial swBleBatteryEnabled;
|
||||
|
||||
private Button btnSave;
|
||||
private Button btnCancel;
|
||||
|
||||
@@ -72,6 +76,7 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_interfaces_settings);
|
||||
applySettingsInsets();
|
||||
settingsManager = new SettingsManager(this);
|
||||
BluetoothManager bm = (BluetoothManager) getSystemService(Context.BLUETOOTH_SERVICE);
|
||||
btAdapter = bm != null ? bm.getAdapter() : null;
|
||||
@@ -82,6 +87,14 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
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() {
|
||||
etUdpPort = findViewById(R.id.et_udp_port);
|
||||
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);
|
||||
etBleBridgeHost = findViewById(R.id.et_ble_udp_host);
|
||||
etBleBridgePort = findViewById(R.id.et_ble_udp_port);
|
||||
swBleBatteryEnabled = findViewById(R.id.switch_ble_battery_enabled);
|
||||
btnSave = findViewById(R.id.btn_save);
|
||||
btnCancel = findViewById(R.id.btn_cancel);
|
||||
btnBleScan = findViewById(R.id.btn_ble_scan);
|
||||
@@ -107,6 +121,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
swBleBridgeEnabled.setChecked(settingsManager.isBleUdpBridgeEnabled());
|
||||
etBleBridgeHost.setText(settingsManager.getBleUdpBridgeHost());
|
||||
etBleBridgePort.setText(String.valueOf(settingsManager.getBleUdpBridgePort()));
|
||||
|
||||
if (swBleBatteryEnabled != null) {
|
||||
swBleBatteryEnabled.setChecked(settingsManager.isBleReadBatteryEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
private void setupHandlers() {
|
||||
@@ -202,6 +220,10 @@ public class InterfacesSettingsActivity extends AppCompatActivity {
|
||||
int brPort = parseInt(etBleBridgePort.getText().toString().trim(), 10110, 1, 65535);
|
||||
settingsManager.setBleUdpBridgePort(brPort);
|
||||
|
||||
if (swBleBatteryEnabled != null) {
|
||||
settingsManager.setBleReadBatteryEnabled(swBleBatteryEnabled.isChecked());
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Настройки сохранены", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -196,60 +196,63 @@ public class BottomSheetsManager {
|
||||
|
||||
if (tvTitle != null) {
|
||||
String name = vessel.getVesselName() != null && !vessel.getVesselName().isEmpty() ? vessel.getVesselName() : "AIS СУДНО";
|
||||
// Флаг страны по MMSI оставляем — это единственный визуальный
|
||||
// маркер, который тут реально несёт смысл. Остальные эмодзи в
|
||||
// карточке цели убраны, чтобы текст не выглядел как чат.
|
||||
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 (tvCallsign != null) tvCallsign.setText("📻 Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
|
||||
if (tvImo != null) tvImo.setText("🏷️ IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
|
||||
if (tvType != null) tvType.setText("🚢 Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
|
||||
if (tvMmsi != null) tvMmsi.setText("MMSI: " + (vessel.getMmsi() != null ? vessel.getMmsi() : "--"));
|
||||
if (tvCallsign != null) tvCallsign.setText("Позывной: " + (vessel.getCallSign() != null ? vessel.getCallSign() : "--"));
|
||||
if (tvImo != null) tvImo.setText("IMO: " + (vessel.getImo() > 0 ? String.valueOf(vessel.getImo()) : "--"));
|
||||
if (tvType != null) tvType.setText("Тип: " + (vessel.getVesselType() != null ? vessel.getVesselType() : "--"));
|
||||
|
||||
if (tvPosition != null) {
|
||||
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 {
|
||||
tvPosition.setText("📍 Координаты: --");
|
||||
tvPosition.setText("Координаты: --");
|
||||
}
|
||||
}
|
||||
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 (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 (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 (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 (tvNavStatus != null) tvNavStatus.setText("🚦 Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
|
||||
if (tvClass != null) tvClass.setText("📋 Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
|
||||
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 (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 (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 (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 (tvNavStatus != null) tvNavStatus.setText("Статус: " + (vessel.getNavigationalStatus() != null ? vessel.getNavigationalStatus() : "--"));
|
||||
if (tvClass != null) tvClass.setText("Класс: " + (vessel.getVesselClass() != null ? vessel.getVesselClass() : "--"));
|
||||
if (tvSignal != null) {
|
||||
if (vessel.getSignalStrength() > 0) {
|
||||
tvSignal.setText(String.format("📶 Сигнал: %d", vessel.getSignalStrength()));
|
||||
tvSignal.setText(String.format("Сигнал: %d", vessel.getSignalStrength()));
|
||||
} 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) {
|
||||
Vessel ourVessel = appCoordinator.getOwnVessel();
|
||||
if (ourVessel != null && ourVessel.getLatitude() != 0 && ourVessel.getLongitude() != 0 && vessel.getLatitude() != 0 && vessel.getLongitude() != 0) {
|
||||
double distance = com.grigowashere.aismap.utils.NavigationUtils.calculateDistance(ourVessel.getLatitude(), ourVessel.getLongitude(), vessel.getLatitude(), vessel.getLongitude());
|
||||
if (tvDistance != null) tvDistance.setText("📏 Расстояние: " + com.grigowashere.aismap.utils.NavigationUtils.formatDistance(distance));
|
||||
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 (tvBearing != null) tvBearing.setText("Пеленг: " + com.grigowashere.aismap.utils.NavigationUtils.formatRelativeBearing(relativeBearing));
|
||||
} else {
|
||||
if (tvDistance != null) tvDistance.setText("📏 Расстояние: --");
|
||||
if (tvBearing != null) tvBearing.setText("🧭 Пеленг: --");
|
||||
if (tvDistance != null) tvDistance.setText("Расстояние: --");
|
||||
if (tvBearing != null) tvBearing.setText("Пеленг: --");
|
||||
}
|
||||
}
|
||||
|
||||
if (tvTimeAgo != null) {
|
||||
if (vessel.getLastUpdate() != null) {
|
||||
long secondsAgo = java.time.Duration.between(vessel.getLastUpdate(), java.time.LocalDateTime.now()).getSeconds();
|
||||
tvTimeAgo.setText("⏱️ Время назад: " + formatTimeAgo(secondsAgo));
|
||||
tvTimeAgo.setText("Время назад: " + formatTimeAgo(secondsAgo));
|
||||
} else {
|
||||
tvTimeAgo.setText("⏱️ Время назад: --");
|
||||
tvTimeAgo.setText("Время назад: --");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +290,7 @@ public class BottomSheetsManager {
|
||||
TextView tvTimeAgo = aisBottomSheetView.findViewById(R.id.bottom_sheet_ais_time_ago);
|
||||
if (tvTimeAgo != null && currentAISVessel.getLastUpdate() != null) {
|
||||
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.util.Log;
|
||||
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.grigowashere.aismap.R;
|
||||
import com.grigowashere.aismap.maps.MapInterface;
|
||||
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.AISVessel;
|
||||
import com.grigowashere.aismap.utils.GeoUtils;
|
||||
import com.grigowashere.aismap.utils.SettingsManager;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.ArrayList;
|
||||
@@ -32,6 +39,8 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
||||
|
||||
private MapInterface mapInterface;
|
||||
private Handler uiHandler;
|
||||
private SettingsManager settingsManager;
|
||||
private android.content.Context appContext;
|
||||
|
||||
// Pending операции для батчинга
|
||||
private Vessel pendingVesselUpdate;
|
||||
@@ -56,6 +65,16 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
||||
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 механизмов
|
||||
*/
|
||||
@@ -137,6 +156,7 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
||||
try {
|
||||
Log.d(TAG, "Выполняем vessel update: " + pendingVesselUpdate.getLatitude() + "," + pendingVesselUpdate.getLongitude());
|
||||
mapInterface.updateOwnVesselPosition(pendingVesselUpdate);
|
||||
applyRangeRingsAround(pendingVesselUpdate);
|
||||
Log.d(TAG, "Vessel update выполнен успешно");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Ошибка vessel update: " + e.getMessage(), e);
|
||||
@@ -145,6 +165,57 @@ public class UIRenderingCoordinator implements UIDataChangeNotifier, MapInterfac
|
||||
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 судов
|
||||
*/
|
||||
|
||||
@@ -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,72 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,29 @@ public class SettingsManager {
|
||||
private static final String KEY_START_ZOOM_LEVEL = "start_zoom_level";
|
||||
/** Как карта следует за ориентацией: {@link #MAP_ROTATION_COMPASS} / COURSE / MANUAL */
|
||||
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
|
||||
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_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_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;
|
||||
@@ -84,6 +101,24 @@ public class SettingsManager {
|
||||
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 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";
|
||||
@@ -107,6 +142,11 @@ public class SettingsManager {
|
||||
/** Как курс (COG / GPS bearing). */
|
||||
public static final String MAP_ROTATION_COURSE = "course";
|
||||
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 SharedPreferences prefs;
|
||||
@@ -455,6 +495,63 @@ public class SettingsManager {
|
||||
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 слушатель
|
||||
*/
|
||||
@@ -697,4 +794,86 @@ public class SettingsManager {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,51 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
protected static final float MAX_SCALE = 2.0f;
|
||||
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 dockTop = true;
|
||||
protected boolean dockTop = true; // Инициализируется в init() через getDefaultDockTop()
|
||||
protected boolean isMorphing = false;
|
||||
protected float morphProgress = 0.0f; // 0 = dock, 1 = circle
|
||||
|
||||
@@ -73,21 +115,18 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
setClickable(true);
|
||||
setFocusable(true);
|
||||
|
||||
// Инициализируем в dock-режиме
|
||||
post(() -> {
|
||||
if (isDocked) {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent != null) {
|
||||
setX(0);
|
||||
setY(0);
|
||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||
lp.width = ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
lp.height = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
dockHeightPx = 0; // Сбрасываем сохраненную высоту
|
||||
setLayoutParams(lp);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Стартовая сторона дока (top/bottom) определяется наследником. Само
|
||||
// фактическое положение задаёт RelativeLayout (alignParentTop / Bottom /
|
||||
// layout_above), а этот флаг — внутренняя модель для resize-зоны и
|
||||
// стакинга других dock-виджетов.
|
||||
this.dockTop = getDefaultDockTop();
|
||||
// Высота view в dock-режиме считается в onMeasure через
|
||||
// measureDockContentHeightPx(...) при lp.height=WRAP_CONTENT (это
|
||||
// прописано в activity_main.xml). А переход dock<->circle сам выставляет
|
||||
// правильные lp в конце анимации (см. setDocked). Намеренно НЕ дёргаем
|
||||
// setLayoutParams() из init().post() — это вызывало второй проход layout
|
||||
// ПОСЛЕ первого measure и оставляло координатный/danger виджет с нулевой
|
||||
// высотой на первый кадр, пока не приходил какой-нибудь size-update.
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -232,7 +271,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
// Ресайзим именно контент (dockHeightPx). Паддинги от WindowInsets
|
||||
// прибавляются поверх в onMeasure, поэтому «рабочая» часть не уезжает
|
||||
// под системный бар даже при минимальном размере.
|
||||
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
int currentContent = dockHeightPx > 0 ? dockHeightPx : (int) dp(getDefaultDockHeightDp());
|
||||
int newHeight = currentContent;
|
||||
|
||||
if (dockTop) {
|
||||
@@ -309,7 +348,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
// При докинге всегда устанавливаем размер по умолчанию
|
||||
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) {
|
||||
@@ -324,10 +363,16 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
if (isDocked) {
|
||||
int width = MeasureSpec.getSize(widthMeasureSpec);
|
||||
// dockHeightPx/DEFAULT — это высота полезного контента; к ней
|
||||
// прибавляем padding от WindowInsets, чтобы виджет фактически
|
||||
// расширялся под статус-бар или нав-бар и не прятал контент.
|
||||
int content = dockHeightPx > 0 ? dockHeightPx : (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
// Высота content-области:
|
||||
// * если пользователь ВРУЧНУЮ растянул виджет (dockHeightPx>0) —
|
||||
// используем эту фиксированную высоту;
|
||||
// * иначе спрашиваем у конкретного виджета через measure-метод,
|
||||
// сколько ему нужно для отрисовки на данной ширине.
|
||||
// Системные паддинги (статус-бар/нав-бар) прибавляются СВЕРХУ
|
||||
// content-области, чтобы карта не пряталась под бары.
|
||||
int content = dockHeightPx > 0
|
||||
? dockHeightPx
|
||||
: measureDockContentHeightPx(width);
|
||||
int height = content + getPaddingTop() + getPaddingBottom();
|
||||
setMeasuredDimension(width, height);
|
||||
} else {
|
||||
@@ -353,7 +398,15 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -372,7 +425,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
int parentWidth = parent.getWidth();
|
||||
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 endW = docked ? parentWidth : circleSize;
|
||||
@@ -422,12 +475,30 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
@Override
|
||||
public void onAnimationEnd(Animator animation) {
|
||||
ViewGroup.LayoutParams lp = getLayoutParams();
|
||||
if (docked) {
|
||||
// В dock-режиме высоту контролирует measureDockContentHeightPx,
|
||||
// ширину — родитель. Если оставить фиксированные endW/endH
|
||||
// от анимации, виджет навсегда «застрянет» в этом размере
|
||||
// и нарушит формулу контент+паддинги.
|
||||
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;
|
||||
|
||||
postInvalidateOnAnimation();
|
||||
@@ -477,7 +548,7 @@ public abstract class BaseDockWidget extends FrameLayout {
|
||||
ViewGroup parent = (ViewGroup) getParent();
|
||||
if (parent == null) return 0;
|
||||
|
||||
int dockHeight = (int) dp(DEFAULT_DOCK_HEIGHT_DP);
|
||||
int dockHeight = (int) dp(getDefaultDockHeightDp());
|
||||
float y = 0;
|
||||
|
||||
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) {
|
||||
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++) {
|
||||
View child = parent.getChildAt(i);
|
||||
if (child instanceof BaseDockWidget) {
|
||||
BaseDockWidget widget = (BaseDockWidget) child;
|
||||
if (widget.isDocked()) {
|
||||
if (widget.isDockTop()) {
|
||||
topWidgets.add(widget);
|
||||
} else {
|
||||
bottomWidgets.add(widget);
|
||||
if (widget.isDocked() && !widget.isMorphing) {
|
||||
widget.setTranslationX(0f);
|
||||
widget.setTranslationY(0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Перепозиционируем виджеты сверху
|
||||
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);
|
||||
}
|
||||
parent.requestLayout();
|
||||
}
|
||||
|
||||
// Абстрактные методы для переопределения в наследниках
|
||||
|
||||
@@ -65,6 +65,29 @@ public class CompassView extends BaseDockWidget {
|
||||
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() {
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
@@ -156,38 +179,34 @@ public class CompassView extends BaseDockWidget {
|
||||
// чтобы под статус-бар/бровь тоже уходил единый тон.
|
||||
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 в
|
||||
// координатах): слева HEADING (азимут), справа MAG (магн. компас).
|
||||
float cx = left + w / 2f;
|
||||
// Размеры шапки фиксированы и не зависят от высоты виджета — это
|
||||
// обычные строчки текста, они и так хорошо смотрятся при любой высоте.
|
||||
float padInner = dp(10);
|
||||
float labelY = top + dp(12) * Math.max(1f, scaleFactor * 0.9f);
|
||||
float valueY = labelY + dp(16) * Math.max(1f, scaleFactor * 0.9f);
|
||||
float labelY = top + dp(12);
|
||||
float valueY = labelY + dp(16);
|
||||
|
||||
labelPaint.setTextAlign(Paint.Align.LEFT);
|
||||
valuePaint.setTextAlign(Paint.Align.LEFT);
|
||||
accentPaint.setTextAlign(Paint.Align.LEFT);
|
||||
|
||||
canvas.drawText("HEADING", left + padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) currentAzimuth) + "°",
|
||||
canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_heading),
|
||||
left + padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) currentAzimuth) + "\u00B0",
|
||||
left + padInner, valueY, accentPaint);
|
||||
|
||||
labelPaint.setTextAlign(Paint.Align.RIGHT);
|
||||
valuePaint.setTextAlign(Paint.Align.RIGHT);
|
||||
canvas.drawText("MAG", right - padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) magneticCompass) + "°",
|
||||
canvas.drawText(getResources().getString(com.grigowashere.aismap.R.string.compass_label_mag),
|
||||
right - padInner, labelY, labelPaint);
|
||||
canvas.drawText(((int) magneticCompass) + "\u00B0",
|
||||
right - padInner, valueY, valuePaint);
|
||||
|
||||
// Разделитель под шапкой — такой же, как в координатах.
|
||||
float dividerY = valueY + dp(6);
|
||||
canvas.drawLine(left + padInner, dividerY, right - padInner, dividerY, dividerPaint);
|
||||
|
||||
// Цвет делений шкалы — светло-серый, чтобы не спорил с фоном палитры.
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setTextSize(24 * scaleFactor);
|
||||
paint.setTextAlign(Paint.Align.CENTER);
|
||||
|
||||
// Плавное обновление азимута
|
||||
@@ -200,49 +219,57 @@ public class CompassView extends BaseDockWidget {
|
||||
postInvalidateOnAnimation();
|
||||
}
|
||||
|
||||
// Рисуем простую шкалу под шапкой. Центр смещён, чтобы шкала
|
||||
// не наезжала на label-строку HEADING/MAG.
|
||||
// === Шкала компаса ===
|
||||
// ВСЕ метрики шкалы выражены в долях от фактической высоты scaleH —
|
||||
// тогда буквы N/S/W/E и градусные подписи никогда не вылезают за
|
||||
// нижнюю границу виджета, даже если пользователь сжал его ручкой.
|
||||
float centerX = left + w / 2f;
|
||||
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;
|
||||
|
||||
// Рисуем деления шкалы
|
||||
for (int degree = 0; degree < 360; degree += 15) {
|
||||
// Вычисляем относительное положение деления
|
||||
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 lineHeight = (degree % 30 == 0) ? 20 * scaleFactor : 10 * scaleFactor;
|
||||
float lineHeight = (degree % 30 == 0) ? majorTickH : minorTickH;
|
||||
canvas.drawLine(x, centerY - lineHeight, x, centerY + lineHeight, paint);
|
||||
|
||||
if (degree % 30 == 0) {
|
||||
String degreeText = String.valueOf(degree);
|
||||
paint.setTextSize(16 * scaleFactor);
|
||||
canvas.drawText(degreeText, x, centerY - 30 * scaleFactor, paint);
|
||||
paint.setTextSize(degreeTextSize);
|
||||
canvas.drawText(String.valueOf(degree), x, degreeTextY, paint);
|
||||
}
|
||||
if (degree % 45 == 0) {
|
||||
int directionIndex = (degree / 45) % 8;
|
||||
if (directionIndex < directions.length) {
|
||||
// Буква стороны света увеличивается при приближении к центру
|
||||
// Буква стороны света увеличивается при приближении к центру.
|
||||
float proximity = 1f - Math.min(Math.abs(relativeDegree) / (visibleDegrees / 2f), 1f);
|
||||
float letterSize = (24f + 36f * proximity) * scaleFactor; // 24..48
|
||||
// На краях ~0.35*scaleH, в центре ~0.7*scaleH — никогда не больше scaleH.
|
||||
float letterSize = scaleH * (0.35f + 0.35f * proximity);
|
||||
paint.setTextSize(letterSize);
|
||||
canvas.drawText(directions[directionIndex], x, centerY + 50 * scaleFactor, paint);
|
||||
}
|
||||
canvas.drawText(directions[directionIndex], x, letterBaseY, 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) {
|
||||
float relativeBearing = getShortestRotation(currentAzimuth, (float) vessel.getCourse());
|
||||
if (Math.abs(relativeBearing) <= visibleDegrees / 2) {
|
||||
float x = centerX + (relativeBearing / (visibleDegrees / 2)) * (w / 2);
|
||||
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));
|
||||
drawVesselTriangle(canvas, x, centerY, size, (float) (vessel.getCourse() - currentAzimuth));
|
||||
}
|
||||
@@ -251,7 +278,7 @@ public class CompassView extends BaseDockWidget {
|
||||
// Центральная линия (направление вперёд) — только в области шкалы,
|
||||
// чтобы не пересекать шапку HEADING/MAG.
|
||||
paint.setColor(Color.RED);
|
||||
paint.setStrokeWidth(3 * scaleFactor);
|
||||
paint.setStrokeWidth(Math.max(2f, scaleH * 0.05f));
|
||||
canvas.drawLine(centerX, scaleTop, centerX, bottom, paint);
|
||||
paint.setColor(TICK_COLOR);
|
||||
paint.setStrokeWidth(1);
|
||||
@@ -360,7 +387,8 @@ public class CompassView extends BaseDockWidget {
|
||||
canvas.drawText(((int) currentAzimuth) + "°", cx, cy + dp(2), accentPaint);
|
||||
labelPaint.setTextAlign(Paint.Align.CENTER);
|
||||
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);
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -46,6 +46,37 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
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() {
|
||||
backgroundPaint = new Paint();
|
||||
backgroundPaint.setColor(BACKGROUND_COLOR);
|
||||
@@ -125,7 +156,7 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
if (vessel.getLatitude() != 0 || vessel.getLongitude() != 0) {
|
||||
coordinatesText = formatLatLon(vessel.getLatitude(), vessel.getLongitude());
|
||||
} else {
|
||||
coordinatesText = "нет фикса";
|
||||
coordinatesText = getResources().getString(com.grigowashere.aismap.R.string.coords_value_no_fix);
|
||||
}
|
||||
|
||||
if (vessel.getSpeed() > 0.05) {
|
||||
@@ -195,34 +226,40 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
float innerTop = top + dp(8);
|
||||
float innerBottom = bottom - dp(8);
|
||||
|
||||
// Строка 1: координаты (с подписью "POSITION").
|
||||
// Строка 1: координаты (с подписью "КООРДИНАТЫ").
|
||||
Paint posPaint = getCoordinatesPaint();
|
||||
float labelH = labelPaint.getTextSize() * 1.1f;
|
||||
float valueH = posPaint.getTextSize() * 1.15f;
|
||||
|
||||
android.content.res.Resources res = getResources();
|
||||
|
||||
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;
|
||||
canvas.drawText(coordinatesText, innerLeft, y, posPaint);
|
||||
|
||||
// Строка 2: SOG | COG | ACC в три колонки.
|
||||
// Строка 2: SOG/COG/ACC в три колонки.
|
||||
float colTop = y + dp(10);
|
||||
float colW = (innerRight - innerLeft) / 3f;
|
||||
float colLabelY = colTop + labelH;
|
||||
float colValueY = colLabelY + valueH;
|
||||
|
||||
// SOG
|
||||
canvas.drawText("SOG", innerLeft, colLabelY, labelPaint);
|
||||
// SOG (скорость).
|
||||
canvas.drawText(res.getString(com.grigowashere.aismap.R.string.coords_label_sog),
|
||||
innerLeft, colLabelY, labelPaint);
|
||||
canvas.drawText(sogText, innerLeft, colValueY, getSOGPaint());
|
||||
|
||||
// COG
|
||||
// COG (курс по земле).
|
||||
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());
|
||||
|
||||
// ACC
|
||||
// ACC (точность).
|
||||
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());
|
||||
|
||||
if (colValueY > innerBottom) {
|
||||
@@ -278,7 +315,8 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
|
||||
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;
|
||||
drawCentered(canvas, latLine, centerX, y, posPaint);
|
||||
y += lineH;
|
||||
@@ -291,14 +329,17 @@ public class CoordinatesDockWidget extends BaseDockWidget {
|
||||
// SOG / COG бок о бок.
|
||||
float colCenterL = centerX - radius * 0.45f;
|
||||
float colCenterR = centerX + radius * 0.45f;
|
||||
drawCentered(canvas, "SOG", colCenterL, y, labelPaint);
|
||||
drawCentered(canvas, "COG", colCenterR, y, labelPaint);
|
||||
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_sog),
|
||||
colCenterL, y, labelPaint);
|
||||
drawCentered(canvas, getResources().getString(com.grigowashere.aismap.R.string.coords_label_cog),
|
||||
colCenterR, y, labelPaint);
|
||||
y += bigValue + lineGap;
|
||||
drawCentered(canvas, sogText, colCenterL, y, sogPaint);
|
||||
drawCentered(canvas, cogText, colCenterR, y, cogPaint);
|
||||
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;
|
||||
drawCentered(canvas, accuracyText, centerX, y, accPaint);
|
||||
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
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.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)}.
|
||||
*/
|
||||
public class DangerTargetsDockWidget extends BaseDockWidget {
|
||||
|
||||
/** Запись таблицы: имя/MMSI + пеленг + дистанция в метрах. */
|
||||
public static final class DangerEntry {
|
||||
public final String name;
|
||||
public final double bearingDeg;
|
||||
public final double distanceMeters;
|
||||
|
||||
public DangerEntry(String name, double bearingDeg, double distanceMeters) {
|
||||
this.name = name;
|
||||
this.bearingDeg = bearingDeg;
|
||||
this.distanceMeters = distanceMeters;
|
||||
}
|
||||
}
|
||||
|
||||
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 = 0xFFFF6B6B;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// === Строки целей. Колонки: имя | пеленг | дистанция ===
|
||||
// Имя — широкая колонка слева, пеленг и дистанция — справа выровнены по
|
||||
// фиксированной ширине, чтобы цифры не «прыгали» между строками.
|
||||
float distMaxWidth = textPaint.measureText("999.99 nm");
|
||||
float bearingMaxWidth = textPaint.measureText("000\u00B0");
|
||||
float colDistanceRight = innerRight;
|
||||
float colBearingRight = colDistanceRight - distMaxWidth - dp(10);
|
||||
float nameRight = colBearingRight - bearingMaxWidth - dp(10);
|
||||
|
||||
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);
|
||||
|
||||
canvas.drawText(name, innerLeft, y, textPaint);
|
||||
// Пеленг и дистанция — выравнивание справа по своим колонкам.
|
||||
float bearingWidth = textPaint.measureText(bearing);
|
||||
canvas.drawText(bearing, colBearingRight - bearingWidth, y, textPaint);
|
||||
float distanceWidth = accentPaint.measureText(distance);
|
||||
canvas.drawText(distance, colDistanceRight - distanceWidth, y, accentPaint);
|
||||
|
||||
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 nearestStr = String.format(Locale.US, "%03.0f\u00B0 %s",
|
||||
nearest.bearingDeg, formatDistance(nearest.distanceMeters, useNm));
|
||||
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,189 @@
|
||||
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.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 Row(String name, double bearingDeg, double distanceMeters) {
|
||||
this.name = name;
|
||||
this.bearingDeg = bearingDeg;
|
||||
this.distanceMeters = distanceMeters;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
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);
|
||||
canvas.drawText(cpaNa, 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"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/settings_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
@@ -13,20 +15,19 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔌 Интерфейсы: UDP и BLE"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="24dp" />
|
||||
android:text="@string/interfaces_title"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<!-- UDP -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="?attr/materialCardViewElevatedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
app:cardCornerRadius="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -37,18 +38,17 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📡 UDP"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/interfaces_section_udp"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Порт"
|
||||
app:helperText="Порт для прослушивания AIS данных">
|
||||
android:hint="@string/interfaces_udp_port_hint"
|
||||
app:helperText="@string/interfaces_udp_port_helper">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_udp_port"
|
||||
@@ -63,20 +63,20 @@
|
||||
android:id="@+id/switch_udp_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить UDP слушатель"
|
||||
android:textSize="16sp"
|
||||
android:checked="true" />
|
||||
android:checked="true"
|
||||
android:text="@string/interfaces_udp_enabled" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- BLE -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="?attr/materialCardViewElevatedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
app:cardCornerRadius="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -87,33 +87,30 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📶 BLE"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/interfaces_section_ble"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_ble_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить BLE источник NMEA"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/interfaces_ble_enabled" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="MAC адрес BLE устройства"
|
||||
app:helperText="Например: 01:23:45:67:89:AB">
|
||||
android:hint="@string/interfaces_ble_mac_hint"
|
||||
app:helperText="@string/interfaces_ble_mac_helper">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_mac"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text"
|
||||
android:text="" />
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
@@ -122,20 +119,20 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_ble_scan"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Сканировать BLE"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
android:text="@string/interfaces_ble_scan" />
|
||||
|
||||
<Button
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_ble_stop_scan"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Стоп"
|
||||
android:layout_marginStart="8dp"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
android:text="@string/interfaces_ble_stop" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
@@ -144,16 +141,33 @@
|
||||
android:layout_height="200dp"
|
||||
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>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- BLE UDP Bridge -->
|
||||
<!-- BLE → UDP Bridge -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="?attr/materialCardViewElevatedStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="4dp">
|
||||
app:cardCornerRadius="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -164,25 +178,23 @@
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔁 BLE UDP Bridge"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:layout_marginBottom="12dp"
|
||||
android:text="@string/interfaces_section_bridge"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/switch_ble_udp_bridge_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Включить UDP-bridge (пересылать NMEA)"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginBottom="8dp" />
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/interfaces_bridge_enabled" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Host (назначение)">
|
||||
android:hint="@string/interfaces_bridge_host_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_udp_host"
|
||||
@@ -196,8 +208,7 @@
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:hint="UDP Port (назначение)">
|
||||
android:hint="@string/interfaces_bridge_port_hint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/et_ble_udp_port"
|
||||
@@ -209,29 +220,33 @@
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="end">
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<Button
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_cancel"
|
||||
style="?attr/materialButtonOutlinedStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Отмена"
|
||||
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:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Сохранить"
|
||||
style="@style/Widget.Material3.Button" />
|
||||
android:text="@string/settings_action_save" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"
|
||||
android:id="@+id/main_root"
|
||||
android:layout_width="match_parent"
|
||||
@@ -13,29 +14,65 @@
|
||||
android:layout_width="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
|
||||
android:id="@+id/compass_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp" />
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@id/banner_connection_lost"
|
||||
android:elevation="2dp" />
|
||||
|
||||
<!-- Виджет координат: нижний inset задаётся в MainActivity (system bar) -->
|
||||
<!-- Виджет координат: contentH считает сам (от размера шрифтов),
|
||||
к нему MainActivity добавляет bottom inset под нав-бар. -->
|
||||
<com.grigowashere.aismap.view.CoordinatesDockWidget
|
||||
android:id="@+id/coordinates_widget"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:layout_marginLeft="0dp"
|
||||
android:layout_marginTop="0dp"
|
||||
android:layout_marginRight="0dp"
|
||||
android:layout_marginBottom="0dp"
|
||||
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 — не перекрывается снизу) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/control_panel"
|
||||
@@ -60,6 +97,17 @@
|
||||
android:scaleType="fitCenter"
|
||||
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
|
||||
android:id="@+id/btn_map_orientation"
|
||||
android:layout_width="40dp"
|
||||
@@ -114,52 +162,64 @@
|
||||
android:scaleType="fitCenter"
|
||||
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
|
||||
android:id="@+id/tv_gps_age"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:text="GPS: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="8dp"/>
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_ais_age"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="AIS: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_ble_rssi"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="BLE RSSI: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_ble_batt"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="BLE Batt: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="2dp"/>
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_fps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="FPS: --"
|
||||
android:textSize="11sp"
|
||||
android:textColor="@android:color/white"
|
||||
android:layout_marginTop="4dp"/>
|
||||
android:textSize="10sp" />
|
||||
|
||||
</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_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="🚢 AIS СУДНО"
|
||||
android:text="AIS СУДНО"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="@android:color/black" />
|
||||
@@ -51,7 +51,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_time_ago"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⏱️ Время назад: --"
|
||||
android:text="Время назад: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -62,7 +62,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_mmsi"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🆔 MMSI: --"
|
||||
android:text="MMSI: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -75,7 +75,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_callsign"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📻 Позывной: --"
|
||||
android:text="Позывной: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -87,7 +87,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_imo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🏷️ IMO: --"
|
||||
android:text="IMO: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -99,7 +99,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_type"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🚢 Тип: --"
|
||||
android:text="Тип: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -111,7 +111,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_position"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📍 Координаты: --"
|
||||
android:text="Координаты: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -130,7 +130,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="🧭 COG: --°"
|
||||
android:text="COG: --°"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -143,7 +143,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="🧭 HDG: --°"
|
||||
android:text="HDG: --°"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -156,7 +156,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="🔄 ROT: --°/мин"
|
||||
android:text="ROT: --°/мин"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -170,7 +170,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_speed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚡ Скорость: -- узлов"
|
||||
android:text="Скорость: -- узлов"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -182,7 +182,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_dimensions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📏 Размеры: --"
|
||||
android:text="Размеры: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -194,7 +194,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_draft"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🌊 Осадка: -- м"
|
||||
android:text="Осадка: -- м"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -206,7 +206,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_destination"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🎯 Назначение: --"
|
||||
android:text="Назначение: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -218,7 +218,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_eta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⏰ ETA: --"
|
||||
android:text="ETA: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -230,7 +230,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_nav_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🚦 Статус: --"
|
||||
android:text="Статус: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -242,7 +242,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_class"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📋 Класс: --"
|
||||
android:text="Класс: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -254,7 +254,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_signal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📶 Сигнал: --"
|
||||
android:text="Сигнал: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -266,7 +266,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_distance"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📏 Расстояние: --"
|
||||
android:text="Расстояние: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -278,7 +278,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_bearing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🧭 Пеленг: --"
|
||||
android:text="Пеленг: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
@@ -290,7 +290,7 @@
|
||||
android:id="@+id/bottom_sheet_ais_last_update"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🕐 Обновлено: --"
|
||||
android:text="Обновлено: --"
|
||||
android:textSize="14sp"
|
||||
android:textColor="@android:color/black"
|
||||
android:layout_marginBottom="8dp"
|
||||
|
||||
@@ -2,4 +2,33 @@
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Кольца дальности вокруг собственного судна.
|
||||
Альфа-каналы подобраны так, чтобы линии и заливки не перекрывали карту. -->
|
||||
<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,164 @@
|
||||
<resources>
|
||||
<string name="app_name">AISMap</string>
|
||||
|
||||
<!-- ===== 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="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:windowLayoutInDisplayCutoutMode" tools:targetApi="27">shortEdges</item>
|
||||
</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>
|
||||
@@ -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,89 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+76
-2
@@ -12,6 +12,8 @@ import json
|
||||
import os
|
||||
import queue
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import struct
|
||||
import sys
|
||||
@@ -1340,7 +1342,9 @@ class DataCharacteristic(Characteristic):
|
||||
|
||||
class StatusCharacteristic(Characteristic):
|
||||
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.notifying = False
|
||||
|
||||
@@ -1417,6 +1421,74 @@ def find_adapter(bus):
|
||||
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() ============
|
||||
|
||||
def main():
|
||||
@@ -1432,6 +1504,7 @@ def main():
|
||||
return 1
|
||||
|
||||
log_info(f'Используем адаптер: {adapter_path}')
|
||||
configure_adapter_for_open_le(bus, adapter_path)
|
||||
|
||||
service_manager = dbus.Interface(
|
||||
bus.get_object(BLUEZ_SERVICE_NAME, adapter_path),
|
||||
@@ -1455,11 +1528,12 @@ def main():
|
||||
if "Connected" not in changed:
|
||||
return
|
||||
connected = bool(changed.get("Connected"))
|
||||
dev_path = str(path or "")
|
||||
dev_path = "" if path is None else str(path)
|
||||
if not dev_path:
|
||||
return
|
||||
if connected:
|
||||
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)
|
||||
else:
|
||||
log_info(f"[BlueZ] Device disconnected: {dev_path} -> removing session")
|
||||
|
||||
Reference in New Issue
Block a user