diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index c13055c3..a8d8a71a 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -64,6 +64,8 @@ import javafx.scene.shape.Polygon; import javafx.stage.Screen; import kst4contest.utils.ApplicationFileUtils; +import kst4contest.view.map.MapCallsignRawSnapshot; +import kst4contest.view.map.MapCallsignRawSnapshotBuilder; import kst4contest.view.map.StationMapBridge; import kst4contest.view.map.StationMapView; import kst4contest.view.map.OfflineDemImportService; @@ -116,6 +118,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL private final Tooltip tipSkedWarnIndicator = new Tooltip(); 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) private static final int TIMELINE_PRIORITY_MARKERS_PER_MINUTE = 2; @@ -414,6 +418,55 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL 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 enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands(); + if (enabledBands == null || !enabledBands.contains(band)) { + return false; + } + + List 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 snapshots = mainViewBandMarkerSnapshotBuilder.buildSnapshots( + variants, + null, + EnumSet.of(band) + ); + + return snapshots.stream().anyMatch(MapCallsignRawSnapshot::offersSelectedBand); + } + private String bandToHumanLabel(kst4contest.model.Band b) { // Human-friendly labels for VHF/UHF/microwave contesting return switch (b) { @@ -1204,14 +1257,18 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public ObservableValue call(CellDataFeatures cellDataFeatures) { SimpleStringProperty callsgn = new SimpleStringProperty(); - if (cellDataFeatures.getValue().getState() == 1) { - callsgn.setValue("(" + cellDataFeatures.getValue().getCallSign() + ")"); //away user - } else { + ChatMember member = cellDataFeatures.getValue(); + String baseCallsign; - 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; } @@ -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() { - @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 vhfCol_subcol = new TableColumn("144"); vhfCol_subcol @@ -1691,6 +1724,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked144()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_144)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1710,6 +1745,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked432()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_432)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1729,6 +1766,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked1240()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_1296)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1747,6 +1786,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked2300()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_2320)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1765,6 +1806,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked3400()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_3400)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1783,6 +1826,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked5600()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_5760)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } @@ -1801,6 +1846,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL if (cellDataFeatures.getValue().isWorked10G()) { wkd.setValue("X"); + } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_10G)) { + wkd.setValue("B+"); } else { wkd.setValue(""); } diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java index 743f66ed..a2f84da1 100644 --- a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java @@ -20,6 +20,7 @@ public record MapCallsignRawSnapshot( double longitudeDeg, String bandSummary, Map lastKnownFrequenciesByBand, + boolean offersSelectedBand, boolean warningToMyDirection, boolean worked, boolean selected, @@ -59,9 +60,10 @@ public record MapCallsignRawSnapshot( } public String markerLabel() { - return bandSummary.isBlank() + String baseLabel = bandSummary.isBlank() ? displayCallSign : displayCallSign + " (" + bandSummary + ")"; + return offersSelectedBand ? baseLabel + " B+" : baseLabel; } public String detailFrequencyText() { diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java index c5cfdd22..69231241 100644 --- a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java @@ -8,11 +8,13 @@ import kst4contest.model.ChatMember; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; +import java.util.EnumSet; import java.util.EnumMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Pattern; /** * Builds immutable map snapshots from the currently visible chat members. @@ -22,8 +24,11 @@ import java.util.Map; */ public final class MapCallsignRawSnapshotBuilder { + private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+"); + public List buildSnapshots(Collection visibleChatMembers, - ChatMember selectedChatMember) { + ChatMember selectedChatMember, + EnumSet selectedBands) { if (visibleChatMembers == null || visibleChatMembers.isEmpty()) { return List.of(); @@ -69,7 +74,9 @@ public final class MapCallsignRawSnapshotBuilder { Location location = new Location(locator6); LinkedHashMap frequenciesByBand = collectLastKnownFrequenciesByBand(variants); - String bandSummary = String.join(", ", frequenciesByBand.keySet()); + EnumSet sureBands = collectSureBands(variants, frequenciesByBand); + String bandSummary = buildBandSummary(sureBands); + boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands); boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange); boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand); @@ -92,6 +99,7 @@ public final class MapCallsignRawSnapshotBuilder { location.getLongitude().toDegrees(), bandSummary, frequenciesByBand, + offersSelectedBand, warningToMyDirection, worked, selected, @@ -182,6 +190,101 @@ public final class MapCallsignRawSnapshotBuilder { return ordered; } + private EnumSet collectSureBands(List variants, + LinkedHashMap frequenciesByBand) { + EnumSet 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 detectBandsFromStationName(String stationName) { + EnumSet 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 sureBands) { + if (sureBands == null || sureBands.isEmpty()) { + return ""; + } + + List labels = sureBands.stream() + .sorted() + .map(this::toBandDisplayLabel) + .toList(); + return String.join(", ", labels); + } + + private boolean hasAnySelectedBand(EnumSet sureBands, EnumSet 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 * knownActiveBands history has not yet been filled. diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java index 4fab7345..0ed78818 100644 --- a/src/main/java/kst4contest/view/map/StationMapBridge.java +++ b/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -11,6 +11,7 @@ import kst4contest.model.ChatPreferences; import java.util.ArrayList; import java.util.Comparator; +import java.util.EnumSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -142,8 +143,13 @@ public final class StationMapBridge { private void refreshNow() { List visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + EnumSet selectedBands = chatController.getReachabilityService().getEnabledStationBands(); - List snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember); + List snapshots = snapshotBuilder.buildSnapshots( + visibleChatMembers, + selectedChatMember, + selectedBands + ); MapCallsignRawSnapshot selectedSnapshot = null; if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java index 066b8a13..8bbc3678 100644 --- a/src/main/java/kst4contest/view/map/StationMapView.java +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -855,7 +855,11 @@ public final class StationMapView { detailLocatorValue.setText(selectedSnapshot.locator6()); detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm())); 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()); detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes())); triggerClusterSpotButton.setDisable(false); diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java new file mode 100644 index 00000000..0bc1bd53 --- /dev/null +++ b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java @@ -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 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 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; + } +}