mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-13 04:35:35 +02:00
* 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>
390 lines
14 KiB
Java
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) {
|
|
}
|
|
} |