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:
Praktimarc authored and GitHub committed 2026-11-05 00:52:17 +01:00
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)
@@ -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.
*
@@ -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)
}
}
@@ -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") }
}
}
}
@@ -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,
)
}
@@ -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
@@ -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()
@@ -0,0 +1,75 @@
package kst4contest.controller;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
/** Keeps category failures and builds the single status shown for both beacons. */
final class BeaconStatus {
static final String SOURCE_NAME = ChatController.BEACON_STATUS_SOURCE;
private boolean mainInvalid;
private boolean secondInvalid;
private String activity = "Beacon configuration loaded.";
/** Records the result of one complete timer run. */
synchronized void recordRun(
boolean mainInvalid,
boolean secondInvalid,
String activity
) {
this.mainInvalid = mainInvalid;
this.secondInvalid = secondInvalid;
this.activity = activity;
}
/** Updates the explanation without hiding a still-relevant category failure. */
synchronized void recordActivity(String activity) {
this.activity = activity;
}
/** Builds a fresh aggregate from the current preferences and remembered failures. */
synchronized ThreadStateMessage message(ChatPreferences preferences) {
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean mainEnabled = preferences.isBcn_beaconsEnabledMainCat();
boolean secondEnabled = secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat();
int available = secondAvailable ? 2 : 1;
int enabled = (mainEnabled ? 1 : 0) + (secondEnabled ? 1 : 0);
boolean critical = (mainInvalid && mainEnabled)
|| (secondInvalid && secondEnabled);
String description;
if (critical) {
description = "FAILED";
} else if (enabled == 0) {
description = "off";
} else if (enabled == available) {
description = "on";
} else {
description = enabled + "/" + available;
}
String details = "Main category beacon: " + onOff(mainEnabled)
+ "; second category beacon: "
+ (secondAvailable
? onOff(secondEnabled)
: "unavailable");
String information = activity == null || activity.isBlank()
? details
: activity + "\n" + details;
ThreadStateMessage message = new ThreadStateMessage(
SOURCE_NAME,
enabled > 0,
information,
critical
);
message.setRunningInformationTextDescription(description);
return message;
}
private static String onOff(boolean enabled) {
return enabled ? "on" : "off";
}
}
@@ -4,7 +4,6 @@ import java.util.TimerTask;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ThreadStateMessage;
/**
* Sends the configured public-chat beacons for both active chat categories.
@@ -20,10 +19,9 @@ import kst4contest.model.ThreadStateMessage;
*/
public class BeaconTask extends TimerTask {
private static final String THREAD_NICKNAME = "MyBeacon";
private final ChatController chatController;
private final ThreadStatusCallback callbackToController;
private final BeaconStatus beaconStatus;
/**
* Creates one execution of the shared beacon timer.
@@ -37,30 +35,34 @@ public class BeaconTask extends TimerTask {
) {
this.chatController = chatController;
this.callbackToController = callbackToController;
this.beaconStatus = chatController.beaconStatusState();
}
@Override
public void run() {
Thread.currentThread().setName("BeaconTask");
reportStatus(THREAD_NICKNAME, true, "initialized", false);
sendMainCategoryBeacon();
sendSecondCategoryBeacon();
Attempt main = sendMainCategoryBeacon();
Attempt second = sendSecondCategoryBeacon();
String activity = "Main category: " + main.description
+ "; second category: " + second.description;
beaconStatus.recordRun(
main == Attempt.INVALID,
second == Attempt.INVALID,
activity
);
callbackToController.onThreadStatus(
BeaconStatus.SOURCE_NAME,
beaconStatus.message(chatController.getChatPreferences())
);
}
/**
* Sends the main-category beacon if it is currently enabled.
*/
private void sendMainCategoryBeacon() {
private Attempt sendMainCategoryBeacon() {
if (!chatController.getChatPreferences()
.isBcn_beaconsEnabledMainCat()) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"off",
false
);
return;
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -72,13 +74,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 1",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -88,31 +84,19 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 1",
true,
"on",
false
);
return Attempt.SENT;
}
/**
* Sends the second-category beacon if the second login and its beacon are
* currently enabled.
*/
private void sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences()
.isLoginToSecondChatEnabled()
|| !chatController.getChatPreferences()
.isBcn_beaconsEnabledSecondCat()) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"off",
false
);
return;
private Attempt sendSecondCategoryBeacon() {
if (!chatController.getChatPreferences().isLoginToSecondChatEnabled()) {
return Attempt.UNAVAILABLE;
}
if (!chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) {
return Attempt.OFF;
}
ChatMessage beaconMessage = buildBeaconMessage(
@@ -124,13 +108,7 @@ public class BeaconTask extends TimerTask {
);
if (beaconMessage == null) {
reportStatus(
THREAD_NICKNAME + " 2",
false,
"invalid text",
true
);
return;
return Attempt.INVALID;
}
System.out.println(
@@ -140,13 +118,7 @@ public class BeaconTask extends TimerTask {
);
chatController.getMessageTXBus().add(beaconMessage);
reportStatus(
THREAD_NICKNAME + " 2",
true,
"on",
false
);
return Attempt.SENT;
}
/**
@@ -199,24 +171,16 @@ public class BeaconTask extends TimerTask {
}
}
/**
* Forwards one state update to the existing thread-status display.
*/
private void reportStatus(
String threadName,
boolean running,
String information,
boolean criticalState
) {
ThreadStateMessage stateMessage = new ThreadStateMessage(
threadName,
running,
information,
criticalState
);
callbackToController.onThreadStatus(
THREAD_NICKNAME,
stateMessage
);
private enum Attempt {
SENT("sent"),
OFF("off"),
UNAVAILABLE("unavailable"),
INVALID("invalid text");
private final String description;
Attempt(String description) {
this.description = description;
}
}
}
}
@@ -64,9 +64,11 @@ public class ChatController implements ThreadStatusCallback {
private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final String BEACON_STATUS_SOURCE = "MyBeacon";
public static final int MIN_BEACON_INTERVAL_MINUTES = 1;
public static final int MAX_BEACON_TEXT_LENGTH = 120;
private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L;
private final BeaconStatus beaconStatus = new BeaconStatus();
private Consumer<Double> viewRotorCallback;
@@ -3381,6 +3383,64 @@ private SimpleRoster<String>
this.dbHandler = dbHandler;
}
/** Applies the main-category beacon switch to the running session immediately. */
public void setMainBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledMainCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/** Applies the second-category beacon switch to the running session immediately. */
public void setSecondBeaconEnabled(boolean enabled) {
getChatPreferences().setBcn_beaconsEnabledSecondCat(enabled);
publishBeaconStatus("Beacon configuration changed.");
}
/**
* Toggles every beacon usable in the current chat configuration.
*
* <p>The dormant second-category preference is deliberately left unchanged when
* the second chat is disabled. Enabling that chat later therefore restores the
* operator's previous category-specific choice.</p>
*/
public void toggleUsableBeacons() {
ChatPreferences preferences = getChatPreferences();
boolean secondAvailable = preferences.isLoginToSecondChatEnabled();
boolean anyEnabled = preferences.isBcn_beaconsEnabledMainCat()
|| (secondAvailable
&& preferences.isBcn_beaconsEnabledSecondCat());
boolean enable = !anyEnabled;
preferences.setBcn_beaconsEnabledMainCat(enable);
if (secondAvailable) {
preferences.setBcn_beaconsEnabledSecondCat(enable);
}
publishBeaconStatus("Beacon status toggled from the main window.");
}
/** Publishes the loaded state without changing either category switch. */
public void refreshBeaconStatus() {
publishBeaconStatus("Beacon configuration loaded.");
}
/** Returns the shared state used by the timer and manual controls. */
BeaconStatus beaconStatusState() {
return beaconStatus;
}
/** Builds the current aggregate when the UI is ready to display it. */
public ThreadStateMessage currentBeaconStatus() {
return beaconStatus.message(getChatPreferences());
}
/** Publishes the aggregate state used by the single beacon status chip. */
private void publishBeaconStatus(String activity) {
beaconStatus.recordActivity(activity);
onThreadStatus(
BeaconStatus.SOURCE_NAME,
currentBeaconStatus()
);
}
/**
* Validates a configured beacon template before it is stored.
@@ -366,8 +366,8 @@ public class ChatPreferences {
* Beacon prefs
*/
boolean bcn_beaconsEnabledMainCat = true;
boolean bcn_beaconsEnabledSecondCat = false;
MutableValue<Boolean> bcn_beaconsEnabledMainCat = new SimpleValue<>(true);
MutableValue<Boolean> bcn_beaconsEnabledSecondCat = new SimpleValue<>(false);
int bcn_beaconIntervalInMinutesMainCat = 20;
int bcn_beaconIntervalInMinutesSecondCat = 20;
String bcn_beaconTextMainCat = "Hi, pse call us";
@@ -737,11 +737,15 @@ public class ChatPreferences {
}
public boolean isBcn_beaconsEnabledSecondCat() {
return bcn_beaconsEnabledSecondCat;
return Boolean.TRUE.equals(bcn_beaconsEnabledSecondCat.get());
}
public void setBcn_beaconsEnabledSecondCat(boolean bcn_beaconsEnabledSecondCat) {
this.bcn_beaconsEnabledSecondCat = bcn_beaconsEnabledSecondCat;
this.bcn_beaconsEnabledSecondCat.set(bcn_beaconsEnabledSecondCat);
}
public MutableValue<Boolean> bcnBeaconsEnabledSecondCatProperty() {
return bcn_beaconsEnabledSecondCat;
}
public int getBcn_beaconIntervalInMinutesSecondCat() {
@@ -1579,6 +1583,10 @@ public class ChatPreferences {
// }
public boolean isBcn_beaconsEnabledMainCat() {
return Boolean.TRUE.equals(bcn_beaconsEnabledMainCat.get());
}
public MutableValue<Boolean> bcnBeaconsEnabledMainCatProperty() {
return bcn_beaconsEnabledMainCat;
}
@@ -1601,7 +1609,7 @@ public class ChatPreferences {
}
public void setBcn_beaconsEnabledMainCat(boolean bcn_beaconsEnabledMainCat) {
this.bcn_beaconsEnabledMainCat = bcn_beaconsEnabledMainCat;
this.bcn_beaconsEnabledMainCat.set(bcn_beaconsEnabledMainCat);
}
public int getBcn_beaconIntervalInMinutesMainCat() {
@@ -3170,11 +3178,11 @@ public class ChatPreferences {
Element beaconCQEl = getFirstElement(doc, "beaconCQ");
if (beaconCQEl != null) {
bcn_beaconsEnabledMainCat = getBoolean(beaconCQEl, bcn_beaconsEnabledMainCat, "bcn_beaconsEnabledMainCat", "beaconCQEnabled");
setBcn_beaconsEnabledMainCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledMainCat(), "bcn_beaconsEnabledMainCat", "beaconCQEnabled"));
bcn_beaconIntervalInMinutesMainCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesMainCat, "bcn_beaconIntervalInMinutesMainCat", "beaconCQIntervalMinutes");
bcn_beaconTextMainCat = getText(beaconCQEl, bcn_beaconTextMainCat, "bcn_beaconTextMainCat", "beaconCQText");
bcn_beaconsEnabledSecondCat = getBoolean(beaconCQEl, bcn_beaconsEnabledSecondCat, "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat");
setBcn_beaconsEnabledSecondCat(getBoolean(beaconCQEl, isBcn_beaconsEnabledSecondCat(), "bcn_beaconsEnabledSecondCat", "beaconCQEnabledSecondCat"));
bcn_beaconIntervalInMinutesSecondCat = getInt(beaconCQEl, bcn_beaconIntervalInMinutesSecondCat, "bcn_beaconIntervalInMinutesSecondCat", "beaconCQIntervalMinutesSecondCat");
bcn_beaconTextSecondCat = getText(beaconCQEl, bcn_beaconTextSecondCat, "bcn_beaconTextSecondCat", "beaconCQTextSecondText");
@@ -1,7 +1,9 @@
package kst4contest.view.map;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Locale;
/**
@@ -81,12 +83,39 @@ public final class MaidenheadGridUtils {
double northLat,
double eastLon,
GridPrecision precision) {
if (westLon > eastLon) {
// Anti-meridian handling is not needed for Europe in this project stage.
if (!Double.isFinite(westLon) || !Double.isFinite(eastLon)) {
return List.of();
}
double unwrappedEast = eastLon;
if (unwrappedEast < westLon) {
unwrappedEast += 360.0;
}
if (unwrappedEast - westLon >= 360.0) {
return buildVisibleCellsWithinWorld(southLat, -180.0, northLat, 180.0, precision);
}
int firstWorld = (int) Math.floor((westLon + 180.0) / 360.0);
int lastWorld = (int) Math.floor((unwrappedEast + 180.0 - EPSILON) / 360.0);
Map<String, GridCell> cells = new LinkedHashMap<>();
for (int world = firstWorld; world <= lastWorld; world++) {
double worldWest = -180.0 + world * 360.0;
double worldEast = 180.0 + world * 360.0;
double localWest = Math.max(westLon, worldWest) - world * 360.0;
double localEast = Math.min(unwrappedEast, worldEast) - world * 360.0;
buildVisibleCellsWithinWorld(southLat, localWest, northLat, localEast, precision)
.forEach(cell -> cells.putIfAbsent(cell.locatorLabel(), cell));
}
return List.copyOf(cells.values());
}
private static List<GridCell> buildVisibleCellsWithinWorld(double southLat,
double westLon,
double northLat,
double eastLon,
GridPrecision precision) {
double clampedSouth = clamp(southLat, -90.0 + EPSILON, 90.0 - EPSILON);
double clampedNorth = clamp(northLat, -90.0 + EPSILON, 90.0 - EPSILON);
double clampedWest = clamp(westLon, -180.0 + EPSILON, 180.0 - EPSILON);
@@ -217,4 +246,4 @@ public final class MaidenheadGridUtils {
private static double clamp(double value, double min, double max) {
return Math.max(min, Math.min(max, value));
}
}
}
@@ -8,6 +8,8 @@ public final class MapMarkerCluster {
private final List<MapCallsignRawSnapshot> markers = new ArrayList<>();
private double sumLon = 0.0;
private double sumLat = 0.0;
private double sumLonSin = 0.0;
private double sumLonCos = 0.0;
public MapMarkerCluster(MapCallsignRawSnapshot first) {
add(first);
@@ -17,10 +19,16 @@ public final class MapMarkerCluster {
markers.add(marker);
sumLon += marker.longitudeDeg();
sumLat += marker.latitudeDeg();
double longitudeRad = Math.toRadians(marker.longitudeDeg());
sumLonSin += Math.sin(longitudeRad);
sumLonCos += Math.cos(longitudeRad);
}
public double getCenterLon() {
return sumLon / markers.size();
if (Math.abs(sumLonSin) < 1e-12 && Math.abs(sumLonCos) < 1e-12) {
return sumLon / markers.size();
}
return Math.toDegrees(Math.atan2(sumLonSin, sumLonCos));
}
public double getCenterLat() {
@@ -99,6 +99,21 @@ public final class MapViewport {
return widthPx / 2.0 + (WebMercatorProjection.lonToX(lon, tileZoom) - centerWorldX) * tileScale;
}
/**
* Places a longitude in the closest wrapped copy of the world.
*
* <p>A target at 179 degrees east and a centre at 179 degrees west are two degrees
* apart, not 358. Tiles already wrap at the anti-meridian; overlays and hit testing
* need the same world copy to stay aligned with them.</p>
*/
public double screenXWrapped(double lon) {
double worldSize = TILE_SIZE_PX * Math.pow(2.0, tileZoom);
double worldX = WebMercatorProjection.lonToX(lon, tileZoom);
double delta = worldX - centerWorldX;
delta -= Math.rint(delta / worldSize) * worldSize;
return widthPx / 2.0 + delta * tileScale;
}
public double screenY(double lat) {
return heightPx / 2.0 + (WebMercatorProjection.latToY(lat, tileZoom) - centerWorldY) * tileScale;
}
@@ -0,0 +1,153 @@
package kst4contest.view.map;
/** Pure viewport calculations for deliberate station-map navigation. */
public final class MapViewportNavigation {
private static final double WEB_MERCATOR_MAX_LATITUDE = 85.05112878;
private MapViewportNavigation() {
}
/**
* Centres the path and zooms out only when both endpoints do not fit with padding.
*
* @return the fitted viewport, or the original viewport for invalid positions or size
*/
public static MapViewport fitPath(MapViewport current,
double homeLatitudeDeg,
double homeLongitudeDeg,
double targetLatitudeDeg,
double targetLongitudeDeg,
double paddingPx) {
if (current == null
|| !isPosition(homeLatitudeDeg, homeLongitudeDeg)
|| !isPosition(targetLatitudeDeg, targetLongitudeDeg)
|| current.widthPx() <= 0.0
|| current.heightPx() <= 0.0) {
return current;
}
double padding = Math.max(0.0, paddingPx);
double availableWidth = current.widthPx() - 2.0 * padding;
double availableHeight = current.heightPx() - 2.0 * padding;
if (availableWidth <= 0.0 || availableHeight <= 0.0) {
return current;
}
double homeWorldX = WebMercatorProjection.lonToX(homeLongitudeDeg, 0);
double targetWorldX = WebMercatorProjection.lonToX(targetLongitudeDeg, 0);
double worldSize = 256.0;
double deltaX = targetWorldX - homeWorldX;
deltaX -= Math.rint(deltaX / worldSize) * worldSize;
double centerWorldX = homeWorldX + deltaX / 2.0;
double homeWorldY = WebMercatorProjection.latToY(homeLatitudeDeg, 0);
double targetWorldY = WebMercatorProjection.latToY(targetLatitudeDeg, 0);
double centerWorldY = (homeWorldY + targetWorldY) / 2.0;
double centerLongitude = WebMercatorProjection.xToLon(centerWorldX, 0);
double centerLatitude = WebMercatorProjection.yToLat(centerWorldY, 0);
MapViewport centred = new MapViewport(
centerLongitude,
centerLatitude,
current.zoom(),
current.widthPx(),
current.heightPx()
);
double widthAtCurrentZoom = Math.abs(
centred.screenXWrapped(targetLongitudeDeg)
- centred.screenXWrapped(homeLongitudeDeg)
);
double heightAtCurrentZoom = Math.abs(
centred.screenY(targetLatitudeDeg) - centred.screenY(homeLatitudeDeg)
);
double widthRatio = widthAtCurrentZoom == 0.0
? Double.POSITIVE_INFINITY
: availableWidth / widthAtCurrentZoom;
double heightRatio = heightAtCurrentZoom == 0.0
? Double.POSITIVE_INFINITY
: availableHeight / heightAtCurrentZoom;
double fitRatio = Math.min(widthRatio, heightRatio);
if (!Double.isFinite(fitRatio) || fitRatio >= 1.0) {
return centred;
}
double fittedZoom = current.zoom() + Math.log(fitRatio) / Math.log(2.0);
return new MapViewport(
centerLongitude,
centerLatitude,
fittedZoom,
current.widthPx(),
current.heightPx()
);
}
/**
* Leaves the zoom unchanged and places most of the viewport in the chosen direction.
*/
public static MapViewport centreOnBearing(MapViewport current,
double homeLatitudeDeg,
double homeLongitudeDeg,
double bearingDeg,
double forwardCentreFraction) {
if (current == null
|| !isPosition(homeLatitudeDeg, homeLongitudeDeg)
|| !Double.isFinite(bearingDeg)
|| current.widthPx() <= 0.0
|| current.heightPx() <= 0.0) {
return current;
}
double fraction = Math.max(0.0, Math.min(0.45, forwardCentreFraction));
double shiftPx = Math.min(current.widthPx(), current.heightPx()) * fraction;
double bearingRad = Math.toRadians(normaliseBearing(bearingDeg));
MapViewport homeCentred = new MapViewport(
homeLongitudeDeg,
homeLatitudeDeg,
current.zoom(),
current.widthPx(),
current.heightPx()
);
double centreX = current.widthPx() / 2.0 + Math.sin(bearingRad) * shiftPx;
double centreY = current.heightPx() / 2.0 - Math.cos(bearingRad) * shiftPx;
return new MapViewport(
homeCentred.lonAt(centreX),
homeCentred.latAt(centreY),
current.zoom(),
current.widthPx(),
current.heightPx()
);
}
public static MapViewport centreOnHome(MapViewport current,
double homeLatitudeDeg,
double homeLongitudeDeg) {
if (current == null || !isPosition(homeLatitudeDeg, homeLongitudeDeg)) {
return current;
}
return new MapViewport(
homeLongitudeDeg,
homeLatitudeDeg,
current.zoom(),
current.widthPx(),
current.heightPx()
);
}
private static boolean isPosition(double latitudeDeg, double longitudeDeg) {
return Double.isFinite(latitudeDeg)
&& Double.isFinite(longitudeDeg)
&& latitudeDeg >= -WEB_MERCATOR_MAX_LATITUDE
&& latitudeDeg <= WEB_MERCATOR_MAX_LATITUDE;
}
private static double normaliseBearing(double bearingDeg) {
double normalised = bearingDeg % 360.0;
return normalised < 0.0 ? normalised + 360.0 : normalised;
}
}
@@ -91,7 +91,7 @@ public final class StationMapClusterer {
continue;
}
long cellX = (long) Math.floor(viewport.screenX(marker.longitudeDeg()) / cellSizePx);
long cellX = (long) Math.floor(viewport.screenXWrapped(marker.longitudeDeg()) / cellSizePx);
long cellY = (long) Math.floor(viewport.screenY(marker.latitudeDeg()) / cellSizePx);
buckets.computeIfAbsent(cellKey(cellX, cellY), key -> new ArrayList<>()).add(marker);
@@ -0,0 +1,97 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconControlTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private ChatController controller;
private StatusUpdateListener listener;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
controller = new ChatController();
controller.setChatPreferences(preferences);
listener = mock(StatusUpdateListener.class);
controller.setStatusListener(listener);
}
@Test
void toggleWithSecondChatDisabledChangesOnlyTheMainBeacon() {
preferences.setLoginToSecondChatEnabled(false);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat(),
"the dormant second-category choice must not be overwritten");
assertLastDescription("on");
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void toggleWithSecondChatEnabledSwitchesBothUsableBeacons() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("on");
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.toggleUsableBeacons();
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("off");
}
@Test
void anIndividualCheckboxPublishesThePartialStateImmediately() {
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
controller.setMainBeaconEnabled(true);
assertTrue(preferences.isBcn_beaconsEnabledMainCat());
assertFalse(preferences.isBcn_beaconsEnabledSecondCat());
assertLastDescription("1/2");
}
private void assertLastDescription(String expected) {
ArgumentCaptor<String> key = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<ThreadStateMessage> message = ArgumentCaptor.forClass(ThreadStateMessage.class);
org.mockito.Mockito.verify(listener, org.mockito.Mockito.atLeastOnce())
.onThreadStatusChanged(key.capture(), message.capture());
assertEquals("MyBeacon", key.getValue());
assertEquals(expected, message.getValue().getRunningInformationTextDescription());
}
}
@@ -0,0 +1,123 @@
package kst4contest.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import kst4contest.model.ChatCategory;
import kst4contest.model.ChatMessage;
import kst4contest.model.ChatPreferences;
import kst4contest.model.ThreadStateMessage;
class BeaconTaskTest {
@TempDir
Path tempDir;
private ChatPreferences preferences;
private LinkedBlockingQueue<ChatMessage> txBus;
private List<ThreadStateMessage> statuses;
private ChatController controller;
private BeaconTask task;
@BeforeEach
void setUp() {
preferences = new ChatPreferences(tempDir.resolve("preferences.xml"));
preferences.setLoginChatCategoryMain(new ChatCategory(ChatCategory.VUHF));
preferences.setLoginChatCategorySecond(new ChatCategory(ChatCategory.MICROWAVE));
preferences.setLoginToSecondChatEnabled(true);
preferences.setBcn_beaconTextMainCat("CQ MAIN");
preferences.setBcn_beaconTextSecondCat("CQ SECOND");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(false);
statuses = new ArrayList<>();
controller = new ChatController();
controller.setChatPreferences(preferences);
txBus = new LinkedBlockingQueue<>();
controller.setMessageTXBus(txBus);
controller.setStatusListener(new StatusUpdateListener() {
@Override
public void onThreadStatusChanged(String key, ThreadStateMessage message) {
statuses.add(message);
}
@Override
public void onUserListUpdated() {
// Not used by beacon tests.
}
@Override
public void onUserListUpdated(String reason) {
// Not used by beacon tests.
}
});
task = new BeaconTask(controller, (key, message) -> statuses.add(message));
}
@Test
void invalidEnabledBeaconIsNotQueuedAndReportsOneCriticalAggregateState() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
assertTrue(txBus.isEmpty());
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isRunning());
assertTrue(status.isCriticalState());
}
@Test
void changingTheOtherCheckboxDoesNotHideAnActiveCategoryFailure() {
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconTextMainCat("invalid|template");
task.run();
statuses.clear();
controller.setSecondBeaconEnabled(true);
assertEquals(1, statuses.size());
ThreadStateMessage status = statuses.get(0);
assertEquals("FAILED", status.getRunningInformationTextDescription());
assertTrue(status.isCriticalState());
}
@Test
void everyRunUsesTheCurrentEnableFlagsAndReportsOneAggregateState() {
task.run();
assertTrue(txBus.isEmpty());
assertAggregateStatus("off", false);
statuses.clear();
preferences.setBcn_beaconsEnabledMainCat(true);
task.run();
assertEquals(1, txBus.size());
assertEquals(ChatCategory.VUHF, txBus.peek().getChatCategory().getCategoryNumber());
assertAggregateStatus("1/2", true);
statuses.clear();
preferences.setBcn_beaconsEnabledSecondCat(true);
task.run();
assertEquals(3, txBus.size());
assertAggregateStatus("on", true);
}
private void assertAggregateStatus(String description, boolean running) {
assertEquals(1, statuses.size(), "one timer run must leave one authoritative chip state");
ThreadStateMessage status = statuses.get(0);
assertEquals(description, status.getRunningInformationTextDescription());
assertEquals(running, status.isRunning());
assertFalse(status.isCriticalState());
}
}
@@ -0,0 +1,57 @@
package kst4contest.model;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ChatPreferencesBeaconStateTest {
@TempDir
Path tempDir;
@Test
void beaconActivationChangesAreObservableAndIndependent() {
ChatPreferences preferences = preferences("observable.xml");
List<Boolean> mainChanges = new ArrayList<>();
List<Boolean> secondChanges = new ArrayList<>();
preferences.bcnBeaconsEnabledMainCatProperty().addListener(mainChanges::add);
preferences.bcnBeaconsEnabledSecondCatProperty().addListener(secondChanges::add);
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertFalse(preferences.isBcn_beaconsEnabledMainCat());
assertTrue(preferences.isBcn_beaconsEnabledSecondCat());
assertEquals(List.of(false), mainChanges);
assertEquals(List.of(true), secondChanges);
}
@Test
void explicitWritePersistsBeaconActivationButAnUnsavedChangeDoesNot() {
ChatPreferences preferences = preferences("persistence.xml");
preferences.setBcn_beaconsEnabledMainCat(false);
preferences.setBcn_beaconsEnabledSecondCat(true);
assertTrue(preferences.writePreferencesToXmlFile());
preferences.setBcn_beaconsEnabledMainCat(true);
preferences.setBcn_beaconsEnabledSecondCat(false);
assertTrue(preferences.writeLayoutPreferencesToXmlFile());
ChatPreferences restored = preferences("persistence.xml");
restored.readPreferencesFromXmlFile();
assertFalse(restored.isBcn_beaconsEnabledMainCat());
assertTrue(restored.isBcn_beaconsEnabledSecondCat());
}
private ChatPreferences preferences(String fileName) {
return new ChatPreferences(tempDir.resolve(fileName));
}
}
@@ -0,0 +1,30 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MaidenheadGridUtilsTest {
@Test
void a_view_crossing_the_anti_meridian_contains_cells_from_both_world_edges() {
var cells = MaidenheadGridUtils.buildVisibleCells(
-2.0,
175.0,
2.0,
185.0,
MaidenheadGridUtils.GridPrecision.FIELD_2
);
Set<String> labels = cells.stream()
.map(MaidenheadGridUtils.GridCell::locatorLabel)
.collect(Collectors.toSet());
assertTrue(labels.stream().anyMatch(label -> label.startsWith("R")), labels.toString());
assertTrue(labels.stream().anyMatch(label -> label.startsWith("A")), labels.toString());
assertEquals(labels.size(), cells.size(), "a wrapped cell was returned twice");
}
}
@@ -0,0 +1,70 @@
package kst4contest.view.map;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapViewportNavigationTest {
private static final double PADDING_PX = 48.0;
@Test
void a_path_that_fits_keeps_the_existing_zoom() {
MapViewport before = new MapViewport(10.0, 51.0, 8.5, 1000.0, 700.0);
MapViewport fitted = MapViewportNavigation.fitPath(
before, 51.0, 10.0, 51.2, 10.4, PADDING_PX);
assertEquals(8.5, fitted.zoom(), 1e-9);
assertInsidePadding(fitted, 51.0, 10.0);
assertInsidePadding(fitted, 51.2, 10.4);
}
@Test
void a_long_path_zooms_out_until_both_endpoints_fit() {
MapViewport before = new MapViewport(10.0, 51.0, 12.0, 900.0, 600.0);
MapViewport fitted = MapViewportNavigation.fitPath(
before, 51.0, 10.0, 44.0, -3.0, PADDING_PX);
assertTrue(fitted.zoom() < before.zoom());
assertInsidePadding(fitted, 51.0, 10.0);
assertInsidePadding(fitted, 44.0, -3.0);
}
@Test
void fitting_across_the_anti_meridian_uses_the_short_path() {
MapViewport before = new MapViewport(180.0, 0.0, 7.0, 900.0, 600.0);
MapViewport fitted = MapViewportNavigation.fitPath(
before, 0.0, 179.0, 0.0, -179.0, PADDING_PX);
assertEquals(7.0, fitted.zoom(), 1e-9, "two degrees should not look like a world-spanning path");
assertInsidePadding(fitted, 0.0, 179.0);
assertInsidePadding(fitted, 0.0, -179.0);
}
@Test
void bearing_navigation_preserves_zoom_and_moves_towards_the_sector() {
MapViewport before = new MapViewport(10.0, 51.0, 8.25, 1000.0, 600.0);
MapViewport northEast = MapViewportNavigation.centreOnBearing(
before, 51.0, 10.0, 45.0, 0.25);
assertEquals(8.25, northEast.zoom(), 1e-9);
assertTrue(northEast.centerLon() > 10.0);
assertTrue(northEast.centerLat() > 51.0);
assertTrue(northEast.screenXWrapped(10.0) < northEast.widthPx() / 2.0);
assertTrue(northEast.screenY(51.0) > northEast.heightPx() / 2.0);
}
private static void assertInsidePadding(MapViewport viewport, double latitude, double longitude) {
double x = viewport.screenXWrapped(longitude);
double y = viewport.screenY(latitude);
assertTrue(x >= PADDING_PX - 1e-6 && x <= viewport.widthPx() - PADDING_PX + 1e-6,
"longitude outside padded viewport at x=" + x);
assertTrue(y >= PADDING_PX - 1e-6 && y <= viewport.heightPx() - PADDING_PX + 1e-6,
"latitude outside padded viewport at y=" + y);
}
}
@@ -203,4 +203,12 @@ class MapViewportTest {
assertEquals(179.0, new MapViewport(-181.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG);
assertEquals(0.0, new MapViewport(360.0, 0.0, 6.0, 800.0, 600.0).centerLon(), TOLERANCE_DEG);
}
@Test
void wrapped_screen_coordinates_use_the_nearest_world_copy() {
MapViewport viewport = new MapViewport(179.0, 0.0, 6.0, 800.0, 600.0);
assertTrue(Math.abs(viewport.screenXWrapped(-179.0) - viewport.screenXWrapped(179.0)) < 100.0);
assertTrue(Math.abs(viewport.screenX(-179.0) - viewport.screenX(179.0)) > 10_000.0);
}
}
@@ -174,6 +174,21 @@ class StationMapClustererTest {
assertEquals(stations.size(), clustered + result.individual().size());
}
@Test
void nearby_stations_across_the_anti_meridian_share_a_cluster() {
MapViewport viewport = new MapViewport(180.0, 0.0, 5.0, 1000.0, 800.0);
List<MapCallsignRawSnapshot> stations = List.of(
station("ZL1AAA", 0.0, 179.9),
station("ZL1BBB", 0.0, -179.9),
station("ZL1CCC", 0.01, 179.8)
);
StationMapClusterer.Result result = StationMapClusterer.cluster(stations, viewport, true);
assertEquals(1, result.clusters().size());
assertTrue(Math.abs(Math.abs(result.clusters().get(0).getCenterLon()) - 180.0) < 0.2);
}
/**
* Selecting a station moves the map to it. It must not throw the operator's zoom
* away doing so.
+9 -5
View File
@@ -186,23 +186,25 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Contest operating speed and low-friction interaction are primary goals.
- Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text.
- Map reset clears the selected target without changing zoom unless explicitly redesigned.
- Map reset clears the selected target and recentres on the local station without changing zoom.
- **Grouping** changes only spatial clustering of the existing Compose map markers. It must not reload tiles or station data, request a new controller snapshot, or change zoom, viewport or selection.
- Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers.
- A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next `showWindow()` performs one current full refresh before normal visible-window updates resume.
- Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged.
- Maidenhead grid construction depends only on viewport bounds, zoom and Compose-canvas size. Unwrapped viewport bounds may cross the international date line and must be split into valid world intervals without duplicating grid cells. Ordinary station-data refreshes must not rebuild the grid when those inputs are unchanged.
- Panning updates viewport and grid state through `notifyViewport()` without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer.
- Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts.
- Station selection preserves the established `/cq callsign` prefill behaviour.
- `StationSelectionState` is the canonical selection shared by the station table and Further Info panel. Its identity is the complete callsign plus chat category. Explicit actions carry a `StationSelectionSource` and increment a separate scroll-request revision even when the same station is selected again; roster reconciliation replaces the selected instance without moving the table.
- `StationSelectionState` is the canonical selection shared by the station table and Further Info panel. Its identity is the complete callsign plus chat category. Explicit actions carry a `StationSelectionSource` and increment separate scroll and explicit-selection revisions even when the same station is selected again; roster reconciliation replaces the selected instance without moving the table or map.
- Recent-join state is transient domain state keyed by complete callsign plus chat category. Only live `UA5`/`UA2` frames can create an entry; the `UA0`/`UE` initial or reconnect snapshot and `UM3` profile updates never do. Duplicate live updates do not renew the timestamp, a leave removes the entry, and a genuine rejoin begins a new ten-minute interval. The local login callsign is excluded. The core `ObservableRoster` is mirrored through `UiDispatcher`; New-joined actions reuse the canonical station selection and scroll request.
- The station table starts with a case-insensitive ascending callsign sort. A user-selected sort remains authoritative across roster and filter updates. An externally selected filtered station is pinned into the table only; `filteredRows` remains the exact filter result used by the map.
- The station table starts with a case-insensitive ascending callsign sort. A user-selected sort remains authoritative across roster and filter updates. An externally selected filtered station is pinned into the table. `filteredRows` remains the exact filter result used for ordinary map markers; the map renders the current filtered-out selection separately without counting or clustering it.
- PM, Public, QSO-of-the-other, priority, timeline and individual map-marker actions route through the application-level station selector. Directed messages target the sender, except that an outgoing local PM targets its receiver. Public and QSO-of-the-other rows target their sender in the message category. Direct clicks and context clicks inside the station table select without issuing an automatic scroll request; selections originating in the map, messages, priority, New joined or other station controls issue one even when the identity was already selected.
- The selected-station message radio mode is independent of station identity and always retains the complete callsign plus chat category. `PM_TO_ME` is the private conversation with the local station plus public traffic from the selected station carrying the structured leading `(LOCALCALL)` address, `PM_TO_OTHER` is private traffic sent by the selected station to other participants, `PUBLIC` is traffic sent by it to `ALL`, and `NOTHING` applies no additional type/receiver restriction. An arbitrary callsign mention in prose is not a structured public address.
- ON4KST chat parsing records `messageDirectedToCommunity` from the receiver field and `messageDirectedToMe` from either a genuine private receiver or the exact leading public address. The selected-station Compose message mirror is initialized with the main window state, before the first selection callback, and roster changes recompute and push the filtered rows automatically.
- The map's **Reset filters** action uses the same `StationFilterState.reset()` path as the main filter bar and does not change sort, selection, zoom, viewport or the display-only Grid-color toggle. Individual raw-callsign markers retain an already selected category variant; otherwise they use the configured Main category. Spatial clusters zoom and never choose an arbitrary member.
- The user list and map share one station context-menu implementation. A secondary click selects a unique target through `StationSelectionState`; base-call markers or screen clusters containing several full-callsign/category identities first require an explicit target choice. Message snippets reuse the existing send-field action, and the context sked editor owns an isolated draft with the target fixed at opening time.
- Map markers draw only the display callsign; bands, `B+`, locator and QRG details remain in tooltips. When Grid color is enabled, the canvas draws a black outline and a 30 percent black fill overlay for four-character squares from `WorkedGrossFieldCache.snapshotWorkedGrossFieldsAny()`, independently of visible station markers. The cache is rebuilt from SQLite at startup and is the same any-band state used by **Only new grids**.
- Explicit station selections publish a map-navigation request separate from ordinary selected-state reconciliation. The map fits the complete home-to-target path with padding, preserves an already sufficient zoom and only zooms out when required. Explicit QTF field and compass-button actions preserve zoom and shift the local station 25 percent of the shorter canvas dimension opposite the selected bearing. Background roster changes, filter restoration, reset, rotor feedback and asynchronous path analysis never publish these requests; the latest explicit request wins. A selection without valid coordinates leaves the camera unchanged. Reset view clears selection and recentres on home without changing zoom.
- Active QTF and QRB predicates are visualised by a distinct semi-transparent overlay using the same filter state and semantics: minimum QRB is exclusive, maximum QRB inclusive, and the QTF sector uses the configured complete beamwidth. An unbounded distance range is clipped by the visible canvas rather than a fabricated radio range. An empty minimum/maximum combination has no fill but retains both boundary lines. The actual antenna beam remains a separate overlay driven by `actualQTF`, `stn_antennaBeamWidthDeg` and `stn_maxQRBDefault`; applying Station settings refreshes its Compose-observable geometry immediately without issuing rotor commands. The map header shows a localized red/blue legend only for overlays that are currently drawable, and the legend reuses the exact canvas line colours rather than defining a parallel colour scheme.
- The AP timeline renders the producer's four candidate lanes in order (two in-beam, two global priority) plus a separate sked lane. Its relative 0–30 minute axis normally uses five-minute ticks and increases the interval when labels would overlap. The vertical message splitter applies pane-specific minimums for the timeline, shortcut row and send row, so older stored divider positions cannot clip these fixed-height controls; stored positions remain the unconstrained baseline.
- Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed.
- The lower selected-station panel contains a vertical two-pane `SplitterPane`: the upper pane owns the independently scrollable message table, while the lower pane scrolls NOT-QRV and Sked above a pinned lookup group. The lower pane reserves 160 dp before stored divider positions are applied, so normal small-window layouts retain direct access to lookup actions; impossible smaller layouts keep the action groups scrollable. `GUIselectedCallSignSplitPane_dividerposition` persists this divider through the existing profile-scoped layout autosave. Station or message updates never reset it. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state.
@@ -215,6 +217,8 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen
- Automated-message loops must be prevented.
- Cooldown/minimum-interval rules must be preserved.
- A reply rejected before a complete valid TX item is queued must not consume cooldown.
- Both category beacons share one timer, while their enable flags and templates remain profile-scoped and independent. The enable flags are observable runtime preferences: Settings changes and the `MyBeacon` status-chip action update them immediately, and every timer run reads their current values without restarting the timer or causing an immediate transmission. Only the explicit full settings write persists those flags; layout autosave deliberately leaves the on-disk functional state untouched.
- `MyBeacon` owns one aggregate thread-status entry for both categories (`off`, a partial `enabled/available` count, `on`, or `FAILED`). Its action disables all currently usable beacons if any is enabled, otherwise it enables all currently usable beacons. A disabled second chat is not usable and its dormant enable flag must not be changed by the aggregate action. Category-specific timer failures remain authoritative while the affected beacon stays active; independent switch changes cannot hide them. The status bar resolves the aggregate from current controller state when a queued UI update is applied, so a delayed timer snapshot cannot overwrite a newer manual switch. Open Settings views observe the same preference values, so changes from the status chip remain synchronized without a parallel UI state.
- Current implementation/tests define the exact message markers and timer details.
## Build / Verification
@@ -413,7 +417,7 @@ Before implementing planned items, re-check current decisions and obtain a fresh
- Historical project context is useful but may be stale; current code/tests win.
- External service/API behaviour must be verified against current upstream documentation when uncertain.
- Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them.
- `station_map_path_analysis.png` and `station_map_reset.png` predate the current header controls, callsign-only markers, shared station context menu and Worked-square overlay. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`.
- `station_map_path_analysis.png` and `station_map_reset.png` predate the current header controls, callsign-only markers, shared station context menu and Worked-square overlay. The former remains useful only where the visible analysis panel itself is the subject. `station_map_spatial_filters.png` is the current general map screenshot shared by the DE/EN interface manuals and the website through `/manual/assets/`.
## Recent Significant Changes
@@ -0,0 +1,108 @@
# Live Beacon Control Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make both beacon enable switches effective immediately at runtime and make the aggregate beacon status chip an accurate shared toggle.
**Architecture:** Keep `ChatPreferences` as the profile-scoped source of truth, but expose the two activation flags as thread-safe observable values. Route settings and chip actions through `ChatController`, publish one aggregate `MyBeacon` status, and give only that thread-status chip an optional click action.
**Tech Stack:** Java 21, Kotlin 2.2, Compose Multiplatform, JUnit 5, Mockito, Gradle wrapper
**Spec:** `docs/superpowers/specs/2026-11-04-live-beacon-control-design.md`
## Global Constraints
- Work directly on `main`, as explicitly authorized.
- Do not stage, commit, push, merge, tag, release or deploy.
- Keep beacon templates, interval handling, TX framing and second-chat eligibility unchanged.
- Write source-code and configuration comments in English.
- Use focused tests first and finish with `./gradlew.bat build --console=plain`.
## Review Focus
- A disabled second chat must leave its dormant second-beacon flag unchanged when the chip is clicked.
- An external chip toggle must update an already open Beacon settings tab without reopening it.
- A runtime toggle must not restart the timer or enqueue an immediate message.
- An invalid beacon template must still be reported as a critical status rather than hidden by the aggregate state.
- Profile reload must replace the observable values without carrying the previous profile's runtime flags across.
---
### Task 1: Observable profile-scoped beacon activation
**Files:**
- Modify: `core/src/main/java/kst4contest/model/ChatPreferences.java`
- Test: `core/src/test/java/kst4contest/model/ChatPreferencesBeaconStateTest.java`
**Interfaces:**
- Produces: existing boolean getters/setters plus observable main/second beacon value accessors
- Consumes: existing `MutableValue` / `SimpleValue` infrastructure
- [x] Write tests that changes are observable, values remain independent, XML reload restores both values, and an unsaved in-memory change does not survive a fresh load.
- [x] Run `./gradlew.bat :core:test --tests kst4contest.model.ChatPreferencesBeaconStateTest --console=plain` and verify the new observable-accessor tests fail because the API is absent.
- [x] Back the two flags with `SimpleValue<Boolean>` while preserving the existing getters, setters and XML element names.
- [x] Re-run the focused test and the existing ChatPreferences tests.
### Task 2: Immediate settings effect and aggregate controller semantics
**Files:**
- Modify: `core/src/main/java/kst4contest/controller/ChatController.java`
- Modify: `core/src/main/java/kst4contest/controller/BeaconTask.java`
- Create: `core/src/main/java/kst4contest/controller/BeaconStatus.java`
- Test: `core/src/test/java/kst4contest/controller/BeaconControlTest.java`
- Test: `core/src/test/java/kst4contest/controller/BeaconTaskTest.java`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsApplyScreenTest.kt`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt`
**Interfaces:**
- Consumes: observable preference values from Task 1
- Produces: controller setters, usable-beacon toggle, aggregate status publication, Compose observation bridge
- [x] Write failing controller tests for `off`, `1/2`, `on`, main-only toggling when second chat is disabled, and both-category toggling when it is enabled.
- [x] Write failing BeaconTask tests proving a flag changed between two runs changes queued output without restarting a timer, and that the final status is aggregate.
- [x] Change Beacon settings tests so activation is immediate while templates and interval still wait for Apply; verify RED against the current draft-backed flags.
- [x] Implement the aggregate status helper and controller actions without changing the outbound-message path.
- [x] Make only the enable flags bypass `SettingsDraft`, observe their preference values in Compose, and wire their setters through the controller.
- [x] Run the core and app-desktop focused tests and verify GREEN.
### Task 3: Clickable beacon status chip
**Files:**
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt`
- Modify: `app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java`
- Modify: `app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt`
- Create or modify: focused Compose status-bar rendering test under `app-desktop/src/test/kotlin/kst4contest/view/compose/`
**Interfaces:**
- Consumes: controller usable-beacon toggle from Task 2
- Produces: optional per-source chip action registered only for `MyBeacon`
- [x] Write failing state tests for actions registered before and after the first status report and for non-action thread chips.
- [x] Write a failing Compose test proving the beacon chip invokes its action while an ordinary status chip remains non-clickable.
- [x] Add optional actions to thread-status buttons and apply button semantics only when an action exists.
- [x] Register the controller toggle for `MyBeacon` during main-window state construction.
- [x] Run the focused status tests and the affected main-window tests.
### Task 4: Documentation and verification
**Files:**
- Modify: relevant beacon sections in `github_docs/de-Konfiguration.md`
- Modify: corresponding sections in `github_docs/en-Configuration.md`
- Modify: `docs/PROJECT_CONTEXT.md`
- Review: relevant screenshots and changelog impact only
**Interfaces:**
- Consumes: completed runtime and UI behavior from Tasks 1-3
- Produces: aligned user documentation and durable state/persistence invariant
- [x] Update both manuals with immediate runtime effect, explicit-save persistence, aggregate chip behavior and second-chat handling.
- [x] Record the observable canonical state, timer-read rule and persistence boundary in `docs/PROJECT_CONTEXT.md`.
- [x] Run all focused tests, then `./gradlew.bat build --console=plain` and read test-result XML summaries.
- [x] Run `git diff --check`, inspect PMD/SpotBugs XML for findings in changed production files, and review the final diff for unrelated changes.
- [x] Leave all changes unstaged and uncommitted and report remaining manual UI checks.
@@ -0,0 +1,110 @@
# Map Overlay Legend and Screenshot Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a responsive, localized map-overlay legend and document the spatial QRB overlay with the supplied screenshot.
**Architecture:** A focused Compose legend consumes the same red antenna and theme-aware blue filter colours as the canvas. `StationMapWindow` derives visibility from the effective antenna and spatial-filter state and places the legend in the existing header `FlowRow`. Documentation receives a new immutable screenshot asset without repurposing the path-analysis image.
**Tech Stack:** Kotlin, Compose Multiplatform, generated properties-based i18n, JUnit 5 Compose UI tests, Markdown, Eleventy
**Spec:** `docs/superpowers/specs/2026-11-04-map-overlay-legend-and-screenshot-design.md`
## Global Constraints
- Preserve map filtering, station selection, antenna heading, rotor control and overlay geometry.
- Reuse existing design tokens and exact overlay colours; add no dependency.
- Keep German and English strings and documentation semantically aligned.
- Keep all changes ungestaged and uncommitted.
## Review Focus
- A map with no drawable overlay must not reserve an empty legend area; Task 1 tests both items absent.
- A QRB-only filter must show the blue item without implying QTF is active; Task 1 tests the generic **Active filters** wording.
- Invalid spatial values must not show the blue item; Task 1 uses `hasRenderableValues` as the gate.
- Narrow windows must wrap rather than clip controls; Task 1 retains the existing `FlowRow` and adds a narrow rendering assertion.
- The screenshot must not replace an image whose caption promises visible path-analysis panels; Task 2 checks all asset references.
---
### Task 1: Localized responsive overlay legend
**Files:**
- Create: `app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayLegend.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/map/SpatialFilterOverlay.kt`
- Modify: `app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt`
- Modify: `app-desktop/src/main/i18n/strings_en.properties`
- Modify: `app-desktop/src/main/i18n/strings_de.properties`
- Test: `app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt`
- Test: existing i18n-generator and translation tests
**Interfaces:**
- Consumes: antenna-sector visibility, `SpatialFilterOverlayState.isActive`, `SpatialFilterOverlayState.hasRenderableValues`, current theme.
- Produces: `MapOverlayLegend(showAntenna: Boolean, showSpatialFilters: Boolean, darkMode: Boolean)` and shared overlay-colour accessors.
- [ ] **Step 1: Add failing Compose tests**
Assert that the red item appears for a drawable antenna sector, the blue item appears only for an active valid spatial filter, both disappear with neither overlay, and the header remains laid out at a narrow test width.
- [ ] **Step 2: Run the focused screen test and confirm the missing legend fails**
Run: `./gradlew.bat :app-desktop:test --tests kst4contest.view.compose.map.StationMapWindowScreenTest --console=plain`
- [ ] **Step 3: Add localized labels and tooltip text**
Add English `Antenna`, `Active filters` and the two-colour explanation, with equivalent German strings `Antenne`, `Aktive Filter` and its explanation.
- [ ] **Step 4: Implement the legend with shared colours**
Expose the existing antenna colour and theme-aware spatial-filter line colour within the map package. Implement compact colour swatches and labels in `MapOverlayLegend.kt`, and add the component to the existing header `FlowRow` with dynamic visibility.
- [ ] **Step 5: Run focused Compose and i18n tests**
Expected: all selected tests pass in both languages; existing map interactions remain unchanged.
### Task 2: Replace the outdated general map screenshot
**Files:**
- Create: `github_docs/station_map_spatial_filters.png`
- Modify: `github_docs/de-Benutzeroberflaeche.md`
- Modify: `github_docs/en-User-Interface.md`
- Modify: `website/src/features/station-map.md`
- Modify when the durable UI invariant changes: `docs/PROJECT_CONTEXT.md`
**Interfaces:**
- Consumes: supplied 1257×1016 PNG showing `QRB > 500 km` and `QRB ≤ 666 km`.
- Produces: one shared manual/website asset copied by Eleventy to `/manual/assets/`.
- [ ] **Step 1: Copy the supplied PNG without resampling**
Verify its dimensions and file hash after copying.
- [ ] **Step 2: Update targeted DE/EN manual references**
Use captions that describe the selected path, red antenna sector and blue QRB ring. Remove only the obsolete screenshot note attached to the replaced general map image.
- [ ] **Step 3: Update the Station Map website feature page**
Reference `/manual/assets/station_map_spatial_filters.png` and retain the older path-analysis image for manual sections where the analysis panels are explicitly described.
- [ ] **Step 4: Verify asset references and website output**
Run: `npm.cmd test` and `npm.cmd run build` in `website`; verify the generated image path exists.
### Task 3: Final verification
**Files:**
- Review all files from Tasks 1 and 2.
**Interfaces:**
- Consumes: completed legend, translations and screenshot references.
- Produces: verified uncommitted working tree.
- [ ] **Step 1: Run focused map and translation tests**
- [ ] **Step 2: Run `./gradlew.bat build --console=plain` and inspect test XML plus PMD/SpotBugs reports**
- [ ] **Step 3: Run `git diff --check` and inspect `git status --short --branch`**
- [ ] **Step 4: Report native visual checks separately from automated rendering evidence**
@@ -0,0 +1,46 @@
# Live Beacon Control Design
Freigegeben von Marc am 2026-11-04.
## Ziel
Die beiden Beacon-Aktivierungsschalter wirken in der laufenden Sitzung sofort. Der gemeinsame
Beacon-Timer bleibt bestehen und liest bei jedem Lauf die aktuellen Zustände. Der Beacon-Chip
im Hauptfenster zeigt den zusammengefassten Zustand und kann die aktuell nutzbaren Beacons
gemeinsam ein- oder ausschalten.
## Verbindliches Verhalten
- Die gespeicherten Beacon-Zustände werden beim Profilstart geladen.
- Ein Klick auf eine Beacon-Checkbox wirkt sofort im Speicher und damit beim nächsten
regulären Timerlauf.
- Aktivieren sendet nicht sofort und startet den Timer nicht neu.
- Erst **Einstellungen speichern** schreibt die Zustände in die Profil-XML. Ohne explizites
Speichern gilt die Änderung nur für die laufende Sitzung.
- Beacon-Text und Intervall bleiben normale Settings-Entwurfswerte. Das Intervall startet den
Timer erst beim Anwenden neu.
- Der Status-Chip zeigt `off`, `1/2` oder `on` für die aktuell nutzbaren Kategorien.
- Sind alle nutzbaren Beacons aus, aktiviert ein Chip-Klick alle nutzbaren Beacons. Ist
mindestens eine nutzbare Beacon an, deaktiviert der Klick alle nutzbaren Beacons.
- Bei deaktiviertem zweiten Chat schaltet der Chip nur die Hauptkategorie und verändert das
gespeicherte Flag der zweiten Kategorie nicht.
- Chip und geöffnetes Settings-Fenster zeigen denselben beobachtbaren Zustand.
- Nur der Beacon-Chip wird anklickbar. Andere Thread-Status-Chips bleiben reine Anzeigen.
## Unveränderte Grenzen
- Beide Kategorien behalten eigene Aktivierungsflags und Texte.
- Der zweite Beacon-Versand setzt weiterhin einen aktivierten zweiten Chat voraus.
- Nachrichtenvalidierung, TX-Warteschlange und ON4KST-Protokoll bleiben unverändert.
- Mindestintervall, gemeinsamer Timer und Intervall-Persistenz bleiben unverändert.
- Profilzustände werden nicht miteinander vermischt.
## Technischer Ansatz
Die vorhandenen `ChatPreferences`-Getter und -Setter bleiben die kompatible Schnittstelle,
werden für die beiden Aktivierungsflags aber auf thread-sichere beobachtbare Werte gestellt.
Der Settings-Tab liest diese Werte als Compose-State und schreibt Aktivierungen direkt durch
den Controller. Der Controller veröffentlicht nach jeder manuellen Umschaltung einen
zusammengefassten `MyBeacon`-Status. `BeaconTask` meldet nach einem Timerlauf ebenfalls genau
diesen zusammengefassten Status. `ThreadStatusButtons` erhält eine optionale Aktion pro
Quelle; die Anwendung registriert sie nur für `MyBeacon`.
@@ -0,0 +1,26 @@
# Map Overlay Legend and Screenshot Design
## Intent
Make the two map overlays understandable without consulting the manual and replace the outdated general map image with the supplied screenshot of an active QRB range.
## Behaviour
- The map header shows a compact red **Antenna** legend item only while the antenna sector can be drawn.
- It shows a compact blue **Active filters** legend item only while a valid QTF or QRB overlay is active.
- A localized tooltip explains that red represents the actual antenna heading, beamwidth and maximum QRB, while blue represents active QTF and QRB filters.
- The existing responsive `FlowRow` may wrap the legend with the other header controls.
- Overlay rendering, filtering, antenna heading and rotor behaviour do not change.
## Screenshot
- Store the supplied image as `github_docs/station_map_spatial_filters.png` without altering its pixels.
- Use it in the German and English UI manual and on the Station Map website feature page.
- Describe the visible range correctly as minimum and maximum QRB, not QTF.
- Retain `station_map_path_analysis.png` where the visible path-analysis panels are the subject.
## Constraints
- Reuse the exact overlay colours rather than duplicating colour literals.
- Keep English and German UI strings and documentation aligned.
- Do not stage, commit, push or publish any change.
+13 -8
View File
@@ -206,6 +206,8 @@ Dezent umrahmte Flächen fassen **QRB**, **QTF** einschließlich Richtungstasten
Standardmäßig aktualisiert eine gültige direkte Eingabe in der QTF-Gruppe oder der Klick auf eine Richtungstaste zugleich die aktuelle eigene Antennenrichtung. Diese ausdrückliche Übernahme funktioniert unabhängig davon, ob **Show only QTF** ein- oder ausgeschaltet ist. Das Zurücksetzen oder bloße Ausschalten des Filters verändert die Antennenrichtung nicht. Im Abschnitt **Station → Manuelle Antennenrichtung** kann das Verhalten mit **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** profilbezogen ausgeschaltet werden. PSTRotator 1 hat vorübergehend Vorrang, ohne diese gespeicherte Auswahl zu verändern.
Dieselbe ausdrückliche QTF-Eingabe beziehungsweise Richtungstaste zentriert eine geöffnete Stationskarte auf den gewählten Sektor, ohne den Zoom zu verändern. Wiederhergestellte Einstellungen, **Reset filters**, Rotor-Rückmeldungen und andere Hintergrundänderungen bewegen die Karte nicht. Diese Navigation ist von der optionalen Übernahme als tatsächliche Antennenrichtung getrennt und löst keinen Rotorbefehl aus.
Bei **New bands** werden aktuelle QRGs, Bandangaben im Namensfeld und aktive Rufzeichenvarianten gemeinsam ausgewertet. Manuelle NOT-QRV-Markierungen haben Vorrang.
Der Filter **Tropo >=0dB** entfernt nur Stationen, für die eine abgeschlossene Berechnung eine negative Marge ergeben hat. Noch nicht berechnete oder fehlgeschlagene Auswertungen bleiben sichtbar. Andernfalls würde ein fehlender API-Wert wie ein nachgewiesen ungeeigneter Funkweg behandelt.
@@ -361,11 +363,9 @@ Die Stationskarte kann auf zwei Wegen geöffnet werden:
- **Windows → Show / hide station map** öffnet oder schließt das Kartenfenster.
- **Show on map** im **Further Info**-Bereich öffnet die Karte und fokussiert die ausgewählte Station.
Die Karte verwendet die Stationen, die nach Anwendung der aktuellen Benutzerlistenfilter noch sichtbar sind. Die Kopfzeile zeigt die Anzahl der dargestellten Stationen und weist mit `filtered view active` auf eine gefilterte Ansicht hin.
Die Karte verwendet die Stationen, die nach Anwendung der aktuellen Benutzerlistenfilter noch sichtbar sind. Die Kopfzeile zeigt die Anzahl der dargestellten Stationen und weist mit `filtered view active` auf eine gefilterte Ansicht hin. Aktive QTF- und QRB-Grenzen erscheinen zusätzlich als eigene halbtransparente Sektor-, Kreis- oder Ringfläche. Dieses Filter-Overlay ist von dem Sektor für die tatsächliche Antennenrichtung, den Öffnungswinkel und den maximalen QRB getrennt. Eine kompakte Legende in der Kopfzeile kennzeichnet das rote Antennen-Overlay und das blaue Overlay der aktiven Filter, sobald sie sichtbar sind.
![Stationskarte mit ausgewählter Station und eingeblendeter Streckenanalyse](station_map_path_analysis.png)
> **Screenshot-Hinweis:** Das Bild zeigt noch die frühere Markerbeschriftung und weder das neue Kontextmenü noch die Worked-Großfeldfläche. Es wird nach der manuellen Kartenabnahme ersetzt.
![Stationskarte mit ausgewähltem Pfad, rotem Antennensektor und aktivem blauem QRB-Bereich](station_map_spatial_filters.png)
### Station auswählen
@@ -378,6 +378,10 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest:
3. aktualisiert den **Further Info**-Bereich und
4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor.
Jede ausdrückliche Stationsauswahl passt außerdem den Kartenausschnitt so an, dass der vollständige Weg vom eigenen Standort bis zur Gegenstation mit etwas Rand sichtbar ist. Reicht der aktuelle Zoom bereits aus, bleibt er erhalten; bei längeren Strecken wird nur so weit wie nötig herausgezoomt. Normale Userlisten-Aktualisierungen, Ergebnisse der Streckenanalyse und andere Hintergrund-Neuberechnungen bewegen die Karte nicht. Eine Station ohne verwendbaren Locator bleibt ausgewählt, löst aber keine Kamerabewegung aus.
Wird eine bereits ausgewählte Station durch eine spätere Filteränderung ausgeblendet, bleibt sie als separates ausgewähltes Ziel mit Verbindungslinie sichtbar. Sie wird nicht wieder in die normale Markerliste, Gruppierung oder angezeigte Stationsanzahl aufgenommen. Das eigentliche Filterergebnis bleibt dadurch unverändert.
Ist **Gruppierung** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker. Der Tooltip erläutert den vollständigen Zweck des kompakten Schalters.
Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Gruppierung** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten.
@@ -407,7 +411,7 @@ Bei aktivem **Grid color** kennzeichnet die Karte dauerhaft bekannte, bereits ge
### Auswahl mit Reset view löschen
**Reset view** löscht die Stationsauswahl, ohne die Kartenposition oder den Zoomlevel zu verändern.
**Reset view** löscht die Stationsauswahl und zentriert die Karte wieder auf den eigenen Standort, ohne den Zoomlevel zu verändern.
Dabei werden:
@@ -415,9 +419,10 @@ Dabei werden:
- die Auswahl in der Benutzerliste aufgehoben,
- die Verbindungslinie zur Gegenstation entfernt,
- eine noch laufende Auswertung der vorherigen Station verworfen und
- der rechte Analysebereich entfernt.
- der rechte Analysebereich entfernt und
- die Karte auf den eigenen Standort zentriert.
Die Karte selbst bleibt im zuvor gewählten Ausschnitt. Die Funktion ist deshalb kein geografischer Reset auf den eigenen Standort.
Der Zoomlevel bleibt unverändert. Die nächste ausdrückliche Stations- oder QTF-Auswahl kann den Kartenausschnitt erneut bewegen.
![Stationskarte nach Reset view ohne ausgewählte Station](station_map_reset.png)
@@ -560,7 +565,7 @@ Bei einer ungünstigen Aufteilung sollten zuerst die Divider und Spaltenbreiten
## Tipps zur Bedienung
- **Einstellungsfenster geöffnet lassen**: Schneller Zugriff auf Beacon-Aktivierung/Deaktivierung.
- **Beacon schnell schalten**: Die Aktivierung ist im geöffneten Einstellungsfenster je Kategorie möglich. Ein Klick auf den Status-Chip **MyBeacon** schaltet alle derzeit nutzbaren Beacons gemeinsam ein oder aus.
- **Rechtsklick in der Benutzerliste**: Öffnet das Snippet-Menü und weitere Aktionen (QRZ.com-Profil, NOT-QRV-Tags setzen).
- **Enter aus dem Chat heraus**: Wenn im Sendfeld Text steht, sendet Enter direkt – auch wenn der Fokus woanders liegt.
- **Beacon stoppen**: Beim Scannen von Frequenzen den Beacon ausschalten, damit der Chat nicht mit Meldungen überflutet wird.
+5 -1
View File
@@ -4,7 +4,7 @@
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken sofort.
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache, die Anmeldedaten im Reiter **Station** und die beiden Beacon-Aktivierungsschalter wirken sofort.
>
> Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
@@ -603,6 +603,10 @@ KST4Contest verwendet einen gemeinsamen Timer für beide Chat-Kategorien. Aktivi
Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn die zweite Kategorie in derselben ON4KST-Sitzung aktiviert und vollständig synchronisiert ist.
Die beiden Aktivierungsschalter wirken sofort auf den laufenden Timer. Sie starten weder den Countdown neu noch senden sie unmittelbar eine Nachricht; der nächste reguläre Timer-Lauf prüft den aktuellen Zustand. Erst **Save Settings** speichert die Aktivierung für den nächsten Programmstart. Nachrichtentexte und Intervall folgen weiterhin der normalen Apply-/Save-Logik.
Der Status-Chip **MyBeacon** im Hauptfenster fasst beide nutzbaren Kategorien zusammen: **off** bedeutet kein aktiver Beacon, **1/2** genau eine von zwei aktiven Kategorien und **on** alle derzeit nutzbaren Kategorien. Ein Klick auf den Chip schaltet alle nutzbaren Beacons gemeinsam ein oder aus. Ist der zweite Chat deaktiviert, ändert der Chip nur den Beacon der Hauptkategorie und lässt die gespeicherte Einstellung des zweiten Beacons unverändert.
### Intervall und Timer-Verhalten
Das Intervall wird in ganzen Minuten angegeben. Der kleinste zulässige Wert ist eine Minute.
+5 -1
View File
@@ -4,7 +4,7 @@
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language and the login data on the **Station** tab take effect immediately.
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language, the login data on the **Station** tab and both beacon enable settings take effect immediately.
>
> Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
@@ -603,6 +603,10 @@ KST4Contest uses one shared timer for both chat categories. Each category nevert
When both beacons are enabled, they are sent one after the other in their respective categories during the same timer run. The second beacon is only considered while the second category is enabled and fully synchronised in the same ON4KST session.
Both enable settings affect the running timer immediately. They neither restart the countdown nor send a message straight away; the next regular timer run checks the current state. Only **Save Settings** stores the enable state for the next program start. Message templates and the interval continue to follow the normal Apply/Save workflow.
The **MyBeacon** status chip in the main window combines the currently usable categories: **off** means that no beacon is enabled, **1/2** that one of two categories is enabled, and **on** that every usable category is enabled. Clicking the chip enables or disables all usable beacons together. If the second chat is disabled, the chip changes only the primary-category beacon and leaves the stored secondary-beacon setting untouched.
### Interval and timer behaviour
The interval is entered in whole minutes. The minimum permitted value is one minute.
+13 -8
View File
@@ -208,6 +208,8 @@ Subtly bordered surfaces group **QRB**, **QTF** including direction buttons, **H
By default, a valid direct entry in the QTF group or a compass-button click also updates the current local antenna heading. This explicit transfer works whether **Show only QTF** is on or off. Resetting or merely disabling the filter does not change the antenna heading. The profile-specific option **Use QTF filter direction as antenna heading** under **Station → Manual antenna heading** can disable this behaviour. PSTRotator 1 takes temporary priority without changing that saved choice.
The same explicit QTF entry or compass-button click centres an open station map towards the chosen sector without changing its zoom. Restored settings, **Reset filters**, rotor feedback and other background updates do not move the map. This navigation is independent of the optional transfer to the actual antenna heading and never sends a rotor command.
For **New bands**, KST4Contest evaluates current QRGs, band information in the name field and active callsign variants together. Manual NOT-QRV marks take precedence.
The **Tropo >=0dB** filter removes only stations for which a completed calculation returned a negative margin. Stations with pending or failed calculations remain visible. Otherwise, a missing API result would incorrectly be treated as proof that the path is unsuitable.
@@ -363,11 +365,9 @@ The station map can be opened in two ways:
- **Windows → Show / hide station map** opens or closes the map window.
- **Show on map** in the **Further Info** panel opens the map and focuses the selected station.
The map uses the stations which remain visible after applying the current user-list filters. Its header shows the number of displayed stations and indicates a filtered view with `filtered view active`.
The map uses the stations which remain visible after applying the current user-list filters. Its header shows the number of displayed stations and indicates a filtered view with `filtered view active`. Active QTF and QRB limits are also drawn as a separate semi-transparent sector, circle or distance ring. This filter overlay is distinct from the sector showing the actual antenna heading, beamwidth and maximum QRB. A compact header legend identifies the red antenna overlay and the blue active-filter overlay whenever they are visible.
![Station map with a selected station and visible path analysis](station_map_path_analysis.png)
> **Screenshot note:** This image still shows the previous marker label and neither the new context menu nor the Worked-square overlay. It will be replaced after manual map acceptance.
![Station map with a selected path, red antenna sector and active blue QRB range](station_map_spatial_filters.png)
### Selecting a Station
@@ -380,6 +380,10 @@ A single station marker can be selected directly. KST4Contest then:
3. updates the **Further Info** panel, and
4. prepares the complete visible callsign as the `/cq` recipient.
Every explicit station selection also fits the complete path from the local station to the target into the map with padding. A zoom level which already shows both ends is retained; longer paths zoom out only as far as required. Ordinary roster refreshes, path-analysis results and other background recompositions do not move the viewport. A station without a usable locator remains selected but does not cause camera movement.
If an already selected station is excluded by a later filter change, it remains visible as the separate selected target with its connection line. It is not reintroduced into the normal marker set, clustering or the visible-station count, so the actual filter result remains unchanged.
Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest.
When **Grouping** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level. Its tooltip explains the compact switch's full purpose.
@@ -411,7 +415,7 @@ While **Grid color** is enabled, the map marks persistently known, already worke
### Clearing the Selection with Reset View
**Reset view** clears the selected station without changing the map position or zoom level.
**Reset view** clears the selected station and returns the map centre to the local station without changing the zoom level.
It:
@@ -419,9 +423,10 @@ It:
- clears the selection in the main user list,
- removes the connection line to the remote station,
- discards a pending analysis for the previous station, and
- removes the right-hand analysis panel.
- removes the right-hand analysis panel, and
- centres the map on the local station.
The map itself remains at the previously selected position and zoom level. This function is therefore not a geographical reset to the local station.
The zoom level remains unchanged. The next explicit station or QTF selection can move the viewport again.
![Station map after Reset view without a selected station](station_map_reset.png)
@@ -565,7 +570,7 @@ If the layout has become inconvenient, first move the dividers and column widths
## Operating Tips
- **Keep the settings window open**: This provides quick access to the beacon controls.
- **Toggle beacons quickly**: Keep the settings window open to control each category separately. Click the **MyBeacon** status chip to enable or disable all currently usable beacons together.
- **Right-click in the user list**: Opens the snippet menu and additional actions, including QRZ.com profiles and NOT-QRV marks.
- **Press Enter while working in the chat**: If the send field contains text, Enter sends it directly even when another control has focus.
- **Stop the beacon while scanning**: Disable the beacon while moving through frequencies to avoid flooding the chat with unnecessary messages.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+5 -1
View File
@@ -71,10 +71,14 @@ This is deliberately strict. Adding another filter narrows the result; it does n
## User list and map share the result
The user list and [Station Map](/features/station-map/) use the same filtered station set. If a station disappears from the table because of QTF, QRB, Worked, band or propagation criteria, it also disappears from the geographical view.
The user list and [Station Map](/features/station-map/) use the same filtered station set. If a station disappears from the table because of QTF, QRB, Worked, band or propagation criteria, it also disappears from the normal geographical marker set.
This makes the map useful as a spatial worklist rather than a second list with different rules.
One deliberate exception preserves navigation context: a station which is already selected remains visible as a separate selected marker and path even when a later filter hides it. It is not counted, clustered or reintroduced into the filter result.
Active QTF and QRB limits are drawn directly on the map as a separate semi-transparent filter overlay. Depending on the combination, this is a directional sector, a maximum-distance circle, the area beyond a minimum distance, an annulus or the corresponding annular sector. The overlay uses the same inclusive maximum, exclusive minimum and beamwidth semantics as the actual station filter.
## Hidden does not mean deleted
A filtered-out station remains in the active station model and continues to participate in chat processing. Incoming messages, QRG and band updates, Worked changes and other state can therefore make it relevant again.
+11 -4
View File
@@ -29,7 +29,7 @@ A table is efficient when callsigns, scores or QRBs need to be sorted. It is les
Since version 1.41, the KST4Contest station map provides this geographical view of the same operating context. It is not an independent list. The filters applied to the chat-member table also determine which stations appear on the map.
![Station map with path analysis](/manual/assets/station_map_path_analysis.png)
![Station map with a selected path, red antenna sector and active blue QRB range](/manual/assets/station_map_spatial_filters.png)
## A geographical contest worklist
@@ -39,9 +39,12 @@ The map retains the context already known by KST4Contest:
- normal, Worked, directional-opportunity and selected marker states;
- known bands and open `B+` opportunities;
- the configured antenna direction, beamwidth and maximum QRB as a visible sector; and
- the configured antenna direction, beamwidth and maximum QRB as a visible sector;
- active QTF and QRB filters as a separate semi-transparent sector, circle or distance ring; and
- a Maidenhead locator grid for geographical orientation.
When either overlay is visible, the compact header legend identifies the red antenna sector and the blue active-filter area.
Only stations with a usable six-character locator can be positioned. Active chat variants of the same normalised base callsign are combined into one marker, while their actual message destinations remain separate.
At lower zoom levels, **Group nearby stations** keeps dense map areas readable by combining close markers into screen-based clusters. The grouping can be disabled immediately without changing the current viewport or selection, and KST4Contest remembers the choice. This setting does not split the geographical marker shared by active variants of the same base callsign.
@@ -50,11 +53,15 @@ Green has a specific meaning: it marks a directional opportunity derived from di
## Selection remains connected to the chat
Selecting a marker selects the corresponding active chat member in the main window. KST4Contest scrolls to the entry in the user list, updates **Further Info** and prepares the complete visible callsign as the message target.
Selecting a marker selects the corresponding active chat member in the main window. KST4Contest scrolls to the entry in the user list, updates **Further Info** and prepares the complete visible callsign as the message target. Explicit selections from the user list, messages, Priority, New joined, Timeline and other station controls use the same navigation action.
The suffix and chat category are retained. Combining several active variants into one geographical marker therefore does not turn them into one interchangeable ON4KST login.
The selected path is shown as a connection line. The map can then remain a quick selection surface or be used together with the lower path-analysis panel.
The selected path is shown as a high-contrast connection line. The map keeps the current zoom if both ends already fit and otherwise zooms out until the complete path is visible. Ordinary station updates and asynchronous analysis results do not take over the camera.
An explicit QTF entry or compass-button click centres the map towards that directional sector while preserving the zoom. This is map navigation only: it neither sends a rotor command nor changes the rules for the optional QTF-to-antenna-heading transfer.
If a filter hides the selected station, the marker and path remain visible as a separate selected target. The station is not added back to the filtered marker count or clustering result.
## G1YBB's map-driven workflow