Add selected-band activity indicator for stations in map and details (#53)

* Initial plan

* Add map indicator for selected-band activity hints

* Add selected-band marker to main station table

* Move band-offer star from callsign to worked status cell

* Move band-offer star from wkdany column to per-band worked cells

Fixes a bad merge that had nested a method definition inside another
method (broke compilation), and relocates the new-band-opportunity
star so it appears on the specific band cell it applies to instead of
a single generic wkdany column, since a station can offer several
bands at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Changed Indicator to B+ instead of star for better understandability

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Philipp Wagner <philipp@wagnersnetz.de>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Copilot
2026-07-08 22:32:54 +02:00
committed by GitHub
co-authored by Claude Sonnet 5 Rsclub2_2
parent ff71623b90
commit 7d3177ba8a
6 changed files with 252 additions and 34 deletions
@@ -64,6 +64,8 @@ import javafx.scene.shape.Polygon;
import javafx.stage.Screen; import javafx.stage.Screen;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView; import kst4contest.view.map.StationMapView;
import kst4contest.view.map.OfflineDemImportService; import kst4contest.view.map.OfflineDemImportService;
@@ -116,6 +118,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
private final Tooltip tipSkedWarnIndicator = new Tooltip(); private final Tooltip tipSkedWarnIndicator = new Tooltip();
private Timeline skedWarnBlinkTimeline; private Timeline skedWarnBlinkTimeline;
private final MapCallsignRawSnapshotBuilder mainViewBandMarkerSnapshotBuilder = new MapCallsignRawSnapshotBuilder();
// Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes) // Timeline: show at most N priority markers per minute bucket (minute 0/1 often has many planes)
private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2;
@@ -414,6 +418,55 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return sb.toString(); return sb.toString();
} }
/**
* Checks whether a station has known activity on the given band and that band is
* one of my own currently enabled bands, i.e. a new-band opportunity worth flagging
* with a star in that band's table cell.
*
* @param chatMember station row
* @param band band to check
* @return true if the band cell should show a star
*/
private boolean isBandOfferForMainView(ChatMember chatMember, Band band) {
if (chatMember == null
|| band == null
|| chatcontroller == null
|| chatcontroller.getReachabilityService() == null
|| chatMember.getCallSignRaw() == null
|| chatMember.getCallSignRaw().isBlank()) {
return false;
}
EnumSet<Band> enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
if (enabledBands == null || !enabledBands.contains(band)) {
return false;
}
List<ChatMember> variants = new ArrayList<>();
synchronized (chatcontroller.getLst_chatMemberList()) {
for (ChatMember variant : chatcontroller.getLst_chatMemberList()) {
if (variant == null || variant.getCallSignRaw() == null) {
continue;
}
if (variant.getCallSignRaw().equalsIgnoreCase(chatMember.getCallSignRaw())) {
variants.add(variant);
}
}
}
if (variants.isEmpty()) {
variants = List.of(chatMember);
}
List<MapCallsignRawSnapshot> snapshots = mainViewBandMarkerSnapshotBuilder.buildSnapshots(
variants,
null,
EnumSet.of(band)
);
return snapshots.stream().anyMatch(MapCallsignRawSnapshot::offersSelectedBand);
}
private String bandToHumanLabel(kst4contest.model.Band b) { private String bandToHumanLabel(kst4contest.model.Band b) {
// Human-friendly labels for VHF/UHF/microwave contesting // Human-friendly labels for VHF/UHF/microwave contesting
return switch (b) { return switch (b) {
@@ -1204,14 +1257,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
public ObservableValue<String> call(CellDataFeatures<ChatMember, String> cellDataFeatures) { public ObservableValue<String> call(CellDataFeatures<ChatMember, String> cellDataFeatures) {
SimpleStringProperty callsgn = new SimpleStringProperty(); SimpleStringProperty callsgn = new SimpleStringProperty();
if (cellDataFeatures.getValue().getState() == 1) { ChatMember member = cellDataFeatures.getValue();
callsgn.setValue("(" + cellDataFeatures.getValue().getCallSign() + ")"); //away user String baseCallsign;
} else {
callsgn.setValue(cellDataFeatures.getValue().getCallSign()); if (member.getState() == 1) {
baseCallsign = "(" + member.getCallSign() + ")"; //away user
} else {
baseCallsign = member.getCallSign();
} }
// System.out.println(cellDataFeatures.getValue().getCallSign() + " / " + cellDataFeatures.getValue().getState()+ " <<<<<<<<<<<<<<<<<< state "); callsgn.setValue(baseCallsign);
// System.out.println(member.getCallSign() + " / " + member.getState()+ " <<<<<<<<<<<<<<<<<< state ");
return callsgn; return callsgn;
} }
@@ -1656,30 +1713,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} }
}); });
/**
* Shows the compact worked/grid status and explains it by tooltip.
*/
wkdAny_subcol.setCellFactory(column -> new TableCell<ChatMember, String>() {
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty) {
setText(null);
setTooltip(null);
setStyle("");
return;
}
ChatMember member = getTableRow() == null ? null : getTableRow().getItem();
setText(item);
setTooltip(new Tooltip(buildWorkedAnyGridStatusTooltip(member)));
setAlignment(Pos.CENTER);
setStyle("-fx-font-weight: bold;");
}
});
TableColumn<ChatMember, String> vhfCol_subcol = new TableColumn<ChatMember, String>("144"); TableColumn<ChatMember, String> vhfCol_subcol = new TableColumn<ChatMember, String>("144");
vhfCol_subcol vhfCol_subcol
@@ -1691,6 +1724,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked144()) { if (cellDataFeatures.getValue().isWorked144()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_144)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1710,6 +1745,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked432()) { if (cellDataFeatures.getValue().isWorked432()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_432)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1729,6 +1766,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked1240()) { if (cellDataFeatures.getValue().isWorked1240()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_1296)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1747,6 +1786,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked2300()) { if (cellDataFeatures.getValue().isWorked2300()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_2320)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1765,6 +1806,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked3400()) { if (cellDataFeatures.getValue().isWorked3400()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_3400)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1783,6 +1826,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked5600()) { if (cellDataFeatures.getValue().isWorked5600()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_5760)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -1801,6 +1846,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (cellDataFeatures.getValue().isWorked10G()) { if (cellDataFeatures.getValue().isWorked10G()) {
wkd.setValue("X"); wkd.setValue("X");
} else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_10G)) {
wkd.setValue("B+");
} else { } else {
wkd.setValue(""); wkd.setValue("");
} }
@@ -20,6 +20,7 @@ public record MapCallsignRawSnapshot(
double longitudeDeg, double longitudeDeg,
String bandSummary, String bandSummary,
Map<String, String> lastKnownFrequenciesByBand, Map<String, String> lastKnownFrequenciesByBand,
boolean offersSelectedBand,
boolean warningToMyDirection, boolean warningToMyDirection,
boolean worked, boolean worked,
boolean selected, boolean selected,
@@ -59,9 +60,10 @@ public record MapCallsignRawSnapshot(
} }
public String markerLabel() { public String markerLabel() {
return bandSummary.isBlank() String baseLabel = bandSummary.isBlank()
? displayCallSign ? displayCallSign
: displayCallSign + " (" + bandSummary + ")"; : displayCallSign + " (" + bandSummary + ")";
return offersSelectedBand ? baseLabel + " B+" : baseLabel;
} }
public String detailFrequencyText() { public String detailFrequencyText() {
@@ -8,11 +8,13 @@ import kst4contest.model.ChatMember;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
/** /**
* Builds immutable map snapshots from the currently visible chat members. * Builds immutable map snapshots from the currently visible chat members.
@@ -22,8 +24,11 @@ import java.util.Map;
*/ */
public final class MapCallsignRawSnapshotBuilder { public final class MapCallsignRawSnapshotBuilder {
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers, public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
ChatMember selectedChatMember) { ChatMember selectedChatMember,
EnumSet<Band> selectedBands) {
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) { if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
return List.of(); return List.of();
@@ -69,7 +74,9 @@ public final class MapCallsignRawSnapshotBuilder {
Location location = new Location(locator6); Location location = new Location(locator6);
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants); LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
String bandSummary = String.join(", ", frequenciesByBand.keySet()); EnumSet<Band> sureBands = collectSureBands(variants, frequenciesByBand);
String bandSummary = buildBandSummary(sureBands);
boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange); boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand); boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
@@ -92,6 +99,7 @@ public final class MapCallsignRawSnapshotBuilder {
location.getLongitude().toDegrees(), location.getLongitude().toDegrees(),
bandSummary, bandSummary,
frequenciesByBand, frequenciesByBand,
offersSelectedBand,
warningToMyDirection, warningToMyDirection,
worked, worked,
selected, selected,
@@ -182,6 +190,101 @@ public final class MapCallsignRawSnapshotBuilder {
return ordered; return ordered;
} }
private EnumSet<Band> collectSureBands(List<ChatMember> variants,
LinkedHashMap<String, String> frequenciesByBand) {
EnumSet<Band> sureBands = EnumSet.noneOf(Band.class);
if (frequenciesByBand != null) {
for (String label : frequenciesByBand.keySet()) {
Band mappedBand = bandFromDisplayLabel(label);
if (mappedBand != null) {
sureBands.add(mappedBand);
}
}
}
for (ChatMember variant : variants) {
if (variant == null || variant.getName() == null || variant.getName().isBlank()) {
continue;
}
sureBands.addAll(detectBandsFromStationName(variant.getName()));
}
return sureBands;
}
private EnumSet<Band> detectBandsFromStationName(String stationName) {
EnumSet<Band> detectedBands = EnumSet.noneOf(Band.class);
if (stationName == null || stationName.isBlank()) {
return detectedBands;
}
String normalized = stationName.toUpperCase(Locale.ROOT);
String[] tokens = TOKEN_SPLIT_PATTERN.split(normalized);
for (String token : tokens) {
if (token == null || token.isBlank()) {
continue;
}
switch (token) {
case "2", "2M", "144", "144MHZ" -> detectedBands.add(Band.B_144);
case "70", "70CM", "432", "432MHZ" -> detectedBands.add(Band.B_432);
case "23", "23CM", "1296", "1296MHZ" -> detectedBands.add(Band.B_1296);
case "13", "13CM", "2300", "2320", "2320MHZ" -> detectedBands.add(Band.B_2320);
case "9", "9CM", "3400", "3400MHZ" -> detectedBands.add(Band.B_3400);
case "6", "6CM", "5600", "5760", "5760MHZ" -> detectedBands.add(Band.B_5760);
case "3", "3CM", "10G", "10GHZ", "10368", "10368MHZ" -> detectedBands.add(Band.B_10G);
case "24G", "24GHZ", "24048", "24048MHZ" -> detectedBands.add(Band.B_24G);
default -> {
}
}
}
return detectedBands;
}
private String buildBandSummary(EnumSet<Band> sureBands) {
if (sureBands == null || sureBands.isEmpty()) {
return "";
}
List<String> labels = sureBands.stream()
.sorted()
.map(this::toBandDisplayLabel)
.toList();
return String.join(", ", labels);
}
private boolean hasAnySelectedBand(EnumSet<Band> sureBands, EnumSet<Band> selectedBands) {
if (sureBands == null || sureBands.isEmpty() || selectedBands == null || selectedBands.isEmpty()) {
return false;
}
for (Band band : selectedBands) {
if (sureBands.contains(band)) {
return true;
}
}
return false;
}
private Band bandFromDisplayLabel(String label) {
if (label == null || label.isBlank()) {
return null;
}
return switch (label) {
case "144" -> Band.B_144;
case "432" -> Band.B_432;
case "1296" -> Band.B_1296;
case "2320" -> Band.B_2320;
case "3400" -> Band.B_3400;
case "5760" -> Band.B_5760;
case "10368" -> Band.B_10G;
case "24048" -> Band.B_24G;
default -> null;
};
}
/** /**
* Fallback for stations where the current displayed QRG exists but the * Fallback for stations where the current displayed QRG exists but the
* knownActiveBands history has not yet been filled. * knownActiveBands history has not yet been filled.
@@ -11,6 +11,7 @@ import kst4contest.model.ChatPreferences;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@@ -142,8 +143,13 @@ public final class StationMapBridge {
private void refreshNow() { private void refreshNow() {
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
EnumSet<Band> selectedBands = chatController.getReachabilityService().getEnabledStationBands();
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember); List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(
visibleChatMembers,
selectedChatMember,
selectedBands
);
MapCallsignRawSnapshot selectedSnapshot = null; MapCallsignRawSnapshot selectedSnapshot = null;
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
@@ -855,7 +855,11 @@ public final class StationMapView {
detailLocatorValue.setText(selectedSnapshot.locator6()); detailLocatorValue.setText(selectedSnapshot.locator6());
detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm())); detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm()));
detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg())); detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg()));
detailBandsValue.setText(selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary()); String bandText = selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary();
if (selectedSnapshot.offersSelectedBand()) {
bandText += " B+";
}
detailBandsValue.setText(bandText);
detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText()); detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText());
detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes())); detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes()));
triggerClusterSpotButton.setDisable(false); triggerClusterSpotButton.setDisable(false);
@@ -0,0 +1,56 @@
package kst4contest.test;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.view.map.MapCallsignRawSnapshot;
import kst4contest.view.map.MapCallsignRawSnapshotBuilder;
import org.junit.jupiter.api.Test;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MapCallsignRawSnapshotBuilderTest {
@Test
void marksSnapshotWhenNameAdvertisesSelectedBand() {
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_1296)
);
assertEquals(1, snapshots.size());
assertTrue(snapshots.get(0).offersSelectedBand());
}
@Test
void keepsSnapshotUnmarkedWhenNoSelectedBandMatches() {
ChatMember station = buildStation("DL1ABC", "QRV 2m only", "JN58TD", 1_000L);
MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
List<MapCallsignRawSnapshot> snapshots = builder.buildSnapshots(
List.of(station),
null,
EnumSet.of(Band.B_2320)
);
assertEquals(1, snapshots.size());
assertFalse(snapshots.get(0).offersSelectedBand());
}
private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
ChatMember chatMember = new ChatMember();
chatMember.setCallSign(callSign);
chatMember.setName(name);
chatMember.setQra(locator);
chatMember.setActivityTimeLastInEpoch(activityEpoch);
return chatMember;
}
}