Compare commits

...
Author SHA1 Message Date
atsunatsu b979524b37 chore: bump version to 4.4.7-ba7opf.1 (versionCode 471) 2026-09-14 20:45:38 +08:00
atsunatsu 9e748f5534 feat(map): worked-grid QSO counts and operated-grid selector
- QSO dialog: each callsign row shows 'n QSO'/'n QSOs' (plurals) centered
  between callsign and date, styled like the date.
- Grid mode + VUCC filter: top-left selector picks the operated grid the
  VUCC count is computed from (defaults to the most-worked grid), with a
  collapsible list of all operated grids; arrow style matches the QSO
  dialog rows.
2026-09-14 20:45:25 +08:00
atsunatsu 25b982ac59 feat(vucc): per operated-grid VUCC counting
GridQso gains myGrid (ADIF MY_GRIDSQUARE) captured per QSO during LoTW
parsing (handles alphabetical field order + EOR reset) and persisted as
JSON key 'mg' (absent in pre-myGrid data -> null). Legacy syncs keep the
global VUCC count; one resync unlocks per-grid counting.
2026-09-14 20:45:20 +08:00
atsunatsu 6a6399b606 fix(passes): recalculate passes when the UTC toggle changes
Port of upstream PR #252 (wty2019wty): combine otherSettings.stateOfUtc
into initRepository so an existing AOS time window is reapplied in the
newly selected timezone right after toggling UTC, instead of waiting for
the next selection/position change.
2026-09-14 18:54:22 +08:00
atsunatsu c1cc992abd chore: bump version to 4.4.7-ba7opf.0 2026-09-14 14:30:27 +08:00
atsunatsu 212dced3c4 feat(data): persist ndot and protect manually imported transceivers
- DB v2 migration: entries.ndot column so hasDecayed() actually works after
  reload (parsed ndot was dropped on write, making the decay detection dead
  code); radios.isCustom column to mark manually imported transceivers.
- updateTransceiversFromFile marks imported radios isCustom=true; remote
  updates now deleteManagedRadios() then insert the fresh snapshot, keeping
  manual imports (no source can refresh them, so nothing would bring them back).
- Wire MIGRATION_1_2 into MainContainer (fallbackToDestructiveMigration=false
  would otherwise crash on upgrade).
2026-09-14 14:30:23 +08:00
atsunatsu 7ef555e377 fix(time): respect UTC toggle for AOS filter window and sunrise/sunset day anchor
- PassesViewModel: use the DEVICE timezone for the natural-day anchor in
  findTodaySunRiseSet instead of the display timezone. With UTC enabled the
  anchor was UTC midnight = 08:00 local for UTC+8 stations, when the sun is
  already up — tripping the white-night guard and rendering '--:--'.
- SatelliteRepo.isAosInRange: follow the UTC display toggle so the pass time
  window is interpreted in UTC when UTC mode is on. Previously the list
  showed UTC times while the filter silently used local time, off by the
  timezone offset (8h for China).
2026-09-14 14:13:16 +08:00
atsunatsu 2773145e0c chore(upstream): v4.4.7 low-risk backports — night overlay alloc-free, radar inset padding, sunrise/sunset tests 2026-09-14 13:41:15 +08:00
atsunatsu d8d3d81648 perf(map): viewport culling, label limit, scroll refresh, SetVisible gating 2026-09-14 13:41:11 +08:00
atsunatsu 25a41def35 i18n: localize WAPC/WAJA map labels to English
- wapc.json: add name_en for all 34 provinces (Beijing/Guangdong/...)
- waja.json: add name_en for all 47 prefectures (Gifu/Kyoto/...)
- AwardBoundaryOverlay: render English label on non-Chinese UI,
  fall back to source name when name_en is absent
- bump version to 4.4.6-ba7opf.10.4 (469)
2026-09-13 01:25:36 +08:00
atsunatsu d9dc0a1735 docs: reword ADIF parsing description in README 2026-09-12 05:56:41 +08:00
atsunatsu 692e917893 docs: drop boundary-claim wording and Wavelog from README (LoTW sync only) 2026-09-12 05:54:03 +08:00
atsunatsu 6371bb114d docs: list major fork features in README — grid mode, award boundaries, LoTW/Wavelog sync, in-app updates, custom TLE sources 2026-09-12 03:05:47 +08:00
29 changed files with 505 additions and 79 deletions

No files matched your search

+7 -2
View File
@@ -6,11 +6,16 @@
## 本仓库特色功能
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框(field 与 sub-square 缩放均显示);极区(南北纬 80°–90°)网格线与二位标签完整可见;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充;香港/澳门作为独立实体显示(DXCC 321/152),港澳标签强制显示
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
- **Passband 模式** — 支持通过位置滑块(Passband)自动计算 TX/RX 频率,避免切换时跳变
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
## 上游仓库
@@ -60,4 +60,7 @@ interface Look4SatDao {
@Query("DELETE FROM radios")
suspend fun deleteRadios()
@Query("DELETE FROM radios WHERE isCustom = 0")
suspend fun deleteManagedRadios()
}
@@ -19,19 +19,21 @@ package com.rtbishop.look4sat.core.data.database
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
@Database(entities = [SatEntry::class, SatRadio::class], version = 2, exportSchema = false)
abstract class Look4SatDb : RoomDatabase() {
abstract fun look4SatDao(): Look4SatDao
}
//val MIGRATION_1_2 = object : Migration(1, 2) {
// override fun migrate(database: SupportSQLiteDatabase) {
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
// database.execSQL("DROP TABLE entries")
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
// }
//}
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
* transceivers that were imported from a file, so that remote updates leave them alone. */
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
}
}
@@ -30,5 +30,6 @@ data class SatEntry(
val argper: Double,
val meanan: Double,
val catnum: Int,
val bstar: Double
val bstar: Double,
val ndot: Double = 0.0
)
@@ -32,5 +32,7 @@ data class SatRadio(
val uplinkHigh: Long?,
val uplinkMode: String?,
val isInverted: Boolean,
val catnum: Int?
val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -24,6 +24,7 @@ import android.hardware.display.DisplayManager
import android.location.LocationManager
import androidx.room.Room
import com.rtbishop.look4sat.core.data.database.Look4SatDb
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
@@ -167,7 +168,10 @@ class MainContainer(private val context: Context) : IMainContainer {
private fun provideLocalSource(): ILocalSource {
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
val database = builder.fallbackToDestructiveMigration(false).build()
val database = builder
.addMigrations(MIGRATION_1_2)
.fallbackToDestructiveMigration(false)
.build()
return LocalSource(database.look4SatDao())
}
@@ -58,7 +58,7 @@ class DatabaseRepo(
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
localSource.insertRadios(transceivers)
localSource.insertRadios(transceivers, isCustom = true)
importedCount = transceivers.size
}
setUpdateSuccessful(System.currentTimeMillis())
@@ -105,9 +105,16 @@ class DatabaseRepo(
val normUrl = normalizeUrl(rawUrl)
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
// insert parsed data into the database
localSource.insertEntries(importedEntries)
localSource.insertRadios(importedRadios)
// insert parsed data into the database.
// Transceivers are a full snapshot: sources publish active entries only, so a retired
// transceiver simply disappears from the feed and has to be dropped locally as well.
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
// would bring them back if they were replaced here.
if (importedRadios.isNotEmpty()) {
localSource.deleteManagedRadios()
localSource.insertRadios(importedRadios)
}
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
setUpdateSuccessful(System.currentTimeMillis())
}
@@ -169,6 +169,7 @@ class LoTWRepository : ILoTWRepository {
var country: String? = null
var cqz: Int? = null
var state: String? = null
var myGrid: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
@@ -177,7 +178,8 @@ class LoTWRepository : ILoTWRepository {
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
call = call, epochMs = epochMs, satName = satName,
mode = mode, bandUp = bandUp, bandDown = bandDown,
dxcc = dxcc, country = country, cqz = cqz, state = state
dxcc = dxcc, country = country, cqz = cqz, state = state,
myGrid = myGrid
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -188,6 +190,7 @@ class LoTWRepository : ILoTWRepository {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
myGrid = null
gridsInRecord.clear()
}
@@ -222,6 +225,13 @@ class LoTWRepository : ILoTWRepository {
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
line.startsWith("<MY_GRIDSQUARE:") -> {
// Own-station grid (must not be confused with GRIDSQUARE —
// the opposite station's grid). Recorded per QSO so awards
// can be counted per operated grid.
val value = adifValue(line)
if (value.length >= 4) myGrid = value.take(4).uppercase()
}
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -35,6 +35,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import java.util.TimeZone
@@ -66,8 +68,11 @@ class SatelliteRepo(
override suspend fun initRepository() = withContext(dispatcher) {
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
settingsRepo.stationPosition,
// Recalculate passes when the UTC display toggle changes, so an existing
// AOS time window is reapplied in the newly selected timezone.
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
) { selectedIds, _, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
@@ -175,7 +180,16 @@ class SatelliteRepo(
aosEndMinute: Int,
invertAosTimeWindow: Boolean
): Boolean {
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
// Follow the UTC display toggle: with UTC enabled the window is interpreted
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
// shows UTC times while the filter silently uses local time — the two
// disagree by the timezone offset (e.g. 8h for China).
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
TimeZone.getTimeZone("UTC")
} else {
TimeZone.getDefault()
}
val offsetMillis = tz.getOffset(aosTime).toLong()
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
val aosMinute = (localMillis / 60_000L).toInt()
val inRange = if (aosStartMinute <= aosEndMinute) {
@@ -204,7 +204,9 @@ class SettingsRepo(
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
country = o.optString("cty").ifBlank { null },
cqz = o.optInt("cq", 0).takeIf { it > 0 },
state = o.optString("st").ifBlank { null }
state = o.optString("st").ifBlank { null },
// Own-grid field: absent in pre-myGrid data -> null.
myGrid = o.optString("mg").ifBlank { null }
)
}
if (list.isNotEmpty()) result[grid] = list
@@ -232,6 +234,7 @@ class SettingsRepo(
.put("cty", q.country ?: "")
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
.put("mg", q.myGrid ?: "")
)
}
root.put(grid, array)
@@ -51,12 +51,12 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
private fun FrameworkEntry.toDomain() = OrbitalData(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun OrbitalData.toEntity() = FrameworkEntry(
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
this.raan, this.argper, this.meanan, this.catnum, this.bstar
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
)
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
@@ -73,20 +73,22 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
return look4SatDao.getRadiosWithId(id).toDomainRadios()
}
override suspend fun insertRadios(radios: List<SatRadio>) {
look4SatDao.insertRadios(radios.toFrameworkRadios())
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
}
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
private fun DomainRadio.toFramework() = FrameworkRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
)
private fun FrameworkRadio.toDomain() = DomainRadio(
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum, this.isCustom
)
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
@@ -185,8 +185,12 @@ private class FakeLocalSource : ILocalSource {
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) {
insertedRadios += radios
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
}
override suspend fun deleteManagedRadios() {
insertedRadios.removeAll { !it.isCustom }
}
override suspend fun deleteRadios() {
@@ -3,6 +3,7 @@ package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LoTWRepositoryTest {
@@ -150,6 +151,24 @@ class LoTWRepositoryTest {
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
}
@Test
fun parseQsosCapturesOwnGridPerQso() {
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
// alphabetically); the per-record buffer must still attach it to the QSO.
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
assertEquals("OL62", result["NL47"]!!.first().myGrid)
assertEquals("OL72", result["OK48"]!!.first().myGrid)
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
}
// endregion
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
@@ -48,7 +48,9 @@ data class GridQso(
val dxcc: Int? = null,
val country: String? = null,
val cqz: Int? = null,
val state: String? = null
val state: String? = null,
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE). */
val myGrid: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -32,5 +32,7 @@ data class SatRadio(
@SerialName("uplink_high") val uplinkHigh: Long?,
@SerialName("uplink_mode") val uplinkMode: String?,
@SerialName("invert") val isInverted: Boolean,
@SerialName("norad_cat_id") val catnum: Int?
@SerialName("norad_cat_id") val catnum: Int?,
/** Set for manually imported transceivers, which remote updates must not replace. */
val isCustom: Boolean = false
)
@@ -31,6 +31,9 @@ interface ILocalSource {
suspend fun getIdsWithModes(modes: List<String>): List<Int>
suspend fun getRadiosTotal(): Int
suspend fun getRadiosWithId(id: Int): List<SatRadio>
suspend fun insertRadios(radios: List<SatRadio>)
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
/** Delete all non-custom transceivers, keeping manually imported ones. */
suspend fun deleteManagedRadios()
suspend fun deleteRadios()
}
@@ -0,0 +1,79 @@
/*
* 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.core.domain.predict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.Instant
import kotlin.math.abs
class CelestialComputerTest {
private data class RiseSetCase(
val name: String,
val observer: GeoPos,
val startIso: String
)
@Test
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
val cases = listOf(
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
)
cases.forEach { testCase ->
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
}
}
@Test
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
}
}
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
private companion object {
private const val SUNRISE_SET_THRESHOLD = -0.8333
private const val HOUR_MILLIS = 60L * 60L * 1000L
private const val DAY_MILLIS = 24L * HOUR_MILLIS
}
}
@@ -122,6 +122,10 @@
<string name="map_visibility">能见度:%s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d 个呼号 · %d 次通联</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">首通</string>
<string name="grid_qso_no_sat">卫星未知</string>
<string name="map_visible">日照中</string>
@@ -147,6 +147,10 @@
<string name="map_visibility">Visibility: %s</string>
<!-- Worked-grid QSO dialog -->
<string name="grid_qso_calls_count">%d callsigns · %d QSOs</string>
<plurals name="grid_qso_count">
<item quantity="one">%d QSO</item>
<item quantity="other">%d QSOs</item>
</plurals>
<string name="grid_qso_first">First</string>
<string name="grid_qso_no_sat">satellite unknown</string>
<string name="map_visible">Visible</string>
+1 -1
View File
@@ -1 +1 @@
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]]},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]]},{"id":2,"code":Line truncated
{"type":"waja","regions":[{"id":0,"code":"22","name":"京都府","label_lon":135.5014,"label_lat":35.2429,"rings":[[[135.0367,35.5373],[134.9265,35.5125],[134.9143,35.5448],[134.8542,35.5881],[134.8725,35.6197],[134.8671,35.6581],[134.9353,35.6505],[134.9849,35.6923],[135.0222,35.6895],[135.0607,35.7081],[135.0857,35.7398],[135.223,35.7792],[135.308,35.6944],[135.3029,35.6628],[135.2874,35.6756],[135.268,35.6674],[135.2443,35.6205],[135.1926,35.5711],[135.1931,35.5383],[135.2547,35.5989],[135.2507,35.5702],[135.2739,35.5609],[135.2449,35.5613],[135.2442,35.5359],[135.2882,35.5155],[135.3303,35.517],[135.3163,35.4512],[135.3467,35.4753],[135.3468,35.494],[135.3975,35.4788],[135.3963,35.5152],[135.3445,35.5041],[135.3423,35.5518],[135.4263,35.5665],[135.4337,35.5907],[135.4619,35.6035],[135.4521,35.5675],[135.4865,35.558],[135.4599,35.5472],[135.449,35.5235],[135.4748,35.5049],[135.4591,35.4675],[135.4958,35.4251],[135.5253,35.4207],[135.5285,35.3797],[135.6419,35.3668],[135.7007,35.3437],[135.7684,35.3561],[135.7637,35.3445],[135.809,35.3161],[135.8313,35.2761],[135.8581,35.2891],[135.8291,35.2207],[135.8558,35.1595],[135.8355,35.059],[135.815,35.0461],[135.8334,35.0223],[135.8199,35.0129],[135.8351,35.0032],[135.8268,34.9911],[135.8493,34.991],[135.8782,34.9475],[135.8664,34.8923],[135.898,34.8712],[135.9405,34.8932],[135.9457,34.8517],[136.005,34.8427],[136.0047,34.8282],[136.0281,34.8192],[136.0104,34.8007],[136.0335,34.7789],[136.0285,34.7572],[136.0552,34.7384],[136.0285,34.7062],[135.9859,34.7151],[135.9754,34.7409],[135.9254,34.7403],[135.9254,34.7575],[135.89,34.7087],[135.7551,34.7275],[135.7284,34.7779],[135.7447,34.8043],[135.6955,34.8482],[135.6749,34.8987],[135.6307,34.9353],[135.5999,34.928],[135.6129,34.9713],[135.5733,34.9717],[135.5783,34.9557],[135.5568,34.9381],[135.58,34.937],[135.5807,34.9236],[135.5418,34.9153],[135.5132,34.9439],[135.481,34.9495],[135.4874,34.9919],[135.3821,35.0101],[135.3688,35.0536],[135.4031,35.0894],[135.3885,35.0971],[135.3894,35.1288],[135.3435,35.1357],[135.3405,35.1499],[135.2822,35.1449],[135.2898,35.1699],[135.2738,35.1771],[135.2067,35.1609],[135.1869,35.1718],[135.1967,35.2029],[135.1556,35.2262],[135.1583,35.2599],[135.109,35.2663],[135.0675,35.2372],[135.0565,35.264],[135.01,35.2698],[134.9988,35.2911],[134.9256,35.3112],[134.9185,35.3645],[134.9338,35.4074],[134.9586,35.4091],[134.9895,35.3863],[135.0483,35.4074]],[[135.4273,35.6825],[135.4214,35.6797],[135.4243,35.6882]],[[135.4809,35.5943],[135.4749,35.5956],[135.4779,35.6059]],[[135.3472,35.4959],[135.3404,35.4986],[135.3474,35.4971]]],"name_en":"Kyoto"},{"id":1,"code":"41","name":"佐賀県","label_lon":130.0903,"label_lat":33.2574,"rings":[[[129.8671,33.5967],[129.8479,33.5998],[129.8612,33.6183]],[[129.9007,33.594],[129.8967,33.6037],[129.9018,33.6029]],[[129.8447,33.5858],[129.8351,33.5891],[129.8459,33.5915]],[[129.757,33.5634],[129.7394,33.5728],[129.7412,33.5781],[129.7679,33.582]],[[129.8741,33.5438],[129.8729,33.5581],[129.8804,33.5675],[129.892,33.5561]],[[130.0387,33.4697],[130.2766,33.4783],[130.3555,33.4335],[130.3953,33.4273],[130.4144,33.4103],[130.4102,33.395],[130.4962,33.4448],[130.5362,33.4382],[130.5352,33.3428],[130.479,33.3307],[130.4863,33.3123],[130.4554,33.3017],[130.4668,33.2886],[130.4489,33.265],[130.431,33.2925],[130.4146,33.2532],[130.3907,33.2599],[130.3843,33.2403],[130.3713,33.243],[130.3766,33.2204],[130.3386,33.2033],[130.3549,33.1404],[130.2878,33.1469],[130.2454,33.2029],[130.2465,33.1923],[130.2149,33.1993],[130.2306,33.1887],[130.2217,33.1653],[130.149,33.1127],[130.1219,33.1247],[130.1277,33.1085],[130.1123,33.1011],[130.1398,33.0943],[130.2198,32.9595],[130.0857,32.9748],[130.0087,33.019],[129.9935,33.0477],[129.9215,33.0904],[129.9256,33.1075],[129.9545,33.1166],[129.9425,33.1643],[129.8877,33.1563],[129.818,33.1835],[129.8134,33.2421],[129.7588,33.2903],[129.7899,33.3403],[129.836,33.2867],[129.8528,33.2921],[129.8274,33.3124],[129.8339,33.3113],[129.8484,33.3287],[129.8273,33.3373],[129.8474,33.3369],[129.868,33.4075],[129.8227,33.4118],[129.8307,33.4218],[129.7828,33.4541],[129.8062,33.4573],[129.7936,33.4713],[129.8055,33.485],[129.8382,33.4621],[129.8352,33.4436],[129.8579,33.4538],[129.8497,33.4751],[129.8399,33.4698],[129.8314,33.4762],[129.8443,33.4786],[129.8273,33.5176],[129.8563,33.5055],[129.8396,33.5222],[129.8562,33.5191],[129.8451,33.5553],[129.8749,33.5371],[129.8744,33.5161],[129.8958,33.5494],[129.9278,33.5507],[129.9288,33.537],[129.956,33.5295],[129.967,33.5086],[129.9362,33.4786],[129.9546,33.4693],[129.9624,33.4835],[129.9671,33.4552],[130.0194,33.4463],[130.0402,33.4558]],[[129.9674,33.5271],[129.9645,33.5389],[129.9721,33.5432],[129.9772,33.5349]],[[129.9928,33.4777],[129.9846,33.4779],[129.9888,33.4806]],[[129.8563,33.4021],[129.8558,33.4049],[129.858,33.4024]],[[129.8601,33.3995],[129.8614,33.4017],[129.8656,33.4027]],[[129.8386,33.3191],[129.8351,33.3215],[129.8352,33.3227]],[[130.2242,32.9565],[130.2127,32.9546],[130.222,32.9584]]Line truncated
+1 -1
View File
@@ -1 +1 @@
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]]},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[117.7539,39.5797],[11Line truncated
{"type":"wapc","regions":[{"id":0,"code":"BJ","name":"北京市","label_lon":116.42,"label_lat":40.1899,"rings":[[[117.3486,40.5811],[117.3899,40.5616],[117.4299,40.5761],[117.4127,40.6052],[117.4675,40.6497],[117.4675,40.6497],[117.5014,40.6366],[117.5149,40.6602],[117.494,40.6752],[117.409,40.687],[117.3425,40.6738],[117.3197,40.6579],[117.2784,40.6643],[117.2082,40.6947],[117.117,40.7001],[117.1121,40.7074],[117.0123,40.6938],[116.9649,40.7096],[116.9267,40.745],[116.9242,40.7736],[116.8485,40.8393],[116.8134,40.8483],[116.7598,40.89],[116.7136,40.9099],[116.7222,40.9275],[116.6779,40.9709],[116.6988,41.0215],[116.6883,41.0445],[116.6477,41.0594],[116.6156,41.0531],[116.623,41.021],[116.5984,40.9745],[116.5676,40.9926],[116.5196,40.9813],[116.5196,40.9813],[116.4555,40.9808],[116.4475,40.9537],[116.4771,40.8999],[116.3982,40.9062],[116.3705,40.9438],[116.3397,40.9293],[116.3342,40.9044],[116.4383,40.8193],[116.466,40.7745],[116.4537,40.7659],[116.3169,40.7722],[116.3114,40.755],[116.2732,40.7627],[116.2473,40.7917],[116.2202,40.7441],[116.2048,40.74],[116.1716,40.6956],[116.1629,40.6625],[116.134,40.6665],[116.0989,40.6307],[116.0052,40.5839],[115.9825,40.5789],[115.972,40.6025],[115.9079,40.6175],[115.8851,40.5952],[115.8279,40.5875],[115.8198,40.5593],[115.7847,40.5584],[115.7552,40.5402],[115.7361,40.5034],[115.781,40.4934],[115.7718,40.4437],[115.8642,40.3594],[115.9178,40.3544],[115.9517,40.2819],[115.9689,40.264],[115.8987,40.2344],[115.8704,40.1859],[115.8556,40.1887],[115.8476,40.147],[115.8063,40.1534],[115.7737,40.1763],[115.7546,40.1457],[115.7546,40.1457],[115.6,40.1196],[115.5907,40.0962],[115.5273,40.0761],[115.4854,40.0404],[115.4546,40.0298],[115.4503,39.9927],[115.4287,39.9844],[115.4263,39.9505],[115.4811,39.9358],[115.523,39.8991],[115.5156,39.8922],[115.5156,39.8922],[115.526,39.8757],[115.5143,39.8375],[115.5673,39.8164],[115.5525,39.7948],[115.5057,39.7842],[115.4835,39.7985],[115.4835,39.7985],[115.4435,39.7856],[115.4398,39.752],[115.4866,39.7419],[115.4916,39.6701],[115.4786,39.6507],[115.4786,39.6507],[115.5224,39.6401],[115.518,39.5973],[115.5458,39.6189],[115.587,39.5899],[115.6338,39.5996],[115.6338,39.5996],[115.6677,39.6152],[115.6985,39.5779],[115.6985,39.5779],[115.6991,39.57],[115.6991,39.57],[115.717,39.5603],[115.717,39.5603],[115.7188,39.5539],[115.7188,39.5539],[115.7207,39.5511],[115.7207,39.5511],[115.7225,39.5442],[115.7213,39.5437],[115.7225,39.5442],[115.7225,39.5437],[115.7213,39.5437],[115.7225,39.5437],[115.725,39.5442],[115.725,39.5442],[115.7385,39.54],[115.7385,39.5396],[115.7385,39.54],[115.7385,39.5396],[115.7527,39.5156],[115.8063,39.51],[115.8063,39.51],[115.8211,39.523],[115.8211,39.523],[115.8285,39.5414],[115.8679,39.5465],[115.8679,39.5465],[115.9153,39.583],[115.9153,39.583],[115.9104,39.6005],[115.9104,39.6005],[115.9572,39.5608],[115.9781,39.5959],[115.9954,39.577],[116.0262,39.5876],[116.036,39.5719],[116.0989,39.5751],[116.1303,39.5677],[116.1518,39.5834],[116.1987,39.5894],[116.2405,39.564],[116.2578,39.5003],[116.3071,39.4883],[116.3379,39.4555],[116.3619,39.4551],[116.3619,39.4551],[116.4346,39.4426],[116.4549,39.4532],[116.4444,39.4823],[116.4118,39.4828],[116.4019,39.528],[116.4438,39.51],[116.4376,39.5267],[116.4783,39.5354],[116.4734,39.553],[116.5085,39.5511],[116.5245,39.5963],[116.5922,39.6212],[116.5922,39.6212],[116.6206,39.6019],[116.6649,39.6056],[116.7234,39.5926],[116.724,39.5926],[116.7234,39.5926],[116.724,39.5926],[116.7265,39.5954],[116.7265,39.5954],[116.7093,39.618],[116.7487,39.6198],[116.7906,39.5959],[116.8121,39.6157],[116.8497,39.6678],[116.9064,39.6774],[116.9057,39.688],[116.8897,39.6876],[116.8873,39.7253],[116.9168,39.7313],[116.9021,39.7635],[116.9495,39.7787],[116.9181,39.8463],[116.9076,39.8325],[116.8657,39.844],[116.8121,39.8899],[116.7844,39.8913],[116.7826,39.9477],[116.7579,39.9679],[116.7813,40.0349],[116.8201,40.0284],[116.8312,40.0514],[116.8676,40.0417],[116.9279,40.055],[116.9452,40.0413],[117.0252,40.0303],[117.0517,40.0596],[117.1053,40.0743],[117.1053,40.0743],[117.1404,40.0642],[117.1595,40.077],[117.2045,40.0697],[117.21,40.082],[117.2242,40.0949],[117.2242,40.0949],[117.255,40.1145],[117.255,40.1145],[117.255,40.1145],[117.2741,40.1059],[117.3073,40.137],[117.3492,40.1365],[117.3671,40.1726],[117.3671,40.1731],[117.3671,40.1731],[117.3671,40.1726],[117.3837,40.1882],[117.3899,40.228],[117.3511,40.2298],[117.3314,40.2896],[117.295,40.2782],[117.2716,40.3252],[117.2716,40.3252],[117.2433,40.3695],[117.226,40.369],[117.234,40.4173],[117.2636,40.4424],[117.2088,40.5016],[117.263,40.5129],[117.2476,40.5398],[117.2698,40.5607],[117.3486,40.5811]]],"name_en":"Beijing"},{"id":1,"code":"TJ","name":"天津市","label_lon":117.347,"label_lat":39.288,"rings":[[[117.7656,39.4005],[117.8469,39.4079],[117.8715,39.4116],[117.8703,39.4551],[117.8999,39.4745],[117.9122,39.5174],[117.9122,39.5174],[117.9048,39.5336],[117.9338,39.5742],[117.8685,39.5968],[117.8297,39.5899],[117.7668,39.5986],[117.7539,39.5797],[1Line truncated
@@ -35,7 +35,8 @@ import kotlin.math.min
* One administrative/boundary region loaded from the award GeoJSON assets.
*
* @param code match key against award statistics (e.g. "GD", "34", "24", "CA", "318")
* @param name display label (province/prefecture/state/country name)
* @param name display label in the source language (province/prefecture/state/country name)
* @param nameEn optional English display label (null when the source already is English)
* @param labelLon/labelLat preferred label anchor (usually the region centroid)
* @param rings polygon exterior rings as lon/lat pairs; the overlay projects
* them per frame with the same projection helpers as the grid overlay
@@ -43,6 +44,7 @@ import kotlin.math.min
data class AwardRegion(
val code: String,
val name: String,
val nameEn: String? = null,
val labelLon: Double,
val labelLat: Double,
val rings: List<List<DoubleArray>>,
@@ -93,6 +95,7 @@ object AwardBoundaryData {
AwardRegion(
code = o.optString("code"),
name = o.optString("name"),
nameEn = o.optString("name_en").takeIf { it.isNotEmpty() },
labelLon = o.optDouble("label_lon", 0.0),
labelLat = o.optDouble("label_lat", 0.0),
rings = rings,
@@ -184,6 +187,10 @@ class AwardBoundaryOverlay : Overlay() {
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
// Label language follows the system: Chinese UI keeps the source
// (Chinese/Japanese) names; any other UI language uses the English
// name when the asset provides one (falls back to the source name).
val useEnglishLabels = mapView.context.resources.configuration.locales[0].language != "zh"
for (i in regions.indices) {
val region = regions[i]
@@ -236,7 +243,8 @@ class AwardBoundaryOverlay : Overlay() {
// anchor sits out on the sea where there is room for the text.
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
if (abs(regionH) < MIN_LABEL_REGION_PX && !region.forceLabel) continue
canvas.drawText(region.name, lx, ly - textHalfHeight, labelPaint)
val label = if (useEnglishLabels) region.nameEn ?: region.name else region.name
canvas.drawText(label, lx, ly - textHalfHeight, labelPaint)
}
}
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos
@@ -39,9 +40,11 @@ import kotlin.math.sin
* 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
* 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.
* evaluations per row, which is imperceptible. draw() is called on every
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/
class MapNightOverlay : Overlay() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// 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
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
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 geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: 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
val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when {
dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot
while (hi - lo > 1) {
val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid
val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: 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
@@ -62,6 +62,7 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -90,6 +91,10 @@ import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
@@ -145,6 +150,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView)
}
@@ -162,8 +182,30 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
var selectedAward by remember { mutableStateOf<AwardType?>(AwardType.VUCC) }
// Six-award progress derived from the confirmed QSO store; recomputed when
// the store changes (LoTW/Wavelog sync).
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos) {
AwardCalculator.calculate(uiState.workedGridQsos)
// Per operated-grid VUCC breakdown: myGrid -> set of worked grids worked from it.
val vuccByMyGrid: Map<String, Set<String>> = remember(uiState.workedGridQsos) {
val m = mutableMapOf<String, MutableSet<String>>()
for ((grid, qsos) in uiState.workedGridQsos) {
for (q in qsos) {
val mg = q.myGrid ?: continue
m.getOrPut(mg) { mutableSetOf() }.add(grid)
}
}
m
}
// Selected operated grid for VUCC counting; defaults to the grid with the
// most worked grids. Null when the store carries no per-QSO myGrid data
// (requires a LoTW resync) — then VUCC falls back to the global count.
var selectedMyGrid by remember(vuccByMyGrid) {
mutableStateOf(vuccByMyGrid.maxByOrNull { it.value.size }?.key)
}
val awardProgress: List<AwardProgress> = remember(uiState.workedGridQsos, vuccByMyGrid, selectedMyGrid) {
val vuccGrids = selectedMyGrid?.let { vuccByMyGrid[it].orEmpty() }
val base = AwardCalculator.calculate(uiState.workedGridQsos)
if (vuccGrids == null) base
else base.map { p ->
if (p.type == AwardType.VUCC) p.copy(workedKeys = vuccGrids, count = vuccGrids.size) else p
}
}
// Attach the tap listener whenever grid mode / worked grids change.
val workedGrids = uiState.workedGrids
@@ -279,6 +321,18 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
// Top-left: operated-grid selector for VUCC counting (grid mode only).
val myGrid = selectedMyGrid
if (uiState.isGridMode && selectedAward == AwardType.VUCC && myGrid != null) {
VuccGridSelector(
options = vuccByMyGrid,
selected = myGrid,
onSelect = { selectedMyGrid = it },
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
)
}
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
@@ -381,18 +435,33 @@ private fun WorkedGridCallRow(
style = MaterialTheme.typography.titleMedium,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = MaterialTheme.colorScheme.tertiary,
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = formatDate(first.epochMs, isUtc),
text = pluralStringResource(R.plurals.grid_qso_count, callQsos.size, callQsos.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (expanded) " ▴" else " ▾",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
modifier = Modifier.weight(1f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.weight(1f)
) {
Text(
text = formatDate(first.epochMs, isUtc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (expanded) " ▴" else " ▾",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
// First-QSO summary line, always visible.
Text(
@@ -560,6 +629,73 @@ private fun AwardChipsRow(
}
}
/**
* Compact pill toggle for switching between satellite view and grid mode,
* floated over the map's top-right corner. The label reflects the active
* mode ("Grid mode" when ON, "Satellite mode" when OFF). Semi-transparent
* background keeps the map readable underneath.
*/
@Composable
private fun VuccGridSelector(
options: Map<String, Set<String>>,
selected: String,
onSelect: (String) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
// Most-worked grid first; selection is made on tap.
val sorted = remember(options) { options.entries.sortedByDescending { it.value.size } }
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(8.dp),
modifier = modifier
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { expanded = !expanded }
.padding(start = 10.dp, end = 6.dp, top = 4.dp, bottom = 4.dp)
) {
Text(
text = "$selected (${options[selected]?.size ?: 0})",
color = ComposeColor.White,
fontSize = 12.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
Text(
text = if (expanded) " ▴" else " ▾",
fontSize = 18.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp)
)
}
if (expanded) {
androidx.compose.material3.HorizontalDivider(color = ComposeColor.White.copy(alpha = 0.2f))
sorted.forEach { (grid, grids) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelect(grid)
expanded = false
}
.padding(horizontal = 10.dp, vertical = 3.dp)
) {
Text(
text = "$grid (${grids.size})",
color = if (grid == selected) MaterialTheme.colorScheme.primary else ComposeColor.White,
fontSize = 12.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
}
/**
* Compact pill toggle for switching between satellite view and grid mode,
* floated over the map's top-right corner. The label reflects the active
@@ -731,6 +867,27 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
}
}
/** Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway. */
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
/** 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
@@ -741,13 +898,20 @@ private fun setPositions(
action: (OrbitalObject) -> Unit
) {
try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) {
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
}
// Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible.
@@ -756,17 +920,48 @@ private fun setPositions(
}
folder.items.clear()
val activeNames = HashSet<String>(posMap.size)
posMap.forEach { (satellite, geoPos) ->
// Cull satellites outside the viewport: only meaningful once zoomed in, but that is
// exactly when marker labels are shown and drawing is most expensive.
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name
activeNames.add(name)
val marker = markerPool.getOrPut(name) {
Marker(mapView).apply {
setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView)
// Resolve the satellite via relatedObject so the listener is allocated
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
}
}
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available
val pos = marker.position
if (pos != null) {
@@ -775,22 +970,34 @@ private fun setPositions(
} else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
}
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker)
}
// Evict markers for satellites no longer tracked
val iter = markerPool.keys.iterator()
while (iter.hasNext()) {
if (iter.next() !in activeNames) iter.remove()
}
// Evict markers that are no longer tracked or no longer visible
markerPool.keys.retainAll(activeNames)
visibleSats.clear()
} catch (e: Exception) {
println(e)
}
}
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
// Grid mode disables the satellite layer — don't re-populate markers off-screen
if (mapView.overlays.getOrNull(OVERLAY_POSITIONS)?.isEnabled == false) return true
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it }
val labelRect = Rect()
@@ -966,9 +1173,23 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) }
}
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose { clearMapCaches() }
}
return mapView
}
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event ->
@@ -48,6 +48,7 @@ sealed interface MapAction {
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
}
data class MapData(
@@ -37,6 +37,7 @@ import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Job
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancelAndJoin
@@ -45,6 +46,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -69,6 +71,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState
init {
@@ -100,6 +105,7 @@ class MapViewModel(
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -137,7 +143,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject
viewModelScope.launch {
dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch {
// Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
@@ -148,6 +158,8 @@ class MapViewModel(
else -> updateFreq
}
while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate)
@@ -175,9 +175,12 @@ class PassesViewModel(
val sdfDate = dateFormat(tz)
val sdfTime = SimpleDateFormat("HH:mm", Locale.getDefault()).also { it.timeZone = tz }
val result = LinkedHashMap<String, Pair<String, String>>()
// DeepSpace group always shows today's sun times
// DeepSpace group always shows today's sun times.
// The natural-day anchor is the DEVICE timezone, not the display timezone:
// UTC midnight is 08:00 local for UTC+8 stations, when the sun is already
// up — that would trip the white-night guard and render "--:--".
if (passes.any { it.isDeepSpace }) {
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), TimeZone.getDefault()))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result["DeepSpace (period >225min)"] = rise to set
@@ -186,7 +189,7 @@ class PassesViewModel(
if (pass.isDeepSpace) continue
val label = sdfDate.format(Date(pass.aosTime))
if (label in result) continue
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
val riseSet = findTodaySunRiseSet(stationPos, startOfDayMillis(pass.aosTime, TimeZone.getDefault()))
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
result[label] = rise to set
@@ -32,8 +32,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -166,6 +169,7 @@ private fun RadarScreen(
Column(
modifier = Modifier
.layoutPadding()
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
.keepScreenOn(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "468"
appVersionCode = "471"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.10.3"
appVersionName = "4.4.7-ba7opf.1"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry