enabledBands =
+ BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
- return snapshots.stream().anyMatch(MapCallsignRawSnapshot::offersSelectedBand);
+ return resolution.getUnworkedEnabledBands(enabledBands).contains(band);
+ }
+
+ /**
+ * Checks whether the station's four-character grid square has already been worked
+ * specifically on the given band, regardless of which call was worked there. Mirrors
+ * the "o" semantics of the wkdAny column, just scoped to one band instead of any band.
+ * Can be turned off via
+ * {@link ChatPreferences#isGuiOptions_showGrossFieldWorkedHintInBandColumns()}.
+ *
+ * @param chatMember station row
+ * @param band band to check
+ * @return true if the cell should show the "o" worked-grid-square hint
+ */
+ private boolean isGrossFieldWorkedForBand(ChatMember chatMember, Band band) {
+ if (chatMember == null || band == null || chatcontroller == null) {
+ return false;
+ }
+
+ if (!chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns()) {
+ return false;
+ }
+
+ String qra = chatMember.getQra();
+ if (qra == null || qra.isBlank()) {
+ return false;
+ }
+
+ return chatcontroller.getWorkedGrossFieldCache().isGrossFieldWorked(band, qra);
+ }
+
+ /**
+ * Resolves the compact per-band status shown in the 144/432/23/13/9/6/3 table
+ * columns.
+ *
+ * {@code X}/{@code a}/{@code B+}/(empty) describe the band-opportunity part:
+ * {@code X} if worked on this exact band, otherwise {@code a}/{@code B+} if the band
+ * is offered, enabled and unworked ({@code a} when the call has not been worked on
+ * any band yet, {@code B+} when it has), otherwise empty.
+ *
+ * {@code o} is an independent overlay, appended to whichever of the above applies,
+ * exactly like the {@code x}/{@code o}/{@code xo} combination in the wkdAny column:
+ * it marks that this band's grid square has already been worked, by any station.
+ * So e.g. {@code "a"}, {@code "ao"}, {@code "B+"}, {@code "B+o"} or just {@code "o"}
+ * can all appear.
+ *
+ * @param chatMember station row
+ * @param band band this column represents
+ * @param workedThisBand true if the per-band worked flag for this exact band is set
+ * @return compact status text for the cell
+ */
+ private String formatBandCellStatus(ChatMember chatMember, Band band, boolean workedThisBand) {
+ String opportunity;
+
+ boolean showFreshCallHint = chatcontroller != null
+ && chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns();
+
+ if (workedThisBand) {
+ opportunity = "X";
+ } else if (isBandOfferForMainView(chatMember, band)) {
+ opportunity = showFreshCallHint && chatMember != null && !chatMember.isWorked() ? "a" : "B+";
+ } else {
+ opportunity = "";
+ }
+
+ return isGrossFieldWorkedForBand(chatMember, band) ? opportunity + "o" : opportunity;
+ }
+
+ /**
+ * Builds the tooltip shown on a band-status cell: a fixed legend plus this row's
+ * resolved status for the given band.
+ */
+ private Tooltip buildBandCellStatusTooltip(ChatMember chatMember, Band band, String status) {
+ StringBuilder tooltip = new StringBuilder("Band status:\n")
+ .append("X = worked on this band\n")
+ .append("B+ = band available, not worked on this band yet (call already worked on another band)\n")
+ .append("a = band available, call not worked on any band yet (can be turned off in GUI settings; falls back to B+)\n")
+ .append("o = grid square already worked on this band, by any station (can be turned off in GUI settings)\n")
+ .append("(empty) = no information for this band\n")
+ .append("o can combine with the others, e.g. \"ao\" or \"B+o\"");
+
+ if (chatMember != null && band != null) {
+ tooltip.append("\n\nThis station (").append(bandToHumanLabel(band)).append("):\n")
+ .append("Status: ").append(status == null || status.isBlank() ? "-" : status);
+ }
+
+ return new Tooltip(tooltip.toString());
+ }
+
+ /**
+ * Creates a shared cell factory for one band-status column, attaching the
+ * {@link #buildBandCellStatusTooltip(ChatMember, Band, String)} tooltip.
+ */
+ private Callback, TableCell> createBandStatusCellFactory(Band band) {
+ return 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(buildBandCellStatusTooltip(member, band, item));
+ setAlignment(Pos.CENTER);
+ setStyle("-fx-font-weight: bold;");
+ }
+ };
}
private String bandToHumanLabel(kst4contest.model.Band b) {
@@ -529,6 +625,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
formatDetectedRxBandsForCallsignRaw(selectedCallSignInfoStageChatMember.getCallSignRaw(), 30L * 60L * 1000L)
);
lblDetectedRxBands.setWrapText(true);
+ lblDetectedRxBands.setTooltip(new Tooltip(
+ "Bands are derived from recent QRG detections and the station name. "
+ + "Manual NOT-QRV tags override automatic hints."
+ ));
selectedCallSignDownerSiteGridPane.add(lblDetectedRxBands, 0, 4, 1, 1);
@@ -684,6 +784,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
@@ -707,6 +808,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -728,6 +830,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -749,6 +852,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -770,6 +874,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -792,6 +897,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -813,6 +919,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -845,6 +952,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
try {
+ chatcontroller.propagateNotQrvStateToActiveMembers(selectedCallSignInfoStageChatMember);
chatcontroller.getDbHandler().updateNotQRVInfoOnChatMember(selectedCallSignInfoStageChatMember);
GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
} catch (Exception e) {
@@ -1692,20 +1800,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked144()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_144)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_144, cellDataFeatures.getValue().isWorked144()
+ ));
}
});
vhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28));
+ vhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_144));
TableColumn uhfCol_subcol = new TableColumn("432");
uhfCol_subcol
@@ -1713,122 +1814,80 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked432()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_432)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_432, cellDataFeatures.getValue().isWorked432()
+ ));
}
});
uhfCol_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(28));
+ uhfCol_subcol.setCellFactory(createBandStatusCellFactory(Band.B_432));
TableColumn shf23_subcol = new TableColumn("23");
shf23_subcol.setCellValueFactory(new Callback, ObservableValue>() {
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked1240()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_1296)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_1296, cellDataFeatures.getValue().isWorked1240()
+ ));
}
});
shf23_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30));
+ shf23_subcol.setCellFactory(createBandStatusCellFactory(Band.B_1296));
TableColumn shf13_subcol = new TableColumn("13");
shf13_subcol.setCellValueFactory(new Callback, ObservableValue>() {
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked2300()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_2320)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_2320, cellDataFeatures.getValue().isWorked2300()
+ ));
}
});
shf13_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(30));
+ shf13_subcol.setCellFactory(createBandStatusCellFactory(Band.B_2320));
TableColumn shf9_subcol = new TableColumn("9");
shf9_subcol.setCellValueFactory(new Callback, ObservableValue>() {
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked3400()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_3400)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_3400, cellDataFeatures.getValue().isWorked3400()
+ ));
}
});
shf9_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32));
+ shf9_subcol.setCellFactory(createBandStatusCellFactory(Band.B_3400));
TableColumn shf6_subcol = new TableColumn("6");
shf6_subcol.setCellValueFactory(new Callback, ObservableValue>() {
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked5600()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_5760)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_5760, cellDataFeatures.getValue().isWorked5600()
+ ));
}
});
shf6_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32));
+ shf6_subcol.setCellFactory(createBandStatusCellFactory(Band.B_5760));
TableColumn shf3_subcol = new TableColumn("3");
shf3_subcol.setCellValueFactory(new Callback, ObservableValue>() {
@Override
public ObservableValue call(CellDataFeatures cellDataFeatures) {
- SimpleStringProperty wkd = new SimpleStringProperty();
-
- if (cellDataFeatures.getValue().isWorked10G()) {
- wkd.setValue("X");
- } else if (isBandOfferForMainView(cellDataFeatures.getValue(), Band.B_10G)) {
- wkd.setValue("B+");
- } else {
- wkd.setValue("");
- }
-
- return wkd;
+ return new SimpleStringProperty(formatBandCellStatus(
+ cellDataFeatures.getValue(), Band.B_10G, cellDataFeatures.getValue().isWorked10G()
+ ));
}
});
shf3_subcol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(32));
+ shf3_subcol.setCellFactory(createBandStatusCellFactory(Band.B_10G));
/**
* section of NOT-QRV flag in chatmember table
@@ -7118,7 +7177,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
}
});
- btnTglNewBands.setTooltip(new Tooltip("Show stations that are QRV on a known active band that has not been worked yet"));
+ btnTglNewBands.setTooltip(new Tooltip(
+ "Show stations offering at least one enabled, unworked band. "
+ + "Recent QRG detections and station names are evaluated; "
+ + "NOT-QRV tags override them."
+ ));
ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m");
Predicate asNext5MinPredicate = new Predicate() {
@@ -10151,7 +10214,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlNotify.add(generateLabeledSeparator(100, "Band-upgrade hint (after log entry)"), 0, 13, 2, 1);
- Label lblNotifyBandUpgradeHint = new Label("Blink + sound if logged station is still QRV on other unworked enabled band(s)");
+ Label lblNotifyBandUpgradeHint = new Label(
+ "Blink + sound if a logged station still offers another unworked enabled band"
+ );
CheckBox chkBxNotifyBandUpgradeHint = new CheckBox();
chkBxNotifyBandUpgradeHint.setSelected(chatcontroller.getChatPreferences().isNotify_bandUpgradeHintOnLogEnabled());
chkBxNotifyBandUpgradeHint.selectedProperty().addListener((obs, o, n) ->
@@ -10708,6 +10773,38 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
0, 2, 2, 1);
grdPnlGuiOptions.add(new Label("Coloring mode:"), 0, 3);
+ grdPnlGuiOptions.add(generateLabeledSeparator(100, "Band table hints"), 0, 4, 2, 1);
+
+ Label lblShowGrossFieldWorkedHint = new Label(
+ "Show \"o\" in band columns when the grid square is already worked on that band"
+ );
+ CheckBox chkBxShowGrossFieldWorkedHint = new CheckBox();
+ chkBxShowGrossFieldWorkedHint.setSelected(
+ chatcontroller.getChatPreferences().isGuiOptions_showGrossFieldWorkedHintInBandColumns()
+ );
+ chkBxShowGrossFieldWorkedHint.selectedProperty().addListener((obs, o, n) -> {
+ chatcontroller.getChatPreferences().setGuiOptions_showGrossFieldWorkedHintInBandColumns(n);
+ GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
+ });
+
+ grdPnlGuiOptions.add(lblShowGrossFieldWorkedHint, 0, 5);
+ grdPnlGuiOptions.add(chkBxShowGrossFieldWorkedHint, 1, 5);
+
+ Label lblShowFreshCallHint = new Label(
+ "Show \"a\" in band columns for a call not worked on any band yet (otherwise always \"B+\")"
+ );
+ CheckBox chkBxShowFreshCallHint = new CheckBox();
+ chkBxShowFreshCallHint.setSelected(
+ chatcontroller.getChatPreferences().isGuiOptions_showFreshCallHintInBandColumns()
+ );
+ chkBxShowFreshCallHint.selectedProperty().addListener((obs, o, n) -> {
+ chatcontroller.getChatPreferences().setGuiOptions_showFreshCallHintInBandColumns(n);
+ GuiUtils.triggerGUIFilteredChatMemberListChange(chatcontroller);
+ });
+
+ grdPnlGuiOptions.add(lblShowFreshCallHint, 0, 6);
+ grdPnlGuiOptions.add(chkBxShowFreshCallHint, 1, 6);
+
@@ -12004,43 +12101,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
* @return true if there is a new-band opportunity
*/
private boolean isNewBandOpportunity(ChatMember member) {
- if (member == null || chatcontroller.getReachabilityService() == null
- || member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
+ if (member == null || chatcontroller == null) {
return false;
}
- EnumSet enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
- for (Band band : member.getKnownActiveBands().keySet()) {
- if (band != null && enabledBands.contains(band) && !isWorkedOnBand(member, band)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Maps the existing per-band worked flags to the Band enum.
- *
- * @param member station row
- * @param band band to inspect
- * @return true if worked on that band
- */
- private boolean isWorkedOnBand(ChatMember member, Band band) {
- if (member == null || band == null) {
- return false;
+ List variants =
+ chatcontroller.findActiveChatMembersByRawCall(member.getCallSignRaw());
+ if (variants.isEmpty()) {
+ variants = List.of(member);
}
- switch (band) {
- case B_144: return member.isWorked144();
- case B_432: return member.isWorked432();
- case B_1296: return member.isWorked1240();
- case B_2320: return member.isWorked2300();
- case B_3400: return member.isWorked3400();
- case B_5760: return member.isWorked5600();
- case B_10G: return member.isWorked10G();
- case B_24G: return member.isWorked24G();
- default: return false;
- }
+ BandOpportunityResolver.Resolution resolution =
+ BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
+ EnumSet enabledBands =
+ BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
+
+ return !resolution.getUnworkedEnabledBands(enabledBands).isEmpty();
}
}
diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
index 6923124..6091321 100644
--- a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
+++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
@@ -1,6 +1,7 @@
package kst4contest.view.map;
import kst4contest.locatorUtils.Location;
+import kst4contest.logic.BandOpportunityResolver;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.Band;
import kst4contest.model.ChatMember;
@@ -14,7 +15,6 @@ 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.
@@ -24,8 +24,6 @@ import java.util.regex.Pattern;
*/
public final class MapCallsignRawSnapshotBuilder {
- private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
-
public List buildSnapshots(Collection visibleChatMembers,
ChatMember selectedChatMember,
EnumSet selectedBands) {
@@ -73,10 +71,21 @@ public final class MapCallsignRawSnapshotBuilder {
Location location = new Location(locator6);
- LinkedHashMap frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
- EnumSet sureBands = collectSureBands(variants, frequenciesByBand);
- String bandSummary = buildBandSummary(sureBands);
- boolean offersSelectedBand = hasAnySelectedBand(sureBands, selectedBands);
+ long nowEpochMs = System.currentTimeMillis();
+ BandOpportunityResolver.Resolution bandResolution =
+ BandOpportunityResolver.resolve(variants, nowEpochMs);
+
+ EnumSet availableBands = bandResolution.getAvailableBands();
+ LinkedHashMap frequenciesByBand = collectLastKnownFrequenciesByBand(
+ variants,
+ availableBands,
+ nowEpochMs
+ );
+
+ String bandSummary = buildBandSummary(availableBands);
+ boolean offersSelectedBand = !bandResolution
+ .getUnworkedEnabledBands(selectedBands)
+ .isEmpty();
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
@@ -152,7 +161,11 @@ public final class MapCallsignRawSnapshotBuilder {
.orElse("");
}
- private LinkedHashMap collectLastKnownFrequenciesByBand(List variants) {
+ private LinkedHashMap collectLastKnownFrequenciesByBand(
+ List variants,
+ EnumSet availableBands,
+ long nowEpochMs
+ ) {
Map latestByBand = new EnumMap<>(Band.class);
@@ -161,16 +174,29 @@ public final class MapCallsignRawSnapshotBuilder {
continue;
}
- for (Map.Entry bandEntry : variant.getKnownActiveBands().entrySet()) {
+ for (Map.Entry bandEntry
+ : variant.getKnownActiveBands().entrySet()) {
+
Band band = bandEntry.getKey();
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
- if (band == null || activeFrequencyInfo == null) {
+ if (band == null
+ || activeFrequencyInfo == null
+ || availableBands == null
+ || !availableBands.contains(band)) {
+ continue;
+ }
+
+ long ageMs = nowEpochMs - activeFrequencyInfo.timestampEpoch;
+ if (ageMs < 0L
+ || ageMs > BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS) {
continue;
}
FrequencyCandidate previous = latestByBand.get(band);
- if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
+ if (previous == null
+ || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
+
latestByBand.put(band, new FrequencyCandidate(
band,
formatFrequency(activeFrequencyInfo.frequency),
@@ -178,152 +204,31 @@ public final class MapCallsignRawSnapshotBuilder {
));
}
}
-
- addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
}
LinkedHashMap ordered = new LinkedHashMap<>();
latestByBand.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
- .forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
+ .forEach(entry -> ordered.put(
+ toBandDisplayLabel(entry.getKey()),
+ entry.getValue().formattedFrequency()
+ ));
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()) {
+ private String buildBandSummary(EnumSet availableBands) {
+ if (availableBands == null || availableBands.isEmpty()) {
return "";
}
- List labels = sureBands.stream()
+ List labels = availableBands.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.
- *
- * This parsing is intentionally tolerant so strings like "144.300 MHz"
- * can still be used.
- */
- private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map latestByBand) {
- if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) {
- return;
- }
-
- String rawFrequency = variant.getFrequency().getValue().trim();
- if (rawFrequency.isBlank()) {
- return;
- }
-
- double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency);
- if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) {
- return;
- }
-
- Band detectedBand = Band.fromFrequency(parsedFrequencyMHz);
- if (detectedBand == null) {
- return;
- }
-
- FrequencyCandidate previous = latestByBand.get(detectedBand);
- if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) {
- return;
- }
-
- latestByBand.put(detectedBand, new FrequencyCandidate(
- detectedBand,
- formatFrequency(parsedFrequencyMHz),
- variant.getActivityTimeLastInEpoch()
- ));
- }
-
private boolean isWorkedAtAnyBand(ChatMember member) {
return member.isWorked()
|| member.isWorked50()
diff --git a/src/main/java/kst4contest/view/map/PathAnalysisResult.java b/src/main/java/kst4contest/view/map/PathAnalysisResult.java
index 200b542..d5d172b 100644
--- a/src/main/java/kst4contest/view/map/PathAnalysisResult.java
+++ b/src/main/java/kst4contest/view/map/PathAnalysisResult.java
@@ -240,6 +240,36 @@ public record PathAnalysisResult(
);
}
+ /**
+ * Creates a placeholder when automatic band resolution has no usable result.
+ */
+ public static PathAnalysisResult waitingForUsableBand(String fromLocator6,
+ String toLocator6,
+ String toCallsignRaw) {
+ return new PathAnalysisResult(
+ "Waiting",
+ fromLocator6,
+ toLocator6,
+ toCallsignRaw,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ false,
+ false,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ Double.NaN,
+ -1,
+ "No usable automatic band is available. "
+ + "Check own enabled bands and the station's NOT-QRV tags.",
+ List.of()
+ );
+ }
+
/**
* Creates a finished result without a usable terrain profile.
*
diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java
index 0ed7881..bb76bae 100644
--- a/src/main/java/kst4contest/view/map/StationMapBridge.java
+++ b/src/main/java/kst4contest/view/map/StationMapBridge.java
@@ -343,6 +343,8 @@ public final class StationMapBridge {
+ "|"
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
+ "|"
+ + selectedSnapshot.bandSummary()
+ + "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
+ "|"
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
diff --git a/src/test/java/kst4contest/test/BandOpportunityResolverTest.java b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java
new file mode 100644
index 0000000..b4d0c64
--- /dev/null
+++ b/src/test/java/kst4contest/test/BandOpportunityResolverTest.java
@@ -0,0 +1,97 @@
+package kst4contest.test;
+
+import kst4contest.logic.BandOpportunityResolver;
+import kst4contest.model.Band;
+import kst4contest.model.ChatMember;
+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 BandOpportunityResolverTest {
+
+ @Test
+ void resolvesCommonShorthandBandsFromStationName() {
+ EnumSet bands = BandOpportunityResolver.detectBandsFromStationName(
+ "Peter QRV 2-70-23/13/9/6/3"
+ );
+
+ assertEquals(
+ EnumSet.of(
+ Band.B_144,
+ Band.B_432,
+ Band.B_1296,
+ Band.B_2320,
+ Band.B_3400,
+ Band.B_5760,
+ Band.B_10G
+ ),
+ bands
+ );
+ }
+
+ @Test
+ void doesNotMistakeOnePointTwoCentimetersForTwoMeters() {
+ EnumSet bands = BandOpportunityResolver.detectBandsFromStationName(
+ "David 23/3/1.2"
+ );
+
+ assertEquals(EnumSet.of(Band.B_1296, Band.B_10G, Band.B_24G), bands);
+ assertFalse(bands.contains(Band.B_144));
+ }
+
+ @Test
+ void keepsOnlyRecentDynamicBandEvidence() {
+ long now = 1_000_000L;
+ ChatMember station = new ChatMember();
+ station.addKnownFrequency(Band.B_144, 144.210);
+ station.addKnownFrequency(Band.B_432, 432.210);
+ station.getKnownActiveBands().get(Band.B_144).timestampEpoch = now - 5_000L;
+ station.getKnownActiveBands().get(Band.B_432).timestampEpoch =
+ now - BandOpportunityResolver.RECENT_DYNAMIC_EVIDENCE_MAX_AGE_MS - 1L;
+
+ BandOpportunityResolver.Resolution resolution =
+ BandOpportunityResolver.resolve(List.of(station), now);
+
+ assertEquals(EnumSet.of(Band.B_144), resolution.getOfferedBands());
+ }
+
+ @Test
+ void notQrvOverridesNameAndFrequencyEvidenceAcrossVariants() {
+ long now = 1_000_000L;
+
+ ChatMember categoryTwo = new ChatMember();
+ categoryTwo.setName("QRV 2m 70cm");
+ categoryTwo.addKnownFrequency(Band.B_432, 432.210);
+ categoryTwo.getKnownActiveBands().get(Band.B_432).timestampEpoch = now - 1_000L;
+
+ ChatMember categoryThree = new ChatMember();
+ categoryThree.setQrv432(false);
+
+ BandOpportunityResolver.Resolution resolution =
+ BandOpportunityResolver.resolve(List.of(categoryTwo, categoryThree), now);
+
+ assertTrue(resolution.getOfferedBands().contains(Band.B_432));
+ assertFalse(resolution.getAvailableBands().contains(Band.B_432));
+ assertTrue(resolution.getAvailableBands().contains(Band.B_144));
+ }
+
+ @Test
+ void opportunityRequiresAvailableEnabledAndUnworkedBand() {
+ ChatMember station = new ChatMember();
+ station.setName("2m 70cm");
+ station.setWorked144(true);
+
+ BandOpportunityResolver.Resolution resolution =
+ BandOpportunityResolver.resolve(List.of(station), System.currentTimeMillis());
+
+ assertEquals(
+ EnumSet.of(Band.B_432),
+ resolution.getUnworkedEnabledBands(EnumSet.of(Band.B_144, Band.B_432))
+ );
+ }
+}
diff --git a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java
index 0bc1bd5..ef81478 100644
--- a/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java
+++ b/src/test/java/kst4contest/test/MapCallsignRawSnapshotBuilderTest.java
@@ -45,6 +45,38 @@ class MapCallsignRawSnapshotBuilderTest {
assertFalse(snapshots.get(0).offersSelectedBand());
}
+ @Test
+ void notQrvOverridesNameDerivedMapOpportunity() {
+ ChatMember station = buildStation("DL1ABC", "QRV 2m 70cm", "JN58TD", 1_000L);
+ station.setQrv432(false);
+
+ MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
+ MapCallsignRawSnapshot snapshot = builder.buildSnapshots(
+ List.of(station),
+ null,
+ EnumSet.of(Band.B_432)
+ ).get(0);
+
+ assertFalse(snapshot.offersSelectedBand());
+ assertFalse(snapshot.bandSummary().contains("432"));
+ }
+
+ @Test
+ void workedBandIsShownAsInformationButNotAsUpgradeOpportunity() {
+ ChatMember station = buildStation("DL1ABC", "QRV 2m", "JN58TD", 1_000L);
+ station.setWorked144(true);
+
+ MapCallsignRawSnapshotBuilder builder = new MapCallsignRawSnapshotBuilder();
+ MapCallsignRawSnapshot snapshot = builder.buildSnapshots(
+ List.of(station),
+ null,
+ EnumSet.of(Band.B_144)
+ ).get(0);
+
+ assertTrue(snapshot.bandSummary().contains("144"));
+ assertFalse(snapshot.offersSelectedBand());
+ }
+
private ChatMember buildStation(String callSign, String name, String locator, long activityEpoch) {
ChatMember chatMember = new ChatMember();
chatMember.setCallSign(callSign);