mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-11-08 22:38:36 +01:00
feat: improve station map navigation and overlays
This commit is contained in:
1 parent
b8bb86977f
commit
3d2fb70f1a
38 files changed
+1783
-166
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:
|
||||
|
||||
@@ -4146,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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -149,6 +152,7 @@ fun buildSettingsTabs(
|
||||
connectionState = connectionState,
|
||||
applySecondRotator = { controller.reconfigureSecondRotator(); host.refreshSo2rState() },
|
||||
refreshBeaconStatus = controller::refreshBeaconStatus,
|
||||
applyMapDisplaySettings = host::refreshMapDisplaySettings,
|
||||
)
|
||||
|
||||
val notificationState = NotificationTabState(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -46,6 +46,8 @@ class StationTabState(
|
||||
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?
|
||||
@@ -57,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) }
|
||||
|
||||
@@ -97,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) }
|
||||
|
||||
@@ -294,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+68
-2
@@ -49,6 +49,8 @@ class ComposeStationMapDrawingTest {
|
||||
|
||||
private val workedGridBorder = 0x000000
|
||||
|
||||
private val selectedDotBorder = 0xFF9900
|
||||
|
||||
/*
|
||||
* An empty cache and a source that always fails, so that "tiles never arrive" holds by
|
||||
* construction. The default fetcher read the user's disk cache and the real tile
|
||||
@@ -71,6 +73,10 @@ class ComposeStationMapDrawingTest {
|
||||
ownLocator6: String = "",
|
||||
workedGrossFields: Set<String> = emptySet(),
|
||||
darkMode: Boolean = true,
|
||||
selectedTarget: MapCallsignRawSnapshot? = null,
|
||||
spatialFilterOverlay: SpatialFilterOverlayState? = null,
|
||||
antennaBeamWidthDeg: Double = 0.0,
|
||||
maxQrbKm: Double = 0.0,
|
||||
): BufferedImage {
|
||||
return onEventThread {
|
||||
val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
|
||||
@@ -82,8 +88,10 @@ class ComposeStationMapDrawingTest {
|
||||
selectedMarkerCallsign = null,
|
||||
ownLocator6 = ownLocator6,
|
||||
antennaAzimuthDeg = 90.0,
|
||||
antennaBeamWidthDeg = 0.0,
|
||||
maxQrbKm = 0.0,
|
||||
antennaBeamWidthDeg = antennaBeamWidthDeg,
|
||||
maxQrbKm = maxQrbKm,
|
||||
selectedTarget = selectedTarget,
|
||||
spatialFilterOverlay = spatialFilterOverlay,
|
||||
onMarkerClicked = null,
|
||||
workedGrossFields = workedGrossFields,
|
||||
darkMode = darkMode,
|
||||
@@ -108,6 +116,16 @@ class ComposeStationMapDrawingTest {
|
||||
return count
|
||||
}
|
||||
|
||||
private fun changedPixels(before: BufferedImage, after: BufferedImage): Int {
|
||||
var changed = 0
|
||||
for (x in 0 until before.width) {
|
||||
for (y in 0 until before.height) {
|
||||
if (before.getRGB(x, y) != after.getRGB(x, y)) changed++
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** The widest horizontal run of a colour, which for a ring is its outer diameter. */
|
||||
private fun widestRun(image: BufferedImage, rgb: Int): Int {
|
||||
var widest = 0
|
||||
@@ -282,6 +300,54 @@ class ComposeStationMapDrawingTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active spatial filters add a distinct overlay in both themes`() {
|
||||
val overlay = SpatialFilterOverlayState(
|
||||
qtfEnabled = true,
|
||||
qtfDegrees = 90.0,
|
||||
beamWidthDeg = 60.0,
|
||||
minQrbEnabled = true,
|
||||
minQrbKm = 50.0,
|
||||
maxQrbEnabled = true,
|
||||
maxQrbKm = 400.0,
|
||||
)
|
||||
|
||||
for (darkMode in listOf(false, true)) {
|
||||
val without = render(6f, markers = emptyList(), ownLocator6 = "JO50JP", darkMode = darkMode)
|
||||
val with = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
ownLocator6 = "JO50JP",
|
||||
darkMode = darkMode,
|
||||
spatialFilterOverlay = overlay,
|
||||
)
|
||||
|
||||
assertTrue(changedPixels(without, with) > 1_000) {
|
||||
"the spatial filter overlay was not visible in darkMode=$darkMode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a filtered selected target is drawn without joining the normal marker list`() {
|
||||
val targetLocation = kst4contest.locatorUtils.Location("JO51AA")
|
||||
val target = station(
|
||||
"DL1HIDE",
|
||||
"JO51AA",
|
||||
targetLocation.latitude.toDegrees(),
|
||||
targetLocation.longitude.toDegrees(),
|
||||
)
|
||||
|
||||
val image = render(
|
||||
6f,
|
||||
markers = emptyList(),
|
||||
ownLocator6 = "JO50JP",
|
||||
selectedTarget = target,
|
||||
)
|
||||
|
||||
assertTrue(countColour(image, selectedDotBorder) > 0, "the separate selected marker is missing")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every grid line has to be drawn once.
|
||||
*
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class MapDisplaySettingsStateTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var directory: Path
|
||||
|
||||
@Test
|
||||
fun `refresh publishes applied beam geometry from preferences`() {
|
||||
val preferences = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||
stn_antennaBeamWidthDeg = 50.0
|
||||
stn_maxQRBDefault = 750.0
|
||||
}
|
||||
val state = MapDisplaySettingsState(preferences)
|
||||
|
||||
preferences.stn_antennaBeamWidthDeg = 72.0
|
||||
preferences.stn_maxQRBDefault = 1_100.0
|
||||
state.refreshFromPreferences()
|
||||
|
||||
assertEquals(72.0, state.antennaBeamWidthDeg)
|
||||
assertEquals(1_100.0, state.maxQrbKm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import kst4contest.view.compose.StationIdentity
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class MapNavigationStateTest {
|
||||
|
||||
@Test
|
||||
fun `repeated station choices remain observable`() {
|
||||
val state = MapNavigationState()
|
||||
val identity = StationIdentity("DL1ABC", 2)
|
||||
|
||||
state.fitSelectedPath(identity)
|
||||
val firstRevision = state.requestRevision
|
||||
state.fitSelectedPath(identity)
|
||||
|
||||
assertTrue(state.requestRevision > firstRevision)
|
||||
assertEquals(MapNavigationRequest.FitSelectedPath(identity), state.request)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `latest explicit action replaces an older one`() {
|
||||
val state = MapNavigationState()
|
||||
|
||||
state.fitSelectedPath(StationIdentity("DL1ABC", 2))
|
||||
state.centerOnQtf(45.0)
|
||||
|
||||
assertEquals(MapNavigationRequest.CenterOnQtf(45.0), state.request)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid QTF values do not create requests`() {
|
||||
val state = MapNavigationState()
|
||||
|
||||
listOf(Double.NaN, Double.POSITIVE_INFINITY, -1.0, 360.1).forEach(state::centerOnQtf)
|
||||
|
||||
assertEquals(0L, state.requestRevision)
|
||||
assertEquals(null, state.request)
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kst4contest.view.compose.Kst4ContestTheme
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MapOverlayLegendScreenTest {
|
||||
|
||||
@Test
|
||||
fun germanEveningLegendWrapsBothItemsInsideANarrowHeader() = runComposeUiTest {
|
||||
var storedLanguage = "de"
|
||||
val languages = LanguageStore(
|
||||
storedLanguage = { storedLanguage },
|
||||
storeLanguage = { storedLanguage = it },
|
||||
systemDefault = Locale.GERMANY,
|
||||
)
|
||||
|
||||
try {
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalStrings provides languages.strings) {
|
||||
Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) {
|
||||
Box(Modifier.width(110.dp)) {
|
||||
MapOverlayLegend(
|
||||
showAntenna = true,
|
||||
showSpatialFilters = true,
|
||||
darkMode = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNodeWithText("Antenne").assertIsDisplayed()
|
||||
onNodeWithText("Aktive Filter").assertIsDisplayed()
|
||||
|
||||
val antenna = onNodeWithTag(MAP_ANTENNA_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val filters = onNodeWithTag(MAP_FILTER_LEGEND_TAG).fetchSemanticsNode().boundsInRoot
|
||||
val widthLimit = with(density) { 110.dp.toPx() }
|
||||
assertTrue(antenna.right <= widthLimit + 0.5f)
|
||||
assertTrue(filters.right <= widthLimit + 0.5f)
|
||||
assertTrue(filters.top >= antenna.bottom, "the legend items did not wrap at narrow width")
|
||||
} finally {
|
||||
runOnIdle { languages.use("en") }
|
||||
}
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package kst4contest.view.compose.map
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SpatialFilterOverlayStateTest {
|
||||
|
||||
@Test
|
||||
fun `maximum QRB is inclusive and minimum QRB is exclusive`() {
|
||||
val state = overlay(minEnabled = true, minKm = 200.0, maxEnabled = true, maxKm = 500.0)
|
||||
|
||||
assertFalse(state.accepts(200.0, 90.0))
|
||||
assertTrue(state.accepts(200.1, 90.0))
|
||||
assertTrue(state.accepts(500.0, 90.0))
|
||||
assertFalse(state.accepts(500.1, 90.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `QTF uses the same wrapped beamwidth semantics as the station filter`() {
|
||||
val state = overlay(qtf = true, direction = 0.0, beamWidth = 60.0)
|
||||
|
||||
assertTrue(state.accepts(300.0, 350.0))
|
||||
assertTrue(state.accepts(300.0, 30.0))
|
||||
assertFalse(state.accepts(300.0, 31.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unusual full-circle beamwidth retains the existing station filter semantics`() {
|
||||
val state = overlay(qtf = true, direction = 0.0, beamWidth = 360.0)
|
||||
|
||||
assertTrue(state.accepts(300.0, 180.0))
|
||||
assertFalse(state.accepts(300.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimum above maximum is an empty distance range`() {
|
||||
val state = overlay(minEnabled = true, minKm = 500.0, maxEnabled = true, maxKm = 200.0)
|
||||
|
||||
assertTrue(state.hasEmptyDistanceRange)
|
||||
assertFalse(state.accepts(100.0, 0.0))
|
||||
assertFalse(state.accepts(300.0, 0.0))
|
||||
assertFalse(state.accepts(600.0, 0.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-finite active values cannot be rendered as a misleading area`() {
|
||||
assertFalse(overlay(qtf = true, direction = Double.NaN).hasRenderableValues)
|
||||
assertFalse(overlay(minEnabled = true, minKm = Double.POSITIVE_INFINITY).hasRenderableValues)
|
||||
assertFalse(overlay(maxEnabled = true, maxKm = Double.NaN).hasRenderableValues)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero distance boundaries report only actually drawable geometry`() {
|
||||
assertFalse(overlay(maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertFalse(overlay(qtf = true, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertFalse(
|
||||
overlay(minEnabled = true, minKm = 0.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry
|
||||
)
|
||||
assertTrue(overlay(minEnabled = true, minKm = 0.0).hasDrawableGeometry)
|
||||
assertTrue(overlay(qtf = true).hasDrawableGeometry)
|
||||
assertTrue(overlay(minEnabled = true, minKm = 100.0, maxEnabled = true, maxKm = 0.0).hasDrawableGeometry)
|
||||
assertTrue(overlay(maxEnabled = true, maxKm = 500.0).hasDrawableGeometry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no spatial filter accepts finite positions`() {
|
||||
val state = overlay()
|
||||
|
||||
assertFalse(state.isActive)
|
||||
assertTrue(state.accepts(1000.0, 275.0))
|
||||
}
|
||||
|
||||
private fun overlay(
|
||||
qtf: Boolean = false,
|
||||
direction: Double = 0.0,
|
||||
beamWidth: Double = 60.0,
|
||||
minEnabled: Boolean = false,
|
||||
minKm: Double = 0.0,
|
||||
maxEnabled: Boolean = false,
|
||||
maxKm: Double = 0.0,
|
||||
) = SpatialFilterOverlayState(
|
||||
qtfEnabled = qtf,
|
||||
qtfDegrees = direction,
|
||||
beamWidthDeg = beamWidth,
|
||||
minQrbEnabled = minEnabled,
|
||||
minQrbKm = minKm,
|
||||
maxQrbEnabled = maxEnabled,
|
||||
maxQrbKm = maxKm,
|
||||
)
|
||||
}
|
||||
+187
-46
@@ -4,7 +4,6 @@ import androidx.compose.ui.test.ComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.assertIsOn
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.isToggleable
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
@@ -42,7 +41,6 @@ import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG
|
||||
import kst4contest.view.compose.ThreadStatusButtons
|
||||
import kst4contest.view.compose.TimelineState
|
||||
import kst4contest.view.compose.TopPriorityState
|
||||
import kst4contest.view.map.StationMapClusterer
|
||||
import kst4contest.view.map.MapCallsignRawSnapshot
|
||||
import kst4contest.view.map.PathAnalysisResult
|
||||
import kst4contest.view.map.TileFetcher
|
||||
@@ -231,7 +229,10 @@ class StationMapWindowScreenTest {
|
||||
val dl1abc = station("DL1ABC", 2)
|
||||
state.selectedStation.select(dl1abc)
|
||||
val selectorCalls = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { member, _ -> selectorCalls += member }
|
||||
state.stationSelector = { member, source ->
|
||||
selectorCalls += member
|
||||
state.selectedStation.select(member, source)
|
||||
}
|
||||
val mapState = mapLookingAt("JO50JP", zoom = 15f)
|
||||
|
||||
show(state, listOf(dl1abc), mapState)
|
||||
@@ -240,11 +241,7 @@ class StationMapWindowScreenTest {
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf<ChatMember?>(null), selectorCalls, "reset did not go through the station selector")
|
||||
assertSame(
|
||||
dl1abc,
|
||||
state.selectedStation.selected,
|
||||
"reset cleared the panel's selection behind the selector's back",
|
||||
)
|
||||
assertEquals(null, state.selectedStation.selected)
|
||||
assertEquals(15f, mapState.zoom, "reset changed the zoom")
|
||||
|
||||
val home = Location(ownLocator)
|
||||
@@ -369,12 +366,9 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields")
|
||||
}
|
||||
|
||||
/**
|
||||
* The cc8c9020 rule: selecting a station pans the map to it and leaves the zoom alone.
|
||||
* The earlier Compose version recomputed the zoom here and threw zoom 15 out to about 7.
|
||||
*/
|
||||
/** An explicit selection fits both ends and still starts the existing path analysis. */
|
||||
@Test
|
||||
fun selectingAStationPansToItAndKeepsTheZoom() = runComposeUiTest {
|
||||
fun selectingAStationFitsTheCompletePath() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val analysis = RecordingPathAnalysis()
|
||||
@@ -382,53 +376,49 @@ class StationMapWindowScreenTest {
|
||||
|
||||
show(state, listOf(dl1abc), mapState, analysis)
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
val home = Location(ownLocator)
|
||||
val target = Location("JO50JP")
|
||||
assertEquals(target.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map did not pan")
|
||||
assertEquals(target.latitude.toDegrees(), mapState.centerLat, 1e-9, "the map did not pan")
|
||||
assertEquals(15f, mapState.zoom, "selecting a station changed the zoom")
|
||||
assertTrue(mapState.centerLon in home.longitude.toDegrees()..target.longitude.toDegrees())
|
||||
assertTrue(mapState.centerLat in home.latitude.toDegrees()..target.latitude.toDegrees())
|
||||
assertTrue(mapState.zoom < 15f, "a path too long for zoom 15 was not fitted")
|
||||
assertEquals(listOf(dl1abc), analysis.requested, "the path analysis was not asked")
|
||||
}
|
||||
|
||||
/**
|
||||
* With grouping on, a selection zooms in only as far as it takes to break the station out
|
||||
* of its bubble, and never out: StationMapClusterer.zoomToRevealStations.
|
||||
*/
|
||||
/** Grouping never forces a path which already fits to zoom in. */
|
||||
@Test
|
||||
fun withGroupingOnASelectionOnlyZoomsInFarEnoughToRevealTheStation() = runComposeUiTest {
|
||||
fun groupingDoesNotZoomInWhileFittingASelectedPath() = runComposeUiTest {
|
||||
val prefs = prefs().apply { isGUIstationMapClusteringEnabled = true }
|
||||
val state = windowState(prefs)
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val far = mapLookingAt(ownLocator, zoom = 5f)
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO40HB")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 5f)
|
||||
|
||||
show(state, listOf(dl1abc), far, RecordingPathAnalysis())
|
||||
show(state, listOf(dl1abc), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(StationMapClusterer.DISABLE_AT_ZOOM.toFloat(), far.zoom)
|
||||
assertEquals(5f, mapState.zoom)
|
||||
}
|
||||
|
||||
/** Without a running application there is nothing to ask, and the map stays put. */
|
||||
/** Camera navigation is independent of the optional path-analysis service. */
|
||||
@Test
|
||||
fun withoutAPathAnalysisASelectionLeavesTheMapWhereItIs() = runComposeUiTest {
|
||||
fun withoutAPathAnalysisASelectionStillFitsThePath() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val dl1abc = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 15f)
|
||||
val home = Location(ownLocator)
|
||||
var asked = 0
|
||||
|
||||
show(state, listOf(dl1abc), mapState, pathAnalysis = null, onPathAnalysisAsked = { asked++ })
|
||||
|
||||
runOnIdle { state.selectedStation.select(dl1abc) }
|
||||
runOnIdle { state.selectStation(dl1abc, StationSelectionSource.STATION_TABLE) }
|
||||
waitForIdle()
|
||||
|
||||
// Proves the selection effect ran, so the unchanged map is not a vacuous pass.
|
||||
assertEquals(1, asked, "the selection effect did not ask for a path analysis")
|
||||
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9)
|
||||
assertEquals(15f, mapState.zoom)
|
||||
assertTrue(mapState.zoom < 15f)
|
||||
}
|
||||
|
||||
/** The two header switches write the profile, so the map opens the same way next time. */
|
||||
@@ -477,6 +467,54 @@ class StationMapWindowScreenTest {
|
||||
assertEquals(listOf(true, false), applied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerLegendFollowsDrawableAntennaAndSpatialFilterOverlays() = runComposeUiTest {
|
||||
val prefs = prefs()
|
||||
val state = windowState(prefs)
|
||||
|
||||
show(state, emptyList(), MapState())
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
|
||||
runOnIdle {
|
||||
state.stationFilter.maxQrbKm = 666.0
|
||||
state.stationFilter.maxQrbEnabled = true
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertExists()
|
||||
|
||||
runOnIdle {
|
||||
prefs.stn_antennaBeamWidthDeg = 0.0
|
||||
prefs.stn_maxQRBDefault = 0.0
|
||||
state.refreshMapDisplaySettings()
|
||||
state.stationFilter.maxQrbEnabled = false
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Antenna").assertDoesNotExist()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidSpatialFilterDoesNotClaimAVisibleOverlay() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
state.stationFilter.maxQrbKm = 0.0
|
||||
state.stationFilter.maxQrbEnabled = true
|
||||
|
||||
show(state, emptyList(), MapState())
|
||||
|
||||
onNodeWithText("Antenna").assertExists()
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
|
||||
runOnIdle { state.stationFilter.maxQrbKm = Double.NaN }
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText("Active filters").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
@@ -553,13 +591,9 @@ class StationMapWindowScreenTest {
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active").assertExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* A selected station the filters hide has no marker, and so nothing to analyse: no
|
||||
* request goes out and the analysis does not sit at "Loading" with an empty target (#21).
|
||||
* The visible station beside it is the control: selecting it does ask and does load.
|
||||
*/
|
||||
/** A filtered-out selection remains a separate target without entering the marker count. */
|
||||
@Test
|
||||
fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest {
|
||||
fun aSelectedStationTheFiltersHideKeepsItsTargetAndPathAnalysis() = runComposeUiTest {
|
||||
val state = windowState(prefs(pathAnalysisVisible = true))
|
||||
val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 }
|
||||
val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 }
|
||||
@@ -568,21 +602,128 @@ class StationMapWindowScreenTest {
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
val analysis = RecordingPathAnalysis()
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9f)
|
||||
val home = Location(ownLocator)
|
||||
|
||||
show(state, listOf(hidden, shown), mapState, analysis)
|
||||
|
||||
runOnIdle { state.selectedStation.select(hidden) }
|
||||
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(emptyList<ChatMember>(), analysis.requested, "a hidden station was analysed")
|
||||
onAllNodesWithText("Loading", substring = true).assertCountEquals(0)
|
||||
assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station")
|
||||
assertEquals(listOf(hidden), analysis.requested, "the selected hidden target was not analysed")
|
||||
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
|
||||
onNodeWithText("Showing 1 visible stations | filtered view active", substring = true).assertExists()
|
||||
|
||||
runOnIdle { state.selectedStation.select(shown) }
|
||||
runOnIdle { state.selectStation(shown, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf(shown), analysis.requested)
|
||||
assertEquals(listOf(hidden, shown), analysis.requested)
|
||||
assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filteredSelectedMarkerWinsHitTestingWhenAnotherMarkerOverlapsIt() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val hidden = station("DL1HIDE", 2, locator = ownLocator).apply { qrb = 100.0 }
|
||||
val shown = station("DL2SHOW", 2, locator = ownLocator).apply { qrb = 800.0 }
|
||||
state.stationFilter.minQrbKm = 300.0
|
||||
state.stationFilter.minQrbEnabled = true
|
||||
state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) }
|
||||
val selections = mutableListOf<ChatMember?>()
|
||||
state.stationSelector = { member, source ->
|
||||
selections += member
|
||||
state.selectedStation.select(member, source)
|
||||
}
|
||||
|
||||
show(state, listOf(hidden, shown), mapLookingAt(ownLocator, zoom = 12f))
|
||||
runOnIdle { state.selectStation(hidden, StationSelectionSource.PRIORITY) }
|
||||
waitForIdle()
|
||||
selections.clear()
|
||||
|
||||
onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { click() }
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(listOf(hidden), selections)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rosterReconciliationDoesNotOverwriteManualCameraMovement() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val selected = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 10f)
|
||||
show(state, listOf(selected), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectStation(selected, StationSelectionSource.PUBLIC_MESSAGE) }
|
||||
waitForIdle()
|
||||
runOnIdle {
|
||||
mapState.centerLon = 3.0
|
||||
mapState.centerLat = 48.0
|
||||
mapState.zoom = 11f
|
||||
state.stations.replaceRows(listOf(station("DL1ABC", 2, locator = "JO50JP")))
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(3.0, mapState.centerLon, 1e-9)
|
||||
assertEquals(48.0, mapState.centerLat, 1e-9)
|
||||
assertEquals(11f, mapState.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitQtfNavigationPreservesZoomAndFilterResetDoesNotNavigate() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
|
||||
show(state, emptyList(), mapState)
|
||||
|
||||
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
|
||||
waitForIdle()
|
||||
val qtfLon = mapState.centerLon
|
||||
val qtfLat = mapState.centerLat
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
assertTrue(qtfLon > Location(ownLocator).longitude.toDegrees())
|
||||
assertTrue(qtfLat > Location(ownLocator).latitude.toDegrees())
|
||||
|
||||
runOnIdle { state.resetStationFilters() }
|
||||
waitForIdle()
|
||||
assertEquals(qtfLon, mapState.centerLon, 1e-9)
|
||||
assertEquals(qtfLat, mapState.centerLat, 1e-9)
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun QtfNavigationDoesNotRequireOrChangeManualAntennaHeading() = runComposeUiTest {
|
||||
val prefs = prefs().apply {
|
||||
setStn_useQtfFilterDirectionAsAntennaHeading(false)
|
||||
actualQTF.set(123.0)
|
||||
}
|
||||
val state = windowState(prefs)
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 9.5f)
|
||||
show(state, emptyList(), mapState)
|
||||
|
||||
runOnIdle { state.applyExplicitQtfFilterDirection(45.0) }
|
||||
waitForIdle()
|
||||
|
||||
assertTrue(mapState.centerLon > Location(ownLocator).longitude.toDegrees())
|
||||
assertTrue(mapState.centerLat > Location(ownLocator).latitude.toDegrees())
|
||||
assertEquals(9.5f, mapState.zoom)
|
||||
assertEquals(123.0, prefs.actualQTF.get(), 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingTheSameStationAgainRepeatsPathNavigation() = runComposeUiTest {
|
||||
val state = windowState(prefs())
|
||||
val selected = station("DL1ABC", 2, locator = "JO50JP")
|
||||
val mapState = mapLookingAt(ownLocator, zoom = 10f)
|
||||
show(state, listOf(selected), mapState, RecordingPathAnalysis())
|
||||
|
||||
runOnIdle { state.selectStation(selected, StationSelectionSource.TIMELINE) }
|
||||
waitForIdle()
|
||||
val firstCentre = mapState.centerLon to mapState.centerLat
|
||||
runOnIdle {
|
||||
mapState.centerLon = 2.0
|
||||
mapState.centerLat = 45.0
|
||||
state.selectStation(selected, StationSelectionSource.TIMELINE)
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(firstCentre.first, mapState.centerLon, 1e-9)
|
||||
assertEquals(firstCentre.second, mapState.centerLat, 1e-9)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
var secondWasApplied = false
|
||||
val secondRotatorCalls = mutableListOf<Unit>()
|
||||
var beaconStatusRefreshes = 0
|
||||
var mapDisplayRefreshes = 0
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(
|
||||
prefs,
|
||||
@@ -29,6 +30,7 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
{ secondApplied = it; secondWasApplied = true },
|
||||
applySecondRotator = { secondRotatorCalls.add(Unit) },
|
||||
refreshBeaconStatus = { beaconStatusRefreshes++ },
|
||||
applyMapDisplaySettings = { mapDisplayRefreshes++ },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -319,6 +321,22 @@ class StationTabStateTest(@param:TempDir private val tempDir: Path) {
|
||||
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()
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -415,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,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,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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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.
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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.
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user