mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf23d53ee8 |
No files matched your search
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
-keep class org.osmdroid.** { *; }
|
||||
-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
|
||||
-dontwarn org.osmdroid.**
|
||||
@@ -15,14 +15,15 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<application
|
||||
android:name=".MainApplication"
|
||||
android:name="cn.bh2vsq.look4sat.MainApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name="cn.bh2vsq.look4sat.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Look4Sat.SplashScreen">
|
||||
<intent-filter>
|
||||
@@ -41,7 +42,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||
android:value="com.rtbishop.look4sat" />
|
||||
android:value="cn.bh2vsq.look4sat" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
package cn.bh2vsq.look4sat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
package cn.bh2vsq.look4sat
|
||||
|
||||
import android.app.Application
|
||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat
|
||||
package cn.bh2vsq.look4sat
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">tianditu.gov.cn</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
+1
-1
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
androidResources {
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||
@@ -82,6 +83,8 @@ class SettingsRepo(
|
||||
private val keyShouldSeeWarning = "shouldSeeWarning"
|
||||
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
|
||||
private val keySstvMode = "sstvMode"
|
||||
private val keyMapSource = "mapSource"
|
||||
private val keyTiandituKey = "tiandituKey"
|
||||
private val keyUseCustomTle = "useCustomTle"
|
||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||
private val keyTleUrl = "tleUrl"
|
||||
@@ -335,6 +338,8 @@ class SettingsRepo(
|
||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||
putString(keySstvMode, new.sstvMode)
|
||||
putString(keyMapSource, MapSource.normalize(new.mapSource))
|
||||
putString(keyTiandituKey, new.tiandituKey)
|
||||
}
|
||||
new
|
||||
}
|
||||
@@ -349,7 +354,9 @@ class SettingsRepo(
|
||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
|
||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR),
|
||||
tiandituKey = preferences.getString(keyTiandituKey, null) ?: ""
|
||||
)
|
||||
//endregion
|
||||
|
||||
|
||||
@@ -57,9 +57,24 @@ data class OtherSettings(
|
||||
val stateOfNightMode: Boolean = false,
|
||||
val shouldSeeWarning: Boolean,
|
||||
val shouldSeeWhatsNew: Boolean,
|
||||
val sstvMode: String = "Auto"
|
||||
val sstvMode: String = "Auto",
|
||||
val mapSource: String = MapSource.TIANDITU_VECTOR,
|
||||
val tiandituKey: String = ""
|
||||
)
|
||||
|
||||
object MapSource {
|
||||
const val OSM = "osm"
|
||||
const val TIANDITU = "tianditu"
|
||||
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||
const val TIANDITU_IMAGE = "tianditu_image"
|
||||
|
||||
fun normalize(source: String): String = when (source) {
|
||||
TIANDITU -> TIANDITU_VECTOR
|
||||
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||
else -> TIANDITU_VECTOR
|
||||
}
|
||||
}
|
||||
|
||||
data class DataSourcesSettings(
|
||||
val useCustomTLE: Boolean,
|
||||
val useCustomTransceivers: Boolean,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<string name="pass_empty_list_message">
|
||||
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
|
||||
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
|
||||
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string>
|
||||
<string name="pass_welcome_title">Bienvenido a Look4Sat-CNMap\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
|
||||
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<string name="pass_empty_list_message">
|
||||
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
|
||||
<string name="pass_error_message">Нет предстоящих пролётов спутников</string>
|
||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string>
|
||||
<string name="pass_welcome_title">Добро пожаловать в Look4Sat-CNMap\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
|
||||
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<string name="pass_empty_list_message">
|
||||
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
|
||||
<string name="pass_error_message">ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.</string>
|
||||
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
||||
<string name="pass_welcome_title">Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
|
||||
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Commmon -->
|
||||
<string name="app_name" translatable="false">Look4Sat</string>
|
||||
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||
<string name="btn_accept">Kabul et</string>
|
||||
<string name="btn_cancel">İptal</string>
|
||||
<string name="empty_list_message">Gösterilecek veri bulunamadı</string>
|
||||
@@ -42,11 +42,11 @@
|
||||
<string name="pass_empty_list_message">
|
||||
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
|
||||
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
|
||||
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string>
|
||||
<string name="pass_welcome_title">Look4Sat-CNMap\'a hoş geldiniz\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
|
||||
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
||||
@@ -84,7 +84,8 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Önceki</string>
|
||||
<string name="map_next">Sonraki</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||
<string name="map_azimuth">Azimut: %.1f°</string>
|
||||
<string name="map_elevation">Yükseklik açısı: %.1f°</string>
|
||||
<string name="map_altitude">İrtifa: %.0f km</string>
|
||||
@@ -100,12 +101,12 @@
|
||||
<string name="map_visible">Görünür</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||
<string name="prefs_donate_title">Bağış yap</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<string name="pass_empty_list_message">
|
||||
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
|
||||
<string name="pass_error_message">Немає майбутніх прольотів супутників</string>
|
||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string>
|
||||
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat-CNMap\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
|
||||
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<string name="pass_altitude">高度:%d km</string>
|
||||
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
|
||||
<string name="pass_error_message">没有即将过境的卫星</string>
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string>
|
||||
<string name="pass_welcome_title">欢迎来到 Look4Sat-CNMap\!</string>
|
||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||
|
||||
<!-- Radar screen -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Commmon -->
|
||||
<string name="app_name" translatable="false">Look4Sat</string>
|
||||
<string name="app_name" translatable="false">Look4Sat-CNMap</string>
|
||||
<string name="btn_accept">Accept</string>
|
||||
<string name="btn_cancel">Cancel</string>
|
||||
<string name="empty_list_message">No data to show</string>
|
||||
@@ -43,11 +43,11 @@
|
||||
<string name="pass_empty_list_message">
|
||||
Make sure your filters are correct and you have selected satellites</string>
|
||||
<string name="pass_error_message">There are no upcoming satellite passes to display</string>
|
||||
<string name="pass_welcome_title">Welcome to Look4Sat\!</string>
|
||||
<string name="pass_welcome_title">Welcome to Look4Sat-CNMap\!</string>
|
||||
<string name="pass_welcome_message">
|
||||
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
|
||||
\n\nPlease update the database at least weekly to get accurate predictions.</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
|
||||
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
|
||||
<string name="pass_whatsnew_message" translatable="false">
|
||||
* Added required tweaks to support Android 17 (API 37)
|
||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
||||
@@ -82,7 +82,8 @@
|
||||
<!-- Map screen -->
|
||||
<string name="map_prev">Prev</string>
|
||||
<string name="map_next">Next</string>
|
||||
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
|
||||
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
|
||||
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||
<string name="map_elevation">Elevation: %.1f°</string>
|
||||
<string name="map_altitude">Altitude: %.0f km</string>
|
||||
@@ -98,12 +99,12 @@
|
||||
<string name="map_visible">Visible</string>
|
||||
|
||||
<!-- Settings screen -->
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string>
|
||||
<string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
|
||||
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
|
||||
<string name="prefs_donate_title">Donate</string>
|
||||
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
|
||||
<string name="prefs_fdroid_title" translatable="false">F-Droid</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string>
|
||||
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
|
||||
<string name="prefs_github_title" translatable="false">GitHub</string>
|
||||
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
|
||||
<string name="prefs_license_title" translatable="false">GPLv3</string>
|
||||
@@ -147,6 +148,13 @@
|
||||
<string name="prefs_bt_output">Bluetooth</string>
|
||||
<string name="prefs_cat_output">CAT</string>
|
||||
|
||||
<string name="prefs_map_title">Map settings</string>
|
||||
<string name="prefs_map_source_osm" translatable="false">OSM</string>
|
||||
<string name="prefs_map_source_tianditu_vector" translatable="false">天地图-矢量</string>
|
||||
<string name="prefs_map_source_tianditu_image" translatable="false">天地图-影像</string>
|
||||
<string name="prefs_map_tianditu_key" translatable="false">天地图密钥</string>
|
||||
<string name="prefs_map_save_key">保存密钥</string>
|
||||
|
||||
<!-- Radio Control -->
|
||||
<string name="nav_radiocontrol">Radio Control</string>
|
||||
<string name="rc_settings_title">CAT Radio Control</string>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
/*
|
||||
* Look4Sat. Amateur radio satellite tracker and pass predictor.
|
||||
* Copyright (C) 2019-2026 Arty Bishop and contributors.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.rtbishop.look4sat.feature.map
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Overlay
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Custom osmdroid overlay that shades the night side of the globe.
|
||||
*
|
||||
* Works entirely in screen-pixel space: for each vertical strip on screen it
|
||||
* asks osmdroid for the geographic coordinate, then tests whether that point is
|
||||
* in the night half-sphere relative to the sub-solar point. Because the
|
||||
* computation happens during draw() the result is always correct regardless
|
||||
* of zoom level or map scroll position — no polygon winding issues possible.
|
||||
*
|
||||
* A point (latRad, lonRad) is in night when the angle to the sub-solar point
|
||||
* exceeds 90°, i.e. the dot product of the two unit vectors is negative:
|
||||
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
|
||||
*
|
||||
* Performance: we sample one column per [stepPx] pixels (default 4) and draw
|
||||
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig
|
||||
* evaluations per row, which is imperceptible.
|
||||
*/
|
||||
class MapNightOverlay : Overlay() {
|
||||
|
||||
/** Sub-solar latitude in degrees. */
|
||||
var sunLatDeg: Double = 0.0
|
||||
|
||||
/** Sub-solar longitude in degrees. */
|
||||
var sunLonDeg: Double = 0.0
|
||||
|
||||
private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.FILL
|
||||
color = Color.argb(75, 0, 0, 0)
|
||||
}
|
||||
|
||||
private val rect = RectF()
|
||||
|
||||
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
|
||||
if (shadow) return
|
||||
|
||||
val proj = mapView.projection
|
||||
val sunLatRad = Math.toRadians(sunLatDeg)
|
||||
val sunLonRad = Math.toRadians(sunLonDeg)
|
||||
val sinSunLat = sin(sunLatRad)
|
||||
val cosSunLat = cos(sunLatRad)
|
||||
|
||||
val w = mapView.width
|
||||
val h = mapView.height
|
||||
val stepPx = 4 // sample every N pixels — balance quality vs CPU
|
||||
|
||||
// We scan column by column. For each column we determine the longitude,
|
||||
// then find the latitude range that is in night and shade it.
|
||||
// Since longitude is constant along a vertical strip and the day/night
|
||||
// boundary at a given longitude is at most two latitudes, we can do a
|
||||
// scan-line fill efficiently.
|
||||
|
||||
var x = 0
|
||||
while (x < w) {
|
||||
// Get the geographic coordinate at the top and bottom of this column.
|
||||
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
|
||||
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
|
||||
|
||||
val lonRad = Math.toRadians(geoTop.longitude)
|
||||
val cosLonDiff = cos(lonRad - sunLonRad)
|
||||
|
||||
// Top pixel geographic lat
|
||||
val latTopRad = Math.toRadians(geoTop.latitude)
|
||||
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
|
||||
val latBotRad = Math.toRadians(geoBot.latitude)
|
||||
|
||||
// dot(sunVec, pointVec) < 0 → night
|
||||
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
|
||||
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
|
||||
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
|
||||
|
||||
when {
|
||||
dotTop < 0 && dotBot < 0 -> {
|
||||
// Entire column is night — shade from top to bottom
|
||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
|
||||
canvas.drawRect(rect, nightPaint)
|
||||
}
|
||||
|
||||
dotTop >= 0 && dotBot >= 0 -> {
|
||||
// Entire column is day — nothing to draw
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Terminator crosses this column — find the crossing pixel by binary search
|
||||
val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
|
||||
if (dotTop < 0) {
|
||||
// Night at top, day at bottom
|
||||
rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
|
||||
canvas.drawRect(rect, nightPaint)
|
||||
} else {
|
||||
// Day at top, night at bottom
|
||||
rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
|
||||
canvas.drawRect(rect, nightPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
x += stepPx
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary-search for the pixel row where the day/night boundary crosses column [x].
|
||||
* [yTop] is in day, [yBot] is in night (or vice versa).
|
||||
*/
|
||||
private fun findCrossingY(
|
||||
proj: org.osmdroid.views.Projection,
|
||||
x: Int,
|
||||
yTop: Int,
|
||||
yBot: Int,
|
||||
sinSunLat: Double,
|
||||
cosSunLat: Double,
|
||||
cosLonDiff: Double
|
||||
): Int {
|
||||
var lo = yTop
|
||||
var hi = yBot
|
||||
while (hi - lo > 1) {
|
||||
val mid = (lo + hi) / 2
|
||||
val geo = proj.fromPixels(x, mid) ?: return mid
|
||||
val latRad = Math.toRadians(geo.latitude)
|
||||
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
|
||||
if (dot < 0) hi = mid else lo = mid
|
||||
}
|
||||
return (lo + hi) / 2
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||
@@ -71,37 +72,56 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
|
||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||
import com.rtbishop.look4sat.core.presentation.layoutPadding
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.MapTileProviderBasic
|
||||
import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.tileprovider.tilesource.XYTileSource
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.util.MapTileIndex
|
||||
import org.osmdroid.views.CustomZoomButtonsController
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.FolderOverlay
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import org.osmdroid.views.overlay.Polyline
|
||||
import org.osmdroid.views.overlay.TilesOverlay
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
// Overlay indices
|
||||
private const val OVERLAY_STATION = 0
|
||||
private const val OVERLAY_TRACK = 1
|
||||
private const val OVERLAY_FOOTPRINT = 2
|
||||
private const val OVERLAY_POSITIONS = 3
|
||||
private const val OVERLAY_TERMINATOR = 4
|
||||
private const val OVERLAY_TDT_LABELS = 0
|
||||
private const val OVERLAY_STATION = 1
|
||||
private const val OVERLAY_TRACK = 2
|
||||
private const val OVERLAY_FOOTPRINT = 3
|
||||
private const val OVERLAY_POSITIONS = 4
|
||||
private const val OVERLAY_SUN = 5
|
||||
private const val OVERLAY_MOON = 6
|
||||
private const val OVERLAY_COUNT = 7
|
||||
|
||||
private val minLat = MapView.getTileSystem().minLatitude
|
||||
private val maxLat = MapView.getTileSystem().maxLatitude
|
||||
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
private const val OFFLINE_MAX_ZOOM = 7.0
|
||||
private const val OSM_MAX_ZOOM = 19.0
|
||||
private const val TIANDITU_MAX_ZOOM = 18.0
|
||||
private const val TILE_CACHE_MAX_BYTES = 1024L * 1024L * 1024L
|
||||
private const val TILE_CACHE_TRIM_BYTES = 900L * 1024L * 1024L
|
||||
private const val TILE_REFRESH_DELAY_MS = 250L
|
||||
private const val TILE_SOURCE_OSM = "osm"
|
||||
private const val TILE_USER_AGENT = "Look4Sat-CNMap"
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 3f
|
||||
strokeWidth = 4f
|
||||
style = Paint.Style.STROKE
|
||||
color = Color.RED
|
||||
color = "#1565C0".toColorInt()
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 3f
|
||||
style = Paint.Style.FILL_AND_STROKE
|
||||
color = "#FFE082".toColorInt()
|
||||
private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
strokeWidth = 4f
|
||||
style = Paint.Style.STROKE
|
||||
color = "#00897B".toColorInt()
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
}
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textSize = 36f
|
||||
@@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
val rotateMod = Modifier.rotate(180f)
|
||||
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
|
||||
val isTimeAos = uiState.mapData?.isTimeAos ?: true
|
||||
val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
|
||||
val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu
|
||||
else R.string.map_copyright_osm
|
||||
|
||||
LaunchedEffect(uiState.track) {
|
||||
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
|
||||
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
|
||||
}
|
||||
LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
|
||||
configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
|
||||
}
|
||||
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val isVertical = isVerticalLayout()
|
||||
if (isVertical) {
|
||||
@@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
uiState.track?.let { setSatelliteTrack(it, view) }
|
||||
uiState.footprint?.let { setFootprint(it, view) }
|
||||
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
|
||||
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
|
||||
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
|
||||
view.invalidate()
|
||||
}
|
||||
uiState.mapData?.let { mapData ->
|
||||
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
|
||||
if (isVertical) MapDataCard(mapData, copyrightResId) else MapDataCards(mapData, copyrightResId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,11 +203,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
|
||||
|
||||
// region Map data composables
|
||||
@Composable
|
||||
private fun MapDataCard(data: MapData) {
|
||||
private fun MapDataCard(data: MapData, copyrightResId: Int) {
|
||||
val textColor = MaterialTheme.colorScheme.primary
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
|
||||
Text(text = stringResource(copyrightResId), fontSize = 14.sp)
|
||||
Card(colors = cardColors) {
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
MapDataRow(
|
||||
@@ -220,7 +245,7 @@ private fun MapDataRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapDataCards(data: MapData) {
|
||||
private fun MapDataCards(data: MapData, copyrightResId: Int) {
|
||||
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
val paddingMod = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
@@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) {
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.map_copyright),
|
||||
text = stringResource(copyrightResId),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
@@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) {
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Tile sources
|
||||
private var configuredTileMapView: MapView? = null
|
||||
private var configuredTileSource: String? = null
|
||||
|
||||
private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
|
||||
val key = tiandituKey.trim()
|
||||
val layers = tiandituLayers(mapSource)
|
||||
val tileSourceKey = if (layers == null || key.isBlank()) {
|
||||
TILE_SOURCE_OSM
|
||||
} else {
|
||||
"tianditu:${layers.base}:${layers.label}:$key"
|
||||
}
|
||||
if (configuredTileMapView === mapView && configuredTileSource == tileSourceKey) {
|
||||
requestTileRefresh(mapView)
|
||||
return
|
||||
}
|
||||
configuredTileMapView = mapView
|
||||
configuredTileSource = tileSourceKey
|
||||
|
||||
if (layers == null || key.isBlank()) {
|
||||
mapView.setUseDataConnection(true)
|
||||
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TileSourceFactory.MAPNIK))
|
||||
mapView.maxZoomLevel = OSM_MAX_ZOOM
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||
mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
|
||||
requestTileRefresh(mapView)
|
||||
return
|
||||
}
|
||||
mapView.setUseDataConnection(true)
|
||||
mapView.setTileProvider(MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.base, key = key)))
|
||||
mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
|
||||
mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
|
||||
mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
|
||||
MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
|
||||
mapView.context
|
||||
).apply {
|
||||
applyTileOverlayDefaults()
|
||||
setUseDataConnection(true)
|
||||
}
|
||||
requestTileRefresh(mapView)
|
||||
}
|
||||
|
||||
private data class TiandituLayers(val base: String, val label: String)
|
||||
|
||||
private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
|
||||
MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
|
||||
MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun TilesOverlay.applyTileOverlayDefaults() {
|
||||
setColorFilter(null)
|
||||
setLoadingBackgroundColor(Color.TRANSPARENT)
|
||||
setLoadingLineColor(Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
private fun requestTileRefresh(mapView: MapView) {
|
||||
mapView.post {
|
||||
mapView.requestLayout()
|
||||
mapView.invalidate()
|
||||
mapView.postInvalidate()
|
||||
}
|
||||
mapView.postDelayed({
|
||||
mapView.invalidate()
|
||||
mapView.postInvalidate()
|
||||
}, TILE_REFRESH_DELAY_MS)
|
||||
}
|
||||
|
||||
private fun buildTiandituWmtsUrl(
|
||||
baseUrl: String,
|
||||
layer: String,
|
||||
zoom: Int,
|
||||
row: Int,
|
||||
col: Int,
|
||||
encodedKey: String
|
||||
): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
|
||||
"&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
|
||||
|
||||
private class TiandituTileSource(
|
||||
private val layer: String,
|
||||
private val key: String
|
||||
) : OnlineTileSourceBase(
|
||||
"tianditu-wmts-https-$layer",
|
||||
1,
|
||||
18,
|
||||
256,
|
||||
".png",
|
||||
Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
|
||||
"Tianditu"
|
||||
) {
|
||||
private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
|
||||
|
||||
override fun getTileURLString(pMapTileIndex: Long): String {
|
||||
val zoom = MapTileIndex.getZoom(pMapTileIndex)
|
||||
val row = MapTileIndex.getY(pMapTileIndex)
|
||||
val col = MapTileIndex.getX(pMapTileIndex)
|
||||
return buildTiandituWmtsUrl(
|
||||
baseUrl = getBaseUrl(),
|
||||
layer = layer,
|
||||
zoom = zoom,
|
||||
row = row,
|
||||
col = col,
|
||||
encodedKey = encodedKey
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
||||
// region Map overlay helpers
|
||||
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
try {
|
||||
@@ -282,6 +415,8 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
|
||||
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
|
||||
private val markerPool = HashMap<String, Marker>()
|
||||
private var lastMapView: MapView? = null
|
||||
private var lastSatelliteTrack: List<List<GeoPos>>? = null
|
||||
private var satelliteTrackOverlay: FolderOverlay? = null
|
||||
|
||||
private fun setPositions(
|
||||
posMap: Map<OrbitalObject, GeoPos>,
|
||||
@@ -294,8 +429,9 @@ private fun setPositions(
|
||||
lastMapView = mapView
|
||||
markerPool.clear()
|
||||
iconCache.evictAll()
|
||||
footprintPolyline = null
|
||||
footprintPoints = null
|
||||
lastSatelliteTrack = null
|
||||
satelliteTrackOverlay = null
|
||||
footprintOverlay = null
|
||||
}
|
||||
// Reuse the existing FolderOverlay — creating a new one and replacing it
|
||||
// causes osmdroid to detach shared Marker objects, making them invisible.
|
||||
@@ -357,6 +493,10 @@ private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
|
||||
}
|
||||
|
||||
private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||
val cachedOverlay = satelliteTrackOverlay
|
||||
if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) {
|
||||
return
|
||||
}
|
||||
val trackOverlay = FolderOverlay()
|
||||
try {
|
||||
satTrack.forEach { track ->
|
||||
@@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
|
||||
trackOverlay.add(this)
|
||||
}
|
||||
}
|
||||
lastSatelliteTrack = satTrack
|
||||
satelliteTrackOverlay = trackOverlay
|
||||
mapView.overlays[OVERLAY_TRACK] = trackOverlay
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reusable footprint Polyline — created once, points updated in-place each frame */
|
||||
private var footprintPolyline: Polyline? = null
|
||||
private var footprintPoints: ArrayList<GeoPoint>? = null
|
||||
/** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */
|
||||
private var footprintOverlay: FolderOverlay? = null
|
||||
|
||||
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
|
||||
try {
|
||||
val rangeCircle = orbitalPos.getRangeCircle()
|
||||
var pts = footprintPoints
|
||||
if (pts == null || pts.size != rangeCircle.size) {
|
||||
pts = ArrayList(rangeCircle.size)
|
||||
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
|
||||
footprintPoints = pts
|
||||
} else {
|
||||
for (i in rangeCircle.indices) {
|
||||
pts[i].latitude = rangeCircle[i].latitude
|
||||
pts[i].longitude = rangeCircle[i].longitude
|
||||
val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it }
|
||||
overlay.items.clear()
|
||||
splitClosedPathOnDateLine(rangeCircle).forEach { segment ->
|
||||
if (segment.size > 1) {
|
||||
overlay.add(
|
||||
Polyline().apply {
|
||||
setPoints(segment.map { GeoPoint(it.latitude, it.longitude) })
|
||||
outlinePaint.set(footprintOutlinePaint)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
val polyline = footprintPolyline ?: Polyline().apply {
|
||||
outlinePaint.set(footprintPaint)
|
||||
footprintPolyline = this
|
||||
}
|
||||
polyline.setPoints(pts)
|
||||
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
|
||||
mapView.overlays[OVERLAY_FOOTPRINT] = overlay
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the NightOverlay with the current sub-solar position.
|
||||
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its
|
||||
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
|
||||
*/
|
||||
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
|
||||
try {
|
||||
val overlay = mapView.overlays[OVERLAY_TERMINATOR]
|
||||
if (overlay is MapNightOverlay) {
|
||||
overlay.sunLatDeg = sunLatDeg
|
||||
overlay.sunLonDeg = sunLonDeg
|
||||
} else {
|
||||
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
|
||||
this.sunLatDeg = sunLatDeg
|
||||
this.sunLonDeg = sunLonDeg
|
||||
private fun splitClosedPathOnDateLine(points: List<GeoPos>): List<List<GeoPos>> {
|
||||
if (points.isEmpty()) return emptyList()
|
||||
val segments = mutableListOf<List<GeoPos>>()
|
||||
val segment = mutableListOf<GeoPos>()
|
||||
var previous = points.first()
|
||||
segment.add(previous)
|
||||
(points.drop(1) + points.first()).forEach { current ->
|
||||
when {
|
||||
previous.longitude < -170.0 && current.longitude > 170.0 -> {
|
||||
val edgeLatitude = getDateLineLatitude(previous, current, -180.0)
|
||||
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||
segments.add(segment.toList())
|
||||
segment.clear()
|
||||
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||
}
|
||||
previous.longitude > 170.0 && current.longitude < -170.0 -> {
|
||||
val edgeLatitude = getDateLineLatitude(previous, current, 180.0)
|
||||
segment.add(GeoPos(edgeLatitude, 180.0))
|
||||
segments.add(segment.toList())
|
||||
segment.clear()
|
||||
segment.add(GeoPos(edgeLatitude, -180.0))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println(e)
|
||||
segment.add(current)
|
||||
previous = current
|
||||
}
|
||||
if (segment.size > 1) segments.add(segment)
|
||||
return segments
|
||||
}
|
||||
|
||||
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos, edgeLongitude: Double): Double {
|
||||
val currentLongitude = when {
|
||||
previous.longitude < -170.0 && current.longitude > 170.0 -> current.longitude - 360.0
|
||||
previous.longitude > 170.0 && current.longitude < -170.0 -> current.longitude + 360.0
|
||||
else -> current.longitude
|
||||
}
|
||||
val fraction = (edgeLongitude - previous.longitude) / (currentLongitude - previous.longitude)
|
||||
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||
}
|
||||
|
||||
/** Place an ic_sun icon marker at the sub-solar point. */
|
||||
@@ -482,40 +637,62 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
|
||||
// region MapView lifecycle
|
||||
@Composable
|
||||
private fun rememberMapViewWithLifecycle(): MapView {
|
||||
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
|
||||
val context = LocalContext.current
|
||||
val isVertical = isVerticalLayout()
|
||||
val mapView = remember {
|
||||
configureOsmdroidCache(context)
|
||||
MapView(context).apply {
|
||||
setMultiTouchControls(true)
|
||||
setUseDataConnection(false)
|
||||
setTileSource(tileSource)
|
||||
setTileSource(offlineTileSource)
|
||||
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
|
||||
maxZoomLevel = 7.0
|
||||
maxZoomLevel = OFFLINE_MAX_ZOOM
|
||||
controller.setCenter(GeoPoint(48.8575, 6.3514))
|
||||
controller.setZoom(minZoomLevel + 2)
|
||||
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
|
||||
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
|
||||
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
|
||||
setHorizontalMapRepetitionEnabled(true)
|
||||
setVerticalMapRepetitionEnabled(false)
|
||||
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
|
||||
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
|
||||
addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) }
|
||||
}
|
||||
}
|
||||
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
DisposableEffect(lifecycle) {
|
||||
lifecycle.addObserver(lifecycleObserver)
|
||||
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||
mapView.onResume()
|
||||
requestTileRefresh(mapView)
|
||||
}
|
||||
onDispose { lifecycle.removeObserver(lifecycleObserver) }
|
||||
}
|
||||
return mapView
|
||||
}
|
||||
|
||||
private fun configureOsmdroidCache(context: android.content.Context) {
|
||||
val cacheRoot = File(context.filesDir, "osmdroid")
|
||||
val tileCache = File(cacheRoot, "tiles")
|
||||
Configuration.getInstance().apply {
|
||||
setUserAgentValue(TILE_USER_AGENT)
|
||||
setOsmdroidBasePath(cacheRoot)
|
||||
setOsmdroidTileCache(tileCache)
|
||||
setTileFileSystemCacheMaxBytes(TILE_CACHE_MAX_BYTES)
|
||||
setTileFileSystemCacheTrimBytes(TILE_CACHE_TRIM_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> mapView.onResume()
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
mapView.onResume()
|
||||
requestTileRefresh(mapView)
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
|
||||
else -> {}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ data class MapState(
|
||||
val mapData: MapData? = null,
|
||||
val isLightUi: Boolean = false,
|
||||
val isUtc: Boolean = false,
|
||||
val mapSource: String = "",
|
||||
val tiandituKey: String = "",
|
||||
val stationPosition: GeoPos? = null,
|
||||
val orbitalPass: OrbitalPass,
|
||||
val track: List<List<GeoPos>>? = null,
|
||||
|
||||
@@ -61,6 +61,8 @@ class MapViewModel(
|
||||
MapState(
|
||||
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
|
||||
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
|
||||
mapSource = settingsRepo.otherSettings.value.mapSource,
|
||||
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
|
||||
orbitalPass = defaultPass
|
||||
)
|
||||
)
|
||||
@@ -74,7 +76,13 @@ class MapViewModel(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.otherSettings.collectLatest { settings ->
|
||||
_uiState.update { it.copy(isUtc = settings.stateOfUtc) }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isUtc = settings.stateOfUtc,
|
||||
mapSource = settings.mapSource,
|
||||
tiandituKey = settings.tiandituKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
|
||||
@@ -269,29 +277,45 @@ class MapViewModel(
|
||||
val satTracks = mutableListOf<List<GeoPos>>()
|
||||
val currentTrack = mutableListOf<GeoPos>()
|
||||
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
|
||||
var oldLongitude = 0.0
|
||||
var previousPosition: GeoPos? = null
|
||||
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
|
||||
val currentPosition = satPos.toMapGeoPos()
|
||||
if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||
val previous = previousPosition
|
||||
if (previous != null && previous.longitude < -170.0 && currentPosition.longitude > 170.0) {
|
||||
// adding left terminal position
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
|
||||
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
} else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||
} else if (previous != null && previous.longitude > 170.0 && currentPosition.longitude < -170.0) {
|
||||
// adding right terminal position
|
||||
currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
|
||||
val edgeLatitude = getDateLineLatitude(previous, currentPosition)
|
||||
currentTrack.add(GeoPos(edgeLatitude, 180.0))
|
||||
val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
|
||||
satTracks.add(finishedTrack)
|
||||
currentTrack.clear()
|
||||
currentTrack.add(GeoPos(edgeLatitude, -180.0))
|
||||
}
|
||||
oldLongitude = currentPosition.longitude
|
||||
previousPosition = currentPosition
|
||||
currentTrack.add(currentPosition)
|
||||
}
|
||||
satTracks.add(currentTrack)
|
||||
_uiState.update { it.copy(track = satTracks) }
|
||||
}
|
||||
|
||||
private fun getDateLineLatitude(previous: GeoPos, current: GeoPos): Double {
|
||||
val currentLon = if (previous.longitude < 0.0 && current.longitude > 0.0) {
|
||||
current.longitude - 360.0
|
||||
} else {
|
||||
current.longitude + 360.0
|
||||
}
|
||||
val edgeLon = if (previous.longitude < 0.0) -180.0 else 180.0
|
||||
val fraction = (edgeLon - previous.longitude) / (currentLon - previous.longitude)
|
||||
return previous.latitude + (current.latitude - previous.latitude) * fraction
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Number of parallel chunks for satellite position computation */
|
||||
private const val PARALLEL_CHUNKS = 4
|
||||
|
||||
+107
@@ -34,10 +34,14 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ElevatedButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -56,11 +60,13 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||
@@ -280,6 +286,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
||||
)
|
||||
}
|
||||
item { OtherCard(uiState.otherSettings, onAction) }
|
||||
item { MapSettingsCard(uiState.otherSettings, onAction) }
|
||||
item { CardCredits() }
|
||||
}
|
||||
}
|
||||
@@ -462,6 +469,106 @@ private fun OtherCardPreview() = MainTheme {
|
||||
OtherCard(settings = values) {}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSettingsCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||
var keyValue by remember(settings.tiandituKey) { mutableStateOf(settings.tiandituKey) }
|
||||
LaunchedEffect(keyValue) {
|
||||
kotlinx.coroutines.delay(400)
|
||||
if (keyValue.trim() != settings.tiandituKey) {
|
||||
onAction(SettingsAction.SetTiandituKey(keyValue))
|
||||
}
|
||||
}
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.prefs_map_title),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
val currentMapSource = MapSource.normalize(settings.mapSource)
|
||||
MapSourceRow(
|
||||
currentMapSource = currentMapSource,
|
||||
firstSource = MapSource.OSM,
|
||||
firstTitle = stringResource(id = R.string.prefs_map_source_osm),
|
||||
secondSource = MapSource.TIANDITU_VECTOR,
|
||||
secondTitle = stringResource(id = R.string.prefs_map_source_tianditu_vector),
|
||||
onAction = onAction
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
MapSourceRow(
|
||||
currentMapSource = currentMapSource,
|
||||
firstSource = MapSource.TIANDITU_IMAGE,
|
||||
firstTitle = stringResource(id = R.string.prefs_map_source_tianditu_image),
|
||||
onAction = onAction
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = keyValue,
|
||||
onValueChange = { keyValue = it },
|
||||
label = { Text(text = stringResource(id = R.string.prefs_map_tianditu_key)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
CardButton(
|
||||
onClick = { onAction(SettingsAction.SetTiandituKey(keyValue)) },
|
||||
text = stringResource(id = R.string.prefs_map_save_key),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSourceRow(
|
||||
currentMapSource: String,
|
||||
firstSource: String,
|
||||
firstTitle: String,
|
||||
secondSource: String? = null,
|
||||
secondTitle: String? = null,
|
||||
onAction: (SettingsAction) -> Unit
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MapSourceButton(
|
||||
onClick = { onAction(SettingsAction.SetMapSource(firstSource)) },
|
||||
text = firstTitle,
|
||||
selected = currentMapSource == firstSource,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (secondSource != null && secondTitle != null) {
|
||||
MapSourceButton(
|
||||
onClick = { onAction(SettingsAction.SetMapSource(secondSource)) },
|
||||
text = secondTitle,
|
||||
selected = currentMapSource == secondSource,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MapSourceButton(onClick: () -> Unit, text: String, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
ElevatedButton(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||
ElevatedCard(
|
||||
|
||||
@@ -61,6 +61,8 @@ sealed interface SettingsAction {
|
||||
data class ToggleSensor(val value: Boolean) : SettingsAction
|
||||
data class ToggleLightTheme(val value: Boolean) : SettingsAction
|
||||
data class ToggleNightMode(val value: Boolean) : SettingsAction
|
||||
data class SetMapSource(val value: String) : SettingsAction
|
||||
data class SetTiandituKey(val value: String) : SettingsAction
|
||||
|
||||
// Remote control
|
||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||
|
||||
+2
@@ -121,6 +121,8 @@ class SettingsViewModel(
|
||||
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
|
||||
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
|
||||
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
|
||||
is SettingsAction.SetMapSource -> settingsRepo.updateOtherSettings { it.copy(mapSource = action.value) }
|
||||
is SettingsAction.SetTiandituKey -> settingsRepo.updateOtherSettings { it.copy(tiandituKey = action.value.trim()) }
|
||||
// Remote control & data sources
|
||||
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
|
||||
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
|
||||
|
||||
@@ -3,3 +3,6 @@ org.gradle.caching=true
|
||||
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.workers.max=4
|
||||
|
||||
# Enabled parallel sync for Gradle 9.4+
|
||||
org.gradle.tooling.parallel=true
|
||||
@@ -10,7 +10,7 @@ minSdk = "24"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
jdkVersion = "21"
|
||||
#noinspection UnusedVersionCatalogEntry
|
||||
packageName = "com.rtbishop.look4sat"
|
||||
packageName = "cn.bh2vsq.look4sat"
|
||||
|
||||
android-gradle-plugin = "9.2.1"
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#Sat Jun 27 12:51:12 BST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.6.1/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user