Compare commits

..
Author SHA1 Message Date
atsunatsu db0320f521 chore: bump version to 4.4.6-ba7opf.9.13 2026-09-08 08:47:56 +08:00
atsunatsu adc9ac008b fix(map): restore grid lines between worked cells
The .9.11 skip logic removed every line segment bordering a worked
gridsquare, which also erased the shared edges between adjacent worked
cells — adjacent greens merged into one unreadable patch. Revert to
drawing the full Maidenhead grid unconditionally; the green fills stay
on top exactly as before.
2026-09-08 08:26:44 +08:00
atsunatsu 4a33d1b19a feat(map): award filters with boundary overlay in grid mode
- Grid-mode top bar replaced with award filter chips (All/DXCC/VUCC/
  WAPC/WAJA/WAZ/WAS) showing live progress (count/target)
- New AwardBoundaryOverlay renders real administrative boundaries
  (provinces/prefectures/CQ zones/states/countries) with the VUCC visual
  language: yellow boundary lines, green fill for worked regions, labels
- Boundary assets packaged from public GeoJSON (DataV China, Japan land,
  HB9HIL CQ zones, Natural Earth states/countries) with ADIF match keys:
  WAPC province codes, WAJA ADIF prefecture numbers, US state abbrs,
  ARRL DXCC entity codes
- GridQso extended with dxcc/country/cqz/state parsed from LoTW report
  (qso_qsldetail); AwardCalculator derives six-award progress; STATE is
  interpreted per DXCC entity (ADIF 'STATE depends on DXCC')
- Grid-mode toggle pill floated at map top-right (Grid mode / Satellite mode)
- New tests: AwardCalculatorTest (9 cases), LoTW award-field parsing (3 cases)
2026-09-08 08:22:12 +08:00
atsunatsu cce9209b3d chore: bump version to 4.4.6-ba7opf.9.12 2026-09-08 06:14:34 +08:00
atsunatsu 237e4cb27a fix(update): APK asset URL used stale '-release' filename — downloads 404'd
Since v4.4.6-ba7opf.9.9 releases are uploaded as Look4Sat-<version>.apk (no
'-release' suffix), but parseRelease still built the download URL as
Look4Sat-<version>-release.apk. Update check succeeded (page parses) while the
download 404'd ('下载失败请重试'). Align the generated URL with the actual
asset name; mirror-prefixed URLs fixed too.
2026-09-08 06:09:42 +08:00
atsunatsu cd3eded77c chore: bump version to 4.4.6-ba7opf.9.11 2026-09-08 06:03:35 +08:00
atsunatsu 7d7f8f1eda feat(map): expanded QSO list — drop ordinal prefix, add per-QSO date
Expanded per-callsign rows previously read '2 · FO-29 · CW · V/U · 11:30Z'
(ordinal + summary, no date). Now each expanded line is
'yyyy-MM-dd · FO-29 · CW · V/U · 11:30Z' — the first QSO's date already
lives in the header row, so only subsequent QSOs carry a date prefix.
2026-09-08 05:57:27 +08:00
atsunatsu 11b46c6710 feat(map): tap worked cells from GRID_ZOOM_SUB (lines visible), labels from zoom 6.5
- Tap-to-show-QSOs now works as soon as 4-char grid lines appear (zoom 6),
  not only when 4-char labels show (was zoom 7). Selection outline already
  used GRID_ZOOM_SUB, so feedback is immediate.
- 4-char labels now appear from zoom 6.5 (was 7.0), so the sub-square name
  shows shortly after the lines appear instead of a full zoom level later.
2026-09-08 05:54:06 +08:00
atsunatsu da1facac64 fix(map): world-repeat copies no longer repaint in place — alpha stacking made worked cells brighter and opaque
Pixel analysis of the user's screenshot: worked-cell green measured RGB(57,160,74)
against background RGB(16,16,15), which matches alpha≈178 — i.e. the alpha=90
fill painted ~3 times in place. Root cause: at low zoom colRepeats>0 and every
turn loop shifted the cell by dLon=turn*360°, but projectionToX() normalizes
longitude back into [-180,180), so all turns collapsed onto the SAME screen x
and each cell/line/label was painted 2*colRepeats+1 times at one spot.

Fix: keep the turn offset as a pixel shift (turn * worldWidthPx) on the x
coordinate in ALL turn loops (worked fills at both zooms, vertical lines,
horizontal lines, labels). World-repeat copies now land on their true screen
positions instead of stacking on top of each other.
2026-09-08 05:46:08 +08:00
atsunatsu cb1f341ed9 fix(map): field-zoom grid lines no longer cut through worked cells — root cause of 'green too bright'
The previous fixes only handled sub-square zoom lines. At field zoom the 20°x10°
grid lines were drawn unconditionally: cellLabel(zoom<6) returns 2-char fields
which never match the 4-char workedGrids set, so every field line crossed every
worked 2°x1° square. Bright-yellow (alpha 160) lines through the green fill is
what read as 'green got brighter' — the fill color itself never changed.

Now both line passes iterate the finer 2°x1° sub-grid (forced 4-char labels via
GRID_ZOOM_SUB) and skip segments bordering worked cells, at field zoom and
sub-square zoom alike. Revert the alpha softening (c2e496c4) — the fill was
never the problem; also restores worked-cell labels (7afce7ee revert).
2026-09-08 05:28:12 +08:00
atsunatsu c2e496c406 fix(map): soften worked-cell fill at sub-square zoom, restore cell labels
Reverting the label-skip from 7afce7ee: worked cells keep their 4-char
labels. The 'green too bright' report is an area effect, not an overlay
overdraw — the same alpha=90 fill that reads as a small patch at field zoom
covers half the viewport at sub-square zoom. Use a softer fill (alpha 50)
for the sub-square branch, field zoom unchanged.
2026-09-08 05:02:05 +08:00
atsunatsu 7afce7ee68 fix(map): skip labels on worked cells — bright-yellow 4-char text on green fill read as 'green too bright'
The previous fix skipped grid LINES through worked cells, but each worked cell
still got a bright-yellow (alpha 220 + black shadow) 4-char label drawn dead
center on the green fill at zoom>=7. Field zoom never shows this because 2-char
labels sit at field centers and almost never overlap a worked 2x1 square. Now
worked cells stay completely label-free, matching the field-zoom look.
2026-09-08 04:47:43 +08:00
atsunatsu 86048ee1fd fix(lotw): run report fetch on Dispatchers.IO — main-thread blocking caused ANR/crash
fetchReportBody is a synchronous HttpURLConnection call (up to 30s connect +
120s read) but was invoked directly from a Main-dispatcher viewModelScope
coroutine: the whole UI froze for the duration of the request, then Android
killed the app with an ANR dialog. Wrap both fetch methods in
withContext(Dispatchers.IO). Bug present since the endpoint was introduced.
2026-09-08 04:32:21 +08:00
atsunatsu 42a6ffa4c9 chore: bump version to 4.4.6-ba7opf.9.9 2026-09-08 04:17:52 +08:00
atsunatsu cf1d8e6539 LoTW sync: distinguish failure causes (bad credentials / rate limit / timeout / network)
Rate limit classification per ARRL policy (one download in progress per user
id; HTTP 503 'Page request limit' observed on throttled pulls) and the HTML
login-error page returned for wrong credentials. Settings screen now shows a
specific message per cause instead of one generic 'check callsign/password/network'.
2026-09-08 04:07:03 +08:00
atsunatsu ab8b072f1f fix(map): worked cells at sub-square zoom stay line-free — grid lines skip worked segments
Lines (alpha 160 yellow) were drawn AFTER the worked fills (alpha 90 green),
so at zoom>=6 every worked cell's four borders got yellow-on-green
composited — the cells looked brighter than the same cells at field zoom
(no square lines there). Segments bordering a worked cell are now skipped
in both meridian and parallel passes.
2026-09-08 03:44:21 +08:00
atsunatsu 1d4036e3d0 fix(map): vertical grid lines vanish at global views — bbox now anchored on view center, not left/right edge pixels
The antimeridian unwrap (span > 180 => shift left edge +360) misfired on
every global view (span is legitimately > 180): the range inverted and the
meridian loop drew nothing. Anchor the bbox on the view-center longitude
plus half viewport width in degrees instead; continuous unwrapped range
handles both antimeridian crossing and world repeats.
2026-09-08 03:25:12 +08:00
atsunatsu ab22244f86 fix(map): grid-mode field center offset by -90/-180 base
Maidenhead field index 0 spans -90..-80 lat / -180..-160 lon, so the
field-center must subtract those bases. Without them, entering grid mode
centered a hemisphere away (OL62 -> 115N/290E instead of 25N/110E).
2026-09-08 03:19:40 +08:00
23 changed files with 1160 additions and 133 deletions

No files matched your search

@@ -18,11 +18,15 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.SocketTimeoutException
import java.net.URL
import java.net.URLEncoder
import java.io.IOException
import javax.net.ssl.SSLException
/**
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
@@ -34,74 +38,54 @@ import java.net.URLEncoder
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
* truncated/expanded to the 4-char form used by the map overlay.
*
* ARRL rate-limits the report endpoint (roughly once per hour per account), which is
* fine for a manual sync button.
* ARRL rate-limits the report endpoint: one download in progress per user id, and
* frequent full-report pulls get refused. Failures are reported with an explicit
* cause (see LoTWResult) so the UI can tell the user what to do next.
*/
class LoTWRepository : ILoTWRepository {
override suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>? =
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
withContext(Dispatchers.IO) {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return@withContext null
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
// Without it, LoTW applies a "system supplied default" since-date and
// only returns confirmations newer than the account's last query —
// subsequent syncs would return an empty/incremental report.
val since = "2000-01-01"
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
append("&qso_qslsince=").append(URLEncoder.encode(since, "UTF-8"))
}
try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.requestMethod = "GET"
connection.setRequestProperty("Accept-Encoding", "gzip")
connection.instanceFollowRedirects = true
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return@withContext null
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
connection.disconnect()
if (body.contains(" password=") && !body.startsWith("ARRL")) return@withContext null
parseConfirmedGrids(body)
} catch (e: Exception) {
println("LoTWRepository fetch failure: $e")
null
}
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onFailure = { toResult(it) }
)
}
override suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
// Single report fetch: grids are derived from the same body as the
// per-QSO detail (avoids a second ARRL hit and keeps the two datasets
// perfectly consistent).
val body = fetchReportBody(callsign, password) ?: return null
): LoTWResult = withContext(Dispatchers.IO) {
fetchReportBody(callsign, password).fold(
onSuccess = { body -> parseBoth(body)?.let { LoTWResult.Success(it.first, it.second) }
?: LoTWResult.RateLimited },
onFailure = { toResult(it) }
)
}
internal fun toResult(e: Throwable): LoTWResult = when (e) {
is CredentialsException -> LoTWResult.BadCredentials
is RateLimitException -> LoTWResult.RateLimited
is TimeoutException -> LoTWResult.Timeout
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
}
/** Single report fetch feeding both the grid set and the per-QSO detail. */
private fun parseBoth(body: String): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>? {
val grids = parseConfirmedGrids(body) ?: return null
val qsos = parseConfirmedGridQsos(body) ?: return null
return grids to qsos
}
private fun fetchReportBody(callsign: String, password: String): String? {
private fun fetchReportBody(callsign: String, password: String): Result<String> {
val call = callsign.trim().uppercase()
val pwd = password.trim()
if (call.isBlank() || pwd.isBlank()) return null
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
// qso_qslsince with an early date forces a FULL confirmed-QSL report.
// Without it, LoTW applies a "system supplied default" since-date and
// only returns confirmations newer than the account's last query —
// subsequent syncs would return an empty/incremental report.
val since = "2000-01-01"
val query = buildString {
append("login=").append(URLEncoder.encode(call, "UTF-8"))
@@ -111,6 +95,11 @@ class LoTWRepository : ILoTWRepository {
}
return try {
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
// ARRL can be slow to accept connections from mobile networks
// (long TLS handshakes across the Pacific, occasional server-side
// queueing). 30s connect + 120s read gives the request enough
// headroom; the sync button stays disabled meanwhile so users
// see progress rather than a hung dialog.
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
connection.requestMethod = "GET"
@@ -119,21 +108,38 @@ class LoTWRepository : ILoTWRepository {
val code = connection.responseCode
if (code !in 200..299) {
connection.disconnect()
return null
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
// report pull with HTTP 503 "Page request limit".
return if (code == 503 || code == 429) Result.failure(RateLimitException())
else Result.failure(IOException("HTTP $code"))
}
val stream = connection.inputStream
val body = ("gzip".equals(connection.contentEncoding, ignoreCase = true))
.let { gz -> if (gz) java.util.zip.GZIPInputStream(stream) else stream }
.bufferedReader().use { it.readText() }
connection.disconnect()
if (body.contains(" password=") && !body.startsWith("ARRL")) return null
body
when {
// Login failure: HTTP 200 + HTML login page with the error text.
body.contains("incorrect", ignoreCase = true) &&
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
// Any other non-ADIF body: rate-limit refusal / server error page.
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
else -> Result.success(body)
}
} catch (e: SocketTimeoutException) {
Result.failure(TimeoutException(e.message ?: "timed out"))
} catch (e: SSLException) {
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
} catch (e: Exception) {
println("LoTWRepository fetch failure: $e")
null
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
}
}
internal class CredentialsException : Exception("bad callsign/password")
internal class RateLimitException : Exception("report refused (rate limit / server error)")
internal class TimeoutException(message: String) : Exception(message)
internal fun parseConfirmedGridQsos(
body: String
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
@@ -150,6 +156,10 @@ class LoTWRepository : ILoTWRepository {
var mode = ""
var bandUp = ""
var bandDown = ""
var dxcc: Int? = null
var country: String? = null
var cqz: Int? = null
var state: String? = null
val gridsInRecord = mutableListOf<String>()
fun emitRecord() {
@@ -157,7 +167,8 @@ class LoTWRepository : ILoTWRepository {
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
call = call, epochMs = epochMs, satName = satName,
mode = mode, bandUp = bandUp, bandDown = bandDown
mode = mode, bandUp = bandUp, bandDown = bandDown,
dxcc = dxcc, country = country, cqz = cqz, state = state
)
for (grid in gridsInRecord) {
result.getOrPut(grid) { mutableListOf() }.add(qso)
@@ -167,6 +178,7 @@ class LoTWRepository : ILoTWRepository {
fun resetRecord() {
propMode = null; call = ""; qsoDate = ""; timeOn = ""
satName = ""; mode = ""; bandUp = ""; bandDown = ""
dxcc = null; country = null; cqz = null; state = null
gridsInRecord.clear()
}
@@ -193,6 +205,14 @@ class LoTWRepository : ILoTWRepository {
bandUp = adifValue(line).uppercase()
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
bandDown = adifValue(line).uppercase()
line.startsWith("<DXCC:") ->
dxcc = adifValue(line).toIntOrNull()
line.startsWith("<COUNTRY:") ->
country = adifValue(line).ifBlank { null }
line.startsWith("<CQZ:") ->
cqz = adifValue(line).toIntOrNull()
line.startsWith("<STATE:") ->
state = adifValue(line).trim().ifBlank { null }
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
// VUCC_GRIDS holds a comma-separated list of grids
// ("EN52en,EN53fa"), up to four for contacts spanning
@@ -199,7 +199,12 @@ class SettingsRepo(
satName = o.optString("s"),
mode = o.optString("m"),
bandUp = o.optString("bu"),
bandDown = o.optString("bd")
bandDown = o.optString("bd"),
// New award fields: absent in pre-award data -> null.
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 }
)
}
if (list.isNotEmpty()) result[grid] = list
@@ -223,6 +228,10 @@ class SettingsRepo(
.put("m", q.mode)
.put("bu", q.bandUp)
.put("bd", q.bandDown)
.put("dx", q.dxcc ?: 0)
.put("cty", q.country ?: "")
.put("cq", q.cqz ?: 0)
.put("st", q.state ?: "")
)
}
root.put(grid, array)
@@ -66,16 +66,18 @@ class UpdateRepository(
.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
.replace("&quot;", "\"").replace("&#39;", "'")
.trim()
// The release APK asset follows the fixed naming scheme used by the build:
// Look4Sat-<tag without leading v>-release.apk. When the page was fetched
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
// versioned filename, no "-release" suffix). When the page was fetched
// through an accelerator mirror (https://<mirror>/https://github.com/...),
// download through the same mirror — raw github.com is unreachable on the
// networks that needed the mirror in the first place.
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
val apkUrl = if (mirrorPrefix.isEmpty()) {
"$DOWNLOAD_BASE_URL/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
"$DOWNLOAD_BASE_URL/$tag/$apkName"
} else {
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/Look4Sat-${tag.removePrefix("v")}-release.apk"
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
}
return LatestRelease(
versionTag = tag,
@@ -1,5 +1,6 @@
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.Test
@@ -128,4 +129,71 @@ class LoTWRepositoryTest {
}
// endregion
// region failure classification (fetchReportBody -> LoTWResult mapping)
@Test
fun failureClassificationMapsEachException() {
// LoTWRepository.toResult() must keep each failure cause distinct so
// the UI can show a specific message per cause.
assertEquals(
LoTWResult.BadCredentials,
repo.toResult(LoTWRepository.CredentialsException())
)
assertEquals(
LoTWResult.RateLimited,
repo.toResult(LoTWRepository.RateLimitException())
)
assertEquals(
LoTWResult.Timeout,
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
)
val net = repo.toResult(java.io.IOException("HTTP 500"))
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
}
@Test
fun parseQsosExtractsAwardFields() {
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
// from LoTW's qso_qsldetail report (STATE depends on DXCC).
val qso = "<CALL:5>BG7XYZ\n" +
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:2>GD\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["OL62"]!!.first()
assertEquals(318, parsed.dxcc)
assertEquals("CHINA", parsed.country)
assertEquals(24, parsed.cqz)
assertEquals("GD", parsed.state)
}
@Test
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(qso))!!
val parsed = result["PM95"]!!.first()
assertNull(parsed.dxcc)
assertNull(parsed.country)
assertNull(parsed.cqz)
assertNull(parsed.state)
}
@Test
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
"<GRIDSQUARE:4>OL62\n<EOR>\n"
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
assertNull(result["PM95"]!!.first().dxcc)
assertNull(result["PM95"]!!.first().cqz)
assertNull(result["PM95"]!!.first().state)
}
// endregion
}
@@ -0,0 +1,194 @@
/*
* 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.model
/**
* Award progress computed from the confirmed-QSO store.
*
* @param type which award
* @param workedKeys matching keys for the boundary assets: each is compared
* against the region "code" field of the award's GeoJSON
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
* @param count distinct worked units (grids / entities / provinces / ...)
* @param target units required for the award
*/
data class AwardProgress(
val type: AwardType,
val workedKeys: Set<String>,
val count: Int,
val target: Int
)
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
object AwardTargets {
const val DXCC = 100
const val VUCC = 100
const val WAPC = 34
const val WAJA = 47
const val WAZ = 40
const val WAS = 50
}
/**
* Derives six-award progress from the confirmed QSO store.
*
* Award rules (ADIF / award-program semantics):
* - VUCC: distinct 4-char gridsquares.
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
* to a callsign-prefix guess when LoTW omitted it).
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
* - WAZ: distinct CQ zones.
* - WAS: distinct US states (STATE abbreviations).
*
* The ADIF STATE field's meaning depends on the DXCC entity
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
*/
object AwardCalculator {
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
val all = qsosByGrid.values.flatten()
return listOf(
AwardProgress(
AwardType.VUCC,
workedKeys = qsosByGrid.keys,
count = qsosByGrid.size,
target = AwardTargets.VUCC
),
calculateDxcc(all),
calculateWapc(all),
calculateWaja(all),
calculateWaz(all),
calculateWas(all)
)
}
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
val code = q.dxcc ?: prefixEntityCode(q.call)
if (code != null) keys.add(code.toString())
}
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
}
private fun calculateWapc(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
when (entityOf(q)) {
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
Entity.HONG_KONG -> keys.add(HK_CODE)
Entity.MACAO -> keys.add(MO_CODE)
Entity.TAIWAN -> keys.add(TW_CODE)
else -> {}
}
}
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
}
private fun calculateWaja(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.JAPAN) continue
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
}
private fun calculateWaz(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
}
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
}
private fun calculateWas(all: List<GridQso>): AwardProgress {
val keys = mutableSetOf<String>()
for (q in all) {
if (entityOf(q) != Entity.USA) continue
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
}
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
}
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
318 -> Entity.CHINA
321 -> Entity.HONG_KONG
330 -> Entity.MACAO
386 -> Entity.TAIWAN
339 -> Entity.JAPAN
291 -> Entity.USA
else -> prefixEntity(q.call)
}
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
private fun prefixEntity(call: String): Entity {
val c = call.uppercase().substringBefore('/')
return when {
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
c.startsWith("VR") -> Entity.HONG_KONG
c.startsWith("XX9") -> Entity.MACAO
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
else -> Entity.OTHER
}
}
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
private fun prefixEntityCode(call: String): Int? {
return when (prefixEntity(call)) {
Entity.CHINA -> 318
Entity.HONG_KONG -> 321
Entity.MACAO -> 330
Entity.TAIWAN -> 386
Entity.JAPAN -> 339
Entity.USA -> 291
else -> null
}
}
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
private val JP_PREFIXES =
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
private val US_PREFIXES =
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
private val CN_STATES = setOf(
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
)
private val US_STATES = setOf(
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
"VA", "WA", "WV", "WI", "WY"
)
private const val HK_CODE = "HK"
private const val MO_CODE = "MO"
private const val TW_CODE = "TW"
}
@@ -29,6 +29,14 @@ package com.rtbishop.look4sat.core.domain.model
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
* LoTW did not include it
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
* <DXCC>), or null when LoTW omitted it
* @param country DXCC entity name (ADIF <COUNTRY>), or null
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
* @param state Primary administrative subdivision of the opposite station
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
* China -> province code ("GD"), Japan -> prefecture number
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
*/
data class GridQso(
val call: String,
@@ -36,7 +44,11 @@ data class GridQso(
val satName: String,
val mode: String,
val bandUp: String,
val bandDown: String
val bandDown: String,
val dxcc: Int? = null,
val country: String? = null,
val cqz: Int? = null,
val state: String? = null
) {
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
val bandLabel: String
@@ -22,17 +22,17 @@ interface ILoTWRepository {
/**
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
* Returns the 4-char grid set, or null on any failure (network / bad credentials).
* Returns the 4-char grid set with an explicit failure cause on error.
*/
suspend fun fetchConfirmedGrids(callsign: String, password: String): Set<String>?
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
/**
* Same report, but keeps the per-QSO detail of every confirmed satellite
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
* gridsquare. Returns null on any failure.
* gridsquare. Failure cause is kept explicit.
*/
suspend fun fetchConfirmedGridQsos(
callsign: String,
password: String
): Pair<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>>?
): LoTWResult
}
@@ -0,0 +1,45 @@
/*
* 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.repository
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
sealed class LoTWResult {
/** Report downloaded and parsed successfully. */
data class Success(
val grids: Set<String>,
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
) : LoTWResult()
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
data object BadCredentials : LoTWResult()
/**
* Report endpoint refused the request without a usable error page. LoTW
* limits downloads to one in progress per user id and flags accounts that
* pull the full report too often; the server answers those with a bare
* non-ADIF body (no <eoh>).
*/
data object RateLimited : LoTWResult()
/** Connect/read timed out — typically a slow route to the ARRL servers. */
data object Timeout : LoTWResult()
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
data class NetworkError(val detail: String) : LoTWResult()
}
@@ -0,0 +1,155 @@
/*
* 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.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AwardCalculatorTest {
private fun qso(
call: String,
dxcc: Int? = null,
cqz: Int? = null,
state: String? = null,
grid: String = "OL62"
) = GridQso(
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
)
private fun byGrid(vararg qsos: GridQso) =
qsos.groupBy { "OL62" } // all share one grid for award-math tests
@Test
fun `vucc counts distinct grid keys`() {
val qsos = mapOf(
"OL62" to listOf(qso("BG7XYZ")),
"PM95" to listOf(qso("JH0ABC")),
"OL62" to listOf(qso("BA7AAA"))
)
// map literal dedupes keys: OL62/PM95 -> 2 grids
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
}
@Test
fun `dxcc counts distinct entities from dxcc field`() {
val all = listOf(
qso("BG7XYZ", dxcc = 318),
qso("JH0ABC", dxcc = 339),
qso("YD1XYZ", dxcc = 327),
qso("BG7ABC", dxcc = 318)
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
assertEquals(3, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
}
@Test
fun `dxcc falls back to prefix when dxcc missing`() {
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"))
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
assertEquals(3, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321")))
}
@Test
fun `wapc counts mainland provinces plus hk mo tw`() {
val all = listOf(
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
qso("VR2X", dxcc = 321), // Hong Kong
qso("XX9A", dxcc = 330), // Macao
qso("BM4X", dxcc = 386) // Taiwan
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
assertEquals(5, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
}
@Test
fun `wapc ignores non-china states and missing states`() {
val all = listOf(
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
qso("BG7XYZ", dxcc = 318, state = null), // missing province
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
assertEquals(0, progress.count)
assertTrue(progress.workedKeys.isEmpty())
}
@Test
fun `waja counts japanese prefectures only`() {
val all = listOf(
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
qso("JH0ABC", dxcc = 339, state = "10"), // dup
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
qso("JE2XYZ", dxcc = 339, state = null) // missing
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
}
@Test
fun `waz counts distinct cq zones in range`() {
val all = listOf(
qso("BG7XYZ", cqz = 24),
qso("JH0ABC", cqz = 25),
qso("YD1XYZ", cqz = 28),
qso("BG7AAA", cqz = 24),
qso("BG7BBB", cqz = null),
qso("BG7CCC", cqz = 0)
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
assertEquals(3, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
}
@Test
fun `was counts us states only`() {
val all = listOf(
qso("K1ABC", dxcc = 291, state = "CA"),
qso("W2XYZ", dxcc = 291, state = "NY"),
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
)
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
assertEquals(2, progress.count)
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
}
@Test
fun `all six awards present with correct targets`() {
val progress = AwardCalculator.calculate(emptyMap())
assertEquals(6, progress.size)
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
}
}
@@ -107,6 +107,9 @@
<!-- Map screen -->
<string name="map_prev">上一个</string>
<string name="map_next">下一个</string>
<string name="map_grid_mode">网格模式</string>
<string name="map_satellite_mode">卫星模式</string>
<string name="map_award_all">全部</string>
<string name="map_azimuth">方位角:%.1f°</string>
<string name="map_elevation">仰角:%.1f°</string>
<string name="map_altitude">高度:%.0f km</string>
@@ -131,6 +131,9 @@
<string name="map_prev">Prev</string>
<string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string>
<string name="map_grid_mode">Grid mode</string>
<string name="map_satellite_mode">Satellite mode</string>
<string name="map_award_all">All</string>
<string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string>
@@ -0,0 +1 @@
{"type":"dxcc","regions":[{"id":0,"code":176,"name":"Fiji","label_lon":177.9759,"label_lat":-17.9376,"rings":[[[180.0,-16.0671],[180.0,-16.5552],[179.3641,-16.8014],[178.7251,-17.012],[178.5968,-16.6392],[179.0966,-16.434],[179.4135,-16.3791]],[[178.1256,-17.5048],[178.3736,-17.3399],[178.7181,-17.6285],[178.5527,-18.1506],[177.9327,-18.288],[177.3815,-18.1643],[177.285,-17.7247],[177.6709,-17.3811]],[[-179.7933,-16.0209],[-179.9174,-16.5018],[-180.0,-16.5552],[-180.0,-16.0671]]]},{"id":1,"code":470,"name":"Tanzania","label_lon":34.1421,"label_lat":-6.2078,"rings":[[[33.9037,-0.95],[34.0726,-1.0598],[37.6987,-3.097],[37.7669,-3.6771],[39.2022,-4.6768],[38.7405,-5.9089],[38.7998,-6.4757],[39.44,-6.84],[39.47,-7.1],[39.1947,-7.7039],[39.252,-8.0078],[39.1865,-8.4855],[39.5357,-9.1124],[39.9496,-10.0984],[40.3166,-10.3171],[40.3166,-10.3171],[39.521,-10.8969],[38.4276,-11.2852],[37.8276,-11.2688],[37.4713,-11.5688],[36.7752,-11.5945],[36.5141,-11.7209],[35.3124,-11.4391],[34.56,-11.52],[34.28,-10.16],[33.9408,-9.6937],[33.7397,-9.4171],[32.7594,-9.2306],[32.1919,-8.9304],[31.5563,-8.762],[31.1578,-8.5946],[30.74,-8.34],[30.74,-8.34],[30.2,-7.08],[29.62,-6.52],[29.42,-5.94],[29.52,-5.42],[29.34,-4.5],[29.7535,-4.4524],[30.1163,-4.0901],[30.5055,-3.5686],[30.7522,-3.3593],[30.743,-3.0343],[30.5277,-2.8076],[30.4697,-2.4139],[30.4697,-2.4138],[30.7583,-2.2872],[30.8161,-1.6989],[30.4191,-1.1347],[30.7699,-1.0146],[31.8662,-1.0274]]]},{"id":2,"code":302,"name":"W. Sahara","label_lon":-12.572,"label_lat":24.2306,"rings":[[[-8.6656,27.6564],[-8.6651,27.5895],[-8.6844,27.3957],[-8.6873,25.8811],[-11.9694,25.9334],[-11.9372,23.3746],[-12.8742,23.2848],[-13.1188,22.7712],[-12.9291,21.3271],[-16.8452,21.3333],[-17.0634,20.9998],[-17.0204,21.4223],[-17.003,21.4207],[-14.751,21.5006],[-14.6308,21.8609],[-14.2212,22.3102],[-13.8911,23.691],[-12.501,24.7701],[-12.0308,26.0309],[-11.7182,26.1041],[-11.3926,26.8834],[-10.5513,26.9908],[-10.1894,26.8609],[-9.7353,26.8609],[-9.413,27.0885],[-8.7949,27.1207],[-8.8178,27.6564]]]},{"id":3,"code":1,"name":"Canada","label_lon":-110.2438,"label_lat":56.7019,"rings":[[[-122.84,49.0],[-122.9742,49.0025],[-124.9102,49.9846],[-125.6246,50.4166],[-127.4356,50.8306],[-127.9928,51.7158],[-127.8503,52.3296],[-129.1298,52.7554],[-129.3052,53.5616],[-130.515,54.2876],[-130.5361,54.8028],[-130.5361,54.8028],[-129.98,55.285],[-130.0078,55.9158],[-131.7078,56.5521],[-132.7304,57.6929],[-133.3556,58.4103],[-134.2711,58.8611],[-134.945,59.2706],[-135.4758,59.7878],[-136.4797,59.4639],[-137.4525,58.905],[-138.3409,59.5621],[-139.039,60.0],[-140.013,60.2768],[-140.9978,60.3064],[-140.9925,66.0],[-140.986,69.712],[-140.986,69.712],[-139.1205,69.471],[-137.5464,68.99],[-136.5036,68.898],[-135.6258,69.3151],[-134.4146,69.6274],[-132.9292,69.5053],[-131.4314,69.9445],[-129.7947,70.1937],[-129.1077,69.7793],[-128.3616,70.0129],[-128.1382,70.4838],[-127.4471,70.3772],[-125.7563,69.4806],[-124.4248,70.1584],[-124.2897,69.3997],[-123.0611,69.5637],[-122.6835,69.8555],[-121.4723,69.7978],[-119.9429,69.3779],[-117.6027,69.0113],[-116.2264,68.8415],[-115.2469,68.9059],[-113.8979,68.3989],[-115.3049,67.9026],[-113.4973,67.6881],[-110.798,67.8061],[-109.9462,67.981],[-108.8802,67.3814],[-107.7924,67.8874],[-108.813,68.3116],[-108.1672,68.6539],[-106.95,68.7],[-106.15,68.8],[-105.3428,68.5612],[-104.3379,68.018],[-103.2211,68.0978],[-101.4543,67.6469],[-99.9019,67.8057],[-98.4432,67.7816],[-98.5586,68.4039],[-97.6695,68.5786],[-96.1199,68.2394],[-96.1259,67.2934],[-95.4894,68.0907],[-94.685,68.0638],[-94.2328,69.069],[-95.3041,69.6857],[-96.4713,70.0898],[-96.3911,71.1948],[-95.2088,71.9205],[-93.89,71.7601],[-92.8782,71.3187],[-91.5196,70.1913],[-92.4069,69.7],[-90.5471,69.4977],[-90.5515,68.475],[-89.2151,69.2587],[-88.0197,68.6151],[-88.3175,67.8734],[-87.3502,67.1987],[-86.3061,67.9215],[-85.5766,68.7846],[-85.522,69.8821],[-84.1008,69.8054],[-82.6226,69.6583],[-81.2804,69.162],[-81.2202,68.6657],[-81.9644,68.1325],[-81.2593,67.5972],[-81.3865,67.1108],[-83.3446,66.4115],[-84.7354,66.2573],[-85.7694,66.5583],[-86.0676,66.0563],[-87.0314,65.213],[-87.3232,64.7756],[-88.483,64.099],[-89.9144,64.0327],[-90.704,63.6102],[-90.77,62.9602],[-91.9334,62.8351],[-93.157,62.0247],[-94.2415,60.8987],[-94.6293,60.1102],[-94.6846,58.9488],[-93.215,58.7821],[-92.7646,57.8457],[-92.297,57.0871],[-90.8977,57.2847],[-89.0395,56.8517],[-88.0398,56.4716],[-87.3242,55.9991],[-86.0712,55.7238],[-85.0118,55.3026],[-83.3606,55.2449],[-82.2729,55.1483],[-82.4362,54.2823],[-82.125,53.277],[-81.4008,52.1579],[-79.9129,51.2084],[-79.143,51.5339],[-78.6019,52.5621],[-79.1242,54.1414],[-79.8296,54.6677],[-78.2287,55.1365],[-77.0956,55.8374],[-76.5414,56.5342],[-76.6232,57.2026],[-77.3023,58.0521],[-78.5169,58.8046],[-77.3368,59.8526],[-77.7727,60.7579],[-78.1069,62.3196],[-77.4107,62.5505],[-75.6962,62.2784],[-74.6682,62.1811],[-73.8399,62.4438],[-72.9085,62.1051],[-71.6771,61.5254],[-71.3737,61.1372],[-69.5904,61.0614],[-69.6203,60.2212]Line truncated
@@ -0,0 +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
@@ -0,0 +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
@@ -0,0 +1 @@
{"type":"was","regions":[{"id":0,"code":"AL","name":"Alabama","label_lon":-86.7494,"label_lat":32.72,"rings":[[[-87.3593,35.0012],[-85.6067,34.9847],[-85.4314,34.1249],[-85.185,32.8597],[-85.0699,32.5804],[-84.9604,32.4215],[-85.0042,32.323],[-84.8892,32.2627],[-85.059,32.1367],[-85.0535,32.0108],[-85.1411,31.841],[-85.0426,31.5398],[-85.1138,31.2769],[-85.0042,31.003],[-85.4971,30.9975],[-87.6003,30.9975],[-87.6331,30.8661],[-87.4086,30.6744],[-87.4469,30.5101],[-87.3702,30.4279],[-87.5181,30.2801],[-87.6551,30.2472],[-87.907,30.4115],[-87.9344,30.658],[-88.0111,30.6854],[-88.1042,30.4991],[-88.137,30.3184],[-88.3944,30.3677],[-88.4711,31.8958],[-88.2411,33.7963],[-88.0987,34.8916],[-88.2027,34.9957]]]},{"id":1,"code":"AK","name":"Alaska","label_lon":-152.8897,"label_lat":62.9226,"rings":[[[-131.602,55.118],[-131.5692,55.2823],[-131.3556,55.1837],[-131.3884,55.0139],[-131.6458,55.0358]],[[-131.8321,55.4247],[-131.6458,55.3042],[-131.7499,55.1289],[-131.8321,55.1892]],[[-132.9767,56.4379],[-132.7357,56.4598],[-132.6317,56.4215],[-132.6645,56.2736],[-132.8781,56.2408],[-133.0698,56.3339]],[[-133.5956,56.3503],[-133.1629,56.3174],[-133.0534,56.1257],[-132.6207,55.9121],[-132.4729,55.7807],[-132.4619,55.6712],[-132.3578,55.6492],[-132.3414,55.5068],[-132.1661,55.3644],[-132.1442,55.2385],[-132.0292,55.2768],[-131.9799,55.1782],[-131.958,54.7894],[-132.0292,54.7017],[-132.3085,54.7182],[-132.3852,54.9153],[-132.4838,54.8989],[-132.6865,55.0468],[-132.7467,54.9975],[-132.9165,55.0468],[-132.8891,54.8989],[-132.7303,54.9372],[-132.6262,54.8825],[-132.6755,54.6798],[-132.8672,54.7017],[-133.1575,54.9592],[-133.2396,55.0906],[-133.2232,55.2275],[-133.4532,55.2166],[-133.4532,55.3206],[-133.278,55.3316],[-133.1027,55.4247],[-133.1794,55.589],[-133.3875,55.6219],[-133.4204,55.8848],[-133.497,56.0162],[-133.6394,55.9231],[-133.6942,56.071],[-133.5463,56.1422],[-133.6668,56.312]],[[-133.738,55.5561],[-133.5463,55.4904],[-133.4149,55.5726],[-133.2834,55.5342],[-133.4204,55.3864],[-133.634,55.4302]],[[-133.9078,56.9308],[-134.0502,57.0294],[-133.8859,57.0952],[-133.3437,57.002],[-133.1027,57.0075],[-132.9329,56.8213],[-132.6207,56.668],[-132.6536,56.5529],[-132.8179,56.4927],[-133.0425,56.5201],[-133.2013,56.4489],[-133.4204,56.4927],[-133.6613,56.4489],[-133.7106,56.6844],[-133.6887,56.8377],[-133.8695,56.8432]],[[-134.1159,56.4817],[-134.2529,56.5584],[-134.4007,56.7227],[-134.4172,56.8487],[-134.2967,56.9089],[-134.1707,56.8487],[-134.1433,56.9528],[-133.749,56.772],[-133.7106,56.5968],[-133.8476,56.5748],[-133.9352,56.3777],[-133.8366,56.3229],[-133.9571,56.0929],[-134.1105,56.1422],[-134.1324,55.9998],[-134.231,56.071],[-134.2912,56.3503]],[[-134.6362,56.2846],[-134.6691,56.1696],[-134.806,56.2353],[-135.1785,56.6789],[-135.414,56.8104],[-135.3318,56.9144],[-135.4249,57.1664],[-135.6878,57.369],[-135.4194,57.5662],[-135.299,57.484],[-135.0634,57.4183],[-134.8498,57.4073],[-134.8444,57.2485],[-134.6362,56.7282]],[[-134.7129,58.2234],[-134.3734,58.1467],[-134.1762,58.1577],[-134.1871,58.081],[-133.9023,57.8072],[-134.0995,57.851],[-134.1488,57.7579],[-133.9352,57.6155],[-133.8695,57.3635],[-134.0831,57.2978],[-134.1543,57.2102],[-134.4993,57.0294],[-134.6034,57.0349],[-134.6472,57.2266],[-134.576,57.3416],[-134.6089,57.5114],[-134.7294,57.7195],[-134.7074,57.8291],[-134.7841,58.0974],[-134.9156,58.2125],[-134.9539,58.4096]],[[-135.8576,57.3307],[-135.7152,57.3307],[-135.5673,57.1499],[-135.633,57.024],[-135.8576,56.9966],[-135.8247,57.1937]],[[-136.2793,58.207],[-135.9781,58.2015],[-135.7809,58.2891],[-135.4961,58.1686],[-135.6495,58.0372],[-135.5947,57.9879],[-135.4523,58.1358],[-135.1073,58.0865],[-134.9156,57.9769],[-135.0251,57.7798],[-134.9375,57.7633],[-134.8225,57.5005],[-135.0854,57.4621],[-135.5728,57.6757],[-135.5564,57.4566],[-135.7097,57.369],[-135.8905,57.4073],[-136.0,57.5443],[-136.2081,57.6374],[-136.367,57.8291],[-136.5696,57.9167],[-136.5587,58.0755],[-136.4217,58.1303],[-136.3779,58.2672]],[[-147.0799,60.2006],[-147.5016,59.9486],[-147.5344,59.8501],[-147.874,59.7843],[-147.8028,59.9377],[-147.4359,60.0965],[-147.2058,60.2718]],[[-147.5618,60.5785],[-147.6166,60.3704],[-147.759,60.1568],[-147.9562,60.228],[-147.7919,60.4744]],[[-147.7864,70.2453],[-147.6823,70.2015],[-147.162,70.1577],[-146.8882,70.185],[-146.5103,70.185],[-146.0995,70.1467],[-145.8585,70.1686],[-145.623,70.0865],[-145.1958,69.9934],[-144.6207,69.9714],[-144.4619,70.0262],[-144.0785,70.0591],[-143.9142,70.1303],[-143.4979,70.1412],[-143.5034,70.0919],[-143.2569,70.1193],[-142.7476,70.0426],[-142.4025,69.9167],[-142.0794,69.8564],[-142.0082,69.8017],[-141.7125,69.7907],[-141.4331,69.6976],[-141.3784,69.6373],[-141.2086,69.6866],[-141.0005,69.6483],[-141.0005,60.3046],[-140.5349,60.2225],[-140.4747,60.3101],[-139.9872,60.1842],[-139.6969,60.343],[-139.089,60.3594],[-139.1985,60.091],[-139.0452,59.9979],[-138.7001,59.9103],[-138.6235,59.7679],[-137.6047,59.2421],[-137.4459,58.908],[-137.2652,59.0011],[-136.827,59.16],[-136.5806,59.165Line truncated
@@ -0,0 +1 @@
{"type":"waz","regions":[{"id":1,"code":"1","name":"1","label_lon":-140,"label_lat":75,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-190.0,40.0],[-190.0,53.0],[-167.6871,66.6671],[-180.0,75.0],[-180.0,85.0]]]},{"id":2,"code":"2","name":"2","label_lon":-82.5,"label_lat":70,"rings":[[[-109.0,85.0],[-109.0,77.5],[-103.2588,75.2824],[-103.4495,72.3827],[-99.2896,70.6113],[-102.0,68.0],[-102.0,60.0],[-85.0,60.0],[-80.8725,54.5159],[-79.7772,51.8514],[-79.6522,51.6179],[-79.5157,51.4606],[-79.5176,50.0],[-60.0,50.0],[-55.9546,51.7455],[-55.4041,51.765],[-49.0,53.0],[-52.2437,59.5838],[-57.8076,67.1394],[-67.854,74.2269],[-74.0332,75.4972],[-74.8618,77.4191],[-73.5809,78.5637],[-69.8831,79.5013],[-68.2947,80.439],[-60.0073,82.2668],[-60.0,85.0]]]},{"id":3,"code":"3","name":"3","label_lon":-125,"label_lat":45,"rings":[[[-120.0,60.0],[-139.0569,60.0001],[-138.7074,59.9061],[-138.6686,59.8091],[-138.626,59.7686],[-137.6062,59.2436],[-137.5007,58.9835],[-137.5266,58.9059],[-137.4512,58.9081],[-137.281,59.001],[-136.8272,59.1604],[-136.5843,59.166],[-136.4897,59.2595],[-136.4956,59.2754],[-136.4695,59.284],[-136.4771,59.4656],[-136.3681,59.449],[-136.3055,59.4644],[-136.2387,59.5242],[-136.2393,59.5592],[-136.3538,59.5998],[-136.1933,59.6401],[-135.9529,59.6622],[-135.4793,59.798],[-135.3601,59.7376],[-135.2336,59.6962],[-135.2198,59.6627],[-135.1575,59.6257],[-135.1179,59.6231],[-135.0289,59.5637],[-135.0275,59.4748],[-135.101,59.4276],[-134.9897,59.3871],[-135.0305,59.3466],[-134.9596,59.2808],[-134.7007,59.2489],[-134.6793,59.1925],[-134.5659,59.1312],[-134.4831,59.1316],[-134.3821,59.0386],[-134.4072,58.9796],[-134.3138,58.962],[-134.3365,58.9235],[-134.2579,58.8612],[-133.8404,58.7293],[-133.7068,58.6117],[-133.3772,58.4303],[-133.462,58.3879],[-133.346,58.2769],[-133.1722,58.1536],[-133.0701,58.0002],[-132.8689,57.8393],[-132.7512,57.6962],[-132.5542,57.4971],[-132.37,57.35],[-132.2487,57.2116],[-132.3688,57.0917],[-132.0449,57.045],[-132.1237,56.8739],[-131.8735,56.806],[-131.9009,56.7537],[-131.8604,56.703],[-131.8353,56.5994],[-131.5815,56.6123],[-131.4716,56.5523],[-131.1726,56.4494],[-131.0884,56.4062],[-130.7825,56.3671],[-130.6242,56.2672],[-130.4683,56.2431],[-130.4264,56.1415],[-130.2468,56.0966],[-130.104,56.1229],[-130.0037,56.0081],[-130.0178,55.9119],[-130.0041,55.9074],[-130.1524,55.7664],[-130.1481,55.7152],[-130.1118,55.6829],[-130.1281,55.5809],[-130.09,55.498],[-130.0413,55.4517],[-130.0211,55.3379],[-129.9742,55.282],[-130.1029,55.1926],[-130.1876,55.0629],[-130.346,54.9177],[-130.5682,54.7922],[-130.6591,54.7631],[-130.6257,54.7381],[-130.6156,54.7074],[-134.5376,54.2944],[-140.0,50.0],[-140.0,40.0],[-130.0,40.0],[-130.0,32.5],[-120.0,32.5],[-117.1249,32.534],[-114.7197,32.7187],[-114.7643,32.6427],[-114.8087,32.6191],[-114.7997,32.5924],[-114.8137,32.5613],[-114.7932,32.5696],[-114.7916,32.5558],[-114.8057,32.5467],[-114.8135,32.4943],[-111.0748,31.3322],[-109.05,31.3325],[-109.0501,41.0007],[-111.0467,40.9979],[-111.049,44.474],[-111.1227,44.4937],[-111.1394,44.5171],[-111.1317,44.5326],[-111.177,44.5509],[-111.1808,44.569Line truncated
@@ -0,0 +1,270 @@
/*
* 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.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import org.json.JSONObject
import org.osmdroid.views.MapView
import org.osmdroid.views.Projection
import org.osmdroid.views.overlay.Overlay
import java.io.InputStream
import kotlin.math.abs
import kotlin.math.max
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 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
*/
data class AwardRegion(
val code: String,
val name: String,
val labelLon: Double,
val labelLat: Double,
val rings: List<List<DoubleArray>>
)
/**
* Loads and caches the award boundary assets (assets/awards/<type>.json).
* Format (generated by the awarddata/convert2.py pipeline):
* {"type": "wapc", "regions": [{"id":..,"code":"GD","name":"广东省",
* "label_lon":..,"label_lat":..,"rings":[[[lon,lat],...],...]}, ...]}
*/
object AwardBoundaryData {
private val cache = mutableMapOf<AwardAsset, List<AwardRegion>>()
enum class AwardAsset(val fileName: String) {
WAPC("wapc.json"),
WAJA("waja.json"),
WAZ("waz.json"),
WAS("was.json"),
DXCC("dxcc.json")
}
fun load(context: Context, asset: AwardAsset): List<AwardRegion> {
cache[asset]?.let { return it }
val regions = try {
val stream: InputStream = context.assets.open("awards/${asset.fileName}")
val text = stream.bufferedReader().use { it.readText() }
val root = JSONObject(text)
val array = root.optJSONArray("regions") ?: return emptyList()
val list = mutableListOf<AwardRegion>()
for (i in 0 until array.length()) {
val o = array.optJSONObject(i) ?: continue
val ringsArray = o.optJSONArray("rings") ?: continue
val rings = mutableListOf<List<DoubleArray>>()
for (r in 0 until ringsArray.length()) {
val ring = ringsArray.optJSONArray(r) ?: continue
val points = mutableListOf<DoubleArray>()
for (p in 0 until ring.length()) {
val pt = ring.optJSONArray(p) ?: continue
if (pt.length() >= 2) points.add(doubleArrayOf(pt.getDouble(0), pt.getDouble(1)))
}
if (points.size >= 3) rings.add(points)
}
if (rings.isEmpty()) continue
list.add(
AwardRegion(
code = o.optString("code"),
name = o.optString("name"),
labelLon = o.optDouble("label_lon", 0.0),
labelLat = o.optDouble("label_lat", 0.0),
rings = rings
)
)
}
list
} catch (_: Exception) {
emptyList()
}
cache[asset] = regions
return regions
}
}
/**
* Renders award boundary regions (provinces/prefectures/CQ zones/states/
* countries) with the same visual language as the VUCC grid overlay:
* yellow boundary lines, green fill for worked regions, yellow labels.
*
* Worked regions are matched by [workedCodes] against each region's [AwardRegion.code]
* (e.g. {"GD", "ZJ", "HK", ...} for WAPC). All region boundaries are always drawn
* so missing regions stay visible; only worked ones get the green fill.
*/
class AwardBoundaryOverlay : Overlay() {
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 1.5f
style = Paint.Style.STROKE
color = Color.argb(160, 255, 224, 130)
}
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 22f
style = Paint.Style.FILL
color = Color.argb(220, 255, 224, 130)
setShadowLayer(3f, 2f, 2f, Color.BLACK)
}
private val workedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(90, 76, 217, 100)
}
/** Boundary regions to render; assigning recomputes culling bounds. */
var regions: List<AwardRegion> = emptyList()
set(value) {
field = value
regionBounds.clear()
for (r in value) {
var minLon = Double.MAX_VALUE; var minLat = Double.MAX_VALUE
var maxLon = -Double.MAX_VALUE; var maxLat = -Double.MAX_VALUE
for (ring in r.rings) for (pt in ring) {
if (pt[0] < minLon) minLon = pt[0]
if (pt[0] > maxLon) maxLon = pt[0]
if (pt[1] < minLat) minLat = pt[1]
if (pt[1] > maxLat) maxLat = pt[1]
}
regionBounds.add(doubleArrayOf(minLon, minLat, maxLon, maxLat))
}
}
/** Codes of worked regions -> green fill. */
var workedCodes: Set<String> = emptySet()
/** Bounding boxes (computed on region assignment) for cheap culling. */
private val regionBounds = mutableListOf<DoubleArray>() // [minLon,minLat,maxLon,maxLat]
private var canvasWidthPx = 1080f
override fun setEnabled(on: Boolean) {
super.setEnabled(on)
}
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow || !isEnabled || regions.isEmpty()) return
val projection = mapView.projection
val zoom = mapView.zoomLevelDouble
canvasWidthPx = canvas.width.toFloat()
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
// Visible bounding box (same anchor-on-center approach as the grid overlay).
val north = projection.fromPixels(0, 0)
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
val centerLon = viewCenter.longitude
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
val leftLon = centerLon - halfSpanDeg
val rightLon = centerLon + halfSpanDeg
labelPaint.textAlign = Paint.Align.CENTER
val fontMetrics = labelPaint.fontMetrics
val textHalfHeight = (fontMetrics.descent + fontMetrics.ascent) / 2f
for (i in regions.indices) {
val region = regions[i]
val b = regionBounds.getOrNull(i) ?: continue
// Cull: region bbox outside the visible span (with wrap slack).
if (b[1] > topLat || b[3] < bottomLat) continue
if (b[0] > rightLon || b[2] < leftLon) continue
val worked = region.code in workedCodes
val path = Path()
var pathHasPoints = false
for (ring in region.rings) {
var penDown = false
var prevX = 0f
var prevY = 0f
var lastLon = Double.NaN
for (pt in ring) {
val lon = pt[0]
val lat = pt[1]
// Split rings crossing the antimeridian (|dLon| > 180°).
if (penDown && !lastLon.isNaN() && abs(lon - lastLon) > 180.0) {
penDown = false
}
val x = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val y = projectionToY(projection, lat) ?: continue
if (!penDown) {
path.moveTo(x, y)
penDown = true
} else {
path.lineTo(x, y)
}
prevX = x; prevY = y; lastLon = lon
pathHasPoints = true
}
if (penDown) path.close()
}
if (!pathHasPoints) continue
if (worked) canvas.drawPath(path, workedPaint)
canvas.drawPath(path, linePaint)
// Label: draw when its anchor is on screen and the region is big
// enough on screen to hold the text (avoid clutter at low zoom).
val labelLonNorm = normalizeLon(region.labelLon)
if (labelLonNorm < leftLon - 1.0 || labelLonNorm > rightLon + 1.0) continue
if (region.labelLat < bottomLat - 1.0 || region.labelLat > topLat + 1.0) continue
val lx = projectionToX(projection, labelLonNorm, centerLon, worldWidthPx) ?: continue
val ly = projectionToY(projection, region.labelLat) ?: continue
// Screen-size gate: the label only when the region spans enough px.
val regionH = (projectionToY(projection, b[3]) ?: 0f) - (projectionToY(projection, b[1]) ?: 0f)
if (abs(regionH) < MIN_LABEL_REGION_PX) continue
canvas.drawText(region.name, lx, ly - textHalfHeight, labelPaint)
}
}
/** X pixel for a longitude (Mercator X is linear in longitude). */
private fun projectionToX(projection: Projection, lon: Double, centerLon: Double, worldWidthPx: Double): Float? {
var delta = lon - centerLon
while (delta > 180.0) delta -= 360.0
while (delta < -180.0) delta += 360.0
val geo = org.osmdroid.util.GeoPoint(0.0, centerLon)
val p = projection.toPixels(geo, null)
return (p.x + delta / 360.0 * worldWidthPx).toFloat()
}
/** Y pixel for a latitude. */
private fun projectionToY(projection: Projection, lat: Double): Float? {
val geo = org.osmdroid.util.GeoPoint(lat, 0.0)
val p = projection.toPixels(geo, null)
return p.y.toFloat()
}
private fun normalizeLon(lon: Double): Double {
var l = lon % 360.0
if (l >= 180.0) l -= 360.0
if (l < -180.0) l += 360.0
return l
}
private companion object {
const val MIN_LABEL_REGION_PX = 42f
}
}
@@ -87,36 +87,32 @@ class MaidenheadGridOverlay : Overlay() {
val cellLat = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LAT else FIELD_LAT
val cellLon = if (zoom >= GRID_ZOOM_SUB) SUB_SQUARE_LON else FIELD_LON
// Visible bounding box in geographic coordinates
// Visible bounding box. Do NOT derive longitude bounds from the left/
// right edge pixels: osmdroid normalizes them to [-180,180), which both
// garbles antimeridian-crossing views AND makes any global view (span
// > 180°) look like an antimeridian crossing — unwrapping then produced
// an inverted range and ALL meridians vanished at low zoom. Instead
// anchor on the view-center longitude and expand by the half-width in
// degrees; the range is continuous (may exceed ±180) and cell indices
// beyond 18 wrap correctly via normalizeLon().
val north = projection.fromPixels(0, 0)
val south = projection.fromPixels(canvas.width, canvas.height)
val topLat = max(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
val bottomLat = min(north.latitude, south.latitude).coerceIn(-90.0, 90.0)
// Longitude: osmdroid normalizes to [-180, 180), so when the view
// straddles the antimeridian (e.g. left edge 170°E, right edge 170°W)
// the raw min/max swap sides and the bounding box spans the wrong way
// (center on the Pacific → grid painted 180° off-screen). Unwrap by
// shifting one edge by +360° so left < right again.
var lonA = north.longitude
var lonB = south.longitude
if (lonA > lonB) {
val t = lonA; lonA = lonB; lonB = t
}
if (lonB - lonA > 180.0) lonA += 360.0
val leftLon = lonA
val rightLon = lonB
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
val viewCenter = projection.fromPixels(canvas.width / 2, canvas.height / 2)
val centerLon = viewCenter.longitude
val halfSpanDeg = (canvas.width / 2.0) / worldWidthPx * 360.0
val leftLon = centerLon - halfSpanDeg
val rightLon = centerLon + halfSpanDeg
val firstRow = floor(bottomLat / cellLat).toInt()
val lastRow = ceil(topLat / cellLat).toInt()
// Longitude cell indices may exceed the [-180, 180) range when the view
// crosses the antimeridian; index arithmetic still works (PMxx at 360° == same grid)
// Longitude cell indices are in the continuous unwrapped space and may
// exceed the [-180, 180) range when the view crosses the antimeridian
// or spans world repeats; labels normalize each cell back.
val firstCol = floor(leftLon / cellLon).toInt()
val lastCol = ceil(rightLon / cellLon).toInt()
val centerLon = (leftLon + rightLon) / 2.0
// World width in screen pixels for the current zoom (256 px per tile,
// 2^zoom tiles across the whole 360° world). Used by the custom
// Mercator-X computation in projectionToX.
val worldWidthPx = 256.0 * Math.pow(2.0, zoom)
// At low zoom the world is narrower than the viewport and osmdroid shows
// repeating copies on both sides. The visible bounding box spans more
// than 360° of longitude there; extend the column range by whole world
@@ -146,8 +142,13 @@ class MaidenheadGridOverlay : Overlay() {
if (yTop == null || yBottom == null) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
// World-repeat copies: keep the turn offset in pixels
// (colRepeats is 0 at this zoom today, but the same
// alpha-stacking fix applies if it ever becomes > 0).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (cellLabel(lat, lon, zoom) in workedGrids) {
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
@@ -157,14 +158,22 @@ class MaidenheadGridOverlay : Overlay() {
} else {
for (grid in workedGrids) {
val cell = gridCellBounds(grid) ?: continue
// World-repeat copies: a 360° turn shifts the cell by a
// full world width in pixels. projectionToX() normalizes
// longitude back into [-180,180), so WITHOUT adding the
// turn*worldWidthPx offset every copy lands on the SAME
// screen x and the cell gets painted 2*colRepeats+1 times
// in place — alpha stacks and the green turns brighter and
// more opaque. Keep the world-width offset on the x.
for (turn in -colRepeats..colRepeats) {
// Shift the cell by whole world turns to cover repeats.
val dLon = turn * 360.0
if (cell.lonRight + dLon <= leftLon || cell.lonLeft + dLon >= rightLon) continue
val yTop = projectionToY(projection, cell.latTop) ?: continue
val yBottom = projectionToY(projection, cell.latBottom) ?: continue
val xLeft = projectionToX(projection, cell.lonLeft + dLon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, cell.lonRight + dLon, centerLon, worldWidthPx) ?: continue
val xLeftBase = projectionToX(projection, cell.lonLeft, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, cell.lonRight, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
if (yBottom < 0f || yTop > canvas.height) continue
canvas.drawRect(xLeft, yTop, xRight, yBottom, workedPaint)
@@ -173,20 +182,44 @@ class MaidenheadGridOverlay : Overlay() {
}
}
// Vertical lines (meridians)
// Vertical lines (meridians). Worked-grid cells are NOT excluded:
// the boundary lines between adjacent worked squares must stay visible
// so the grid structure remains readable (reverted 2026-09-09 — the
// earlier skip logic removed those shared edges entirely).
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val x = projectionToX(projection, lon, centerLon, worldWidthPx)
if (x == null) continue
canvas.drawLine(x, 0f, x, canvas.height.toFloat(), linePaint)
// World-repeat copies: keep the turn offset in pixels, otherwise
// every turn normalizes to the same x and the line paints over
// itself 2*colRepeats+1 times (alpha stacking).
val xBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val x = xBase + turn * worldWidthPx.toFloat()
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat < -90.0 || lat >= 90.0) continue
val topLatCell = lat + cellLat
if (topLatCell > 90.0) continue
val yTop = projectionToY(projection, topLatCell) ?: continue
val yBottom = projectionToY(projection, lat) ?: continue
canvas.drawLine(x, yTop, x, yBottom, linePaint)
}
}
// Horizontal lines (parallels)
// Horizontal lines (parallels).
for (row in firstRow..lastRow) {
val lat = row * cellLat
if (lat <= -90.0 || lat >= 90.0) continue
val y = projectionToY(projection, lat)
if (y == null) continue
canvas.drawLine(0f, y, canvas.width.toFloat(), y, linePaint)
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
// World-repeat copies: keep the turn offset in pixels (same
// alpha-stacking fix as the vertical lines above).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
canvas.drawLine(xLeft, y, xRight, y, linePaint)
}
}
// The station's own grid square: redraw its four borders thicker on top.
@@ -241,8 +274,12 @@ class MaidenheadGridOverlay : Overlay() {
if (yBottom < 0f || yTop > canvas.height) continue
for (turn in -colRepeats..colRepeats) for (col in firstCol..lastCol) {
val lon = col * cellLon
val xLeft = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRight = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
// World-repeat copies: keep the turn offset in pixels (same
// alpha-stacking fix as the lines above).
val xLeftBase = projectionToX(projection, lon, centerLon, worldWidthPx) ?: continue
val xRightBase = projectionToX(projection, lon + cellLon, centerLon, worldWidthPx) ?: continue
val xLeft = xLeftBase + turn * worldWidthPx.toFloat()
val xRight = xRightBase + turn * worldWidthPx.toFloat()
if (xRight < 0f || xLeft > canvas.width) continue
val label = cellLabel(lat, lon, zoom)
canvas.drawText(label, (xLeft + xRight) / 2f, yCenter, labelPaint)
@@ -329,8 +366,8 @@ class MaidenheadGridOverlay : Overlay() {
// on-screen size of each square.
const val GRID_ZOOM_SUB = 6.0
// Zoom at which the 4-char sub-square names appear (one level above the
// grid lines: zoom 6 = lines only, zoom 7+ = lines + names).
const val LABEL_ZOOM_SUB = 7.0
// grid lines: zoom 6 = lines only, zoom 6.5+ = lines + names).
const val LABEL_ZOOM_SUB = 6.5
const val MIN_LABEL_CELL_PX = 48f
const val MAX_OVERSHOOT_PX = 64
}
@@ -35,12 +35,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -52,6 +59,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.stringResource
import androidx.compose.ui.unit.dp
@@ -66,6 +75,9 @@ 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.AwardCalculator
import com.rtbishop.look4sat.core.domain.model.AwardProgress
import com.rtbishop.look4sat.core.domain.model.AwardType
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -145,6 +157,13 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
// Tapped worked grid -> centered QSO dialog. Local UI state: the map is the
// only consumer and it resets when leaving the page.
var selectedGrid by remember { mutableStateOf<String?>(null) }
// Selected award filter (null = "All" = plain worked-grid view).
var selectedAward by remember { mutableStateOf<AwardType?>(null) }
// 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)
}
// Attach the tap listener whenever grid mode / worked grids change.
val workedGrids = uiState.workedGrids
val isGridMode = uiState.isGridMode
@@ -153,7 +172,9 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
override fun singleTapConfirmedHelper(p: GeoPoint?): Boolean {
if (!isGridMode || p == null) return false
val zoom = mapView.zoomLevelDouble
if (zoom < MaidenheadGridOverlay.LABEL_ZOOM_SUB) return false
// Allow tapping worked cells as soon as the 4-char grid LINES
// appear (GRID_ZOOM_SUB), not only when labels show (LABEL_ZOOM_SUB).
if (zoom < MaidenheadGridOverlay.GRID_ZOOM_SUB) return false
val grid = gridOfPoint(p.latitude, p.longitude) ?: return false
if (grid !in workedGrids) return false
selectedGrid = grid
@@ -182,7 +203,15 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
}
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout()
if (isVertical) {
if (isGridMode) {
// Grid mode: replace the satellite-selection top bar with the
// award filter chips (All / DXCC / VUCC / WAPC / WAJA / WAZ / WAS).
AwardChipsRow(
progress = awardProgress,
selected = selectedAward,
onSelect = { selectedAward = it }
)
} else if (isVertical) {
TopBar {
IconCard(action = { onAction(MapAction.SelectPrev) }, resId = R.drawable.ic_arrow, modifier = rotateMod)
TimerRow(timeString = timeString, isTimeAos = isTimeAos)
@@ -203,17 +232,29 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
// the map page with grid mode already ON also centers the map.
var prevGridMode by remember { mutableStateOf(false) }
AndroidView({ mapView }) { view ->
// Center on the station grid whenever entering grid mode.
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
val shouldCenter = uiState.isGridMode && !prevGridMode
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
if (shouldCenter) uiState.stationPosition else null
)
if (!shouldCenter || uiState.stationPosition != null) {
// Award filter mode: show the selected award's regions with
// worked ones filled green; hide satellite layers like grid mode.
val awardMode = selectedAward?.takeIf { it != AwardType.VUCC }
if (uiState.isGridMode && awardMode != null) {
setAwardMode(
awardMode,
awardProgress.firstOrNull { it.type == awardMode }?.workedKeys.orEmpty(),
view
)
prevGridMode = uiState.isGridMode
} else {
// Center on the station grid whenever entering grid mode.
// Only mark the transition as consumed once a real position
// was available; otherwise a first frame with a null
// stationPosition would swallow the centering forever.
val shouldCenter = uiState.isGridMode && !prevGridMode
setGridMode(
uiState.isGridMode, uiState.workedGrids, view,
if (shouldCenter) uiState.stationPosition else null
)
if (!shouldCenter || uiState.stationPosition != null) {
prevGridMode = uiState.isGridMode
}
}
if (!uiState.isGridMode) {
uiState.stationPosition?.let { setStationPosition(it, view) }
@@ -231,6 +272,13 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
}
}
GridModeToggle(
isGridMode = uiState.isGridMode,
onToggle = { onAction(MapAction.ToggleGridMode(it)) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
)
}
}
}
@@ -345,12 +393,13 @@ private fun WorkedGridCallRow(
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 3.dp)
)
// Subsequent QSOs in this grid, revealed on expand.
// Subsequent QSOs in this grid, revealed on expand. No ordinal prefix;
// each line carries its own date (the first QSO's date already sits in
// the header row).
if (expanded && callQsos.size > 1) {
callQsos.drop(1).forEachIndexed { index, qso ->
val ordinal = "${index + 2}"
callQsos.drop(1).forEach { qso ->
Text(
text = "$ordinal " + qsoSummary(qso, isUtc),
text = formatDate(qso.epochMs, isUtc) + " · " + qsoSummary(qso, isUtc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 3.dp)
@@ -440,7 +489,8 @@ private fun MapDataCards(data: MapData) {
Text(text = stringResource(R.string.map_elevation, data.elevation))
}
}
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd)) {
// Top-end card is offset down to make room for the grid-mode toggle pill.
Card(colors = cardColors, modifier = Modifier.align(Alignment.TopEnd).padding(top = 60.dp)) {
Column(horizontalAlignment = Alignment.End, modifier = paddingMod) {
Text(text = stringResource(R.string.map_altitude, data.altitude), color = textColor)
Text(text = stringResource(R.string.map_distance, data.range))
@@ -467,8 +517,127 @@ private fun MapDataCards(data: MapData) {
}
// endregion
// region Grid mode toggle pill
/**
* Award filter chips row shown in grid mode (replaces the satellite-selection
* top bar). "All" = plain worked-grid view; each award chip carries its live
* progress (count/target) and selecting it switches the map to the award's
* boundary view.
*/
@Composable
private fun AwardChipsRow(
progress: List<AwardProgress>,
selected: AwardType?,
onSelect: (AwardType?) -> Unit
) {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
item {
FilterChip(
selected = selected == null,
onClick = { onSelect(null) },
label = {
Text(
text = stringResource(R.string.map_award_all),
fontSize = 12.sp
)
},
colors = FilterChipDefaults.filterChipColors()
)
}
items(progress) { p ->
FilterChip(
selected = selected == p.type,
onClick = { onSelect(p.type) },
label = {
Text(
text = "${p.type.name} ${p.count}/${p.target}",
fontSize = 12.sp
)
},
colors = FilterChipDefaults.filterChipColors()
)
}
}
}
/**
* 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 GridModeToggle(
isGridMode: Boolean,
onToggle: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = ComposeColor.Black.copy(alpha = 0.45f),
shape = RoundedCornerShape(percent = 50),
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 2.dp, bottom = 2.dp)
) {
Text(
text = stringResource(
if (isGridMode) R.string.map_grid_mode else R.string.map_satellite_mode
),
color = ComposeColor.White,
fontSize = 12.sp,
modifier = Modifier.padding(end = 2.dp)
)
Switch(
checked = isGridMode,
onCheckedChange = onToggle,
modifier = Modifier.scale(0.75f)
)
}
}
}
// endregion
// region Map overlay helpers
/**
* Switches the grid-slot overlay to the award boundary view for the given
* award: loads the boundary asset once (cached), paints worked regions green.
* Satellite-related layers are hidden exactly like grid mode. VUCC keeps the
* plain Maidenhead grid overlay (the grid view IS the VUCC view).
*/
private fun setAwardMode(award: AwardType, workedCodes: Set<String>, mapView: MapView) {
val asset = when (award) {
AwardType.WAPC -> AwardBoundaryData.AwardAsset.WAPC
AwardType.WAJA -> AwardBoundaryData.AwardAsset.WAJA
AwardType.WAZ -> AwardBoundaryData.AwardAsset.WAZ
AwardType.WAS -> AwardBoundaryData.AwardAsset.WAS
AwardType.DXCC -> AwardBoundaryData.AwardAsset.DXCC
AwardType.VUCC -> return
}
val overlay = mapView.overlays.getOrNull(OVERLAY_GRID)
if (overlay is AwardBoundaryOverlay) {
overlay.isEnabled = true
overlay.regions = AwardBoundaryData.load(mapView.context, asset)
overlay.workedCodes = workedCodes
} else {
mapView.overlays[OVERLAY_GRID] = AwardBoundaryOverlay().apply {
isEnabled = true
regions = AwardBoundaryData.load(mapView.context, asset)
this.workedCodes = workedCodes
}
}
for (index in OVERLAY_STATION..OVERLAY_MOON) {
mapView.overlays.getOrNull(index)?.isEnabled = false
}
}
/** Toggle the grid-mode layer visibility on/off without recreating any overlay. */
private fun setGridMode(
gridMode: Boolean,
@@ -503,10 +672,14 @@ private fun setGridMode(
val pos = stationPosition ?: return
val lat = pos.latitude
val lon = pos.longitude
// Maidenhead field indices: field 0 spans -90..-80° lat and
// -180..-160° lon, so the field-center must be offset by those
// bases — omitting them maps e.g. OL62's field center to
// 115°N/290°E (an ocean a hemisphere away) instead of 25°N/110°E.
val fieldLat = ((lat + 90.0) / 10.0).toInt().coerceIn(0, 17)
val fieldLon = ((lon + 180.0) / 20.0).toInt().coerceIn(0, 17)
val centerLat = fieldLat * 10.0 + 5.0
val centerLon = fieldLon * 20.0 + 10.0
val centerLat = fieldLat * 10.0 + 5.0 - 90.0
val centerLon = fieldLon * 20.0 + 10.0 - 180.0
mapView.post { mapView.controller.setCenter(GeoPoint(centerLat, centerLon)) }
}
} catch (e: Exception) {
@@ -45,6 +45,7 @@ sealed interface MapAction {
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
data class ToggleGridMode(val value: Boolean) : MapAction
}
data class MapData(
@@ -98,6 +98,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.ToggleGridMode -> settingsRepo.updateOtherSettings { it.copy(stateOfMapGrid = action.value) }
}
}
@@ -201,18 +201,47 @@ class SettingsViewModel(
settingsRepo.updateLoTWSettings(settings)
_uiState.update { it.copy(lotwSyncing = true, lotwMessage = null) }
viewModelScope.launch {
val lotwResult = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)
_uiState.update { state ->
if (lotwResult == null) {
state.copy(lotwSyncing = false, lotwMessage = "LoTW sync failed — check callsign/password/network")
} else {
when (val result = lotwRepo.fetchConfirmedGridQsos(settings.callsign, settings.password)) {
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Success -> {
// Wavelog entry removed: LoTW is now the only source, so the
// synced set fully replaces the stored worked grids.
val (grids, qsos) = lotwResult
settingsRepo.setWorkedGrids(grids)
settingsRepo.setWorkedGridQsos(qsos)
state.copy(lotwSyncing = false, workedGridsCount = grids.size, lotwMessage = null)
settingsRepo.setWorkedGrids(result.grids)
settingsRepo.setWorkedGridQsos(result.qsos)
_uiState.update { state ->
state.copy(
lotwSyncing = false, workedGridsCount = result.grids.size,
lotwMessage = null
)
}
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.BadCredentials ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — callsign or password incorrect"
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.RateLimited ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — rate limited by server, wait a few minutes and retry"
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.Timeout ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — connection timed out, try another network"
)
}
is com.rtbishop.look4sat.core.domain.repository.LoTWResult.NetworkError ->
_uiState.update { state ->
state.copy(
lotwSyncing = false,
lotwMessage = "LoTW sync failed — network error (${result.detail})"
)
}
}
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
[versions]
#noinspection UnusedVersionCatalogEntry
appVersionCode = "459"
appVersionCode = "464"
#noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.6-ba7opf.9.8"
appVersionName = "4.4.6-ba7opf.9.13"
#noinspection UnusedVersionCatalogEntry
compileSdk = "37"
#noinspection UnusedVersionCatalogEntry