Files
kst4contest/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
T
7d3177ba8a 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>
2026-07-08 22:32:54 +02:00

390 lines
14 KiB
Java

package kst4contest.view.map;
import kst4contest.locatorUtils.Location;
import kst4contest.model.AirPlaneReflectionInfo;
import kst4contest.model.Band;
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.
*
* The aggregation key is callSignRaw because the map shall show exactly one marker
* per base callsign, even if the same station exists in multiple chat categories.
*/
public final class MapCallsignRawSnapshotBuilder {
private static final Pattern TOKEN_SPLIT_PATTERN = Pattern.compile("[^A-Z0-9]+");
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
ChatMember selectedChatMember,
EnumSet<Band> selectedBands) {
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
return List.of();
}
String selectedCallsignRaw = normalizeCallsignRaw(
selectedChatMember == null ? null : selectedChatMember.getCallSignRaw()
);
Map<String, List<ChatMember>> groupedByCallsignRaw = new LinkedHashMap<>();
for (ChatMember chatMember : visibleChatMembers) {
if (chatMember == null) {
continue;
}
String callSignRaw = normalizeCallsignRaw(chatMember.getCallSignRaw());
if (callSignRaw.isBlank()) {
continue;
}
groupedByCallsignRaw
.computeIfAbsent(callSignRaw, ignored -> new ArrayList<>())
.add(chatMember);
}
List<MapCallsignRawSnapshot> snapshots = new ArrayList<>(groupedByCallsignRaw.size());
for (Map.Entry<String, List<ChatMember>> entry : groupedByCallsignRaw.entrySet()) {
String callSignRaw = entry.getKey();
List<ChatMember> variants = entry.getValue();
ChatMember representative = chooseRepresentative(variants);
if (representative == null) {
continue;
}
String locator6 = findBestLocator6(variants);
if (locator6.isBlank()) {
continue;
}
Location location = new Location(locator6);
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
EnumSet<Band> 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);
boolean selected = callSignRaw.equals(selectedCallsignRaw);
double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0;
double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0;
int reachableAirplanes = variants.stream()
.map(ChatMember::getAirPlaneReflectInfo)
.mapToInt(this::extractReachableAirplanes)
.max()
.orElse(0);
snapshots.add(new MapCallsignRawSnapshot(
callSignRaw,
bestDisplayCallsign(variants, representative),
locator6,
location.getLatitude().toDegrees(),
location.getLongitude().toDegrees(),
bandSummary,
frequenciesByBand,
offersSelectedBand,
warningToMyDirection,
worked,
selected,
qrbKm,
qtfDeg,
reachableAirplanes,
representative.getActivityTimeLastInEpoch()
));
}
snapshots.sort(Comparator.comparing(
MapCallsignRawSnapshot::displayCallSign,
String.CASE_INSENSITIVE_ORDER
));
return List.copyOf(snapshots);
}
private ChatMember chooseRepresentative(List<ChatMember> variants) {
return variants.stream()
.filter(chatMember -> chatMember != null && normalizeLocator6(chatMember.getQra()).length() == 6)
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElseGet(() -> variants.stream()
.filter(chatMember -> chatMember != null)
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
.orElse(null));
}
private String bestDisplayCallsign(List<ChatMember> variants, ChatMember representative) {
if (representative != null && representative.getCallSign() != null && !representative.getCallSign().isBlank()) {
return representative.getCallSign().trim().toUpperCase(Locale.ROOT);
}
for (ChatMember variant : variants) {
if (variant != null && variant.getCallSign() != null && !variant.getCallSign().isBlank()) {
return variant.getCallSign().trim().toUpperCase(Locale.ROOT);
}
}
return representative == null ? "" : normalizeCallsignRaw(representative.getCallSignRaw());
}
private String findBestLocator6(List<ChatMember> variants) {
return variants.stream()
.sorted(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch).reversed())
.map(ChatMember::getQra)
.map(this::normalizeLocator6)
.filter(locator -> locator.length() == 6)
.findFirst()
.orElse("");
}
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
for (ChatMember variant : variants) {
if (variant == null) {
continue;
}
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
Band band = bandEntry.getKey();
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
if (band == null || activeFrequencyInfo == null) {
continue;
}
FrequencyCandidate previous = latestByBand.get(band);
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
latestByBand.put(band, new FrequencyCandidate(
band,
formatFrequency(activeFrequencyInfo.frequency),
activeFrequencyInfo.timestampEpoch
));
}
}
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
}
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
latestByBand.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
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
* 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<Band, FrequencyCandidate> 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()
|| member.isWorked70()
|| member.isWorked144()
|| member.isWorked432()
|| member.isWorked1240()
|| member.isWorked2300()
|| member.isWorked3400()
|| member.isWorked5600()
|| member.isWorked10G()
|| member.isWorked24G()
|| member.isWorked47G()
|| member.isWorked76G();
}
private int extractReachableAirplanes(AirPlaneReflectionInfo airPlaneReflectionInfo) {
return airPlaneReflectionInfo == null ? 0 : airPlaneReflectionInfo.getAirPlanesReachableCntr();
}
private String normalizeCallsignRaw(String callSignRaw) {
if (callSignRaw == null) {
return "";
}
return callSignRaw.trim().toUpperCase(Locale.ROOT);
}
private String normalizeLocator6(String locator) {
if (locator == null) {
return "";
}
String normalized = locator.trim().toUpperCase(Locale.ROOT);
if (normalized.length() >= 6) {
normalized = normalized.substring(0, 6);
}
if (!normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$")) {
return "";
}
return normalized;
}
private String toBandDisplayLabel(Band band) {
return switch (band) {
case B_144 -> "144";
case B_432 -> "432";
case B_1296 -> "1296";
case B_2320 -> "2320";
case B_3400 -> "3400";
case B_5760 -> "5760";
case B_10G -> "10368";
case B_24G -> "24048";
};
}
private String formatFrequency(double frequencyMHz) {
return String.format(Locale.US, "%.3f", frequencyMHz);
}
private record FrequencyCandidate(Band band, String formattedFrequency, long timestampEpochMs) {
}
}