mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-08 14:38:36 +01:00
Apply live beacon settings and improve station map (#164)
Apply beacon checkbox changes at runtime and keep the aggregate status chip synchronized. Improve explicit station-map navigation, spatial filter overlays, overlay legend, selected-path contrast, and the documented map screenshot.
This commit is contained in:
1 parent
4d96822c0c
commit
d925c83412
61 files changed
+2686
-299
No files matched your search
@@ -319,6 +319,9 @@ map.showPathAnalysis=Streckenanalyse zeigen
|
||||
map.hidePathAnalysis=Streckenanalyse ausblenden
|
||||
map.pathAnalysisHidden=Die Streckenanalyse ist ausgeblendet.
|
||||
map.tilesUnavailable=Kartenkacheln nicht verfügbar — die Netzverbindung prüfen.
|
||||
map.overlayAntenna=Antenne
|
||||
map.overlayFilters=Aktive Filter
|
||||
map.overlayLegendTooltip=Rot: tatsächliche Antennenrichtung, Öffnungswinkel und maximaler QRB. Blau: aktive QTF- und QRB-Filter.
|
||||
path.title=Strecken- und Geländeanalyse
|
||||
path.selectStation=Eine Station wählen, um Strecken- und Geländeanalyse vorzubereiten.
|
||||
path.fromLocator=Vom Locator:
|
||||
|
||||
@@ -328,6 +328,9 @@ map.showPathAnalysis=Show path analysis
|
||||
map.hidePathAnalysis=Hide path analysis
|
||||
map.pathAnalysisHidden=Path analysis is hidden.
|
||||
map.tilesUnavailable=Map tiles unavailable — check the network connection.
|
||||
map.overlayAntenna=Antenna
|
||||
map.overlayFilters=Active filters
|
||||
map.overlayLegendTooltip=Red: actual antenna heading, beamwidth and maximum QRB. Blue: active QTF and QRB filters.
|
||||
path.title=Path / terrain analysis
|
||||
path.selectStation=Select a station to prepare path and terrain analysis.
|
||||
path.fromLocator=From locator:
|
||||
|
||||
@@ -3278,6 +3278,16 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
}),
|
||||
newJoined,
|
||||
reachability);
|
||||
composeMainWindowState.getThreadButtons().registerAction(
|
||||
ChatController.BEACON_STATUS_SOURCE,
|
||||
() -> {
|
||||
chatcontroller.toggleUsableBeacons();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
});
|
||||
composeMainWindowState.getThreadButtons().registerStatusProvider(
|
||||
ChatController.BEACON_STATUS_SOURCE,
|
||||
chatcontroller::currentBeaconStatus);
|
||||
chatcontroller.refreshBeaconStatus();
|
||||
|
||||
/*
|
||||
* The feed, not the window construction, owns the timeline's data from here.
|
||||
@@ -4136,6 +4146,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void refreshMapDisplaySettings() {
|
||||
uiDispatcher.runOnUi(() -> {
|
||||
if (composeMainWindowState == null) {
|
||||
return;
|
||||
}
|
||||
composeMainWindowState.refreshMapDisplaySettings();
|
||||
forceChatMemberFilterRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op since the JavaFX own-QRG text field was removed with the never-shown window.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.runtime.setValue
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import kst4contest.view.compose.map.MapDisplaySettingsState
|
||||
import kst4contest.view.compose.map.MapNavigationState
|
||||
|
||||
/**
|
||||
* Everything the main window draws, in one object.
|
||||
@@ -67,6 +69,19 @@ class MainWindowState(
|
||||
val reachability: ReachabilityState = ReachabilityState(activeBands),
|
||||
) {
|
||||
|
||||
internal val mapNavigation = MapNavigationState()
|
||||
|
||||
internal val mapDisplaySettings = MapDisplaySettingsState(prefs)
|
||||
|
||||
init {
|
||||
stationSelection.explicitSelectionListener = { member, source ->
|
||||
when {
|
||||
source == StationSelectionSource.RESET_VIEW -> mapNavigation.resetToHome()
|
||||
member != null -> StationIdentity.of(member)?.let(mapNavigation::fitSelectedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit map analysis requested from either station context menu. */
|
||||
val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty())
|
||||
|
||||
@@ -111,9 +126,15 @@ class MainWindowState(
|
||||
|
||||
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
|
||||
fun applyExplicitQtfFilterDirection(qtf: Double) {
|
||||
mapNavigation.centerOnQtf(qtf)
|
||||
applyExplicitQtfHeading(prefs, qtf)
|
||||
}
|
||||
|
||||
/** Refreshes map geometry after the Station settings draft has been applied. */
|
||||
fun refreshMapDisplaySettings() {
|
||||
mapDisplaySettings.refreshFromPreferences()
|
||||
}
|
||||
|
||||
fun requestLayoutSave() {
|
||||
layoutSaveRequested?.invoke()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.observe.ObservableValue
|
||||
import java.util.function.Consumer
|
||||
@@ -35,3 +39,19 @@ class ObservedValue<T>(private val source: ObservableValue<T>) {
|
||||
source.removeListener(follower)
|
||||
}
|
||||
}
|
||||
|
||||
/** Observes a domain value only for the lifetime of the current composition. */
|
||||
@Composable
|
||||
internal fun <T> ObservableValue<T>.observeAsState(): State<T?> {
|
||||
val state = remember(this) { mutableStateOf(get()) }
|
||||
|
||||
DisposableEffect(this) {
|
||||
val follower = Consumer<T> { updated -> state.value = updated }
|
||||
addListener(follower)
|
||||
// Close the gap between the initial read and listener registration.
|
||||
state.value = get()
|
||||
onDispose { removeListener(follower) }
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -62,6 +62,9 @@ interface SettingsHost {
|
||||
*/
|
||||
fun refreshSo2rState()
|
||||
|
||||
/** Pushes applied antenna-beam geometry into the open Compose map. */
|
||||
fun refreshMapDisplaySettings()
|
||||
|
||||
/** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
|
||||
fun refreshTextSnippetContextMenusFromSettings()
|
||||
|
||||
@@ -148,6 +151,8 @@ fun buildSettingsTabs(
|
||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||
connectionState = connectionState,
|
||||
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
|
||||
refreshBeaconStatus = controller::refreshBeaconStatus,
|
||||
applyMapDisplaySettings = host::refreshMapDisplaySettings,
|
||||
)
|
||||
|
||||
val notificationState = NotificationTabState(
|
||||
@@ -185,6 +190,8 @@ fun buildSettingsTabs(
|
||||
draft,
|
||||
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
||||
restartBeaconTimer = { controller.restartBeaconTimer() },
|
||||
setMainBeaconEnabled = controller::setMainBeaconEnabled,
|
||||
setSecondBeaconEnabled = controller::setSecondBeaconEnabled,
|
||||
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
||||
secondCategoryName = categoryName(controller.chatCategorySecondChat, kst4contest.view.i18n.CurrentStrings.get().settingsCategorySecondFallback),
|
||||
)
|
||||
|
||||
@@ -90,6 +90,13 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
|
||||
private set
|
||||
|
||||
/** Changes only for deliberate station choices, including repeated choices. */
|
||||
var explicitSelectionRevision: Long by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
/** Receives deliberate station choices without observing roster reconciliation. */
|
||||
internal var explicitSelectionListener: ((ChatMember?, StationSelectionSource) -> Unit)? = null
|
||||
|
||||
val identity: StationIdentity?
|
||||
get() = StationIdentity.of(current)
|
||||
|
||||
@@ -101,6 +108,10 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
current = row
|
||||
lastSource = source
|
||||
selectionRevision++
|
||||
if (source.isExplicitSelection()) {
|
||||
explicitSelectionRevision++
|
||||
explicitSelectionListener?.invoke(row, source)
|
||||
}
|
||||
if (row != null && source.requestsStationTableScroll()) {
|
||||
scrollRequestRevision++
|
||||
}
|
||||
@@ -118,6 +129,11 @@ class StationSelectionState : TableSelectionModel<ChatMember> {
|
||||
}
|
||||
}
|
||||
|
||||
private fun StationSelectionSource.isExplicitSelection(): Boolean = when (this) {
|
||||
StationSelectionSource.PROGRAMMATIC -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
|
||||
StationSelectionSource.STATION_TABLE,
|
||||
StationSelectionSource.STATION_CONTEXT_MENU,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
@@ -25,6 +26,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -39,11 +41,9 @@ import kst4contest.controller.On4KstConnectionState
|
||||
* only the indicators are left here — which is also what finally makes the bar wrap
|
||||
* properly on a narrow window instead of pushing the badges out of sight.
|
||||
*
|
||||
* Nothing in this bar is clickable. The three indicators were mouse-transparent and
|
||||
* non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
|
||||
* status badge must never take the keyboard. The background-thread buttons were ordinary
|
||||
* focusable Buttons there, with no action attached — they are drawn as plain labels here,
|
||||
* which is what they always behaved like.
|
||||
* The connection and notice indicators remain non-clickable. A background-thread status is
|
||||
* actionable only when the application explicitly registers an action for that source; the
|
||||
* beacon uses this for its quick switch, while every other worker remains a plain label.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -206,10 +206,16 @@ private fun ThreadStatusButtonView(button: ThreadStatusButton) {
|
||||
val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
|
||||
|
||||
TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
|
||||
val actionModifier = if (button.hasAction) {
|
||||
Modifier.clickable(role = Role.Button) { button.activate() }
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minHeight = 22.dp)
|
||||
.background(background, NOTICE_SHAPE)
|
||||
.then(actionModifier)
|
||||
.padding(NOTICE_PADDING),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
@@ -176,20 +176,34 @@ object StatusIndicators {
|
||||
class ThreadStatusButtons {
|
||||
|
||||
private val bySource = LinkedHashMap<String, ThreadStatusButton>()
|
||||
private val actions = LinkedHashMap<String, () -> Unit>()
|
||||
private val statusProviders = LinkedHashMap<String, () -> ThreadStateMessage>()
|
||||
private val ordered = mutableStateListOf<ThreadStatusButton>()
|
||||
|
||||
val buttons: List<ThreadStatusButton> get() = ordered
|
||||
|
||||
fun update(sourceName: String, message: ThreadStateMessage) {
|
||||
val effectiveMessage = statusProviders[sourceName]?.invoke() ?: message
|
||||
val button = bySource.getOrPut(sourceName) {
|
||||
ThreadStatusButton(sourceName).also { ordered.add(it) }
|
||||
ThreadStatusButton(sourceName, actions[sourceName]).also { ordered.add(it) }
|
||||
}
|
||||
button.report(
|
||||
label = "$sourceName: ${message.runningInformationTextDescription}",
|
||||
tooltip = message.runningInformation.orEmpty(),
|
||||
label = "$sourceName: ${effectiveMessage.runningInformationTextDescription}",
|
||||
tooltip = effectiveMessage.runningInformation.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Gives one status source an explicit action without changing any other source. */
|
||||
fun registerAction(sourceName: String, action: () -> Unit) {
|
||||
actions[sourceName] = action
|
||||
bySource[sourceName]?.setAction(action)
|
||||
}
|
||||
|
||||
/** Resolves a status only when its queued UI update is actually applied. */
|
||||
fun registerStatusProvider(sourceName: String, provider: () -> ThreadStateMessage) {
|
||||
statusProviders[sourceName] = provider
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
|
||||
const val HIGHLIGHT_MILLIS = 200L
|
||||
@@ -197,7 +211,10 @@ class ThreadStatusButtons {
|
||||
}
|
||||
|
||||
/** One background thread's button. */
|
||||
class ThreadStatusButton(val sourceName: String) {
|
||||
class ThreadStatusButton(
|
||||
val sourceName: String,
|
||||
initialAction: (() -> Unit)? = null,
|
||||
) {
|
||||
|
||||
var label: String by mutableStateOf(sourceName)
|
||||
private set
|
||||
@@ -212,6 +229,12 @@ class ThreadStatusButton(val sourceName: String) {
|
||||
var revision: Int by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
var action: (() -> Unit)? by mutableStateOf(initialAction)
|
||||
private set
|
||||
|
||||
val hasAction: Boolean
|
||||
get() = action != null
|
||||
|
||||
internal fun report(label: String, tooltip: String) {
|
||||
this.label = label
|
||||
this.tooltip = tooltip
|
||||
@@ -222,4 +245,15 @@ class ThreadStatusButton(val sourceName: String) {
|
||||
fun clearHighlight() {
|
||||
highlighted = false
|
||||
}
|
||||
|
||||
internal fun setAction(action: () -> Unit) {
|
||||
this.action = action
|
||||
}
|
||||
|
||||
/** Runs the registered action and reports whether this status is actionable. */
|
||||
fun activate(): Boolean {
|
||||
val current = action ?: return false
|
||||
current()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,15 @@ fun ComposeStationMap(
|
||||
antennaAzimuthDeg: Double,
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
selectedTarget: MapCallsignRawSnapshot? = markers.firstOrNull {
|
||||
it.callSignRaw() == selectedMarkerCallsign
|
||||
},
|
||||
spatialFilterOverlay: SpatialFilterOverlayState? = null,
|
||||
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
|
||||
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
onCanvasSizeChanged: (Size) -> Unit = { },
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val imageCache = remember { TileBitmapCache() }
|
||||
@@ -147,6 +152,13 @@ fun ComposeStationMap(
|
||||
* one linear pass over a few hundred markers.
|
||||
*/
|
||||
val home = remember(ownLocator6) { homePosition(ownLocator6) }
|
||||
val selectedTargetOutsideMarkers by remember(markers, selectedTarget) {
|
||||
derivedStateOf {
|
||||
selectedTarget?.takeIf { target ->
|
||||
target.hasUsablePosition() && markers.none { it.callSignRaw() == target.callSignRaw() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val clusterResult by remember(markers, groupingEnabled, canvasSize) {
|
||||
derivedStateOf {
|
||||
StationMapClusterer.cluster(
|
||||
@@ -205,17 +217,28 @@ fun ComposeStationMap(
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
|
||||
.onSizeChanged {
|
||||
canvasSize = Size(it.width.toFloat(), it.height.toFloat())
|
||||
onCanvasSizeChanged(canvasSize)
|
||||
}
|
||||
.onGloballyPositioned { canvasCoordinates = it }
|
||||
.onPointerEvent(PointerEventType.Press) { event ->
|
||||
if (event.buttons.isSecondaryPressed) {
|
||||
val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent
|
||||
val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height)
|
||||
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
|
||||
val selectedOutside = selectedTargetOutsideMarkers?.takeIf { marker ->
|
||||
within(
|
||||
local,
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
val cluster = result.clusters().firstOrNull { candidate ->
|
||||
within(
|
||||
local,
|
||||
viewport.screenX(candidate.centerLon).toFloat(),
|
||||
viewport.screenXWrapped(candidate.centerLon).toFloat(),
|
||||
viewport.screenY(candidate.centerLat).toFloat(),
|
||||
clusterRadiusPx(candidate.size()),
|
||||
)
|
||||
@@ -223,12 +246,14 @@ fun ComposeStationMap(
|
||||
val individual = result.individual().firstOrNull { marker ->
|
||||
within(
|
||||
local,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
val targets = cluster?.getMarkers() ?: individual?.let(::listOf)
|
||||
val targets = selectedOutside?.let(::listOf)
|
||||
?: cluster?.getMarkers()
|
||||
?: individual?.let(::listOf)
|
||||
val window = canvasCoordinates?.localToWindow(local)
|
||||
if (!targets.isNullOrEmpty() && window != null) {
|
||||
onMarkerContextRequested?.invoke(
|
||||
@@ -281,15 +306,28 @@ fun ComposeStationMap(
|
||||
}
|
||||
}
|
||||
}
|
||||
.pointerInput(markers, groupingEnabled, onMarkerClicked) {
|
||||
.pointerInput(markers, selectedTargetOutsideMarkers, groupingEnabled, onMarkerClicked) {
|
||||
detectTapGestures { offset ->
|
||||
val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
|
||||
val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
|
||||
|
||||
val selectedOutside = selectedTargetOutsideMarkers?.takeIf { marker ->
|
||||
within(
|
||||
offset,
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
if (selectedOutside != null) {
|
||||
onMarkerClicked?.invoke(selectedOutside)
|
||||
return@detectTapGestures
|
||||
}
|
||||
|
||||
val hitCluster = result.clusters().firstOrNull { cluster ->
|
||||
within(
|
||||
offset,
|
||||
viewport.screenX(cluster.centerLon).toFloat(),
|
||||
viewport.screenXWrapped(cluster.centerLon).toFloat(),
|
||||
viewport.screenY(cluster.centerLat).toFloat(),
|
||||
clusterRadiusPx(cluster.size()),
|
||||
)
|
||||
@@ -312,14 +350,12 @@ fun ComposeStationMap(
|
||||
val hit = result.individual().firstOrNull { marker ->
|
||||
within(
|
||||
offset,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
if (hit != null) {
|
||||
onMarkerClicked?.invoke(hit)
|
||||
}
|
||||
if (hit != null) onMarkerClicked?.invoke(hit)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -335,11 +371,34 @@ fun ComposeStationMap(
|
||||
drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
|
||||
|
||||
drawTiles(viewport, imageCache, darkMode)
|
||||
if (home != null && spatialFilterOverlay != null) {
|
||||
drawSpatialFilterOverlay(
|
||||
viewport = viewport,
|
||||
homeLatitudeDeg = home.first,
|
||||
homeLongitudeDeg = home.second,
|
||||
state = spatialFilterOverlay,
|
||||
darkMode = darkMode,
|
||||
)
|
||||
}
|
||||
drawMaidenheadGrid(viewport, textMeasurer, darkMode)
|
||||
drawWorkedGrossFields(viewport, workedGrossFields)
|
||||
drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
|
||||
drawBeamAndPath(
|
||||
viewport,
|
||||
home,
|
||||
antennaAzimuthDeg,
|
||||
antennaBeamWidthDeg,
|
||||
maxQrbKm,
|
||||
selectedTarget,
|
||||
darkMode,
|
||||
)
|
||||
drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
|
||||
drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
|
||||
selectedTargetOutsideMarkers?.let { target ->
|
||||
val x = viewport.screenXWrapped(target.longitudeDeg()).toFloat()
|
||||
val y = viewport.screenY(target.latitudeDeg()).toFloat()
|
||||
drawStationDot(x, y, selected = true, worked = target.worked(), warning = target.warningToMyDirection())
|
||||
drawStationLabel(x, y, target.markerLabel(), target.warningToMyDirection(), textMeasurer, darkMode)
|
||||
}
|
||||
drawHover(viewport, hoverPosition, clusterResult, selectedTargetOutsideMarkers, textMeasurer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,8 +533,8 @@ private fun DrawScope.drawMaidenheadGrid(
|
||||
val verticals = sortedSetOf<Float>()
|
||||
val horizontals = sortedSetOf<Float>()
|
||||
for (cell in cells) {
|
||||
verticals.add(viewport.screenX(cell.westLon()).toFloat())
|
||||
verticals.add(viewport.screenX(cell.eastLon()).toFloat())
|
||||
verticals.add(viewport.screenXWrapped(cell.westLon()).toFloat())
|
||||
verticals.add(viewport.screenXWrapped(cell.eastLon()).toFloat())
|
||||
horizontals.add(viewport.screenY(cell.northLat()).toFloat())
|
||||
horizontals.add(viewport.screenY(cell.southLat()).toFloat())
|
||||
}
|
||||
@@ -516,7 +575,7 @@ private fun DrawScope.drawMaidenheadGrid(
|
||||
}
|
||||
|
||||
/* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
|
||||
val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
|
||||
val centerX = viewport.screenXWrapped((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
|
||||
val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
|
||||
|
||||
val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
|
||||
@@ -540,8 +599,7 @@ private fun DrawScope.drawBeamAndPath(
|
||||
antennaAzimuthDeg: Double,
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
markers: List<MapCallsignRawSnapshot>,
|
||||
selectedMarkerCallsign: String?,
|
||||
selectedTarget: MapCallsignRawSnapshot?,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
if (home == null) {
|
||||
@@ -549,32 +607,40 @@ private fun DrawScope.drawBeamAndPath(
|
||||
}
|
||||
val (homeLat, homeLon) = home
|
||||
|
||||
if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
|
||||
if (isAntennaSectorDrawable(home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)) {
|
||||
val polygon = Path()
|
||||
buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
|
||||
.forEachIndexed { index, point ->
|
||||
val x = viewport.screenX(point.second).toFloat()
|
||||
val x = viewport.screenXWrapped(point.second).toFloat()
|
||||
val y = viewport.screenY(point.first).toFloat()
|
||||
if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
|
||||
}
|
||||
polygon.close()
|
||||
|
||||
drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
|
||||
drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
|
||||
drawPath(path = polygon, color = ANTENNA_OVERLAY_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
|
||||
drawPath(path = polygon, color = ANTENNA_OVERLAY_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
|
||||
}
|
||||
|
||||
val selected = markers.firstOrNull {
|
||||
it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
|
||||
}
|
||||
val selected = selectedTarget?.takeIf(MapCallsignRawSnapshot::hasUsablePosition)
|
||||
if (selected != null) {
|
||||
val start = Offset(viewport.screenXWrapped(homeLon).toFloat(), viewport.screenY(homeLat).toFloat())
|
||||
val end = Offset(
|
||||
viewport.screenXWrapped(selected.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(selected.latitudeDeg()).toFloat(),
|
||||
)
|
||||
drawLine(
|
||||
color = if (darkMode) CONNECTION_HALO_DARK else CONNECTION_HALO_LIGHT,
|
||||
alpha = CONNECTION_HALO_ALPHA,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = CONNECTION_HALO_WIDTH_PX,
|
||||
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
|
||||
)
|
||||
drawLine(
|
||||
color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
|
||||
alpha = CONNECTION_ALPHA,
|
||||
start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
|
||||
end = Offset(
|
||||
viewport.screenX(selected.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(selected.latitudeDeg()).toFloat(),
|
||||
),
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = CONNECTION_STROKE_WIDTH_PX,
|
||||
pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
|
||||
)
|
||||
@@ -585,9 +651,9 @@ private fun DrawScope.drawBeamAndPath(
|
||||
* home is, and with the beam switched off there was none. A small dot is kept here
|
||||
* deliberately.
|
||||
*/
|
||||
val homeX = viewport.screenX(homeLon).toFloat()
|
||||
val homeX = viewport.screenXWrapped(homeLon).toFloat()
|
||||
val homeY = viewport.screenY(homeLat).toFloat()
|
||||
drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
|
||||
drawCircle(color = ANTENNA_OVERLAY_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
|
||||
drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
|
||||
}
|
||||
|
||||
@@ -602,7 +668,7 @@ private fun DrawScope.drawStations(
|
||||
) {
|
||||
for (cluster in clusterResult.clusters()) {
|
||||
drawClusterBubble(
|
||||
x = viewport.screenX(cluster.centerLon).toFloat(),
|
||||
x = viewport.screenXWrapped(cluster.centerLon).toFloat(),
|
||||
y = viewport.screenY(cluster.centerLat).toFloat(),
|
||||
count = cluster.size(),
|
||||
containsWorked = cluster.getMarkers().any { it.worked() },
|
||||
@@ -611,7 +677,7 @@ private fun DrawScope.drawStations(
|
||||
}
|
||||
|
||||
for (marker in clusterResult.individual()) {
|
||||
val x = viewport.screenX(marker.longitudeDeg()).toFloat()
|
||||
val x = viewport.screenXWrapped(marker.longitudeDeg()).toFloat()
|
||||
val y = viewport.screenY(marker.latitudeDeg()).toFloat()
|
||||
val selected = marker.callSignRaw() == selectedMarkerCallsign
|
||||
|
||||
@@ -729,14 +795,23 @@ private fun DrawScope.drawHover(
|
||||
viewport: MapViewport,
|
||||
hoverPosition: Offset?,
|
||||
clusterResult: StationMapClusterer.Result,
|
||||
selectedTargetOutsideMarkers: MapCallsignRawSnapshot?,
|
||||
textMeasurer: TextMeasurer,
|
||||
) {
|
||||
val position = hoverPosition ?: return
|
||||
|
||||
val selectedTargetUnderPointer = selectedTargetOutsideMarkers?.takeIf { marker ->
|
||||
within(
|
||||
position,
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
|
||||
within(
|
||||
position,
|
||||
viewport.screenX(cluster.centerLon).toFloat(),
|
||||
viewport.screenXWrapped(cluster.centerLon).toFloat(),
|
||||
viewport.screenY(cluster.centerLat).toFloat(),
|
||||
clusterRadiusPx(cluster.size()),
|
||||
)
|
||||
@@ -744,13 +819,14 @@ private fun DrawScope.drawHover(
|
||||
val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
|
||||
within(
|
||||
position,
|
||||
viewport.screenX(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenXWrapped(marker.longitudeDeg()).toFloat(),
|
||||
viewport.screenY(marker.latitudeDeg()).toFloat(),
|
||||
MARKER_HIT_RADIUS_PX,
|
||||
)
|
||||
}
|
||||
|
||||
val text = when {
|
||||
selectedTargetUnderPointer != null -> selectedTargetUnderPointer.tooltipText()
|
||||
|
||||
clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
|
||||
|
||||
markerUnderPointer != null -> markerUnderPointer.tooltipText()
|
||||
@@ -804,8 +880,8 @@ private fun DrawScope.drawWorkedGrossFields(
|
||||
workedGrossFields: Set<String>,
|
||||
) {
|
||||
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
|
||||
val left = viewport.screenX(cell.westLon()).toFloat()
|
||||
val right = viewport.screenX(cell.eastLon()).toFloat()
|
||||
val left = viewport.screenXWrapped(cell.westLon()).toFloat()
|
||||
val right = viewport.screenXWrapped(cell.eastLon()).toFloat()
|
||||
val top = viewport.screenY(cell.northLat()).toFloat()
|
||||
val bottom = viewport.screenY(cell.southLat()).toFloat()
|
||||
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
|
||||
@@ -863,7 +939,7 @@ private fun clusterFontSizePx(count: Int): Float = when {
|
||||
}
|
||||
|
||||
/** Null rather than a guess when the operator has no usable locator configured. */
|
||||
private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
|
||||
internal fun homePosition(ownLocator6: String): Pair<Double, Double>? {
|
||||
if (ownLocator6.length < 4) {
|
||||
return null
|
||||
}
|
||||
@@ -875,6 +951,15 @@ private fun homePosition(ownLocator6: String): Pair<Double, Double>? {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isAntennaSectorDrawable(
|
||||
home: Pair<Double, Double>?,
|
||||
antennaAzimuthDeg: Double,
|
||||
antennaBeamWidthDeg: Double,
|
||||
maxQrbKm: Double,
|
||||
): Boolean = home != null && antennaAzimuthDeg.isFinite() &&
|
||||
antennaBeamWidthDeg.isFinite() && antennaBeamWidthDeg > 0.0 &&
|
||||
maxQrbKm.isFinite() && maxQrbKm > 0.0
|
||||
|
||||
fun calculateDestinationPoint(
|
||||
startLatDeg: Double,
|
||||
startLonDeg: Double,
|
||||
@@ -982,16 +1067,20 @@ private const val CLUSTER_RADIUS_SMALL_PX = 16f
|
||||
private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
|
||||
private const val CLUSTER_RADIUS_LARGE_PX = 21f
|
||||
|
||||
private val BEAM_COLOR = Color(0xFFFF4D4D)
|
||||
internal val ANTENNA_OVERLAY_COLOR = Color(0xFFFF4D4D)
|
||||
private const val BEAM_FILL_ALPHA = 0.12f
|
||||
private const val BEAM_STROKE_WIDTH_PX = 2f
|
||||
private const val HOME_RADIUS_PX = 6f
|
||||
private const val HOME_INNER_RADIUS_PX = 2f
|
||||
|
||||
private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
|
||||
private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
|
||||
private const val CONNECTION_ALPHA = 0.85f
|
||||
private const val CONNECTION_STROKE_WIDTH_PX = 2f
|
||||
private val CONNECTION_COLOR_DARK = Color(0xFF52E7FF)
|
||||
private val CONNECTION_COLOR_LIGHT = Color(0xFF007A9E)
|
||||
private val CONNECTION_HALO_DARK = Color.White
|
||||
private val CONNECTION_HALO_LIGHT = Color.Black
|
||||
private const val CONNECTION_ALPHA = 0.98f
|
||||
private const val CONNECTION_HALO_ALPHA = 0.82f
|
||||
private const val CONNECTION_STROKE_WIDTH_PX = 3f
|
||||
private const val CONNECTION_HALO_WIDTH_PX = 5f
|
||||
private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
|
||||
|
||||
private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/** Compose-observable map geometry read from the active profile. */
|
||||
internal class MapDisplaySettingsState(private val prefs: ChatPreferences) {
|
||||
|
||||
var antennaBeamWidthDeg: Double by mutableStateOf(prefs.stn_antennaBeamWidthDeg)
|
||||
private set
|
||||
|
||||
var maxQrbKm: Double by mutableStateOf(prefs.stn_maxQRBDefault)
|
||||
private set
|
||||
|
||||
fun refreshFromPreferences() {
|
||||
antennaBeamWidthDeg = prefs.stn_antennaBeamWidthDeg
|
||||
maxQrbKm = prefs.stn_maxQRBDefault
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kst4contest.view.compose.StationIdentity
|
||||
|
||||
/** A deliberate operator action that may move the station-map camera. */
|
||||
internal sealed interface MapNavigationRequest {
|
||||
data class FitSelectedPath(val identity: StationIdentity) : MapNavigationRequest
|
||||
|
||||
data class CenterOnQtf(val qtfDegrees: Double) : MapNavigationRequest
|
||||
|
||||
data object ResetToHome : MapNavigationRequest
|
||||
}
|
||||
/**
|
||||
* The latest deliberate map-navigation request.
|
||||
*
|
||||
* Ordinary model refreshes never write this state. Keeping one monotonically increasing
|
||||
* revision makes repeated selections observable and gives the most recent station or QTF
|
||||
* action deterministic precedence when several actions happen before the map draws.
|
||||
*/
|
||||
internal class MapNavigationState {
|
||||
|
||||
var request: MapNavigationRequest? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var requestRevision: Long by mutableStateOf(0L)
|
||||
private set
|
||||
|
||||
fun fitSelectedPath(identity: StationIdentity) {
|
||||
publish(MapNavigationRequest.FitSelectedPath(identity))
|
||||
}
|
||||
|
||||
fun centerOnQtf(qtfDegrees: Double) {
|
||||
if (!qtfDegrees.isFinite() || qtfDegrees !in 0.0..360.0) return
|
||||
publish(MapNavigationRequest.CenterOnQtf(qtfDegrees))
|
||||
}
|
||||
|
||||
fun resetToHome() {
|
||||
publish(MapNavigationRequest.ResetToHome)
|
||||
}
|
||||
|
||||
private fun publish(next: MapNavigationRequest) {
|
||||
request = next
|
||||
requestRevision++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.TooltipArea
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.TooltipCard
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun MapOverlayLegend(
|
||||
showAntenna: Boolean,
|
||||
showSpatialFilters: Boolean,
|
||||
darkMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!showAntenna && !showSpatialFilters) return
|
||||
|
||||
val strings = LocalStrings.current
|
||||
TooltipArea(
|
||||
tooltip = { TooltipCard(strings.mapOverlayLegendTooltip) },
|
||||
modifier = modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showAntenna) {
|
||||
LegendItem(
|
||||
color = ANTENNA_OVERLAY_COLOR,
|
||||
label = strings.mapOverlayAntenna,
|
||||
tag = MAP_ANTENNA_LEGEND_TAG,
|
||||
)
|
||||
}
|
||||
if (showSpatialFilters) {
|
||||
LegendItem(
|
||||
color = spatialFilterOverlayLineColor(darkMode),
|
||||
label = strings.mapOverlayFilters,
|
||||
tag = MAP_FILTER_LEGEND_TAG,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendItem(color: Color, label: String, tag: String) {
|
||||
Row(
|
||||
modifier = Modifier.testTag(tag),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.size(9.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
Text(text = label, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MAP_ANTENNA_LEGEND_TAG = "station-map-antenna-legend"
|
||||
internal const val MAP_FILTER_LEGEND_TAG = "station-map-filter-legend"
|
||||
@@ -0,0 +1,273 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import kst4contest.locatorUtils.DirectionUtils
|
||||
import kst4contest.view.map.MapViewport
|
||||
import kst4contest.view.map.PathGeometryUtils
|
||||
import kotlin.math.ceil
|
||||
|
||||
/** Immutable effective QTF/QRB filter values drawn by the station map. */
|
||||
data class SpatialFilterOverlayState(
|
||||
val qtfEnabled: Boolean,
|
||||
val qtfDegrees: Double,
|
||||
val beamWidthDeg: Double,
|
||||
val minQrbEnabled: Boolean,
|
||||
val minQrbKm: Double,
|
||||
val maxQrbEnabled: Boolean,
|
||||
val maxQrbKm: Double,
|
||||
) {
|
||||
val isActive: Boolean
|
||||
get() = qtfEnabled || minQrbEnabled || maxQrbEnabled
|
||||
|
||||
val hasEmptyDistanceRange: Boolean
|
||||
get() = minQrbEnabled && maxQrbEnabled && minQrbKm >= maxQrbKm
|
||||
|
||||
val hasRenderableValues: Boolean
|
||||
get() = (!qtfEnabled || qtfDegrees.isFinite() && beamWidthDeg.isFinite()) &&
|
||||
(!minQrbEnabled || minQrbKm.isFinite()) &&
|
||||
(!maxQrbEnabled || maxQrbKm.isFinite())
|
||||
|
||||
/** True when the current values produce at least one visible fill or boundary. */
|
||||
val hasDrawableGeometry: Boolean
|
||||
get() {
|
||||
if (!isActive || !hasRenderableValues) return false
|
||||
if (minQrbEnabled && !maxQrbEnabled) return true
|
||||
if (minQrbEnabled && minQrbKm > 0.0) return true
|
||||
if (maxQrbEnabled && maxQrbKm > 0.0) return true
|
||||
return qtfEnabled && !maxQrbEnabled
|
||||
}
|
||||
|
||||
/** Mirrors the filter predicate: minimum exclusive, maximum inclusive. */
|
||||
fun accepts(distanceKm: Double, bearingDeg: Double): Boolean {
|
||||
if (!distanceKm.isFinite() || !bearingDeg.isFinite()) return false
|
||||
if (minQrbEnabled && distanceKm <= minQrbKm) return false
|
||||
if (maxQrbEnabled && distanceKm > maxQrbKm) return false
|
||||
if (qtfEnabled && !DirectionUtils.isAngleInRange(bearingDeg, qtfDegrees, beamWidthDeg)) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
internal fun DrawScope.drawSpatialFilterOverlay(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
state: SpatialFilterOverlayState,
|
||||
darkMode: Boolean,
|
||||
) {
|
||||
if (!state.hasDrawableGeometry ||
|
||||
!homeLatitudeDeg.isFinite() || !homeLongitudeDeg.isFinite()
|
||||
) return
|
||||
|
||||
val fillColor = if (darkMode) FILTER_FILL_DARK else FILTER_FILL_LIGHT
|
||||
val lineColor = spatialFilterOverlayLineColor(darkMode)
|
||||
val angular = state.qtfEnabled && state.beamWidthDeg.isFinite()
|
||||
val validAngularArea = !angular || state.beamWidthDeg > 0.0 && state.beamWidthDeg < 360.0
|
||||
val visibleRadius = visibleRadiusKm(viewport, homeLatitudeDeg, homeLongitudeDeg)
|
||||
val outerRadius = if (state.maxQrbEnabled) state.maxQrbKm else visibleRadius
|
||||
val innerRadius = if (state.minQrbEnabled) state.minQrbKm else 0.0
|
||||
|
||||
if (!state.hasEmptyDistanceRange && validAngularArea && outerRadius > innerRadius && outerRadius > 0.0) {
|
||||
val fillPath = when {
|
||||
angular -> annularSectorPath(
|
||||
viewport,
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
state.qtfDegrees,
|
||||
state.beamWidthDeg,
|
||||
innerRadius.coerceAtLeast(0.0),
|
||||
outerRadius,
|
||||
)
|
||||
|
||||
state.minQrbEnabled && !state.maxQrbEnabled -> outsideCirclePath(
|
||||
viewport,
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
innerRadius.coerceAtLeast(0.0),
|
||||
)
|
||||
|
||||
else -> ringPath(
|
||||
viewport,
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
innerRadius.coerceAtLeast(0.0),
|
||||
outerRadius,
|
||||
)
|
||||
}
|
||||
drawPath(fillPath, fillColor, style = Fill)
|
||||
}
|
||||
|
||||
val stroke = Stroke(
|
||||
width = FILTER_LINE_WIDTH_PX,
|
||||
pathEffect = PathEffect.dashPathEffect(FILTER_DASH_PX),
|
||||
)
|
||||
if (state.minQrbEnabled && state.minQrbKm > 0.0) {
|
||||
drawPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, state.minQrbKm), lineColor, style = stroke)
|
||||
}
|
||||
if (state.maxQrbEnabled && state.maxQrbKm > 0.0) {
|
||||
drawPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, state.maxQrbKm), lineColor, style = stroke)
|
||||
}
|
||||
if (angular) {
|
||||
val boundaryRadius = if (state.maxQrbEnabled) state.maxQrbKm else visibleRadius
|
||||
val halfWidth = state.beamWidthDeg.coerceAtLeast(0.0) / 2.0
|
||||
listOf(state.qtfDegrees - halfWidth, state.qtfDegrees + halfWidth).forEach { bearing ->
|
||||
val endpoint = calculateDestinationPoint(
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
bearing,
|
||||
boundaryRadius,
|
||||
)
|
||||
drawLine(
|
||||
color = lineColor,
|
||||
start = Offset(
|
||||
viewport.screenXWrapped(homeLongitudeDeg).toFloat(),
|
||||
viewport.screenY(homeLatitudeDeg).toFloat(),
|
||||
),
|
||||
end = Offset(
|
||||
viewport.screenXWrapped(endpoint.second).toFloat(),
|
||||
viewport.screenY(endpoint.first).toFloat(),
|
||||
),
|
||||
strokeWidth = FILTER_LINE_WIDTH_PX,
|
||||
pathEffect = PathEffect.dashPathEffect(FILTER_DASH_PX),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun outsideCirclePath(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
radiusKm: Double,
|
||||
): Path = Path().apply {
|
||||
fillType = PathFillType.EvenOdd
|
||||
addRect(Rect(0f, 0f, viewport.widthPx().toFloat(), viewport.heightPx().toFloat()))
|
||||
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, radiusKm))
|
||||
}
|
||||
|
||||
private fun ringPath(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
innerRadiusKm: Double,
|
||||
outerRadiusKm: Double,
|
||||
): Path = Path().apply {
|
||||
fillType = PathFillType.EvenOdd
|
||||
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, outerRadiusKm))
|
||||
if (innerRadiusKm > 0.0) {
|
||||
addPath(circlePath(viewport, homeLatitudeDeg, homeLongitudeDeg, innerRadiusKm))
|
||||
}
|
||||
}
|
||||
|
||||
private fun annularSectorPath(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
centerBearingDeg: Double,
|
||||
beamWidthDeg: Double,
|
||||
innerRadiusKm: Double,
|
||||
outerRadiusKm: Double,
|
||||
): Path {
|
||||
val halfWidth = beamWidthDeg / 2.0
|
||||
val outer = arcPoints(
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
centerBearingDeg - halfWidth,
|
||||
beamWidthDeg,
|
||||
outerRadiusKm,
|
||||
)
|
||||
val inner = if (innerRadiusKm > 0.0) {
|
||||
arcPoints(
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
centerBearingDeg - halfWidth,
|
||||
beamWidthDeg,
|
||||
innerRadiusKm,
|
||||
).asReversed()
|
||||
} else {
|
||||
listOf(homeLatitudeDeg to homeLongitudeDeg)
|
||||
}
|
||||
|
||||
return pathThrough(viewport, outer + inner, close = true)
|
||||
}
|
||||
|
||||
private fun circlePath(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
radiusKm: Double,
|
||||
): Path = pathThrough(
|
||||
viewport,
|
||||
arcPoints(homeLatitudeDeg, homeLongitudeDeg, 0.0, 360.0, radiusKm),
|
||||
close = true,
|
||||
)
|
||||
|
||||
private fun arcPoints(
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
startBearingDeg: Double,
|
||||
sweepDeg: Double,
|
||||
radiusKm: Double,
|
||||
): List<Pair<Double, Double>> {
|
||||
val segmentCount = maxOf(24, ceil(kotlin.math.abs(sweepDeg) / 3.0).toInt())
|
||||
return (0..segmentCount).map { segment ->
|
||||
calculateDestinationPoint(
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
startBearingDeg + sweepDeg * segment / segmentCount,
|
||||
radiusKm,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pathThrough(
|
||||
viewport: MapViewport,
|
||||
points: List<Pair<Double, Double>>,
|
||||
close: Boolean,
|
||||
): Path = Path().apply {
|
||||
points.forEachIndexed { index, point ->
|
||||
val x = viewport.screenXWrapped(point.second).toFloat()
|
||||
val y = viewport.screenY(point.first).toFloat()
|
||||
if (index == 0) moveTo(x, y) else lineTo(x, y)
|
||||
}
|
||||
if (close && points.isNotEmpty()) close()
|
||||
}
|
||||
|
||||
private fun visibleRadiusKm(
|
||||
viewport: MapViewport,
|
||||
homeLatitudeDeg: Double,
|
||||
homeLongitudeDeg: Double,
|
||||
): Double {
|
||||
val corners = listOf(
|
||||
viewport.northLat() to viewport.westLon(),
|
||||
viewport.northLat() to viewport.eastLon(),
|
||||
viewport.southLat() to viewport.westLon(),
|
||||
viewport.southLat() to viewport.eastLon(),
|
||||
)
|
||||
val farthestCorner = corners.maxOfOrNull { (latitude, longitude) ->
|
||||
PathGeometryUtils.calculateGreatCircleDistanceKm(
|
||||
homeLatitudeDeg,
|
||||
homeLongitudeDeg,
|
||||
latitude,
|
||||
longitude,
|
||||
)
|
||||
} ?: 0.0
|
||||
return farthestCorner * VIEWPORT_RADIUS_MARGIN
|
||||
}
|
||||
|
||||
private val FILTER_FILL_LIGHT = Color(0xFF5B4BC4).copy(alpha = 0.16f)
|
||||
private val FILTER_FILL_DARK = Color(0xFF78A9FF).copy(alpha = 0.18f)
|
||||
private val FILTER_LINE_LIGHT = Color(0xFF4937B8).copy(alpha = 0.88f)
|
||||
private val FILTER_LINE_DARK = Color(0xFF8CB8FF).copy(alpha = 0.92f)
|
||||
internal fun spatialFilterOverlayLineColor(darkMode: Boolean): Color =
|
||||
if (darkMode) FILTER_LINE_DARK else FILTER_LINE_LIGHT
|
||||
private const val FILTER_LINE_WIDTH_PX = 2f
|
||||
private val FILTER_DASH_PX = floatArrayOf(7f, 5f)
|
||||
private const val VIEWPORT_RADIUS_MARGIN = 1.12
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -26,6 +28,7 @@ import kst4contest.view.compose.StationContextOverlays
|
||||
import kst4contest.view.compose.StationSelectionSource
|
||||
import kst4contest.view.map.MapCallsignRawSnapshotBuilder
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.MapViewportNavigation
|
||||
import kst4contest.view.map.StationMapStatusText
|
||||
import kst4contest.view.map.TileFetcher
|
||||
import java.util.EnumSet
|
||||
@@ -108,9 +111,8 @@ object StationMapWindow {
|
||||
* @param chatMembers the roster a marker click looks its station up in; a marker stands
|
||||
* for a raw callsign, and this is where its category variants come from
|
||||
* @param mapState where the map is looking
|
||||
* @param pathAnalysis asked each time the selection changes; null means there is no
|
||||
* running application, and then the map neither pans nor analyses, exactly as
|
||||
* when no chat controller existed before the split
|
||||
* @param pathAnalysis asked for an explicit selection; null disables only the terrain
|
||||
* analysis while viewport navigation remains available
|
||||
*/
|
||||
@Composable
|
||||
internal fun Content(
|
||||
@@ -151,8 +153,8 @@ object StationMapWindow {
|
||||
|
||||
val ownLocator6 by remember { androidx.compose.runtime.mutableStateOf(mainWindowState.prefs.stn_loginLocatorMainCat) }
|
||||
val antennaAzimuthDeg = mainWindowState.antennaQtf.value
|
||||
val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
|
||||
val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
|
||||
val antennaBeamWidthDeg = mainWindowState.mapDisplaySettings.antennaBeamWidthDeg
|
||||
val maxQrbKm = mainWindowState.mapDisplaySettings.maxQrbKm
|
||||
|
||||
/*
|
||||
* The markers are the stations the list shows, not all of them (#21): the
|
||||
@@ -184,20 +186,37 @@ object StationMapWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Selection is independent of the filtered marker set. A selected station
|
||||
* remains a target even when an active filter removes its normal marker; it is
|
||||
* drawn in a separate selection layer and never enters marker counts or clusters.
|
||||
*/
|
||||
val selectedTarget by remember(
|
||||
selectedMember,
|
||||
mainWindowState.stations.contentRevision,
|
||||
mainWindowState.activeBands,
|
||||
) {
|
||||
derivedStateOf {
|
||||
selectedMember?.let { selected ->
|
||||
val variants = chatMembers().filter { it.callSignRaw == selected.callSignRaw }
|
||||
.ifEmpty { listOf(selected) }
|
||||
val bands = EnumSet.noneOf(Band::class.java).apply {
|
||||
addAll(mainWindowState.activeBands)
|
||||
}
|
||||
MapCallsignRawSnapshotBuilder()
|
||||
.buildSnapshots(variants, selected, bands)
|
||||
.firstOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var pathAnalysisResult by remember { mutableStateOf<PathAnalysisResult?>(null) }
|
||||
var automaticAnalysisRevision by remember { mutableStateOf(0L) }
|
||||
|
||||
// Read through updated state so a caller passing a new lambda is never served a stale one.
|
||||
val currentPathAnalysis by rememberUpdatedState(pathAnalysis)
|
||||
|
||||
/*
|
||||
* Whether the selected station has a marker. A filter can hide it (#21), and a
|
||||
* station without a locator never has one; then there is nothing to analyse.
|
||||
*/
|
||||
val selectedHasMarker = selectedMember != null &&
|
||||
markers.any { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
|
||||
LaunchedEffect(selectedMember, selectedHasMarker) {
|
||||
LaunchedEffect(mainWindowState.stationSelection.explicitSelectionRevision) {
|
||||
val automaticRequest = ++automaticAnalysisRevision
|
||||
val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision
|
||||
if (selectedMember == null) {
|
||||
@@ -205,34 +224,15 @@ object StationMapWindow {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
|
||||
val selectedSnapshot = selectedTarget
|
||||
if (selectedSnapshot == null) {
|
||||
// No marker: no request, and no "Loading" for a station the map does not show.
|
||||
// No valid locator: no request and no misleading loading state.
|
||||
pathAnalysisResult = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val analysis = currentPathAnalysis()
|
||||
if (analysis != null) {
|
||||
if (selectedSnapshot.hasUsablePosition()) {
|
||||
/*
|
||||
* Pan to the station and leave the zoom alone. The Leaflet map
|
||||
* did exactly this (focusCallsignRaw) and touched the zoom only
|
||||
* when the station was hidden inside a bubble, and then only far
|
||||
* enough to break the bubble open. An earlier Compose version
|
||||
* recomputed a zoom from a bounding box on every selection and
|
||||
* clamped it into 4..12, which threw an operator working at zoom
|
||||
* 15 out to about 7 every time they picked the next station.
|
||||
* AGENTS.md names zoom first among the things not to change as
|
||||
* an incidental side effect.
|
||||
*/
|
||||
mapState.centerLon = selectedSnapshot.longitudeDeg()
|
||||
mapState.centerLat = selectedSnapshot.latitudeDeg()
|
||||
mapState.zoom = kst4contest.view.map.StationMapClusterer
|
||||
.zoomToRevealStations(mapState.zoom.toDouble(), groupingEnabled)
|
||||
.toFloat()
|
||||
}
|
||||
|
||||
pathAnalysisResult = PathAnalysisResult.loading(
|
||||
prefs.stn_loginLocatorMainCat,
|
||||
selectedSnapshot.locator6(),
|
||||
@@ -249,6 +249,72 @@ object StationMapWindow {
|
||||
}
|
||||
}
|
||||
|
||||
var mapCanvasSize by remember { mutableStateOf(Size.Zero) }
|
||||
var consumedNavigationRevision by remember(mapState) { mutableStateOf(Long.MIN_VALUE) }
|
||||
val navigationRevision = mainWindowState.mapNavigation.requestRevision
|
||||
val navigationRequest = mainWindowState.mapNavigation.request
|
||||
val navigationPaddingPx = with(LocalDensity.current) { 40.dp.toPx().toDouble() }
|
||||
|
||||
LaunchedEffect(
|
||||
navigationRevision,
|
||||
navigationRequest,
|
||||
mapCanvasSize,
|
||||
selectedTarget,
|
||||
ownLocator6,
|
||||
) {
|
||||
if (navigationRevision == consumedNavigationRevision ||
|
||||
mapCanvasSize.width <= 0f || mapCanvasSize.height <= 0f
|
||||
) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val home = runCatching { kst4contest.locatorUtils.Location(ownLocator6) }.getOrNull()
|
||||
val current = mapState.viewportFor(mapCanvasSize.width, mapCanvasSize.height)
|
||||
val next = when (val request = navigationRequest) {
|
||||
is MapNavigationRequest.FitSelectedPath -> {
|
||||
val target = selectedTarget
|
||||
if (home == null || target == null || !target.hasUsablePosition() ||
|
||||
request.identity != StationIdentity.of(selectedMember)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
MapViewportNavigation.fitPath(
|
||||
current,
|
||||
home.latitude.toDegrees(),
|
||||
home.longitude.toDegrees(),
|
||||
target.latitudeDeg(),
|
||||
target.longitudeDeg(),
|
||||
navigationPaddingPx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is MapNavigationRequest.CenterOnQtf -> {
|
||||
if (home == null) null else MapViewportNavigation.centreOnBearing(
|
||||
current,
|
||||
home.latitude.toDegrees(),
|
||||
home.longitude.toDegrees(),
|
||||
request.qtfDegrees,
|
||||
QTF_FORWARD_CENTRE_FRACTION,
|
||||
)
|
||||
}
|
||||
|
||||
MapNavigationRequest.ResetToHome -> {
|
||||
if (home == null) null else MapViewportNavigation.centreOnHome(
|
||||
current,
|
||||
home.latitude.toDegrees(),
|
||||
home.longitude.toDegrees(),
|
||||
)
|
||||
}
|
||||
|
||||
null -> null
|
||||
}
|
||||
consumedNavigationRevision = navigationRevision
|
||||
if (next != null) {
|
||||
mapState.applyFrom(next)
|
||||
}
|
||||
}
|
||||
|
||||
val explicitReachabilityResult = mainWindowState.mapReachability.result
|
||||
LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) {
|
||||
if (explicitReachabilityResult != null) {
|
||||
@@ -264,6 +330,24 @@ object StationMapWindow {
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
val spatialFilterOverlay = SpatialFilterOverlayState(
|
||||
qtfEnabled = mainWindowState.stationFilter.qtfEnabled,
|
||||
qtfDegrees = mainWindowState.stationFilter.qtfDegrees,
|
||||
beamWidthDeg = antennaBeamWidthDeg,
|
||||
minQrbEnabled = mainWindowState.stationFilter.minQrbEnabled,
|
||||
minQrbKm = mainWindowState.stationFilter.minQrbKm,
|
||||
maxQrbEnabled = mainWindowState.stationFilter.maxQrbEnabled,
|
||||
maxQrbKm = mainWindowState.stationFilter.maxQrbKm,
|
||||
)
|
||||
val mapHome = remember(ownLocator6) { homePosition(ownLocator6) }
|
||||
val showAntennaOverlay = isAntennaSectorDrawable(
|
||||
mapHome,
|
||||
antennaAzimuthDeg ?: 0.0,
|
||||
antennaBeamWidthDeg,
|
||||
maxQrbKm,
|
||||
)
|
||||
val showSpatialFilterOverlay = mapHome != null && spatialFilterOverlay.hasDrawableGeometry
|
||||
val isDarkTheme = isDarkThemeState.value
|
||||
|
||||
androidx.compose.material3.Surface(
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.background,
|
||||
@@ -288,7 +372,7 @@ object StationMapWindow {
|
||||
markers.size,
|
||||
mainWindowState.stationFilter.activeFilters.isNotEmpty() ||
|
||||
mainWindowState.stationFilter.searchText.isNotBlank(),
|
||||
markers.firstOrNull { it.callSignRaw() == selectedMember?.callSignRaw },
|
||||
selectedTarget,
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -308,6 +392,12 @@ object StationMapWindow {
|
||||
)
|
||||
}
|
||||
|
||||
MapOverlayLegend(
|
||||
showAntenna = showAntennaOverlay,
|
||||
showSpatialFilters = showSpatialFilterOverlay,
|
||||
darkMode = isDarkTheme,
|
||||
)
|
||||
|
||||
if (selectedMember != null) {
|
||||
kst4contest.view.compose.Form.button(strings.mapTriggerClusterSpot) {
|
||||
ApplicationRuntimeLauncher.getCurrent()?.chatController
|
||||
@@ -317,12 +407,6 @@ object StationMapWindow {
|
||||
|
||||
kst4contest.view.compose.Form.button(strings.mapResetView) {
|
||||
mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW)
|
||||
val home = runCatching {
|
||||
kst4contest.locatorUtils.Location(ownLocator6)
|
||||
}.getOrNull()
|
||||
mapState.centerLon = home?.longitude?.toDegrees() ?: 10.0
|
||||
mapState.centerLat = home?.latitude?.toDegrees() ?: 51.0
|
||||
/* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
|
||||
}
|
||||
|
||||
kst4contest.view.compose.Form.button(strings.filterReset) {
|
||||
@@ -388,7 +472,6 @@ object StationMapWindow {
|
||||
|
||||
val leftPane: @Composable () -> Unit = {
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
val isDarkTheme = isDarkThemeState.value
|
||||
androidx.compose.foundation.layout.Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
ComposeStationMap(
|
||||
markers = markers,
|
||||
@@ -400,8 +483,11 @@ object StationMapWindow {
|
||||
antennaAzimuthDeg = antennaAzimuthDeg ?: 0.0,
|
||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||
maxQrbKm = maxQrbKm,
|
||||
selectedTarget = selectedTarget,
|
||||
spatialFilterOverlay = spatialFilterOverlay,
|
||||
darkMode = isDarkTheme,
|
||||
workedGrossFields = workedGrossFields,
|
||||
onCanvasSizeChanged = { mapCanvasSize = it },
|
||||
onMarkerClicked = { snapshot ->
|
||||
val variants = chatMembers()
|
||||
.filter { it.callSignRaw == snapshot.callSignRaw() }
|
||||
@@ -501,6 +587,7 @@ object StationMapWindow {
|
||||
internal const val MAP_CANVAS_TAG = "station-map-canvas"
|
||||
internal const val GROUPING_TAG = "station-map-grouping"
|
||||
internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top"
|
||||
private const val QTF_FORWARD_CENTRE_FRACTION = 0.25
|
||||
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) {
|
||||
|
||||
@@ -4,9 +4,11 @@ import kst4contest.view.i18n.LocalStrings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Form
|
||||
import kst4contest.view.compose.observeAsState
|
||||
|
||||
/**
|
||||
* The beacon tab: one CQ template per chat category and the interval they share.
|
||||
@@ -18,12 +20,14 @@ import kst4contest.view.compose.Form
|
||||
@Composable
|
||||
fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
||||
val strings = LocalStrings.current
|
||||
val mainEnabled by state.mainBeaconEnabled.observeAsState()
|
||||
val secondEnabled by state.secondBeaconEnabled.observeAsState()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Form.section(strings.beaconSectionFor(state.mainCategoryName)) {
|
||||
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledMainCat) {
|
||||
Form.check(strings.beaconEnabled, mainEnabled == true) {
|
||||
state.bcn_beaconsEnabledMainCat = it
|
||||
}
|
||||
Form.committed(
|
||||
@@ -36,7 +40,7 @@ fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
|
||||
}
|
||||
|
||||
Form.section(strings.beaconSectionFor(state.secondCategoryName)) {
|
||||
Form.check(strings.beaconEnabled, state.bcn_beaconsEnabledSecondCat) {
|
||||
Form.check(strings.beaconEnabled, secondEnabled == true) {
|
||||
state.bcn_beaconsEnabledSecondCat = it
|
||||
}
|
||||
Form.committed(
|
||||
|
||||
@@ -12,8 +12,9 @@ import kst4contest.view.compose.setting
|
||||
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
||||
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
|
||||
* return the refusal text instead, so the tab decides how to show it and a test can
|
||||
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
|
||||
* when the operator applies it; a refused one never gets there.
|
||||
* read it. Accepted templates and intervals are staged in the window's [SettingsDraft]
|
||||
* and take effect when the operator applies them. The two enable switches deliberately
|
||||
* bypass the draft so the running timer sees their values immediately.
|
||||
*
|
||||
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
||||
* the contract of `ChatController.validateBeaconTemplate`.
|
||||
@@ -27,15 +28,20 @@ class BeaconTabState(
|
||||
private val restartBeaconTimer: () -> Unit,
|
||||
val mainCategoryName: String,
|
||||
val secondCategoryName: String,
|
||||
private val setMainBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledMainCat,
|
||||
private val setSecondBeaconEnabled: (Boolean) -> Unit = prefs::setBcn_beaconsEnabledSecondCat,
|
||||
) {
|
||||
|
||||
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
|
||||
{ prefs.isBcn_beaconsEnabledMainCat() },
|
||||
) { prefs.setBcn_beaconsEnabledMainCat(it) }
|
||||
val mainBeaconEnabled = prefs.bcnBeaconsEnabledMainCatProperty()
|
||||
val secondBeaconEnabled = prefs.bcnBeaconsEnabledSecondCatProperty()
|
||||
|
||||
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
|
||||
{ prefs.isBcn_beaconsEnabledSecondCat() },
|
||||
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
|
||||
var bcn_beaconsEnabledMainCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledMainCat()
|
||||
set(value) = setMainBeaconEnabled(value)
|
||||
|
||||
var bcn_beaconsEnabledSecondCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledSecondCat()
|
||||
set(value) = setSecondBeaconEnabled(value)
|
||||
|
||||
private var textMainCat: String by draft.setting(
|
||||
{ prefs.getBcn_beaconTextMainCat() },
|
||||
@@ -69,7 +75,7 @@ class BeaconTabState(
|
||||
*
|
||||
* The clamp is not cosmetic: a stored zero would otherwise be displayed as a
|
||||
* valid setting while `ChatController` sends at the minimum anyway
|
||||
* (ChatController:3425). The JavaFX field applied the same `Math.max` when it
|
||||
* when scheduling the timer. The JavaFX field applied the same `Math.max` when it
|
||||
* restored the value after a rejected entry.
|
||||
*/
|
||||
val beaconIntervalMinutes: Int
|
||||
|
||||
@@ -44,6 +44,10 @@ class StationTabState(
|
||||
* TRX synch tab through [SECOND_ROTATOR], so it runs once per apply.
|
||||
*/
|
||||
private val applySecondRotator: () -> Unit = {},
|
||||
/** Recomputes status whose available-count depends on the second-chat switch. */
|
||||
private val refreshBeaconStatus: () -> Unit = {},
|
||||
/** Repaints applied beam geometry and dependent QTF results in the running UI. */
|
||||
private val applyMapDisplaySettings: () -> Unit = {},
|
||||
) {
|
||||
|
||||
var loginChatCategoryMain: ChatCategory?
|
||||
@@ -55,7 +59,11 @@ class StationTabState(
|
||||
get() = prefs.getLoginChatCategorySecond()
|
||||
set(value) { prefs.setLoginChatCategorySecond(value) }
|
||||
|
||||
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
|
||||
var stn_antennaBeamWidthDeg: Double by draft.setting(
|
||||
{ prefs.getStn_antennaBeamWidthDeg() },
|
||||
effectKey = MAP_DISPLAY_SETTINGS,
|
||||
effect = applyMapDisplaySettings,
|
||||
) { prefs.setStn_antennaBeamWidthDeg(it) }
|
||||
|
||||
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
|
||||
|
||||
@@ -95,7 +103,11 @@ class StationTabState(
|
||||
get() = prefs.getStn_loginPassword()
|
||||
set(value) { prefs.setStn_loginPassword(value) }
|
||||
|
||||
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
|
||||
var stn_maxQRBDefault: Double by draft.setting(
|
||||
{ prefs.getStn_maxQRBDefault() },
|
||||
effectKey = MAP_DISPLAY_SETTINGS,
|
||||
effect = applyMapDisplaySettings,
|
||||
) { prefs.setStn_maxQRBDefault(it) }
|
||||
|
||||
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
|
||||
|
||||
@@ -234,7 +246,10 @@ class StationTabState(
|
||||
* pickers it governs. It decides what the Connect button does, so it belongs with
|
||||
* the login settings and not with the display options.
|
||||
*/
|
||||
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
|
||||
var loginToSecondChatEnabled: Boolean by draft.setting(
|
||||
{ prefs.isLoginToSecondChatEnabled() },
|
||||
effect = { refreshBeaconStatus() },
|
||||
) { prefs.setLoginToSecondChatEnabled(it) }
|
||||
|
||||
/**
|
||||
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
||||
@@ -289,6 +304,9 @@ class StationTabState(
|
||||
/** The effect key of everything that restarts rotator 2; shared with the TRX synch tab. */
|
||||
const val SECOND_ROTATOR: String = "secondRotator"
|
||||
|
||||
/** Shared effect for the two settings that define the map's antenna sector. */
|
||||
const val MAP_DISPLAY_SETTINGS: String = "mapDisplaySettings"
|
||||
|
||||
/**
|
||||
* Whether two PSTRotator ports collide: the same port, or one apart because
|
||||
* PSTRotator answers on port + 1, which would be the other client's local port.
|
||||
|
||||
@@ -35,7 +35,9 @@ class SettingsFieldCoverageTest {
|
||||
""
|
||||
}
|
||||
|
||||
val missing = expected.filterNot { composeSources.contains("$it(") }
|
||||
val missing = expected.filterNot {
|
||||
composeSources.contains("$it(") || composeSources.contains("::$it")
|
||||
}
|
||||
|
||||
assertTrue(missing.isEmpty(),
|
||||
"settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
|
||||
|
||||
@@ -70,12 +70,27 @@ class StationSelectionStateTest {
|
||||
val state = StationSelectionState()
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE)
|
||||
val revision = state.scrollRequestRevision
|
||||
val explicitRevision = state.explicitSelectionRevision
|
||||
val refreshed = station("DL1ABC", 2)
|
||||
|
||||
state.reconcile(refreshed)
|
||||
|
||||
assertSame(refreshed, state.selected)
|
||||
assertEquals(revision, state.scrollRequestRevision)
|
||||
assertEquals(explicitRevision, state.explicitSelectionRevision)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roster reconciliation does not publish explicit navigation`() {
|
||||
val state = StationSelectionState()
|
||||
val requests = mutableListOf<Pair<ChatMember?, StationSelectionSource>>()
|
||||
state.explicitSelectionListener = { member, source -> requests += member to source }
|
||||
state.select(station("DL1ABC", 2), StationSelectionSource.PUBLIC_MESSAGE)
|
||||
|
||||
state.reconcile(station("DL1ABC", 2))
|
||||
|
||||
assertEquals(1, requests.size)
|
||||
assertEquals(StationSelectionSource.PUBLIC_MESSAGE, requests.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertHasClickAction
|
||||
import androidx.compose.ui.test.assertHasNoClickAction
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ThreadStateMessage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class StatusBarScreenTest {
|
||||
|
||||
@Test
|
||||
fun onlyAThreadWithARegisteredActionIsClickable() = runComposeUiTest {
|
||||
val buttons = ThreadStatusButtons()
|
||||
var beaconClicks = 0
|
||||
buttons.registerAction("MyBeacon") { beaconClicks++ }
|
||||
buttons.update("MyBeacon", status("off"))
|
||||
buttons.update("Cluster", status("on"))
|
||||
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
StatusBar(
|
||||
connectionState = null,
|
||||
connectionDetail = null,
|
||||
skedNotice = BlinkingNotice(),
|
||||
bandUpgradeNotice = BlinkingNotice(),
|
||||
threadButtons = buttons,
|
||||
showConnectionBadge = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("MyBeacon: off").assertHasClickAction().performClick()
|
||||
waitForIdle()
|
||||
assertEquals(1, beaconClicks)
|
||||
onNodeWithText("Cluster: on").assertHasNoClickAction()
|
||||
}
|
||||
|
||||
private fun status(description: String): ThreadStateMessage =
|
||||
ThreadStateMessage("worker", true, description, false).apply {
|
||||
runningInformationTextDescription = description
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,52 @@ class StatusIndicatorStateTest {
|
||||
assertEquals("Cluster: b", button.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an action registered before the first report reaches the created button`() {
|
||||
val buttons = ThreadStatusButtons()
|
||||
var clicks = 0
|
||||
buttons.registerAction("MyBeacon") { clicks++ }
|
||||
|
||||
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
|
||||
buttons.buttons.single().activate()
|
||||
|
||||
assertEquals(1, clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an action registered after a report reaches the existing button`() {
|
||||
val buttons = ThreadStatusButtons()
|
||||
var clicks = 0
|
||||
buttons.update("MyBeacon", threadState(nick = "beacon", description = "off", info = "disabled"))
|
||||
|
||||
buttons.registerAction("MyBeacon") { clicks++ }
|
||||
buttons.buttons.single().activate()
|
||||
|
||||
assertEquals(1, clicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a thread without an action cannot be activated`() {
|
||||
val buttons = ThreadStatusButtons()
|
||||
buttons.update("Cluster", threadState(nick = "cluster", description = "on", info = "running"))
|
||||
|
||||
assertFalse(buttons.buttons.single().activate())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a status provider replaces a stale worker snapshot when the UI applies it`() {
|
||||
val buttons = ThreadStatusButtons()
|
||||
var current = threadState(nick = "beacon", description = "on", info = "enabled")
|
||||
buttons.registerStatusProvider("MyBeacon") { current }
|
||||
val queuedSnapshot = current
|
||||
current = threadState(nick = "beacon", description = "off", info = "disabled")
|
||||
|
||||
buttons.update("MyBeacon", queuedSnapshot)
|
||||
|
||||
assertEquals("MyBeacon: off", buttons.buttons.single().label)
|
||||
assertEquals("disabled", buttons.buttons.single().tooltip)
|
||||
}
|
||||
|
||||
private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
|
||||
ThreadStateMessage(nick, true, info, false).apply {
|
||||
setRunningInformationTextDescription(description)
|
||||
|
||||
+68
-2
@@ -49,6 +49,8 @@ class ComposeStationMapDrawingTest {
|
||||
|
||||
private val workedGridBorder = 0x000000
|
||||
|
||||
private val selectedDotBorder = 0xFF9900
|
||||
|
||||
/*
|
||||
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
|
||||
* construction. The default fetcher read the user's disk cache and the real tile
|
||||
@@ -71,6 +73,10 @@ class ComposeStationMapDrawingTest {
|
||||
ownLocator6: String = "",
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
selectedTarget: MapCallsignRawSnapshot? = null,
|
||||
spatialFilterOverlay: SpatialFilterOverlayState? = null,
|
||||
antennaBeamWidthDeg: Double = 0.0,
|
||||
maxQrbKm: Double = 0.0,
|
||||
): BufferedImage {
|
||||
return onEventThread {
|
||||
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
|
||||
@@ -82,8 +88,10 @@ class ComposeStationMapDrawingTest {
|
||||
selectedMarkerCallsign = null,
|
||||
ownLocator6 = ownLocator6,
|
||||
antennaAzimuthDeg = 90.0,
|
||||
antennaBeamWidthDeg = 0.0,
|
||||
maxQrbKm = 0.0,
|
||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||
maxQrbKm = maxQrbKm,
|
||||
selectedTarget = selectedTarget,
|
||||
spatialFilterOverlay = spatialFilterOverlay,
|
||||
onMarkerClicked = null,
|
||||
workedGrossFields = workedGrossFields,
|
||||
darkMode = darkMode,
|
||||
@@ -108,6 +116,16 @@ class ComposeStationMapDrawingTest {
|
||||
return count
|
||||
}
|
||||
|
||||
private fun changedPixels(before: BufferedImage, after: BufferedImage): Int {
|
||||
var changed = 0
|
||||
for (x in 0 until before.width) {
|
||||
for (y in 0 until before.height) {
|
||||
if (before.getRGB(x, y) != after.getRGB(x, y)) changed++
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** The widest horizontal run of a colour, which for a ring is its outer diameter. */
|
||||
private fun widestRun(image: BufferedImage, rgb: Int): Int {
|
||||
var widest = 0
|
||||
@@ -282,6 +300,54 @@ class ComposeStationMapDrawingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active spatial filters add a distinct overlay in both themes`() {
|
||||
val overlay = SpatialFilterOverlayState(
|
||||
qtfEnabled = true,
|
||||
qtfDegrees = 90.0,
|
||||
beamWidthDeg = 60.0,
|
||||
minQrbEnabled = true,
|
||||
minQrbKm = 50.0,
|
||||
maxQrbEnabled = true,
|
||||
maxQrbKm = 400.0,
|
||||
)
|
||||
|
||||
for (darkMode in listOf(false, true)) {
|
||||
val without = render(6f, markers = emptyList(), ownLocator6 = "JO50JP", darkMode = darkMode)
|
||||
val with = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
ownLocator6 = "JO50JP",
|
||||
darkMode = darkMode,
|
||||
spatialFilterOverlay = overlay,
|
||||
)
|
||||
|
||||
assertTrue(changedPixels(without, with) > 1_000) {
|
||||
"the spatial filter overlay was not visible in darkMode=$darkMode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a filtered selected target is drawn without joining the normal marker list`() {
|
||||
val targetLocation = kst4contest.locatorUtils.Location("JO51AA")
|
||||
val target = station(
|
||||
"DL1HIDE",
|
||||
"JO51AA",
|
||||
targetLocation.latitude.toDegrees(),
|
||||
targetLocation.longitude.toDegrees(),
|
||||
)
|
||||
|
||||
val image = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
ownLocator6 = "JO50JP",
|
||||
selectedTarget = target,
|
||||
)
|
||||
|
||||
assertTrue(countColour(image, selectedDotBorder) > 0, "the separate selected marker is missing")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every grid line has to be drawn once.
|
||||
*
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class MapDisplaySettingsStateTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var directory: Path
|
||||
|
||||
@Test
|
||||
fun `refresh publishes applied beam geometry from preferences`() {
|
||||
val preferences = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||
stn_antennaBeamWidthDeg = 50.0
|
||||
stn_maxQRBDefault = 750.0
|
||||
}
|
||||
val state = MapDisplaySettingsState(preferences)
|
||||
|
||||
preferences.stn_antennaBeamWidthDeg = 72.0
|
||||
preferences.stn_maxQRBDefault = 1_100.0
|
||||
state.refreshFromPreferences()
|
||||
|
||||
assertEquals(72.0, state.antennaBeamWidthDeg)
|
||||
assertEquals(1_100.0, state.maxQrbKm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import kst4contest.view.compose.StationIdentity
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MapNavigationStateTest {
|
||||
|
||||
@Test
|
||||
fun `repeated station choices remain observable`() {
|
||||
val state = MapNavigationState()
|
||||
val identity = StationIdentity("DL1ABC", 2)
|
||||
|
||||
state.fitSelectedPath(identity)
|
||||
val firstRevision = state.requestRevision
|
||||
state.fitSelectedPath(identity)
|
||||
|
||||
assertTrue(state.requestRevision > firstRevision)
|
||||
assertEquals(MapNavigationRequest.FitSelectedPath(identity), state.request)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest explicit action replaces an older one`() {
|
||||
val state = MapNavigationState()
|
||||
|
||||
state.fitSelectedPath(StationIdentity("DL1ABC", 2))
|
||||
state.centerOnQtf(45.0)
|
||||
|
||||
assertEquals(MapNavigationRequest.CenterOnQtf(45.0), state.request)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid QTF values do not create requests`() {
|
||||
val state = MapNavigationState()
|
||||
|
||||
listOf(Double.NaN, Double.POSITIVE_INFINITY, -1.0, 360.1).forEach(state::centerOnQtf)
|
||||
|
||||
assertEquals(0L, state.requestRevision)
|
||||
assertEquals(null, state.request)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Kst4ContestTheme
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MapOverlayLegendScreenTest {
|
||||
|
||||
@Test
|
||||
fun germanEveningLegendWrapsBothItemsInsideANarrowHeader() = runComposeUiTest {
|
||||
var storedLanguage = "de"
|
||||
val languages = LanguageStore(
|
||||
storedLanguage = { storedLanguage },
|
||||
storeLanguage = { storedLanguage = it },
|
||||
systemDefault = Locale.GERMANY,
|
||||
)
|
||||
|
||||
try {
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalStrings provides languages.strings) {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(110.dp)) {
|
||||
MapOverlayLegend(
|
||||
showAntenna = true,
|
||||
showSpatialFilters = true,
|
||||
darkMode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Antenne").assertIsDisplayed()
|
||||
onNodeWithText("Aktive Filter").assertIsDisplayed()
|
||||
|
||||
val antenna = onNodeWithTag(MAP_ANTENNA_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val filters = onNodeWithTag(MAP_FILTER_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val widthLimit = with(density) { 110.dp.toPx() }
|
||||
assertTrue(antenna.right <= widthLimit + 0.5f)
|
||||
assertTrue(filters.right <= widthLimit + 0.5f)
|
||||
assertTrue(filters.top >= antenna.bottom, "the legend items did not wrap at narrow width")
|
||||
} finally {
|
||||
runOnIdle { languages.use("en") }
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SpatialFilterOverlayStateTest {
|
||||
|
||||
@Test
|
||||
fun `maximum QRB is inclusive and minimum QRB is exclusive`() {
|
||||
val state = overlay(minEnabled = true, minKm = 200.0, maxEnabled = true, maxKm = 500.0)
|
||||
|
||||
assertFalse(state.accepts(200.0, 90.0))
|
||||
assertTrue(state.accepts(200.1, 90.0))
|
||||
assertTrue(state.accepts(500.0, 90.0))
|
||||
assertFalse(state.accepts(500.1, 90.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QTF uses the same wrapped beamwidth semantics as the station filter`() {
|
||||
val state = overlay(qtf = true, direction = 0.0, beamWidth = 60.0)
|
||||
|
||||
assertTrue(state.accepts(300.0, 350.0))
|
||||
assertTrue(state.accepts(300.0, 30.0))
|
||||
assertFalse(state.accepts(300.0, 31.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusual full-circle beamwidth retains the existing station filter semantics`() {
|
||||
val state = overlay(qtf = true, direction = 0.0, beamWidth = 360.0)
|
||||
|
||||
assertTrue(state.accepts(300.0, 180.0))
|
||||
assertFalse(state.accepts(300.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimum above maximum is an empty distance range`() {
|
||||
val state = overlay(minEnabled = true, minKm = 500.0, maxEnabled = true, maxKm = 200.0)
|
||||
|
||||
assertTrue(state.hasEmptyDistanceRange)
|
||||
assertFalse(state.accepts(100.0, 0.0))
|
||||
assertFalse(state.accepts(300.0, 0.0))
|
||||
assertFalse(state.accepts(600.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite active values cannot be rendered as a misleading area`() {
|
||||
assertFalse(overlay(qtf = true, direction = Double.NaN).hasRenderableValues)
|
||||
assertFalse(overlay(minEnabled = true, minKm = Double.POSITIVE_INFINITY).hasRenderableValues)
|
||||
assertFalse(overlay(maxEnabled = true, maxKm = Double.NaN).hasRenderableValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero distance boundaries report only actually drawable geometry`() {
|
||||
assertFalse(overlay(maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertFalse(overlay(qtf = true, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertFalse(
|
||||
overlay(minEnabled = true, minKm = 0.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry
|
||||
)
|
||||
assertTrue(overlay(minEnabled = true, minKm = 0.0).hasDrawableGeometry)
|
||||
assertTrue(overlay(qtf = true).hasDrawableGeometry)
|
||||
assertTrue(overlay(minEnabled = true, minKm = 100.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertTrue(overlay(maxEnabled = true, maxKm = 500.0).hasDrawableGeometry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no spatial filter accepts finite positions`() {
|
||||
val state = overlay()
|
||||
|
||||
assertFalse(state.isActive)
|
||||
assertTrue(state.accepts(1000.0, 275.0))
|
||||
}
|
||||
|
||||
private fun overlay(
|
||||
qtf: Boolean = false,
|
||||
direction: Double = 0.0,
|
||||
beamWidth: Double = 60.0,
|
||||
minEnabled: Boolean = false,
|
||||
minKm: Double = 0.0,
|
||||
maxEnabled: Boolean = false,
|
||||
maxKm: Double = 0.0,
|
||||
) = SpatialFilterOverlayState(
|
||||
qtfEnabled = qtf,
|
||||
qtfDegrees = direction,
|
||||
beamWidthDeg = beamWidth,
|
||||
minQrbEnabled = minEnabled,
|
||||
minQrbKm = minKm,
|
||||
maxQrbEnabled = maxEnabled,
|
||||
maxQrbKm = maxKm,
|
||||
)
|
||||
}
|
||||
+187
-46
@@ -4,7 +4,6 @@ import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
@@ -42,7 +41,6 @@ import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
|
||||
import kst4contest.view.compose.ThreadStatusButtons
|
||||
import kst4contest.view.compose.TimelineState
|
||||
import kst4contest.view.compose.TopPriorityState
|
||||
import kst4contest.view.map.StationMapClusterer
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.TileFetcher
|
||||
@@ -231,7 +229,10 @@ class StationMapWindowScreenTest {
|
||||
val dl1abc = station("DL1ABC", 2)
|
||||
state.selectedStation.select(dl1abc)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
state.stationSelector = { member, source ->
|
||||
selectorCalls += member
|
||||
state.selectedStation.select(member, source)
|
||||
}
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 15f)
|
||||
|
||||
show(state, listOf(dl1abc), mapState)
|
||||
@@ -240,11 +241,7 @@ class StationMapWindowScreenTest {
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
|
||||
assertSame(
|
||||
dl1abc,
|
||||
state.selectedStation.selected,
|
||||
"reset cleared the panel's selection behind the selector's back",
|
||||
)
|
||||
assertEquals(null, state.selectedStation.selected)
|
||||
assertEquals(15f, mapState.zoom, "reset changed the zoom")
|
||||
|
||||
val home = Location(ownLocator)
|
||||
@@ -369,12 +366,9 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
|
||||
}
|
||||
|
||||
/**
|
||||
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
|
||||
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
|
||||
*/
|
||||
/** An explicit selection fits both ends and still starts the existing path analysis. */
|
||||
@Test
|
||||
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
|
||||
fun selectingAStationFitsTheCompletePath() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val analysis = RecordingPathAnalysis()
|
||||
@@ -382,53 +376,49 @@ class StationMapWindowScreenTest {
|
||||
|
||||
show(state, listOf(dl1abc), mapState, analysis)
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
val home = Location(ownLocator)
|
||||
val target = Location("JO50JP")
|
||||
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
|
||||
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
|
||||
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
|
||||
assertTrue(mapState.centerLon in home.longitude.toDegrees()..target.longitude.toDegrees())
|
||||
assertTrue(mapState.centerLat in home.latitude.toDegrees()..target.latitude.toDegrees())
|
||||
assertTrue(mapState.zoom < 15f, "a path too long for zoom 15 was not fitted")
|
||||
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
|
||||
}
|
||||
|
||||
/**
|
||||
* With grouping on, a selection zooms in only as far as it takes to break the station out
|
||||
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
|
||||
*/
|
||||
/** Grouping never forces a path which already fits to zoom in. */
|
||||
@Test
|
||||
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
|
||||
fun groupingDoesNotZoomInWhileFittingASelectedPath() = runComposeUiTest {
|
||||
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
|
||||
val state = windowState(prefs)
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val far = mapLookingAt(ownLocator, zoom = 5f)
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO40HB")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 5f)
|
||||
|
||||
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
|
||||
show(state, listOf(dl1abc), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
|
||||
assertEquals(5f, mapState.zoom)
|
||||
}
|
||||
|
||||
/** Without a running application there is nothing to ask, and the map stays put. */
|
||||
/** Camera navigation is independent of the optional path-analysis service. */
|
||||
@Test
|
||||
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
|
||||
fun withoutAPathAnalysisASelectionStillFitsThePath() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 15f)
|
||||
val home = Location(ownLocator)
|
||||
var asked = 0
|
||||
|
||||
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
|
||||
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
|
||||
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
|
||||
assertEquals(15f, mapState.zoom)
|
||||
assertTrue(mapState.zoom < 15f)
|
||||
}
|
||||
|
||||
/** The two header switches write the profile, so the map opens the same way next time. */
|
||||
@@ -477,6 +467,54 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(listOf(true, false), applied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerLegendFollowsDrawableAntennaAndSpatialFilterOverlays() = runComposeUiTest {
|
||||
val prefs = prefs()
|
||||
val state = windowState(prefs)
|
||||
|
||||
show(state, emptyList(), MapState())
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
|
||||
runOnIdle {
|
||||
state.stationFilter.maxQrbKm = 666.0
|
||||
state.stationFilter.maxQrbEnabled = true
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertExists()
|
||||
|
||||
runOnIdle {
|
||||
prefs.stn_antennaBeamWidthDeg = 0.0
|
||||
prefs.stn_maxQRBDefault = 0.0
|
||||
state.refreshMapDisplaySettings()
|
||||
state.stationFilter.maxQrbEnabled = false
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Antenna").assertDoesNotExist()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSpatialFilterDoesNotClaimAVisibleOverlay() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
state.stationFilter.maxQrbKm = 0.0
|
||||
state.stationFilter.maxQrbEnabled = true
|
||||
|
||||
show(state, emptyList(), MapState())
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
|
||||
runOnIdle { state.stationFilter.maxQrbKm = Double.NaN }
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
@@ -553,13 +591,9 @@ class StationMapWindowScreenTest {
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected station the filters hide has no marker, and so nothing to analyse: no
|
||||
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
|
||||
* The visible station beside it is the control: selecting it does ask and does load.
|
||||
*/
|
||||
/** A filtered-out selection remains a separate target without entering the marker count. */
|
||||
@Test
|
||||
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
|
||||
fun aSelectedStationTheFiltersHideKeepsItsTargetAndPathAnalysis() = runComposeUiTest {
|
||||
val state = windowState(prefs(pathAnalysisVisible = true))
|
||||
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
|
||||
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
|
||||
@@ -568,21 +602,128 @@ class StationMapWindowScreenTest {
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
val analysis = RecordingPathAnalysis()
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9f)
|
||||
val home = Location(ownLocator)
|
||||
|
||||
show(state, listOf(hidden, shown), mapState, analysis)
|
||||
|
||||
runOnIdle { state.selectedStation.select(hidden) }
|
||||
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
|
||||
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
|
||||
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
|
||||
assertEquals(listOf(hidden), analysis.requested, "the selected hidden target was not analysed")
|
||||
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active", substring = true).assertExists()
|
||||
|
||||
runOnIdle { state.selectedStation.select(shown) }
|
||||
runOnIdle { state.selectStation(shown, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf(shown), analysis.requested)
|
||||
assertEquals(listOf(hidden, shown), analysis.requested)
|
||||
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filteredSelectedMarkerWinsHitTestingWhenAnotherMarkerOverlapsIt() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val hidden = station("DL1HIDE", 2, locator = ownLocator).apply { qrb = 100.0 }
|
||||
val shown = station("DL2SHOW", 2, locator = ownLocator).apply { qrb = 800.0 }
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
val selections = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { member, source ->
|
||||
selections += member
|
||||
state.selectedStation.select(member, source)
|
||||
}
|
||||
|
||||
show(state, listOf(hidden, shown), mapLookingAt(ownLocator, zoom = 12f))
|
||||
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
selections.clear()
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { click() }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf(hidden), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rosterReconciliationDoesNotOverwriteManualCameraMovement() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val selected = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 10f)
|
||||
show(state, listOf(selected), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectStation(selected, StationSelectionSource.PUBLIC_MESSAGE) }
|
||||
waitForIdle()
|
||||
runOnIdle {
|
||||
mapState.centerLon = 3.0
|
||||
mapState.centerLat = 48.0
|
||||
mapState.zoom = 11f
|
||||
state.stations.replaceRows(listOf(station("DL1ABC", 2, locator = "JO50JP")))
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(3.0, mapState.centerLon, 1e-9)
|
||||
assertEquals(48.0, mapState.centerLat, 1e-9)
|
||||
assertEquals(11f, mapState.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitQtfNavigationPreservesZoomAndFilterResetDoesNotNavigate() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
|
||||
show(state, emptyList(), mapState)
|
||||
|
||||
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
|
||||
waitForIdle()
|
||||
val qtfLon = mapState.centerLon
|
||||
val qtfLat = mapState.centerLat
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
assertTrue(qtfLon > Location(ownLocator).longitude.toDegrees())
|
||||
assertTrue(qtfLat > Location(ownLocator).latitude.toDegrees())
|
||||
|
||||
runOnIdle { state.resetStationFilters() }
|
||||
waitForIdle()
|
||||
assertEquals(qtfLon, mapState.centerLon, 1e-9)
|
||||
assertEquals(qtfLat, mapState.centerLat, 1e-9)
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun QtfNavigationDoesNotRequireOrChangeManualAntennaHeading() = runComposeUiTest {
|
||||
val prefs = prefs().apply {
|
||||
setStn_useQtfFilterDirectionAsAntennaHeading(false)
|
||||
actualQTF.set(123.0)
|
||||
}
|
||||
val state = windowState(prefs)
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
|
||||
show(state, emptyList(), mapState)
|
||||
|
||||
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
|
||||
waitForIdle()
|
||||
|
||||
assertTrue(mapState.centerLon > Location(ownLocator).longitude.toDegrees())
|
||||
assertTrue(mapState.centerLat > Location(ownLocator).latitude.toDegrees())
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
assertEquals(123.0, prefs.actualQTF.get(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingTheSameStationAgainRepeatsPathNavigation() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val selected = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 10f)
|
||||
show(state, listOf(selected), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectStation(selected, StationSelectionSource.TIMELINE) }
|
||||
waitForIdle()
|
||||
val firstCentre = mapState.centerLon to mapState.centerLat
|
||||
runOnIdle {
|
||||
mapState.centerLon = 2.0
|
||||
mapState.centerLat = 45.0
|
||||
state.selectStation(selected, StationSelectionSource.TIMELINE)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(firstCentre.first, mapState.centerLon, 1e-9)
|
||||
assertEquals(firstCentre.second, mapState.centerLat, 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beacon edits wait for apply, and the timer restarts only then`() {
|
||||
fun `enable changes immediately while template and interval wait for apply`() {
|
||||
val s = Session(draft = SettingsDraft())
|
||||
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
|
||||
s.prefs.setBcn_beaconsEnabledMainCat(false)
|
||||
@@ -51,7 +51,7 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
|
||||
assertNull(state.commitBeaconInterval("7"))
|
||||
|
||||
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
|
||||
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat(), "the runtime switch did not take effect immediately")
|
||||
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
|
||||
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
|
||||
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
|
||||
@@ -65,6 +65,18 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertEquals(1, s.timerRestarts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enable changes do not become pending draft edits`() {
|
||||
val s = Session(draft = SettingsDraft())
|
||||
s.prefs.setBcn_beaconsEnabledMainCat(false)
|
||||
val state = s.state()
|
||||
|
||||
state.bcn_beaconsEnabledMainCat = true
|
||||
|
||||
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
|
||||
assertFalse(s.draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarded beacon edits leave the timer and the templates alone`() {
|
||||
val s = Session(draft = SettingsDraft())
|
||||
|
||||
@@ -162,8 +162,6 @@ class DraftedSettingsTableTest {
|
||||
}, { it.prefs.getLst_txtSnipList().snapshot() }),
|
||||
|
||||
// Beacon
|
||||
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
|
||||
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
|
||||
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
|
||||
{ it.prefs.getBcn_beaconTextMainCat() }),
|
||||
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
|
||||
|
||||
@@ -3,6 +3,7 @@ package kst4contest.view.compose.tabs
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
@@ -190,6 +191,19 @@ class MessagingTabsScreenTest {
|
||||
assertEquals("cq %foo%", s.prefs.bcn_beaconTextMainCat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anExternalBeaconToggleUpdatesTheOpenSettingsTab() = runComposeUiTest {
|
||||
val s = Beacon(preferences())
|
||||
s.prefs.isBcn_beaconsEnabledMainCat = false
|
||||
showTab { BeaconTab(s.state) { s.refusals += it } }
|
||||
checkBeside("Send the beacon in this category").assertIsOff()
|
||||
|
||||
runOnIdle { s.prefs.isBcn_beaconsEnabledMainCat = true }
|
||||
waitForIdle()
|
||||
|
||||
checkBeside("Send the beacon in this category").assertIsOn()
|
||||
}
|
||||
|
||||
/*
|
||||
* The tab reads the interval from a plain preference getter, so nothing recomposes it
|
||||
* after Enter commits. Compared with that stale value, leaving the field afterwards
|
||||
|
||||
+20
-20
@@ -107,14 +107,14 @@ class SettingsApplyScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
|
||||
fun beaconEnableTakesEffectImmediatelyWhileTheIntervalWaitsForApply() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the runtime switch did not take effect immediately")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertEquals(0, w.restarts, "the timer restarted before Apply")
|
||||
|
||||
@@ -178,19 +178,19 @@ class SettingsApplyScreenTest {
|
||||
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
type("Server port", "23x")
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the valid edit went into effect despite the refusal")
|
||||
assertTrue(w.draft.hasPendingChanges)
|
||||
runOnIdle { w.notices.dismiss() }
|
||||
|
||||
runOnIdle { w.draft.discard() }
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -264,7 +264,7 @@ class SettingsApplyScreenTest {
|
||||
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
@@ -273,7 +273,7 @@ class SettingsApplyScreenTest {
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertTrue(w.closed)
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ class SettingsApplyScreenTest {
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat, "the immediate runtime switch was unexpectedly reverted")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertEquals(0, w.restarts)
|
||||
assertTrue(w.closed)
|
||||
@@ -299,7 +299,7 @@ class SettingsApplyScreenTest {
|
||||
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog("Back to the settings")).performClick()
|
||||
@@ -307,7 +307,7 @@ class SettingsApplyScreenTest {
|
||||
|
||||
assertFalse(w.closed)
|
||||
assertTrue(w.draft.hasPendingChanges)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
checkBeside("Send the beacon in this category").assertExists()
|
||||
}
|
||||
|
||||
@@ -377,13 +377,13 @@ class SettingsApplyScreenTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertTrue(wentOn)
|
||||
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
|
||||
}
|
||||
@@ -393,7 +393,7 @@ class SettingsApplyScreenTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Back to the settings")).performClick()
|
||||
@@ -408,14 +408,14 @@ class SettingsApplyScreenTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(wentOn)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -423,7 +423,7 @@ class SettingsApplyScreenTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
@@ -432,7 +432,7 @@ class SettingsApplyScreenTest {
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(wentOn, "went on over a refused entry")
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "applied despite the refusal")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -457,7 +457,7 @@ class SettingsApplyScreenTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
@@ -477,7 +477,7 @@ class SettingsApplyScreenTest {
|
||||
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
type("Server port", "23x")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
@@ -489,7 +489,7 @@ class SettingsApplyScreenTest {
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.closed)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat, "something was applied")
|
||||
}
|
||||
|
||||
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
|
||||
|
||||
@@ -20,6 +20,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
var secondApplied: ChatCategory? = null
|
||||
var secondWasApplied = false
|
||||
val secondRotatorCalls = mutableListOf<Unit>()
|
||||
var beaconStatusRefreshes = 0
|
||||
var mapDisplayRefreshes = 0
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(
|
||||
prefs,
|
||||
@@ -27,6 +29,8 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
{ mainApplied = it },
|
||||
{ secondApplied = it; secondWasApplied = true },
|
||||
applySecondRotator = { secondRotatorCalls.add(Unit) },
|
||||
refreshBeaconStatus = { beaconStatusRefreshes++ },
|
||||
applyMapDisplaySettings = { mapDisplayRefreshes++ },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -305,6 +309,34 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
assertEquals(true, state.loginToSecondChatEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applying the second chat switch refreshes aggregate beacon status`() {
|
||||
val session = Session()
|
||||
|
||||
session.state.loginToSecondChatEnabled = true
|
||||
assertEquals(0, session.beaconStatusRefreshes)
|
||||
|
||||
session.draft.apply()
|
||||
|
||||
assertEquals(1, session.beaconStatusRefreshes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beam width and maximum QRB refresh the map once after both are applied`() {
|
||||
val session = Session()
|
||||
|
||||
session.state.stn_antennaBeamWidthDeg = 72.0
|
||||
session.state.stn_maxQRBDefault = 1_250.0
|
||||
assertEquals(0, session.mapDisplayRefreshes)
|
||||
|
||||
val outcome = session.draft.apply()
|
||||
|
||||
assertTrue(outcome.isClean, outcome.failures.toString())
|
||||
assertEquals(72.0, session.prefs.stn_antennaBeamWidthDeg, 0.0001)
|
||||
assertEquals(1_250.0, session.prefs.stn_maxQRBDefault, 0.0001)
|
||||
assertEquals(1, session.mapDisplayRefreshes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the own height above sea level reads empty while the elevation model decides`() {
|
||||
val s = Session()
|
||||
|
||||
Reference in new issue
Block a user