From bf6f04c910eaafee9a40ed741aeb2225f4b77f85 Mon Sep 17 00:00:00 2001 From: Marc Froehlich Date: Mon, 22 Jun 2026 20:15:34 +0200 Subject: [PATCH] Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now --- SimpleLogFile.txt | 2 + .../kst4contest/ApplicationConstants.java | 17 + .../controller/ChatController.java | 257 +- .../MessageBusManagementThread.java | 44 +- .../kst4contest/model/ChatPreferences.java | 329 +++ .../service/path/FresnelMathUtils.java | 74 + .../view/Kst4ContestApplication.java | 402 ++- .../map/ChainedTerrainProfileProvider.java | 36 + ...CopernicusGlo30TerrainProfileProvider.java | 293 ++ .../java/kst4contest/view/map/DemDataset.java | 45 + .../map/FallbackTerrainProfileProvider.java | 48 + .../map/GeometryOnlyPathAnalysisService.java | 1188 +++++++++ .../view/map/MaidenheadGridRenderPlanner.java | 228 ++ .../view/map/MaidenheadGridUtils.java | 220 ++ .../view/map/MapCallsignRawSnapshot.java | 78 + .../map/MapCallsignRawSnapshotBuilder.java | 287 ++ .../view/map/MapHtmlResources.java | 675 +++++ .../view/map/NoOpTerrainProfileProvider.java | 12 + .../view/map/OfflineDemImportService.java | 232 ++ .../view/map/OfflineDemManager.java | 214 ++ .../map/OfflineDemTerrainProfileProvider.java | 43 + .../map/OpenMeteoTerrainProfileProvider.java | 340 +++ .../view/map/PathAnalysisRequest.java | 102 + .../view/map/PathAnalysisResult.java | 710 +++++ .../view/map/PathAnalysisService.java | 12 + .../view/map/PathGeometryUtils.java | 698 +++++ .../view/map/PathHorizonSummary.java | 108 + .../view/map/PathLinkBudgetSettings.java | 64 + .../view/map/PathLinkBudgetSummary.java | 130 + .../view/map/PathObstructionSummary.java | 105 + .../view/map/PathProfileChart.java | 1162 ++++++++ .../view/map/PathProfilePoint.java | 212 ++ .../view/map/PathPropagationAssessment.java | 105 + .../view/map/StationMapBridge.java | 387 +++ .../kst4contest/view/map/StationMapView.java | 1446 ++++++++++ .../map/SyntheticTerrainProfileProvider.java | 57 + .../kst4contest/view/map/TerrainCatalog.java | 95 + .../view/map/TerrainCatalogClient.java | 300 +++ .../view/map/TerrainCatalogPackageEntry.java | 107 + .../view/map/TerrainCoverageResolver.java | 377 +++ .../view/map/TerrainPackageDownloader.java | 259 ++ .../view/map/TerrainPackageInstaller.java | 303 +++ .../view/map/TerrainPackageManifest.java | 101 + .../view/map/TerrainPackageService.java | 418 +++ .../map/TerrainProfileCacheRepository.java | 300 +++ .../view/map/TerrainProfileData.java | 26 + .../view/map/TerrainProfileProvider.java | 9 + .../view/map/TerrainProfileRequest.java | 26 + .../view/map/TerrainTileMetadata.java | 132 + .../java/kst4contest/view/map/mapTest.java | 24 + src/main/java/module-info.java | 6 +- udpReaderBackup.txt | 2368 +++++++++++++++++ 52 files changed, 15153 insertions(+), 60 deletions(-) create mode 100644 SimpleLogFile.txt create mode 100644 src/main/java/kst4contest/service/path/FresnelMathUtils.java create mode 100644 src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/DemDataset.java create mode 100644 src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java create mode 100644 src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java create mode 100644 src/main/java/kst4contest/view/map/MaidenheadGridUtils.java create mode 100644 src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java create mode 100644 src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java create mode 100644 src/main/java/kst4contest/view/map/MapHtmlResources.java create mode 100644 src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/OfflineDemImportService.java create mode 100644 src/main/java/kst4contest/view/map/OfflineDemManager.java create mode 100644 src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/PathAnalysisRequest.java create mode 100644 src/main/java/kst4contest/view/map/PathAnalysisResult.java create mode 100644 src/main/java/kst4contest/view/map/PathAnalysisService.java create mode 100644 src/main/java/kst4contest/view/map/PathGeometryUtils.java create mode 100644 src/main/java/kst4contest/view/map/PathHorizonSummary.java create mode 100644 src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java create mode 100644 src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java create mode 100644 src/main/java/kst4contest/view/map/PathObstructionSummary.java create mode 100644 src/main/java/kst4contest/view/map/PathProfileChart.java create mode 100644 src/main/java/kst4contest/view/map/PathProfilePoint.java create mode 100644 src/main/java/kst4contest/view/map/PathPropagationAssessment.java create mode 100644 src/main/java/kst4contest/view/map/StationMapBridge.java create mode 100644 src/main/java/kst4contest/view/map/StationMapView.java create mode 100644 src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/TerrainCatalog.java create mode 100644 src/main/java/kst4contest/view/map/TerrainCatalogClient.java create mode 100644 src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java create mode 100644 src/main/java/kst4contest/view/map/TerrainCoverageResolver.java create mode 100644 src/main/java/kst4contest/view/map/TerrainPackageDownloader.java create mode 100644 src/main/java/kst4contest/view/map/TerrainPackageInstaller.java create mode 100644 src/main/java/kst4contest/view/map/TerrainPackageManifest.java create mode 100644 src/main/java/kst4contest/view/map/TerrainPackageService.java create mode 100644 src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java create mode 100644 src/main/java/kst4contest/view/map/TerrainProfileData.java create mode 100644 src/main/java/kst4contest/view/map/TerrainProfileProvider.java create mode 100644 src/main/java/kst4contest/view/map/TerrainProfileRequest.java create mode 100644 src/main/java/kst4contest/view/map/TerrainTileMetadata.java create mode 100644 src/main/java/kst4contest/view/map/mapTest.java create mode 100644 udpReaderBackup.txt diff --git a/SimpleLogFile.txt b/SimpleLogFile.txt new file mode 100644 index 0000000..4b37091 --- /dev/null +++ b/SimpleLogFile.txt @@ -0,0 +1,2 @@ +S53MM +PA9R \ No newline at end of file diff --git a/src/main/java/kst4contest/ApplicationConstants.java b/src/main/java/kst4contest/ApplicationConstants.java index 577262a..06405a4 100644 --- a/src/main/java/kst4contest/ApplicationConstants.java +++ b/src/main/java/kst4contest/ApplicationConstants.java @@ -38,7 +38,24 @@ public class ApplicationConstants { public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it) + /** + * UI message retention limits. + * + * The global chat message list is the backing list for several FilteredLists + * and TableViews. It must not grow without limit during long contest runs. + * + * The list is kept in newest-first order: + * index 0 = newest message + * last index = oldest message + */ + public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000; + public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000; + /** + * DXCluster table retention limits. + */ + public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000; + public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000; /** * generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index e81ea9c..2388075 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -23,7 +23,6 @@ import kst4contest.locatorUtils.DirectionUtils; import kst4contest.logic.PriorityCalculator; import kst4contest.model.*; import kst4contest.test.MockKstServer; -import kst4contest.utils.BoundedDequeObservableList; import kst4contest.utils.PlayAudioUtils; import kst4contest.view.Kst4ContestApplication; @@ -33,6 +32,8 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Predicate; + + /** * * Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat. @@ -1094,8 +1095,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // ******All abstract types below here are used by the messageprocessor! // *************** - private static final int MAX_CHAT_MESSAGES = 10000; - private final BoundedDequeObservableList lst_globalChatMessageList = new BoundedDequeObservableList<>(MAX_CHAT_MESSAGES); //All chatmessages will be put in there, later create filtered message lists + private ObservableList lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists // private ObservableList lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all // (beacon) private FilteredList lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all @@ -1129,6 +1129,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList private ObservableList> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList(); private ObservableList lst_clusterMemberList = FXCollections.observableArrayList(); + + /* + * Message table update buffers. + * + * Do not write directly to lst_globalChatMessageList from worker threads. + * Use publishChatMessage(...) instead. + * + * The actual ObservableList mutation is batched and executed on the JavaFX + * application thread. The visible list order remains newest-first. + */ + private final Object pendingChatMessagesLock = new Object(); + private final List pendingChatMessages = new ArrayList<>(); + private boolean chatMessageFlushScheduled = false; + + /* + * Same idea for DXCluster messages. + */ + private final Object pendingClusterMessagesLock = new Object(); + private final List pendingClusterMessages = new ArrayList<>(); + private boolean clusterMessageFlushScheduled = false; + private ObservableList lst_DBBasedWkdCallSignList = FXCollections.observableArrayList(); // private HashMap map_ucxLogInfoWorkedCalls = new HashMap(); //Destination of ucx-log worked-messages @@ -1240,14 +1261,152 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList; } - public void addChatMessage(ChatMessage message) { - lst_globalChatMessageList.addFirst(message); - } - public ObservableList getLst_globalChatMessageList() { return lst_globalChatMessageList; } + /** + * Adds a chat message to the UI message store. + * + * Important: + * - This method may be called from worker threads. + * - The ObservableList is modified only on the JavaFX application thread. + * - The backing list remains newest-first. + * - Old messages are trimmed to avoid unlimited memory growth. + */ + public void publishChatMessage(ChatMessage message) { + if (message == null) { + return; + } + + synchronized (pendingChatMessagesLock) { + pendingChatMessages.add(message); + + if (chatMessageFlushScheduled) { + return; + } + + chatMessageFlushScheduled = true; + } + + Platform.runLater(this::flushPendingChatMessagesToUi); + } + + private void flushPendingChatMessagesToUi() { + List batch; + + synchronized (pendingChatMessagesLock) { + batch = new ArrayList<>(pendingChatMessages); + pendingChatMessages.clear(); + chatMessageFlushScheduled = false; + } + + if (batch.isEmpty()) { + return; + } + + /* + * pendingChatMessages is collected in arrival order: + * old -> new + * + * lst_globalChatMessageList must stay newest-first: + * new -> old + */ + Collections.reverse(batch); + + lst_globalChatMessageList.addAll(0, batch); + + trimGlobalChatMessageListIfNeeded(); + } + + private void trimGlobalChatMessageListIfNeeded() { + int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE; + int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE; + + if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { + return; + } + + int currentSize = lst_globalChatMessageList.size(); + + if (currentSize <= maxSize) { + return; + } + + /* + * List order is newest-first. + * Therefore old messages are at the end of the list. + */ + lst_globalChatMessageList.remove(trimToSize, currentSize); + } + + /** + * Adds a DXCluster message to the UI cluster message store. + * + * Same policy as for chat messages: + * - batched UI update + * - JavaFX thread only for ObservableList mutation + * - newest-first visible order + * - bounded list size + */ + public void publishClusterMessage(ClusterMessage message) { + if (message == null) { + return; + } + + synchronized (pendingClusterMessagesLock) { + pendingClusterMessages.add(message); + + if (clusterMessageFlushScheduled) { + return; + } + + clusterMessageFlushScheduled = true; + } + + Platform.runLater(this::flushPendingClusterMessagesToUi); + } + + private void flushPendingClusterMessagesToUi() { + List batch; + + synchronized (pendingClusterMessagesLock) { + batch = new ArrayList<>(pendingClusterMessages); + pendingClusterMessages.clear(); + clusterMessageFlushScheduled = false; + } + + if (batch.isEmpty()) { + return; + } + + Collections.reverse(batch); + + lst_clusterMemberList.addAll(0, batch); + + trimClusterMessageListIfNeeded(); + } + + private void trimClusterMessageListIfNeeded() { + int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE; + int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE; + + if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) { + return; + } + + int currentSize = lst_clusterMemberList.size(); + + if (currentSize <= maxSize) { + return; + } + + lst_clusterMemberList.remove(trimToSize, currentSize); + } + + public void setLst_globalChatMessageList(ObservableList lst_globalChatMessageList) { + this.lst_globalChatMessageList = lst_globalChatMessageList; + } public String getHostname() { return hostname; @@ -1482,6 +1641,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList } + private void initLst_toMeMessageList() { // ObservableList sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList(); @@ -1498,14 +1658,18 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // --- NEUE LOGIK: Sniffer Liste prüfen --- // Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen - if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) || - lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) && - (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) { +// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) || +// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) && +// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) { +// +// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText; +// chatMessage.setMessageText(msgText); +// return true; +// } - msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText; - chatMessage.setMessageText(msgText); - return true; - } + if (isSniffedMessage(chatMessage)) { + return true; + } // --- BESTEHENDE LOGIK --- @@ -2388,4 +2552,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth); } + + /** + * decides if a message in the in-queue is directed to me or if its directed to another station and sniffed + * @param chatMessage + * @return + */ + public boolean isSniffedMessage(ChatMessage chatMessage) { + if (chatMessage == null || chatMessage.getSender() == null || chatMessage.getReceiver() == null) { + return false; + } + + String senderCall = chatMessage.getSender().getCallSign(); + String receiverCall = chatMessage.getReceiver().getCallSign(); + + if (senderCall == null || receiverCall == null) { + return false; + } + + if (lstNotify_QSOSniffer_sniffedCallSignList == null || lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) { + return false; + } + + boolean observedCall = + lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) + || lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall); + + if (!observedCall) { + return false; + } + + String myCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSign() : null; + String myRawCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSignRaw() : null; + + /* + * Sniffed messages should appear in the private table only if they are not + * already direct messages to my own callsign. + */ + return !receiverCall.equals(myCall) && !receiverCall.equals(myRawCall); + } + + /** + * changes the chatmessage if it had been a sniffed one and not directed to me. Only for marking. + * @param chatMessage + * @return + */ + public String formatChatMessageTextForDisplay(ChatMessage chatMessage) { + if (chatMessage == null) { + return ""; + } + + String msgText = chatMessage.getMessageText(); + + if (msgText == null) { + msgText = ""; + } + + if (!isSniffedMessage(chatMessage)) { + return msgText; + } + + String senderCall = chatMessage.getSender() != null ? chatMessage.getSender().getCallSign() : ""; + String receiverCall = chatMessage.getReceiver() != null ? chatMessage.getReceiver().getCallSign() : ""; + + return "Sniffed: (" + senderCall + " > " + receiverCall + ") " + msgText; + } } \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 064da22..eba1e07 100644 --- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -772,7 +772,9 @@ public class MessageBusManagementThread extends Thread { dummy.setCallSign("ALL"); newMessageArrived.setReceiver(dummy); - this.client.addChatMessage(newMessageArrived); // sdtout to all message-List + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) + +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List } else { //message is directed to another chatmember, process as such! @@ -817,7 +819,9 @@ public class MessageBusManagementThread extends Thread { if (newMessageArrived.getReceiver().getCallSign() .equals(this.client.getChatPreferences().getStn_loginCallSign())) { - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); + + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) if (this.client.getChatPreferences().isNotify_playSimpleSounds()) { this.client.getPlayAudioUtils().playNoiseLauncher('P'); @@ -960,14 +964,9 @@ public class MessageBusManagementThread extends Thread { String originalMessage = newMessageArrived.getMessageText(); newMessageArrived .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0,newMessageArrived); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) - // If our message contained a frequency (e.g. "QRG is: 144.375"), record that - // WE sent our QRG to this OM – used by SKED frequency resolution. - if (originalMessage != null && newMessageArrived.getReceiver() != null - && originalMessage.matches(".*\\b\\d{3,5}[.,]\\d{1,3}.*")) { - this.client.recordOutboundQRG(newMessageArrived.getReceiver().getCallSign()); - } // if you sent the message to another station, it will be sorted in to // the "to me message list" with modified messagetext, added rxers callsign @@ -1031,7 +1030,8 @@ public class MessageBusManagementThread extends Thread { newMessageArrived.getSender().setInAngleAndRange(false); } - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) // System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign()); } } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { @@ -1134,7 +1134,8 @@ public class MessageBusManagementThread extends Thread { dxcMsg.setMessageInhibited(splittedMessageLine[7]); dxcMsg.setQrgSpotted(splittedMessageLine[5]); - this.client.getLst_clusterMemberList().add(0, dxcMsg); +// this.client.getLst_clusterMemberList().add(0, dxcMsg); + this.client.publishClusterMessage(dxcMsg); // System.out.println("[MSGBUSMGT:] DXCluster Message detected "); @@ -1173,7 +1174,8 @@ public class MessageBusManagementThread extends Thread { dxcMsg2.setMessageInhibited(splittedMessageLine[6]); dxcMsg2.setQrgSpotted(splittedMessageLine[4]); - this.client.getLst_clusterMemberList().add(0, dxcMsg2); +// this.client.getLst_clusterMemberList().add(0, dxcMsg2); + this.client.publishClusterMessage(dxcMsg2); } else @@ -1203,8 +1205,8 @@ public class MessageBusManagementThread extends Thread { dxcMsg3.setMessageInhibited(""); dxcMsg3.setQrgSpotted(""); - this.client.getLst_clusterMemberList().add(0, dxcMsg3); - +// this.client.getLst_clusterMemberList().add(0, dxcMsg3); + this.client.publishClusterMessage(dxcMsg3); } else /** @@ -1371,7 +1373,8 @@ public class MessageBusManagementThread extends Thread { dummy.setCallSign("ALL"); newMessageArrived.setReceiver(dummy); - this.client.addChatMessage(newMessageArrived); // sdtout to all message-List +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) } else { //message is directed to another chatmember, process as such! @@ -1415,7 +1418,8 @@ public class MessageBusManagementThread extends Thread { if (newMessageArrived.getReceiver().getCallSign() .equals(this.client.getChatPreferences().getStn_loginCallSign())) { - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + "."); @@ -1428,8 +1432,9 @@ public class MessageBusManagementThread extends Thread { String originalMessage = newMessageArrived.getMessageText(); newMessageArrived .setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage); - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0,newMessageArrived); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) // if you sent the message to another station, it will be sorted in to // the "to me message list" with modified messagetext, added rxers callsign @@ -1448,7 +1453,8 @@ public class MessageBusManagementThread extends Thread { newMessageArrived.getSender().setInAngleAndRange(false); } - this.client.addChatMessage(newMessageArrived); +// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); + this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7) // System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign()); } } catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) { @@ -1521,7 +1527,7 @@ public class MessageBusManagementThread extends Thread { for (int i = 0; i < 10; i++) { - client.addChatMessage(pwErrorMsg); + client.getLst_globalChatMessageList().add(pwErrorMsg); // client.getLst_toMeMessageList().add(pwErrorMsg); // client.getLst_toAllMessageList().add(pwErrorMsg); } diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index 02cbacf..ac598ae 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -173,6 +173,23 @@ public class ChatPreferences { double stn_maxQRBDefault = 900; double stn_qtfDefault = 135; + double stn_pathAnalysisOwnAntennaHeightMeters = 10.0; + double stn_pathAnalysisDefaultTargetAntennaHeightMeters = 10.0; + String stn_pathAnalysisDemRootDirectory = ""; + String stn_pathAnalysisDemDatasetId = "copernicus_glo_30"; + double stn_pathAnalysisOwnTxPowerWatts = 750.0; + double stn_pathAnalysisOwnAntennaGainDbi = 8.0; + double stn_pathAnalysisDefaultTargetTxPowerWatts = 100.0; + double stn_pathAnalysisDefaultTargetAntennaGainDbi = 8.0; + + double stn_pathAnalysisVhfFeederLossPerStationDb = 2.0; + double stn_pathAnalysisFeederLossIncreaseDbPer200MHz = 2.0; + double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = 20.0; + + double stn_pathAnalysisRequiredSsbSignalDbm = -126.0; + double stn_pathAnalysisRequiredCwSignalDbm = -132.0; + double stn_pathAnalysisContestMarginDb = 6.0; + ChatCategory loginChatCategoryMain = new ChatCategory(2); ChatCategory loginChatCategorySecond = new ChatCategory(3); boolean loginToSecondChatEnabled; @@ -309,6 +326,9 @@ public class ChatPreferences { boolean guiOptions_defaultFilterPmToOther; boolean guiOptions_defaultFilterPublicMsgs; + private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 }; + private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; + /********************************************************************************* * @@ -371,6 +391,42 @@ public class ChatPreferences { this.MYQRGFirstCat.set(MYQRGFirstCat); } + public double getStn_pathAnalysisOwnAntennaHeightMeters() { + return stn_pathAnalysisOwnAntennaHeightMeters; + } + + public void setStn_pathAnalysisOwnAntennaHeightMeters(double stn_pathAnalysisOwnAntennaHeightMeters) { + this.stn_pathAnalysisOwnAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisOwnAntennaHeightMeters); + } + + public double getStn_pathAnalysisDefaultTargetAntennaHeightMeters() { + return stn_pathAnalysisDefaultTargetAntennaHeightMeters; + } + + public void setStn_pathAnalysisDefaultTargetAntennaHeightMeters(double stn_pathAnalysisDefaultTargetAntennaHeightMeters) { + this.stn_pathAnalysisDefaultTargetAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisDefaultTargetAntennaHeightMeters); + } + + public String getStn_pathAnalysisDemRootDirectory() { + return stn_pathAnalysisDemRootDirectory; + } + + public void setStn_pathAnalysisDemRootDirectory(String stn_pathAnalysisDemRootDirectory) { + this.stn_pathAnalysisDemRootDirectory = stn_pathAnalysisDemRootDirectory == null + ? "" + : stn_pathAnalysisDemRootDirectory.trim(); + } + + public String getStn_pathAnalysisDemDatasetId() { + return stn_pathAnalysisDemDatasetId; + } + + public void setStn_pathAnalysisDemDatasetId(String stn_pathAnalysisDemDatasetId) { + this.stn_pathAnalysisDemDatasetId = (stn_pathAnalysisDemDatasetId == null || stn_pathAnalysisDemDatasetId.isBlank()) + ? "copernicus_glo_30" + : stn_pathAnalysisDemDatasetId.trim().toLowerCase(); + } + public String getStn_loginNameSecondCat() { return stn_loginNameSecondCat; } @@ -547,6 +603,22 @@ public class ChatPreferences { this.loginToSecondChatEnabled = loginToSecondChatEnabled; } + public double[] getGUIstationMapStageSceneSizeHW() { + return GUIstationMapStageSceneSizeHW; + } + + public void setGUIstationMapStageSceneSizeHW(double[] GUIstationMapStageSceneSizeHW) { + this.GUIstationMapStageSceneSizeHW = GUIstationMapStageSceneSizeHW; + } + + public double[] getGUIstationMapStagePositionXY() { + return GUIstationMapStagePositionXY; + } + + public void setGUIstationMapStagePositionXY(double[] GUIstationMapStagePositionXY) { + this.GUIstationMapStagePositionXY = GUIstationMapStagePositionXY; + } + public boolean isGuiOptions_defaultFilterNothing() { return guiOptions_defaultFilterNothing; } @@ -1245,6 +1317,63 @@ public class ChatPreferences { stn_qtfDefault.setTextContent(this.stn_qtfDefault+""); station.appendChild(stn_qtfDefault); + Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters"); + stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + ""); + station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters); + + Element stn_pathAnalysisDefaultTargetAntennaHeightMeters = doc.createElement("stn_pathAnalysisDefaultTargetAntennaHeightMeters"); + stn_pathAnalysisDefaultTargetAntennaHeightMeters.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaHeightMeters + ""); + station.appendChild(stn_pathAnalysisDefaultTargetAntennaHeightMeters); + + Element stn_pathAnalysisDemRootDirectory = doc.createElement("stn_pathAnalysisDemRootDirectory"); + stn_pathAnalysisDemRootDirectory.setTextContent(this.stn_pathAnalysisDemRootDirectory); + station.appendChild(stn_pathAnalysisDemRootDirectory); + + Element stn_pathAnalysisDemDatasetId = doc.createElement("stn_pathAnalysisDemDatasetId"); + stn_pathAnalysisDemDatasetId.setTextContent(this.stn_pathAnalysisDemDatasetId); + station.appendChild(stn_pathAnalysisDemDatasetId); + Element stn_pathAnalysisOwnTxPowerWatts = doc.createElement("stn_pathAnalysisOwnTxPowerWatts"); + stn_pathAnalysisOwnTxPowerWatts.setTextContent(this.stn_pathAnalysisOwnTxPowerWatts + ""); + station.appendChild(stn_pathAnalysisOwnTxPowerWatts); + + Element stn_pathAnalysisOwnAntennaGainDbi = doc.createElement("stn_pathAnalysisOwnAntennaGainDbi"); + stn_pathAnalysisOwnAntennaGainDbi.setTextContent(this.stn_pathAnalysisOwnAntennaGainDbi + ""); + station.appendChild(stn_pathAnalysisOwnAntennaGainDbi); + + Element stn_pathAnalysisDefaultTargetTxPowerWatts = doc.createElement("stn_pathAnalysisDefaultTargetTxPowerWatts"); + stn_pathAnalysisDefaultTargetTxPowerWatts.setTextContent(this.stn_pathAnalysisDefaultTargetTxPowerWatts + ""); + station.appendChild(stn_pathAnalysisDefaultTargetTxPowerWatts); + + Element stn_pathAnalysisDefaultTargetAntennaGainDbi = doc.createElement("stn_pathAnalysisDefaultTargetAntennaGainDbi"); + stn_pathAnalysisDefaultTargetAntennaGainDbi.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaGainDbi + ""); + station.appendChild(stn_pathAnalysisDefaultTargetAntennaGainDbi); + + Element stn_pathAnalysisVhfFeederLossPerStationDb = doc.createElement("stn_pathAnalysisVhfFeederLossPerStationDb"); + stn_pathAnalysisVhfFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisVhfFeederLossPerStationDb + ""); + station.appendChild(stn_pathAnalysisVhfFeederLossPerStationDb); + + Element stn_pathAnalysisFeederLossIncreaseDbPer200MHz = doc.createElement("stn_pathAnalysisFeederLossIncreaseDbPer200MHz"); + stn_pathAnalysisFeederLossIncreaseDbPer200MHz.setTextContent(this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz + ""); + station.appendChild(stn_pathAnalysisFeederLossIncreaseDbPer200MHz); + + Element stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = doc.createElement("stn_pathAnalysisMaxEstimatedFeederLossPerStationDb"); + stn_pathAnalysisMaxEstimatedFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb + ""); + station.appendChild(stn_pathAnalysisMaxEstimatedFeederLossPerStationDb); + + Element stn_pathAnalysisRequiredSsbSignalDbm = doc.createElement("stn_pathAnalysisRequiredSsbSignalDbm"); + stn_pathAnalysisRequiredSsbSignalDbm.setTextContent(this.stn_pathAnalysisRequiredSsbSignalDbm + ""); + station.appendChild(stn_pathAnalysisRequiredSsbSignalDbm); + + Element stn_pathAnalysisRequiredCwSignalDbm = doc.createElement("stn_pathAnalysisRequiredCwSignalDbm"); + stn_pathAnalysisRequiredCwSignalDbm.setTextContent(this.stn_pathAnalysisRequiredCwSignalDbm + ""); + station.appendChild(stn_pathAnalysisRequiredCwSignalDbm); + + Element stn_pathAnalysisContestMarginDb = doc.createElement("stn_pathAnalysisContestMarginDb"); + stn_pathAnalysisContestMarginDb.setTextContent(this.stn_pathAnalysisContestMarginDb + ""); + station.appendChild(stn_pathAnalysisContestMarginDb); + + + Element stn_bandActive144 = doc.createElement("stn_bandActive144"); stn_bandActive144.setTextContent(this.stn_bandActive144+""); station.appendChild(stn_bandActive144); @@ -1740,6 +1869,18 @@ public class ChatPreferences { GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault())); guiOptions.appendChild(GUIpnl_directedMSGWin_dividerpositionDefault); + Element GUIstationMapStageSceneSizeHW = doc.createElement("GUIstationMapStageSceneSizeHW"); + GUIstationMapStageSceneSizeHW.setTextContent( + this.getGUIstationMapStageSceneSizeHW()[0] + ";" + this.getGUIstationMapStageSceneSizeHW()[1] + ); + guiOptions.appendChild(GUIstationMapStageSceneSizeHW); + + Element GUIstationMapStagePositionXY = doc.createElement("GUIstationMapStagePositionXY"); + GUIstationMapStagePositionXY.setTextContent( + this.getGUIstationMapStagePositionXY()[0] + ";" + this.getGUIstationMapStagePositionXY()[1] + ); + guiOptions.appendChild(GUIstationMapStagePositionXY); + /**************************************************************************************** ****************************** now write this XML! ************************************* ****************************************************************************************/ @@ -1856,6 +1997,90 @@ public class ChatPreferences { stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault"); stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"); + stn_pathAnalysisOwnAntennaHeightMeters = getDouble( + stationEl, + stn_pathAnalysisOwnAntennaHeightMeters, + "stn_pathAnalysisOwnAntennaHeightMeters" + ); + + stn_pathAnalysisDefaultTargetAntennaHeightMeters = getDouble( + stationEl, + stn_pathAnalysisDefaultTargetAntennaHeightMeters, + "stn_pathAnalysisDefaultTargetAntennaHeightMeters" + ); + + stn_pathAnalysisDemRootDirectory = getText( + stationEl, + stn_pathAnalysisDemRootDirectory, + "stn_pathAnalysisDemRootDirectory" + ); + + stn_pathAnalysisDemDatasetId = getText( + stationEl, + stn_pathAnalysisDemDatasetId, + "stn_pathAnalysisDemDatasetId" + ); + + stn_pathAnalysisOwnTxPowerWatts = getDouble( + stationEl, + stn_pathAnalysisOwnTxPowerWatts, + "stn_pathAnalysisOwnTxPowerWatts" + ); + + stn_pathAnalysisOwnAntennaGainDbi = getDouble( + stationEl, + stn_pathAnalysisOwnAntennaGainDbi, + "stn_pathAnalysisOwnAntennaGainDbi" + ); + + stn_pathAnalysisDefaultTargetTxPowerWatts = getDouble( + stationEl, + stn_pathAnalysisDefaultTargetTxPowerWatts, + "stn_pathAnalysisDefaultTargetTxPowerWatts" + ); + + stn_pathAnalysisDefaultTargetAntennaGainDbi = getDouble( + stationEl, + stn_pathAnalysisDefaultTargetAntennaGainDbi, + "stn_pathAnalysisDefaultTargetAntennaGainDbi" + ); + + stn_pathAnalysisVhfFeederLossPerStationDb = getDouble( + stationEl, + stn_pathAnalysisVhfFeederLossPerStationDb, + "stn_pathAnalysisVhfFeederLossPerStationDb" + ); + + stn_pathAnalysisFeederLossIncreaseDbPer200MHz = getDouble( + stationEl, + stn_pathAnalysisFeederLossIncreaseDbPer200MHz, + "stn_pathAnalysisFeederLossIncreaseDbPer200MHz" + ); + + stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = getDouble( + stationEl, + stn_pathAnalysisMaxEstimatedFeederLossPerStationDb, + "stn_pathAnalysisMaxEstimatedFeederLossPerStationDb" + ); + + stn_pathAnalysisRequiredSsbSignalDbm = getDouble( + stationEl, + stn_pathAnalysisRequiredSsbSignalDbm, + "stn_pathAnalysisRequiredSsbSignalDbm" + ); + + stn_pathAnalysisRequiredCwSignalDbm = getDouble( + stationEl, + stn_pathAnalysisRequiredCwSignalDbm, + "stn_pathAnalysisRequiredCwSignalDbm" + ); + + stn_pathAnalysisContestMarginDb = getDouble( + stationEl, + stn_pathAnalysisContestMarginDb, + "stn_pathAnalysisContestMarginDb" + ); + // Band activity flags (introduced later; if missing -> keep defaults) stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144"); stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432"); @@ -2264,6 +2489,16 @@ public class ChatPreferences { parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW()); parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW()); + parseSemicolonDoublesInto( + getText(element, null, "GUIstationMapStageSceneSizeHW"), + this.getGUIstationMapStageSceneSizeHW() + ); + + parseSemicolonDoublesInto( + getText(element, null, "GUIstationMapStagePositionXY"), + this.getGUIstationMapStagePositionXY() + ); + // Splitpane divider positions String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition"); if (s1 != null) { @@ -2660,6 +2895,100 @@ public class ChatPreferences { } } + public double getStn_pathAnalysisOwnTxPowerWatts() { + return stn_pathAnalysisOwnTxPowerWatts; + } + + public void setStn_pathAnalysisOwnTxPowerWatts(double stn_pathAnalysisOwnTxPowerWatts) { + this.stn_pathAnalysisOwnTxPowerWatts = stn_pathAnalysisOwnTxPowerWatts; + } + + public double getStn_pathAnalysisOwnAntennaGainDbi() { + return stn_pathAnalysisOwnAntennaGainDbi; + } + + public void setStn_pathAnalysisOwnAntennaGainDbi(double stn_pathAnalysisOwnAntennaGainDbi) { + this.stn_pathAnalysisOwnAntennaGainDbi = stn_pathAnalysisOwnAntennaGainDbi; + } + + public double getStn_pathAnalysisDefaultTargetTxPowerWatts() { + return stn_pathAnalysisDefaultTargetTxPowerWatts; + } + + public void setStn_pathAnalysisDefaultTargetTxPowerWatts(double stn_pathAnalysisDefaultTargetTxPowerWatts) { + this.stn_pathAnalysisDefaultTargetTxPowerWatts = stn_pathAnalysisDefaultTargetTxPowerWatts; + } + + public double getStn_pathAnalysisDefaultTargetAntennaGainDbi() { + return stn_pathAnalysisDefaultTargetAntennaGainDbi; + } + + public void setStn_pathAnalysisDefaultTargetAntennaGainDbi(double stn_pathAnalysisDefaultTargetAntennaGainDbi) { + this.stn_pathAnalysisDefaultTargetAntennaGainDbi = stn_pathAnalysisDefaultTargetAntennaGainDbi; + } + + public double getStn_pathAnalysisVhfFeederLossPerStationDb() { + return stn_pathAnalysisVhfFeederLossPerStationDb; + } + + public void setStn_pathAnalysisVhfFeederLossPerStationDb(double stn_pathAnalysisVhfFeederLossPerStationDb) { + this.stn_pathAnalysisVhfFeederLossPerStationDb = stn_pathAnalysisVhfFeederLossPerStationDb; + } + + public double getStn_pathAnalysisFeederLossIncreaseDbPer200MHz() { + return stn_pathAnalysisFeederLossIncreaseDbPer200MHz; + } + + public void setStn_pathAnalysisFeederLossIncreaseDbPer200MHz(double stn_pathAnalysisFeederLossIncreaseDbPer200MHz) { + this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz = stn_pathAnalysisFeederLossIncreaseDbPer200MHz; + } + + public double getStn_pathAnalysisMaxEstimatedFeederLossPerStationDb() { + return stn_pathAnalysisMaxEstimatedFeederLossPerStationDb; + } + + public void setStn_pathAnalysisMaxEstimatedFeederLossPerStationDb(double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb) { + this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = stn_pathAnalysisMaxEstimatedFeederLossPerStationDb; + } + + public double getStn_pathAnalysisRequiredSsbSignalDbm() { + return stn_pathAnalysisRequiredSsbSignalDbm; + } + + public void setStn_pathAnalysisRequiredSsbSignalDbm(double stn_pathAnalysisRequiredSsbSignalDbm) { + this.stn_pathAnalysisRequiredSsbSignalDbm = stn_pathAnalysisRequiredSsbSignalDbm; + } + + public double getStn_pathAnalysisRequiredCwSignalDbm() { + return stn_pathAnalysisRequiredCwSignalDbm; + } + + public void setStn_pathAnalysisRequiredCwSignalDbm(double stn_pathAnalysisRequiredCwSignalDbm) { + this.stn_pathAnalysisRequiredCwSignalDbm = stn_pathAnalysisRequiredCwSignalDbm; + } + + public double getStn_pathAnalysisContestMarginDb() { + return stn_pathAnalysisContestMarginDb; + } + + public void setStn_pathAnalysisContestMarginDb(double stn_pathAnalysisContestMarginDb) { + this.stn_pathAnalysisContestMarginDb = stn_pathAnalysisContestMarginDb; + } + + public kst4contest.view.map.PathLinkBudgetSettings buildPathLinkBudgetSettings() { + return new kst4contest.view.map.PathLinkBudgetSettings( + stn_pathAnalysisOwnTxPowerWatts, + stn_pathAnalysisOwnAntennaGainDbi, + stn_pathAnalysisDefaultTargetTxPowerWatts, + stn_pathAnalysisDefaultTargetAntennaGainDbi, + stn_pathAnalysisVhfFeederLossPerStationDb, + stn_pathAnalysisFeederLossIncreaseDbPer200MHz, + stn_pathAnalysisMaxEstimatedFeederLossPerStationDb, + stn_pathAnalysisRequiredSsbSignalDbm, + stn_pathAnalysisRequiredCwSignalDbm, + stn_pathAnalysisContestMarginDb + ); + } } diff --git a/src/main/java/kst4contest/service/path/FresnelMathUtils.java b/src/main/java/kst4contest/service/path/FresnelMathUtils.java new file mode 100644 index 0000000..4cf3170 --- /dev/null +++ b/src/main/java/kst4contest/service/path/FresnelMathUtils.java @@ -0,0 +1,74 @@ +package kst4contest.service.path; + +/** + * Utility methods for Fresnel zone calculations. + * + *

This helper intentionally contains only pure mathematical functions. + * It has no dependency on UI code or terrain providers.

+ */ +public final class FresnelMathUtils { + + /** + * Speed of light in vacuum in meters per second. + */ + public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0; + + private FresnelMathUtils() { + // Utility class + } + + /** + * Computes the wavelength in meters for the given frequency. + * + * @param frequencyHz signal frequency in Hz + * @return wavelength in meters, or 0 if the input is invalid + */ + public static double computeWavelengthMeters(final double frequencyHz) { + if (frequencyHz <= 0.0) { + return 0.0; + } + return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz; + } + + /** + * Computes the radius of the first Fresnel zone at a specific point + * along the path. + * + *

Formula: + * r = sqrt(lambda * d1 * d2 / (d1 + d2))

+ * + * @param frequencyHz signal frequency in Hz + * @param distanceFromTxMeters distance from TX to the current point in meters + * @param totalPathDistanceMeters full TX-to-RX path length in meters + * @return Fresnel radius in meters, or 0 at invalid inputs / path ends + */ + public static double computeFirstFresnelRadiusMeters( + final double frequencyHz, + final double distanceFromTxMeters, + final double totalPathDistanceMeters) { + + if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) { + return 0.0; + } + + final double clampedDistanceFromTxMeters = Math.max( + 0.0, + Math.min(distanceFromTxMeters, totalPathDistanceMeters) + ); + + final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters; + + if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) { + return 0.0; + } + + final double wavelengthMeters = computeWavelengthMeters(frequencyHz); + + return Math.sqrt( + wavelengthMeters + * clampedDistanceFromTxMeters + * distanceFromPointToRxMeters + / totalPathDistanceMeters + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 180d4bd..52a78fa 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -62,11 +62,16 @@ import kst4contest.model.*; import javafx.scene.shape.Line; import javafx.scene.shape.Polygon; import kst4contest.utils.ApplicationFileUtils; +import kst4contest.view.map.StationMapBridge; +import kst4contest.view.map.StationMapView; +import kst4contest.view.map.OfflineDemImportService; public class Kst4ContestApplication extends Application implements StatusUpdateListener { // private static final Kst4ContestApplication dbcontroller = new DBController(); + private StationMapView stationMapView; //view class for the avl stn map + private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view private final Button btnBandUpgradeIndicator = new Button("BAND+"); private final Tooltip tipBandUpgradeIndicator = new Tooltip(); @@ -116,6 +121,73 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8]; + private void ensureStationMapSupportInitialized() { + if (stationMapView != null && stationMapBridge != null) { + return; + } + + stationMapView = new StationMapView(chatcontroller.getChatPreferences()); + stationMapBridge = new StationMapBridge( + chatcontroller, + tbl_chatMember, + stationMapView, + this::focusChatMemberAndPrepareCq + ); + stationMapBridge.install(); + } + + private void toggleStationMapWindow() { + ensureStationMapSupportInitialized(); + stationMapBridge.toggleWindow(); + } + + private void showSelectedCallsignOnMap() { + ensureStationMapSupportInitialized(); + + if (selectedCallSignInfoStageChatMember != null) { + chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember); + } + + stationMapBridge.focusSelectedCallsign(); + } + + private void refreshStationMapIfVisible() { + if (stationMapBridge != null && stationMapView != null && stationMapView.isShowing()) { + stationMapBridge.requestImmediateRefresh(); + } + } + + /** + * Resolves a reasonable initial directory for the DEM tile file chooser. + * + *

Preference order: + *

    + *
  1. configured DEM root directory if it already exists
  2. + *
  3. its parent directory if that exists
  4. + *
  5. user home directory
  6. + *
+ * + * @param configuredDemRootDirectory current DEM root directory text + * @return usable initial directory or null + */ + private File resolveInitialDirectoryForDemImport(String configuredDemRootDirectory) { + if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) { + File configuredDirectory = new File(configuredDemRootDirectory.trim()); + + if (configuredDirectory.isDirectory()) { + return configuredDirectory; + } + + File parentDirectory = configuredDirectory.getParentFile(); + if (parentDirectory != null && parentDirectory.isDirectory()) { + return parentDirectory; + } + } + + File userHomeDirectory = new File(System.getProperty("user.home")); + return userHomeDirectory.isDirectory() ? userHomeDirectory : null; + } + /** * helper DTO for planes and arriving time in minutes. Maybe */ @@ -705,23 +777,21 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection())); + Button selectedCallSignShowOnMapBtn = new Button("Show on map"); + selectedCallSignShowOnMapBtn.setOnAction(new EventHandler() { + @Override + public void handle(ActionEvent actionEvent) { + showSelectedCallsignOnMap(); + } + }); + Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw()); selectedCallSignTurnAntBtn.setOnAction(new EventHandler() { @Override public void handle(ActionEvent actionEvent) { -// chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember); -// Alert a = new Alert(AlertType.INFORMATION); -// -// a.setTitle("Not yet implemented!"); -// a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER + ": This is a todo!"); -// a.setContentText("Mach mal hinne!"); -// a.show(); -// chatcontroller.stopRotator(); //if it´s running, stop it firstly, then set the new value -// chatcontroller.stopRotator(); chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection()); - //TODO: Hier muss was hin } }); @@ -743,7 +813,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1); +// selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1); + + HBox selectedCallSignPathAndMapButtons = new HBox(10, selectedCallSignShowAsPathBtn, selectedCallSignShowOnMapBtn); + selectedCallSignDownerSiteGridPane.add(selectedCallSignPathAndMapButtons, 1,0,1,1); + selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1); selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1); @@ -2278,6 +2352,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty +// TableColumn msgCol = new TableColumn("Message"); +// msgCol.setCellValueFactory(new Callback, ObservableValue>() { +// +// @Override +// public ObservableValue call(CellDataFeatures cellDataFeatures) { +// SimpleStringProperty msg = new SimpleStringProperty(); +// +// if (cellDataFeatures.getValue().getMessageText() != null) { +// +// msg.setValue(cellDataFeatures.getValue().getMessageText()); +// } else { +// +// msg.setValue("");// TODO: Prevents a bug of not setting all values as a default +// } +// return msg; +// } +// }); + TableColumn msgCol = new TableColumn("Message"); msgCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -2285,13 +2377,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public ObservableValue call(CellDataFeatures cellDataFeatures) { SimpleStringProperty msg = new SimpleStringProperty(); - if (cellDataFeatures.getValue().getMessageText() != null) { - - msg.setValue(cellDataFeatures.getValue().getMessageText()); + if (cellDataFeatures.getValue() != null) { + msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue())); } else { - - msg.setValue("");// TODO: Prevents a bug of not setting all values as a default + msg.setValue(""); } + return msg; } }); @@ -3777,7 +3868,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING); chatcontroller.getChatPreferences().setGUI_darkModeActive(true); - + if (stationMapBridge != null) { + stationMapBridge.applyThemeFromPreferences(); //dark mode for the map + } } }); @@ -3796,10 +3889,32 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); chatcontroller.getChatPreferences().setGUI_darkModeActive(false); + + if (stationMapBridge != null) { + stationMapBridge.applyThemeFromPreferences(); + } } }); - windowMenu.getItems().addAll(window1, window20, window30, window40); + + MenuItem window50 = new MenuItem("Show / hide station map"); + window50.setOnAction(new EventHandler() { + public void handle(ActionEvent event) { + toggleStationMapWindow(); + } + }); + +// windowMenu.getItems().addAll(window1, window20, window30, window40, window50); + + windowMenu.getItems().addAll( + window1, + window20, + new SeparatorMenuItem(), + window50, + new SeparatorMenuItem(), + window30, + window40 + ); Menu helpMenu = new Menu("Info"); @@ -6518,9 +6633,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText()); chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(txtFldstn_antennaBeamWidthDeg.getText())); + refreshStationMapIfVisible(); //updates the mapview + } }); + TextField txtFldstn_pathAnalysisOwnTxPowerWatts = createDoublePreferenceTextField( + this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnTxPowerWatts(), + "Own TX power in watts used for path link-budget estimates.", + value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnTxPowerWatts(value) + ); + + TextField txtFldstn_pathAnalysisOwnAntennaGainDbi = createDoublePreferenceTextField( + this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaGainDbi(), + "Own antenna gain in dBi used for path link-budget estimates. 12 dBd = 14.15 dBi.", + value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaGainDbi(value) + ); + + TextField txtFldstn_pathAnalysisDefaultTargetTxPowerWatts = createDoublePreferenceTextField( + this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetTxPowerWatts(), + "Assumed default DX station TX power in watts. Used when no station-specific data exists.", + value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetTxPowerWatts(value) + ); + + TextField txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi = createDoublePreferenceTextField( + this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaGainDbi(), + "Assumed default DX antenna gain in dBi. 8-10 dBi is realistic for many 2m contest stations.", + value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) + ); + + TextField txtFldstn_maxQRBDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_maxQRBDefault() + ""); txtFldstn_maxQRBDefault.setFocusTraversable(false); @@ -6539,6 +6681,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText()); chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(txtFldstn_maxQRBDefault.getText())); + refreshStationMapIfVisible(); } }); @@ -6566,6 +6709,144 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); + + TextField txtFldstn_pathAnalysisOwnAntennaHeightMeters = + new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters() + ""); + txtFldstn_pathAnalysisOwnAntennaHeightMeters.setFocusTraversable(false); + txtFldstn_pathAnalysisOwnAntennaHeightMeters.setTooltip(new Tooltip( + "Own antenna height above local ground in meters.\nThis value is used for path analysis." + )); + txtFldstn_pathAnalysisOwnAntennaHeightMeters.textProperty().addListener(new ChangeListener() { + + @Override + public void changed(ObservableValue observed, String oldString, String newString) { + + if (newString.equals("")) { + txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText("0"); + } + + if (!newString.matches("\\d*(\\.\\d*)?")) { + txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText(newString.replaceAll("[^\\d.]", "")); + return; + } + + try { + double value = Double.parseDouble(txtFldstn_pathAnalysisOwnAntennaHeightMeters.getText()); + chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaHeightMeters(value); + refreshStationMapIfVisible(); + } catch (NumberFormatException ignored) { + } + } + }); + + TextField txtFldstn_pathAnalysisDemRootDirectory = + new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisDemRootDirectory()); + txtFldstn_pathAnalysisDemRootDirectory.setFocusTraversable(false); + txtFldstn_pathAnalysisDemRootDirectory.setTooltip(new Tooltip( + "Root directory that contains locally extracted Copernicus GLO-30 DEM tiles.\n" + + "The program scans this directory recursively for *_DEM.tif tiles." + )); + txtFldstn_pathAnalysisDemRootDirectory.focusedProperty().addListener(new ChangeListener() { + @Override + public void changed(ObservableValue observableValue, Boolean oldValue, Boolean newValue) { + if (!newValue) { + chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( + txtFldstn_pathAnalysisDemRootDirectory.getText() + ); + refreshStationMapIfVisible(); + } + } + }); + + OfflineDemImportService offlineDemImportService = new OfflineDemImportService(); + + Button btnUseDefaultDemDirectory = new Button("Default"); + btnUseDefaultDemDirectory.setFocusTraversable(false); + btnUseDefaultDemDirectory.setTooltip(new Tooltip( + "Creates and uses the default local Copernicus DEM directory below .praktiKST.\n" + + "This does not download tiles yet, it only prepares the folder." + )); + btnUseDefaultDemDirectory.setOnAction(event -> { + OfflineDemImportService.ImportResult importResult = + offlineDemImportService.ensureDefaultCopernicusRootDirectory(); + + if (importResult.targetRootDirectory() != null) { + txtFldstn_pathAnalysisDemRootDirectory.setText( + importResult.targetRootDirectory().toAbsolutePath().toString() + ); + chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( + txtFldstn_pathAnalysisDemRootDirectory.getText() + ); + refreshStationMapIfVisible(); + } + + Alert alert = new Alert(importResult.success() ? AlertType.INFORMATION : AlertType.WARNING); + alert.setTitle("DEM directory"); + alert.setHeaderText(importResult.success() + ? "Local Copernicus DEM directory is ready" + : "DEM directory could not be prepared"); + alert.setContentText(importResult.message()); + alert.show(); + }); + + Button btnImportDemTiles = new Button("Import tiles..."); + btnImportDemTiles.setFocusTraversable(false); + btnImportDemTiles.setTooltip(new Tooltip( + "Copies manually selected Copernicus *_DEM.tif files into the configured DEM root directory.\n" + + "If no DEM root directory is configured yet, the default .praktiKST/dem/copernicus_glo30 directory is used." + )); + btnImportDemTiles.setOnAction(event -> { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Import Copernicus GLO-30 DEM tiles"); + fileChooser.getExtensionFilters().add( + new FileChooser.ExtensionFilter("GeoTIFF DEM tiles", "*.tif", "*.tiff") + ); + + File initialDirectory = resolveInitialDirectoryForDemImport( + txtFldstn_pathAnalysisDemRootDirectory.getText() + ); + if (initialDirectory != null) { + fileChooser.setInitialDirectory(initialDirectory); + } + + List selectedFiles = fileChooser.showOpenMultipleDialog(primaryStage); + if (selectedFiles == null || selectedFiles.isEmpty()) { + return; + } + + OfflineDemImportService.ImportResult importResult = + offlineDemImportService.importTiles( + selectedFiles, + txtFldstn_pathAnalysisDemRootDirectory.getText() + ); + + if (importResult.targetRootDirectory() != null) { + txtFldstn_pathAnalysisDemRootDirectory.setText( + importResult.targetRootDirectory().toAbsolutePath().toString() + ); + chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory( + txtFldstn_pathAnalysisDemRootDirectory.getText() + ); + refreshStationMapIfVisible(); + } + + Alert alert = new Alert( + importResult.success() && importResult.importedFileCount() > 0 + ? AlertType.INFORMATION + : AlertType.WARNING + ); + alert.setTitle("DEM tile import"); + alert.setHeaderText( + importResult.success() && importResult.importedFileCount() > 0 + ? "DEM tiles imported" + : "No DEM tiles were imported" + ); + alert.setContentText(importResult.message()); + alert.show(); + }); + + HBox hbxDemDirectoryActions = new HBox(8.0, btnUseDefaultDemDirectory, btnImportDemTiles); + Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):"); CheckBox chkBx_station_pstRotatorEnabled = new CheckBox(); chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled()); @@ -6589,12 +6870,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL grdPnlStation.add(choiceBxChatChategory, 1, 4); grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5); grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 5); - grdPnlStation.add(new Label("Default maximum QRB:"), 0, 6); - grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 6); - grdPnlStation.add(new Label("Default filter QTF:"), 0, 7); - grdPnlStation.add(txtFldstn_qtfDefault, 1, 7); - grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8); - grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8); + + grdPnlStation.add(new Label("Own antenna height AGL:"), 0, 8); + grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8); + + grdPnlStation.add(new Label("DEM root directory:"), 0, 9); + grdPnlStation.add(txtFldstn_pathAnalysisDemRootDirectory, 1, 9); + grdPnlStation.add(hbxDemDirectoryActions, 2, 7, 2, 1); + + grdPnlStation.add(new Label("Default maximum QRB:"), 0, 10); + grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 10); + + grdPnlStation.add(new Label("Default filter QTF:"), 0, 11); + grdPnlStation.add(txtFldstn_qtfDefault, 1, 11); + + grdPnlStation.add(new Label("Own TX power W:"), 2, 5); + grdPnlStation.add(txtFldstn_pathAnalysisOwnTxPowerWatts, 3, 5); + + grdPnlStation.add(new Label("Own ant. gain dBi:"), 0, 6); + grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaGainDbi, 1, 6); + + grdPnlStation.add(new Label("DX OM TX power W:"), 2, 6); + grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetTxPowerWatts, 3, 6); + + grdPnlStation.add(new Label("DX OM ant. gain dBi:"), 0, 7); + grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi, 1, 7); + + grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 10); + grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 10); + + VBox vbxStation = new VBox(); vbxStation.setPadding(new Insets(10, 10, 10, 10)); @@ -6746,6 +7051,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL // vbxStation.getChildren().add(settings_chkbx_QRV5600); // vbxStation.getChildren().add(settings_chkbx_QRV10G); + + + /************************************************************************************* * Log synch settings Tab *************************************************************************************/ @@ -8786,6 +9094,49 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return null; } + /** + * Helper for creating station double preferences textfields + * @param initialValue + * @param tooltipText + * @param valueConsumer + * @return + */ + private TextField createDoublePreferenceTextField(double initialValue, + String tooltipText, + java.util.function.DoubleConsumer valueConsumer) { + TextField textField = new TextField(String.valueOf(initialValue)); + textField.setFocusTraversable(false); + textField.setTooltip(new Tooltip(tooltipText)); + + textField.focusedProperty().addListener((observable, oldValue, focused) -> { + if (!focused) { + try { + String normalizedText = textField.getText().trim().replace(",", "."); + double parsedValue = Double.parseDouble(normalizedText); + valueConsumer.accept(parsedValue); + textField.setText(String.valueOf(parsedValue)); + refreshStationMapIfVisible(); + } catch (NumberFormatException exception) { + textField.setText(String.valueOf(initialValue)); + } + } + }); + + textField.setOnAction(event -> { + try { + String normalizedText = textField.getText().trim().replace(",", "."); + double parsedValue = Double.parseDouble(normalizedText); + valueConsumer.accept(parsedValue); + textField.setText(String.valueOf(parsedValue)); + refreshStationMapIfVisible(); + } catch (NumberFormatException exception) { + textField.setText(String.valueOf(initialValue)); + } + }); + + return textField; + } + } /** @@ -8820,6 +9171,7 @@ class ActionButtonTableCell extends TableCell { setGraphic(actionButton); } } + } /** @@ -8858,6 +9210,4 @@ class CheckBoxTableCell extends TableCell { - - } \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java new file mode 100644 index 0000000..c1e1ee1 --- /dev/null +++ b/src/main/java/kst4contest/view/map/ChainedTerrainProfileProvider.java @@ -0,0 +1,36 @@ +package kst4contest.view.map; + +import java.util.List; + +/** + * Tries terrain providers in order and returns the first usable profile. + */ +public final class ChainedTerrainProfileProvider implements TerrainProfileProvider { + + private final List providers; + + public ChainedTerrainProfileProvider(List providers) { + this.providers = providers == null ? List.of() : List.copyOf(providers); + } + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + TerrainProfileData lastResult = TerrainProfileData.empty("No terrain provider"); + + for (TerrainProfileProvider provider : providers) { + if (provider == null) { + continue; + } + + TerrainProfileData currentResult = provider.loadProfile(request); + if (currentResult != null) { + lastResult = currentResult; + if (currentResult.hasUsableProfile()) { + return currentResult; + } + } + } + + return lastResult; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java b/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java new file mode 100644 index 0000000..c9e8069 --- /dev/null +++ b/src/main/java/kst4contest/view/map/CopernicusGlo30TerrainProfileProvider.java @@ -0,0 +1,293 @@ +package kst4contest.view.map; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.awt.image.Raster; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Offline terrain provider for locally extracted Copernicus GLO-30 DGED DEM tiles. + * + *

Assumptions of this reader: + *

    + *
  • local tiles already exist on disk
  • + *
  • official DGED GeoTIFF filenames are used
  • + *
  • tiles represent 1° x 1° geocells
  • + *
  • the raster uses RasterPixelIsPoint semantics
  • + *
+ * + *

The active improvement step uses great-circle interpolation for the + * sampled path points. This avoids the path distortion of simple linear + * latitude/longitude interpolation on longer Europe-wide paths.

+ */ +public final class CopernicusGlo30TerrainProfileProvider implements TerrainProfileProvider { + + private static final String SOURCE_NAME = "Copernicus GLO-30 offline DEM"; + private static final double NODATA_VALUE = -32767.0; + private static final int MAX_LOADED_TILES = 8; + + private final Supplier demRootDirectorySupplier; + private final OfflineDemManager offlineDemManager; + + private final Map loadedTileCache = + new LinkedHashMap<>(16, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_LOADED_TILES; + } + }; + + public CopernicusGlo30TerrainProfileProvider(Supplier demRootDirectorySupplier, + OfflineDemManager offlineDemManager) { + this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier"); + this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager"); + } + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) { + return TerrainProfileData.empty(SOURCE_NAME); + } + + OfflineDemManager.OfflineDemIndex demIndex = + offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), DemDataset.COPERNICUS_GLO_30); + + if (!demIndex.usable()) { + return TerrainProfileData.empty(SOURCE_NAME + " unavailable"); + } + + int sampleCount = Math.max(2, request.requestedSampleCount()); + List points = new ArrayList<>(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1); + + PathGeometryUtils.GeoPoint interpolatedPoint = + PathGeometryUtils.interpolateGreatCirclePoint( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg(), + t + ); + + double latitudeDeg = interpolatedPoint.latitudeDeg(); + double longitudeDeg = interpolatedPoint.longitudeDeg(); + double distanceKm = request.totalDistanceKm() * t; + + if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) { + return TerrainProfileData.empty(SOURCE_NAME + " path interpolation failed"); + } + + Path tilePath = demIndex.findTilePath(latitudeDeg, longitudeDeg); + if (tilePath == null) { + return TerrainProfileData.empty(String.format( + Locale.US, + "%s missing required tile(s) near %.5f / %.5f", + SOURCE_NAME, + latitudeDeg, + longitudeDeg + )); + } + + LoadedTile loadedTile = getOrLoadTile(tilePath); + if (loadedTile == null) { + return TerrainProfileData.empty(SOURCE_NAME + " tile read failed"); + } + + double elevationMeters = sampleElevationMeters(loadedTile, latitudeDeg, longitudeDeg); + if (!Double.isFinite(elevationMeters)) { + return TerrainProfileData.empty(String.format( + Locale.US, + "%s contains no-data sample(s) near %.5f / %.5f", + SOURCE_NAME, + latitudeDeg, + longitudeDeg + )); + } + + points.add(new PathProfilePoint( + distanceKm, + latitudeDeg, + longitudeDeg, + elevationMeters + )); + } + + return new TerrainProfileData(points, SOURCE_NAME, false); + } + + private synchronized LoadedTile getOrLoadTile(Path tilePath) { + LoadedTile cachedTile = loadedTileCache.get(tilePath); + if (cachedTile != null) { + return cachedTile; + } + + LoadedTile loadedTile = loadTile(tilePath); + if (loadedTile != null) { + loadedTileCache.put(tilePath, loadedTile); + } + + return loadedTile; + } + + private LoadedTile loadTile(Path tilePath) { + if (tilePath == null || !Files.isRegularFile(tilePath)) { + return null; + } + + try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(tilePath.toFile())) { + if (imageInputStream == null) { + return null; + } + + Iterator readers = ImageIO.getImageReaders(imageInputStream); + if (!readers.hasNext()) { + return null; + } + + ImageReader imageReader = readers.next(); + try { + imageReader.setInput(imageInputStream, true, true); + Raster raster = imageReader.readRaster(0, null); + + if (raster == null || raster.getWidth() < 2 || raster.getHeight() < 2) { + return null; + } + + return new LoadedTile( + tilePath, + raster, + raster.getWidth(), + raster.getHeight(), + parseSouthDeg(tilePath.getFileName().toString()), + parseWestDeg(tilePath.getFileName().toString()) + ); + } finally { + imageReader.dispose(); + } + } catch (IOException exception) { + System.err.println("[StationMap] Could not read DEM tile " + tilePath + ": " + exception.getMessage()); + return null; + } + } + + private int parseSouthDeg(String filename) { + ParsedTileKey key = ParsedTileKey.fromFilename(filename); + return key == null ? 0 : key.southDeg(); + } + + private int parseWestDeg(String filename) { + ParsedTileKey key = ParsedTileKey.fromFilename(filename); + return key == null ? 0 : key.westDeg(); + } + + /** + * Samples one DEM tile using bilinear interpolation. + * + *

The current reader assumes 1° x 1° geocells and derives raster + * coordinates directly from the sample latitude/longitude.

+ * + * @param tile loaded DEM tile + * @param latitudeDeg sample latitude in degrees + * @param longitudeDeg sample longitude in degrees + * @return interpolated elevation in meters or NaN + */ + private double sampleElevationMeters(LoadedTile tile, double latitudeDeg, double longitudeDeg) { + if (tile == null) { + return Double.NaN; + } + + double x = (longitudeDeg - tile.westDeg()) * (tile.width() - 1); + double y = ((tile.southDeg() + 1.0) - latitudeDeg) * (tile.height() - 1); + + x = clamp(x, 0.0, tile.width() - 1.0); + y = clamp(y, 0.0, tile.height() - 1.0); + + int x0 = (int) Math.floor(x); + int y0 = (int) Math.floor(y); + int x1 = Math.min(x0 + 1, tile.width() - 1); + int y1 = Math.min(y0 + 1, tile.height() - 1); + + double q11 = readSample(tile.raster(), x0, y0); + double q21 = readSample(tile.raster(), x1, y0); + double q12 = readSample(tile.raster(), x0, y1); + double q22 = readSample(tile.raster(), x1, y1); + + if (!Double.isFinite(q11) || !Double.isFinite(q21) || !Double.isFinite(q12) || !Double.isFinite(q22)) { + double nearest = readSample(tile.raster(), (int) Math.round(x), (int) Math.round(y)); + return Double.isFinite(nearest) ? nearest : Double.NaN; + } + + double dx = x - x0; + double dy = y - y0; + + double top = q11 + (q21 - q11) * dx; + double bottom = q12 + (q22 - q12) * dx; + + return top + (bottom - top) * dy; + } + + private double readSample(Raster raster, int x, int y) { + double value = raster.getSampleDouble(x, y, 0); + if (!Double.isFinite(value) || value <= NODATA_VALUE) { + return Double.NaN; + } + return value; + } + + private double clamp(double value, double minValue, double maxValue) { + return Math.max(minValue, Math.min(maxValue, value)); + } + + private record LoadedTile( + Path path, + Raster raster, + int width, + int height, + int southDeg, + int westDeg + ) { + } + + private record ParsedTileKey(int southDeg, int westDeg) { + + private static final java.util.regex.Pattern TILE_PATTERN = + java.util.regex.Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$"); + + static ParsedTileKey fromFilename(String filename) { + if (filename == null) { + return null; + } + + var matcher = TILE_PATTERN.matcher(filename); + if (!matcher.matches()) { + return null; + } + + int south = signed(matcher.group(1), matcher.group(2)); + int west = signed(matcher.group(4), matcher.group(5)); + + return new ParsedTileKey(south, west); + } + + private static int signed(String direction, String degrees) { + int value = Integer.parseInt(degrees); + if ("S".equalsIgnoreCase(direction) || "W".equalsIgnoreCase(direction)) { + return -value; + } + return value; + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/DemDataset.java b/src/main/java/kst4contest/view/map/DemDataset.java new file mode 100644 index 0000000..fa4617d --- /dev/null +++ b/src/main/java/kst4contest/view/map/DemDataset.java @@ -0,0 +1,45 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Supported offline DEM datasets. + * + * First real offline target: + * Copernicus DEM GLO-30 DGED GeoTIFF. + */ +public enum DemDataset { + + COPERNICUS_GLO_30("copernicus_glo_30", "Copernicus DEM GLO-30"); + + private final String id; + private final String displayName; + + DemDataset(String id, String displayName) { + this.id = id; + this.displayName = displayName; + } + + public String id() { + return id; + } + + public String displayName() { + return displayName; + } + + public static DemDataset fromId(String id) { + if (id == null || id.isBlank()) { + return COPERNICUS_GLO_30; + } + + String normalized = id.trim().toLowerCase(Locale.ROOT); + for (DemDataset dataset : values()) { + if (dataset.id.equals(normalized)) { + return dataset; + } + } + + return COPERNICUS_GLO_30; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java new file mode 100644 index 0000000..4ada799 --- /dev/null +++ b/src/main/java/kst4contest/view/map/FallbackTerrainProfileProvider.java @@ -0,0 +1,48 @@ +package kst4contest.view.map; + +import java.util.Objects; + +/** + * Terrain provider wrapper that tries a primary source first and falls back + * to a secondary provider when the primary source returns no usable profile. + */ +public final class FallbackTerrainProfileProvider implements TerrainProfileProvider { + + private final TerrainProfileProvider primaryProvider; + private final TerrainProfileProvider fallbackProvider; + + public FallbackTerrainProfileProvider(TerrainProfileProvider primaryProvider, + TerrainProfileProvider fallbackProvider) { + this.primaryProvider = Objects.requireNonNull(primaryProvider, "primaryProvider"); + this.fallbackProvider = Objects.requireNonNull(fallbackProvider, "fallbackProvider"); + } + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + TerrainProfileData primaryData = safeLoad(primaryProvider, request); + if (primaryData.hasUsableProfile()) { + return primaryData; + } + + TerrainProfileData fallbackData = safeLoad(fallbackProvider, request); + if (fallbackData.hasUsableProfile()) { + return fallbackData; + } + + return fallbackData.profilePoints().isEmpty() ? primaryData : fallbackData; + } + + private TerrainProfileData safeLoad(TerrainProfileProvider provider, TerrainProfileRequest request) { + try { + TerrainProfileData result = provider.loadProfile(request); + return result == null + ? TerrainProfileData.empty(provider.getClass().getSimpleName()) + : result; + } catch (Exception exception) { + System.err.println("[StationMap] Terrain provider failed: " + + provider.getClass().getSimpleName() + + " -> " + exception.getMessage()); + return TerrainProfileData.empty(provider.getClass().getSimpleName()); + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java b/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java new file mode 100644 index 0000000..cad1712 --- /dev/null +++ b/src/main/java/kst4contest/view/map/GeometryOnlyPathAnalysisService.java @@ -0,0 +1,1188 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +/** + * Path analysis service that combines: + *
    + *
  • terrain/profile loading from the configured provider chain
  • + *
  • adaptive profile sampling based on path length
  • + *
  • Earth-curvature-adjusted line-of-sight evaluation
  • + *
  • a symmetric first Fresnel hull around the direct path
  • + *
  • worst-intrusion extraction from exactly the same profile points that are rendered later
  • + *
+ * + *

The main design goal is numeric/visual consistency: + * the chart should never recalculate different geometry than the service.

+ */ +public final class GeometryOnlyPathAnalysisService implements PathAnalysisService { + + private final TerrainProfileProvider terrainProfileProvider; + + public GeometryOnlyPathAnalysisService(TerrainProfileProvider terrainProfileProvider) { + this.terrainProfileProvider = Objects.requireNonNull(terrainProfileProvider, "terrainProfileProvider"); + } + + @Override + public PathAnalysisResult analyze(PathAnalysisRequest request) { + if (request == null) { + return PathAnalysisResult.waitingForSelection(""); + } + + String fromLocator6 = normalizeLocator6(request.fromLocator6()); + String toLocator6 = normalizeLocator6(request.toLocator6()); + String toCallsignRaw = normalizeCallsignRaw(request.toCallsignRaw()); + + if (!request.hasUsableHome()) { + return PathAnalysisResult.waitingForValidHomeLocator(fromLocator6, toLocator6); + } + + if (!request.hasUsableTarget()) { + return PathAnalysisResult.waitingForValidTarget(fromLocator6, toLocator6); + } + + double distanceKm = PathGeometryUtils.calculateGreatCircleDistanceKm( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg() + ); + + double bearingDeg = PathGeometryUtils.calculateInitialBearingDeg( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg() + ); + + double analysisFrequencyMHz = request.hasUsableFrequency() + ? request.frequencyMHz() + : PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ; + + double homeAntennaHeightMeters = sanitizeAntennaHeightMeters(request.homeAntennaHeightMeters()); + double targetAntennaHeightMeters = sanitizeAntennaHeightMeters(request.targetAntennaHeightMeters()); + + double effectiveEarthRadiusFactor = + PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(request.effectiveEarthRadiusFactor()); + + int requestedSampleCount = PathGeometryUtils.resolveAdaptiveSampleCount(distanceKm); + + TerrainProfileRequest terrainRequest = new TerrainProfileRequest( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg(), + distanceKm, + requestedSampleCount + ); + + final TerrainProfileData terrainProfileData; + try { + terrainProfileData = terrainProfileProvider.loadProfile(terrainRequest); + } catch (Exception exception) { + return PathAnalysisResult.noProfile( + "Terrain error", + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + "Terrain provider failed: " + exception.getMessage() + ); + } + + if (terrainProfileData == null || !terrainProfileData.hasUsableProfile()) { + String sourceName = terrainProfileData == null ? "Unknown terrain source" : terrainProfileData.sourceName(); + + return PathAnalysisResult.noProfile( + sourceName.isBlank() ? "No profile" : sourceName, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + buildNoProfileStatusText(sourceName, requestedSampleCount) + ); + } + + List enrichedProfilePoints = buildEnrichedProfilePoints( + terrainProfileData.profilePoints(), + distanceKm, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + effectiveEarthRadiusFactor + ); + + PathHorizonSummary horizonSummary = buildHorizonSummary( + enrichedProfilePoints, + distanceKm, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + effectiveEarthRadiusFactor + ); + + + ProfileSummary summary = summarizeProfile(enrichedProfilePoints); + + + PathObstructionSummary obstructionSummary = buildObstructionSummary( + enrichedProfilePoints, + distanceKm, + analysisFrequencyMHz, + summary + ); + + PathLinkBudgetSummary linkBudgetSummary = buildLinkBudgetSummary( + distanceKm, + analysisFrequencyMHz, + obstructionSummary, + request.linkBudgetSettings() + ); + + PathPropagationAssessment propagationAssessment = buildPropagationAssessment( + summary, + obstructionSummary, + linkBudgetSummary + ); + + String analysisMode = terrainProfileData.sourceName().isBlank() + ? (terrainProfileData.synthetic() ? "Synthetic fallback" : "Terrain profile") + : terrainProfileData.sourceName(); + + String statusText = buildCompletedStatusText( + terrainProfileData, + summary, + horizonSummary, + obstructionSummary, + linkBudgetSummary, + propagationAssessment, + enrichedProfilePoints.size(), + requestedSampleCount, + distanceKm, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz + ); + + + return PathAnalysisResult.completed( + analysisMode, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + summary.lineOfSightClear, + summary.fresnelClear, + summary.minimumLineOfSightClearanceMeters, + summary.minimumLowerFresnelClearanceMeters, + summary.worstFresnelIntrusionMeters, + summary.worstFresnelIntrusionRatio, + summary.worstFresnelDistanceKm, + summary.worstFresnelSampleIndex, + effectiveEarthRadiusFactor, + horizonSummary, + obstructionSummary, + linkBudgetSummary, + propagationAssessment, + statusText, + enrichedProfilePoints + ); + } + + /** + * Converts raw terrain profile samples into enriched samples that already contain: + *
    + *
  • curvature-adjusted terrain
  • + *
  • direct LOS height
  • + *
  • upper/lower first Fresnel hull
  • + *
  • LOS and Fresnel clearances
  • + *
+ * + *

Those values are later used unchanged by both the detail texts and the chart.

+ * + * @param rawProfilePoints raw terrain profile points + * @param totalDistanceKm total path length in kilometers + * @param homeAntennaHeightMeters own antenna height in meters AGL + * @param targetAntennaHeightMeters target antenna height in meters AGL + * @param analysisFrequencyMHz analysis frequency in MHz + * @return enriched immutable profile point list + */ + private List buildEnrichedProfilePoints(List rawProfilePoints, + double totalDistanceKm, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + double effectiveEarthRadiusFactor) { + + List enrichedPoints = new ArrayList<>(rawProfilePoints.size()); + + PathProfilePoint firstRawPoint = rawProfilePoints.get(0); + PathProfilePoint lastRawPoint = rawProfilePoints.get(rawProfilePoints.size() - 1); + + double startTerrainMeters = PathGeometryUtils.calculateCurvatureAdjustedElevationMeters( + firstRawPoint, + totalDistanceKm, + effectiveEarthRadiusFactor + ); + + double endTerrainMeters = PathGeometryUtils.calculateCurvatureAdjustedElevationMeters( + lastRawPoint, + totalDistanceKm, + effectiveEarthRadiusFactor + ); + + double startAntennaMeters = startTerrainMeters + homeAntennaHeightMeters; + double endAntennaMeters = endTerrainMeters + targetAntennaHeightMeters; + + + for (int sampleIndex = 0; sampleIndex < rawProfilePoints.size(); sampleIndex++) { + PathProfilePoint rawPoint = rawProfilePoints.get(sampleIndex); + + double curvatureAdjustedElevationMeters = + PathGeometryUtils.calculateCurvatureAdjustedElevationMeters( + rawPoint, + totalDistanceKm, + effectiveEarthRadiusFactor + ); + + double normalizedDistance = totalDistanceKm <= 0.0 + ? 0.0 + : Math.max(0.0, Math.min(1.0, rawPoint.distanceKm() / totalDistanceKm)); + + double lineOfSightHeightMeters = + startAntennaMeters + (endAntennaMeters - startAntennaMeters) * normalizedDistance; + + double fresnelRadiusMeters = PathGeometryUtils.calculateFirstFresnelRadiusMeters( + rawPoint.distanceKm(), + totalDistanceKm, + analysisFrequencyMHz + ); + + if (!Double.isFinite(fresnelRadiusMeters)) { + fresnelRadiusMeters = Double.NaN; + } + + double fresnelUpperHeightMeters = Double.isFinite(fresnelRadiusMeters) + ? lineOfSightHeightMeters + fresnelRadiusMeters + : Double.NaN; + + double fresnelLowerHeightMeters = Double.isFinite(fresnelRadiusMeters) + ? lineOfSightHeightMeters - fresnelRadiusMeters + : Double.NaN; + + double lineOfSightClearanceMeters = + lineOfSightHeightMeters - curvatureAdjustedElevationMeters; + + double lowerFresnelClearanceMeters = Double.isFinite(fresnelLowerHeightMeters) + ? fresnelLowerHeightMeters - curvatureAdjustedElevationMeters + : Double.NaN; + + double fresnelIntrusionMeters = Double.isFinite(lowerFresnelClearanceMeters) + ? Math.max(0.0, curvatureAdjustedElevationMeters - fresnelLowerHeightMeters) + : 0.0; + + enrichedPoints.add(new PathProfilePoint( + sampleIndex, + rawPoint.distanceKm(), + rawPoint.latitudeDeg(), + rawPoint.longitudeDeg(), + rawPoint.elevationMeters(), + curvatureAdjustedElevationMeters, + lineOfSightHeightMeters, + fresnelUpperHeightMeters, + fresnelLowerHeightMeters, + lineOfSightClearanceMeters, + lowerFresnelClearanceMeters, + fresnelIntrusionMeters + )); + } + + return List.copyOf(enrichedPoints); + } + + /** + * Aggregates the LOS/Fresnel summary from the already enriched profile points. + * + * @param enrichedProfilePoints enriched profile point list + * @return immutable summary + */ + private ProfileSummary summarizeProfile(List enrichedProfilePoints) { + boolean lineOfSightClear = true; + boolean fresnelClear = true; + + double minimumLineOfSightClearanceMeters = Double.POSITIVE_INFINITY; + double minimumLowerFresnelClearanceMeters = Double.POSITIVE_INFINITY; + double worstFresnelIntrusionMeters = 0.0; + double worstFresnelIntrusionRatio = 0.0; + double worstFresnelDistanceKm = Double.NaN; + int worstFresnelSampleIndex = -1; + + for (PathProfilePoint point : enrichedProfilePoints) { + if (Double.isFinite(point.lineOfSightClearanceMeters())) { + minimumLineOfSightClearanceMeters = Math.min( + minimumLineOfSightClearanceMeters, + point.lineOfSightClearanceMeters() + ); + + if (point.lineOfSightClearanceMeters() < 0.0) { + lineOfSightClear = false; + } + } + + if (Double.isFinite(point.lowerFresnelClearanceMeters())) { + minimumLowerFresnelClearanceMeters = Math.min( + minimumLowerFresnelClearanceMeters, + point.lowerFresnelClearanceMeters() + ); + } + + if (point.hasFresnelIntrusion()) { + fresnelClear = false; + } + + if (Double.isFinite(point.fresnelIntrusionMeters()) + && point.fresnelIntrusionMeters() > worstFresnelIntrusionMeters) { + + worstFresnelIntrusionMeters = point.fresnelIntrusionMeters(); + worstFresnelDistanceKm = point.distanceKm(); + worstFresnelSampleIndex = point.sampleIndex(); + + double localFresnelRadiusMeters = + point.lineOfSightHeightMeters() - point.fresnelLowerHeightMeters(); + + if (Double.isFinite(localFresnelRadiusMeters) && localFresnelRadiusMeters > 0.0) { + worstFresnelIntrusionRatio = + point.fresnelIntrusionMeters() / localFresnelRadiusMeters; + } else { + worstFresnelIntrusionRatio = 0.0; + } + } + } + + if (Double.isInfinite(minimumLineOfSightClearanceMeters)) { + minimumLineOfSightClearanceMeters = Double.NaN; + } + + if (Double.isInfinite(minimumLowerFresnelClearanceMeters)) { + minimumLowerFresnelClearanceMeters = Double.NaN; + } + + return new ProfileSummary( + lineOfSightClear, + fresnelClear, + minimumLineOfSightClearanceMeters, + minimumLowerFresnelClearanceMeters, + worstFresnelIntrusionMeters, + worstFresnelIntrusionRatio, + worstFresnelDistanceKm, + worstFresnelSampleIndex + ); + } + + + /** + * Builds horizon information from the already enriched profile points. + * + *

The simple radio horizon uses only antenna height and k-factor. The terrain + * horizon is derived from the actual profile by finding the terrain point with + * the highest apparent elevation angle from each endpoint.

+ * + * @param enrichedProfilePoints enriched path profile + * @param totalDistanceKm total path distance + * @param homeAntennaHeightMeters own antenna height AGL + * @param targetAntennaHeightMeters target antenna height AGL + * @param effectiveEarthRadiusFactor k-factor + * @return horizon summary + */ + private PathHorizonSummary buildHorizonSummary(List enrichedProfilePoints, + double totalDistanceKm, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double effectiveEarthRadiusFactor) { + + double homeSimpleRadioHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( + homeAntennaHeightMeters, + effectiveEarthRadiusFactor + ); + + double targetSimpleRadioHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( + targetAntennaHeightMeters, + effectiveEarthRadiusFactor + ); + + double combinedSimpleRadioHorizonKm = + Double.isFinite(homeSimpleRadioHorizonKm) && Double.isFinite(targetSimpleRadioHorizonKm) + ? homeSimpleRadioHorizonKm + targetSimpleRadioHorizonKm + : Double.NaN; + + TerrainHorizonCandidate homeTerrainHorizon = findHomeTerrainHorizon( + enrichedProfilePoints, + homeAntennaHeightMeters + ); + + TerrainHorizonCandidate targetTerrainHorizon = findTargetTerrainHorizon( + enrichedProfilePoints, + totalDistanceKm, + targetAntennaHeightMeters + ); + + return new PathHorizonSummary( + effectiveEarthRadiusFactor, + + homeSimpleRadioHorizonKm, + targetSimpleRadioHorizonKm, + combinedSimpleRadioHorizonKm, + + homeTerrainHorizon.pathDistanceKm(), + homeTerrainHorizon.elevationAngleDeg(), + homeTerrainHorizon.sampleIndex(), + + targetTerrainHorizon.pathDistanceKm(), + targetTerrainHorizon.distanceFromEndpointKm(), + targetTerrainHorizon.elevationAngleDeg(), + targetTerrainHorizon.sampleIndex() + ); + } + + private TerrainHorizonCandidate findHomeTerrainHorizon(List enrichedProfilePoints, + double homeAntennaHeightMeters) { + + if (enrichedProfilePoints == null || enrichedProfilePoints.size() < 3) { + return TerrainHorizonCandidate.empty(); + } + + PathProfilePoint firstPoint = enrichedProfilePoints.get(0); + double observerHeightMeters = firstPoint.curvatureAdjustedElevationMeters() + homeAntennaHeightMeters; + + TerrainHorizonCandidate bestCandidate = TerrainHorizonCandidate.empty(); + double bestAngleDeg = Double.NEGATIVE_INFINITY; + + for (int i = 1; i < enrichedProfilePoints.size() - 1; i++) { + PathProfilePoint point = enrichedProfilePoints.get(i); + + if (!Double.isFinite(point.curvatureAdjustedElevationMeters()) + || !Double.isFinite(point.distanceKm()) + || point.distanceKm() <= 0.0) { + continue; + } + + double angleDeg = PathGeometryUtils.calculateElevationAngleDeg( + observerHeightMeters, + point.curvatureAdjustedElevationMeters(), + point.distanceKm() + ); + + if (Double.isFinite(angleDeg) && angleDeg > bestAngleDeg) { + bestAngleDeg = angleDeg; + bestCandidate = new TerrainHorizonCandidate( + point.sampleIndex(), + point.distanceKm(), + point.distanceKm(), + angleDeg + ); + } + } + + return bestCandidate; + } + + private TerrainHorizonCandidate findTargetTerrainHorizon(List enrichedProfilePoints, + double totalDistanceKm, + double targetAntennaHeightMeters) { + + if (enrichedProfilePoints == null + || enrichedProfilePoints.size() < 3 + || !Double.isFinite(totalDistanceKm) + || totalDistanceKm <= 0.0) { + return TerrainHorizonCandidate.empty(); + } + + PathProfilePoint lastPoint = enrichedProfilePoints.get(enrichedProfilePoints.size() - 1); + double observerHeightMeters = lastPoint.curvatureAdjustedElevationMeters() + targetAntennaHeightMeters; + + TerrainHorizonCandidate bestCandidate = TerrainHorizonCandidate.empty(); + double bestAngleDeg = Double.NEGATIVE_INFINITY; + + for (int i = enrichedProfilePoints.size() - 2; i > 0; i--) { + PathProfilePoint point = enrichedProfilePoints.get(i); + + double distanceFromTargetKm = totalDistanceKm - point.distanceKm(); + + if (!Double.isFinite(point.curvatureAdjustedElevationMeters()) + || !Double.isFinite(distanceFromTargetKm) + || distanceFromTargetKm <= 0.0) { + continue; + } + + double angleDeg = PathGeometryUtils.calculateElevationAngleDeg( + observerHeightMeters, + point.curvatureAdjustedElevationMeters(), + distanceFromTargetKm + ); + + if (Double.isFinite(angleDeg) && angleDeg > bestAngleDeg) { + bestAngleDeg = angleDeg; + bestCandidate = new TerrainHorizonCandidate( + point.sampleIndex(), + point.distanceKm(), + distanceFromTargetKm, + angleDeg + ); + } + } + + return bestCandidate; + } + + private record TerrainHorizonCandidate( + int sampleIndex, + double pathDistanceKm, + double distanceFromEndpointKm, + double elevationAngleDeg + ) { + private static TerrainHorizonCandidate empty() { + return new TerrainHorizonCandidate(-1, Double.NaN, Double.NaN, Double.NaN); + } + } + + /** + * Builds a status text that clearly separates geometric/topographic evaluation + * from radio-technical interpretation. + * + * @param terrainProfileData original terrain profile metadata + * @param summary aggregated LOS/Fresnel summary + * @param horizonSummary radio/terrain horizon summary + * @param effectiveSampleCount number of samples actually used + * @param requestedSampleCount requested sample count for this path + * @param totalDistanceKm total path distance + * @param homeAntennaHeightMeters own antenna height AGL + * @param targetAntennaHeightMeters target antenna height AGL + * @param analysisFrequencyMHz analysis frequency + * @return human-readable multi-line status text + */ + private String buildCompletedStatusText(TerrainProfileData terrainProfileData, + ProfileSummary summary, + PathHorizonSummary horizonSummary, + PathObstructionSummary obstructionSummary, + PathLinkBudgetSummary linkBudgetSummary, + PathPropagationAssessment propagationAssessment, + int effectiveSampleCount, + int requestedSampleCount, + double totalDistanceKm, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz) { + + String sourceName = terrainProfileData.sourceName().isBlank() + ? "terrain source" + : terrainProfileData.sourceName(); + + StringBuilder status = new StringBuilder(); + + status.append("Geometric / topographic evaluation\n"); + status.append("• Loaded ") + .append(effectiveSampleCount) + .append(" / ") + .append(requestedSampleCount) + .append(" samples from ") + .append(sourceName) + .append(".\n"); + + status.append("• Earth/refraction model: ") + .append(horizonSummary.effectiveEarthRadiusText()) + .append(".\n"); + + status.append("• Direct line of sight: ") + .append(summary.lineOfSightClear ? "clear" : "blocked") + .append(" (minimum clearance ") + .append(formatSignedMeters(summary.minimumLineOfSightClearanceMeters)) + .append(").\n"); + + if (summary.fresnelClear) { + status.append("• 1st Fresnel zone: clear") + .append(" (minimum lower clearance ") + .append(formatSignedMeters(summary.minimumLowerFresnelClearanceMeters)) + .append(").\n"); + } else { + status.append("• 1st Fresnel zone: intruded") + .append(" (worst intrusion ") + .append(formatUnsignedMeters(summary.worstFresnelIntrusionMeters)) + .append(" at ") + .append(formatDistanceKm(summary.worstFresnelDistanceKm)) + .append(", about ") + .append(String.format(Locale.US, "%.0f%%", summary.worstFresnelIntrusionRatio * 100.0)) + .append(" of the local Fresnel radius).\n"); + } + + status.append("• Endpoint antenna heights: Home ") + .append(String.format(Locale.US, "%.0f m AGL", homeAntennaHeightMeters)) + .append(", DX ") + .append(String.format(Locale.US, "%.0f m AGL", targetAntennaHeightMeters)) + .append(".\n"); + + status.append("• Simple radio horizon: ") + .append(horizonSummary.simpleRadioHorizonText()) + .append(".\n"); + + status.append("• Terrain horizon from profile: ") + .append(horizonSummary.terrainHorizonText()) + .append(".\n"); + + status.append("• Dominant obstruction / diffraction candidate: ") + .append(obstructionSummary.obstructionText()) + .append(".\n"); + + status.append("• Link budget: ") + .append(linkBudgetSummary.ssbMarginText()) + .append(".\n"); + + status.append("• Estimated RX power: ") + .append(linkBudgetSummary.rxPowerText()) + .append(".\n"); + + status.append("• Link-budget details: ") + .append(linkBudgetSummary.linkBudgetDetailText()) + .append(".\n"); + + status.append("• CW hint: ") + .append(linkBudgetSummary.cwHintText()) + .append(".\n"); + + status.append("• Propagation assessment: ") + .append(propagationAssessment.shortText()) + .append(".\n"); + + + if (Double.isFinite(totalDistanceKm) + && Double.isFinite(horizonSummary.combinedSimpleRadioHorizonKm())) { + status.append("• Distance vs. simple radio horizon: ") + .append(formatDistanceKm(totalDistanceKm)) + .append(" path distance vs. ") + .append(formatDistanceKm(horizonSummary.combinedSimpleRadioHorizonKm())) + .append(" combined simple horizon.\n"); + } + + if (Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0) { + status.append("• Analysis frequency: ") + .append(String.format(Locale.US, "%.3f MHz", analysisFrequencyMHz)) + .append(".\n"); + } + + status.append("\n"); + status.append("Radio-technical interpretation\n"); + + status.append("• Assessment: ") + .append(propagationAssessment.category()) + .append(" — ") + .append(propagationAssessment.detailText()) + .append("\n"); + + status.append("• Likely mechanisms: ") + .append(propagationAssessment.likelyMechanisms()) + .append(".\n"); + + if (summary.lineOfSightClear && summary.fresnelClear) { + status.append("• The path is geometrically favorable. A direct tropospheric path is plausible.\n"); + } else if (summary.lineOfSightClear) { + status.append("• The direct path is open, but the Fresnel zone is partly obstructed. Expect additional loss, not an automatic failure.\n"); + } else { + status.append("• The path is geometrically blocked. This does not automatically mean that a VHF/UHF QSO is impossible.\n"); + + if (obstructionSummary.hasDominantLosObstruction()) { + status.append("• The dominant obstruction gives a rough single-knife-edge estimate of about ") + .append(String.format(Locale.US, "%.1f dB", obstructionSummary.estimatedKnifeEdgeLossDb())) + .append(" additional loss. Treat this as a severity hint, not as a final path prediction.\n"); + } + + status.append("• Possible mechanisms include diffraction over terrain, troposcatter, enhanced tropospheric refraction / tropo, and aircraft scatter.\n"); + } + + status.append("• Important: this is still not a full propagation model. It does not yet calculate diffraction loss, troposcatter probability, aircraft-scatter windows or ducting strength.\n"); + + if (terrainProfileData.synthetic()) { + status.append("• Synthetic fallback is active, so the result is only a rough approximation.\n"); + } + + return status.toString().trim(); + } + + private String formatSignedMeters(double value) { + if (!Double.isFinite(value)) { + return "-"; + } + return String.format(Locale.US, "%+.1f m", value); + } + + private String formatUnsignedMeters(double value) { + if (!Double.isFinite(value)) { + return "-"; + } + return String.format(Locale.US, "%.1f m", value); + } + + private String formatDistanceKm(double distanceKm) { + if (!Double.isFinite(distanceKm)) { + return "-"; + } + return String.format(Locale.US, "%.1f km", distanceKm); + } + + /** + * Builds the status text when no usable terrain profile could be loaded. + * + * @param sourceName terrain source label + * @param requestedSampleCount requested sample count for this path + * @return human-readable status text + */ + private String buildNoProfileStatusText(String sourceName, int requestedSampleCount) { + if (sourceName == null || sourceName.isBlank()) { + return "Terrain provider returned no usable profile for " + requestedSampleCount + " samples."; + } + return "Terrain provider returned no usable profile for " + + requestedSampleCount + + " samples: " + + sourceName; + } + + private String normalizeLocator6(String locator) { + return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeCallsignRaw(String callsignRaw) { + return callsignRaw == null ? "" : callsignRaw.trim().toUpperCase(Locale.ROOT); + } + + private double sanitizeAntennaHeightMeters(double antennaHeightMeters) { + if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) { + return 0.0; + } + return antennaHeightMeters; + } + + /** + * Internal immutable summary of LOS and Fresnel evaluation. + */ + private static final class ProfileSummary { + + private final boolean lineOfSightClear; + private final boolean fresnelClear; + private final double minimumLineOfSightClearanceMeters; + private final double minimumLowerFresnelClearanceMeters; + private final double worstFresnelIntrusionMeters; + private final double worstFresnelIntrusionRatio; + private final double worstFresnelDistanceKm; + private final int worstFresnelSampleIndex; + + private ProfileSummary(boolean lineOfSightClear, + boolean fresnelClear, + double minimumLineOfSightClearanceMeters, + double minimumLowerFresnelClearanceMeters, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio, + double worstFresnelDistanceKm, + int worstFresnelSampleIndex) { + this.lineOfSightClear = lineOfSightClear; + this.fresnelClear = fresnelClear; + this.minimumLineOfSightClearanceMeters = minimumLineOfSightClearanceMeters; + this.minimumLowerFresnelClearanceMeters = minimumLowerFresnelClearanceMeters; + this.worstFresnelIntrusionMeters = worstFresnelIntrusionMeters; + this.worstFresnelIntrusionRatio = worstFresnelIntrusionRatio; + this.worstFresnelDistanceKm = worstFresnelDistanceKm; + this.worstFresnelSampleIndex = worstFresnelSampleIndex; + } + } + + /** + * Builds an operator-facing propagation assessment from the geometric profile. + * + *

This method intentionally avoids "possible/impossible" wording. It gives + * a practical severity estimate and suggests likely propagation mechanisms.

+ * + * @param summary LOS/Fresnel summary + * @param obstructionSummary dominant obstruction / diffraction summary + * @return operator-facing propagation assessment + */ + private PathPropagationAssessment buildPropagationAssessment(ProfileSummary summary, + PathObstructionSummary obstructionSummary, + PathLinkBudgetSummary linkBudgetSummary) { + + if (summary == null) { + return PathPropagationAssessment.unknown(); + } + + double ssbMarginDb = linkBudgetSummary == null + ? Double.NaN + : linkBudgetSummary.bidirectionalSsbMarginDb(); + + if (summary.lineOfSightClear && summary.fresnelClear) { + if (Double.isFinite(ssbMarginDb) && ssbMarginDb < 0.0) { + return new PathPropagationAssessment( + "Geometrically favorable, weak budget", + "Direct path possible, but SSB budget is weak", + String.format( + Locale.US, + "The path geometry is favorable, but the bidirectional SSB link margin is only %+.1f dB. CW may be more realistic.", + ssbMarginDb + ), + "Direct tropospheric path, CW if SSB is too weak", + 2 + ); + } + + return PathPropagationAssessment.directFavorable(); + } + + if (summary.lineOfSightClear) { + if (Double.isFinite(ssbMarginDb) && ssbMarginDb >= 6.0) { + return new PathPropagationAssessment( + "Direct path lossy but budget-positive", + "Likely without aircraft scatter", + String.format( + Locale.US, + "The direct path is open, the Fresnel zone is obstructed, but the bidirectional SSB margin is still %+.1f dB.", + ssbMarginDb + ), + "Direct path with Fresnel loss, mild diffraction, tropo enhancement", + 2 + ); + } + + return PathPropagationAssessment.directLossy(summary.worstFresnelIntrusionRatio); + } + + if (linkBudgetSummary != null && linkBudgetSummary.hasUsableBudget()) { + if (ssbMarginDb >= 10.0) { + return new PathPropagationAssessment( + "Obstructed but budget-positive", + "Likely without aircraft scatter", + String.format( + Locale.US, + "The path is geometrically obstructed, but the bidirectional SSB link margin is still %+.1f dB after the rough diffraction estimate.", + ssbMarginDb + ), + "Terrain diffraction, tropo enhancement, troposcatter", + 3 + ); + } + + if (ssbMarginDb >= 0.0) { + return new PathPropagationAssessment( + "Obstructed but workable", + "Possible without aircraft scatter under good conditions", + String.format( + Locale.US, + "The path is geometrically obstructed, but the bidirectional SSB budget remains just positive at %+.1f dB. This is a candidate for diffraction/tropo, not a guaranteed QSO.", + ssbMarginDb + ), + "Terrain diffraction, tropo enhancement, troposcatter", + 3 + ); + } + + if (linkBudgetSummary.bidirectionalCwMarginDb() >= 0.0) { + return new PathPropagationAssessment( + "SSB marginal, CW possible", + "SSB weak; CW may still work", + String.format( + Locale.US, + "The bidirectional SSB margin is %+.1f dB, but the CW margin is %+.1f dB.", + ssbMarginDb, + linkBudgetSummary.bidirectionalCwMarginDb() + ), + "CW via diffraction/tropo, SSB only with better conditions", + 4 + ); + } + } + + if (obstructionSummary == null || !obstructionSummary.hasDominantLosObstruction()) { + return PathPropagationAssessment.blockedNoLossEstimate(); + } + + double knifeEdgeLossDb = obstructionSummary.estimatedKnifeEdgeLossDb(); + + if (!Double.isFinite(knifeEdgeLossDb)) { + return PathPropagationAssessment.blockedNoLossEstimate(); + } + + if (knifeEdgeLossDb < 15.0) { + return PathPropagationAssessment.diffractionPlausible(knifeEdgeLossDb); + } + + if (knifeEdgeLossDb < 30.0) { + return PathPropagationAssessment.obstructedNeedsHelp(knifeEdgeLossDb); + } + + return PathPropagationAssessment.severelyObstructed(knifeEdgeLossDb); + } + + /** + * Finds the dominant LOS-blocking terrain obstruction and estimates a rough + * single-knife-edge diffraction loss for that point. + * + *

The selected obstruction is the point with the highest estimated + * single-knife-edge loss. This is intentionally a simple severity indicator, + * not a full multi-edge propagation model.

+ * + * @param enrichedProfilePoints enriched path profile + * @param totalDistanceKm total path distance + * @param analysisFrequencyMHz analysis frequency + * @param summary existing LOS/Fresnel profile summary + * @return obstruction summary + */ + private PathObstructionSummary buildObstructionSummary(List enrichedProfilePoints, + double totalDistanceKm, + double analysisFrequencyMHz, + ProfileSummary summary) { + + if (enrichedProfilePoints == null + || enrichedProfilePoints.size() < 3 + || !Double.isFinite(totalDistanceKm) + || totalDistanceKm <= 0.0) { + return PathObstructionSummary.empty(); + } + + PathObstructionCandidate bestCandidate = PathObstructionCandidate.empty(); + + for (int i = 1; i < enrichedProfilePoints.size() - 1; i++) { + PathProfilePoint point = enrichedProfilePoints.get(i); + + if (!Double.isFinite(point.lineOfSightClearanceMeters()) + || !Double.isFinite(point.distanceKm())) { + continue; + } + + double heightAboveLosMeters = Math.max(0.0, -point.lineOfSightClearanceMeters()); + if (heightAboveLosMeters <= 0.0) { + continue; + } + + double distanceFromHomeKm = point.distanceKm(); + double distanceFromTargetKm = totalDistanceKm - point.distanceKm(); + + if (distanceFromHomeKm <= 0.0 || distanceFromTargetKm <= 0.0) { + continue; + } + + double localFirstFresnelRadiusMeters = point.lineOfSightHeightMeters() - point.fresnelLowerHeightMeters(); + + if (!Double.isFinite(localFirstFresnelRadiusMeters) || localFirstFresnelRadiusMeters <= 0.0) { + localFirstFresnelRadiusMeters = PathGeometryUtils.calculateFirstFresnelRadiusMeters( + point.distanceKm(), + totalDistanceKm, + analysisFrequencyMHz + ); + } + + double obstructionFresnelRatio = + Double.isFinite(localFirstFresnelRadiusMeters) && localFirstFresnelRadiusMeters > 0.0 + ? heightAboveLosMeters / localFirstFresnelRadiusMeters + : Double.NaN; + + double vParameter = PathGeometryUtils.calculateKnifeEdgeVParameter( + heightAboveLosMeters, + distanceFromHomeKm, + distanceFromTargetKm, + analysisFrequencyMHz + ); + + double estimatedKnifeEdgeLossDb = + PathGeometryUtils.calculateSingleKnifeEdgeLossDb(vParameter); + + if (!Double.isFinite(estimatedKnifeEdgeLossDb)) { + continue; + } + + if (!bestCandidate.hasCandidate() + || estimatedKnifeEdgeLossDb > bestCandidate.estimatedKnifeEdgeLossDb()) { + bestCandidate = new PathObstructionCandidate( + point.sampleIndex(), + point.distanceKm(), + heightAboveLosMeters, + localFirstFresnelRadiusMeters, + obstructionFresnelRatio, + vParameter, + estimatedKnifeEdgeLossDb + ); + } + } + + if (!bestCandidate.hasCandidate()) { + return new PathObstructionSummary( + analysisFrequencyMHz, + + -1, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + + summary.worstFresnelSampleIndex, + summary.worstFresnelDistanceKm, + summary.worstFresnelIntrusionMeters, + summary.worstFresnelIntrusionRatio + ); + } + + return new PathObstructionSummary( + analysisFrequencyMHz, + + bestCandidate.sampleIndex(), + bestCandidate.pathDistanceKm(), + bestCandidate.heightAboveLosMeters(), + bestCandidate.localFirstFresnelRadiusMeters(), + bestCandidate.obstructionFresnelRatio(), + bestCandidate.diffractionVParameter(), + bestCandidate.estimatedKnifeEdgeLossDb(), + + summary.worstFresnelSampleIndex, + summary.worstFresnelDistanceKm, + summary.worstFresnelIntrusionMeters, + summary.worstFresnelIntrusionRatio + ); + } + + private record PathObstructionCandidate( + int sampleIndex, + double pathDistanceKm, + double heightAboveLosMeters, + double localFirstFresnelRadiusMeters, + double obstructionFresnelRatio, + double diffractionVParameter, + double estimatedKnifeEdgeLossDb + ) { + private static PathObstructionCandidate empty() { + return new PathObstructionCandidate( + -1, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN + ); + } + + private boolean hasCandidate() { + return sampleIndex >= 0 && Double.isFinite(estimatedKnifeEdgeLossDb); + } + } + + private PathLinkBudgetSummary buildLinkBudgetSummary(double distanceKm, + double analysisFrequencyMHz, + PathObstructionSummary obstructionSummary, + PathLinkBudgetSettings settings) { + + PathLinkBudgetSettings safeSettings = settings == null + ? PathLinkBudgetSettings.defaults() + : settings; + + double ownTxPowerDbm = PathGeometryUtils.wattsToDbm(safeSettings.ownTxPowerWatts()); + double targetTxPowerDbm = PathGeometryUtils.wattsToDbm(safeSettings.targetTxPowerWatts()); + + double freeSpacePathLossDb = PathGeometryUtils.calculateFreeSpacePathLossDb( + distanceKm, + analysisFrequencyMHz + ); + + double ownFeederLossDb = PathGeometryUtils.estimateFeederLossPerStationDb( + analysisFrequencyMHz, + safeSettings + ); + + double targetFeederLossDb = ownFeederLossDb; + + double diffractionLossDb = obstructionSummary != null + && obstructionSummary.hasDominantLosObstruction() + && Double.isFinite(obstructionSummary.estimatedKnifeEdgeLossDb()) + ? obstructionSummary.estimatedKnifeEdgeLossDb() + : 0.0; + + double homeToTargetRxPowerDbm = + ownTxPowerDbm + + safeSettings.ownAntennaGainDbi() + - ownFeederLossDb + - freeSpacePathLossDb + - diffractionLossDb + + safeSettings.targetAntennaGainDbi() + - targetFeederLossDb; + + double targetToHomeRxPowerDbm = + targetTxPowerDbm + + safeSettings.targetAntennaGainDbi() + - targetFeederLossDb + - freeSpacePathLossDb + - diffractionLossDb + + safeSettings.ownAntennaGainDbi() + - ownFeederLossDb; + + double requiredSsbWithContestMarginDbm = + safeSettings.requiredSsbSignalDbm() + safeSettings.contestMarginDb(); + + double requiredCwWithContestMarginDbm = + safeSettings.requiredCwSignalDbm() + safeSettings.contestMarginDb(); + + double homeToTargetSsbMarginDb = + homeToTargetRxPowerDbm - requiredSsbWithContestMarginDbm; + + double targetToHomeSsbMarginDb = + targetToHomeRxPowerDbm - requiredSsbWithContestMarginDbm; + + double bidirectionalSsbMarginDb = + Math.min(homeToTargetSsbMarginDb, targetToHomeSsbMarginDb); + + double homeToTargetCwMarginDb = + homeToTargetRxPowerDbm - requiredCwWithContestMarginDbm; + + double targetToHomeCwMarginDb = + targetToHomeRxPowerDbm - requiredCwWithContestMarginDbm; + + double bidirectionalCwMarginDb = + Math.min(homeToTargetCwMarginDb, targetToHomeCwMarginDb); + + return new PathLinkBudgetSummary( + analysisFrequencyMHz, + + ownTxPowerDbm, + targetTxPowerDbm, + safeSettings.ownAntennaGainDbi(), + safeSettings.targetAntennaGainDbi(), + + ownFeederLossDb, + targetFeederLossDb, + freeSpacePathLossDb, + diffractionLossDb, + + homeToTargetRxPowerDbm, + targetToHomeRxPowerDbm, + + safeSettings.requiredSsbSignalDbm(), + safeSettings.requiredCwSignalDbm(), + safeSettings.contestMarginDb(), + + homeToTargetSsbMarginDb, + targetToHomeSsbMarginDb, + bidirectionalSsbMarginDb, + + homeToTargetCwMarginDb, + targetToHomeCwMarginDb, + bidirectionalCwMarginDb + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java b/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java new file mode 100644 index 0000000..d7bec2b --- /dev/null +++ b/src/main/java/kst4contest/view/map/MaidenheadGridRenderPlanner.java @@ -0,0 +1,228 @@ +package kst4contest.view.map; + +import static kst4contest.view.map.MaidenheadGridUtils.GridPrecision; + +/** + * Chooses a grid rendering strategy for the current viewport. + * + * Goals: + * - keep the current zoom based progression as a baseline + * - avoid overly dense grid rendering on unlucky viewport sizes + * - make label visibility depend on actual on-screen cell size + * - expose row/column strides so labels form a stable raster pattern + */ +public final class MaidenheadGridRenderPlanner { + + private static final int MAX_SUBSQUARE_CELLS = 4500; + private static final int MAX_SQUARE_CELLS = 2500; + + private static final double MIN_SUBSQUARE_CELL_WIDTH_PX = 8.0; + private static final double MIN_SUBSQUARE_CELL_HEIGHT_PX = 7.0; + private static final double MIN_SQUARE_CELL_WIDTH_PX = 10.0; + private static final double MIN_SQUARE_CELL_HEIGHT_PX = 9.0; + + private MaidenheadGridRenderPlanner() { + } + + public static GridRenderPlan createPlan(int leafletZoom, + double southLat, + double westLon, + double northLat, + double eastLon, + double viewportWidthPx, + double viewportHeightPx) { + + double safeViewportWidthPx = Math.max(1.0, viewportWidthPx); + double safeViewportHeightPx = Math.max(1.0, viewportHeightPx); + + GridPrecision requestedPrecision = MaidenheadGridUtils.precisionForZoom(leafletZoom); + GridPrecision effectivePrecision = chooseEffectivePrecision( + requestedPrecision, + southLat, + westLon, + northLat, + eastLon, + safeViewportWidthPx, + safeViewportHeightPx + ); + + double estimatedCellWidthPx = estimateCellWidthPx(effectivePrecision, westLon, eastLon, safeViewportWidthPx); + double estimatedCellHeightPx = estimateCellHeightPx(effectivePrecision, southLat, northLat, safeViewportHeightPx); + + boolean showLabels = shouldShowLabels(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx); + int labelColumnStride = showLabels ? computeStride(estimatedCellWidthPx, desiredLabelWidthPx(effectivePrecision)) : Integer.MAX_VALUE; + int labelRowStride = showLabels ? computeStride(estimatedCellHeightPx, desiredLabelHeightPx(effectivePrecision)) : Integer.MAX_VALUE; + double labelFontSizePx = estimateLabelFontSizePx(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx); + + return new GridRenderPlan( + effectivePrecision, + showLabels, + labelRowStride, + labelColumnStride, + estimatedCellWidthPx, + estimatedCellHeightPx, + labelFontSizePx + ); + } + + private static GridPrecision chooseEffectivePrecision(GridPrecision requestedPrecision, + double southLat, + double westLon, + double northLat, + double eastLon, + double viewportWidthPx, + double viewportHeightPx) { + + GridPrecision effectivePrecision = requestedPrecision; + + if (effectivePrecision == GridPrecision.SUBSQUARE_6 + && !canRenderSubsquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) { + effectivePrecision = GridPrecision.SQUARE_4; + } + + if (effectivePrecision == GridPrecision.SQUARE_4 + && !canRenderSquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) { + effectivePrecision = GridPrecision.FIELD_2; + } + + return effectivePrecision; + } + + private static boolean canRenderSubsquareGrid(double southLat, + double westLon, + double northLat, + double eastLon, + double viewportWidthPx, + double viewportHeightPx) { + + double cellWidthPx = estimateCellWidthPx(GridPrecision.SUBSQUARE_6, westLon, eastLon, viewportWidthPx); + double cellHeightPx = estimateCellHeightPx(GridPrecision.SUBSQUARE_6, southLat, northLat, viewportHeightPx); + int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SUBSQUARE_6, southLat, westLon, northLat, eastLon); + + return cellWidthPx >= MIN_SUBSQUARE_CELL_WIDTH_PX + && cellHeightPx >= MIN_SUBSQUARE_CELL_HEIGHT_PX + && estimatedCellCount <= MAX_SUBSQUARE_CELLS; + } + + private static boolean canRenderSquareGrid(double southLat, + double westLon, + double northLat, + double eastLon, + double viewportWidthPx, + double viewportHeightPx) { + + double cellWidthPx = estimateCellWidthPx(GridPrecision.SQUARE_4, westLon, eastLon, viewportWidthPx); + double cellHeightPx = estimateCellHeightPx(GridPrecision.SQUARE_4, southLat, northLat, viewportHeightPx); + int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SQUARE_4, southLat, westLon, northLat, eastLon); + + return cellWidthPx >= MIN_SQUARE_CELL_WIDTH_PX + && cellHeightPx >= MIN_SQUARE_CELL_HEIGHT_PX + && estimatedCellCount <= MAX_SQUARE_CELLS; + } + + private static int estimateVisibleCellCount(GridPrecision precision, + double southLat, + double westLon, + double northLat, + double eastLon) { + + double lonSpanDeg = Math.max(1e-6, eastLon - westLon); + double latSpanDeg = Math.max(1e-6, northLat - southLat); + + int columns = Math.max(1, (int) Math.ceil(lonSpanDeg / precision.cellWidthDeg())); + int rows = Math.max(1, (int) Math.ceil(latSpanDeg / precision.cellHeightDeg())); + return columns * rows; + } + + private static double estimateCellWidthPx(GridPrecision precision, + double westLon, + double eastLon, + double viewportWidthPx) { + + double lonSpanDeg = Math.max(1e-6, eastLon - westLon); + double visibleColumns = Math.max(1.0, lonSpanDeg / precision.cellWidthDeg()); + return viewportWidthPx / visibleColumns; + } + + private static double estimateCellHeightPx(GridPrecision precision, + double southLat, + double northLat, + double viewportHeightPx) { + + double latSpanDeg = Math.max(1e-6, northLat - southLat); + double visibleRows = Math.max(1.0, latSpanDeg / precision.cellHeightDeg()); + return viewportHeightPx / visibleRows; + } + + private static boolean shouldShowLabels(GridPrecision precision, double cellWidthPx, double cellHeightPx) { + return switch (precision) { + case FIELD_2 -> cellWidthPx >= 28.0 && cellHeightPx >= 14.0; + case SQUARE_4 -> cellWidthPx >= 22.0 && cellHeightPx >= 14.0; + case SUBSQUARE_6 -> cellWidthPx >= 18.0 && cellHeightPx >= 11.0; + }; + } + + private static double desiredLabelWidthPx(GridPrecision precision) { + return switch (precision) { + case FIELD_2 -> 30.0; + case SQUARE_4 -> 44.0; + case SUBSQUARE_6 -> 56.0; + }; + } + + private static double desiredLabelHeightPx(GridPrecision precision) { + return switch (precision) { + case FIELD_2 -> 18.0; + case SQUARE_4 -> 18.0; + case SUBSQUARE_6 -> 16.0; + }; + } + + private static double estimateLabelFontSizePx(GridPrecision precision, + double cellWidthPx, + double cellHeightPx) { + + double minFontSizePx = switch (precision) { + case FIELD_2 -> 14.0; + case SQUARE_4 -> 11.5; + case SUBSQUARE_6 -> 10.5; + }; + + double maxFontSizePx = switch (precision) { + case FIELD_2 -> 18.0; + case SQUARE_4 -> 15.0; + case SUBSQUARE_6 -> 13.5; + }; + + double estimatedFontSizePx = Math.min(cellHeightPx * 0.55, cellWidthPx * 0.24); + return clamp(estimatedFontSizePx, minFontSizePx, maxFontSizePx); + } + + private static int computeStride(double cellSizePx, double desiredLabelSizePx) { + return Math.max(1, (int) Math.ceil(desiredLabelSizePx / Math.max(1.0, cellSizePx))); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + public record GridRenderPlan( + GridPrecision precision, + boolean showLabels, + int labelRowStride, + int labelColumnStride, + double estimatedCellWidthPx, + double estimatedCellHeightPx, + double labelFontSizePx + ) { + + public boolean shouldShowLabel(MaidenheadGridUtils.GridCell cell) { + if (!showLabels || cell == null) { + return false; + } + + return (cell.rowIndex() % labelRowStride) == 0 + && (cell.columnIndex() % labelColumnStride) == 0; + } + } +} diff --git a/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java b/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java new file mode 100644 index 0000000..d7f896f --- /dev/null +++ b/src/main/java/kst4contest/view/map/MaidenheadGridUtils.java @@ -0,0 +1,220 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Utility methods for generating visible Maidenhead grid rectangles. + * + * Supported levels: + * - 2 characters (fields) + * - 4 characters (squares) + * - 6 characters (subsquares) + */ +public final class MaidenheadGridUtils { + + private static final double EPSILON = 1e-9; + + private MaidenheadGridUtils() { + } + + public enum GridPrecision { + FIELD_2(2, 20.0, 10.0), + SQUARE_4(4, 2.0, 1.0), + SUBSQUARE_6(6, 5.0 / 60.0, 2.5 / 60.0); + + private final int locatorLength; + private final double cellWidthDeg; + private final double cellHeightDeg; + + GridPrecision(int locatorLength, double cellWidthDeg, double cellHeightDeg) { + this.locatorLength = locatorLength; + this.cellWidthDeg = cellWidthDeg; + this.cellHeightDeg = cellHeightDeg; + } + + public int locatorLength() { + return locatorLength; + } + + public double cellWidthDeg() { + return cellWidthDeg; + } + + public double cellHeightDeg() { + return cellHeightDeg; + } + } + + public record GridCell( + String locatorLabel, + double southLat, + double westLon, + double northLat, + double eastLon, + int rowIndex, + int columnIndex + ) { + } + + public static GridPrecision precisionForZoom(int leafletZoom) { + if (leafletZoom <= 5) { + return GridPrecision.FIELD_2; + } + if (leafletZoom <= 7) { + return GridPrecision.SQUARE_4; + } + return GridPrecision.SUBSQUARE_6; + } + + public static List buildVisibleCells(double southLat, + double westLon, + double northLat, + double eastLon, + int leafletZoom) { + return buildVisibleCells(southLat, westLon, northLat, eastLon, precisionForZoom(leafletZoom)); + } + + public static List buildVisibleCells(double southLat, + double westLon, + double northLat, + double eastLon, + GridPrecision precision) { + + if (westLon > eastLon) { + // Anti-meridian handling is not needed for Europe in this project stage. + return List.of(); + } + + double clampedSouth = clamp(southLat, -90.0 + EPSILON, 90.0 - EPSILON); + double clampedNorth = clamp(northLat, -90.0 + EPSILON, 90.0 - EPSILON); + double clampedWest = clamp(westLon, -180.0 + EPSILON, 180.0 - EPSILON); + double clampedEast = clamp(eastLon, -180.0 + EPSILON, 180.0 - EPSILON); + + return switch (precision) { + case FIELD_2 -> build2CharFields(clampedSouth, clampedWest, clampedNorth, clampedEast); + case SQUARE_4 -> build4CharSquares(clampedSouth, clampedWest, clampedNorth, clampedEast); + case SUBSQUARE_6 -> build6CharSubsquares(clampedSouth, clampedWest, clampedNorth, clampedEast); + }; + } + + private static List build2CharFields(double southLat, double westLon, double northLat, double eastLon) { + List cells = new ArrayList<>(); + + int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 20.0), 0, 17); + int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 20.0), 0, 17); + + int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 10.0), 0, 17); + int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 10.0), 0, 17); + + for (int lonIndex = lonStart; lonIndex <= lonEnd; lonIndex++) { + for (int latIndex = latStart; latIndex <= latEnd; latIndex++) { + double west = -180.0 + lonIndex * 20.0; + double east = west + 20.0; + double south = -90.0 + latIndex * 10.0; + double north = south + 10.0; + + String label = "" + (char) ('A' + lonIndex) + (char) ('A' + latIndex); + cells.add(new GridCell(label, south, west, north, east, latIndex, lonIndex)); + } + } + + return cells; + } + + private static List build4CharSquares(double southLat, double westLon, double northLat, double eastLon) { + List cells = new ArrayList<>(); + + int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 2.0), 0, 179); + int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 2.0), 0, 179); + + int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 1.0), 0, 179); + int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 1.0), 0, 179); + + for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) { + for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) { + + int lonFieldIndex = lonTotalIndex / 10; + int lonSquareIndex = lonTotalIndex % 10; + + int latFieldIndex = latTotalIndex / 10; + int latSquareIndex = latTotalIndex % 10; + + double west = -180.0 + lonTotalIndex * 2.0; + double east = west + 2.0; + double south = -90.0 + latTotalIndex; + double north = south + 1.0; + + String label = String.format( + Locale.ROOT, + "%c%c%d%d", + (char) ('A' + lonFieldIndex), + (char) ('A' + latFieldIndex), + lonSquareIndex, + latSquareIndex + ); + + cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex)); + } + } + + return cells; + } + + private static List build6CharSubsquares(double southLat, double westLon, double northLat, double eastLon) { + List cells = new ArrayList<>(); + + double lonStepDeg = 5.0 / 60.0; + double latStepDeg = 2.5 / 60.0; + + int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / lonStepDeg), 0, 4319); + int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / lonStepDeg), 0, 4319); + + int latStart = clampIndex((int) Math.floor((southLat + 90.0) / latStepDeg), 0, 4319); + int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / latStepDeg), 0, 4319); + + for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) { + for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) { + + int lonFieldIndex = lonTotalIndex / 240; + int lonWithinField = lonTotalIndex % 240; + int lonSquareIndex = lonWithinField / 24; + int lonSubsquareIndex = lonWithinField % 24; + + int latFieldIndex = latTotalIndex / 240; + int latWithinField = latTotalIndex % 240; + int latSquareIndex = latWithinField / 24; + int latSubsquareIndex = latWithinField % 24; + + double west = -180.0 + lonTotalIndex * lonStepDeg; + double east = west + lonStepDeg; + double south = -90.0 + latTotalIndex * latStepDeg; + double north = south + latStepDeg; + + String label = String.format( + Locale.ROOT, + "%c%c%d%d%c%c", + (char) ('A' + lonFieldIndex), + (char) ('A' + latFieldIndex), + lonSquareIndex, + latSquareIndex, + (char) ('a' + lonSubsquareIndex), + (char) ('a' + latSubsquareIndex) + ); + + cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex)); + } + } + + return cells; + } + + private static int clampIndex(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java new file mode 100644 index 0000000..743f66e --- /dev/null +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java @@ -0,0 +1,78 @@ +package kst4contest.view.map; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.StringJoiner; + +/** + * Immutable map render model. + * + * One snapshot represents exactly one visible marker on the map, + * aggregated by callSignRaw. + */ +public record MapCallsignRawSnapshot( + String callSignRaw, + String displayCallSign, + String locator6, + double latitudeDeg, + double longitudeDeg, + String bandSummary, + Map lastKnownFrequenciesByBand, + boolean warningToMyDirection, + boolean worked, + boolean selected, + double qrbKm, + double qtfDeg, + int reachableAirplanes, + long lastActivityEpochMs +) { + + public MapCallsignRawSnapshot { + callSignRaw = normalizeUpper(callSignRaw); + displayCallSign = (displayCallSign == null || displayCallSign.isBlank()) + ? callSignRaw + : displayCallSign.trim(); + + locator6 = locator6 == null ? "" : locator6.trim().toUpperCase(Locale.ROOT); + bandSummary = bandSummary == null ? "" : bandSummary.trim(); + + LinkedHashMap orderedFrequencies = new LinkedHashMap<>(); + if (lastKnownFrequenciesByBand != null) { + orderedFrequencies.putAll(lastKnownFrequenciesByBand); + } + lastKnownFrequenciesByBand = Collections.unmodifiableMap(orderedFrequencies); + } + + private static String normalizeUpper(String value) { + if (value == null) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } + + public boolean hasUsablePosition() { + return !locator6.isBlank() + && Double.isFinite(latitudeDeg) + && Double.isFinite(longitudeDeg); + } + + public String markerLabel() { + return bandSummary.isBlank() + ? displayCallSign + : displayCallSign + " (" + bandSummary + ")"; + } + + public String detailFrequencyText() { + if (lastKnownFrequenciesByBand.isEmpty()) { + return "-"; + } + + StringJoiner joiner = new StringJoiner("\n"); + for (Map.Entry entry : lastKnownFrequenciesByBand.entrySet()) { + joiner.add(entry.getKey() + ": " + entry.getValue()); + } + return joiner.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java new file mode 100644 index 0000000..c5cfdd2 --- /dev/null +++ b/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java @@ -0,0 +1,287 @@ +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.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 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 { + + public List buildSnapshots(Collection visibleChatMembers, + ChatMember selectedChatMember) { + + if (visibleChatMembers == null || visibleChatMembers.isEmpty()) { + return List.of(); + } + + String selectedCallsignRaw = normalizeCallsignRaw( + selectedChatMember == null ? null : selectedChatMember.getCallSignRaw() + ); + + Map> 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 snapshots = new ArrayList<>(groupedByCallsignRaw.size()); + + for (Map.Entry> entry : groupedByCallsignRaw.entrySet()) { + String callSignRaw = entry.getKey(); + List 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 frequenciesByBand = collectLastKnownFrequenciesByBand(variants); + String bandSummary = String.join(", ", frequenciesByBand.keySet()); + + 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, + 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 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 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 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 collectLastKnownFrequenciesByBand(List variants) { + + Map latestByBand = new EnumMap<>(Band.class); + + for (ChatMember variant : variants) { + if (variant == null) { + continue; + } + + for (Map.Entry 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 ordered = new LinkedHashMap<>(); + latestByBand.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency())); + + return ordered; + } + + /** + * 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() + || 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) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/MapHtmlResources.java b/src/main/java/kst4contest/view/map/MapHtmlResources.java new file mode 100644 index 0000000..0dcced9 --- /dev/null +++ b/src/main/java/kst4contest/view/map/MapHtmlResources.java @@ -0,0 +1,675 @@ +package kst4contest.view.map; + +/** + * HTML host for the JavaFX WebView map. + * + * This version keeps DOM-based station markers, but exposes helper APIs so the + * JavaFX WebView can decide interactions directly: + * - inspectPoint(x,y) returns what is under the cursor + * - zoomIn()/zoomOut() are callable from Java + * - grid / beam / connection use non-interactive panes + * - JavaScript logs are forwarded to Java through javaMapBridge + * - setTheme(light|dark) aligns the map with the JavaFX application theme + */ +public final class MapHtmlResources { + + private MapHtmlResources() { + } + + public static String createStationMapHtml() { + return """ + + + + + + KST4Contest Station Map + + + + +
+ + + + + + + """; + } +} diff --git a/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java new file mode 100644 index 0000000..a5793ad --- /dev/null +++ b/src/main/java/kst4contest/view/map/NoOpTerrainProfileProvider.java @@ -0,0 +1,12 @@ +package kst4contest.view.map; + +/** + * Placeholder terrain provider. + */ +public final class NoOpTerrainProfileProvider implements TerrainProfileProvider { + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + return TerrainProfileData.empty("No terrain provider"); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/OfflineDemImportService.java b/src/main/java/kst4contest/view/map/OfflineDemImportService.java new file mode 100644 index 0000000..7c886bb --- /dev/null +++ b/src/main/java/kst4contest/view/map/OfflineDemImportService.java @@ -0,0 +1,232 @@ +package kst4contest.view.map; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Small helper service for preparing and filling the local offline DEM directory. + * + *

This is intentionally the first practical step before adding a real network + * downloader: + *

    + *
  • create a known default Copernicus DEM root directory below .praktiKST
  • + *
  • copy manually selected local *_DEM.tif files into that directory
  • + *
+ * + *

The active terrain provider already scans the configured root directory + * recursively. Therefore it is sufficient to import valid Copernicus DGED + * GeoTIFF files into one dedicated folder tree.

+ */ +public final class OfflineDemImportService { + + /** + * Default relative DEM directory below the application's hidden home folder. + */ + public static final String DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY = "dem/copernicus_glo30"; + + /** + * Resolves the default local Copernicus DEM root directory below .praktiKST. + * + * @return default DEM root directory path + */ + public Path resolveDefaultCopernicusRootDirectory() { + return Path.of( + ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY + ) + ); + } + + /** + * Ensures that the default local Copernicus DEM root directory exists. + * + * @return preparation result including the effective target directory + */ + public ImportResult ensureDefaultCopernicusRootDirectory() { + Path targetRootDirectory = resolveDefaultCopernicusRootDirectory(); + return ensureTargetDirectoryExists(targetRootDirectory); + } + + /** + * Imports manually selected Copernicus DGED GeoTIFF tiles into the configured + * DEM root directory. + * + *

If the configured DEM root directory is blank, the default directory + * below .praktiKST is used automatically.

+ * + * @param selectedFiles selected local files from the file chooser + * @param configuredDemRootDirectory current configured DEM root directory text + * @return import result with counts and a user-friendly summary message + */ + public ImportResult importTiles(List selectedFiles, String configuredDemRootDirectory) { + Path targetRootDirectory = resolveEffectiveTargetRootDirectory(configuredDemRootDirectory); + + ImportResult directoryPreparationResult = ensureTargetDirectoryExists(targetRootDirectory); + if (!directoryPreparationResult.success()) { + return directoryPreparationResult; + } + + if (selectedFiles == null || selectedFiles.isEmpty()) { + return new ImportResult( + targetRootDirectory, + true, + 0, + 0, + List.of(), + "No files were selected for import." + ); + } + + int importedFileCount = 0; + int skippedFileCount = 0; + List skippedFilenames = new ArrayList<>(); + + for (File selectedFile : selectedFiles) { + if (selectedFile == null || !selectedFile.isFile() || !selectedFile.canRead()) { + skippedFileCount++; + skippedFilenames.add(selectedFile == null ? "" : selectedFile.getName()); + continue; + } + + if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(selectedFile.getName())) { + skippedFileCount++; + skippedFilenames.add(selectedFile.getName()); + continue; + } + + try { + Files.copy( + selectedFile.toPath(), + targetRootDirectory.resolve(selectedFile.getName()), + StandardCopyOption.REPLACE_EXISTING + ); + importedFileCount++; + } catch (Exception exception) { + skippedFileCount++; + skippedFilenames.add(selectedFile.getName()); + } + } + + StringBuilder message = new StringBuilder(); + message.append(String.format( + Locale.US, + "Imported %d DEM tile(s) into:%n%s", + importedFileCount, + targetRootDirectory.toAbsolutePath() + )); + + if (skippedFileCount > 0) { + message.append(String.format( + Locale.US, + "%n%nSkipped %d file(s) because they were unreadable or did not match the supported Copernicus *_DEM.tif naming scheme.", + skippedFileCount + )); + + int previewCount = Math.min(8, skippedFilenames.size()); + if (previewCount > 0) { + message.append(String.format(Locale.US, "%n%nSkipped examples:%n")); + for (int i = 0; i < previewCount; i++) { + message.append("- ").append(skippedFilenames.get(i)).append(System.lineSeparator()); + } + } + } + + return new ImportResult( + targetRootDirectory, + true, + importedFileCount, + skippedFileCount, + List.copyOf(skippedFilenames), + message.toString().trim() + ); + } + + private Path resolveEffectiveTargetRootDirectory(String configuredDemRootDirectory) { + if (configuredDemRootDirectory == null || configuredDemRootDirectory.isBlank()) { + return resolveDefaultCopernicusRootDirectory(); + } + + return Path.of(configuredDemRootDirectory.trim()); + } + + private ImportResult ensureTargetDirectoryExists(Path targetRootDirectory) { + if (targetRootDirectory == null) { + return new ImportResult( + null, + false, + 0, + 0, + List.of(), + "DEM target directory is undefined." + ); + } + + try { + if (Files.exists(targetRootDirectory) && !Files.isDirectory(targetRootDirectory)) { + return new ImportResult( + targetRootDirectory, + false, + 0, + 0, + List.of(), + "Configured DEM root path exists but is not a directory:\n" + + targetRootDirectory.toAbsolutePath() + ); + } + + Files.createDirectories(targetRootDirectory); + + return new ImportResult( + targetRootDirectory, + true, + 0, + 0, + List.of(), + "Using local Copernicus DEM directory:\n" + + targetRootDirectory.toAbsolutePath() + + "\n\nYou can now import extracted Copernicus *_DEM.tif tiles into this folder." + ); + } catch (Exception exception) { + return new ImportResult( + targetRootDirectory, + false, + 0, + 0, + List.of(), + "Could not create DEM root directory:\n" + + targetRootDirectory.toAbsolutePath() + + "\n\nReason: " + + exception.getMessage() + ); + } + } + + /** + * Immutable result of a DEM directory preparation or tile import action. + * + * @param targetRootDirectory effective DEM root directory + * @param success true if the action succeeded + * @param importedFileCount number of imported files + * @param skippedFileCount number of skipped files + * @param skippedFilenames skipped filenames for diagnostics + * @param message user-friendly summary message + */ + public record ImportResult( + Path targetRootDirectory, + boolean success, + int importedFileCount, + int skippedFileCount, + List skippedFilenames, + String message + ) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/OfflineDemManager.java b/src/main/java/kst4contest/view/map/OfflineDemManager.java new file mode 100644 index 0000000..c5ebea8 --- /dev/null +++ b/src/main/java/kst4contest/view/map/OfflineDemManager.java @@ -0,0 +1,214 @@ +package kst4contest.view.map; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Small manager for offline DEM discovery and indexing. + * + * This class intentionally supports both intermediate API shapes that appeared + * during development: + * + * - old style: + * inspect(...) -> OfflineDemStatus + * + * - new style: + * inspectAndIndex(...) -> OfflineDemIndex + * + * This keeps the current codebase compilable even if older helper/provider + * classes are still present in the source tree. + */ +public final class OfflineDemManager { + + /** + * Matches official DGED DEM filenames like: + * Copernicus_DSM_10_N50_00_E020_00_DEM.tif + */ + private static final Pattern COPERNICUS_GLO_30_DEM_FILE_PATTERN = + Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$"); + + /** + * Returns true if the filename matches the currently supported Copernicus + * GLO-30 DGED GeoTIFF tile naming scheme. + * + *

This helper is intentionally public so that UI/import helpers can + * validate manually selected files before copying them into the DEM root.

+ * + * @param filename candidate filename + * @return true if the filename looks like a supported local DEM tile + */ + public static boolean isSupportedCopernicusGlo30DemFilename(String filename) { + return filename != null + && COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename).matches(); + } + + private String lastIndexedRootDirectory = null; + private DemDataset lastIndexedDataset = null; + private OfflineDemIndex lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured."); + + /** + * Newer API used by the active Copernicus provider. + */ + public synchronized OfflineDemIndex inspectAndIndex(String demRootDirectory, DemDataset dataset) { + DemDataset effectiveDataset = dataset == null ? DemDataset.COPERNICUS_GLO_30 : dataset; + + if (demRootDirectory == null || demRootDirectory.isBlank()) { + lastIndexedRootDirectory = demRootDirectory; + lastIndexedDataset = effectiveDataset; + lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured."); + return lastIndex; + } + + String normalizedRootDirectory = demRootDirectory.trim(); + + if (normalizedRootDirectory.equals(lastIndexedRootDirectory) && effectiveDataset == lastIndexedDataset) { + return lastIndex; + } + + lastIndexedRootDirectory = normalizedRootDirectory; + lastIndexedDataset = effectiveDataset; + lastIndex = buildIndex(normalizedRootDirectory, effectiveDataset); + + return lastIndex; + } + + /** + * Older compatibility API still referenced by older intermediate provider code. + */ + public synchronized OfflineDemStatus inspect(String demRootDirectory, DemDataset dataset) { + OfflineDemIndex index = inspectAndIndex(demRootDirectory, dataset); + + return new OfflineDemStatus( + index.dataset(), + index.rootDirectory(), + demRootDirectory != null && !demRootDirectory.isBlank(), + index.usable(), + index.message() + ); + } + + private OfflineDemIndex buildIndex(String demRootDirectory, DemDataset dataset) { + Path rootPath = Paths.get(demRootDirectory); + + if (!Files.exists(rootPath)) { + return OfflineDemIndex.empty("Configured DEM root directory does not exist."); + } + + if (!Files.isDirectory(rootPath)) { + return OfflineDemIndex.empty("Configured DEM root path is not a directory."); + } + + if (!Files.isReadable(rootPath)) { + return OfflineDemIndex.empty("Configured DEM root directory is not readable."); + } + + Map tilePathByGeocellKey = new LinkedHashMap<>(); + + try (var pathStream = Files.walk(rootPath)) { + pathStream + .filter(Files::isRegularFile) + .forEach(path -> tryRegisterTile(path, tilePathByGeocellKey)); + } catch (IOException exception) { + return OfflineDemIndex.empty("Scanning DEM root directory failed: " + exception.getMessage()); + } + + if (tilePathByGeocellKey.isEmpty()) { + return new OfflineDemIndex( + dataset, + rootPath.toAbsolutePath().toString(), + false, + Collections.emptyMap(), + "No local Copernicus GLO-30 DEM tiles were found below the configured root directory." + ); + } + + return new OfflineDemIndex( + dataset, + rootPath.toAbsolutePath().toString(), + true, + Map.copyOf(tilePathByGeocellKey), + "Found " + tilePathByGeocellKey.size() + " local Copernicus GLO-30 DEM tiles." + ); + } + + private void tryRegisterTile(Path path, Map tilePathByGeocellKey) { + String filename = path.getFileName().toString(); + Matcher matcher = COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename); + + if (!matcher.matches()) { + return; + } + + int southDeg = signedDegrees(matcher.group(1), matcher.group(2), matcher.group(3)); + int westDeg = signedDegrees(matcher.group(4), matcher.group(5), matcher.group(6)); + + tilePathByGeocellKey.put(toGeocellKey(southDeg, westDeg), path.toAbsolutePath()); + } + + private int signedDegrees(String hemisphereOrDirection, String integerPart, String decimalPart) { + int sign = ("S".equalsIgnoreCase(hemisphereOrDirection) || "W".equalsIgnoreCase(hemisphereOrDirection)) ? -1 : 1; + int degrees = Integer.parseInt(integerPart); + + if (!"00".equals(decimalPart)) { + // The current reader expects whole-degree LL-corner geocells. + return sign * degrees; + } + + return sign * degrees; + } + + private String toGeocellKey(int southDeg, int westDeg) { + return southDeg + ":" + westDeg; + } + + /** + * Newer indexed offline DEM state used by the active Copernicus provider. + */ + public record OfflineDemIndex( + DemDataset dataset, + String rootDirectory, + boolean usable, + Map tilePathByGeocellKey, + String message + ) { + + public static OfflineDemIndex empty(String message) { + return new OfflineDemIndex( + DemDataset.COPERNICUS_GLO_30, + "", + false, + Collections.emptyMap(), + message + ); + } + + public Path findTilePath(double latitudeDeg, double longitudeDeg) { + int southDeg = (int) Math.floor(latitudeDeg); + int westDeg = (int) Math.floor(longitudeDeg); + return tilePathByGeocellKey.get(southDeg + ":" + westDeg); + } + + public int availableTileCount() { + return tilePathByGeocellKey.size(); + } + } + + /** + * Older compatibility status shape still referenced by older intermediate code. + */ + public record OfflineDemStatus( + DemDataset dataset, + String demRootDirectory, + boolean configured, + boolean usableRootDirectory, + String message + ) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java new file mode 100644 index 0000000..0d567e2 --- /dev/null +++ b/src/main/java/kst4contest/view/map/OfflineDemTerrainProfileProvider.java @@ -0,0 +1,43 @@ +package kst4contest.view.map; + +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Compatibility offline-first provider. + * + * This class is currently not part of the active StationMap pipeline anymore, + * but it is kept compilable so the source tree stays consistent while the + * Copernicus provider is the primary offline implementation. + */ +public final class OfflineDemTerrainProfileProvider implements TerrainProfileProvider { + + private final Supplier demRootDirectorySupplier; + private final Supplier datasetSupplier; + private final OfflineDemManager offlineDemManager; + + public OfflineDemTerrainProfileProvider(Supplier demRootDirectorySupplier, + Supplier datasetSupplier, + OfflineDemManager offlineDemManager) { + this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier"); + this.datasetSupplier = Objects.requireNonNull(datasetSupplier, "datasetSupplier"); + this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager"); + } + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + DemDataset dataset = datasetSupplier.get(); + if (dataset == null) { + dataset = DemDataset.COPERNICUS_GLO_30; + } + + OfflineDemManager.OfflineDemIndex index = + offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), dataset); + + if (!index.usable()) { + return TerrainProfileData.empty(dataset.displayName() + " offline DEM unavailable"); + } + + return TerrainProfileData.empty(dataset.displayName() + " offline DEM indexed but not used by this compatibility provider"); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java new file mode 100644 index 0000000..114b77a --- /dev/null +++ b/src/main/java/kst4contest/view/map/OpenMeteoTerrainProfileProvider.java @@ -0,0 +1,340 @@ +package kst4contest.view.map; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Terrain provider using the Open-Meteo Elevation API. + * + *

Important API constraint: Open-Meteo accepts up to 100 coordinate pairs per + * elevation request. Earlier KST4Contest profile calculations could ask for up + * to 1201 samples, which forced many HTTP requests for one visible profile. This + * provider intentionally clamps the online profile to one API request. The + * offline Copernicus path can later keep using the denser local sample count.

+ * + *

The provider keeps a small in-memory cache for the running session. This is + * deliberately local and lightweight: it avoids repeated API calls when the user + * re-selects the same station, but it does not replace the existing long-term + * offline DEM/download architecture.

+ */ +public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider { + + private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90"; + private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation"; + + /** + * Open-Meteo elevation requests accept up to 100 coordinate pairs. Keeping the + * online provider at exactly one request per path makes limit behavior + * predictable and avoids request bursts while clicking through stations. + */ + private static final int MAX_COORDINATES_PER_REQUEST = 100; + private static final int MAX_ONLINE_PROFILE_SAMPLE_COUNT = MAX_COORDINATES_PER_REQUEST; + private static final int MIN_ONLINE_PROFILE_SAMPLE_COUNT = 2; + + private static final int MAX_CACHED_PROFILES = 256; + + /** + * The free tier allows many more calls per minute, but this soft limiter keeps + * accidental click/refresh bursts polite and easier to diagnose. + */ + private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250); + + private static final Pattern ELEVATION_ARRAY_PATTERN = + Pattern.compile("\\\"elevation\\\"\\s*:\\s*\\[(.*?)]", Pattern.DOTALL); + + private static final Pattern ERROR_REASON_PATTERN = + Pattern.compile("\\\"reason\\\"\\s*:\\s*\\\"(.*?)\\\"", Pattern.DOTALL); + + private final HttpClient httpClient; + private final Duration requestTimeout; + private final String baseUrl; + + private final Map profileCache = + new LinkedHashMap<>(32, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHED_PROFILES; + } + }; + + private long lastRequestStartEpochMillis = 0L; + + public OpenMeteoTerrainProfileProvider() { + this( + HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(4)) + .build(), + Duration.ofSeconds(8), + DEFAULT_BASE_URL + ); + } + + public OpenMeteoTerrainProfileProvider(HttpClient httpClient, + Duration requestTimeout, + String baseUrl) { + this.httpClient = httpClient; + this.requestTimeout = requestTimeout == null ? Duration.ofSeconds(8) : requestTimeout; + this.baseUrl = (baseUrl == null || baseUrl.isBlank()) ? DEFAULT_BASE_URL : baseUrl.trim(); + } + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) { + return TerrainProfileData.empty(SOURCE_NAME); + } + + int sampleCount = resolveOnlineSampleCount(request.requestedSampleCount()); + RequestCacheKey cacheKey = RequestCacheKey.from(request, sampleCount); + + TerrainProfileData cachedProfile = loadFromCache(cacheKey); + if (cachedProfile != null) { + return cachedProfile; + } + + List samplePoints = buildSamplePoints(request, sampleCount); + TerrainProfileData profileData = fetchProfile(samplePoints); + + if (profileData.hasUsableProfile()) { + saveToCache(cacheKey, profileData); + } + + return profileData; + } + + private int resolveOnlineSampleCount(int requestedSampleCount) { + return Math.max( + MIN_ONLINE_PROFILE_SAMPLE_COUNT, + Math.min(requestedSampleCount, MAX_ONLINE_PROFILE_SAMPLE_COUNT) + ); + } + + private TerrainProfileData loadFromCache(RequestCacheKey cacheKey) { + synchronized (profileCache) { + return profileCache.get(cacheKey); + } + } + + private void saveToCache(RequestCacheKey cacheKey, TerrainProfileData profileData) { + synchronized (profileCache) { + profileCache.put(cacheKey, profileData); + } + } + + private TerrainProfileData fetchProfile(List samplePoints) { + try { + List elevations = fetchElevations(samplePoints); + + if (elevations.size() != samplePoints.size()) { + return TerrainProfileData.empty(SOURCE_NAME + " returned an incomplete elevation array"); + } + + List profilePoints = new ArrayList<>(samplePoints.size()); + + for (int i = 0; i < samplePoints.size(); i++) { + SamplePoint samplePoint = samplePoints.get(i); + double elevationMeters = elevations.get(i); + + if (!Double.isFinite(elevationMeters)) { + return TerrainProfileData.empty(SOURCE_NAME + " returned no-data elevation samples"); + } + + profilePoints.add(new PathProfilePoint( + samplePoint.distanceKm(), + samplePoint.latitudeDeg(), + samplePoint.longitudeDeg(), + elevationMeters + )); + } + + return new TerrainProfileData(profilePoints, SOURCE_NAME, false); + } catch (Exception exception) { + return TerrainProfileData.empty(SOURCE_NAME + " failed: " + buildShortFailureMessage(exception)); + } + } + + private List buildSamplePoints(TerrainProfileRequest request, int sampleCount) { + List points = new ArrayList<>(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1); + + PathGeometryUtils.GeoPoint interpolatedPoint = + PathGeometryUtils.interpolateGreatCirclePoint( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg(), + t + ); + + double latitudeDeg = interpolatedPoint.latitudeDeg(); + double longitudeDeg = interpolatedPoint.longitudeDeg(); + + if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) { + return List.of(); + } + + points.add(new SamplePoint( + request.totalDistanceKm() * t, + latitudeDeg, + longitudeDeg + )); + } + + return points; + } + + private List fetchElevations(List samplePoints) throws Exception { + if (samplePoints == null || samplePoints.isEmpty()) { + return List.of(); + } + + if (samplePoints.size() > MAX_COORDINATES_PER_REQUEST) { + throw new IllegalArgumentException("Open-Meteo request would contain more than 100 coordinates"); + } + + waitForRateLimitSlot(); + + String latitudeParameter = samplePoints.stream() + .map(point -> formatCoordinate(point.latitudeDeg())) + .collect(Collectors.joining(",")); + + String longitudeParameter = samplePoints.stream() + .map(point -> formatCoordinate(point.longitudeDeg())) + .collect(Collectors.joining(",")); + + String requestUrl = baseUrl + + "?latitude=" + latitudeParameter + + "&longitude=" + longitudeParameter; + + HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(requestUrl)) + .timeout(requestTimeout) + .header("Accept", "application/json") + .header("User-Agent", "KST4Contest path-analysis") + .GET() + .build(); + + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() / 100 != 2) { + String reason = extractErrorReason(response.body()); + throw new IOException("HTTP " + response.statusCode() + (reason.isBlank() ? "" : " - " + reason)); + } + + return parseElevationArray(response.body()); + } + + private synchronized void waitForRateLimitSlot() throws InterruptedException { + long now = System.currentTimeMillis(); + long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis); + + if (waitMillis > 0L) { + Thread.sleep(waitMillis); + } + + lastRequestStartEpochMillis = System.currentTimeMillis(); + } + + private List parseElevationArray(String responseBody) throws IOException { + if (responseBody == null || responseBody.isBlank()) { + throw new IOException("Empty response body"); + } + + Matcher elevationArrayMatcher = ELEVATION_ARRAY_PATTERN.matcher(responseBody); + if (!elevationArrayMatcher.find()) { + String reason = extractErrorReason(responseBody); + throw new IOException("No elevation array found" + (reason.isBlank() ? "" : ": " + reason)); + } + + String arrayContent = elevationArrayMatcher.group(1).trim(); + if (arrayContent.isBlank()) { + return List.of(); + } + + String[] parts = arrayContent.split("\\s*,\\s*"); + List elevations = new ArrayList<>(parts.length); + + for (String part : parts) { + String value = part.trim(); + + if (value.isBlank() + || "null".equalsIgnoreCase(value) + || "nan".equalsIgnoreCase(value)) { + elevations.add(Double.NaN); + } else { + elevations.add(Double.parseDouble(value)); + } + } + + return elevations; + } + + private String extractErrorReason(String responseBody) { + Matcher matcher = ERROR_REASON_PATTERN.matcher(responseBody == null ? "" : responseBody); + if (matcher.find()) { + return matcher.group(1); + } + return ""; + } + + private String buildShortFailureMessage(Exception exception) { + if (exception == null) { + return "unknown error"; + } + + Throwable current = exception; + while (current.getCause() != null) { + current = current.getCause(); + } + + String message = current.getMessage(); + if (message == null || message.isBlank()) { + message = exception.getMessage(); + } + + return message == null || message.isBlank() + ? current.getClass().getSimpleName() + : message; + } + + private String formatCoordinate(double value) { + return String.format(Locale.US, "%.6f", value); + } + + private record SamplePoint(double distanceKm, double latitudeDeg, double longitudeDeg) { + } + + private record RequestCacheKey( + String fromLatitudeDeg, + String fromLongitudeDeg, + String toLatitudeDeg, + String toLongitudeDeg, + int sampleCount + ) { + private static RequestCacheKey from(TerrainProfileRequest request, int sampleCount) { + return new RequestCacheKey( + normalizeCoordinate(request.fromLatitudeDeg()), + normalizeCoordinate(request.fromLongitudeDeg()), + normalizeCoordinate(request.toLatitudeDeg()), + normalizeCoordinate(request.toLongitudeDeg()), + sampleCount + ); + } + + private static String normalizeCoordinate(double value) { + return String.format(Locale.US, "%.6f", value); + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathAnalysisRequest.java b/src/main/java/kst4contest/view/map/PathAnalysisRequest.java new file mode 100644 index 0000000..21feb9e --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathAnalysisRequest.java @@ -0,0 +1,102 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Immutable input for one path analysis run. + */ +public record PathAnalysisRequest( + String fromLocator6, + double fromLatitudeDeg, + double fromLongitudeDeg, + String toCallsignRaw, + String toLocator6, + double toLatitudeDeg, + double toLongitudeDeg, + double frequencyMHz, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double effectiveEarthRadiusFactor, + PathLinkBudgetSettings linkBudgetSettings +) { + + public PathAnalysisRequest(String fromLocator6, + double fromLatitudeDeg, + double fromLongitudeDeg, + String toCallsignRaw, + String toLocator6, + double toLatitudeDeg, + double toLongitudeDeg, + double frequencyMHz, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters) { + this( + fromLocator6, + fromLatitudeDeg, + fromLongitudeDeg, + toCallsignRaw, + toLocator6, + toLatitudeDeg, + toLongitudeDeg, + frequencyMHz, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, + PathLinkBudgetSettings.defaults() + ); + } + + public PathAnalysisRequest { + fromLocator6 = normalizeLocator(fromLocator6); + toCallsignRaw = normalizeUpper(toCallsignRaw); + toLocator6 = normalizeLocator(toLocator6); + + if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) { + frequencyMHz = Double.NaN; + } + + if (!Double.isFinite(homeAntennaHeightMeters) || homeAntennaHeightMeters < 0.0) { + homeAntennaHeightMeters = Double.NaN; + } + + if (!Double.isFinite(targetAntennaHeightMeters) || targetAntennaHeightMeters < 0.0) { + targetAntennaHeightMeters = Double.NaN; + } + + PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); + + linkBudgetSettings = linkBudgetSettings == null + ? PathLinkBudgetSettings.defaults() + : linkBudgetSettings; + } + + private static String normalizeLocator(String value) { + if (value == null) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } + + private static String normalizeUpper(String value) { + if (value == null) { + return ""; + } + return value.trim().toUpperCase(Locale.ROOT); + } + + public boolean hasUsableHome() { + return fromLocator6.length() == 6 + && Double.isFinite(fromLatitudeDeg) + && Double.isFinite(fromLongitudeDeg); + } + + public boolean hasUsableTarget() { + return toLocator6.length() == 6 + && Double.isFinite(toLatitudeDeg) + && Double.isFinite(toLongitudeDeg); + } + + public boolean hasUsableFrequency() { + return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathAnalysisResult.java b/src/main/java/kst4contest/view/map/PathAnalysisResult.java new file mode 100644 index 0000000..200b542 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathAnalysisResult.java @@ -0,0 +1,710 @@ +package kst4contest.view.map; + +import java.util.List; +import java.util.Locale; + +/** + * Immutable UI-facing result for one path analysis run. + * + *

This result intentionally combines: + *

    + *
  • basic path metadata used by the detail panel
  • + *
  • the enriched path profile used by the preview chart
  • + *
  • formatted state helpers for concise UI binding
  • + *
+ * + *

The chart and the numeric evaluation both consume the same already + * computed profile data to avoid drift between visual and numeric output.

+ */ +public record PathAnalysisResult( + String analysisMode, + String fromLocator6, + String toLocator6, + String toCallsignRaw, + double distanceKm, + double bearingDeg, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + boolean lineOfSightClear, + boolean fresnelClear, + double minimumLineOfSightClearanceMeters, + double minimumLowerFresnelClearanceMeters, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio, + double worstFresnelDistanceKm, + int worstFresnelSampleIndex, + double effectiveEarthRadiusFactor, + PathHorizonSummary horizonSummary, + PathObstructionSummary obstructionSummary, + PathPropagationAssessment propagationAssessment, + String statusText, + List profilePoints, + PathLinkBudgetSummary linkBudgetSummary +) { + + public PathAnalysisResult { + analysisMode = normalizeText(analysisMode); + fromLocator6 = normalizeUpper(fromLocator6); + toLocator6 = normalizeUpper(toLocator6); + toCallsignRaw = normalizeUpper(toCallsignRaw); + statusText = normalizeText(statusText); + profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints); + effectiveEarthRadiusFactor = + PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); + + horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary; + obstructionSummary = obstructionSummary == null ? PathObstructionSummary.empty() : obstructionSummary; + + propagationAssessment = propagationAssessment == null + ? PathPropagationAssessment.unknown() + : propagationAssessment; + + linkBudgetSummary = linkBudgetSummary == null + ? PathLinkBudgetSummary.empty() + : linkBudgetSummary; + } + + public PathAnalysisResult(String analysisMode, + String fromLocator6, + String toLocator6, + String toCallsignRaw, + double distanceKm, + double bearingDeg, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + boolean lineOfSightClear, + boolean fresnelClear, + double minimumLineOfSightClearanceMeters, + double minimumLowerFresnelClearanceMeters, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio, + double worstFresnelDistanceKm, + int worstFresnelSampleIndex, + String statusText, + List profilePoints) { + this( + analysisMode, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + lineOfSightClear, + fresnelClear, + minimumLineOfSightClearanceMeters, + minimumLowerFresnelClearanceMeters, + worstFresnelIntrusionMeters, + worstFresnelIntrusionRatio, + worstFresnelDistanceKm, + worstFresnelSampleIndex, + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, + PathHorizonSummary.empty(), + PathObstructionSummary.empty(), + PathPropagationAssessment.unknown(), + statusText, + profilePoints, + PathLinkBudgetSummary.empty() + ); + } + + /** + * Creates a placeholder result shown while no target station is selected. + * + * @param fromLocator6 normalized own locator if known + * @return waiting result + */ + public static PathAnalysisResult waitingForSelection(String fromLocator6) { + return new PathAnalysisResult( + "Waiting", + fromLocator6, + "", + "", + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + false, + false, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + "Select a station to prepare path and terrain analysis.", + List.of() + ); + } + + /** + * Creates a placeholder result shown while analysis is currently running. + * + * @param fromLocator6 own locator + * @param toLocator6 target locator if known + * @param toCallsignRaw target callsign if known + * @return loading result + */ + public static PathAnalysisResult loading(String fromLocator6, + String toLocator6, + String toCallsignRaw) { + return new PathAnalysisResult( + "Loading", + 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, + "Loading terrain/profile data and evaluating the path.", + List.of() + ); + } + + /** + * Creates a placeholder result shown when the own locator is not usable. + * + * @param fromLocator6 own locator candidate + * @param toLocator6 target locator candidate + * @return waiting result with home-locator warning + */ + public static PathAnalysisResult waitingForValidHomeLocator(String fromLocator6, + String toLocator6) { + return new PathAnalysisResult( + "Waiting", + fromLocator6, + toLocator6, + "", + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + false, + false, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + "Own locator is missing or invalid. Path analysis cannot start yet.", + List.of() + ); + } + + /** + * Creates a placeholder result shown when the selected target has no usable position. + * + * @param fromLocator6 own locator + * @param toLocator6 target locator candidate + * @return waiting result with target-position warning + */ + public static PathAnalysisResult waitingForValidTarget(String fromLocator6, + String toLocator6) { + return new PathAnalysisResult( + "Waiting", + fromLocator6, + toLocator6, + "", + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + false, + false, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + "Selected station has no usable locator/position for path analysis.", + List.of() + ); + } + + /** + * Creates a finished result without a usable terrain profile. + * + * @param analysisMode source/mode text + * @param fromLocator6 own locator + * @param toLocator6 target locator + * @param toCallsignRaw target callsign + * @param distanceKm path distance in kilometers + * @param bearingDeg initial bearing in degrees + * @param homeAntennaHeightMeters own antenna height in meters AGL + * @param targetAntennaHeightMeters target antenna height in meters AGL + * @param analysisFrequencyMHz analysis frequency in MHz + * @param statusText human-readable status text + * @return finished result without profile points + */ + public static PathAnalysisResult noProfile(String analysisMode, + String fromLocator6, + String toLocator6, + String toCallsignRaw, + double distanceKm, + double bearingDeg, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + String statusText) { + return new PathAnalysisResult( + analysisMode, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + false, + false, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + statusText, + List.of() + ); + } + + /** + * Creates a fully evaluated result. + * + * @param analysisMode source/mode text + * @param fromLocator6 own locator + * @param toLocator6 target locator + * @param toCallsignRaw target callsign + * @param distanceKm path distance in kilometers + * @param bearingDeg initial bearing in degrees + * @param homeAntennaHeightMeters own antenna height in meters AGL + * @param targetAntennaHeightMeters target antenna height in meters AGL + * @param analysisFrequencyMHz analysis frequency in MHz + * @param lineOfSightClear true if the direct path is clear + * @param fresnelClear true if the lower Fresnel hull is not intruded + * @param minimumLineOfSightClearanceMeters minimum LOS clearance in meters + * @param minimumLowerFresnelClearanceMeters minimum lower Fresnel clearance in meters + * @param worstFresnelIntrusionMeters maximum lower Fresnel intrusion in meters + * @param worstFresnelIntrusionRatio intrusion divided by local Fresnel radius + * @param worstFresnelDistanceKm distance of the worst intrusion from TX in kilometers + * @param worstFresnelSampleIndex sample index of the worst intrusion + * @param statusText human-readable status text + * @param profilePoints enriched profile points + * @return completed path analysis result + */ + public static PathAnalysisResult completed(String analysisMode, + String fromLocator6, + String toLocator6, + String toCallsignRaw, + double distanceKm, + double bearingDeg, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + boolean lineOfSightClear, + boolean fresnelClear, + double minimumLineOfSightClearanceMeters, + double minimumLowerFresnelClearanceMeters, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio, + double worstFresnelDistanceKm, + int worstFresnelSampleIndex, + String statusText, + List profilePoints) { + return new PathAnalysisResult( + analysisMode, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + lineOfSightClear, + fresnelClear, + minimumLineOfSightClearanceMeters, + minimumLowerFresnelClearanceMeters, + worstFresnelIntrusionMeters, + worstFresnelIntrusionRatio, + worstFresnelDistanceKm, + worstFresnelSampleIndex, + statusText, + profilePoints + ); + } + + /** + * Creates a fully evaluated result including horizon/refraction metadata. + * + * @return completed path analysis result + */ + public static PathAnalysisResult completed(String analysisMode, + String fromLocator6, + String toLocator6, + String toCallsignRaw, + double distanceKm, + double bearingDeg, + double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + boolean lineOfSightClear, + boolean fresnelClear, + double minimumLineOfSightClearanceMeters, + double minimumLowerFresnelClearanceMeters, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio, + double worstFresnelDistanceKm, + int worstFresnelSampleIndex, + double effectiveEarthRadiusFactor, + PathHorizonSummary horizonSummary, + PathObstructionSummary obstructionSummary, + PathLinkBudgetSummary linkBudgetSummary, + PathPropagationAssessment propagationAssessment, + String statusText, + List profilePoints) { + return new PathAnalysisResult( + analysisMode, + fromLocator6, + toLocator6, + toCallsignRaw, + distanceKm, + bearingDeg, + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + lineOfSightClear, + fresnelClear, + minimumLineOfSightClearanceMeters, + minimumLowerFresnelClearanceMeters, + worstFresnelIntrusionMeters, + worstFresnelIntrusionRatio, + worstFresnelDistanceKm, + worstFresnelSampleIndex, + effectiveEarthRadiusFactor, + horizonSummary, + obstructionSummary, + propagationAssessment, + statusText, + profilePoints, + linkBudgetSummary = linkBudgetSummary == null + ? PathLinkBudgetSummary.empty() + : linkBudgetSummary + ); + } + + /** + * Returns true if the result contains a profile usable for charting/evaluation. + * + * @return true if at least two profile points are available + */ + public boolean hasUsableProfile() { + return profilePoints.size() >= 2; + } + + /** + * Returns the dominant obstruction / diffraction candidate text. + * + * @return obstruction summary text + */ + public String obstructionText() { + return obstructionSummary == null + ? "-" + : obstructionSummary.obstructionText(); + } + + /** + * Returns a short UI text for direct LOS state. + * + * @return LOS summary text + */ + public String losText() { + if (!hasUsableProfile()) { + return "-"; + } + + return lineOfSightClear + ? String.format(Locale.US, "Geometric LOS clear (min %+,.1f m)", minimumLineOfSightClearanceMeters) + : String.format(Locale.US, "Geometric LOS blocked (min %+,.1f m)", minimumLineOfSightClearanceMeters); + } + + /** + * Returns a short UI text for the minimum LOS clearance. + * + * @return worst direct-path clearance text + */ + public String worstClearanceText() { + if (!hasUsableProfile() || !Double.isFinite(minimumLineOfSightClearanceMeters)) { + return "-"; + } + return String.format(Locale.US, "%.1f m", minimumLineOfSightClearanceMeters); + } + + /** + * Returns a short UI text for the analysis frequency. + * + * @return frequency text or "-" + */ + public String analysisFrequencyText() { + if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) { + return "-"; + } + return String.format(Locale.US, "%.3f MHz", analysisFrequencyMHz); + } + + /** + * Returns a short UI text for the Fresnel state. + * + * @return Fresnel summary text + */ + public String fresnelText() { + if (!hasUsableProfile()) { + return "-"; + } + + if (fresnelClear) { + return String.format( + Locale.US, + "1st Fresnel clear (min %+,.1f m)", + minimumLowerFresnelClearanceMeters + ); + } + + return String.format( + Locale.US, + "1st Fresnel intruded (worst %.1f m)", + worstFresnelIntrusionMeters + ); + } + + /** + * Returns a short UI text for the worst Fresnel intrusion. + * + * @return worst Fresnel intrusion text + */ + public String worstFresnelClearanceText() { + if (!hasUsableProfile()) { + return "-"; + } + + if (Double.isFinite(worstFresnelIntrusionMeters) && worstFresnelIntrusionMeters > 0.0) { + return String.format( + Locale.US, + "%.1f m @ %.1f km (%.0f%%)", + worstFresnelIntrusionMeters, + worstFresnelDistanceKm, + worstFresnelIntrusionRatio * 100.0 + ); + } + + if (Double.isFinite(minimumLowerFresnelClearanceMeters)) { + return String.format( + Locale.US, + "No intrusion (min %.1f m)", + minimumLowerFresnelClearanceMeters + ); + } + + return "-"; + } + + + /** + * Returns the effective Earth radius / refraction model text used by this + * analysis result. + * + * @return k-factor text + */ + public String effectiveEarthRadiusText() { + return horizonSummary == null + ? "-" + : horizonSummary.effectiveEarthRadiusText(); + } + + /** + * Returns a concise simple radio-horizon summary for both endpoint antenna + * heights. + * + * @return radio-horizon summary + */ + public String radioHorizonText() { + return horizonSummary == null + ? "-" + : horizonSummary.simpleRadioHorizonText(); + } + + /** + * Returns the actual terrain horizon derived from the current terrain profile. + * + * @return terrain-horizon summary + */ + public String terrainHorizonText() { + return horizonSummary == null + ? "-" + : horizonSummary.terrainHorizonText(); + } + + /** + * Returns the distance as formatted text. + * + * @return distance text or "-" + */ + public String distanceText() { + if (!Double.isFinite(distanceKm) || distanceKm < 0.0) { + return "-"; + } + return String.format(Locale.US, "%.1f km", distanceKm); + } + + /** + * Returns the initial path bearing as formatted text. + * + * @return bearing text or "-" + */ + public String bearingText() { + if (!Double.isFinite(bearingDeg)) { + return "-"; + } + return String.format(Locale.US, "%.0f°", bearingDeg); + } + + /** + * Returns a concise endpoint summary with ground level and configured antenna heights. + * + * @return endpoint/antenna summary text + */ + public String endpointSummaryText() { + PathProfilePoint firstPoint = firstProfilePoint(); + PathProfilePoint lastPoint = lastProfilePoint(); + + boolean hasHomeGround = firstPoint != null && Double.isFinite(firstPoint.elevationMeters()); + boolean hasTargetGround = lastPoint != null && Double.isFinite(lastPoint.elevationMeters()); + + boolean hasHomeAntenna = Double.isFinite(homeAntennaHeightMeters); + boolean hasTargetAntenna = Double.isFinite(targetAntennaHeightMeters); + + String homeText; + if (hasHomeGround && hasHomeAntenna) { + homeText = String.format( + Locale.US, + "Home %.0f m ASL + %.0f m AGL", + firstPoint.elevationMeters(), + homeAntennaHeightMeters + ); + } else if (hasHomeAntenna) { + homeText = String.format(Locale.US, "Home +%.0f m AGL", homeAntennaHeightMeters); + } else { + homeText = "Home -"; + } + + String targetText; + if (hasTargetGround && hasTargetAntenna) { + targetText = String.format( + Locale.US, + "DX %.0f m ASL + %.0f m AGL", + lastPoint.elevationMeters(), + targetAntennaHeightMeters + ); + } else if (hasTargetAntenna) { + targetText = String.format(Locale.US, "DX +%.0f m AGL", targetAntennaHeightMeters); + } else { + targetText = "DX -"; + } + + return homeText + " | " + targetText; + } + + + /** + * Returns the operator-facing propagation assessment. + * + * @return short propagation assessment text + */ + public String propagationAssessmentText() { + return propagationAssessment == null + ? "-" + : propagationAssessment.shortText(); + } + + /** + * Returns the likely propagation mechanisms suggested by the current assessment. + * + * @return mechanism summary + */ + public String propagationMechanismsText() { + return propagationAssessment == null + ? "-" + : propagationAssessment.likelyMechanisms(); + } + + /** + * Returns a numeric severity level from 0 to 5. + * + * @return severity level + */ + public int propagationSeverityLevel() { + return propagationAssessment == null + ? 0 + : propagationAssessment.severityLevel(); + } + + private PathProfilePoint firstProfilePoint() { + return profilePoints.isEmpty() ? null : profilePoints.get(0); + } + + private PathProfilePoint lastProfilePoint() { + return profilePoints.isEmpty() ? null : profilePoints.get(profilePoints.size() - 1); + } + + private static String normalizeText(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + public String linkBudgetText() { + return linkBudgetSummary == null + ? "-" + : linkBudgetSummary.ssbMarginText(); + } + + public String linkBudgetRxPowerText() { + return linkBudgetSummary == null + ? "-" + : linkBudgetSummary.rxPowerText(); + } + + public String linkBudgetDetailText() { + return linkBudgetSummary == null + ? "-" + : linkBudgetSummary.linkBudgetDetailText(); + } + + public String cwHintText() { + return linkBudgetSummary == null + ? "-" + : linkBudgetSummary.cwHintText(); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathAnalysisService.java b/src/main/java/kst4contest/view/map/PathAnalysisService.java new file mode 100644 index 0000000..e36c535 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathAnalysisService.java @@ -0,0 +1,12 @@ +package kst4contest.view.map; + +/** + * Abstraction for path analysis. + * + * First implementation can be geometry-only. + * Later this can call terrain APIs, caching layers, LOS checks, etc. + */ +public interface PathAnalysisService { + + PathAnalysisResult analyze(PathAnalysisRequest request); +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathGeometryUtils.java b/src/main/java/kst4contest/view/map/PathGeometryUtils.java new file mode 100644 index 0000000..3888cd8 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathGeometryUtils.java @@ -0,0 +1,698 @@ +package kst4contest.view.map; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Shared helper methods for path geometry and radio-path calculations. + * + *

This class intentionally centralizes: + *

    + *
  • great-circle geometry
  • + *
  • Earth curvature calculations
  • + *
  • Fresnel calculations
  • + *
  • adaptive profile sampling heuristics
  • + *
  • default/fallback frequency handling
  • + *
  • tolerant frequency parsing from UI/chat strings
  • + *
+ */ +public final class PathGeometryUtils { + + private static final double EARTH_RADIUS_METERS = 6_371_009.0; + private static final double EARTH_RADIUS_KM = EARTH_RADIUS_METERS / 1000.0; + private static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0; + + /** + * Default effective Earth radius factor used for VHF/UHF path geometry. + * + *

k = 4/3 is the common standard-atmosphere approximation. It bends the + * radio path slightly with the atmosphere and therefore reduces the apparent + * Earth bulge compared with pure optical geometry.

+ * + *

This is still only a geometric/refraction approximation. It does not model + * troposcatter, aircraft scatter, ducting or diffraction loss numerically.

+ */ + public static final double DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR = 4.0 / 3.0; + + /** + * Central fallback frequency for path analysis when no station frequency + * could be extracted from the chat member / aggregated marker data. + * + * Easy to change later if users mainly work on another band. + */ + public static final double DEFAULT_ANALYSIS_FREQUENCY_MHZ = 144.0; + + /** + * Common practical recommendation: at least 60% of the first Fresnel zone + * should remain clear. + */ + public static final double DEFAULT_FRESNEL_CLEARANCE_FACTOR = 0.60; + + /** + * Current sampling heuristic for terrain/profile analysis. + * + *

The active goal is to sample approximately every 0.5 km while + * keeping the UI responsive. A later batch/service variant can use + * different limits.

+ */ + public static final double DEFAULT_TARGET_SAMPLE_STEP_KM = 0.5; + public static final int MIN_PROFILE_SAMPLE_COUNT = 121; + public static final int MAX_PROFILE_SAMPLE_COUNT = 1201; + + private static final Pattern FREQUENCY_MHZ_PATTERN = + Pattern.compile("(?i)(\\d{2,6}(?:[\\.,]\\d+)?)\\s*(?:mhz)?"); + + private PathGeometryUtils() { + } + + /** + * Great-circle distance in kilometers using the haversine formula. + * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @return great-circle distance in kilometers + */ + public static double calculateGreatCircleDistanceKm(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg) { + + if (!Double.isFinite(fromLatitudeDeg) + || !Double.isFinite(fromLongitudeDeg) + || !Double.isFinite(toLatitudeDeg) + || !Double.isFinite(toLongitudeDeg)) { + return Double.NaN; + } + + double fromLatitudeRad = Math.toRadians(fromLatitudeDeg); + double fromLongitudeRad = Math.toRadians(fromLongitudeDeg); + double toLatitudeRad = Math.toRadians(toLatitudeDeg); + double toLongitudeRad = Math.toRadians(toLongitudeDeg); + + double deltaLatitude = toLatitudeRad - fromLatitudeRad; + double deltaLongitude = toLongitudeRad - fromLongitudeRad; + + double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0) + + Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad) + * Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0); + + double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a))); + + return EARTH_RADIUS_KM * c; + } + + /** + * Initial great-circle bearing in degrees from north. + * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @return initial bearing in degrees within [0, 360) + */ + public static double calculateInitialBearingDeg(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg) { + + if (!Double.isFinite(fromLatitudeDeg) + || !Double.isFinite(fromLongitudeDeg) + || !Double.isFinite(toLatitudeDeg) + || !Double.isFinite(toLongitudeDeg)) { + return Double.NaN; + } + + double fromLatitudeRad = Math.toRadians(fromLatitudeDeg); + double toLatitudeRad = Math.toRadians(toLatitudeDeg); + double deltaLongitudeRad = Math.toRadians(toLongitudeDeg - fromLongitudeDeg); + + double y = Math.sin(deltaLongitudeRad) * Math.cos(toLatitudeRad); + double x = Math.cos(fromLatitudeRad) * Math.sin(toLatitudeRad) + - Math.sin(fromLatitudeRad) * Math.cos(toLatitudeRad) * Math.cos(deltaLongitudeRad); + + double bearingDeg = Math.toDegrees(Math.atan2(y, x)); + + return normalizeBearingDeg(bearingDeg); + } + + /** + * Interpolates one point on the great-circle path between two endpoints. + * + *

This uses spherical linear interpolation (slerp) on the unit sphere. + * It avoids the path distortion that appears when latitude and longitude + * are interpolated independently.

+ * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @param t interpolation factor in [0, 1] + * @return interpolated great-circle point + */ + public static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg, + double t) { + + if (!Double.isFinite(fromLatitudeDeg) + || !Double.isFinite(fromLongitudeDeg) + || !Double.isFinite(toLatitudeDeg) + || !Double.isFinite(toLongitudeDeg) + || !Double.isFinite(t)) { + return new GeoPoint(Double.NaN, Double.NaN); + } + + double clampedT = clamp(t, 0.0, 1.0); + + if (clampedT <= 0.0) { + return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg)); + } + + if (clampedT >= 1.0) { + return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg)); + } + + double fromLatitudeRad = Math.toRadians(fromLatitudeDeg); + double fromLongitudeRad = Math.toRadians(fromLongitudeDeg); + double toLatitudeRad = Math.toRadians(toLatitudeDeg); + double toLongitudeRad = Math.toRadians(toLongitudeDeg); + + double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad); + double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad); + double z1 = Math.sin(fromLatitudeRad); + + double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad); + double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad); + double z2 = Math.sin(toLatitudeRad); + + double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0); + double omega = Math.acos(dot); + + if (omega < 1e-12) { + double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT; + double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT); + return new GeoPoint(latitudeDeg, longitudeDeg); + } + + double sinOmega = Math.sin(omega); + double a = Math.sin((1.0 - clampedT) * omega) / sinOmega; + double b = Math.sin(clampedT * omega) / sinOmega; + + double x = a * x1 + b * x2; + double y = a * y1 + b * y2; + double z = a * z1 + b * z2; + + double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y)); + double longitudeRad = Math.atan2(y, x); + + return new GeoPoint( + Math.toDegrees(latitudeRad), + normalizeLongitudeDeg(Math.toDegrees(longitudeRad)) + ); + } + + /** + * Resolves an adaptive terrain/profile sample count from the full path distance. + * + *

Current heuristic: + *

    + *
  • target spacing about 0.5 km
  • + *
  • minimum 121 samples
  • + *
  • maximum 1201 samples
  • + *
+ * + * @param totalDistanceKm full path distance in kilometers + * @return clamped sample count + */ + public static int resolveAdaptiveSampleCount(double totalDistanceKm) { + if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { + return MIN_PROFILE_SAMPLE_COUNT; + } + + int computedSampleCount = (int) Math.ceil(totalDistanceKm / DEFAULT_TARGET_SAMPLE_STEP_KM) + 1; + + return clampInt(computedSampleCount, MIN_PROFILE_SAMPLE_COUNT, MAX_PROFILE_SAMPLE_COUNT); + } + + /** + * Returns the Earth bulge above the straight endpoint chord at one point + * along the path. + * + *

Distances are given along the path in kilometers.

+ * + *

Approximation: + * bulge = d1 * d2 / (2 * R_eff)

+ */ + public static double calculateEarthBulgeMeters(double distanceFromStartKm, + double totalDistanceKm) { + return calculateEarthBulgeMeters( + distanceFromStartKm, + totalDistanceKm, + DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR + ); + } + + public static double calculateEarthBulgeMeters(double distanceFromStartKm, + double totalDistanceKm, + double effectiveEarthRadiusFactor) { + + if (!Double.isFinite(distanceFromStartKm) + || !Double.isFinite(totalDistanceKm) + || !Double.isFinite(effectiveEarthRadiusFactor) + || totalDistanceKm <= 0.0 + || effectiveEarthRadiusFactor <= 0.0) { + return 0.0; + } + + double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm)); + double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm; + + double distanceFromStartMeters = clampedDistanceFromStartKm * 1000.0; + double distanceToTargetMeters = distanceToTargetKm * 1000.0; + double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * effectiveEarthRadiusFactor; + + return (distanceFromStartMeters * distanceToTargetMeters) / (2.0 * effectiveEarthRadiusMeters); + } + + /** + * Returns terrain height plus Earth curvature using the default effective Earth + * radius factor. + * + * @param point terrain/profile point + * @param totalDistanceKm full path distance in kilometers + * @return curvature-adjusted terrain height in meters + */ + public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point, + double totalDistanceKm) { + return calculateCurvatureAdjustedElevationMeters( + point, + totalDistanceKm, + DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR + ); + } + + /** + * Returns terrain height plus Earth curvature using the given effective Earth + * radius factor. + * + *

k = 1.0 means optical/geometric Earth curvature. k = 4/3 is the common + * standard radio-refraction approximation for VHF/UHF path previews.

+ * + * @param point terrain/profile point + * @param totalDistanceKm full path distance in kilometers + * @param effectiveEarthRadiusFactor k-factor for effective Earth radius + * @return curvature-adjusted terrain height in meters + */ + public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point, + double totalDistanceKm, + double effectiveEarthRadiusFactor) { + if (point == null || !Double.isFinite(point.elevationMeters())) { + return Double.NaN; + } + + return point.elevationMeters() + + calculateEarthBulgeMeters( + point.distanceKm(), + totalDistanceKm, + sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor) + ); + } + + /** + * Calculates the simple radio horizon distance from antenna height above ground. + * + *

The result is based on the effective Earth radius model: + * d = sqrt(2 * R_eff * h)

+ * + *

This is a local tangent-horizon approximation. It is useful as an operator + * hint, but it is not a complete propagation prediction.

+ * + * @param antennaHeightMeters antenna height above local ground in meters + * @param effectiveEarthRadiusFactor k-factor for effective Earth radius + * @return radio horizon distance in kilometers + */ + public static double calculateRadioHorizonDistanceKm(double antennaHeightMeters, + double effectiveEarthRadiusFactor) { + if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) { + return Double.NaN; + } + + double sanitizedFactor = sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); + double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * sanitizedFactor; + + return Math.sqrt(2.0 * effectiveEarthRadiusMeters * antennaHeightMeters) / 1000.0; + } + + /** + * Calculates the apparent elevation angle from an observer height to a target + * height over a given distance. + * + * @param observerHeightMeters observer height in the already curvature-adjusted profile space + * @param targetHeightMeters target height in the same profile space + * @param distanceKm distance between observer and target in kilometers + * @return elevation angle in degrees + */ + public static double calculateElevationAngleDeg(double observerHeightMeters, + double targetHeightMeters, + double distanceKm) { + if (!Double.isFinite(observerHeightMeters) + || !Double.isFinite(targetHeightMeters) + || !Double.isFinite(distanceKm) + || distanceKm <= 0.0) { + return Double.NaN; + } + + double distanceMeters = distanceKm * 1000.0; + return Math.toDegrees(Math.atan2(targetHeightMeters - observerHeightMeters, distanceMeters)); + } + + + /** + * Sanitizes the effective Earth radius factor. + * + *

Values outside a practical range are folded back to the standard default + * so broken preferences or malformed future XML values cannot destabilize path + * analysis.

+ * + * @param effectiveEarthRadiusFactor raw k-factor + * @return usable k-factor + */ + public static double sanitizeEffectiveEarthRadiusFactor(double effectiveEarthRadiusFactor) { + if (!Double.isFinite(effectiveEarthRadiusFactor) + || effectiveEarthRadiusFactor < 0.5 + || effectiveEarthRadiusFactor > 10.0) { + return DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR; + } + + return effectiveEarthRadiusFactor; + } + + /** + * Converts a frequency in MHz to wavelength in meters. + * + * @param frequencyMHz signal frequency in MHz + * @return wavelength in meters + */ + public static double calculateWavelengthMeters(double frequencyMHz) { + if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) { + return Double.NaN; + } + return SPEED_OF_LIGHT_METERS_PER_SECOND / (frequencyMHz * 1_000_000.0); + } + + /** + * First Fresnel zone radius at one point along the path. + * + * @param distanceFromStartKm distance from TX in kilometers + * @param totalDistanceKm total path distance in kilometers + * @param frequencyMHz signal frequency in MHz + * @return first Fresnel radius in meters + */ + public static double calculateFirstFresnelRadiusMeters(double distanceFromStartKm, + double totalDistanceKm, + double frequencyMHz) { + + if (!Double.isFinite(distanceFromStartKm) + || !Double.isFinite(totalDistanceKm) + || !Double.isFinite(frequencyMHz) + || totalDistanceKm <= 0.0 + || frequencyMHz <= 0.0) { + return Double.NaN; + } + + double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm)); + double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm; + + double d1Meters = clampedDistanceFromStartKm * 1000.0; + double d2Meters = distanceToTargetKm * 1000.0; + + if (d1Meters <= 0.0 || d2Meters <= 0.0) { + return 0.0; + } + + double wavelengthMeters = calculateWavelengthMeters(frequencyMHz); + if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) { + return Double.NaN; + } + + return Math.sqrt((wavelengthMeters * d1Meters * d2Meters) / (d1Meters + d2Meters)); + } + + /** + * Recommended minimum clearance, currently 60% of the first Fresnel zone. + * + * @param distanceFromStartKm distance from TX in kilometers + * @param totalDistanceKm total path distance in kilometers + * @param frequencyMHz signal frequency in MHz + * @return recommended clearance in meters + */ + public static double calculateRecommendedFresnelClearanceMeters(double distanceFromStartKm, + double totalDistanceKm, + double frequencyMHz) { + + double firstFresnelRadiusMeters = + calculateFirstFresnelRadiusMeters(distanceFromStartKm, totalDistanceKm, frequencyMHz); + + if (!Double.isFinite(firstFresnelRadiusMeters)) { + return Double.NaN; + } + + return firstFresnelRadiusMeters * DEFAULT_FRESNEL_CLEARANCE_FACTOR; + } + + /** + * Tries to extract a MHz frequency from strings such as: + *
    + *
  • "144.300"
  • + *
  • "144,300"
  • + *
  • "144.300 MHz"
  • + *
  • "QRG 432.174 MHz"
  • + *
+ * + * @param rawText free-form raw text + * @return parsed frequency in MHz or NaN + */ + public static double tryParseFrequencyMHz(String rawText) { + if (rawText == null || rawText.isBlank()) { + return Double.NaN; + } + + Matcher matcher = FREQUENCY_MHZ_PATTERN.matcher(rawText.trim()); + if (!matcher.find()) { + return Double.NaN; + } + + String numericText = matcher.group(1).replace(',', '.'); + + try { + double parsedFrequencyMHz = Double.parseDouble(numericText); + return parsedFrequencyMHz > 0.0 ? parsedFrequencyMHz : Double.NaN; + } catch (NumberFormatException ignored) { + return Double.NaN; + } + } + + /** + * Resolves one usable analysis frequency from aggregated marker frequency data. + * + *

Strategy: + *

    + *
  1. Prefer 144 MHz data if available
  2. + *
  3. Otherwise use the first parsable known station frequency
  4. + *
  5. Otherwise use the central default frequency
  6. + *
+ * + * @param frequenciesByBand known frequencies grouped by band + * @return resolved analysis frequency in MHz + */ + public static double resolveAnalysisFrequencyMHz(Map frequenciesByBand) { + if (frequenciesByBand != null && !frequenciesByBand.isEmpty()) { + String band144Text = frequenciesByBand.get("144"); + double parsed144 = tryParseFrequencyMHz(band144Text); + if (Double.isFinite(parsed144) && parsed144 > 0.0) { + return parsed144; + } + + for (String value : frequenciesByBand.values()) { + double parsedFrequencyMHz = tryParseFrequencyMHz(value); + if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) { + return parsedFrequencyMHz; + } + } + } + + return DEFAULT_ANALYSIS_FREQUENCY_MHZ; + } + + /** + * Small immutable geographic point used by great-circle interpolation. + * + * @param latitudeDeg latitude in degrees + * @param longitudeDeg longitude in degrees + */ + public record GeoPoint(double latitudeDeg, double longitudeDeg) { + } + + private static double normalizeLongitudeDeg(double longitudeDeg) { + if (!Double.isFinite(longitudeDeg)) { + return Double.NaN; + } + + double normalized = longitudeDeg % 360.0; + if (normalized > 180.0) { + normalized -= 360.0; + } else if (normalized <= -180.0) { + normalized += 360.0; + } + return normalized; + } + + private static double normalizeBearingDeg(double bearingDeg) { + if (!Double.isFinite(bearingDeg)) { + return Double.NaN; + } + + double normalized = bearingDeg % 360.0; + if (normalized < 0.0) { + normalized += 360.0; + } + return normalized; + } + + private static double clamp(double value, double minValue, double maxValue) { + return Math.max(minValue, Math.min(maxValue, value)); + } + + private static int clampInt(int value, int minValue, int maxValue) { + return Math.max(minValue, Math.min(maxValue, value)); + } + + + /** + * Calculates the Fresnel-Kirchhoff diffraction parameter v for a single + * obstruction. + * + *

Positive height means the obstruction is above the direct line of sight. + * This method is useful for a rough single-knife-edge severity estimate.

+ * + * @param heightAboveLosMeters obstruction height above direct LOS in meters + * @param distanceFromHomeKm distance from home endpoint to obstruction + * @param distanceFromTargetKm distance from target endpoint to obstruction + * @param frequencyMHz analysis frequency in MHz + * @return diffraction parameter v + */ + public static double calculateKnifeEdgeVParameter(double heightAboveLosMeters, + double distanceFromHomeKm, + double distanceFromTargetKm, + double frequencyMHz) { + if (!Double.isFinite(heightAboveLosMeters) + || !Double.isFinite(distanceFromHomeKm) + || !Double.isFinite(distanceFromTargetKm) + || distanceFromHomeKm <= 0.0 + || distanceFromTargetKm <= 0.0) { + return Double.NaN; + } + + double wavelengthMeters = calculateWavelengthMeters(frequencyMHz); + if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) { + return Double.NaN; + } + + double d1Meters = distanceFromHomeKm * 1000.0; + double d2Meters = distanceFromTargetKm * 1000.0; + + return heightAboveLosMeters + * Math.sqrt(2.0 * (d1Meters + d2Meters) / (wavelengthMeters * d1Meters * d2Meters)); + } + + /** + * Estimates the additional diffraction loss for a single knife edge. + * + *

This is a rough operator-facing indicator. It must not be interpreted as a + * complete path-loss model.

+ * + * @param v Fresnel-Kirchhoff diffraction parameter + * @return estimated additional loss in dB + */ + public static double calculateSingleKnifeEdgeLossDb(double v) { + if (!Double.isFinite(v)) { + return Double.NaN; + } + + if (v <= -0.78) { + return 0.0; + } + + return 6.9 + 20.0 * Math.log10( + Math.sqrt(Math.pow(v - 0.1, 2.0) + 1.0) + v - 0.1 + ); + } + + /** + * Converts RF power from watts to dBm. + * + * @param watts power in watts + * @return power in dBm + */ + public static double wattsToDbm(double watts) { + if (!Double.isFinite(watts) || watts <= 0.0) { + return Double.NaN; + } + + return 10.0 * Math.log10(watts * 1000.0); + } + + /** + * Calculates free-space path loss. + * + * @param distanceKm path distance in kilometers + * @param frequencyMHz frequency in MHz + * @return free-space path loss in dB + */ + public static double calculateFreeSpacePathLossDb(double distanceKm, double frequencyMHz) { + if (!Double.isFinite(distanceKm) + || !Double.isFinite(frequencyMHz) + || distanceKm <= 0.0 + || frequencyMHz <= 0.0) { + return Double.NaN; + } + + return 32.44 + 20.0 * Math.log10(distanceKm) + 20.0 * Math.log10(frequencyMHz); + } + + /** + * Estimates per-station feeder loss from a simple VHF baseline and a frequency + * dependent increase. + * + *

The result is capped because a linear MHz-based feeder heuristic becomes + * unrealistic on microwave bands where transverters are often placed near the + * antenna. Later this should become a per-band user setting.

+ * + * @param frequencyMHz frequency in MHz + * @param settings link-budget settings + * @return estimated per-station feeder loss in dB + */ + public static double estimateFeederLossPerStationDb(double frequencyMHz, + PathLinkBudgetSettings settings) { + PathLinkBudgetSettings safeSettings = settings == null + ? PathLinkBudgetSettings.defaults() + : settings; + + double baseLossDb = safeSettings.vhfFeederLossPerStationDb(); + + if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) { + return baseLossDb; + } + + double additionalLossDb = Math.max(0.0, (frequencyMHz - 144.0) / 200.0) + * safeSettings.feederLossIncreaseDbPer200MHz(); + + double estimatedLossDb = baseLossDb + additionalLossDb; + + return Math.min(estimatedLossDb, safeSettings.maxEstimatedFeederLossPerStationDb()); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathHorizonSummary.java b/src/main/java/kst4contest/view/map/PathHorizonSummary.java new file mode 100644 index 0000000..a9c5934 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathHorizonSummary.java @@ -0,0 +1,108 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Immutable summary of simple radio-horizon and terrain-profile horizon data. + * + *

The simple radio horizon is based only on antenna height and effective + * Earth radius. The terrain horizon is derived from the actual path profile and + * therefore represents the highest apparent terrain angle from each endpoint.

+ */ +public record PathHorizonSummary( + double effectiveEarthRadiusFactor, + + double homeSimpleRadioHorizonKm, + double targetSimpleRadioHorizonKm, + double combinedSimpleRadioHorizonKm, + + double homeTerrainHorizonPathDistanceKm, + double homeTerrainHorizonElevationAngleDeg, + int homeTerrainHorizonSampleIndex, + + double targetTerrainHorizonPathDistanceKm, + double targetTerrainHorizonDistanceFromTargetKm, + double targetTerrainHorizonElevationAngleDeg, + int targetTerrainHorizonSampleIndex +) { + + public PathHorizonSummary { + effectiveEarthRadiusFactor = + PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); + } + + public static PathHorizonSummary empty() { + return new PathHorizonSummary( + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + Double.NaN, + Double.NaN, + Double.NaN, + -1 + ); + } + + public boolean hasHomeTerrainHorizon() { + return homeTerrainHorizonSampleIndex >= 0 + && Double.isFinite(homeTerrainHorizonPathDistanceKm) + && Double.isFinite(homeTerrainHorizonElevationAngleDeg); + } + + public boolean hasTargetTerrainHorizon() { + return targetTerrainHorizonSampleIndex >= 0 + && Double.isFinite(targetTerrainHorizonPathDistanceKm) + && Double.isFinite(targetTerrainHorizonDistanceFromTargetKm) + && Double.isFinite(targetTerrainHorizonElevationAngleDeg); + } + + public String effectiveEarthRadiusText() { + return String.format( + Locale.US, + "k = %.2f effective Earth radius", + effectiveEarthRadiusFactor + ); + } + + public String simpleRadioHorizonText() { + if (!Double.isFinite(homeSimpleRadioHorizonKm) + || !Double.isFinite(targetSimpleRadioHorizonKm) + || !Double.isFinite(combinedSimpleRadioHorizonKm)) { + return "-"; + } + + return String.format( + Locale.US, + "Home %.1f km | DX %.1f km | combined %.1f km", + homeSimpleRadioHorizonKm, + targetSimpleRadioHorizonKm, + combinedSimpleRadioHorizonKm + ); + } + + public String terrainHorizonText() { + String homeText = hasHomeTerrainHorizon() + ? String.format( + Locale.US, + "Home %.1f km / %+.2f°", + homeTerrainHorizonPathDistanceKm, + homeTerrainHorizonElevationAngleDeg + ) + : "Home -"; + + String targetText = hasTargetTerrainHorizon() + ? String.format( + Locale.US, + "DX %.1f km from DX / %+.2f°", + targetTerrainHorizonDistanceFromTargetKm, + targetTerrainHorizonElevationAngleDeg + ) + : "DX -"; + + return homeText + " | " + targetText; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java b/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java new file mode 100644 index 0000000..b91a6c4 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathLinkBudgetSettings.java @@ -0,0 +1,64 @@ +package kst4contest.view.map; + +/** + * User/configuration values for the path link-budget estimate. + * + *

All antenna gains are dBi. If the user knows dBd values, add 2.15 dB + * before entering them as dBi. Example: 12 dBd = 14.15 dBi.

+ */ +public record PathLinkBudgetSettings( + double ownTxPowerWatts, + double ownAntennaGainDbi, + double targetTxPowerWatts, + double targetAntennaGainDbi, + double vhfFeederLossPerStationDb, + double feederLossIncreaseDbPer200MHz, + double maxEstimatedFeederLossPerStationDb, + double requiredSsbSignalDbm, + double requiredCwSignalDbm, + double contestMarginDb +) { + + public static PathLinkBudgetSettings defaults() { + return new PathLinkBudgetSettings( + 750.0, + 8.0, + 100.0, + 8.0, + 2.0, + 2.0, + 20.0, + -126.0, + -132.0, + 6.0 + ); + } + + public PathLinkBudgetSettings { + ownTxPowerWatts = sanitizePositive(ownTxPowerWatts, 750.0); + ownAntennaGainDbi = sanitizeFinite(ownAntennaGainDbi, 8.0); + + targetTxPowerWatts = sanitizePositive(targetTxPowerWatts, 100.0); + targetAntennaGainDbi = sanitizeFinite(targetAntennaGainDbi, 8.0); + + vhfFeederLossPerStationDb = sanitizeNonNegative(vhfFeederLossPerStationDb, 2.0); + feederLossIncreaseDbPer200MHz = sanitizeNonNegative(feederLossIncreaseDbPer200MHz, 2.0); + maxEstimatedFeederLossPerStationDb = sanitizeNonNegative(maxEstimatedFeederLossPerStationDb, 20.0); + + requiredSsbSignalDbm = sanitizeFinite(requiredSsbSignalDbm, -126.0); + requiredCwSignalDbm = sanitizeFinite(requiredCwSignalDbm, -132.0); + contestMarginDb = sanitizeNonNegative(contestMarginDb, 6.0); + } + + private static double sanitizePositive(double value, double fallback) { + return Double.isFinite(value) && value > 0.0 ? value : fallback; + } + + private static double sanitizeNonNegative(double value, double fallback) { + return Double.isFinite(value) && value >= 0.0 ? value : fallback; + } + + private static double sanitizeFinite(double value, double fallback) { + return Double.isFinite(value) ? value : fallback; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java b/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java new file mode 100644 index 0000000..007b895 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathLinkBudgetSummary.java @@ -0,0 +1,130 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Bidirectional link-budget estimate for one path. + * + *

The estimate is intentionally operator-facing. It combines free-space + * loss, the current diffraction/obstruction estimate, antenna gain, feeder loss, + * TX power and required SSB/CW receiver levels.

+ */ +public record PathLinkBudgetSummary( + double frequencyMHz, + + double ownTxPowerDbm, + double targetTxPowerDbm, + double ownAntennaGainDbi, + double targetAntennaGainDbi, + + double ownFeederLossDb, + double targetFeederLossDb, + double freeSpacePathLossDb, + double diffractionLossDb, + + double homeToTargetRxPowerDbm, + double targetToHomeRxPowerDbm, + + double requiredSsbSignalDbm, + double requiredCwSignalDbm, + double contestMarginDb, + + double homeToTargetSsbMarginDb, + double targetToHomeSsbMarginDb, + double bidirectionalSsbMarginDb, + + double homeToTargetCwMarginDb, + double targetToHomeCwMarginDb, + double bidirectionalCwMarginDb +) { + + public static PathLinkBudgetSummary empty() { + return new PathLinkBudgetSummary( + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN + ); + } + + public boolean hasUsableBudget() { + return Double.isFinite(bidirectionalSsbMarginDb); + } + + public String ssbMarginText() { + if (!hasUsableBudget()) { + return "-"; + } + + return String.format( + Locale.US, + "SSB QSO margin %+.1f dB | H→DX %+.1f dB | DX→H %+.1f dB", + bidirectionalSsbMarginDb, + homeToTargetSsbMarginDb, + targetToHomeSsbMarginDb + ); + } + + public String rxPowerText() { + if (!Double.isFinite(homeToTargetRxPowerDbm) + || !Double.isFinite(targetToHomeRxPowerDbm)) { + return "-"; + } + + return String.format( + Locale.US, + "H→DX %.1f dBm | DX→H %.1f dBm", + homeToTargetRxPowerDbm, + targetToHomeRxPowerDbm + ); + } + + public String linkBudgetDetailText() { + if (!hasUsableBudget()) { + return "-"; + } + + return String.format( + Locale.US, + "FSPL %.1f dB, diffraction %.1f dB, feeder H %.1f dB / DX %.1f dB, required SSB %.1f dBm + %.1f dB margin", + freeSpacePathLossDb, + diffractionLossDb, + ownFeederLossDb, + targetFeederLossDb, + requiredSsbSignalDbm, + contestMarginDb + ); + } + + public String cwHintText() { + if (!Double.isFinite(bidirectionalCwMarginDb)) { + return "-"; + } + + if (bidirectionalSsbMarginDb >= 0.0) { + return String.format(Locale.US, "SSB workable; CW margin %+.1f dB.", bidirectionalCwMarginDb); + } + + if (bidirectionalCwMarginDb >= 0.0) { + return String.format(Locale.US, "SSB marginal/weak, but CW may still be workable (%+.1f dB).", bidirectionalCwMarginDb); + } + + return String.format(Locale.US, "Even CW budget is negative (%+.1f dB).", bidirectionalCwMarginDb); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathObstructionSummary.java b/src/main/java/kst4contest/view/map/PathObstructionSummary.java new file mode 100644 index 0000000..ddb12ba --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathObstructionSummary.java @@ -0,0 +1,105 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Summary of the dominant terrain obstruction and a rough single-knife-edge + * diffraction estimate. + * + *

This is intentionally only a severity indicator. It does not replace a full + * propagation model with multiple diffraction edges, clutter, refractivity, + * troposcatter, aircraft scatter or ducting.

+ */ +public record PathObstructionSummary( + double analysisFrequencyMHz, + + int dominantObstructionSampleIndex, + double dominantObstructionPathDistanceKm, + double dominantObstructionHeightAboveLosMeters, + double localFirstFresnelRadiusMeters, + double obstructionFresnelRatio, + double diffractionVParameter, + double estimatedKnifeEdgeLossDb, + + int worstFresnelIntrusionSampleIndex, + double worstFresnelIntrusionPathDistanceKm, + double worstFresnelIntrusionMeters, + double worstFresnelIntrusionRatio +) { + + public static PathObstructionSummary empty() { + return new PathObstructionSummary( + Double.NaN, + -1, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + -1, + Double.NaN, + Double.NaN, + Double.NaN + ); + } + + public boolean hasDominantLosObstruction() { + return dominantObstructionSampleIndex >= 0 + && Double.isFinite(dominantObstructionPathDistanceKm) + && Double.isFinite(dominantObstructionHeightAboveLosMeters) + && dominantObstructionHeightAboveLosMeters > 0.0; + } + + public boolean hasFresnelIntrusion() { + return worstFresnelIntrusionSampleIndex >= 0 + && Double.isFinite(worstFresnelIntrusionMeters) + && worstFresnelIntrusionMeters > 0.0; + } + + public String obstructionText() { + if (hasDominantLosObstruction()) { + return String.format( + Locale.US, + "%.1f km | %.1f m above LOS | %.2f Fresnel radii | knife-edge ≈ %.1f dB (%s)", + dominantObstructionPathDistanceKm, + dominantObstructionHeightAboveLosMeters, + obstructionFresnelRatio, + estimatedKnifeEdgeLossDb, + severityText() + ); + } + + if (hasFresnelIntrusion()) { + return String.format( + Locale.US, + "No LOS-blocking edge. Fresnel intrusion %.1f m at %.1f km (%.0f%%).", + worstFresnelIntrusionMeters, + worstFresnelIntrusionPathDistanceKm, + worstFresnelIntrusionRatio * 100.0 + ); + } + + return "No dominant LOS obstruction detected."; + } + + public String severityText() { + if (!Double.isFinite(estimatedKnifeEdgeLossDb)) { + return "unknown"; + } + + if (estimatedKnifeEdgeLossDb < 6.0) { + return "low"; + } + + if (estimatedKnifeEdgeLossDb < 15.0) { + return "moderate"; + } + + if (estimatedKnifeEdgeLossDb < 25.0) { + return "high"; + } + + return "severe"; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathProfileChart.java b/src/main/java/kst4contest/view/map/PathProfileChart.java new file mode 100644 index 0000000..ed0a8ed --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathProfileChart.java @@ -0,0 +1,1162 @@ +package kst4contest.view.map; + +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.paint.Color; + +import java.util.List; +import java.util.Locale; + +import java.util.function.Consumer; +import javafx.scene.input.MouseEvent; + +/** + * JavaFX canvas chart for previewing the current path profile. + * + *

The chart is intentionally explicit and self-explanatory: + *

    + *
  • terrain profile
  • + *
  • direct line of sight
  • + *
  • 1st Fresnel zone
  • + *
  • endpoint ground / antenna information
  • + *
  • critical point marker
  • + *
  • axes and legend
  • + *
+ * + *

Important: this chart visualizes geometric/topographic path evaluation. + * It does not claim that a blocked LOS automatically means "no connection".

+ */ +public final class PathProfileChart extends Canvas { + + + private Consumer onProfilePointHovered; + + private PathObstructionSummary obstructionSummary = PathObstructionSummary.empty(); + private List profilePoints = List.of(); + private double totalDistanceKm = Double.NaN; + private boolean darkMode; + + private double homeAntennaHeightMeters = Double.NaN; + private double targetAntennaHeightMeters = Double.NaN; + private double analysisFrequencyMHz = Double.NaN; + private double effectiveEarthRadiusFactor = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR; + private PathHorizonSummary horizonSummary = PathHorizonSummary.empty(); + + + public PathProfileChart() { + setWidth(420); + setHeight(280); + + widthProperty().addListener((obs, oldValue, newValue) -> redraw()); + heightProperty().addListener((obs, oldValue, newValue) -> redraw()); + + setOnMouseMoved(this::handleMouseMoved); + setOnMouseExited(event -> { + if (onProfilePointHovered != null) { + onProfilePointHovered.accept(null); + } + }); + } + + public void setRadioPath(double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz) { + setRadioPath( + homeAntennaHeightMeters, + targetAntennaHeightMeters, + analysisFrequencyMHz, + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR + ); + } + + public void setRadioPath(double homeAntennaHeightMeters, + double targetAntennaHeightMeters, + double analysisFrequencyMHz, + double effectiveEarthRadiusFactor) { + this.homeAntennaHeightMeters = homeAntennaHeightMeters; + this.targetAntennaHeightMeters = targetAntennaHeightMeters; + this.analysisFrequencyMHz = analysisFrequencyMHz; + this.effectiveEarthRadiusFactor = + PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor); + redraw(); + } + + public void setDarkMode(boolean darkMode) { + this.darkMode = darkMode; + redraw(); + } + + public void setProfile(List profilePoints, double totalDistanceKm) { + this.profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints); + this.totalDistanceKm = totalDistanceKm; + redraw(); + } + + private void redraw() { + double width = getWidth(); + double height = getHeight(); + + if (width <= 1.0 || height <= 1.0) { + return; + } + + GraphicsContext gc = getGraphicsContext2D(); + + Color background = darkMode ? Color.rgb(34, 39, 43) : Color.rgb(248, 248, 248); + Color border = darkMode ? Color.rgb(96, 106, 116) : Color.rgb(180, 180, 180); + Color grid = darkMode ? Color.rgb(95, 105, 115, 0.70) : Color.rgb(185, 185, 185, 0.75); + Color text = darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40); + + Color terrainFill = darkMode ? Color.rgb(70, 165, 95, 0.35) : Color.rgb(90, 180, 110, 0.35); + Color terrainLine = darkMode ? Color.rgb(95, 210, 120) : Color.rgb(45, 150, 70); + + Color losLine = darkMode ? Color.rgb(255, 182, 80) : Color.rgb(220, 130, 20); + + Color fresnelFill = darkMode ? Color.rgb(170, 135, 255, 0.16) : Color.rgb(150, 105, 240, 0.16); + Color fresnelLine = darkMode ? Color.rgb(205, 155, 255) : Color.rgb(130, 75, 210); + + Color endpointMarker = darkMode ? Color.rgb(245, 245, 245) : Color.rgb(45, 45, 45); + Color horizonMarker = darkMode ? Color.rgb(95, 190, 255) : Color.rgb(30, 120, 200); + Color criticalMarker = darkMode ? Color.rgb(255, 100, 100) : Color.rgb(210, 45, 45); + + Color terrainHorizonMarker = darkMode ? Color.rgb(255, 220, 105) : Color.rgb(185, 125, 0); + Color obstructionMarker = darkMode ? Color.rgb(255, 135, 75) : Color.rgb(225, 85, 30); + + gc.setFill(background); + gc.fillRect(0, 0, width, height); + + gc.setStroke(border); + gc.strokeRect(0.5, 0.5, width - 1.0, height - 1.0); + + double left = 58.0; + double headerTop = 12.0; + double headerHeight = 34.0; + double endpointTextHeight = 18.0; + double top = headerTop + headerHeight + endpointTextHeight; + double right = 18.0; + double bottom = 56.0; + + double plotX = left; + double plotY = top; + double plotWidth = Math.max(10.0, width - left - right); + double plotHeight = Math.max(10.0, height - top - bottom); + + drawGrid(gc, grid, plotX, plotY, plotWidth, plotHeight); + + if (profilePoints.isEmpty()) { + gc.setFill(text); + gc.fillText("No profile samples available.", plotX, plotY + plotHeight / 2.0); + drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, 0.0, 1.0); + return; + } + + double minElevation = determineMinimumElevation(); + double maxElevation = determineMaximumElevation(); + double elevationRange = Math.max(1.0, maxElevation - minElevation); + double paddingMeters = Math.max(20.0, elevationRange * 0.08); + + minElevation -= paddingMeters; + maxElevation += paddingMeters; + elevationRange = Math.max(1.0, maxElevation - minElevation); + + drawTerrainFill(gc, terrainFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawFresnelFill(gc, fresnelFill, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + + drawTerrain(gc, terrainLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawFresnelHull(gc, fresnelLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawLosLine(gc, losLine, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + + drawEndpointMarkers(gc, endpointMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawRadioHorizonMarkers(gc, horizonMarker, text, plotX, plotY, plotWidth, plotHeight); + drawTerrainHorizonMarkers(gc, terrainHorizonMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawObstructionMarker(gc, obstructionMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + drawCriticalMarker(gc, criticalMarker, text, plotX, plotY, plotWidth, plotHeight, minElevation, elevationRange); + + drawAxisLabels(gc, text, plotX, plotY, plotWidth, plotHeight, minElevation, maxElevation); + drawLegend(gc, text, terrainLine, losLine, fresnelLine, horizonMarker, terrainHorizonMarker, obstructionMarker, criticalMarker, plotX, headerTop + 12.0, plotWidth); + } + + private void drawGrid(GraphicsContext gc, + Color gridColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight) { + + gc.setStroke(gridColor); + gc.setLineWidth(1.0); + gc.setLineDashes(null); + + for (int i = 0; i <= 4; i++) { + double y = plotY + plotHeight * i / 4.0; + gc.strokeLine(plotX, y, plotX + plotWidth, y); + } + + for (int i = 0; i <= 4; i++) { + double x = plotX + plotWidth * i / 4.0; + gc.strokeLine(x, plotY, x, plotY + plotHeight); + } + } + + private double determineMinimumElevation() { + double minElevation = Double.POSITIVE_INFINITY; + + for (PathProfilePoint point : profilePoints) { + minElevation = Math.min(minElevation, terrainDisplayElevationMeters(point)); + + if (Double.isFinite(point.lineOfSightHeightMeters())) { + minElevation = Math.min(minElevation, point.lineOfSightHeightMeters()); + } + + if (Double.isFinite(point.fresnelUpperHeightMeters())) { + minElevation = Math.min(minElevation, point.fresnelUpperHeightMeters()); + } + + if (Double.isFinite(point.fresnelLowerHeightMeters())) { + minElevation = Math.min(minElevation, point.fresnelLowerHeightMeters()); + } + } + + return Double.isInfinite(minElevation) ? 0.0 : minElevation; + } + + private double determineMaximumElevation() { + double maxElevation = Double.NEGATIVE_INFINITY; + + for (PathProfilePoint point : profilePoints) { + maxElevation = Math.max(maxElevation, terrainDisplayElevationMeters(point)); + + if (Double.isFinite(point.lineOfSightHeightMeters())) { + maxElevation = Math.max(maxElevation, point.lineOfSightHeightMeters()); + } + + if (Double.isFinite(point.fresnelUpperHeightMeters())) { + maxElevation = Math.max(maxElevation, point.fresnelUpperHeightMeters()); + } + + if (Double.isFinite(point.fresnelLowerHeightMeters())) { + maxElevation = Math.max(maxElevation, point.fresnelLowerHeightMeters()); + } + } + + return Double.isInfinite(maxElevation) ? 1.0 : maxElevation; + } + + private void drawTerrainFill(GraphicsContext gc, + Color fillColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (profilePoints.size() < 2) { + return; + } + + int pointCount = profilePoints.size() + 2; + double[] xs = new double[pointCount]; + double[] ys = new double[pointCount]; + + xs[0] = plotX + normalizeDistance(profilePoints.get(0).distanceKm()) * plotWidth; + ys[0] = plotY + plotHeight; + + for (int i = 0; i < profilePoints.size(); i++) { + PathProfilePoint point = profilePoints.get(i); + xs[i + 1] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; + ys[i + 1] = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; + } + + xs[pointCount - 1] = plotX + normalizeDistance(profilePoints.get(profilePoints.size() - 1).distanceKm()) * plotWidth; + ys[pointCount - 1] = plotY + plotHeight; + + gc.setFill(fillColor); + gc.fillPolygon(xs, ys, pointCount); + } + + private void drawTerrain(GraphicsContext gc, + Color terrainLine, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + gc.setStroke(terrainLine); + gc.setLineWidth(2.0); + gc.setLineDashes(null); + + for (int i = 1; i < profilePoints.size(); i++) { + PathProfilePoint previous = profilePoints.get(i - 1); + PathProfilePoint current = profilePoints.get(i); + + double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; + double y1 = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(previous), minElevation, elevationRange) * plotHeight; + + double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; + double y2 = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(current), minElevation, elevationRange) * plotHeight; + + gc.strokeLine(x1, y1, x2, y2); + } + } + + private void drawLosLine(GraphicsContext gc, + Color losLine, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + gc.setStroke(losLine); + gc.setLineWidth(1.5); + gc.setLineDashes(null); + + if (hasEnrichedLosGeometry()) { + for (int i = 1; i < profilePoints.size(); i++) { + PathProfilePoint previous = profilePoints.get(i - 1); + PathProfilePoint current = profilePoints.get(i); + + if (!Double.isFinite(previous.lineOfSightHeightMeters()) + || !Double.isFinite(current.lineOfSightHeightMeters())) { + continue; + } + + double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; + double y1 = plotY + plotHeight + - normalizeElevation(previous.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; + + double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; + double y2 = plotY + plotHeight + - normalizeElevation(current.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight; + + gc.strokeLine(x1, y1, x2, y2); + } + return; + } + + if (profilePoints.size() < 2 + || !Double.isFinite(homeAntennaHeightMeters) + || !Double.isFinite(targetAntennaHeightMeters)) { + return; + } + + double startAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(0)) + homeAntennaHeightMeters; + double endAntennaMeters = terrainDisplayElevationMeters(profilePoints.get(profilePoints.size() - 1)) + + targetAntennaHeightMeters; + + double x1 = plotX; + double y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight; + + double x2 = plotX + plotWidth; + double y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight; + + gc.strokeLine(x1, y1, x2, y2); + } + + private void drawFresnelFill(GraphicsContext gc, + Color fillColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (!hasEnrichedFresnelGeometry() || profilePoints.size() < 2) { + return; + } + + for (PathProfilePoint point : profilePoints) { + if (!Double.isFinite(point.fresnelUpperHeightMeters()) + || !Double.isFinite(point.fresnelLowerHeightMeters())) { + return; + } + } + + int n = profilePoints.size(); + double[] xs = new double[n * 2]; + double[] ys = new double[n * 2]; + + for (int i = 0; i < n; i++) { + PathProfilePoint point = profilePoints.get(i); + xs[i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; + ys[i] = plotY + plotHeight + - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; + } + + for (int i = 0; i < n; i++) { + PathProfilePoint point = profilePoints.get(n - 1 - i); + xs[n + i] = plotX + normalizeDistance(point.distanceKm()) * plotWidth; + ys[n + i] = plotY + plotHeight + - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; + } + + gc.setFill(fillColor); + gc.fillPolygon(xs, ys, xs.length); + } + + private void drawFresnelHull(GraphicsContext gc, + Color fresnelLine, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (!hasEnrichedFresnelGeometry()) { + return; + } + + gc.setStroke(fresnelLine); + gc.setLineWidth(1.0); + gc.setLineDashes(6.0, 4.0); + + for (int i = 1; i < profilePoints.size(); i++) { + PathProfilePoint previous = profilePoints.get(i - 1); + PathProfilePoint current = profilePoints.get(i); + + if (Double.isFinite(previous.fresnelUpperHeightMeters()) + && Double.isFinite(current.fresnelUpperHeightMeters())) { + + double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; + double y1 = plotY + plotHeight + - normalizeElevation(previous.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; + + double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; + double y2 = plotY + plotHeight + - normalizeElevation(current.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight; + + gc.strokeLine(x1, y1, x2, y2); + } + + if (Double.isFinite(previous.fresnelLowerHeightMeters()) + && Double.isFinite(current.fresnelLowerHeightMeters())) { + + double x1 = plotX + normalizeDistance(previous.distanceKm()) * plotWidth; + double y1 = plotY + plotHeight + - normalizeElevation(previous.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; + + double x2 = plotX + normalizeDistance(current.distanceKm()) * plotWidth; + double y2 = plotY + plotHeight + - normalizeElevation(current.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight; + + gc.strokeLine(x1, y1, x2, y2); + } + } + + gc.setLineDashes(null); + } + + private void drawEndpointMarkers(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (profilePoints.size() < 2) { + return; + } + + PathProfilePoint first = profilePoints.get(0); + PathProfilePoint last = profilePoints.get(profilePoints.size() - 1); + + double firstGround = terrainDisplayElevationMeters(first); + double lastGround = terrainDisplayElevationMeters(last); + + double firstAntenna = Double.isFinite(first.lineOfSightHeightMeters()) + ? first.lineOfSightHeightMeters() + : firstGround + homeAntennaHeightMeters; + + double lastAntenna = Double.isFinite(last.lineOfSightHeightMeters()) + ? last.lineOfSightHeightMeters() + : lastGround + targetAntennaHeightMeters; + + double x1 = plotX; + double y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight; + double y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight; + + double x2 = plotX + plotWidth; + double y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight; + double y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight; + + gc.setStroke(markerColor); + gc.setFill(markerColor); + gc.setLineWidth(1.2); + + gc.strokeLine(x1, y1Ground, x1, y1Antenna); + gc.fillOval(x1 - 3.0, y1Antenna - 3.0, 6.0, 6.0); + + gc.strokeLine(x2, y2Ground, x2, y2Antenna); + gc.fillOval(x2 - 3.0, y2Antenna - 3.0, 6.0, 6.0); + + gc.setFill(textColor); + + String homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters); + String dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters); + + double headerLabelY = plotY - 10.0; + + gc.fillText(homeLabel, plotX + 4.0, headerLabelY); + + double dxLabelWidth = estimateTextWidth(dxLabel); + gc.fillText( + dxLabel, + Math.max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45), + headerLabelY + ); + } + + private String buildEndpointLabel(String name, double groundMetersAsl, double antennaHeightMetersAgl) { + boolean hasGround = Double.isFinite(groundMetersAsl); + boolean hasAntenna = Double.isFinite(antennaHeightMetersAgl); + + if (hasGround && hasAntenna) { + return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl); + } + + if (hasGround) { + return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl); + } + + if (hasAntenna) { + return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl); + } + + return name + ": -"; + } + + private void drawObstructionMarker(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (obstructionSummary == null || !obstructionSummary.hasDominantLosObstruction()) { + return; + } + + PathProfilePoint point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex()); + if (point == null) { + return; + } + + double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; + double y = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; + + gc.setFill(markerColor); + + double[] diamondX = {x, x - 5.0, x, x + 5.0}; + double[] diamondY = {y - 7.0, y, y + 7.0, y}; + gc.fillPolygon(diamondX, diamondY, 4); + + gc.setStroke(markerColor); + gc.setLineWidth(1.0); + gc.setLineDashes(2.0, 4.0); + gc.strokeLine(x, y, x, plotY + plotHeight); + gc.setLineDashes(null); + + String label = String.format( + Locale.US, + "Diffraction candidate %.1f km, KE ≈ %.1f dB", + obstructionSummary.dominantObstructionPathDistanceKm(), + obstructionSummary.estimatedKnifeEdgeLossDb() + ); + + double labelWidth = estimateTextWidth(label); + double labelX = x + 8.0; + + if (labelX + labelWidth > plotX + plotWidth) { + labelX = Math.max(plotX, x - labelWidth - 8.0); + } + + double labelY = Math.min(plotY + plotHeight - 34.0, y + 30.0); + labelY = Math.max(plotY + 34.0, labelY); + + gc.setFill(textColor); + gc.fillText(label, labelX, labelY); + } + + private double drawHorizontalLegendDiamondItem(GraphicsContext gc, + Color textColor, + Color markerColor, + double x, + double y, + String text) { + + gc.setFill(markerColor); + + double centerX = x + 4.0; + double centerY = y - 6.0; + + double[] diamondX = {centerX, centerX - 4.0, centerX, centerX + 4.0}; + double[] diamondY = {centerY - 4.0, centerY, centerY + 4.0, centerY}; + gc.fillPolygon(diamondX, diamondY, 4); + + gc.setFill(textColor); + gc.fillText(text, x + 14.0, y); + + return x + 14.0 + estimateTextWidth(text) + 18.0; + } + + private void drawCriticalMarker(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + PathProfilePoint criticalPoint = findCriticalPoint(); + if (criticalPoint == null) { + return; + } + + double x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth; + double y = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight; + + gc.setFill(markerColor); + gc.fillOval(x - 4.0, y - 4.0, 8.0, 8.0); + + gc.setStroke(markerColor); + gc.setLineWidth(1.0); + gc.strokeLine(x, y, x, plotY + plotHeight); + + gc.setFill(textColor); + + String label; + if (criticalPoint.hasFresnelIntrusion()) { + label = String.format( + Locale.US, + "Critical point: Fresnel intrusion %.1f m @ %.1f km", + criticalPoint.fresnelIntrusionMeters(), + criticalPoint.distanceKm() + ); + } else { + label = String.format( + Locale.US, + "Critical point: LOS clearance %.1f m @ %.1f km", + criticalPoint.lineOfSightClearanceMeters(), + criticalPoint.distanceKm() + ); + } + + double labelWidth = estimateTextWidth(label); + double labelX = x + 8.0; + if (labelX + labelWidth > plotX + plotWidth) { + labelX = Math.max(plotX, x - labelWidth - 8.0); + } + + double labelY = Math.max(plotY + 22.0, y - 10.0); + + if (labelY < plotY + 18.0) { + labelY = plotY + 18.0; + } + + gc.fillText(label, labelX, labelY); + } + + private PathProfilePoint findCriticalPoint() { + PathProfilePoint worstFresnelPoint = null; + double worstFresnelIntrusion = 0.0; + + PathProfilePoint worstLosPoint = null; + double minimumLosClearance = Double.POSITIVE_INFINITY; + + for (PathProfilePoint point : profilePoints) { + if (Double.isFinite(point.fresnelIntrusionMeters()) + && point.fresnelIntrusionMeters() > worstFresnelIntrusion) { + worstFresnelIntrusion = point.fresnelIntrusionMeters(); + worstFresnelPoint = point; + } + + if (Double.isFinite(point.lineOfSightClearanceMeters()) + && point.lineOfSightClearanceMeters() < minimumLosClearance) { + minimumLosClearance = point.lineOfSightClearanceMeters(); + worstLosPoint = point; + } + } + + if (worstFresnelPoint != null) { + return worstFresnelPoint; + } + + if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked()) { + return worstLosPoint; + } + + return null; + } + + private void drawTerrainHorizonMarkers(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange) { + + if (horizonSummary == null || profilePoints.isEmpty()) { + return; + } + + if (horizonSummary.hasHomeTerrainHorizon()) { + drawTerrainHorizonMarker( + gc, + markerColor, + textColor, + plotX, + plotY, + plotWidth, + plotHeight, + minElevation, + elevationRange, + horizonSummary.homeTerrainHorizonSampleIndex(), + "Home terrain horizon" + ); + } + + if (horizonSummary.hasTargetTerrainHorizon()) { + drawTerrainHorizonMarker( + gc, + markerColor, + textColor, + plotX, + plotY, + plotWidth, + plotHeight, + minElevation, + elevationRange, + horizonSummary.targetTerrainHorizonSampleIndex(), + "DX terrain horizon" + ); + } + } + + private void drawTerrainHorizonMarker(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double elevationRange, + int sampleIndex, + String label) { + + PathProfilePoint point = findPointBySampleIndex(sampleIndex); + if (point == null) { + return; + } + + double x = plotX + normalizeDistance(point.distanceKm()) * plotWidth; + double y = plotY + plotHeight + - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight; + + gc.setFill(markerColor); + + double[] triangleX = {x, x - 5.0, x + 5.0}; + double[] triangleY = {y - 8.0, y + 2.0, y + 2.0}; + gc.fillPolygon(triangleX, triangleY, 3); + + gc.setStroke(markerColor); + gc.setLineWidth(1.0); + gc.setLineDashes(3.0, 5.0); + gc.strokeLine(x, y, x, plotY + plotHeight); + gc.setLineDashes(null); + + String fullLabel = String.format( + Locale.US, + "%s %.1f km", + label, + point.distanceKm() + ); + + double labelWidth = estimateTextWidth(fullLabel); + double labelX = x + 7.0; + + if (labelX + labelWidth > plotX + plotWidth) { + labelX = Math.max(plotX, x - labelWidth - 7.0); + } + + double labelY = Math.min(plotY + plotHeight - 22.0, y + 18.0); + labelY = Math.max(plotY + 20.0, labelY); + + gc.setFill(textColor); + gc.fillText(fullLabel, labelX, labelY); + } + + private PathProfilePoint findPointBySampleIndex(int sampleIndex) { + if (sampleIndex < 0) { + return null; + } + + for (PathProfilePoint point : profilePoints) { + if (point.sampleIndex() == sampleIndex) { + return point; + } + } + + return null; + } + + + private void drawAxisLabels(GraphicsContext gc, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double minElevation, + double maxElevation) { + + gc.setFill(textColor); + + for (int i = 0; i <= 4; i++) { + double fraction = (double) i / 4.0; + double y = plotY + plotHeight - fraction * plotHeight; + double value = minElevation + fraction * (maxElevation - minElevation); + + gc.fillText(String.format(Locale.US, "%.0f", value), 8.0, y + 4.0); + } + + double[] xFractions = {0.0, 0.25, 0.50, 0.75, 1.0}; + for (double xFraction : xFractions) { + double x = plotX + xFraction * plotWidth; + double distance = Double.isFinite(totalDistanceKm) ? totalDistanceKm * xFraction : 0.0; + gc.fillText(String.format(Locale.US, "%.0f", distance), x - 8.0, plotY + plotHeight + 16.0); + } + + gc.fillText("Height [m]", plotX, plotY - 4.0); + gc.fillText("Distance [km]", plotX + plotWidth / 2.0 - 30.0, plotY + plotHeight + 34.0); + + if (Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0) { + String freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz); + gc.fillText(freqText, plotX + plotWidth - estimateTextWidth(freqText), plotY + plotHeight + 34.0); + } + } + + private void drawLegend(GraphicsContext gc, + Color textColor, + Color terrainLine, + Color losLine, + Color fresnelLine, + Color horizonMarker, + Color terrainHorizonMarker, + Color obstructionMarker, + Color criticalMarker, + double plotX, + double legendBaselineY, + double plotWidth) { + + double x = plotX; + double y = legendBaselineY; + + gc.setFill(textColor); + gc.fillText("Legend:", x, y); + x += 48.0; + + x = drawHorizontalLegendLineItem(gc, textColor, terrainLine, false, x, y, "Terrain"); + x = drawHorizontalLegendLineItem(gc, textColor, losLine, false, x, y, "LOS"); + x = drawHorizontalLegendLineItem(gc, textColor, fresnelLine, true, x, y, "Fresnel"); + x = drawHorizontalLegendLineItem(gc, textColor, horizonMarker, true, x, y, "Radio hor."); + x = drawHorizontalLegendTriangleItem(gc, textColor, terrainHorizonMarker, x, y, "Terr. hor."); + x = drawHorizontalLegendDiamondItem(gc, textColor, obstructionMarker, x, y, "Diffraction"); + + drawHorizontalLegendDotItem(gc, textColor, criticalMarker, x, y, "Critical"); + } + + + private double drawHorizontalLegendTriangleItem(GraphicsContext gc, + Color textColor, + Color markerColor, + double x, + double y, + String text) { + + gc.setFill(markerColor); + + double[] triangleX = {x + 4.0, x, x + 8.0}; + double[] triangleY = {y - 10.0, y - 2.0, y - 2.0}; + gc.fillPolygon(triangleX, triangleY, 3); + + gc.setFill(textColor); + gc.fillText(text, x + 14.0, y); + + return x + 14.0 + estimateTextWidth(text) + 18.0; + } + + private double drawHorizontalLegendLineItem(GraphicsContext gc, + Color textColor, + Color lineColor, + boolean dashed, + double x, + double y, + String text) { + + gc.setStroke(lineColor); + gc.setLineWidth(1.6); + gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); + gc.strokeLine(x, y - 4.0, x + 14.0, y - 4.0); + gc.setLineDashes(null); + + gc.setFill(textColor); + gc.fillText(text, x + 18.0, y); + + return x + 18.0 + estimateTextWidth(text) + 18.0; + } + + private double drawHorizontalLegendDotItem(GraphicsContext gc, + Color textColor, + Color dotColor, + double x, + double y, + String text) { + + gc.setFill(dotColor); + gc.fillOval(x, y - 8.0, 8.0, 8.0); + + gc.setFill(textColor); + gc.fillText(text, x + 14.0, y); + + return x + 14.0 + estimateTextWidth(text) + 18.0; + } + + private void drawLegendLine(GraphicsContext gc, + Color color, + boolean dashed, + double x, + double y, + String text) { + + gc.setStroke(color); + gc.setLineWidth(1.6); + gc.setLineDashes(dashed ? new double[]{6.0, 4.0} : null); + gc.strokeLine(x, y - 4.0, x + 10.0, y - 4.0); + gc.setLineDashes(null); + + gc.setFill(darkMode ? Color.rgb(230, 235, 239) : Color.rgb(40, 40, 40)); + gc.fillText(text, x + 14.0, y); + } + + private boolean hasEnrichedLosGeometry() { + return profilePoints.stream().anyMatch(point -> Double.isFinite(point.lineOfSightHeightMeters())); + } + + private boolean hasEnrichedFresnelGeometry() { + return profilePoints.stream().anyMatch(point -> + Double.isFinite(point.fresnelUpperHeightMeters()) + || Double.isFinite(point.fresnelLowerHeightMeters())); + } + + private double terrainDisplayElevationMeters(PathProfilePoint point) { + if (point == null) { + return Double.NaN; + } + + if (Double.isFinite(point.curvatureAdjustedElevationMeters())) { + return point.curvatureAdjustedElevationMeters(); + } + + return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm); + } + + private double normalizeDistance(double distanceKm) { + if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { + return 0.0; + } + return Math.max(0.0, Math.min(1.0, distanceKm / totalDistanceKm)); + } + + private double normalizeElevation(double elevationMeters, double minElevation, double elevationRange) { + return Math.max(0.0, Math.min(1.0, (elevationMeters - minElevation) / elevationRange)); + } + + private double estimateTextWidth(String text) { + if (text == null || text.isBlank()) { + return 0.0; + } + return text.length() * 6.2; + } + + private void drawRadioHorizonMarkers(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight) { + + if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { + return; + } + + double homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( + homeAntennaHeightMeters, + effectiveEarthRadiusFactor + ); + + double targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm( + targetAntennaHeightMeters, + effectiveEarthRadiusFactor + ); + + if (Double.isFinite(homeHorizonKm) + && homeHorizonKm > 0.0 + && homeHorizonKm < totalDistanceKm) { + + drawVerticalHorizonMarker( + gc, + markerColor, + textColor, + plotX, + plotY, + plotWidth, + plotHeight, + homeHorizonKm, + "Home radio horizon" + ); + } + + double targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm; + + if (Double.isFinite(targetMarkerDistanceKm) + && targetMarkerDistanceKm > 0.0 + && targetMarkerDistanceKm < totalDistanceKm) { + + drawVerticalHorizonMarker( + gc, + markerColor, + textColor, + plotX, + plotY, + plotWidth, + plotHeight, + targetMarkerDistanceKm, + "DX radio horizon" + ); + } + } + + private void drawVerticalHorizonMarker(GraphicsContext gc, + Color markerColor, + Color textColor, + double plotX, + double plotY, + double plotWidth, + double plotHeight, + double distanceKm, + String label) { + + double x = plotX + normalizeDistance(distanceKm) * plotWidth; + + gc.setStroke(markerColor); + gc.setLineWidth(1.0); + gc.setLineDashes(4.0, 4.0); + gc.strokeLine(x, plotY, x, plotY + plotHeight); + gc.setLineDashes(null); + + gc.setFill(textColor); + + String fullLabel = String.format( + Locale.US, + "%s %.1f km", + label, + distanceKm + ); + + double labelWidth = estimateTextWidth(fullLabel); + double labelX = x + 5.0; + + if (labelX + labelWidth > plotX + plotWidth) { + labelX = Math.max(plotX, x - labelWidth - 5.0); + } + + gc.fillText(fullLabel, labelX, plotY + plotHeight - 8.0); + } + + public void setHorizonSummary(PathHorizonSummary horizonSummary) { + this.horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary; + redraw(); + } + + public void setObstructionSummary(PathObstructionSummary obstructionSummary) { + this.obstructionSummary = obstructionSummary == null + ? PathObstructionSummary.empty() + : obstructionSummary; + redraw(); + } + + public void setOnProfilePointHovered(Consumer onProfilePointHovered) { + this.onProfilePointHovered = onProfilePointHovered; + } + + private void handleMouseMoved(MouseEvent event) { + if (onProfilePointHovered == null || profilePoints.isEmpty()) { + return; + } + + double width = getWidth(); + + double left = 58.0; + double right = 18.0; + double plotWidth = Math.max(10.0, width - left - right); + + double x = event.getX(); + + if (x < left || x > left + plotWidth) { + onProfilePointHovered.accept(null); + return; + } + + double normalizedDistance = Math.max(0.0, Math.min(1.0, (x - left) / plotWidth)); + double targetDistanceKm = Double.isFinite(totalDistanceKm) + ? normalizedDistance * totalDistanceKm + : Double.NaN; + + PathProfilePoint nearestPoint = findNearestProfilePoint(targetDistanceKm); + onProfilePointHovered.accept(nearestPoint); + } + + private PathProfilePoint findNearestProfilePoint(double targetDistanceKm) { + if (!Double.isFinite(targetDistanceKm) || profilePoints.isEmpty()) { + return null; + } + + PathProfilePoint nearestPoint = null; + double bestDistanceDelta = Double.POSITIVE_INFINITY; + + for (PathProfilePoint point : profilePoints) { + double delta = Math.abs(point.distanceKm() - targetDistanceKm); + + if (delta < bestDistanceDelta) { + bestDistanceDelta = delta; + nearestPoint = point; + } + } + + return nearestPoint; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathProfilePoint.java b/src/main/java/kst4contest/view/map/PathProfilePoint.java new file mode 100644 index 0000000..7c60eb8 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathProfilePoint.java @@ -0,0 +1,212 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Immutable terrain sample plus optional derived radio-path geometry. + * + *

The first four properties are the raw terrain profile payload: + *

    + *
  • distance along the path in kilometers
  • + *
  • sample latitude in degrees
  • + *
  • sample longitude in degrees
  • + *
  • terrain elevation in meters above mean sea level
  • + *
+ * + *

The remaining properties are optional derived values filled by + * {@link GeometryOnlyPathAnalysisService}. They are all expressed in the + * same chart/analysis space as the curvature-adjusted terrain profile: + *

    + *
  • curvature-adjusted terrain elevation
  • + *
  • direct line-of-sight height
  • + *
  • upper / lower first Fresnel hull
  • + *
  • LOS and Fresnel clearances
  • + *
  • worst-intrusion support values
  • + *
+ */ +public final class PathProfilePoint { + + private final int sampleIndex; + private final double distanceKm; + private final double latitudeDeg; + private final double longitudeDeg; + private final double elevationMeters; + + private final double curvatureAdjustedElevationMeters; + private final double lineOfSightHeightMeters; + private final double fresnelUpperHeightMeters; + private final double fresnelLowerHeightMeters; + private final double lineOfSightClearanceMeters; + private final double lowerFresnelClearanceMeters; + private final double fresnelIntrusionMeters; + + /** + * Creates a raw terrain profile sample without derived LOS/Fresnel values. + * + *

This constructor is used by terrain providers and cache deserialization. + * Derived geometry values remain unavailable until the path analysis service + * enriches the profile.

+ * + * @param distanceKm distance from the path origin in kilometers + * @param latitudeDeg sample latitude in degrees + * @param longitudeDeg sample longitude in degrees + * @param elevationMeters terrain elevation in meters above mean sea level + */ + public PathProfilePoint(double distanceKm, + double latitudeDeg, + double longitudeDeg, + double elevationMeters) { + this( + -1, + distanceKm, + latitudeDeg, + longitudeDeg, + elevationMeters, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN, + Double.NaN + ); + } + + /** + * Creates a terrain profile sample enriched with derived radio-path geometry. + * + * @param sampleIndex zero-based sample index in the analyzed path + * @param distanceKm distance from the path origin in kilometers + * @param latitudeDeg sample latitude in degrees + * @param longitudeDeg sample longitude in degrees + * @param elevationMeters raw terrain elevation in meters above mean sea level + * @param curvatureAdjustedElevationMeters terrain elevation plus Earth bulge in meters + * @param lineOfSightHeightMeters direct path height in the same chart space + * @param fresnelUpperHeightMeters upper first Fresnel hull in the same chart space + * @param fresnelLowerHeightMeters lower first Fresnel hull in the same chart space + * @param lineOfSightClearanceMeters direct LOS clearance against curvature-adjusted terrain + * @param lowerFresnelClearanceMeters lower Fresnel clearance against curvature-adjusted terrain + * @param fresnelIntrusionMeters terrain intrusion into the lower Fresnel hull + */ + public PathProfilePoint(int sampleIndex, + double distanceKm, + double latitudeDeg, + double longitudeDeg, + double elevationMeters, + double curvatureAdjustedElevationMeters, + double lineOfSightHeightMeters, + double fresnelUpperHeightMeters, + double fresnelLowerHeightMeters, + double lineOfSightClearanceMeters, + double lowerFresnelClearanceMeters, + double fresnelIntrusionMeters) { + + this.sampleIndex = sampleIndex; + this.distanceKm = distanceKm; + this.latitudeDeg = latitudeDeg; + this.longitudeDeg = longitudeDeg; + this.elevationMeters = elevationMeters; + this.curvatureAdjustedElevationMeters = curvatureAdjustedElevationMeters; + this.lineOfSightHeightMeters = lineOfSightHeightMeters; + this.fresnelUpperHeightMeters = fresnelUpperHeightMeters; + this.fresnelLowerHeightMeters = fresnelLowerHeightMeters; + this.lineOfSightClearanceMeters = lineOfSightClearanceMeters; + this.lowerFresnelClearanceMeters = lowerFresnelClearanceMeters; + this.fresnelIntrusionMeters = fresnelIntrusionMeters; + } + + public int sampleIndex() { + return sampleIndex; + } + + public double distanceKm() { + return distanceKm; + } + + public double latitudeDeg() { + return latitudeDeg; + } + + public double longitudeDeg() { + return longitudeDeg; + } + + public double elevationMeters() { + return elevationMeters; + } + + public double curvatureAdjustedElevationMeters() { + return curvatureAdjustedElevationMeters; + } + + public double lineOfSightHeightMeters() { + return lineOfSightHeightMeters; + } + + public double fresnelUpperHeightMeters() { + return fresnelUpperHeightMeters; + } + + public double fresnelLowerHeightMeters() { + return fresnelLowerHeightMeters; + } + + public double lineOfSightClearanceMeters() { + return lineOfSightClearanceMeters; + } + + public double lowerFresnelClearanceMeters() { + return lowerFresnelClearanceMeters; + } + + public double fresnelIntrusionMeters() { + return fresnelIntrusionMeters; + } + + /** + * Returns true if enriched LOS/Fresnel geometry is available. + * + * @return true if this sample was enriched by the path analysis service + */ + public boolean hasDerivedGeometry() { + return Double.isFinite(curvatureAdjustedElevationMeters) + || Double.isFinite(lineOfSightHeightMeters) + || Double.isFinite(fresnelUpperHeightMeters) + || Double.isFinite(fresnelLowerHeightMeters); + } + + /** + * Returns true if the terrain blocks the direct line of sight at this sample. + * + * @return true if the LOS clearance is negative + */ + public boolean isLineOfSightBlocked() { + return Double.isFinite(lineOfSightClearanceMeters) && lineOfSightClearanceMeters < 0.0; + } + + /** + * Returns true if the terrain intrudes into the lower Fresnel hull. + * + * @return true if Fresnel intrusion is positive + */ + public boolean hasFresnelIntrusion() { + return Double.isFinite(fresnelIntrusionMeters) && fresnelIntrusionMeters > 0.0; + } + + @Override + public String toString() { + return String.format( + Locale.ROOT, + "PathProfilePoint{index=%d, distanceKm=%.3f, lat=%.6f, lon=%.6f, elevation=%.2f, curvature=%.2f, los=%.2f, fresnelLower=%.2f, intrusion=%.2f}", + sampleIndex, + distanceKm, + latitudeDeg, + longitudeDeg, + elevationMeters, + curvatureAdjustedElevationMeters, + lineOfSightHeightMeters, + fresnelLowerHeightMeters, + fresnelIntrusionMeters + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/PathPropagationAssessment.java b/src/main/java/kst4contest/view/map/PathPropagationAssessment.java new file mode 100644 index 0000000..950a5e0 --- /dev/null +++ b/src/main/java/kst4contest/view/map/PathPropagationAssessment.java @@ -0,0 +1,105 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Operator-facing propagation assessment derived from geometric path analysis. + * + *

This class deliberately avoids binary "possible/impossible" wording. + * VHF/UHF paths can work despite blocked geometric LOS due to diffraction, + * troposcatter, enhanced tropospheric refraction, ducting or aircraft scatter.

+ */ +public record PathPropagationAssessment( + String category, + String shortText, + String detailText, + String likelyMechanisms, + int severityLevel +) { + + public static PathPropagationAssessment unknown() { + return new PathPropagationAssessment( + "Unknown", + "No propagation assessment available.", + "No usable terrain/profile data is available.", + "-", + 0 + ); + } + + public static PathPropagationAssessment directFavorable() { + return new PathPropagationAssessment( + "Direct path favorable", + "Direct path likely", + "The geometric line of sight and the first Fresnel zone are clear. A direct tropospheric path is plausible.", + "Direct tropospheric path", + 1 + ); + } + + public static PathPropagationAssessment directLossy(double fresnelIntrusionRatio) { + return new PathPropagationAssessment( + "Direct path lossy", + "Direct path plausible, but lossy", + String.format( + Locale.US, + "The direct line of sight is clear, but the first Fresnel zone is obstructed by about %.0f%% of the local Fresnel radius. Expect additional loss.", + fresnelIntrusionRatio * 100.0 + ), + "Direct path with Fresnel loss, possible mild diffraction", + 2 + ); + } + + public static PathPropagationAssessment diffractionPlausible(double knifeEdgeLossDb) { + return new PathPropagationAssessment( + "Diffraction plausible", + "Obstructed, diffraction may still be plausible", + String.format( + Locale.US, + "The direct geometric path is blocked, but the rough single-knife-edge estimate is moderate at about %.1f dB. A QSO may still be possible with sufficient antennas, power and conditions.", + knifeEdgeLossDb + ), + "Terrain diffraction, tropo enhancement", + 3 + ); + } + + public static PathPropagationAssessment obstructedNeedsHelp(double knifeEdgeLossDb) { + return new PathPropagationAssessment( + "Obstructed", + "Obstructed, enhanced propagation likely required", + String.format( + Locale.US, + "The geometric path is blocked and the rough single-knife-edge estimate is high at about %.1f dB. Direct diffraction alone may be weak; tropo enhancement or scatter mechanisms become more relevant.", + knifeEdgeLossDb + ), + "Diffraction, troposcatter, tropo enhancement, aircraft scatter", + 4 + ); + } + + public static PathPropagationAssessment severelyObstructed(double knifeEdgeLossDb) { + return new PathPropagationAssessment( + "Severely obstructed", + "Severely obstructed, special propagation probably required", + String.format( + Locale.US, + "The geometric path is strongly blocked. The rough single-knife-edge estimate is about %.1f dB, so a normal direct path is unlikely. This does not mean impossible on VHF/UHF, but special propagation is probably needed.", + knifeEdgeLossDb + ), + "Aircraft scatter, tropo ducting/enhancement, troposcatter, strong diffraction only in exceptional cases", + 5 + ); + } + + public static PathPropagationAssessment blockedNoLossEstimate() { + return new PathPropagationAssessment( + "Blocked", + "Geometrically blocked", + "The geometric line of sight is blocked. No reliable diffraction-loss estimate is available for this profile.", + "Diffraction, tropo enhancement, troposcatter, aircraft scatter", + 4 + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java new file mode 100644 index 0000000..60d0ae5 --- /dev/null +++ b/src/main/java/kst4contest/view/map/StationMapBridge.java @@ -0,0 +1,387 @@ +package kst4contest.view.map; + +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; +import javafx.scene.control.TableView; +import javafx.util.Duration; +import kst4contest.controller.ChatController; +import kst4contest.locatorUtils.Location; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; + +/** + * Synchronizes the application state with the station map window. + * + * Responsibilities: + * - observes the visible filtered table content + * - observes the central selection + * - forwards marker clicks back into the main application + * - triggers explicit DXCluster spots from the map detail panel + */ +public final class StationMapBridge { + + private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory()); + private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); + + private final ChatController chatController; + private final TableView chatMemberTable; + private final StationMapView stationMapView; + private final Consumer focusChatMemberConsumer; + + + + + + private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); +// private final OfflineDemManager offlineDemManager = new OfflineDemManager(); + private final PathAnalysisService pathAnalysisService; + + private String lastPathAnalysisRequestSignature = ""; + + private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0)); + + public StationMapBridge(ChatController chatController, + TableView chatMemberTable, + StationMapView stationMapView, + Consumer focusChatMemberConsumer) { + + this.chatController = Objects.requireNonNull(chatController, "chatController"); + this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable"); + this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView"); + this.focusChatMemberConsumer = Objects.requireNonNull(focusChatMemberConsumer, "focusChatMemberConsumer"); + + this.refreshCoalescer.setOnFinished(event -> refreshNow()); + + this.pathAnalysisService = new GeometryOnlyPathAnalysisService( + new OpenMeteoTerrainProfileProvider() + ); + } + + public void install() { + stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection); + stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot); + + chatController.getLst_chatMemberSortedFilteredList().addListener( + (ListChangeListener) change -> scheduleRefresh() + ); + + chatController.getScoreService().selectedChatMemberProperty().addListener( + (obs, oldValue, newValue) -> requestImmediateRefresh() + ); + + chatController.getChatPreferences().getActualQTF().addListener( + (obs, oldValue, newValue) -> scheduleRefresh() + ); + + requestImmediateRefresh(); + } + + public void showWindow() { + stationMapView.showWindow(); + requestImmediateRefresh(); + } + + public void hideWindow() { + stationMapView.hideWindow(); + } + + public void toggleWindow() { + if (stationMapView.isShowing()) { + hideWindow(); + } else { + showWindow(); + } + } + + public void requestImmediateRefresh() { + if (Platform.isFxApplicationThread()) { + refreshNow(); + } else { + Platform.runLater(this::refreshNow); + } + } + + public void focusSelectedCallsign() { + showWindow(); + + ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { + stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw()); + } + } + + public void applyThemeFromPreferences() { + if (Platform.isFxApplicationThread()) { + stationMapView.applyThemeFromPreferences(); + } else { + Platform.runLater(stationMapView::applyThemeFromPreferences); + } + } + + private void scheduleRefresh() { + if (Platform.isFxApplicationThread()) { + refreshCoalescer.playFromStart(); + } else { + Platform.runLater(() -> refreshCoalescer.playFromStart()); + } + } + + private void refreshNow() { + List visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList()); + ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + + List snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember); + + MapCallsignRawSnapshot selectedSnapshot = null; + if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) { + String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw()); + selectedSnapshot = snapshots.stream() + .filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw)) + .findFirst() + .orElse(null); + } + + boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size(); + + ChatPreferences preferences = chatController.getChatPreferences(); + + stationMapView.refreshMap( + snapshots, + selectedSnapshot, + preferences.getStn_loginLocatorMainCat(), + preferences.getActualQTF().get(), + preferences.getStn_antennaBeamWidthDeg(), + preferences.getStn_maxQRBDefault(), + filteredViewActive + ); + + requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); + } + + private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { + String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); + + String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot); + + if (selectedSnapshot == null) { + lastPathAnalysisRequestSignature = ""; + } else if (requestSignature.equals(lastPathAnalysisRequestSignature)) { + return; + } else { + lastPathAnalysisRequestSignature = requestSignature; + } + + if (selectedSnapshot == null) { + long generation = pathAnalysisGeneration.incrementAndGet(); + Platform.runLater(() -> { + if (generation == pathAnalysisGeneration.get()) { + stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6)); + } + }); + return; + } + + String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6()); + String targetCallsignRaw = selectedSnapshot.callSignRaw(); + + long generation = pathAnalysisGeneration.incrementAndGet(); + + stationMapView.setPathAnalysisResult( + PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw) + ); + + pathAnalysisExecutor.submit(() -> { + PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot); + + Platform.runLater(() -> { + if (generation != pathAnalysisGeneration.get()) { + return; + } + stationMapView.setPathAnalysisResult(result); + }); + }); + } + + public void dispose() { + pathAnalysisExecutor.shutdownNow(); + } + + private void handleMapCallsignSelection(String callSignRaw) { + System.out.println("########################### map selected callsign " + callSignRaw); + + ChatMember resolved = resolveBestChatMember(callSignRaw); + if (resolved == null) { + return; + } + + Platform.runLater(() -> { + chatController.getScoreService().setSelectedChatMember(resolved); + + chatMemberTable.getSelectionModel().select(resolved); + chatMemberTable.scrollTo(resolved); + + focusChatMemberConsumer.accept(resolved); + requestImmediateRefresh(); + }); + } + + private void handleExplicitClusterSpot(String callSignRaw) { + ChatMember resolved = resolveBestChatMember(callSignRaw); + if (resolved == null) { + return; + } + + if (chatController.getDxClusterServer() != null) { + chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved); + } + } + + private ChatMember resolveBestChatMember(String callSignRaw) { + String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw); + if (normalizedCallsignRaw.isBlank()) { + return null; + } + + ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember(); + if (selectedChatMember != null + && normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) { + return selectedChatMember; + } + + ChatMember visibleBest = chatMemberTable.getItems().stream() + .filter(chatMember -> chatMember != null) + .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) + .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) + .orElse(null); + + if (visibleBest != null) { + return visibleBest; + } + + synchronized (chatController.getLst_chatMemberList()) { + return chatController.getLst_chatMemberList().stream() + .filter(chatMember -> chatMember != null) + .filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw()))) + .max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch)) + .orElse(null); + } + } + + private String normalizeCallsignRaw(String callSignRaw) { + if (callSignRaw == null) { + return ""; + } + return callSignRaw.trim().toUpperCase(Locale.ROOT); + } + + private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { + String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); + + if (selectedSnapshot == null) { + return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6); + } + + String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6()); + + if (normalizedOwnLocator6.length() != 6) { + return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6); + } + + if (!selectedSnapshot.hasUsablePosition()) { + return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6); + } + + Location homeLocation = new Location(normalizedOwnLocator6); + double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot); + + PathAnalysisRequest request = new PathAnalysisRequest( + normalizedOwnLocator6, + homeLocation.getLatitude().toDegrees(), + homeLocation.getLongitude().toDegrees(), + selectedSnapshot.callSignRaw(), + normalizedTargetLocator6, + selectedSnapshot.latitudeDeg(), + selectedSnapshot.longitudeDeg(), + analysisFrequencyMHz, + chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(), + chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(), + PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR, + chatController.getChatPreferences().buildPathLinkBudgetSettings() + ); + + return pathAnalysisService.analyze(request); + } + + private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) { + if (selectedSnapshot == null) { + return PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ; + } + + return PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand()); + } + + + + private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { + if (selectedSnapshot == null) { + return ""; + } + + double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot); + ChatPreferences preferences = chatController.getChatPreferences(); + + return normalizeLocator6(ownLocator6) + + "|" + + selectedSnapshot.callSignRaw() + + "|" + + normalizeLocator6(selectedSnapshot.locator6()) + + "|" + + String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg()) + + "|" + + String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg()) + + "|" + + String.format(Locale.US, "%.3f", analysisFrequencyMHz) + + "|" + + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters()) + + "|" + + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters()) + + "|" + + preferences.getStn_pathAnalysisDemRootDirectory() + + "|" + + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts()) + + "|" + + String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi()) + + "|" + + String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts()) + + "|" + + String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi()); + } + + private String normalizeLocator6(String locator) { + if (locator == null) { + return ""; + } + return locator.trim().toUpperCase(Locale.ROOT); + } + + private static final class PathAnalysisThreadFactory implements ThreadFactory { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "station-map-path-analysis"); + thread.setDaemon(true); + return thread; + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/StationMapView.java b/src/main/java/kst4contest/view/map/StationMapView.java new file mode 100644 index 0000000..fed7ef5 --- /dev/null +++ b/src/main/java/kst4contest/view/map/StationMapView.java @@ -0,0 +1,1446 @@ +package kst4contest.view.map; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; +import javafx.scene.web.WebEngine; +import javafx.scene.web.WebView; +import javafx.stage.Stage; +import netscape.javascript.JSObject; +import kst4contest.ApplicationConstants; +import kst4contest.locatorUtils.Location; +import kst4contest.model.ChatPreferences; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.ColumnConstraints; + +/** + * Standalone station map window. + * + * Responsibilities: + * - hosts the JavaFX WebView + * - renders stations, beam, connection line and Maidenhead grid + * - shows detail information for the selected callsign + * - exposes click callbacks to the application bridge + * - routes critical click/zoom interactions directly from JavaFX when the DOM listeners are unreliable + */ +public final class StationMapView { + + private final PathProfileChart detailPathProfileChart = new PathProfileChart(); + private final Label detailPathModeValue = new Label("-"); + + private final ChatPreferences chatPreferences; + + private final Stage stage = new Stage(); + private final WebView webView = new WebView(); + private final WebEngine webEngine = webView.getEngine(); + + private Scene scene; + private BorderPane rootPane; + private SplitPane mainSplitPane; + private VBox detailPane; + + private final Label statusLabel = new Label("Station map not initialized yet."); + private final Label detailCallsignValue = new Label("-"); + private final Label detailLocatorValue = new Label("-"); + private final Label detailQrbValue = new Label("-"); + private final Label detailQtfValue = new Label("-"); + private final Label detailBandsValue = new Label("-"); + private final TextArea detailFrequenciesArea = new TextArea(); + private final Label detailAirplanesValue = new Label("-"); + private final Button triggerClusterSpotButton = new Button("Trigger cluster spot"); + + private final Label detailPathFromLocatorValue = new Label("-"); + private final Label detailPathToLocatorValue = new Label("-"); + private final Label detailPathDistanceValue = new Label("-"); + private final Label detailPathBearingValue = new Label("-"); + private final Label detailPathEndpointsValue = new Label("-"); + private final Label detailPathStatusValue = new Label("No station selected."); + + private String homeLocator6 = ""; + + + private Consumer onCallsignRawSelected; + private Consumer onTriggerClusterSpot; + + private boolean mapReady; + private boolean homeViewInitialized; + + private List lastSnapshots = List.of(); + private MapCallsignRawSnapshot lastSelectedSnapshot; + private boolean filteredViewActive; + + private double homeLatitudeDeg = Double.NaN; + private double homeLongitudeDeg = Double.NaN; + private double antennaAzimuthDeg; + private double beamWidthDeg; + private double maxQrbKm; + + private double viewportSouthLat = Double.NaN; + private double viewportWestLon = Double.NaN; + private double viewportNorthLat = Double.NaN; + private double viewportEastLon = Double.NaN; + private int viewportZoom = 6; + + private String detailCallsignRaw; + + private PathAnalysisResult lastPathAnalysisResult = PathAnalysisResult.waitingForSelection(""); + + private VBox mapAndProfilePane; + private ScrollPane detailScrollPane; + + private final Label detailPathLosValue = new Label("-"); + private final Label detailPathWorstClearanceValue = new Label("-"); + + private final Label detailPathSamplesValue = new Label("-"); + + private final Label detailPathFrequencyValue = new Label("-"); + private final Label detailPathRefractionValue = new Label("-"); + private final Label detailPathHorizonValue = new Label("-"); + private final Label detailPathTerrainHorizonValue = new Label("-"); + private final Label detailPathFresnelValue = new Label("-"); + private final Label detailPathWorstFresnelValue = new Label("-"); + + private final Label detailPathObstructionValue = new Label("-"); + + private final Label detailPathAssessmentValue = new Label("-"); + private final Label detailPathMechanismsValue = new Label("-"); + + private final Label detailPathLinkBudgetValue = new Label("-"); + private final Label detailPathRxPowerValue = new Label("-"); + private final Label detailPathCwHintValue = new Label("-"); + + + public StationMapView(ChatPreferences chatPreferences) { + this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences"); + initializeUi(); + initializeWebView(); + } + + public void setPathAnalysisResult(PathAnalysisResult pathAnalysisResult) { + this.lastPathAnalysisResult = pathAnalysisResult == null + ? PathAnalysisResult.waitingForSelection(homeLocator6) + : pathAnalysisResult; + + updatePathAnalysisPanel(this.lastPathAnalysisResult); + + boolean loading = "Loading".equalsIgnoreCase(this.lastPathAnalysisResult.analysisMode()); + detailPathStatusValue.setStyle(loading ? "-fx-font-style: italic;" : ""); + } + + public void setOnCallsignRawSelected(Consumer onCallsignRawSelected) { + this.onCallsignRawSelected = onCallsignRawSelected; + } + + public void setOnTriggerClusterSpot(Consumer onTriggerClusterSpot) { + this.onTriggerClusterSpot = onTriggerClusterSpot; + } + + public void showWindow() { + + applyThemeFromPreferences(); + + if (!stage.isShowing()) { + stage.show(); + } + stage.toFront(); + + Platform.runLater(() -> { + webView.requestFocus(); + requestMapInvalidateSize(); + }); + } + + public void hideWindow() { + stage.hide(); + } + + public boolean isShowing() { + return stage.isShowing(); + } + + public void applyThemeFromPreferences() { + boolean darkMode = chatPreferences.isGUI_darkModeActive(); + applySceneTheme(darkMode); + applyMapThemeToWebView(darkMode); + detailPathProfileChart.setDarkMode(darkMode); + } + + public void focusCallsignRaw(String callSignRaw) { + if (!mapReady || callSignRaw == null || callSignRaw.isBlank()) { + return; + } + + executeMapScriptSafely( + "window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(callSignRaw.trim().toUpperCase(Locale.ROOT)) + ");" + ); + } + + public void refreshMap(List snapshots, + MapCallsignRawSnapshot selectedSnapshot, + String ownLocator6, + double antennaAzimuthDeg, + double beamWidthDeg, + double maxQrbKm, + boolean filteredViewActive) { + + this.lastSnapshots = snapshots == null ? List.of() : List.copyOf(snapshots); + this.lastSelectedSnapshot = selectedSnapshot; + this.antennaAzimuthDeg = antennaAzimuthDeg; + this.beamWidthDeg = beamWidthDeg; + this.maxQrbKm = maxQrbKm; + this.filteredViewActive = filteredViewActive; + + this.homeLocator6 = normalizeLocator6(ownLocator6); + updateHomeLocationFromOwnLocator(this.homeLocator6); + updateStatusLabel(); + updateDetailPanel(selectedSnapshot); + + if (mapReady) { + renderAll(); + } + } + + private void initializeUi() { + + stage.setTitle("Station Map"); + + detailFrequenciesArea.setEditable(false); + detailFrequenciesArea.setWrapText(true); + detailFrequenciesArea.setPrefRowCount(4); + + detailPathEndpointsValue.setWrapText(true); + detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE); + + detailPathStatusValue.setWrapText(true); + detailPathStatusValue.setMaxWidth(Double.MAX_VALUE); + + detailPathAssessmentValue.setWrapText(true); + detailPathAssessmentValue.setMaxWidth(Double.MAX_VALUE); + + detailPathMechanismsValue.setWrapText(true); + detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE); + + triggerClusterSpotButton.setDisable(true); + triggerClusterSpotButton.setOnAction(event -> { + if (detailCallsignRaw != null && onTriggerClusterSpot != null) { + onTriggerClusterSpot.accept(detailCallsignRaw); + } + }); + + webView.setFocusTraversable(true); + webView.setPickOnBounds(true); + + webView.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> logWebViewMouseEvent("MOUSE_PRESSED", event)); + webView.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> logWebViewMouseEvent("MOUSE_RELEASED", event)); + webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event)); + + webView.addEventHandler(ScrollEvent.SCROLL, event -> { + System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX() + + " y=" + (int) event.getY() + + " deltaY=" + event.getDeltaY()); + + InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY()); + System.out.println("[StationMap FX] inspect scroll -> " + target); + + if (event.getDeltaY() > 0) { + executeMapScriptSafely("window.kstMapApi.zoomIn();"); + requestViewportPullFromJs(); + } else if (event.getDeltaY() < 0) { + executeMapScriptSafely("window.kstMapApi.zoomOut();"); + requestViewportPullFromJs(); + } + + event.consume(); + + event.consume(); + }); + + webView.widthProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); + webView.heightProperty().addListener((obs, oldValue, newValue) -> requestMapInvalidateSize()); + + VBox profileSection = createProfileSection(); + + mapAndProfilePane = new VBox(6, webView, profileSection); + mapAndProfilePane.setPadding(new Insets(0)); + +// Important: allow the SplitPane to shrink the map side. + mapAndProfilePane.setMinWidth(0); + mapAndProfilePane.setMinHeight(0); + + webView.setMinWidth(0); + webView.setMinHeight(220); + webView.setPrefHeight(420); + VBox.setVgrow(webView, Priority.ALWAYS); + + profileSection.setMinWidth(0); + profileSection.setMinHeight(210); + profileSection.setPrefHeight(260); + VBox.setVgrow(profileSection, Priority.NEVER); + + detailPathProfileChart.widthProperty().unbind(); + detailPathProfileChart.widthProperty().bind( + mapAndProfilePane.widthProperty().subtract(20) + ); + + detailPane = new VBox(10, + createSelectedStationSection(), + createPathAnalysisSection() + ); + detailPane.setPadding(new Insets(10)); + + detailPane.setMinWidth(0); + detailPane.setPrefWidth(340); + detailPane.setMaxWidth(Double.MAX_VALUE); + + detailScrollPane = new ScrollPane(detailPane); + + detailScrollPane.setFitToWidth(true); + detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + detailScrollPane.setMinWidth(300); + detailScrollPane.setPrefWidth(350); + detailScrollPane.setMaxWidth(Double.MAX_VALUE); + + detailScrollPane.setFitToWidth(true); + detailScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + detailScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + + + mainSplitPane = new SplitPane(mapAndProfilePane, detailScrollPane); + mainSplitPane.setDividerPositions(0.65); + SplitPane.setResizableWithParent(detailScrollPane, true); + + rootPane = new BorderPane(); + rootPane.setTop(statusLabel); + BorderPane.setMargin(statusLabel, new Insets(8)); + rootPane.setCenter(mainSplitPane); + + double[] size = chatPreferences.getGUIstationMapStageSceneSizeHW(); + + double initialWidth = resolveInitialStationMapWidth(size); + double initialHeight = resolveInitialStationMapHeight(size); + + scene = new Scene(rootPane, initialWidth, initialHeight); + + stage.setMinWidth(900); + stage.setMinHeight(650); + + stage.setScene(scene); + applyThemeFromPreferences(); + detailPathProfileChart.setOnProfilePointHovered(this::showProfileHoverPointOnMap); + + + double[] pos = chatPreferences.getGUIstationMapStagePositionXY(); + if (pos.length >= 2 && !Double.isNaN(pos[0]) && !Double.isNaN(pos[1])) { + stage.setX(pos[0]); + stage.setY(pos[1]); + } + + stage.widthProperty().addListener((obs, oldValue, newValue) -> + chatPreferences.getGUIstationMapStageSceneSizeHW()[0] = newValue.doubleValue()); + + stage.heightProperty().addListener((obs, oldValue, newValue) -> + chatPreferences.getGUIstationMapStageSceneSizeHW()[1] = newValue.doubleValue()); + + stage.xProperty().addListener((obs, oldValue, newValue) -> + chatPreferences.getGUIstationMapStagePositionXY()[0] = newValue.doubleValue()); + + stage.yProperty().addListener((obs, oldValue, newValue) -> + chatPreferences.getGUIstationMapStagePositionXY()[1] = newValue.doubleValue()); + + stage.setOnShown(event -> Platform.runLater(() -> { + webView.requestFocus(); + requestMapInvalidateSize(); + })); + detailPathProfileChart.setDarkMode(chatPreferences.isGUI_darkModeActive()); + + makePathValueLabel(detailPathModeValue); + makePathValueLabel(detailPathSamplesValue); + makePathValueLabel(detailPathFromLocatorValue); + makePathValueLabel(detailPathToLocatorValue); + makePathValueLabel(detailPathDistanceValue); + makePathValueLabel(detailPathBearingValue); + makePathValueLabel(detailPathEndpointsValue); + makePathValueLabel(detailPathFrequencyValue); + makePathValueLabel(detailPathRefractionValue); + makePathValueLabel(detailPathHorizonValue); + makePathValueLabel(detailPathTerrainHorizonValue); + makePathValueLabel(detailPathFresnelValue); + makePathValueLabel(detailPathWorstFresnelValue); + makePathValueLabel(detailPathLosValue); + makePathValueLabel(detailPathWorstClearanceValue); + makePathValueLabel(detailPathObstructionValue); + makePathValueLabel(detailPathAssessmentValue); + makePathValueLabel(detailPathMechanismsValue); + makePathValueLabel(detailPathLinkBudgetValue); + makePathValueLabel(detailPathRxPowerValue); + makePathValueLabel(detailPathCwHintValue); + makePathValueLabel(detailPathStatusValue); + + } + + private VBox createSelectedStationSection() { + GridPane detailGrid = new GridPane(); + detailGrid.setHgap(8); + detailGrid.setVgap(6); + + configureCompactGrid(detailGrid); + + int row = 0; + + detailGrid.add(new Label("Station:"), 0, row); + detailGrid.add(detailCallsignValue, 1, row++); + + detailGrid.add(new Label("Locator:"), 0, row); + detailGrid.add(detailLocatorValue, 1, row++); + + detailGrid.add(new Label("Path:"), 0, row); + Label compactPathValue = new Label(); + compactPathValue.textProperty().bind( + detailQrbValue.textProperty() + .concat(" / ") + .concat(detailQtfValue.textProperty()) + ); + detailGrid.add(compactPathValue, 1, row++); + + detailGrid.add(new Label("Bands:"), 0, row); + detailGrid.add(detailBandsValue, 1, row++); + + // Frequencies are useful, but they consume vertical space. Keep them compact. + detailFrequenciesArea.setPrefRowCount(2); + detailGrid.add(new Label("QRG:"), 0, row); + detailGrid.add(detailFrequenciesArea, 1, row++); + + return new VBox(8, + new Label("Selected station"), + new Separator(Orientation.HORIZONTAL), + detailGrid, + triggerClusterSpotButton + ); + } + + /** + * ensures that the labels remains visible + * @param gridPane + */ + private void configureCompactGrid(GridPane gridPane) { + gridPane.getColumnConstraints().clear(); + + ColumnConstraints labelColumn = new ColumnConstraints(); + labelColumn.setMinWidth(105); + labelColumn.setPrefWidth(115); + labelColumn.setMaxWidth(130); + labelColumn.setHgrow(Priority.NEVER); + + ColumnConstraints valueColumn = new ColumnConstraints(); + valueColumn.setMinWidth(180); + valueColumn.setHgrow(Priority.ALWAYS); + + gridPane.getColumnConstraints().addAll(labelColumn, valueColumn); + } + + private VBox createPathAnalysisSection() { + GridPane pathGrid = new GridPane(); + pathGrid.setHgap(10); + pathGrid.setVgap(8); + configureCompactGrid(pathGrid); + + int row = 0; + pathGrid.add(new Label("From locator:"), 0, row); + pathGrid.add(detailPathFromLocatorValue, 1, row++); + + pathGrid.add(new Label("To locator:"), 0, row); + pathGrid.add(detailPathToLocatorValue, 1, row++); + + Label distanceBearingValue = new Label(); + distanceBearingValue.textProperty().bind( + detailPathDistanceValue.textProperty() + .concat(" / ") + .concat(detailPathBearingValue.textProperty()) + ); + distanceBearingValue.setWrapText(true); + + pathGrid.add(new Label("Distance/QTF:"), 0, row); + pathGrid.add(distanceBearingValue, 1, row++); + + + + pathGrid.add(new Label("Endpoints:"), 0, row); + pathGrid.add(detailPathEndpointsValue, 1, row++); + + + Label sourceValue = new Label(); + sourceValue.textProperty().bind( + detailPathModeValue.textProperty() + .concat(" / ") + .concat(detailPathSamplesValue.textProperty()) + .concat(" samples") + ); + sourceValue.setWrapText(true); + + pathGrid.add(new Label("Source:"), 0, row); + pathGrid.add(sourceValue, 1, row++); + + pathGrid.add(new Label("Frequency:"), 0, row); + pathGrid.add(detailPathFrequencyValue, 1, row++); + + pathGrid.add(new Label("Refraction:"), 0, row); + pathGrid.add(detailPathRefractionValue, 1, row++); + + pathGrid.add(new Label("Radio horizon:"), 0, row); + pathGrid.add(detailPathHorizonValue, 1, row++); + + pathGrid.add(new Label("Terrain horizon:"), 0, row); + pathGrid.add(detailPathTerrainHorizonValue, 1, row++); + + Label fresnelCombinedValue = new Label(); + fresnelCombinedValue.textProperty().bind( + detailPathFresnelValue.textProperty() + .concat(" / ") + .concat(detailPathWorstFresnelValue.textProperty()) + ); + fresnelCombinedValue.setWrapText(true); + + pathGrid.add(new Label("Fresnel:"), 0, row); + pathGrid.add(fresnelCombinedValue, 1, row++); + + pathGrid.add(new Label("Obstruction:"), 0, row); + pathGrid.add(detailPathObstructionValue, 1, row++); + + + pathGrid.add(new Label("Assessment:"), 0, row); + pathGrid.add(detailPathAssessmentValue, 1, row++); + + pathGrid.add(new Label("Link budget:"), 0, row); + pathGrid.add(detailPathLinkBudgetValue, 1, row++); + + pathGrid.add(new Label("RX power:"), 0, row); + pathGrid.add(detailPathRxPowerValue, 1, row++); + + pathGrid.add(new Label("CW hint:"), 0, row); + pathGrid.add(detailPathCwHintValue, 1, row++); + + pathGrid.add(new Label("Mechanisms:"), 0, row); + pathGrid.add(detailPathMechanismsValue, 1, row++); + + Label losClearanceValue = new Label(); + losClearanceValue.textProperty().bind( + detailPathLosValue.textProperty() + .concat(" / worst ") + .concat(detailPathWorstClearanceValue.textProperty()) + ); + losClearanceValue.setWrapText(true); + + pathGrid.add(new Label("LOS:"), 0, row); + pathGrid.add(losClearanceValue, 1, row++); + + pathGrid.add(new Label("Status:"), 0, row); + pathGrid.add(detailPathStatusValue, 1, row++); + + return new VBox(10, + new Label("Path / terrain analysis"), + new Separator(Orientation.HORIZONTAL), + pathGrid + ); + } + + private void requestViewportPullFromJs() { + if (!mapReady) { + return; + } + + Platform.runLater(() -> + Platform.runLater(this::pullViewportFromJsAndRedrawGrid)); + } + + private void pullViewportFromJsAndRedrawGrid() { + if (!mapReady) { + return; + } + + try { + Object result = webEngine.executeScript("window.kstMapApi.getViewportState();"); + if (result == null) { + System.err.println("[StationMap FX] getViewportState returned null"); + return; + } + + String raw = result.toString(); + if (raw.isBlank()) { + System.err.println("[StationMap FX] getViewportState returned blank"); + return; + } + + String[] parts = raw.split("\\|"); + if (parts.length != 5) { + System.err.println("[StationMap FX] getViewportState unexpected format: " + raw); + return; + } + + viewportSouthLat = Double.parseDouble(parts[0]); + viewportWestLon = Double.parseDouble(parts[1]); + viewportNorthLat = Double.parseDouble(parts[2]); + viewportEastLon = Double.parseDouble(parts[3]); + viewportZoom = (int) Math.round(Double.parseDouble(parts[4])); + + System.out.println("[StationMap FX] pulled viewport south=" + viewportSouthLat + + " west=" + viewportWestLon + + " north=" + viewportNorthLat + + " east=" + viewportEastLon + + " zoom=" + viewportZoom); + + renderGridIfViewportKnown(); + } catch (Exception exception) { + System.err.println("[StationMap FX] pullViewportFromJsAndRedrawGrid failed: " + exception.getMessage()); + } + } + + private void initializeWebView() { + webView.setContextMenuEnabled(false); + + webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { + if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { + JSObject window = (JSObject) webEngine.executeScript("window"); + window.setMember("javaMapBridge", new JavaMapBridge()); + + executeMapScriptSafely("window.kstMapApi.init();"); + + mapReady = true; + applyMapThemeToWebView(chatPreferences.isGUI_darkModeActive()); + renderAll(); + requestMapInvalidateSize(); + } + }); + + webEngine.loadContent(MapHtmlResources.createStationMapHtml()); + } + + private void requestMapInvalidateSize() { + if (!mapReady) { + return; + } + + Platform.runLater(() -> + Platform.runLater(() -> + executeMapScriptSafely("window.kstMapApi.invalidateSize();"))); + } + + private void handleWebViewClick(MouseEvent event) { + logWebViewMouseEvent("MOUSE_CLICKED", event); + + InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY()); + System.out.println("[StationMap FX] inspect click -> " + target); + + switch (target.kind()) { + case STATION -> { + if (target.callSignRaw() != null && !target.callSignRaw().isBlank() && onCallsignRawSelected != null) { + System.out.println("[StationMap FX] selecting station " + target.callSignRaw()); + onCallsignRawSelected.accept(target.callSignRaw()); + event.consume(); + } + } + case ZOOM_IN -> { + System.out.println("[StationMap FX] zoom in click routed by JavaFX"); + executeMapScriptSafely("window.kstMapApi.zoomIn();"); + requestViewportPullFromJs(); + event.consume(); + } + case ZOOM_OUT -> { + System.out.println("[StationMap FX] zoom out click routed by JavaFX"); + executeMapScriptSafely("window.kstMapApi.zoomOut();"); + requestViewportPullFromJs(); + event.consume(); + } + + case NONE -> { + // Let normal WebView processing continue. + } + } + } + + private void logWebViewMouseEvent(String type, MouseEvent event) { + System.out.println("[StationMap FX] " + type + + " x=" + (int) event.getX() + + " y=" + (int) event.getY() + + " target=" + event.getTarget().getClass().getSimpleName() + + " button=" + event.getButton()); + + probeDomElementAt(event.getX(), event.getY()); + } + + private void probeDomElementAt(double x, double y) { + if (!mapReady) { + return; + } + + String script = String.format(Locale.US, """ + (function() { + var el = document.elementFromPoint(%f, %f); + if (!el) { + return "null"; + } + var cls = ""; + try { + cls = el.className ? el.className.toString() : ""; + } catch (e) { + cls = "[className-error]"; + } + var id = el.id ? el.id.toString() : ""; + var text = el.textContent ? el.textContent.trim() : ""; + if (text.length > 80) { + text = text.substring(0, 80); + } + return "tag=" + el.tagName + " class=" + cls + " id=" + id + " text=" + text; + })(); + """, x, y); + + try { + Object result = webEngine.executeScript(script); + System.out.println("[StationMap FX] elementFromPoint -> " + result); + } catch (Exception exception) { + System.err.println("[StationMap FX] elementFromPoint failed: " + exception.getMessage()); + } + } + + private InteractiveTarget inspectInteractiveTarget(double x, double y) { + if (!mapReady) { + return InteractiveTarget.none(); + } + + String script = String.format(Locale.US, """ + window.kstMapApi.inspectPoint(%f, %f); + """, x, y); + + try { + Object result = webEngine.executeScript(script); + if (result == null) { + return InteractiveTarget.none(); + } + + String raw = result.toString(); + + String[] parts = raw.split("\\|", 5); + + String kind = parts.length > 0 ? parts[0] : "none"; + String callSignRaw = parts.length > 1 ? parts[1] : ""; + String tag = parts.length > 2 ? parts[2] : ""; + String cssClass = parts.length > 3 ? parts[3] : ""; + String text = parts.length > 4 ? parts[4] : ""; + + return switch (kind) { + case "station" -> new InteractiveTarget(InteractiveKind.STATION, callSignRaw, tag, cssClass, text); + case "zoomIn" -> new InteractiveTarget(InteractiveKind.ZOOM_IN, "", tag, cssClass, text); + case "zoomOut" -> new InteractiveTarget(InteractiveKind.ZOOM_OUT, "", tag, cssClass, text); + default -> new InteractiveTarget(InteractiveKind.NONE, "", tag, cssClass, text); + }; + } catch (Exception exception) { + System.err.println("[StationMap FX] inspectInteractiveTarget failed: " + exception.getMessage()); + return InteractiveTarget.none(); + } + } + + private void updateHomeLocationFromOwnLocator(String ownLocator6) { + String normalizedLocator = normalizeLocator6(ownLocator6); + if (normalizedLocator.isBlank()) { + homeLatitudeDeg = Double.NaN; + homeLongitudeDeg = Double.NaN; + return; + } + + Location homeLocation = new Location(normalizedLocator); + homeLatitudeDeg = homeLocation.getLatitude().toDegrees(); + homeLongitudeDeg = homeLocation.getLongitude().toDegrees(); + } + + private void updateStatusLabel() { + StringBuilder text = new StringBuilder(); + text.append("Showing ").append(lastSnapshots.size()).append(" visible stations"); + + if (filteredViewActive) { + text.append(" | filtered view active"); + } + + statusLabel.setText(text.toString()); + } + + private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) { + if (selectedSnapshot == null) { + clearSelectedStationPanel(); + clearPathAnalysisPanel(); + return; + } + + updateSelectedStationPanel(selectedSnapshot); + + if (selectedSnapshot == null) { + updatePathAnalysisPanel(PathAnalysisResult.waitingForSelection(homeLocator6)); + } else { + updatePathAnalysisPanel(lastPathAnalysisResult); + } + + } + + private void clearSelectedStationPanel() { + detailCallsignRaw = null; + detailCallsignValue.setText("-"); + detailLocatorValue.setText("-"); + detailQrbValue.setText("-"); + detailQtfValue.setText("-"); + detailBandsValue.setText("-"); + detailFrequenciesArea.setText("-"); + detailAirplanesValue.setText("-"); + triggerClusterSpotButton.setDisable(true); + } + + private void updateSelectedStationPanel(MapCallsignRawSnapshot selectedSnapshot) { + detailCallsignRaw = selectedSnapshot.callSignRaw(); + detailCallsignValue.setText(selectedSnapshot.displayCallSign()); + 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()); + detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText()); + detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes())); + triggerClusterSpotButton.setDisable(false); + } + + private void clearPathAnalysisPanel() { + detailPathFromLocatorValue.setText(homeLocator6.isBlank() ? "-" : homeLocator6); + detailPathToLocatorValue.setText("-"); + detailPathDistanceValue.setText("-"); + detailPathBearingValue.setText("-"); + detailPathEndpointsValue.setText("-"); + detailPathStatusValue.setText("Select a station to prepare path and terrain analysis."); + detailPathModeValue.setText("-"); + detailPathSamplesValue.setText("-"); + detailPathLosValue.setText("-"); + detailPathWorstClearanceValue.setText("-"); + + + detailPathFrequencyValue.setText("-"); + detailPathRefractionValue.setText("-"); + detailPathHorizonValue.setText("-"); + detailPathFresnelValue.setText("-"); + detailPathWorstFresnelValue.setText("-"); + detailPathTerrainHorizonValue.setText("-"); + detailPathObstructionValue.setText("-"); + detailPathAssessmentValue.setText("-"); + detailPathMechanismsValue.setText("-"); + + detailPathLinkBudgetValue.setText("-"); + detailPathRxPowerValue.setText("-"); + detailPathCwHintValue.setText("-"); + + detailPathProfileChart.setObstructionSummary(PathObstructionSummary.empty()); + + + detailPathProfileChart.setProfile(List.of(), Double.NaN); + detailPathProfileChart.setRadioPath( + Double.NaN, + Double.NaN, + Double.NaN + ); + detailPathProfileChart.setHorizonSummary(PathHorizonSummary.empty()); + applyPropagationAssessmentStyle(PathAnalysisResult.waitingForSelection(homeLocator6)); + } + + private void updatePathAnalysisPanel(PathAnalysisResult result) { + + detailPathModeValue.setText( + result.analysisMode().isBlank() ? "-" : result.analysisMode() + ); + detailPathSamplesValue.setText(String.valueOf(result.profilePoints().size())); + detailPathLosValue.setText(result.losText()); + detailPathWorstClearanceValue.setText(result.worstClearanceText()); + + + detailPathFrequencyValue.setText(result.analysisFrequencyText()); + detailPathRefractionValue.setText(result.effectiveEarthRadiusText()); + detailPathHorizonValue.setText(result.radioHorizonText()); + detailPathTerrainHorizonValue.setText(result.terrainHorizonText()); + detailPathFresnelValue.setText(result.fresnelText()); + detailPathWorstFresnelValue.setText(result.worstFresnelClearanceText()); + detailPathObstructionValue.setText(result.obstructionText()); + + detailPathAssessmentValue.setText(result.propagationAssessmentText()); + detailPathMechanismsValue.setText(result.propagationMechanismsText()); + applyPropagationAssessmentStyle(result); + + detailPathFromLocatorValue.setText(result.fromLocator6().isBlank() ? "-" : result.fromLocator6()); + detailPathToLocatorValue.setText(result.toLocator6().isBlank() ? "-" : result.toLocator6()); + detailPathDistanceValue.setText(result.distanceText()); + detailPathBearingValue.setText(result.bearingText()); + detailPathEndpointsValue.setText(result.endpointSummaryText()); + detailPathStatusValue.setText(result.statusText()); + detailPathProfileChart.setProfile(result.profilePoints(), result.distanceKm()); + + detailPathLinkBudgetValue.setText(result.linkBudgetText()); + detailPathRxPowerValue.setText(result.linkBudgetRxPowerText()); + detailPathCwHintValue.setText(result.cwHintText()); + + detailPathProfileChart.setRadioPath( + result.homeAntennaHeightMeters(), + result.targetAntennaHeightMeters(), + result.analysisFrequencyMHz() + ); + detailPathProfileChart.setHorizonSummary(result.horizonSummary()); + detailPathProfileChart.setObstructionSummary(result.obstructionSummary()); + } + + + + + private void renderAll() { + if (!mapReady) { + return; + } + + if (!homeViewInitialized && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) { + executeMapScriptSafely( + "window.kstMapApi.setHome(" + formatDouble(homeLatitudeDeg) + ", " + + formatDouble(homeLongitudeDeg) + ", 6);" + ); + homeViewInitialized = true; + } + + renderStations(); + renderBeam(); + renderConnectionLine(); + renderGridIfViewportKnown(); + + if (lastSelectedSnapshot != null) { + focusCallsignRaw(lastSelectedSnapshot.callSignRaw()); + } + } + + private void renderStations() { + executeMapScriptSafely( + "window.kstMapApi.setStations(" + toJsStringLiteral(toStationsJson(lastSnapshots)) + ");" + ); + } + + private void renderBeam() { + if (!Double.isFinite(homeLatitudeDeg) + || !Double.isFinite(homeLongitudeDeg) + || beamWidthDeg <= 0.0 + || maxQrbKm <= 0.0) { + + executeMapScriptSafely("window.kstMapApi.setBeam('null');"); + return; + } + + List sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm); + executeMapScriptSafely( + "window.kstMapApi.setBeam(" + toJsStringLiteral(toPointArrayJson(sectorPoints)) + ");" + ); + } + + private void renderConnectionLine() { + if (lastSelectedSnapshot == null || !lastSelectedSnapshot.hasUsablePosition() + || !Double.isFinite(homeLatitudeDeg) || !Double.isFinite(homeLongitudeDeg)) { + + executeMapScriptSafely("window.kstMapApi.setConnection('null');"); + return; + } + + List points = List.of( + new double[]{homeLatitudeDeg, homeLongitudeDeg}, + new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()} + ); + + executeMapScriptSafely( + "window.kstMapApi.setConnection(" + toJsStringLiteral(toPointArrayJson(points)) + ");" + ); + } + + private void renderGridIfViewportKnown() { + if (!Double.isFinite(viewportSouthLat) + || !Double.isFinite(viewportWestLon) + || !Double.isFinite(viewportNorthLat) + || !Double.isFinite(viewportEastLon)) { + return; + } + + double viewportWidthPx = Math.max(1.0, webView.getWidth()); + double viewportHeightPx = Math.max(1.0, webView.getHeight()); + + MaidenheadGridRenderPlanner.GridRenderPlan renderPlan = MaidenheadGridRenderPlanner.createPlan( + viewportZoom, + viewportSouthLat, + viewportWestLon, + viewportNorthLat, + viewportEastLon, + viewportWidthPx, + viewportHeightPx + ); + + List visibleGridCells = MaidenheadGridUtils.buildVisibleCells( + viewportSouthLat, + viewportWestLon, + viewportNorthLat, + viewportEastLon, + renderPlan.precision() + ); + + System.out.println("[StationMap] renderGridIfViewportKnown zoom=" + viewportZoom + + " precision=" + renderPlan.precision().locatorLength() + + " labelStride=" + renderPlan.labelColumnStride() + "x" + renderPlan.labelRowStride() + + " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx()) + + " cells=" + visibleGridCells.size()); + + executeMapScriptSafely( + "window.kstMapApi.setGrid(" + toJsStringLiteral(toGridJson(visibleGridCells, renderPlan)) + ");" + ); + } + + private List buildBeamPolygon(double startLatDeg, + double startLonDeg, + double centerAzimuthDeg, + double beamWidthDeg, + double radiusKm) { + + List polygon = new ArrayList<>(); + polygon.add(new double[]{startLatDeg, startLonDeg}); + + double startAzimuth = normalizeAngle(centerAzimuthDeg - beamWidthDeg / 2.0); + + int segmentCount = Math.max(12, (int) Math.ceil(beamWidthDeg / 4.0)); + double angleStep = beamWidthDeg / segmentCount; + + for (int i = 0; i <= segmentCount; i++) { + double currentAzimuth = normalizeAngle(startAzimuth + i * angleStep); + polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, currentAzimuth, radiusKm)); + } + + polygon.add(new double[]{startLatDeg, startLonDeg}); + return polygon; + } + + private double[] calculateDestinationPoint(double startLatDeg, + double startLonDeg, + double bearingDeg, + double distanceKm) { + + double earthRadiusKm = 6371.009; + + double angularDistance = distanceKm / earthRadiusKm; + double bearingRad = Math.toRadians(bearingDeg); + double startLatRad = Math.toRadians(startLatDeg); + double startLonRad = Math.toRadians(startLonDeg); + + double destinationLatRad = Math.asin( + Math.sin(startLatRad) * Math.cos(angularDistance) + + Math.cos(startLatRad) * Math.sin(angularDistance) * Math.cos(bearingRad) + ); + + double destinationLonRad = startLonRad + Math.atan2( + Math.sin(bearingRad) * Math.sin(angularDistance) * Math.cos(startLatRad), + Math.cos(angularDistance) - Math.sin(startLatRad) * Math.sin(destinationLatRad) + ); + + double destinationLatDeg = Math.toDegrees(destinationLatRad); + double destinationLonDeg = normalizeLongitude(Math.toDegrees(destinationLonRad)); + + return new double[]{destinationLatDeg, destinationLonDeg}; + } + + private double normalizeAngle(double angleDeg) { + double normalized = angleDeg % 360.0; + return normalized < 0.0 ? normalized + 360.0 : normalized; + } + + private double normalizeLongitude(double longitudeDeg) { + double normalized = longitudeDeg; + while (normalized < -180.0) { + normalized += 360.0; + } + while (normalized > 180.0) { + normalized -= 360.0; + } + return normalized; + } + + 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); + } + + return normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$") ? normalized : ""; + } + + private void executeMapScriptSafely(String script) { + try { + webEngine.executeScript(script); + } catch (Exception exception) { + System.err.println("[StationMap] executeScript failed: " + exception.getMessage()); + } + } + + private void applySceneTheme(boolean darkMode) { + if (scene == null) { + return; + } + + scene.getStylesheets().clear(); + scene.getStylesheets().add(darkMode + ? ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING + : ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT); + + if (darkMode) { + rootPane.setStyle("-fx-background-color: #2b3035;"); + mainSplitPane.setStyle("-fx-background-color: #2b3035;"); + detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;"); + statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); + detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;"); + } else { + rootPane.setStyle("-fx-background-color: #f2f2f2;"); + mainSplitPane.setStyle("-fx-background-color: #f2f2f2;"); + detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;"); + statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;"); + detailFrequenciesArea.setStyle(""); + } + } + + private void applyMapThemeToWebView(boolean darkMode) { + if (!mapReady) { + return; + } + + executeMapScriptSafely( + "window.kstMapApi.setTheme(" + toJsStringLiteral(darkMode ? "dark" : "light") + ");" + ); + } + + private void applyPropagationAssessmentStyle(PathAnalysisResult result) { + int severity = result == null ? 0 : result.propagationSeverityLevel(); + + String textColor = chatPreferences.isGUI_darkModeActive() + ? "#f0f0f0" + : "#202020"; + + String backgroundColor; + String borderColor; + + switch (severity) { + case 1 -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#1f4d2b" : "#d8f3dc"; + borderColor = "#3aa655"; + } + case 2 -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4a4420" : "#fff3bf"; + borderColor = "#d4a017"; + } + case 3 -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#4d3520" : "#ffe0b2"; + borderColor = "#e69138"; + } + case 4 -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2b20" : "#ffc9a9"; + borderColor = "#d96c2c"; + } + case 5 -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#5a2020" : "#ffcdd2"; + borderColor = "#d63b3b"; + } + default -> { + backgroundColor = chatPreferences.isGUI_darkModeActive() ? "#33383e" : "#eeeeee"; + borderColor = chatPreferences.isGUI_darkModeActive() ? "#666f78" : "#cccccc"; + } + } + + detailPathAssessmentValue.setStyle( + "-fx-text-fill: " + textColor + ";" + + "-fx-background-color: " + backgroundColor + ";" + + "-fx-border-color: " + borderColor + ";" + + "-fx-border-radius: 4;" + + "-fx-background-radius: 4;" + + "-fx-padding: 3 6 3 6;" + ); + } + + private String toStationsJson(List snapshots) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + + for (MapCallsignRawSnapshot snapshot : snapshots) { + if (snapshot == null || !snapshot.hasUsablePosition()) { + continue; + } + + if (!first) { + json.append(','); + } + first = false; + + json.append('{') + .append("\"callSignRaw\":").append(toJsonString(snapshot.callSignRaw())).append(',') + .append("\"markerLabel\":").append(toJsonString(snapshot.markerLabel())).append(',') + .append("\"latitudeDeg\":").append(formatDouble(snapshot.latitudeDeg())).append(',') + .append("\"longitudeDeg\":").append(formatDouble(snapshot.longitudeDeg())).append(',') + .append("\"warningToMyDirection\":").append(snapshot.warningToMyDirection()).append(',') + .append("\"worked\":").append(snapshot.worked()).append(',') + .append("\"selected\":").append(snapshot.selected()) + .append('}'); + } + + json.append(']'); + return json.toString(); + } + + private String toGridJson(List cells, + MaidenheadGridRenderPlanner.GridRenderPlan renderPlan) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + + for (MaidenheadGridUtils.GridCell cell : cells) { + if (!first) { + json.append(','); + } + first = false; + + boolean showLabel = renderPlan.shouldShowLabel(cell); + + json.append('{') + .append("\"locatorLabel\":").append(toJsonString(cell.locatorLabel())).append(',') + .append("\"southLat\":").append(formatDouble(cell.southLat())).append(',') + .append("\"westLon\":").append(formatDouble(cell.westLon())).append(',') + .append("\"northLat\":").append(formatDouble(cell.northLat())).append(',') + .append("\"eastLon\":").append(formatDouble(cell.eastLon())).append(',') + .append("\"showLabel\":").append(showLabel).append(',') + .append("\"labelFontPx\":").append(formatDouble(renderPlan.labelFontSizePx())) + .append('}'); + } + + json.append(']'); + return json.toString(); + } + + private String toPointArrayJson(List points) { + StringBuilder json = new StringBuilder("["); + boolean first = true; + + for (double[] point : points) { + if (!first) { + json.append(','); + } + first = false; + + json.append('{') + .append("\"lat\":").append(formatDouble(point[0])).append(',') + .append("\"lon\":").append(formatDouble(point[1])) + .append('}'); + } + + json.append(']'); + return json.toString(); + } + + private String formatDouble(double value) { + return String.format(Locale.US, "%.8f", value); + } + + private String toJsStringLiteral(String raw) { + return "'" + escapeForJavaScript(raw) + "'"; + } + + private String toJsonString(String raw) { + return "\"" + escapeJson(raw) + "\""; + } + + private String escapeJson(String raw) { + if (raw == null) { + return ""; + } + + return raw + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } + + private String escapeForJavaScript(String raw) { + if (raw == null) { + return ""; + } + + return raw + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } + + public final class JavaMapBridge { + + public void onMapReady() { + System.out.println("[StationMap JS] onMapReady"); + mapReady = true; + renderAll(); + requestMapInvalidateSize(); + } + + public void onViewportChanged(double southLat, + double westLon, + double northLat, + double eastLon, + double zoom) { + + viewportSouthLat = southLat; + viewportWestLon = westLon; + viewportNorthLat = northLat; + viewportEastLon = eastLon; + viewportZoom = (int) Math.round(zoom); + + System.out.println("[StationMap JS] onViewportChanged zoom=" + viewportZoom); + + if (Platform.isFxApplicationThread()) { + renderGridIfViewportKnown(); + } else { + Platform.runLater(StationMapView.this::renderGridIfViewportKnown); + } + } + + public void onCallsignRawClicked(String callSignRaw) { + System.out.println("[StationMap JS] onCallsignRawClicked " + callSignRaw); + if (onCallsignRawSelected != null) { + onCallsignRawSelected.accept(callSignRaw); + } + } + + public void onJsLog(String message) { + System.out.println("[StationMap JS] " + message); + } + + public void onJsError(String message) { + System.err.println("[StationMap JS ERROR] " + message); + } + } + + private enum InteractiveKind { + NONE, + STATION, + ZOOM_IN, + ZOOM_OUT + } + + private record InteractiveTarget( + InteractiveKind kind, + String callSignRaw, + String tag, + String cssClass, + String text + ) { + static InteractiveTarget none() { + return new InteractiveTarget(InteractiveKind.NONE, "", "", "", ""); + } + } + + private VBox createProfileSection() { + Label titleLabel = new Label("Path profile / terrain analysis"); + titleLabel.setStyle("-fx-font-weight: bold;"); + + detailPathProfileChart.setHeight(220); +// detailPathProfileChart.setMinHeight(180); +// detailPathProfileChart.setPrefHeight(220); + + VBox chartBox = new VBox(4, titleLabel, detailPathProfileChart); + chartBox.setPadding(new Insets(4, 8, 8, 8)); + chartBox.setMinHeight(200); + chartBox.setPrefHeight(250); + +// detailPathProfileChart.widthProperty().bind(chartBox.widthProperty().subtract(20)); + + VBox.setVgrow(detailPathProfileChart, Priority.ALWAYS); + + return chartBox; + } + + private void makePathValueLabel(Label label) { + if (label == null) { + return; + } + + label.setWrapText(true); + label.setMaxWidth(Double.MAX_VALUE); + GridPane.setHgrow(label, Priority.ALWAYS); + } + + /** + * Callback to show the howered profile path points position at the map + * @param point + */ + private void showProfileHoverPointOnMap(PathProfilePoint point) { + if (!mapReady) { + return; + } + + if (point == null + || !Double.isFinite(point.latitudeDeg()) + || !Double.isFinite(point.longitudeDeg())) { + executeMapScriptSafely("window.kstMapApi.setProfileHoverPoint(null);"); + return; + } + + String label = String.format( + Locale.US, + "%.1f km / %.0f m", + point.distanceKm(), + point.elevationMeters() + ); + + executeMapScriptSafely( + "window.kstMapApi.setProfileHoverPoint({" + + "lat:" + formatDouble(point.latitudeDeg()) + "," + + "lon:" + formatDouble(point.longitudeDeg()) + "," + + "label:" + toJsStringLiteral(label) + + "});" + ); + } + + private double resolveInitialStationMapWidth(double[] storedSize) { + if (storedSize == null || storedSize.length < 1 || !Double.isFinite(storedSize[0])) { + return 1024.0; + } + + // Avoid restoring very large old test sizes after the layout changed. + if (storedSize[0] < 900.0 || storedSize[0] > 1600.0) { + return 1024.0; + } + + return storedSize[0]; + } + + private double resolveInitialStationMapHeight(double[] storedSize) { + if (storedSize == null || storedSize.length < 2 || !Double.isFinite(storedSize[1])) { + return 768.0; + } + + // Avoid restoring very large old test sizes after the layout changed. + if (storedSize[1] < 650.0 || storedSize[1] > 1100.0) { + return 768.0; + } + + return storedSize[1]; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java b/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java new file mode 100644 index 0000000..26e0de2 --- /dev/null +++ b/src/main/java/kst4contest/view/map/SyntheticTerrainProfileProvider.java @@ -0,0 +1,57 @@ +package kst4contest.view.map; + +import java.util.ArrayList; +import java.util.List; + +/** + * Deterministic synthetic fallback profile provider for UI/testing. + * + * This provider intentionally stays fully offline and deterministic. + * It is only used when no real offline DEM data is available. + */ +public final class SyntheticTerrainProfileProvider implements TerrainProfileProvider { + + @Override + public TerrainProfileData loadProfile(TerrainProfileRequest request) { + if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) { + return TerrainProfileData.empty("Synthetic fallback profile"); + } + + int sampleCount = Math.max(2, request.requestedSampleCount()); + List points = new ArrayList<>(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1); + + double latitudeDeg = interpolate(request.fromLatitudeDeg(), request.toLatitudeDeg(), t); + double longitudeDeg = interpolate(request.fromLongitudeDeg(), request.toLongitudeDeg(), t); + double distanceKm = request.totalDistanceKm() * t; + + double elevationMeters = syntheticElevationMeters(t, request.totalDistanceKm()); + + points.add(new PathProfilePoint( + distanceKm, + latitudeDeg, + longitudeDeg, + elevationMeters + )); + } + + return new TerrainProfileData(points, "Synthetic fallback profile", true); + } + + private double interpolate(double start, double end, double t) { + return start + (end - start) * t; + } + + private double syntheticElevationMeters(double t, double totalDistanceKm) { + double baseLevelMeters = 80.0 + Math.min(60.0, totalDistanceKm * 0.12); + + double broadHill = 90.0 * Math.sin(Math.PI * t); + double window = Math.pow(Math.sin(Math.PI * t), 1.35); + double secondaryShape = window * 28.0 * Math.sin(3.0 * Math.PI * t + 0.55); + double fineStructure = window * 10.0 * Math.cos(7.0 * Math.PI * t + 0.25); + + return Math.max(0.0, baseLevelMeters + broadHill + secondaryShape + fineStructure); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainCatalog.java b/src/main/java/kst4contest/view/map/TerrainCatalog.java new file mode 100644 index 0000000..a0f7649 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainCatalog.java @@ -0,0 +1,95 @@ +package kst4contest.view.map; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Immutable terrain catalog describing all downloadable terrain packages + * currently offered by the service. + * + *

The catalog intentionally contains the required attribution/license texts + * so they can later be shown both in the client UI and in service responses.

+ */ +public record TerrainCatalog( + int schemaVersion, + int catalogVersion, + String generatedAtUtc, + String regionSet, + String packageBaseUrl, + String sourceAttribution, + String licenseNotice, + String disclaimerNotice, + List packages +) { + + public TerrainCatalog { + generatedAtUtc = normalizeText(generatedAtUtc); + regionSet = normalizeLower(regionSet); + packageBaseUrl = normalizeText(packageBaseUrl); + sourceAttribution = normalizeText(sourceAttribution); + licenseNotice = normalizeText(licenseNotice); + disclaimerNotice = normalizeText(disclaimerNotice); + packages = packages == null ? List.of() : List.copyOf(packages); + + if (schemaVersion < 0) { + schemaVersion = 0; + } + + if (catalogVersion < 0) { + catalogVersion = 0; + } + } + + /** + * Returns true if the catalog contains at least one package entry. + * + * @return true if the catalog is non-empty + */ + public boolean hasPackages() { + return !packages.isEmpty(); + } + + /** + * Finds one package entry by its canonical package id. + * + * @param packageId canonical package id + * @return matching catalog entry if found + */ + public Optional findPackageById(String packageId) { + String normalizedPackageId = normalizeLower(packageId); + + return packages.stream() + .filter(entry -> entry.packageId().equals(normalizedPackageId)) + .findFirst(); + } + + /** + * Finds one package entry by region type and region id. + * + * @param regionType region type, e.g. "maidenhead4" + * @param regionId region id, e.g. "JO22" + * @return matching package entry if found + */ + public Optional findPackageByRegion(String regionType, String regionId) { + String normalizedRegionType = normalizeLower(regionType); + String normalizedRegionId = normalizeUpper(regionId); + + return packages.stream() + .filter(entry -> entry.regionType().equals(normalizedRegionType)) + .filter(entry -> entry.regionId().equals(normalizedRegionId)) + .findFirst(); + } + + private static String normalizeText(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + private static String normalizeLower(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogClient.java b/src/main/java/kst4contest/view/map/TerrainCatalogClient.java new file mode 100644 index 0000000..da1f5ce --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainCatalogClient.java @@ -0,0 +1,300 @@ +package kst4contest.view.map; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.File; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Downloads and parses the terrain package catalog. + * + *

The first implementation intentionally uses XML instead of JSON because: + *

    + *
  • the current project already contains XML parsing patterns
  • + *
  • no additional JSON dependency is required
  • + *
  • we can move faster toward a working package download/install flow
  • + *
+ * + *

The parsed result still maps into the shared terrain catalog model classes.

+ */ +public final class TerrainCatalogClient { + + private static final String LOCAL_TERRAIN_CATALOG_RELATIVE_PATH = "terrain/catalog/terrain-catalog-v1.xml"; + + private final HttpClient httpClient; + + public TerrainCatalogClient() { + this.httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * Downloads the remote XML catalog and stores it below the local .praktiKST directory. + * + * @param catalogUrl full catalog URL + * @return download result + */ + public CatalogDownloadResult downloadCatalog(String catalogUrl) { + Path localCatalogFile = resolveLocalCatalogFile(); + + try { + Files.createDirectories(localCatalogFile.getParent()); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(catalogUrl)) + .GET() + .build(); + + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofFile(localCatalogFile) + ); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return new CatalogDownloadResult( + localCatalogFile, + false, + "Catalog download failed with HTTP status " + response.statusCode() + "." + ); + } + + return new CatalogDownloadResult( + localCatalogFile, + true, + "Catalog downloaded successfully to:\n" + localCatalogFile.toAbsolutePath() + ); + } catch (Exception exception) { + return new CatalogDownloadResult( + localCatalogFile, + false, + "Catalog download failed: " + exception.getMessage() + ); + } + } + + /** + * Loads the previously downloaded local catalog file. + * + * @return parsed catalog load result + */ + public CatalogLoadResult loadLocalCatalog() { + return loadCatalog(resolveLocalCatalogFile()); + } + + /** + * Loads and parses one XML catalog file. + * + * @param catalogFile local catalog XML file + * @return parsed catalog load result + */ + public CatalogLoadResult loadCatalog(Path catalogFile) { + if (catalogFile == null || !Files.isRegularFile(catalogFile)) { + return new CatalogLoadResult( + null, + catalogFile, + false, + "Local terrain catalog file does not exist." + ); + } + + try { + Document document = parseXmlDocument(catalogFile.toFile()); + TerrainCatalog terrainCatalog = parseTerrainCatalog(document); + + return new CatalogLoadResult( + terrainCatalog, + catalogFile, + terrainCatalog != null && terrainCatalog.hasPackages(), + terrainCatalog != null && terrainCatalog.hasPackages() + ? "Terrain catalog loaded successfully." + : "Terrain catalog was loaded but contains no packages." + ); + } catch (Exception exception) { + return new CatalogLoadResult( + null, + catalogFile, + false, + "Could not parse terrain catalog: " + exception.getMessage() + ); + } + } + + private Path resolveLocalCatalogFile() { + return Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + LOCAL_TERRAIN_CATALOG_RELATIVE_PATH + )); + } + + private Document parseXmlDocument(File xmlFile) throws Exception { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + + DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + return documentBuilder.parse(xmlFile); + } + + private TerrainCatalog parseTerrainCatalog(Document document) { + Element root = document.getDocumentElement(); + if (root == null || !"terrainCatalog".equals(root.getTagName())) { + throw new IllegalArgumentException("Unexpected catalog root element."); + } + + int schemaVersion = parseIntAttribute(root, "schemaVersion", 1); + int catalogVersion = parseIntAttribute(root, "catalogVersion", 1); + String generatedAtUtc = root.getAttribute("generatedAtUtc"); + String regionSet = root.getAttribute("regionSet"); + String packageBaseUrl = root.getAttribute("packageBaseUrl"); + + String sourceAttribution = getDirectChildText(root, "sourceAttribution"); + String licenseNotice = getDirectChildText(root, "licenseNotice"); + String disclaimerNotice = getDirectChildText(root, "disclaimerNotice"); + + Element packagesElement = getFirstDirectChild(root, "packages"); + List packageEntries = new ArrayList<>(); + + if (packagesElement != null) { + List packageElements = getDirectChildElements(packagesElement, "package"); + + for (Element packageElement : packageElements) { + List tileIds = new ArrayList<>(); + + Element tileIdsElement = getFirstDirectChild(packageElement, "tileIds"); + if (tileIdsElement != null) { + for (Element tileIdElement : getDirectChildElements(tileIdsElement, "tileId")) { + tileIds.add(tileIdElement.getTextContent()); + } + } + + packageEntries.add(new TerrainCatalogPackageEntry( + packageElement.getAttribute("packageId"), + packageElement.getAttribute("regionType"), + packageElement.getAttribute("regionId"), + parseIntAttribute(packageElement, "packageVersion", 1), + packageElement.getAttribute("downloadUrl"), + parseLongAttribute(packageElement, "sizeBytes", 0L), + packageElement.getAttribute("sha256"), + parseDoubleAttribute(packageElement, "minLatitudeDeg", Double.NaN), + parseDoubleAttribute(packageElement, "maxLatitudeDeg", Double.NaN), + parseDoubleAttribute(packageElement, "minLongitudeDeg", Double.NaN), + parseDoubleAttribute(packageElement, "maxLongitudeDeg", Double.NaN), + tileIds, + packageElement.getAttribute("sourceDataset"), + getDirectChildText(packageElement, "sourceAttribution"), + getDirectChildText(packageElement, "derivedProductNotice"), + getDirectChildText(packageElement, "disclaimerNotice") + )); + } + } + + return new TerrainCatalog( + schemaVersion, + catalogVersion, + generatedAtUtc, + regionSet, + packageBaseUrl, + sourceAttribution, + licenseNotice, + disclaimerNotice, + packageEntries + ); + } + + private static List getDirectChildElements(Element parent, String tagName) { + List result = new ArrayList<>(); + + if (parent == null) { + return result; + } + + for (int index = 0; index < parent.getChildNodes().getLength(); index++) { + Node node = parent.getChildNodes().item(index); + if (node instanceof Element element && tagName.equals(element.getTagName())) { + result.add(element); + } + } + + return result; + } + + private static Element getFirstDirectChild(Element parent, String tagName) { + for (Element element : getDirectChildElements(parent, tagName)) { + return element; + } + return null; + } + + private static String getDirectChildText(Element parent, String tagName) { + Element child = getFirstDirectChild(parent, tagName); + return child == null ? "" : child.getTextContent().trim(); + } + + private static int parseIntAttribute(Element element, String attributeName, int defaultValue) { + try { + return Integer.parseInt(element.getAttribute(attributeName)); + } catch (Exception ignored) { + return defaultValue; + } + } + + private static long parseLongAttribute(Element element, String attributeName, long defaultValue) { + try { + return Long.parseLong(element.getAttribute(attributeName)); + } catch (Exception ignored) { + return defaultValue; + } + } + + private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) { + try { + return Double.parseDouble(element.getAttribute(attributeName)); + } catch (Exception ignored) { + return defaultValue; + } + } + + /** + * Download result for the XML terrain catalog. + * + * @param localCatalogFile target local file path + * @param success true if the download succeeded + * @param message human-readable result text + */ + public record CatalogDownloadResult( + Path localCatalogFile, + boolean success, + String message + ) { + } + + /** + * Parse/load result for one terrain catalog. + * + * @param terrainCatalog parsed catalog, or null + * @param localCatalogFile source file path + * @param success true if a usable catalog was loaded + * @param message human-readable result text + */ + public record CatalogLoadResult( + TerrainCatalog terrainCatalog, + Path localCatalogFile, + boolean success, + String message + ) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java b/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java new file mode 100644 index 0000000..8a419bd --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainCatalogPackageEntry.java @@ -0,0 +1,107 @@ +package kst4contest.view.map; + +import java.util.List; +import java.util.Locale; + +/** + * Immutable catalog entry describing one downloadable terrain package. + * + *

This model is intentionally shared between the future server-side catalog + * generation and the future desktop downloader.

+ */ +public record TerrainCatalogPackageEntry( + String packageId, + String regionType, + String regionId, + int packageVersion, + String downloadUrl, + long sizeBytes, + String sha256, + double minLatitudeDeg, + double maxLatitudeDeg, + double minLongitudeDeg, + double maxLongitudeDeg, + List tileIds, + String sourceDataset, + String sourceAttribution, + String derivedProductNotice, + String disclaimerNotice +) { + + public TerrainCatalogPackageEntry { + packageId = normalizeLower(packageId); + regionType = normalizeLower(regionType); + regionId = normalizeUpper(regionId); + downloadUrl = normalizeText(downloadUrl); + sha256 = normalizeLower(sha256); + tileIds = tileIds == null ? List.of() : tileIds.stream() + .map(TerrainCatalogPackageEntry::normalizeUpper) + .filter(value -> !value.isBlank()) + .distinct() + .toList(); + sourceDataset = normalizeLower(sourceDataset); + sourceAttribution = normalizeText(sourceAttribution); + derivedProductNotice = normalizeText(derivedProductNotice); + disclaimerNotice = normalizeText(disclaimerNotice); + + if (packageVersion < 0) { + packageVersion = 0; + } + + if (sizeBytes < 0L) { + sizeBytes = 0L; + } + } + + /** + * Returns true if the entry contains enough information for download/install logic. + * + * @return true if the entry is usable + */ + public boolean isUsable() { + return !packageId.isBlank() + && !regionType.isBlank() + && !regionId.isBlank() + && !downloadUrl.isBlank(); + } + + /** + * Returns true if the package matches the given region identifier. + * + * @param expectedRegionType region type, e.g. "maidenhead4" + * @param expectedRegionId region id, e.g. "JO22" + * @return true if both match + */ + public boolean matchesRegion(String expectedRegionType, String expectedRegionId) { + return regionType.equals(normalizeLower(expectedRegionType)) + && regionId.equals(normalizeUpper(expectedRegionId)); + } + + /** + * Returns true if the bounding box covers the given point. + * + * @param latitudeDeg latitude in degrees + * @param longitudeDeg longitude in degrees + * @return true if the point lies inside the package coverage box + */ + public boolean covers(double latitudeDeg, double longitudeDeg) { + return Double.isFinite(latitudeDeg) + && Double.isFinite(longitudeDeg) + && latitudeDeg >= minLatitudeDeg + && latitudeDeg <= maxLatitudeDeg + && longitudeDeg >= minLongitudeDeg + && longitudeDeg <= maxLongitudeDeg; + } + + private static String normalizeText(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + private static String normalizeLower(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java b/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java new file mode 100644 index 0000000..22f58e0 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainCoverageResolver.java @@ -0,0 +1,377 @@ +package kst4contest.view.map; + +import kst4contest.locatorUtils.Location; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * Resolves which terrain packages and which 1° tiles are required for + * a path analysis request. + * + *

This class is intentionally self-contained so it can later be reused + * unchanged both in the desktop client and in a future Spring service.

+ */ +public final class TerrainCoverageResolver { + + /** + * The current package scheme groups terrain downloads by Maidenhead-4 region. + */ + public static final String REGION_TYPE_MAIDENHEAD4 = "maidenhead4"; + + /** + * Fixed region set label for the first Europe-wide terrain service generation. + */ + public static final String REGION_SET_EU = "eu"; + + /** + * Sampling step used to determine required coverage along a path. + * + *

This is intentionally finer than the final profile sampling so that + * package/tile coverage is not missed on diagonal or border-crossing paths.

+ */ + private static final double COVERAGE_SAMPLE_STEP_KM = 10.0; + + /** + * Safety minimum number of sampling points per path. + */ + private static final int MIN_COVERAGE_SAMPLE_COUNT = 32; + + public TerrainCoverageResolver() { + } + + /** + * Resolves all package and tile requirements for the given path request. + * + * @param request immutable path analysis request + * @return combined package/tile coverage selection + */ + public TerrainCoverageSelection resolveCoverageForPath(PathAnalysisRequest request) { + if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) { + return TerrainCoverageSelection.empty(); + } + + return resolveCoverageForPath( + request.fromLatitudeDeg(), + request.fromLongitudeDeg(), + request.toLatitudeDeg(), + request.toLongitudeDeg() + ); + } + + /** + * Resolves all package and tile requirements for the given geographic path. + * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @return combined package/tile coverage selection + */ + public TerrainCoverageSelection resolveCoverageForPath(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg) { + + if (!areUsableCoordinates(fromLatitudeDeg, fromLongitudeDeg) + || !areUsableCoordinates(toLatitudeDeg, toLongitudeDeg)) { + return TerrainCoverageSelection.empty(); + } + + double totalDistanceKm = calculateGreatCircleDistanceKm( + fromLatitudeDeg, + fromLongitudeDeg, + toLatitudeDeg, + toLongitudeDeg + ); + + int coverageSampleCount = resolveCoverageSampleCount(totalDistanceKm); + + LinkedHashSet requiredRegionIds = new LinkedHashSet<>(); + LinkedHashSet requiredPackageIds = new LinkedHashSet<>(); + LinkedHashSet requiredTileIds = new LinkedHashSet<>(); + + for (int sampleIndex = 0; sampleIndex < coverageSampleCount; sampleIndex++) { + double t = coverageSampleCount == 1 + ? 0.0 + : (double) sampleIndex / (double) (coverageSampleCount - 1); + + GeoPoint point = interpolateGreatCirclePoint( + fromLatitudeDeg, + fromLongitudeDeg, + toLatitudeDeg, + toLongitudeDeg, + t + ); + + if (!areUsableCoordinates(point.latitudeDeg(), point.longitudeDeg())) { + continue; + } + + String maidenhead4 = toMaidenhead4(point.latitudeDeg(), point.longitudeDeg()); + if (!maidenhead4.isBlank()) { + requiredRegionIds.add(maidenhead4); + requiredPackageIds.add(buildPackageId(REGION_SET_EU, maidenhead4, 1)); + } + + int southDeg = floorDegree(point.latitudeDeg()); + int westDeg = floorDegree(point.longitudeDeg()); + requiredTileIds.add(TerrainTileMetadata.buildTileId(southDeg, westDeg)); + } + + return new TerrainCoverageSelection( + List.copyOf(requiredRegionIds), + List.copyOf(requiredPackageIds), + List.copyOf(requiredTileIds), + totalDistanceKm, + coverageSampleCount + ); + } + + /** + * Resolves only the required package ids for the given path. + * + * @param request immutable path analysis request + * @return ordered distinct package ids + */ + public List resolveRequiredPackageIdsForPath(PathAnalysisRequest request) { + return resolveCoverageForPath(request).packageIds(); + } + + /** + * Resolves only the required tile ids for the given path. + * + * @param request immutable path analysis request + * @return ordered distinct tile ids + */ + public List resolveRequiredTileIdsForPath(PathAnalysisRequest request) { + return resolveCoverageForPath(request).tileIds(); + } + + /** + * Builds the canonical package id for one region package. + * + * Example: + *
    + *
  • terrain-eu-jo22-v1
  • + *
+ * + * @param regionSet region set, e.g. "eu" + * @param regionId region id, e.g. "JO22" + * @param packageVersion package version + * @return canonical package id + */ + public static String buildPackageId(String regionSet, String regionId, int packageVersion) { + String normalizedRegionSet = regionSet == null ? "" : regionSet.trim().toLowerCase(Locale.ROOT); + String normalizedRegionId = regionId == null ? "" : regionId.trim().toLowerCase(Locale.ROOT); + int normalizedPackageVersion = Math.max(0, packageVersion); + + return String.format( + Locale.ROOT, + "terrain-%s-%s-v%d", + normalizedRegionSet, + normalizedRegionId, + normalizedPackageVersion + ); + } + + /** + * Returns the Maidenhead-4 region id for the given point. + * + * @param latitudeDeg latitude in degrees + * @param longitudeDeg longitude in degrees + * @return Maidenhead-4 id such as JO22, or empty string if conversion failed + */ + public static String toMaidenhead4(double latitudeDeg, double longitudeDeg) { + if (!areUsableCoordinates(latitudeDeg, longitudeDeg)) { + return ""; + } + + String maidenhead6 = Location.toMaidenhead(latitudeDeg, longitudeDeg); + if (maidenhead6 == null || maidenhead6.length() < 4) { + return ""; + } + + return maidenhead6.substring(0, 4).toUpperCase(Locale.ROOT); + } + + private static int resolveCoverageSampleCount(double totalDistanceKm) { + if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) { + return MIN_COVERAGE_SAMPLE_COUNT; + } + + int computedSampleCount = (int) Math.ceil(totalDistanceKm / COVERAGE_SAMPLE_STEP_KM) + 1; + return Math.max(MIN_COVERAGE_SAMPLE_COUNT, computedSampleCount); + } + + private static int floorDegree(double value) { + return (int) Math.floor(value); + } + + private static boolean areUsableCoordinates(double latitudeDeg, double longitudeDeg) { + return Double.isFinite(latitudeDeg) + && Double.isFinite(longitudeDeg) + && latitudeDeg >= -90.0 + && latitudeDeg <= 90.0 + && longitudeDeg >= -180.0 + && longitudeDeg <= 180.0; + } + + /** + * Great-circle distance in kilometers using the haversine formula. + * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @return great-circle distance in kilometers + */ + private static double calculateGreatCircleDistanceKm(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg) { + + double fromLatitudeRad = Math.toRadians(fromLatitudeDeg); + double fromLongitudeRad = Math.toRadians(fromLongitudeDeg); + double toLatitudeRad = Math.toRadians(toLatitudeDeg); + double toLongitudeRad = Math.toRadians(toLongitudeDeg); + + double deltaLatitude = toLatitudeRad - fromLatitudeRad; + double deltaLongitude = toLongitudeRad - fromLongitudeRad; + + double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0) + + Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad) + * Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0); + + double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a))); + + return 6371.009 * c; + } + + /** + * Interpolates one point on the great-circle path between the given endpoints. + * + * @param fromLatitudeDeg source latitude in degrees + * @param fromLongitudeDeg source longitude in degrees + * @param toLatitudeDeg target latitude in degrees + * @param toLongitudeDeg target longitude in degrees + * @param t interpolation factor within [0, 1] + * @return interpolated geographic point + */ + private static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg, + double t) { + + double clampedT = clamp(t, 0.0, 1.0); + + if (clampedT <= 0.0) { + return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg)); + } + + if (clampedT >= 1.0) { + return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg)); + } + + double fromLatitudeRad = Math.toRadians(fromLatitudeDeg); + double fromLongitudeRad = Math.toRadians(fromLongitudeDeg); + double toLatitudeRad = Math.toRadians(toLatitudeDeg); + double toLongitudeRad = Math.toRadians(toLongitudeDeg); + + double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad); + double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad); + double z1 = Math.sin(fromLatitudeRad); + + double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad); + double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad); + double z2 = Math.sin(toLatitudeRad); + + double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0); + double omega = Math.acos(dot); + + if (omega < 1e-12) { + double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT; + double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT); + return new GeoPoint(latitudeDeg, longitudeDeg); + } + + double sinOmega = Math.sin(omega); + double a = Math.sin((1.0 - clampedT) * omega) / sinOmega; + double b = Math.sin(clampedT * omega) / sinOmega; + + double x = a * x1 + b * x2; + double y = a * y1 + b * y2; + double z = a * z1 + b * z2; + + double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y)); + double longitudeRad = Math.atan2(y, x); + + return new GeoPoint( + Math.toDegrees(latitudeRad), + normalizeLongitudeDeg(Math.toDegrees(longitudeRad)) + ); + } + + private static double normalizeLongitudeDeg(double longitudeDeg) { + double normalized = longitudeDeg % 360.0; + + if (normalized > 180.0) { + normalized -= 360.0; + } else if (normalized <= -180.0) { + normalized += 360.0; + } + + return normalized; + } + + private static double clamp(double value, double minValue, double maxValue) { + return Math.max(minValue, Math.min(maxValue, value)); + } + + /** + * Combined path coverage selection. + * + * @param regionIds ordered distinct Maidenhead-4 region ids + * @param packageIds ordered distinct package ids + * @param tileIds ordered distinct 1° tile ids + * @param totalDistanceKm great-circle path distance in kilometers + * @param coverageSampleCount number of sampling points used for coverage resolution + */ + public record TerrainCoverageSelection( + List regionIds, + List packageIds, + List tileIds, + double totalDistanceKm, + int coverageSampleCount + ) { + public TerrainCoverageSelection { + regionIds = regionIds == null ? List.of() : List.copyOf(regionIds); + packageIds = packageIds == null ? List.of() : List.copyOf(packageIds); + tileIds = tileIds == null ? List.of() : List.copyOf(tileIds); + + if (coverageSampleCount < 0) { + coverageSampleCount = 0; + } + } + + public static TerrainCoverageSelection empty() { + return new TerrainCoverageSelection(List.of(), List.of(), List.of(), Double.NaN, 0); + } + + public boolean hasCoverage() { + return !packageIds.isEmpty() || !tileIds.isEmpty(); + } + } + + /** + * Small immutable geographic point for great-circle interpolation. + * + * @param latitudeDeg latitude in degrees + * @param longitudeDeg longitude in degrees + */ + private record GeoPoint(double latitudeDeg, double longitudeDeg) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java b/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java new file mode 100644 index 0000000..0dd19b9 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainPackageDownloader.java @@ -0,0 +1,259 @@ +package kst4contest.view.map; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +/** + * Downloads terrain package archives (*.tpak) to the local .praktiKST directory. + * + *

This implementation is intentionally simple and robust: + *

    + *
  • download packages by catalog entry
  • + *
  • store them below terrain/packages
  • + *
  • reuse existing files when the checksum already matches
  • + *
+ */ +public final class TerrainPackageDownloader { + + private static final String LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY = "terrain/packages"; + + private final HttpClient httpClient; + + public TerrainPackageDownloader() { + this.httpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * Downloads all required package ids that are present in the catalog. + * + * @param terrainCatalog current terrain catalog + * @param requiredPackageIds ordered package ids + * @return batch download result + */ + public BatchDownloadResult downloadPackages(TerrainCatalog terrainCatalog, List requiredPackageIds) { + List itemResults = new ArrayList<>(); + + if (terrainCatalog == null || requiredPackageIds == null || requiredPackageIds.isEmpty()) { + return new BatchDownloadResult(List.of(), "No terrain packages were requested."); + } + + for (String packageId : requiredPackageIds) { + PackageDownloadResult itemResult = terrainCatalog.findPackageById(packageId) + .map(this::downloadPackage) + .orElseGet(() -> new PackageDownloadResult( + packageId, + null, + false, + false, + "Package id is not present in the loaded catalog." + )); + + itemResults.add(itemResult); + } + + return new BatchDownloadResult(itemResults, buildBatchMessage(itemResults)); + } + + /** + * Downloads one package archive from one catalog entry. + * + * @param packageEntry catalog package entry + * @return download result + */ + public PackageDownloadResult downloadPackage(TerrainCatalogPackageEntry packageEntry) { + if (packageEntry == null || !packageEntry.isUsable()) { + return new PackageDownloadResult( + "", + null, + false, + false, + "Terrain package entry is missing or incomplete." + ); + } + + Path packagesDirectory = resolveLocalPackagesDirectory(); + Path localPackageFile = packagesDirectory.resolve(packageEntry.packageId() + ".tpak"); + + try { + Files.createDirectories(packagesDirectory); + + if (Files.isRegularFile(localPackageFile) + && !packageEntry.sha256().isBlank() + && packageEntry.sha256().equalsIgnoreCase(computeSha256(localPackageFile))) { + return new PackageDownloadResult( + packageEntry.packageId(), + localPackageFile, + true, + false, + "Terrain package is already present locally and matches the expected checksum." + ); + } + + Path tempFile = packagesDirectory.resolve(packageEntry.packageId() + ".download"); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(packageEntry.downloadUrl())) + .GET() + .build(); + + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofFile(tempFile) + ); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + Files.deleteIfExists(tempFile); + + return new PackageDownloadResult( + packageEntry.packageId(), + localPackageFile, + false, + false, + "Terrain package download failed with HTTP status " + response.statusCode() + "." + ); + } + + if (!packageEntry.sha256().isBlank()) { + String actualSha256 = computeSha256(tempFile); + if (!packageEntry.sha256().equalsIgnoreCase(actualSha256)) { + Files.deleteIfExists(tempFile); + + return new PackageDownloadResult( + packageEntry.packageId(), + localPackageFile, + false, + false, + "Downloaded terrain package checksum does not match the catalog." + ); + } + } + + Files.move(tempFile, localPackageFile, StandardCopyOption.REPLACE_EXISTING); + + return new PackageDownloadResult( + packageEntry.packageId(), + localPackageFile, + true, + true, + "Terrain package downloaded successfully." + ); + } catch (Exception exception) { + return new PackageDownloadResult( + packageEntry.packageId(), + localPackageFile, + false, + false, + "Terrain package download failed: " + exception.getMessage() + ); + } + } + + private Path resolveLocalPackagesDirectory() { + return Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY + )); + } + + private String computeSha256(Path file) throws Exception { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int bytesRead; + + while ((bytesRead = inputStream.read(buffer)) >= 0) { + messageDigest.update(buffer, 0, bytesRead); + } + } + + return HexFormat.of().formatHex(messageDigest.digest()); + } + + private String buildBatchMessage(List itemResults) { + int successfulCount = 0; + int downloadedCount = 0; + int reusedCount = 0; + + for (PackageDownloadResult itemResult : itemResults) { + if (itemResult.success()) { + successfulCount++; + } + if (itemResult.downloadedNow()) { + downloadedCount++; + } else if (itemResult.success()) { + reusedCount++; + } + } + + return "Terrain packages processed: " + + itemResults.size() + + ", successful: " + + successfulCount + + ", downloaded now: " + + downloadedCount + + ", reused locally: " + + reusedCount + + "."; + } + + /** + * Result of one terrain package download attempt. + * + * @param packageId canonical package id + * @param localPackageFile local target package file + * @param success true if a usable local package file is available afterward + * @param downloadedNow true if the package was downloaded in this run + * @param message human-readable result text + */ + public record PackageDownloadResult( + String packageId, + Path localPackageFile, + boolean success, + boolean downloadedNow, + String message + ) { + } + + /** + * Result of a batch terrain package download attempt. + * + * @param itemResults one result per requested package + * @param message human-readable summary text + */ + public record BatchDownloadResult( + List itemResults, + String message + ) { + public BatchDownloadResult { + itemResults = itemResults == null ? List.of() : List.copyOf(itemResults); + } + + public boolean allSuccessful() { + return !itemResults.isEmpty() && itemResults.stream().allMatch(PackageDownloadResult::success); + } + + public List successfulPackageFiles() { + return itemResults.stream() + .filter(PackageDownloadResult::success) + .map(PackageDownloadResult::localPackageFile) + .filter(path -> path != null) + .toList(); + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java b/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java new file mode 100644 index 0000000..bb33827 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainPackageInstaller.java @@ -0,0 +1,303 @@ +package kst4contest.view.map; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** + * Installs one downloaded terrain package into the local DEM directory. + * + *

This first vertical slice intentionally extracts raw Copernicus *_DEM.tif + * files, because the current runtime already knows how to scan and load them. + * That means we get an end-to-end working package flow quickly without first + * introducing a second runtime terrain format.

+ */ +public final class TerrainPackageInstaller { + + private static final String DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY = "dem/copernicus_glo30"; + + /** + * Installs one terrain package into the configured DEM root directory. + * + *

If no DEM root directory is configured, the default directory below + * .praktiKST is used automatically.

+ * + * @param packageFile local *.tpak file + * @param configuredDemRootDirectory user-configured DEM root directory text + * @return installation result + */ + public InstallResult installPackage(Path packageFile, String configuredDemRootDirectory) { + if (packageFile == null || !Files.isRegularFile(packageFile)) { + return new InstallResult( + null, + null, + 0, + false, + "Terrain package file does not exist." + ); + } + + try (ZipFile zipFile = new ZipFile(packageFile.toFile())) { + ZipEntry manifestEntry = zipFile.getEntry("manifest.xml"); + if (manifestEntry == null) { + return new InstallResult( + null, + null, + 0, + false, + "Terrain package does not contain manifest.xml." + ); + } + + TerrainPackageManifest terrainPackageManifest; + try (InputStream manifestInputStream = zipFile.getInputStream(manifestEntry)) { + terrainPackageManifest = parseManifest(manifestInputStream); + } + + if (terrainPackageManifest == null || !terrainPackageManifest.hasUsableTiles()) { + return new InstallResult( + terrainPackageManifest, + null, + 0, + false, + "Terrain package manifest is missing or contains no usable tiles." + ); + } + + Path demRootDirectory = resolveEffectiveDemRootDirectory(configuredDemRootDirectory); + Path targetPackageDirectory = demRootDirectory + .resolve("packages") + .resolve(terrainPackageManifest.packageId()); + + Files.createDirectories(targetPackageDirectory); + + int installedTileCount = 0; + + List zipEntries = zipFile.stream().toList(); + for (ZipEntry zipEntry : zipEntries) { + if (zipEntry.isDirectory()) { + continue; + } + + String entryName = zipEntry.getName(); + String fileName = Path.of(entryName).getFileName().toString(); + + if ("manifest.xml".equalsIgnoreCase(fileName)) { + Path targetFile = targetPackageDirectory.resolve("manifest.xml"); + extractZipEntry(zipFile, zipEntry, targetFile); + continue; + } + + if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(fileName)) { + continue; + } + + Path targetFile = targetPackageDirectory.resolve(fileName); + extractZipEntry(zipFile, zipEntry, targetFile); + installedTileCount++; + } + + if (installedTileCount <= 0) { + return new InstallResult( + terrainPackageManifest, + targetPackageDirectory, + 0, + false, + "Terrain package did not contain any supported Copernicus *_DEM.tif files." + ); + } + + return new InstallResult( + terrainPackageManifest, + targetPackageDirectory, + installedTileCount, + true, + "Installed " + + installedTileCount + + " DEM tile(s) into:\n" + + targetPackageDirectory.toAbsolutePath() + ); + } catch (Exception exception) { + return new InstallResult( + null, + null, + 0, + false, + "Terrain package installation failed: " + exception.getMessage() + ); + } + } + + private Path resolveEffectiveDemRootDirectory(String configuredDemRootDirectory) { + if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) { + return Path.of(configuredDemRootDirectory.trim()); + } + + return Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY + )); + } + + private void extractZipEntry(ZipFile zipFile, ZipEntry zipEntry, Path targetFile) throws Exception { + Path normalizedTarget = targetFile.normalize(); + + if (normalizedTarget.getParent() != null) { + Files.createDirectories(normalizedTarget.getParent()); + } + + try (InputStream entryInputStream = zipFile.getInputStream(zipEntry)) { + Files.copy(entryInputStream, normalizedTarget, StandardCopyOption.REPLACE_EXISTING); + } + } + + private TerrainPackageManifest parseManifest(InputStream inputStream) throws Exception { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + + DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + Document document = documentBuilder.parse(inputStream); + + Element root = document.getDocumentElement(); + if (root == null || !"terrainPackageManifest".equals(root.getTagName())) { + throw new IllegalArgumentException("Unexpected package manifest root element."); + } + + int schemaVersion = parseIntAttribute(root, "schemaVersion", 1); + String packageId = root.getAttribute("packageId"); + int packageVersion = parseIntAttribute(root, "packageVersion", 1); + String regionType = root.getAttribute("regionType"); + String regionId = root.getAttribute("regionId"); + + double minLatitudeDeg = parseDoubleAttribute(root, "minLatitudeDeg", Double.NaN); + double maxLatitudeDeg = parseDoubleAttribute(root, "maxLatitudeDeg", Double.NaN); + double minLongitudeDeg = parseDoubleAttribute(root, "minLongitudeDeg", Double.NaN); + double maxLongitudeDeg = parseDoubleAttribute(root, "maxLongitudeDeg", Double.NaN); + + String primaryDataset = root.getAttribute("primaryDataset"); + String fallbackDataset = root.getAttribute("fallbackDataset"); + String packageBuiltAtUtc = root.getAttribute("packageBuiltAtUtc"); + String packageSha256 = root.getAttribute("packageSha256"); + + String sourceAttribution = getDirectChildText(root, "sourceAttribution"); + String derivedProductNotice = getDirectChildText(root, "derivedProductNotice"); + String disclaimerNotice = getDirectChildText(root, "disclaimerNotice"); + + List tiles = new ArrayList<>(); + Element tilesElement = getFirstDirectChild(root, "tiles"); + + if (tilesElement != null) { + for (Element tileElement : getDirectChildElements(tilesElement, "tile")) { + tiles.add(new TerrainTileMetadata( + tileElement.getAttribute("tileId"), + tileElement.getAttribute("fileName"), + parseIntAttribute(tileElement, "southDeg", 0), + parseIntAttribute(tileElement, "westDeg", 0), + parseIntAttribute(tileElement, "width", 3601), + parseIntAttribute(tileElement, "height", 3601), + parseIntAttribute(tileElement, "arcSecondResolution", 1), + (short) parseIntAttribute(tileElement, "noDataValue", -32768), + tileElement.getAttribute("sourceDataset"), + tileElement.getAttribute("sha256") + )); + } + } + + return new TerrainPackageManifest( + schemaVersion, + packageId, + packageVersion, + regionType, + regionId, + minLatitudeDeg, + maxLatitudeDeg, + minLongitudeDeg, + maxLongitudeDeg, + primaryDataset, + fallbackDataset, + packageBuiltAtUtc, + sourceAttribution, + derivedProductNotice, + disclaimerNotice, + packageSha256, + tiles + ); + } + + private static List getDirectChildElements(Element parent, String tagName) { + List result = new ArrayList<>(); + + if (parent == null) { + return result; + } + + for (int index = 0; index < parent.getChildNodes().getLength(); index++) { + Node node = parent.getChildNodes().item(index); + if (node instanceof Element element && tagName.equals(element.getTagName())) { + result.add(element); + } + } + + return result; + } + + private static Element getFirstDirectChild(Element parent, String tagName) { + for (Element element : getDirectChildElements(parent, tagName)) { + return element; + } + return null; + } + + private static String getDirectChildText(Element parent, String tagName) { + Element child = getFirstDirectChild(parent, tagName); + return child == null ? "" : child.getTextContent().trim(); + } + + private static int parseIntAttribute(Element element, String attributeName, int defaultValue) { + try { + return Integer.parseInt(element.getAttribute(attributeName)); + } catch (Exception ignored) { + return defaultValue; + } + } + + private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) { + try { + return Double.parseDouble(element.getAttribute(attributeName)); + } catch (Exception ignored) { + return defaultValue; + } + } + + /** + * Result of one terrain package installation. + * + * @param terrainPackageManifest parsed manifest, or null + * @param targetPackageDirectory extraction target directory + * @param installedTileCount number of extracted DEM GeoTIFF files + * @param success true if at least one usable tile was installed + * @param message human-readable result text + */ + public record InstallResult( + TerrainPackageManifest terrainPackageManifest, + Path targetPackageDirectory, + int installedTileCount, + boolean success, + String message + ) { + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainPackageManifest.java b/src/main/java/kst4contest/view/map/TerrainPackageManifest.java new file mode 100644 index 0000000..9e21bc4 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainPackageManifest.java @@ -0,0 +1,101 @@ +package kst4contest.view.map; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Immutable manifest describing one locally installable terrain package. + * + *

This model is the authoritative description of the package contents after + * download and before/after installation.

+ */ +public record TerrainPackageManifest( + int schemaVersion, + String packageId, + int packageVersion, + String regionType, + String regionId, + double minLatitudeDeg, + double maxLatitudeDeg, + double minLongitudeDeg, + double maxLongitudeDeg, + String primaryDataset, + String fallbackDataset, + String packageBuiltAtUtc, + String sourceAttribution, + String derivedProductNotice, + String disclaimerNotice, + String packageSha256, + List tiles +) { + + public TerrainPackageManifest { + packageId = normalizeLower(packageId); + regionType = normalizeLower(regionType); + regionId = normalizeUpper(regionId); + primaryDataset = normalizeLower(primaryDataset); + fallbackDataset = normalizeLower(fallbackDataset); + packageBuiltAtUtc = normalizeText(packageBuiltAtUtc); + sourceAttribution = normalizeText(sourceAttribution); + derivedProductNotice = normalizeText(derivedProductNotice); + disclaimerNotice = normalizeText(disclaimerNotice); + packageSha256 = normalizeLower(packageSha256); + tiles = tiles == null ? List.of() : List.copyOf(tiles); + + if (schemaVersion < 0) { + schemaVersion = 0; + } + + if (packageVersion < 0) { + packageVersion = 0; + } + } + + /** + * Returns true if the manifest contains at least one usable tile. + * + * @return true if the manifest appears installable + */ + public boolean hasUsableTiles() { + return tiles.stream().anyMatch(TerrainTileMetadata::isUsable); + } + + /** + * Finds one tile metadata entry by canonical tile id. + * + * @param tileId canonical tile id + * @return tile metadata if found + */ + public Optional findTile(String tileId) { + String normalizedTileId = normalizeUpper(tileId); + + return tiles.stream() + .filter(tile -> tile.tileId().equals(normalizedTileId)) + .findFirst(); + } + + /** + * Returns true if the manifest belongs to the given region. + * + * @param expectedRegionType region type + * @param expectedRegionId region id + * @return true if region type and id match + */ + public boolean matchesRegion(String expectedRegionType, String expectedRegionId) { + return regionType.equals(normalizeLower(expectedRegionType)) + && regionId.equals(normalizeUpper(expectedRegionId)); + } + + private static String normalizeText(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + private static String normalizeLower(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainPackageService.java b/src/main/java/kst4contest/view/map/TerrainPackageService.java new file mode 100644 index 0000000..c733e59 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainPackageService.java @@ -0,0 +1,418 @@ +package kst4contest.view.map; + +import kst4contest.locatorUtils.Location; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * High-level orchestration service for terrain package preparation. + * + *

This is the first end-to-end vertical slice that connects: + *

    + *
  • path coverage resolution
  • + *
  • catalog download/load
  • + *
  • package download
  • + *
  • package installation into the local DEM directory
  • + *
+ * + *

The current implementation intentionally installs Copernicus *_DEM.tif files + * into the DEM root that is already used by the existing terrain provider chain. + * That allows us to reach a working automated download flow with very few + * intermediate refactorings.

+ */ +public final class TerrainPackageService { + + /** + * First default catalog URL for the future modular terrain service on hamradioonline.de. + * + *

This can later move into user preferences without changing the orchestration flow.

+ */ + public static final String DEFAULT_TERRAIN_CATALOG_URL = + "https://terrain.hamradioonline.de/catalog/terrain-catalog-v1.xml"; + + private final TerrainCoverageResolver terrainCoverageResolver; + private final TerrainCatalogClient terrainCatalogClient; + private final TerrainPackageDownloader terrainPackageDownloader; + private final TerrainPackageInstaller terrainPackageInstaller; + + public TerrainPackageService() { + this( + new TerrainCoverageResolver(), + new TerrainCatalogClient(), + new TerrainPackageDownloader(), + new TerrainPackageInstaller() + ); + } + + public TerrainPackageService(TerrainCoverageResolver terrainCoverageResolver, + TerrainCatalogClient terrainCatalogClient, + TerrainPackageDownloader terrainPackageDownloader, + TerrainPackageInstaller terrainPackageInstaller) { + this.terrainCoverageResolver = terrainCoverageResolver; + this.terrainCatalogClient = terrainCatalogClient; + this.terrainPackageDownloader = terrainPackageDownloader; + this.terrainPackageInstaller = terrainPackageInstaller; + } + + /** + * Prepares all terrain packages required for the given path using the default catalog URL. + * + * @param request path analysis request + * @param configuredDemRootDirectory currently configured DEM root directory + * @return combined terrain preparation result + */ + public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request, + String configuredDemRootDirectory) { + return prepareTerrainForPath( + request, + DEFAULT_TERRAIN_CATALOG_URL, + configuredDemRootDirectory + ); + } + + /** + * Prepares all terrain packages required for the given path. + * + *

Workflow: + *

    + *
  1. resolve required package ids from the path geometry
  2. + *
  3. download the catalog
  4. + *
  5. fallback to the last local catalog if the download fails
  6. + *
  7. download all matching required packages
  8. + *
  9. install all successfully downloaded packages into the DEM root
  10. + *
+ * + * @param request path analysis request + * @param catalogUrl terrain catalog URL + * @param configuredDemRootDirectory currently configured DEM root directory + * @return combined terrain preparation result + */ + public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request, + String catalogUrl, + String configuredDemRootDirectory) { + + if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) { + return TerrainPreparationResult.failure( + request, + TerrainCoverageResolver.TerrainCoverageSelection.empty(), + null, + null, + null, + List.of(), + List.of(), + "Path request is missing or does not contain usable endpoints." + ); + } + + TerrainCoverageResolver.TerrainCoverageSelection coverageSelection = + terrainCoverageResolver.resolveCoverageForPath(request); + + if (!coverageSelection.hasCoverage()) { + return TerrainPreparationResult.failure( + request, + coverageSelection, + null, + null, + null, + List.of(), + List.of(), + "No terrain coverage could be resolved for the requested path." + ); + } + + TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult = + terrainCatalogClient.downloadCatalog(catalogUrl); + + TerrainCatalogClient.CatalogLoadResult catalogLoadResult = + terrainCatalogClient.loadLocalCatalog(); + + if (!catalogLoadResult.success() || catalogLoadResult.terrainCatalog() == null) { + StringBuilder message = new StringBuilder(); + message.append("Terrain catalog is not available."); + + if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) { + message.append("\n\nDownload: ").append(catalogDownloadResult.message()); + } + + if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) { + message.append("\n\nLoad: ").append(catalogLoadResult.message()); + } + + return TerrainPreparationResult.failure( + request, + coverageSelection, + catalogDownloadResult, + catalogLoadResult, + null, + List.of(), + List.of(), + message.toString() + ); + } + + TerrainCatalog terrainCatalog = catalogLoadResult.terrainCatalog(); + + List missingPackageIds = coverageSelection.packageIds().stream() + .filter(packageId -> terrainCatalog.findPackageById(packageId).isEmpty()) + .toList(); + + List availablePackageIds = coverageSelection.packageIds().stream() + .filter(packageId -> terrainCatalog.findPackageById(packageId).isPresent()) + .toList(); + + if (availablePackageIds.isEmpty()) { + return TerrainPreparationResult.failure( + request, + coverageSelection, + catalogDownloadResult, + catalogLoadResult, + null, + missingPackageIds, + List.of(), + "None of the required terrain packages are present in the loaded catalog." + ); + } + + TerrainPackageDownloader.BatchDownloadResult batchDownloadResult = + terrainPackageDownloader.downloadPackages(terrainCatalog, availablePackageIds); + + List installResults = new ArrayList<>(); + for (Path packageFile : batchDownloadResult.successfulPackageFiles()) { + installResults.add( + terrainPackageInstaller.installPackage(packageFile, configuredDemRootDirectory) + ); + } + + String message = buildSummaryMessage( + coverageSelection, + catalogDownloadResult, + catalogLoadResult, + batchDownloadResult, + missingPackageIds, + installResults + ); + + boolean success = missingPackageIds.isEmpty() + && batchDownloadResult != null + && batchDownloadResult.allSuccessful() + && installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success); + + return new TerrainPreparationResult( + request, + coverageSelection, + catalogDownloadResult, + catalogLoadResult, + batchDownloadResult, + List.copyOf(missingPackageIds), + List.copyOf(installResults), + success, + message + ); + } + + /** + * Convenience helper for the likely future main workflow where only locators + * are available from the chat/channel context. + * + * @param ownLocator6 own 6-digit Maidenhead locator + * @param targetLocator6 target 6-digit Maidenhead locator + * @param targetCallsignRaw target callsign + * @param analysisFrequencyMHz analysis frequency in MHz + * @param ownAntennaHeightMeters own antenna height in meters AGL + * @param targetAntennaHeightMeters target antenna height in meters AGL + * @param configuredDemRootDirectory configured DEM root directory + * @return combined terrain preparation result + */ + public TerrainPreparationResult prepareTerrainForLocators(String ownLocator6, + String targetLocator6, + String targetCallsignRaw, + double analysisFrequencyMHz, + double ownAntennaHeightMeters, + double targetAntennaHeightMeters, + String configuredDemRootDirectory) { + + String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); + String normalizedTargetLocator6 = normalizeLocator6(targetLocator6); + + if (normalizedOwnLocator6.length() != 6 || normalizedTargetLocator6.length() != 6) { + return TerrainPreparationResult.failure( + null, + TerrainCoverageResolver.TerrainCoverageSelection.empty(), + null, + null, + null, + List.of(), + List.of(), + "Own locator or target locator is missing/invalid." + ); + } + + Location ownLocation = new Location(normalizedOwnLocator6); + Location targetLocation = new Location(normalizedTargetLocator6); + + PathAnalysisRequest request = new PathAnalysisRequest( + normalizedOwnLocator6, + ownLocation.getLatitude().toDegrees(), + ownLocation.getLongitude().toDegrees(), + normalizeCallsignRaw(targetCallsignRaw), + normalizedTargetLocator6, + targetLocation.getLatitude().toDegrees(), + targetLocation.getLongitude().toDegrees(), + Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0 + ? analysisFrequencyMHz + : PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ, + sanitizeAntennaHeightMeters(ownAntennaHeightMeters), + sanitizeAntennaHeightMeters(targetAntennaHeightMeters) + ); + + return prepareTerrainForPath(request, configuredDemRootDirectory); + } + + private String buildSummaryMessage(TerrainCoverageResolver.TerrainCoverageSelection coverageSelection, + TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult, + TerrainCatalogClient.CatalogLoadResult catalogLoadResult, + TerrainPackageDownloader.BatchDownloadResult batchDownloadResult, + List missingPackageIds, + List installResults) { + + int successfulInstallCount = (int) installResults.stream() + .filter(TerrainPackageInstaller.InstallResult::success) + .count(); + + int installedTileCount = installResults.stream() + .filter(TerrainPackageInstaller.InstallResult::success) + .mapToInt(TerrainPackageInstaller.InstallResult::installedTileCount) + .sum(); + + StringBuilder message = new StringBuilder(); + + message.append(String.format( + Locale.US, + "Required coverage: %d package(s), %d tile(s), %.1f km path, %d coverage samples.", + coverageSelection.packageIds().size(), + coverageSelection.tileIds().size(), + coverageSelection.totalDistanceKm(), + coverageSelection.coverageSampleCount() + )); + + if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) { + message.append("\n\nCatalog download: ").append(catalogDownloadResult.message()); + } + + if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) { + message.append("\n\nCatalog load: ").append(catalogLoadResult.message()); + } + + if (batchDownloadResult != null && batchDownloadResult.message() != null && !batchDownloadResult.message().isBlank()) { + message.append("\n\nPackage download: ").append(batchDownloadResult.message()); + } + + message.append(String.format( + Locale.US, + "\n\nInstallation: %d package(s) installed successfully, %d DEM tile(s) extracted.", + successfulInstallCount, + installedTileCount + )); + + if (missingPackageIds != null && !missingPackageIds.isEmpty()) { + message.append("\n\nMissing package ids in catalog:"); + for (String packageId : missingPackageIds) { + message.append("\n- ").append(packageId); + } + } + + List failedInstalls = installResults.stream() + .filter(result -> !result.success()) + .toList(); + + if (!failedInstalls.isEmpty()) { + message.append("\n\nFailed installations:"); + for (TerrainPackageInstaller.InstallResult failedInstall : failedInstalls) { + String packageId = failedInstall.terrainPackageManifest() == null + ? "" + : failedInstall.terrainPackageManifest().packageId(); + message.append("\n- ").append(packageId).append(": ").append(failedInstall.message()); + } + } + + return message.toString().trim(); + } + + private String normalizeLocator6(String locator6) { + if (locator6 == null) { + return ""; + } + + String trimmed = locator6.trim().toUpperCase(Locale.ROOT); + return trimmed.length() >= 6 ? trimmed.substring(0, 6) : trimmed; + } + + private String normalizeCallsignRaw(String callSignRaw) { + return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT); + } + + private double sanitizeAntennaHeightMeters(double antennaHeightMeters) { + if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) { + return 0.0; + } + return antennaHeightMeters; + } + + /** + * Combined result of one terrain preparation run. + * + * @param request original path request + * @param coverageSelection resolved required coverage + * @param catalogDownloadResult catalog download result + * @param catalogLoadResult catalog load result + * @param batchDownloadResult package download result + * @param missingPackageIds required package ids that are not present in the catalog + * @param installResults installation results for downloaded packages + * @param success true if full required package coverage was available and at least one package was installed successfully + * @param message human-readable summary message + */ + public record TerrainPreparationResult( + PathAnalysisRequest request, + TerrainCoverageResolver.TerrainCoverageSelection coverageSelection, + TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult, + TerrainCatalogClient.CatalogLoadResult catalogLoadResult, + TerrainPackageDownloader.BatchDownloadResult batchDownloadResult, + List missingPackageIds, + List installResults, + boolean success, + String message + ) { + public TerrainPreparationResult { + missingPackageIds = missingPackageIds == null ? List.of() : List.copyOf(missingPackageIds); + installResults = installResults == null ? List.of() : List.copyOf(installResults); + } + + public static TerrainPreparationResult failure(PathAnalysisRequest request, + TerrainCoverageResolver.TerrainCoverageSelection coverageSelection, + TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult, + TerrainCatalogClient.CatalogLoadResult catalogLoadResult, + TerrainPackageDownloader.BatchDownloadResult batchDownloadResult, + List missingPackageIds, + List installResults, + String message) { + return new TerrainPreparationResult( + request, + coverageSelection, + catalogDownloadResult, + catalogLoadResult, + batchDownloadResult, + missingPackageIds, + installResults, + false, + message + ); + } + + public boolean hasInstalledAnything() { + return installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success); + } + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java b/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java new file mode 100644 index 0000000..c01b5e0 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainProfileCacheRepository.java @@ -0,0 +1,300 @@ +package kst4contest.view.map; + +import kst4contest.ApplicationConstants; +import kst4contest.controller.DBController; +import kst4contest.utils.ApplicationFileUtils; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Persistent terrain profile cache stored in the application's existing SQLite database. + * + * The cache is intentionally owner-bound: + * if the configured own callsign or own locator changes, all cached terrain + * profiles are cleared automatically. + */ +public final class TerrainProfileCacheRepository { + + private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw"; + private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6"; + + private final String databasePath; + + public TerrainProfileCacheRepository() { + ApplicationFileUtils.copyResourceIfRequired( + ApplicationConstants.APPLICATION_NAME, + DBController.DATABASE_RESOURCE, + DBController.DATABASE_FILE + ); + + this.databasePath = ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + DBController.DATABASE_FILE + ); + } + + public synchronized Optional load(String ownerCallsignRaw, + String ownerLocator6, + String targetCallsignRaw, + String targetLocator6, + int sampleCount, + String providerId) { + + try (Connection connection = openConnection()) { + ensureSchema(connection); + ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6); + + String sql = """ + SELECT profile_points_text, source_name, synthetic + FROM TerrainProfileCache + WHERE owner_callsign_raw = ? + AND owner_locator6 = ? + AND target_callsign_raw = ? + AND target_locator6 = ? + AND sample_count = ? + AND provider_id = ? + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, normalize(ownerCallsignRaw)); + statement.setString(2, normalize(ownerLocator6)); + statement.setString(3, normalize(targetCallsignRaw)); + statement.setString(4, normalize(targetLocator6)); + statement.setInt(5, sampleCount); + statement.setString(6, normalize(providerId)); + + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + + String serializedProfile = resultSet.getString("profile_points_text"); + String sourceName = resultSet.getString("source_name"); + boolean synthetic = resultSet.getInt("synthetic") != 0; + + List points = deserializeProfile(serializedProfile); + TerrainProfileData result = new TerrainProfileData(points, sourceName, synthetic); + + return result.hasUsableProfile() ? Optional.of(result) : Optional.empty(); + } + } + } catch (Exception exception) { + System.err.println("[StationMap] Terrain cache load failed: " + exception.getMessage()); + return Optional.empty(); + } + } + + public synchronized void save(String ownerCallsignRaw, + String ownerLocator6, + String targetCallsignRaw, + String targetLocator6, + int sampleCount, + String providerId, + TerrainProfileData terrainProfileData) { + + if (terrainProfileData == null || !terrainProfileData.hasUsableProfile() || terrainProfileData.synthetic()) { + return; + } + + try (Connection connection = openConnection()) { + ensureSchema(connection); + ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6); + + String sql = """ + INSERT INTO TerrainProfileCache ( + owner_callsign_raw, + owner_locator6, + target_callsign_raw, + target_locator6, + sample_count, + provider_id, + profile_points_text, + source_name, + synthetic, + created_at_epoch_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT( + owner_callsign_raw, + owner_locator6, + target_callsign_raw, + target_locator6, + sample_count, + provider_id + ) DO UPDATE SET + profile_points_text = excluded.profile_points_text, + source_name = excluded.source_name, + synthetic = excluded.synthetic, + created_at_epoch_ms = excluded.created_at_epoch_ms + """; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, normalize(ownerCallsignRaw)); + statement.setString(2, normalize(ownerLocator6)); + statement.setString(3, normalize(targetCallsignRaw)); + statement.setString(4, normalize(targetLocator6)); + statement.setInt(5, sampleCount); + statement.setString(6, normalize(providerId)); + statement.setString(7, serializeProfile(terrainProfileData.profilePoints())); + statement.setString(8, terrainProfileData.sourceName()); + statement.setInt(9, terrainProfileData.synthetic() ? 1 : 0); + statement.setLong(10, System.currentTimeMillis()); + statement.executeUpdate(); + } + } catch (Exception exception) { + System.err.println("[StationMap] Terrain cache save failed: " + exception.getMessage()); + } + } + + private Connection openConnection() throws Exception { + return DriverManager.getConnection("jdbc:sqlite:" + databasePath); + } + + private void ensureSchema(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate(""" + CREATE TABLE IF NOT EXISTS TerrainProfileCache ( + owner_callsign_raw TEXT NOT NULL, + owner_locator6 TEXT NOT NULL, + target_callsign_raw TEXT NOT NULL, + target_locator6 TEXT NOT NULL, + sample_count INTEGER NOT NULL, + provider_id TEXT NOT NULL, + profile_points_text TEXT NOT NULL, + source_name TEXT NOT NULL, + synthetic INTEGER NOT NULL DEFAULT 0, + created_at_epoch_ms INTEGER NOT NULL, + PRIMARY KEY ( + owner_callsign_raw, + owner_locator6, + target_callsign_raw, + target_locator6, + sample_count, + provider_id + ) + ) + """); + + statement.executeUpdate(""" + CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta ( + meta_key TEXT NOT NULL PRIMARY KEY, + meta_value TEXT NOT NULL + ) + """); + } + } + + private void ensureOwnerIdentity(Connection connection, + String currentOwnerCallsignRaw, + String currentOwnerLocator6) throws Exception { + + String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw); + String normalizedOwnerLocator6 = normalize(currentOwnerLocator6); + + String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW); + String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6); + + boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw); + boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6); + + if (callsignChanged || locatorChanged) { + clearTerrainCache(connection); + } + + writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw); + writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6); + } + + private void clearTerrainCache(Connection connection) throws Exception { + try (Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM TerrainProfileCache"); + } + } + + private String readMetaValue(Connection connection, String key) throws Exception { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) { + statement.setString(1, key); + + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getString(1) : null; + } + } + } + + private void writeMetaValue(Connection connection, String key, String value) throws Exception { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value) + VALUES (?, ?) + ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value + """)) { + statement.setString(1, key); + statement.setString(2, value == null ? "" : value); + statement.executeUpdate(); + } + } + + private String serializeProfile(List profilePoints) { + StringBuilder builder = new StringBuilder(); + + for (PathProfilePoint point : profilePoints) { + if (point == null) { + continue; + } + + if (!builder.isEmpty()) { + builder.append('\n'); + } + + builder.append(String.format( + Locale.US, + "%.6f;%.8f;%.8f;%.3f", + point.distanceKm(), + point.latitudeDeg(), + point.longitudeDeg(), + point.elevationMeters() + )); + } + + return builder.toString(); + } + + private List deserializeProfile(String serializedProfile) { + if (serializedProfile == null || serializedProfile.isBlank()) { + return List.of(); + } + + String[] lines = serializedProfile.split("\\R+"); + List points = new ArrayList<>(lines.length); + + for (String line : lines) { + String[] parts = line.split(";"); + if (parts.length != 4) { + continue; + } + + try { + points.add(new PathProfilePoint( + Double.parseDouble(parts[0]), + Double.parseDouble(parts[1]), + Double.parseDouble(parts[2]), + Double.parseDouble(parts[3]) + )); + } catch (NumberFormatException ignored) { + } + } + + return List.copyOf(points); + } + + private String normalize(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainProfileData.java b/src/main/java/kst4contest/view/map/TerrainProfileData.java new file mode 100644 index 0000000..caac52b --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainProfileData.java @@ -0,0 +1,26 @@ +package kst4contest.view.map; + +import java.util.List; + +/** + * Immutable terrain/profile payload including source metadata. + */ +public record TerrainProfileData( + List profilePoints, + String sourceName, + boolean synthetic +) { + + public TerrainProfileData { + profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints); + sourceName = sourceName == null ? "" : sourceName.trim(); + } + + public static TerrainProfileData empty(String sourceName) { + return new TerrainProfileData(List.of(), sourceName, false); + } + + public boolean hasUsableProfile() { + return profilePoints.size() >= 2; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainProfileProvider.java b/src/main/java/kst4contest/view/map/TerrainProfileProvider.java new file mode 100644 index 0000000..21fa452 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainProfileProvider.java @@ -0,0 +1,9 @@ +package kst4contest.view.map; + +/** + * Abstraction for terrain/profile retrieval. + */ +public interface TerrainProfileProvider { + + TerrainProfileData loadProfile(TerrainProfileRequest request); +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainProfileRequest.java b/src/main/java/kst4contest/view/map/TerrainProfileRequest.java new file mode 100644 index 0000000..31c8221 --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainProfileRequest.java @@ -0,0 +1,26 @@ +package kst4contest.view.map; + +/** + * Immutable request for terrain/profile sampling between two endpoints. + */ +public record TerrainProfileRequest( + double fromLatitudeDeg, + double fromLongitudeDeg, + double toLatitudeDeg, + double toLongitudeDeg, + double totalDistanceKm, + int requestedSampleCount +) { + public TerrainProfileRequest { + requestedSampleCount = Math.max(0, requestedSampleCount); + } + + public boolean hasUsableEndpoints() { + return Double.isFinite(fromLatitudeDeg) + && Double.isFinite(fromLongitudeDeg) + && Double.isFinite(toLatitudeDeg) + && Double.isFinite(toLongitudeDeg) + && Double.isFinite(totalDistanceKm) + && totalDistanceKm >= 0.0; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/TerrainTileMetadata.java b/src/main/java/kst4contest/view/map/TerrainTileMetadata.java new file mode 100644 index 0000000..9f65afb --- /dev/null +++ b/src/main/java/kst4contest/view/map/TerrainTileMetadata.java @@ -0,0 +1,132 @@ +package kst4contest.view.map; + +import java.util.Locale; + +/** + * Immutable metadata for one internal terrain tile. + * + *

This model intentionally describes the runtime-ready tile after ingestion + * into the KST4Contest terrain format, not the original upstream GeoTIFF.

+ */ +public record TerrainTileMetadata( + String tileId, + String fileName, + int southDeg, + int westDeg, + int width, + int height, + int arcSecondResolution, + short noDataValue, + String sourceDataset, + String sha256 +) { + + public TerrainTileMetadata { + tileId = normalizeUpper(tileId); + fileName = normalizeText(fileName); + sourceDataset = normalizeText(sourceDataset); + sha256 = normalizeLower(sha256); + + if (width < 0) { + width = 0; + } + + if (height < 0) { + height = 0; + } + + if (arcSecondResolution < 0) { + arcSecondResolution = 0; + } + } + + /** + * Returns the northern edge of the covered 1° x 1° geocell. + * + * @return north edge latitude in degrees + */ + public int northDeg() { + return southDeg + 1; + } + + /** + * Returns the eastern edge of the covered 1° x 1° geocell. + * + * @return east edge longitude in degrees + */ + public int eastDeg() { + return westDeg + 1; + } + + /** + * Returns true if this metadata appears complete enough for installation/runtime. + * + * @return true if the essential fields are usable + */ + public boolean isUsable() { + return !tileId.isBlank() + && !fileName.isBlank() + && width > 0 + && height > 0 + && arcSecondResolution > 0; + } + + /** + * Returns true if the tile covers the given geographic sample point. + * + *

The tile is interpreted as the 1° x 1° cell + * [southDeg, southDeg+1) x [westDeg, westDeg+1).

+ * + * @param latitudeDeg sample latitude in degrees + * @param longitudeDeg sample longitude in degrees + * @return true if the point lies inside this tile + */ + public boolean covers(double latitudeDeg, double longitudeDeg) { + return Double.isFinite(latitudeDeg) + && Double.isFinite(longitudeDeg) + && latitudeDeg >= southDeg + && latitudeDeg < northDeg() + && longitudeDeg >= westDeg + && longitudeDeg < eastDeg(); + } + + /** + * Builds the canonical internal tile id for one 1° x 1° cell. + * + * Examples: + *
    + *
  • N51_E007
  • + *
  • N52_W003
  • + *
  • S01_E010
  • + *
+ * + * @param southDeg southern cell boundary in degrees + * @param westDeg western cell boundary in degrees + * @return canonical tile id + */ + public static String buildTileId(int southDeg, int westDeg) { + String latPrefix = southDeg >= 0 ? "N" : "S"; + String lonPrefix = westDeg >= 0 ? "E" : "W"; + + return String.format( + Locale.ROOT, + "%s%02d_%s%03d", + latPrefix, + Math.abs(southDeg), + lonPrefix, + Math.abs(westDeg) + ); + } + + private static String normalizeText(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeUpper(String value) { + return value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + } + + private static String normalizeLower(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/map/mapTest.java b/src/main/java/kst4contest/view/map/mapTest.java new file mode 100644 index 0000000..d00978f --- /dev/null +++ b/src/main/java/kst4contest/view/map/mapTest.java @@ -0,0 +1,24 @@ +package kst4contest.view.map; + +public class mapTest { + + public static void main(String[] args) { + + TerrainPackageService terrainPackageService = new TerrainPackageService(); + + TerrainPackageService.TerrainPreparationResult result = + terrainPackageService.prepareTerrainForLocators( + "JO51IJ", + "JO22JK", + "TEST", + 144.300, + 10.0, + 10.0, + "" + ); + + System.out.println(result.success()); + System.out.println(result.message()); + + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 5382c37..66cd3cb 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -5,10 +5,14 @@ module praktiKST { requires jdk.xml.dom; requires java.sql; requires javafx.media; - requires java.logging; + requires jdk.jsobject; + requires java.net.http; + requires java.desktop; exports kst4contest.controller.interfaces; exports kst4contest.controller; exports kst4contest.locatorUtils; exports kst4contest.model; exports kst4contest.view; + + opens kst4contest.view.map to javafx.web; } \ No newline at end of file diff --git a/udpReaderBackup.txt b/udpReaderBackup.txt new file mode 100644 index 0000000..54efa10 --- /dev/null +++ b/udpReaderBackup.txt @@ -0,0 +1,2368 @@ + +9A5R;Zeljko;JN95MM;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5M;Marc;JN49FL;StringProperty [value: 144.243 ];true;true;false;false;false;false;false;false +DM5M;Marc;JO51JL;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5M;Marc;JO51JL;StringProperty [value: null];true;true;true;false;false;false;false;false +DM5M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0GEB;Marc;JO51IJ;StringProperty [value: 144.174 ];true;true;false;false;false;false;false;false +DF0GEB;Marc;JO51IJ;StringProperty [value: 144.174 ];true;true;true;false;false;false;false;false +DF9QX;Matthias;JO42HD;StringProperty [value: null];true;false;false;false;false;false;false;false +DF9QX;Matthias;JO42HD;StringProperty [value: null];true;true;false;false;false;false;false;false +DF9QX;Matthias;JO42HD;StringProperty [value: null];true;true;true;false;false;false;false;false +9A1AAY;RKNG;JN85PJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5AMF;Marc;JO51IJ;StringProperty [value: null];true;false;true;false;false;false;false;false +;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM2EUN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2ALF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6KDS;Klaus;JO50KQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0YY;Berlin 432.240;JO62GD;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2AKT;Jens;JO50NV;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AAJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HBS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0NA;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DD6YR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5D;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM3F;Fred 70/23cm;JO60OM;StringProperty [value: null];true;false;true;false;false;false;false;false +DG3RAP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Z;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF0YY;Berlin 432.240;JO62GD;StringProperty [value: 432.240 ];true;false;true;false;false;false;false;false +OE5D;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM3F;Fred 70/23cm;JO60OM;StringProperty [value: null];true;false;true;false;false;false;false;false +OL3Z;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4NWM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Z;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DN4DI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0WF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL5MO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1X;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG7NBE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OL7C;Club 2m;JO60JJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3BST;Sven 2x9 /2x16;JO51KW;StringProperty [value: null];true;true;false;false;false;false;false;false +DR2L;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7C;3cm up;JO50WB;StringProperty [value: 377 ];true;true;false;false;false;false;false;false +DL6ON;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5DAW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK2LB;Torsten;JO53LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4VW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG2ON;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7NX;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3LAR;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG3AWN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN5PW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL1YDI;Dirk 2m/9Ele;JO42FA;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2WC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3QB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3LGI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF2KD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2YCT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6AA;Sven;JO43JH;StringProperty [value: 165 ];true;false;true;false;false;false;false;false +DL6ZEJ/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2RMC;Tom 70 + 23cm;JO50WB;StringProperty [value: 432.179.4 ];true;false;true;false;false;false;false;false +DK7SG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1AK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ9FC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG2YIQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3NGN/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL9OLI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AXC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2BQC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO4HBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2ALF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL0ARN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6JH;Julian 2 70 3cm;JO51TX;StringProperty [value: null];true;true;false;false;false;false;false;false +DF5EM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1NAS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3UKW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2BK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3LGI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3BST;Sven 2x9 /2x16;JO51KW;StringProperty [value: null];true;true;true;false;false;false;false;false +DJ1OB;Olli - 2m;JN48UG;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5D;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5EZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1NPF/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7GA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1AYJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RDO;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2NDL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6UJH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA2R;Hans-Jürgen;JN69EM;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4HMS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5DWF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8ZT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5HQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8LR;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4MA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2CF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HTI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1AKY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ABB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0KTL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6RI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2L;Team 2m;JN99BN;StringProperty [value: 144.230 ];true;true;false;false;false;false;false;false +OR6T;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF1ASG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1RLB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1RWO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKT;Jens;JO50NV;StringProperty [value: null];true;true;true;false;false;false;false;false +DL9AAA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5F;Marcel 2/70/23;JO71ES;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ANS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0NF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DO4SKH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9BBD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1HSF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RMR;Club;JO60QC;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4UF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6CNG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7ZN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4RL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3ZF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1OHL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB3LO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1XRK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH0HD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2YDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH7ACI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2D;2x8 QRO SSB/CW;JO64ND;StringProperty [value: 180 ];true;true;false;false;false;false;false;false +DH1GSD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2LBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1MJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3AK;Detlef;JO52GJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3AX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ZXG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5OU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2EV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM6AT;Andreas;JO52JG;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1UF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5XAT;Holger 2m only;JO53CN;StringProperty [value: null];true;true;false;false;false;false;false;false +DO2PSW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OZ6TY;Henning;JO55XE;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4OP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4WK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6OL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3VE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH0LS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2JST;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN5PW;Philipp 2m SSB;JO50LQ;StringProperty [value: null];true;true;true;false;false;false;false;false +DC7EF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3LAR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC7BK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8GHH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0BQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8AMB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO8THW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2LSM;Guenter;JO61GH;StringProperty [value: 144.065 ];true;true;false;false;false;false;false;false +DL5ZA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MW;Ralf 2m;JO50KQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8CV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2NDL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5NE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0DLE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6NBS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH6DAO;Ray;JO41CN;StringProperty [value: null];true;true;false;false;false;false;false;false +DH0CF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1PAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2MDU;Chris;JN58RF;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7AW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG2SER;Carsten;JN58OH;StringProperty [value: 337 ];true;true;false;false;false;false;false;false +DC9UN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2FR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2WU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5MO;Thomas 2m/7023;JO50LQ;StringProperty [value: null];true;true;true;false;false;false;false;false +9A1MC;Mladen 144;JN85QJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2MHO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6KDS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ2DA;Hans 432;JO61PG;StringProperty [value: null];true;false;true;false;false;false;false;false +DM5GG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1AYJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL9AAA/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3BUA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6AC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP7VVB;Maciek;JO91VQ;StringProperty [value: 340 ];true;true;false;false;false;false;false;false +SP6CPF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD6OM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5BRE;Ronny 70/23/13/9;JO62VM;StringProperty [value: 185 ];true;true;false;false;false;false;false;false +DG5BRE;Ronny 70/23/13/9;JO62VM;StringProperty [value: 185 ];true;true;true;false;false;false;false;false +DH5BS;erni 6/2/70;JO63UW;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6EB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1GSD;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DO1MEW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1HSF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3RHN;Rüdiger 2m;JO63PM;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3HXS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK0FWS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC5IMM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8OAZ/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HSX;Heiko 2m/70cm;JO51XC;StringProperty [value: 300 ];true;false;true;false;false;false;false;false +DJ2NR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1VRY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2RAS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8QS;Heiko;JO43KH;StringProperty [value: 432288 ];true;false;true;false;false;false;false;false +OE3NHW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SAM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2FFW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL9MKA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3FKS/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AWD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK6R;144 only;JO70HG;StringProperty [value: 144.176 ];true;true;false;false;false;false;false;false +DH9NFM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB5SM;Klaus-2m;JN59LE;StringProperty [value: 144.200 ];true;true;false;false;false;false;false;false +DL0GM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL4N;club 2m;JO60VR;StringProperty [value: 144.232.8 ];true;true;false;false;false;false;false;false +DL2NBU;Peter;JN59KQ;StringProperty [value: 144.239 ];true;true;false;false;false;false;false;false +DM5D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1DSX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KCB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +S57O;Frank;JN86DT;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KKI;OK1KKI 144MHz;JN79NF;StringProperty [value: 144310 ];true;true;false;false;false;false;false;false +OL7M;OL7M;JO80FG;StringProperty [value: 144.341 ];true;true;false;false;false;false;false;false +OK1KQH;Radioclub;JN79GO;StringProperty [value: 144.351 ];true;true;false;false;false;false;false;false +DQ2C;2m only;JN48WM;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1JHR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7AC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM6DN;2x12ele 950asl;JN99FI;StringProperty [value: 144.155 ];true;true;false;false;false;false;false;false +DL5ALW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG3FFM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG3FFM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM1PIO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2C;70cm-76GHz;JN99AJ;StringProperty [value: 432,333 ];true;false;true;false;false;false;false;false +DF0YY;Berlin.240;JO62GD;StringProperty [value: 432.240 ];true;true;true;false;false;false;false;false +DD2ML;Ulli 4x10 QRO;JN68GI;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2CB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1ATI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0LU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2C;70cm-76GHz;JN99AJ;StringProperty [value: 432 333.000 ];true;false;true;false;false;false;false;false +DM5B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK5T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE2M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5OMH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2UPG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8MEM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KKP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5OCD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1HXL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8TM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO7WM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2AJ;Andy 2/4/6m;JN48MW;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2FFW;FRANK 2m;JO50LQ;StringProperty [value: 203 ];true;true;true;false;false;false;false;false +9A1N;Radio klub;JN85LI;StringProperty [value: 216 ];true;false;true;false;false;false;false;false +DL6NEJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2MAJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7SCB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DP9X;Pom 144SSB;JO42SC;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0OB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5BL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH0LS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR6T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6QS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6NI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6FBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0GL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2PZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB7MM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8EAY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1E;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2PZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PC2K;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO8HK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6FE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +G2N;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ZBS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PA3FVE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH7FFE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0PU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1MF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PA0GSM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1KUB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5CAT;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL8PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5HQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8IAB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL1SUZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1FY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM4KCS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9FBA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3NCR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4YAJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2OY;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL1AVF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5OCD;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DO2NFS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SDQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8LR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1X;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3LE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1OIB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1LDZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO4OFR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0OGJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL5OAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ8AK;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +HB9TTY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1ASG;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL9FBF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF9LW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9NDP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ALW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF4HA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5IR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ4KD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6NI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DB1RUL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5DJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM3KOM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HG7M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM5AW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KRT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK3TFA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK7PY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG8LG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KJU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1HF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC8RI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK9TF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2FLY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0OGJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8OI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN7OMB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5F;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DQ55DIG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SQ1GU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7LTM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD5DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0PP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD9FJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AAJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +S53O;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2LSM;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2FQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK3ZQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1PR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8NSB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9IAB/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8RH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7NBE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK6NJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL7PV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RDO;ok1rdo;JN69KL;StringProperty [value: null];true;true;true;false;false;false;false;false +DL4M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2YL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3SFB;Martin 70cm;JN48WM;StringProperty [value: 432.224 ];true;false;true;false;false;false;false;false +F8KID;Club;JN38AT;StringProperty [value: 144 254 ];true;true;false;false;false;false;false;false +DL2DHM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2IT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8XC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ZBG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1AKY;Jens 70;JO50LQ;StringProperty [value: null];true;true;true;false;false;false;false;false +OE5LHM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7AVZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1NPF;Roman 2/70;JO70UK;StringProperty [value: 144.351 ];true;true;false;false;false;false;false;false +F6KFH;RC 70cm;JN39OC;StringProperty [value: 267 ];true;false;true;false;false;false;false;false +OK2O;club;JN89IW;StringProperty [value: 144,317 ];true;true;false;false;false;false;false;false +DL0WX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +TM5R;Didier;JN19BQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9NDP;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +ON8TT/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9GF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KAD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7AX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KAA;Club 2m;JN79QJ;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1DMP;Milan 2m/70cm;JN79IX;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5JTS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1IME;Ota 2m;JO70FB;StringProperty [value: null];true;true;false;false;false;false;false;false +G3XDY;John;JO02OB;StringProperty [value: 144.214 ];true;true;false;false;false;false;false;false +OK1KCR;BIG GUN;JN79VS;StringProperty [value: 144.162 ];true;true;false;false;false;false;false;false +OK1WAV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2RZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9NM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Z;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP9KDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ5NN;MonteNerone144;JN63GN;StringProperty [value: 144.100 ];true;true;false;false;false;false;false;false +DL9NM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7ACN;Jens, 144;JN49JC;StringProperty [value: 284 ];true;true;false;false;false;false;false;false +DL0NF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD7PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL7W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9NE;Contest Team;JN37JC;StringProperty [value: 273.4 ];true;true;false;false;false;false;false;false +DL6GCK;Konrad;JN47OR;StringProperty [value: 338 ];true;true;false;false;false;false;false;false +OK1KCB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0GFF/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3D;Club;JO62IH;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0A;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S59P;Club JN86AO;JN86AO;StringProperty [value: 144108 ];true;true;false;false;false;false;false;false +OL7C;Radio Club;JO60JJ;StringProperty [value: 144.211 ];true;true;false;false;false;false;false;false +OE5D;ARGE Braunau;JN68PC;StringProperty [value: 328 ];true;true;true;false;false;false;false;false +OK2R;70cm;JN89JM;StringProperty [value: 240 ];true;true;false;false;false;false;false;false +9A0V;RC Vukovar;JN95PE;StringProperty [value: 144.155 ];true;true;false;false;false;false;false;false +HG1Z;Team 2m;JN86KU;StringProperty [value: 335 ];true;true;false;false;false;false;false;false +9A8D;radio klub Dalj;JN95LM;StringProperty [value: 144060 ];true;true;false;false;false;false;false;false +OK2KCN;Club, 2m only;JN89OI;StringProperty [value: 144,049 ];true;true;false;false;false;false;false;false +S50L;mt. Slivnica;JN75ES;StringProperty [value: null];true;true;false;false;false;false;false;false +DD5M;franta;JN58VC;StringProperty [value: 144110 ];true;true;false;false;false;false;false;false +DK0A;Club (1140m asl);JN48CO;StringProperty [value: 144236 ];true;true;false;false;false;false;false;false +OK1RW;144 only;JO70HG;StringProperty [value: 144.177 ];true;true;false;false;false;false;false;false +OK5Y;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR1H;144320 8*12el;JN59OP;StringProperty [value: 144.320 ];true;true;false;false;false;false;false;false +OK1VDJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3AAV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1TV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ9MH;Hajo;JO50FA;StringProperty [value: 144.070 ];true;true;false;false;false;false;false;false +DL1QC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0XX;Contest Club;JO52BO;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2BR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF4AJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4KHG;Gaetan 2m/3cm;JO10XO;StringProperty [value: null];true;true;false;false;false;false;false;false +PD4R;dennis;JO32CD;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6YID;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0ONW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF6LH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH4JQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PA1T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4EI/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9MKA;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG0ONW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD0PX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1QR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0MU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0MI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HXE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3JAN;Janek 2m QRO;JO60OM;StringProperty [value: 350 ];true;true;false;false;false;false;false;false +9A3DF;Zeljko;JN86HF;StringProperty [value: 144233 ];true;true;false;false;false;false;false;false +DL2DRG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0JMB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP6FXF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2UHE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4VW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4OCF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1HC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3WE;Rudolf;JN57WS;StringProperty [value: 432241,3 ];true;false;true;false;false;false;false;false +DL1HTL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD6ULF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AWE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2PK;Peter 2m 750W;JO31IK;StringProperty [value: null];true;true;false;false;false;false;false;false +G3M;432.237;JO01QD;StringProperty [value: 432.237 ];true;true;false;false;false;false;false;false +DF7JU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PE1ITR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2ZO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4VW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK2BO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3AW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2CHK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WMA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1GPP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2RSF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0TZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP6ZHP/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HWA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1RS;432.323 only;JO60MM;StringProperty [value: 432.323 ];true;false;true;false;false;false;false;false +PE1OBL;Hans 12 EL ZL;JO21ET;StringProperty [value: 162 ];true;true;false;false;false;false;false;false +OK1DOY;Zdeno 2m;JO60UQ;StringProperty [value: 144.326 ];true;true;false;false;false;false;false;false +DJ8MS;Tor_70cm;JO54VC;StringProperty [value: 282 ];true;false;true;false;false;false;false;false +DO9OM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4IN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1EIP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7BBP/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK6AO;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1HCU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0PW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1MLH;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2AWR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2YDS;Stefan;JO42HG;StringProperty [value: null];true;true;true;false;false;false;false;false +DR6R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8KVK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1KC/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8NAS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR7B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MHT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG3AWN;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4NAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KOJ;70 & 23 cm;JN79UG;StringProperty [value: 233 ];true;false;true;false;false;false;false;false +OK2KYJ;2/70 1kW/500W;JN89QQ;StringProperty [value: 305 ];true;true;false;false;false;false;false;false +DO1JKO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3DG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN5KA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6ME;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG1HQK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0LFG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KAD;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DH1AKY;Jens 2m;JO50LQ;StringProperty [value: null];true;true;true;false;false;false;false;false +OK7MH;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +SM7FMX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0TU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK6M;Martin;JN99CR;StringProperty [value: 177 ];true;false;true;false;false;false;false;false +DL2LMS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1KA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2L;Volker;JN68DT;StringProperty [value: 312 ];true;true;false;false;false;false;false;false +DO2LNJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB7AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1YEG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7B;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OZ1JMN;Allan;JO46VE;StringProperty [value: 262 250 ];true;true;false;false;false;false;false;false +DB0DH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1DCS;Vaclav;JN78CS;StringProperty [value: null];true;true;false;false;false;false;false;false +SN7L;Team 144.236;JO91QF;StringProperty [value: 144.240 ];true;true;false;false;false;false;false;false +DH6AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4M;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1AUO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD7MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OP5Y/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK9TF;Juergen 23+13;JO31NF;StringProperty [value: 1296.233 ];true;true;true;false;false;false;false;false +DL8DAU;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL8SCD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6KB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DC6HG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1AA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6JJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1FPQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7KN/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5KK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WN;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL6ZEJ/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2ZA;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL0BBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM8MM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5BAW/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0GC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6YGE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1YBN;Harald;JO31VX;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0AP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1PZ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF2QZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2PU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD5DD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK9ZQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0PF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK9AM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE4WHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5HMK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6GYH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7WL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9YBQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1WB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S57M;Bojan 432285;JN76PO;StringProperty [value: 390 ];true;false;true;false;false;false;false;false +DG1HP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC2TH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9OOH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2CD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9DBF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ASK;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ7AQ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ3AM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1EHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5YL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2MAJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DC6HG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2AP;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DB0AI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6DBN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5NUA;Klaus(70cm);JO63PO;StringProperty [value: 190 ];true;false;true;false;false;false;false;false +PI4ADH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1FY;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL5NUA;Klaus(70cm);JO63PO;StringProperty [value: 190 ];true;false;true;false;false;false;false;false +DF3TE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC6CX/P;Chris 2/70/23;JO31SE;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1SE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB3LO;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1MBT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1AA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO4SSH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1PS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3YCW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK5ET;Martin LP 9elY;JO70WE;StringProperty [value: 268 ];true;true;false;false;false;false;false;false +OE5JWL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1PZ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5TI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DO1ARR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8GHH;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL1SE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1VQC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AWE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1PMA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5CB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5FLM;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4MN;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OE6V;Werner 72 el kW;JN76VT;StringProperty [value: 078 ];true;true;false;false;false;false;false;false +DL6CWM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ASK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5SE;Dan 70cm;JO50XL;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ6VX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM3W;Club 2m;JN99CH;StringProperty [value: 302 ];true;true;false;false;false;false;false;false +DL2NDL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1OLA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +I4GHG;Rino 144.390;JN63DT;StringProperty [value: 144.390 ];true;true;false;false;false;false;false;false +DL4LAM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0KTL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1AK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF4UM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6OO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7AC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ0MW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2BHG;Heinz;JO51MW;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5KT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HBS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0GM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5FDP;Stefan;JN49LP;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5DIN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ9MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0DLE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0AP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2CF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2FQ;Tzetzo-23cm 60W;JN49EW;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1X;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6WE;Ewald 6 4 2m;JO31GO;StringProperty [value: null];true;true;false;false;false;false;false;false +G4PIQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7JU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0UM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2WU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB8TS/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2MR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG3RAP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8QS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4LDP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8V;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA2K;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1CBR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4WDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF5TV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5HI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6QO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO4HBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3AK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4APJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR2L;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1DEU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6KDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB7AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2CB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5F;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2I;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6UJH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5MA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4VDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ABB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AVF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1GSD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7UDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KIM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL7C;Radio Club;JO60JJ;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KAD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Z;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4BIT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8ABK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2FR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4SHE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA0M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1BZT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD6YR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6OL;Ralf 2m-13cm/6cm;JO52AP;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ON;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6AO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2LB;Torsten;JO53LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ANS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1CS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM3KOM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB3LO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1ASG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2BXC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ZEJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MHW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2OAU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AWE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ST;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG8AB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6JH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1AWD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DP9X;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3LAR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HXE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC6HG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0LB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1AKY;Jens 2m;JO50LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DK8MM;Mark only VHF;JO53CL;StringProperty [value: null];true;true;false;false;false;false;false;false +DP6K/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2PZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4NWM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP9KDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP7VVB;Maciek 2m;JO91VQ;StringProperty [value: 374.5 ];true;true;false;false;false;false;false;false +DH2UAK;Klaus;JO71FU;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8GHH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2XF/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2UHE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1RLB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2BC;Bert 2 m;JO63PO;StringProperty [value: null];true;true;false;false;false;false;false;false +F0EUY/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK7C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2AF;Milan 2m;JN89AR;StringProperty [value: 374,5 ];true;true;false;false;false;false;false;false +DK5IR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP6AB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5OU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1BUG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0AC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9OBI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG2SER;Carsten;JN58OH;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1JHR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0MA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH9ET;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SAM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A1AAY;RKNG;JN85PJ;StringProperty [value: null];true;true;false;false;false;false;false;false +9A1W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A2AE;Zvonko 144272;JN86HF;StringProperty [value: 144272 ];true;true;false;false;false;false;false;false +OE3XOE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A3SM;Mato;JN85FW;StringProperty [value: 149 ];true;true;false;false;false;false;false;false +S59P;Club JN86AO;JN86AO;StringProperty [value: 144.333 ];true;true;false;false;false;false;false;false +OK5IM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5NE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2AJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5HQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2GBG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4OP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE2M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A5AY;Damir;JN85OK;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3TFA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF3RU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB5SM;Klaus-2m;JN59LE;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0SX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HTI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN5PW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A2YF;Tiko 2m;JN85OO;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA2T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6CNG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1FDK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1HF;Andi JO43VF;JO43VF;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6NEJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0PF;Gilbert 2m;JO50LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9DBF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5AS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6HJO/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2GZ;Harry;JN49GB;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6QS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2YDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8LR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9AAA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9FBF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR2E;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ8MW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6VX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5VL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5DF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6GCK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1SMA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0CO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9IM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ7YP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8FBP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH0HAN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2SL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ZXG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK3ZQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1TRE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2ZO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB1BAC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1FAR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO2PSW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1FZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR2X;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0PU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1OB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2IT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ZBS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6TW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1AA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1ATZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1PWV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0FW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5TM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +I3FGX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1NPF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HXE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1FMP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A0BB;144.239 BB Team;JN85EI;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1AN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0LFG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE6V;Werner 72 el kW;JN76VT;StringProperty [value: 144.186 ];true;true;false;false;false;false;false;false +DD5VL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1MZM;Milos144358;JN89IW;StringProperty [value: 144.358 ];true;true;false;false;false;false;false;false +DF0WF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3LA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE8Q/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1CJT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA0FF;144.245;JO40XL;StringProperty [value: 144245 ];true;true;false;false;false;false;false;false +PA2TMS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4YDR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ5NN;MonteNerone144;JN63GN;StringProperty [value: 144182 ];true;true;false;false;false;false;false;false +OK7O;144;JN69OU;StringProperty [value: 144.380 ];true;true;false;false;false;false;false;false +OM3KII;2m;JN88UU;StringProperty [value: 267 ];true;true;false;false;false;false;false;false +DR0R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ9FC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1DAF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0YY;Berlin.240;JO62GD;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5HQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC5IMM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP2CNW;Jan 2m ,;JO93AI;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2LB;Torsten;JO53LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +OM6DN;2x12ele 950asl;JN99FI;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3HXS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK8QC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0MU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL1C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD2ML;Ulli 4x10 QRO;JN68GI;StringProperty [value: 370,2 ];true;true;false;false;false;false;false;false +OK2KAA;Club 2m;JN79QJ;StringProperty [value: 144.217 ];true;true;false;false;false;false;false;false +IQ4KD;Monghidoro C.T;JN54PF;StringProperty [value: 234 ];true;true;false;false;false;false;false;false +9A9D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IK4GNG;Gibo;JN64FB;StringProperty [value: 373 ];true;true;false;false;false;false;false;false +IQ8BI;2m Test;JN63NJ;StringProperty [value: 367 ];true;true;false;false;false;false;false;false +DL8CHR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ4CT;Team 2m;JN54WE;StringProperty [value: 220 ];true;true;false;false;false;false;false;false +OZ1BEF;Dan;JO46OE;StringProperty [value: 144277 ];true;true;false;false;false;false;false;false +DB9OH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK8XY;Torsten,2m SSB;JO53AP;StringProperty [value: 307 ];true;true;false;false;false;false;false;false +OZ7UV;Svend;JO65DH;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0LU;Team 144.280 CQ;JO43UA;StringProperty [value: 280 ];true;true;false;false;false;false;false;false +DM2HEY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM2Y;Jan;JN88RS;StringProperty [value: 144.169 ];true;true;false;false;false;false;false;false +OM3FW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK6R;144 only;JN79OW;StringProperty [value: 184 ];true;true;false;false;false;false;false;false +OK3W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM8A;OM8A_2m;JN87WV;StringProperty [value: 144280 ];true;true;false;false;false;false;false;false +DR1H;144320 8*12el;JN59OP;StringProperty [value: 144320 ];true;true;false;false;false;false;false;false +DK0NA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7C;24GHz up;JO50WB;StringProperty [value: 388 ];true;true;false;false;false;false;false;false +OE5LHM/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DP3P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5BRE;Ronny 70/23/13/9;JO62VM;StringProperty [value: 280 ];true;true;false;false;false;false;false;false +DL2FFW;Frank 2m;JO50LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KEA;Radioclub;JN89EJ;StringProperty [value: 144.375 ];true;true;false;false;false;false;false;false +DL5WW;Guenter 2m only;JO63PL;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ALW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7AX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KCR;BIG GUN;JN79VS;StringProperty [value: 144.162 ];true;true;false;false;false;false;false;false +OK1LN;Lada;JN79AI;StringProperty [value: 144.317 ];true;true;false;false;false;false;false;false +OK1KKI;RK J.Hradec _2m;JN79NF;StringProperty [value: 144276 ];true;true;false;false;false;false;false;false +OK1KPA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5OT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KCN;Club, 2m only;JN89OI;StringProperty [value: null];true;true;false;false;false;false;false;false +OK5W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2BMJ;Milan only 2m;JN89VC;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KOJ;2m;JN79UG;StringProperty [value: 166 ];true;true;false;false;false;false;false;false +DL0NF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5NNN/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL7M;2m 1100m asl;JO80FG;StringProperty [value: 144.307 ];true;true;false;false;false;false;false;false +OM5AW;Joe 144/QRO;JN98AH;StringProperty [value: 144324 ];true;true;false;false;false;false;false;false +SN6Z;Mike;JO81MG;StringProperty [value: 144220 ];true;true;false;false;false;false;false;false +SN7L;Team 144.182;JO91QF;StringProperty [value: 182 ];true;true;false;false;false;false;false;false +DL5AA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4EI/P;Oli 4x4+1x7 1KW;JO20JO;StringProperty [value: 182 ];true;true;false;false;false;false;false;false +OK4C;Klondajk;JN79BU;StringProperty [value: 144.155 ];true;true;false;false;false;false;false;false +OL4N;2m;JO60VR;StringProperty [value: 144260 ];true;true;false;false;false;false;false;false +DR5T;Marek;JN47KW;StringProperty [value: 144.351 ];true;true;false;false;false;false;false;false +DD7PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON8TT/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0RN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5EZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4IYU;Christophe;JN19GD;StringProperty [value: 295 ];true;true;false;false;false;false;false;false +DD0PX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM5R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KKP;Club;JO70DG;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4VAI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +G4PIQ;Andy 1kW 17el;JO02OD;StringProperty [value: 144.133 ];true;true;false;false;false;false;false;false +DM5B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1IME;Ota 2m;JO70FB;StringProperty [value: null];true;true;false;false;false;false;false;false +OK5T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1VAV;Petr_2-70 SSB;JN79FW;StringProperty [value: 250 ];true;true;false;false;false;false;false;false +S51S;Igor;JN75ES;StringProperty [value: 432250 ];true;true;false;false;false;false;false;false +DL2NBU;Peter;JN59KQ;StringProperty [value: 144095 ];true;true;false;false;false;false;false;false +S53O;ljubo 2m;JN86AT;StringProperty [value: 144382 ];true;true;false;false;false;false;false;false +HA1CA;Zoli;JN86HN;StringProperty [value: 144 ];true;true;false;false;false;false;false;false +OK1KCB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK5Y;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HG6N;Tibor;JN98VD;StringProperty [value: 144.050 ];true;true;false;false;false;false;false;false +DK1KC/P;Mike;JN58QH;StringProperty [value: 068 ];true;true;false;false;false;false;false;false +PA1T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0ZB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7SCB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +TM5R;144.340.00;JN19BQ;StringProperty [value: 144.340.00 ];true;true;false;false;false;false;false;false +DM5D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP9KDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD5DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6FE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF3VM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR6T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4DAW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6NDW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6WT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2BO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5CW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB9LG;Lutz;JO40DM;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7UP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1PAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC6CX/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ0YS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4JXV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK9PT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK3WG;Jurg;JO72GI;StringProperty [value: 144.030 ];true;true;false;false;false;false;false;false +DO1AYJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC2ZL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA2R;Hans-Jürgen;JN69EM;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5RE/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6GTH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F8CND/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH9NAD/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3AWK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ0CC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6KDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3EAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PD4R;Dennis;JO32CD;StringProperty [value: 260 ];true;true;false;false;false;false;false;false +DL4ZAA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0ET;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5BAW/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6JJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB6XG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1BFR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F5UIN/N;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6AC;Michael;JO52IJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1E;2m/70cm;JO53WH;StringProperty [value: 350 ];true;true;false;false;false;false;false;false +DJ8PY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9KB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7QX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR6R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3SN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S57GM;Borut-2m;JN76CC;StringProperty [value: 350 ];true;true;false;false;false;false;false;false +OK2L;Team 2m;JN99BN;StringProperty [value: 293 299 ];true;true;false;false;false;false;false;false +OK1UGI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2AP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RDO;2m;JN69JK;StringProperty [value: 372 ];true;true;false;false;false;false;false;false +SP3LX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1OPT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1XRK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0FE;Lothar;JO62UN;StringProperty [value: null];true;true;false;false;false;false;false;false +SM7EYW;Torleif;JO65NK;StringProperty [value: 184 ];true;true;false;false;false;false;false;false +DL2BQC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7D;Ronald;JO62LI;StringProperty [value: 092 ];true;true;false;false;false;false;false;false +SM7FMX;Mog;JO65KN;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DP4K;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH5BS;erni 2m qro +70;JO63UW;StringProperty [value: 144327 ];true;true;false;false;false;false;false;false +DF1HC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2NG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP4SAS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6RI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF9ME/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4NAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ3LX;Laguna Team;JN54TF;StringProperty [value: 144.295 ];true;true;false;false;false;false;false;false +DK2AT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3GRA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3DQL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR0R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5KW/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF5RF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PC0A;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MRA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F5MGD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8SL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9FBA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH6DAO;Ray;JO41CN;StringProperty [value: null];true;true;false;false;false;false;false;false +DD0D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4EI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6NI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5FK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4JU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0RD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1DAC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM8MM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3DG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0AU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8OI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5UHR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3VE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB1PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8XC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5RX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9YBQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD1IW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9LEH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK3JH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4KHG;Gaetan 2m/3cm;JO10XO;StringProperty [value: 185 ];true;true;false;false;false;false;false;false +OK1RMR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8ZH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5RA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7TW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO7AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM1DE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S570CST;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC6NY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1LSH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8AMB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM2DT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG8OBN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL2J;RK Jihlava;JN79TI;StringProperty [value: 144.104 ];true;true;false;false;false;false;false;false +DL5DWF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4PT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2BPN;Jaroslav;JN89UF;StringProperty [value: 299 ];true;true;false;false;false;false;false;false +DL7AYK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9UO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HVM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2O;Milos144229;JN89IW;StringProperty [value: 144.229 ];true;true;false;false;false;false;false;false +DH9YX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8GL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IU3OAR;Gian 144.264;JN54FO;StringProperty [value: 144.264 ];true;true;false;false;false;false;false;false +HB9TTY;2/70-2x12Y-400W;JN37XG;StringProperty [value: 299 ];true;true;false;false;false;false;false;false +DF4NR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7FBB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4KJP/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IK5AMB;144.318;JN53SR;StringProperty [value: d144 318 ];true;true;false;false;false;false;false;false +IK4ZHH;Phil;JN63AX;StringProperty [value: 144.285 285 ];true;true;false;false;false;false;false;false +OK1KMP;Clubstation 2/70;JO70UK;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1GK;2m 17ele 200W;JO70FA;StringProperty [value: 299 ];true;true;false;false;false;false;false;false +DK0TUI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE1W;Team;JN77TX;StringProperty [value: 144.358,50 ];true;true;false;false;false;false;false;false +OK1VRY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2MDU;Chris;JN58RF;StringProperty [value: 144277 ];true;true;false;false;false;false;false;false +DO5SA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0WB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8QS;Heiko;JO43KH;StringProperty [value: 267 ];true;true;false;false;false;false;false;false +DL8GL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON8JA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6QK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DQ55DIG;Team;JO73CE;StringProperty [value: 144.238 ];true;true;false;false;false;false;false;false +DK9ZQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1MPK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4FCX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5SA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1FOX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1XTN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7VN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4HRM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK9ZC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0G;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH5NAH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ASK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1GSD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1WB;Hans;JO52FG;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2DXG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1SE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC9DC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6HMQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1KUB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK4VW;Ulli 70cm;JO40IT;StringProperty [value: 321 ];true;true;false;false;false;false;false;false +DL5SKH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC5GF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1DT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5DQ;Nico-2m;JO31QH;StringProperty [value: 318 ];true;true;false;false;false;false;false;false +DL0BBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5PD;Lothar 2m;JN39VV;StringProperty [value: 144,144 ];true;true;false;false;false;false;false;false +DH1PS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7KF;Dithmar;JO30FK;StringProperty [value: null];true;true;false;false;false;false;false;false +EI4GNB;tim 2/4/5/6/8;IO63WE;StringProperty [value: null];true;true;false;false;false;false;false;false +G8XVJ;Erik;IO83QK;StringProperty [value: null];true;true;false;false;false;false;false;false +GW0GEI;steve 144393;IO72VE;StringProperty [value: null];true;true;false;false;false;false;false;false +NO3I;Bob;EN90UW;StringProperty [value: null];true;true;false;false;false;false;false;false +G4LOH;Tim;IO70JC;StringProperty [value: null];true;true;false;false;false;false;false;false +F1GTU;Daniel - 2M/70CM;JN05IE;StringProperty [value: null];true;true;false;false;false;false;false;false +IZ5EME;Marco 10elm QRO;JN52NS;StringProperty [value: null];true;true;false;false;false;false;false;false +OV3T;Thomas;JO46CM;StringProperty [value: null];true;true;false;false;false;false;false;false +F5ICN;Alex QRV 2/70/23;JN03BF;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5AMF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ASG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +G3M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM8AK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2BO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1FS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8LR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1KC/P;Mike 144,163;JN58QH;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5JSL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG2SER;Carsten 2m;JN58OH;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AKT;Jens;JO50NV;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0SB/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1SRX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1AKY/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB7AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON4KHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1SE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ZK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5KT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC8RI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5HQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ZEJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB3DY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1WM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3LAR/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0DLE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1EHG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON8TT/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0RI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4KIY/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2FA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0UM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2FQ;Tzetzo;JN49EW;StringProperty [value: null];true;true;false;false;false;false;false;false +DP6T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN4DI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1FKB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7JU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8GHH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0ED;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0SAT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MW;Ralf 2m;JO50KQ;StringProperty [value: 292 ];true;true;false;false;false;false;false;false +OK5W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2NDL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2YDS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB1FLO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD6YR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2AX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1ASG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9CLN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK7O;144;JN69OU;StringProperty [value: 144.374 ];true;true;false;false;false;false;false;false +DM5B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5MRM/3;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6TW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2IE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KCR;Big gun;JN79VS;StringProperty [value: 144.162 ];true;true;false;false;false;false;false;false +DK0CO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG7BBP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +5P5T;Team;JO64GX;StringProperty [value: 265 ];true;true;false;false;false;false;false;false +OL9W;club 2m 1teh;JN99CL;StringProperty [value: 238 ];true;true;false;false;false;false;false;false +OE1W;TEAM 2M;JN77TX;StringProperty [value: 144333 ];true;true;false;false;false;false;false;false +DR7C;team 2m;JO50WB;StringProperty [value: 195 ];true;true;false;false;false;false;false;false +OK1KPA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK4C;Klondajk;JO60LJ;StringProperty [value: 144.397 ];true;true;false;false;false;false;false;false +OL7M;QRO 1100asl;JO80FG;StringProperty [value: 144377 ];true;true;false;false;false;false;false;false +OL1C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Z;QRO 2m;JN79FX;StringProperty [value: 144.286 ];true;true;false;false;false;false;false;false +DK0NA;144280.0;JO50TI;StringProperty [value: 280 ];true;true;false;false;false;false;false;false +S50C;Menina;JN76JG;StringProperty [value: 144.241 ];true;true;false;false;false;false;false;false +IO2V;Team 144;JN54WE;StringProperty [value: 144.219,9 ];true;true;false;false;false;false;false;false +OL4A;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL4N;club 2m;JO60VR;StringProperty [value: 144.213 ];true;true;false;false;false;false;false;false +DK0CWC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KKD;Petr;JO60WD;StringProperty [value: null];true;true;false;false;false;false;false;false +ON5WU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8QS;Heiko;JO43KH;StringProperty [value: 322 ];true;true;false;false;false;false;false;false +ON6LL/P;La Louvière;JO20BL;StringProperty [value: 344 ];true;true;false;false;false;false;false;false +OK1RDO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3EAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PD1AHM;Hans (144 ssb);JO21WF;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6NEJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2MS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO7SBR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1OK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1AYJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6GCK;Konrad;JN47NR;StringProperty [value: 241 ];true;true;false;false;false;false;false;false +DK5TA;Thomas;JN68GI;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0NF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1SUZ;Uwe 2m;JO53UN;StringProperty [value: null];true;true;false;false;false;false;false;false +DO7AGN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4BIT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG3RAP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0ONW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RDO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9NDP/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1RWO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6KFH;radioclub;JN39UN;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9IAB/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0BC;Team;JN48RN;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5I;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DC6GF/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1UEI;Aleksandar;JN79FV;StringProperty [value: 320 ];true;true;false;false;false;false;false;false +OL7K;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S59DEM;RC Proteus;JN75DS;StringProperty [value: 144.328 ];true;true;false;false;false;false;false;false +DR9A;144.392;JN48EQ;StringProperty [value: 144392 ];true;true;false;false;false;false;false;false +S53O;ljubo 2m;JN86AT;StringProperty [value: 399 ];true;true;false;false;false;false;false;false +HB9GF;Funkclub;JN47BC;StringProperty [value: 385 ];true;true;false;false;false;false;false;false +OK2PVF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5D;DM5D 2m-Team;JO61OC;StringProperty [value: 144.360 ];true;true;false;false;false;false;false;false +DF0WF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH6DAO;Ray;JO41CN;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6ZXG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5LJM/3;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9MKA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2BHG;Heinz;JO51MW;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ2KP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ1OB;Olli - 2m;JN48UG;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5IR;Jochen;JN49IC;StringProperty [value: 306 ];true;true;false;false;false;false;false;false +DL8SCQ;Ebi 2m 2x9;JN48RV;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8KVK;Kurt 2m only;JN57MT;StringProperty [value: null];true;true;false;false;false;false;false;false +SP3PWL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN5PW/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0GM;Team G07;JO31UB;StringProperty [value: 347 ];true;true;false;false;false;false;false;false +DL4DAW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1DCI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0TWK/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0TWK/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0WO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0AC;Weisser Stein 2m;JO30EJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6SH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7A;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2CB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2LB;Torsten;JO53LQ;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3FKS/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0MA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6CNG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SYL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SYL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6AA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3ZAE/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB6JG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP6CPF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9GRE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4ASK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP2IPK;Edward;JO93LR;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6LH/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP2FRY;Andrzej;JO83WR;StringProperty [value: 327,83 ];true;true;false;false;false;false;false;false +DJ8JA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5MO/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0MOL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7D;Ronald;JO62LI;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2OCH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7ZN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD6OM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF4HA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2LBK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2FQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0OGJ;Ragna 2m;JO50KQ;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RMR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0OJO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7QX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH1PAL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2TX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK1PZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0VX/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5SBY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4APJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM7EE;Christian;JO52JJ;StringProperty [value: 144,063 ];true;true;false;false;false;false;false;false +DL0FT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2RM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO3BST;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5Y;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8V;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ5TM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6QS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8RB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4JXS/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5RGA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2PA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4JC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ8BI;2m Test;JN63NJ;StringProperty [value: 075 ];true;true;false;false;false;false;false;false +DD3SF;Florian;JN39LF;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ9MC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0PU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9TTY;3x11Y 400W;JN37XG;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0A;Club (1140m asl);JN48CO;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5DIN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1GK;2m 17ele 500W;JO70FA;StringProperty [value: 372 ];true;true;false;false;false;false;false;false +DG0FE;Lothar;JO62UN;StringProperty [value: null];true;true;false;false;false;false;false;false +9A1AAY;RKNG;JN85PJ;StringProperty [value: 144.340 ];true;true;false;false;false;false;false;false +OM3CQF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3YDP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IU3CQP;Manu 4X9;JN65DM;StringProperty [value: 181 ];true;true;false;false;false;false;false;false +DK1LJ;Janin-2m;JN57MT;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5DJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9FAP;Fabio;JN47PH;StringProperty [value: 162 ];true;true;false;false;false;false;false;false +DL7MY/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6OL;Ralf 2m-13cm/6cm;JO52AP;StringProperty [value: 210 ];true;true;false;false;false;false;false;false +OE5D;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OR6T;Contest;JO20KV;StringProperty [value: 206 ];true;true;false;false;false;false;false;false +DL6DVU;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0LU;DF0LU;JO43UA;StringProperty [value: null];true;true;false;false;false;false;false;false +PA0WMX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IQ3XL;Club Ladinia;JN56UO;StringProperty [value: 285 ];true;true;false;false;false;false;false;false +DK5TI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1NUX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL3Y;Club 2M;JN69KK;StringProperty [value: 200 ];true;true;false;false;false;false;false;false +DL7QX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5EZ;George 70m only;JO31NH;StringProperty [value: 144.368 ];true;true;false;false;false;false;false;false +DP9X;Pom 144SSB;JO50MM;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1MWW;Jiri 2m/70cm;JN89DW;StringProperty [value: 144.260 ];true;true;false;false;false;false;false;false +OK2KRT;Club 2m;JN99BK;StringProperty [value: 193 ];true;true;false;false;false;false;false;false +OK2L;Team 2m;JN99BN;StringProperty [value: 293 ];true;true;false;false;false;false;false;false +OK1KKI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2KCN;Club, 2m only;JN89OI;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2BMJ;Milan only 2m;JN89UI;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7NB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0XX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP3JZX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KCB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM5F;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3TFA;Thomas;JN78UQ;StringProperty [value: null];true;true;false;false;false;false;false;false +DF6FQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1FAR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5SKH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DD5JK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4YDR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7QF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1PHB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3ZF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3SN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0HM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH6AD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F8CND/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0FC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PC5T;Crew;JO23KE;StringProperty [value: 237 ];true;true;false;false;false;false;false;false +OK1VSJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE2M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5DWF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SP1KZE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4KJP/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL250CDF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F5JNX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7DX;Daniel;JO42HJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9DBF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +G4PIQ;Andy .328 1kW 17;JO02OD;StringProperty [value: 164 ];true;true;false;false;false;false;false;false +TM6T;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3ABL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR1H;144380;JN59OP;StringProperty [value: 144380 ];true;true;false;false;false;false;false;false +DO3HTV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB3LO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4KLS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4KLS;stef 2/70/23;JN25JU;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1MWG;Markus 23cm;JN58VD;StringProperty [value: null];true;true;false;false;false;false;false;false +DR0X;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1TRE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0SWG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE3CIN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S51IV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5T;Marek;JN47KW;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6KDS/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1HSF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +TM5R;Didier;JN19BQ;StringProperty [value: null];true;true;false;false;false;false;false;false +9A5Y;144.340.00;JN85RO;StringProperty [value: 340 ];true;true;false;false;false;false;false;false +IQ5NN;Monte Nerone;JN63GN;StringProperty [value: 144.097 ];true;true;false;false;false;false;false;false +DH1TW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5FPL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MHW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A2RD;Mario 432mhz;JN65TF;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2O;club;JN89IW;StringProperty [value: 144344.9 ];true;true;false;false;false;false;false;false +DL0HTW;2m;JO60QU;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1IME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5DCN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM3KII;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ3AK;Detlef;JO52IJ;StringProperty [value: null];true;true;false;false;false;false;false;false +DO2LNJ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM2Y;Jan;JN88RS;StringProperty [value: 144.169 ];true;true;false;false;false;false;false;false +SN7L;Team 144.180;JO70UR;StringProperty [value: 144.180 ];true;true;false;false;false;false;false;false +OK1KUO;club;JO80FF;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +9A0BB;144320 BB Team;JN85EI;StringProperty [value: 144320 ];true;true;false;false;false;false;false;false +OL1R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SQ2SAT;2x9 QRO;JO83XG;StringProperty [value: 228 ];true;true;false;false;false;false;false;false +S59P;144.306;JN86AO;StringProperty [value: 144306,0 ];true;true;false;false;false;false;false;false +OK2KEA;Club 2m only;JN89EJ;StringProperty [value: 305.3 ];true;true;false;false;false;false;false;false +DL2LSM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM6DN;2x12ele 950asl;JN99FI;StringProperty [value: 144.399 ];true;true;false;false;false;false;false;false +OK1NPF;Roman 2m SSB100W;JO70UK;StringProperty [value: 144.324 ];true;true;false;false;false;false;false;false +G2N;273;JO02QV;StringProperty [value: 082 ];true;true;false;false;false;false;false;false +S57GM;.;JN76CC;StringProperty [value: 360 ];true;true;false;false;false;false;false;false +OE5BGN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +S54W;144.262;JN86DT;StringProperty [value: 144262 ];true;true;false;false;false;false;false;false +OK1AME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HA2R;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0RN/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IK4GNG;Gibo;JN64FB;StringProperty [value: 222 ];true;true;false;false;false;false;false;false +OM3W;Club 2m;JN99CH;StringProperty [value: 144.059 ];true;true;false;false;false;false;false;false +OK1DT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +TM9A;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0YY;club;JO62GD;StringProperty [value: 057 ];true;true;false;false;false;false;false;false +S53XX;Slavko;JN76GI;StringProperty [value: 093 ];true;true;false;false;false;false;false;false +OK2KYZ;Club 2 m;JO80NB;StringProperty [value: 144.188 ];true;true;false;false;false;false;false;false +IQ4KD;Monghidoro C.T 1;JN54PF;StringProperty [value: 144.105 ];true;true;false;false;false;false;false;false +F8KID;Club;JN38AT;StringProperty [value: 060 ];true;true;false;false;false;false;false;false +OK1KKP;Club 144.198;JO70DG;StringProperty [value: 042 ];true;true;false;false;false;false;false;false +DD5DX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM2DT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AAZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2TXT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KEL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ8MS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3OCA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1MM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2FFW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2BXC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4WK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE5NNN/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1ZDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8SAM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG4MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF3VM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0XT/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0BN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WMA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6CWM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9KB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +PA3C;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AAK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1HTQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR5W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1HTL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO6NI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1RDO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1RW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4SKF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1LN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ9MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9AAA/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9AG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG9FBA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1MH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG6OG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2HXE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2AQI/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL9FB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2BRW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1TRW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1CS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1DEK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SM7EYW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH2UHE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1TN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM6AT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM1JS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM6EE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2DXG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5HF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1ATI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OZ1JMN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7UDA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2BQC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2EUN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SA7W;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0GUB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6MRA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1JKO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5AWE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0HG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8MFL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F5JFU/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6GYH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F4JXX/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1DBE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9AHD;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ7GX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6GTH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0TX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM3DG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DJ6VX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1XRK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2PZ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF7WL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0MI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3IS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8FBX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6OA;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DA0FF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F6HMQ;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO9PL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1DBR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5WN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +ON6LL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5FBC/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2RAS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF5GO/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5NW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1GPP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF8OI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2BKB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1YEG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5XL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB7SH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DB0DH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF2BR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG8AB;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SN1I;Team 2m;JO84CE;StringProperty [value: 305 ];true;true;false;false;false;false;false;false +SQ6POB;Marek;JO73XP;StringProperty [value: null];true;true;false;false;false;false;false;false +SP2AWJ;Wojtek;JO83XD;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0TEC;Contest Crew;JO73CE;StringProperty [value: 305 ];true;true;false;false;false;false;false;false +DL5DRG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG5BRE;Ronny 2m-9cm;JO62VM;StringProperty [value: n273 ];true;true;false;false;false;false;false;false +SP7VVB;Maciek;JO91VQ;StringProperty [value: 163 ];true;true;false;false;false;false;false;false +DK7AM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF1DT;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH8KV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0AP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL1Z;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1PGS;Franta 2m;JN69RS;StringProperty [value: 194 ];true;true;false;false;false;false;false;false +DG4VW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2CBQ/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +G4ODA;Keith 144.217;IO92WS;StringProperty [value: 325 ];true;true;false;false;false;false;false;false +DF8XC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK9TF;Juergen .157;JO31NF;StringProperty [value: null];true;true;false;false;false;false;false;false +DK7AW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5JS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL0WB;Club OV P36;JN48HH;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0VK/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2DBR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL1BFR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF3TE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4MN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK5PD;Lothar 2m;JN39VV;StringProperty [value: null];true;true;false;false;false;false;false;false +DK0IL/P;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OE9MON;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +HB9CQL;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF4WO;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK2WF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL3BH;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6AC;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL2RZ;Volker;JO43WG;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2VV;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL5ME;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL6AUI;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR7B;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG8LG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DH3NAN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO1FDK;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DN3SY;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DR3K;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG0LFG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DG1YBN;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL4YR;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DK6QW;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +IN3BJX;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL7YS;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +SN6J;Club .253;JO71SA;StringProperty [value: null];true;true;false;false;false;false;false;false +OK2R;23-3 cm;JN89JM;StringProperty [value: null];true;true;false;false;false;false;false;false +DC5IMM;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OL7C;Club 2m;JO60JJ;StringProperty [value: 144.207 ];true;true;false;false;false;false;false;false +OL2J;RK Jihlava;JN79TI;StringProperty [value: 144.087 ];true;true;false;false;false;false;false;false +OK1KKL;Club;JO70PO;StringProperty [value: 306 ];true;true;false;false;false;false;false;false +SP3YDE;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OK1KEP;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +OM8A;Team;JN87WV;StringProperty [value: null];true;true;false;false;false;false;false;false +SP3QDM;Tom 2/70/23cm;JO82EF;StringProperty [value: null];true;true;false;false;false;false;false;false +HA6W;Contest group;KN08FB;StringProperty [value: 315 ];true;true;false;false;false;false;false;false +DF1ASG;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL1NGS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DA2T;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK0NA;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK0NA;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DL9TU;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG2SER;Carsten 2m;JN58OH;StringProperty [value: null];true;false;true;false;false;false;false;false +DD2D;Barney 70cm;JO50DO;StringProperty [value: null];true;false;true;false;false;false;false;false +DM5D;70cm-Team;JO61OC;StringProperty [value: null];true;false;true;false;false;false;false;false +DG0VOG;ONLY 432 !!!;JO60QU;StringProperty [value: 205 ];true;false;true;false;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DQ8N;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DO1AYJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR7B;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5AJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM2CF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK4VW;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4MN;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL6ZDS/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ9WJ/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG6ME;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR4M;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF0WF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OL1C;70cm QRO;JO60UQ;StringProperty [value: 265 ];true;false;true;false;false;false;false;false +DQ8N;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK4C;Klondajk;JN79BU;StringProperty [value: 432.347 ];true;false;true;false;false;false;false;false +DL1SE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL6CWM;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK2OAS;Club LP 70cm;JN89DO;StringProperty [value: 355 ];true;false;true;false;false;false;false;false +OK1KIY;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3ZAE/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL0DLE;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG5BRE;Ronny 2m-9cm;JO62VM;StringProperty [value: 432.250 ];true;false;true;false;false;false;false;false +DL3LAR;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DD6ZJ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3HXS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OM6A;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +S50C;Menina;JN76JG;StringProperty [value: 432.295 ];true;false;true;false;false;false;false;false +DL4ASK;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DD5DX;ALEX 2m;JO61CA;StringProperty [value: null];true;false;true;false;false;false;false;false +OK2L;Klubova;JN99BN;StringProperty [value: 245 ];true;false;true;false;false;false;false;false +DM5B;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL5ZK;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL7AFB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK5P;RC NOVA PAKA 70;JO70UK;StringProperty [value: 432.226 ];true;false;true;false;false;false;false;false +DA0M;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK5Y;CLUB 70cm;JN79FV;StringProperty [value: 307 ];true;false;true;false;false;false;false;false +DL6AA;Sven 432 MHz;JO43JH;StringProperty [value: 303 ];true;false;true;false;false;false;false;false +OK2C;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL3LAR;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL9MKA;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5II;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2FFW;Frank 70cm;JO50LQ;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1KAD;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5II;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: null];true;false;false;true;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: null];true;false;false;true;false;false;false;false +DL5CAT;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DO1MLH;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ6JJ;Helmut(exDG9YIH);JO32OH;StringProperty [value: null];true;false;true;false;false;false;false;false +DK0TUI;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DG0PF/P;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DK5II;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DF2AP;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM5D;70cm-Team;JO61OC;StringProperty [value: null];true;false;true;true;false;false;false;false +DL6ON;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DB3LO;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ6QS;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DH2UAK;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DJ2NR;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG0PF/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DG0LAD;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: 170 ];true;false;false;true;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: 170 ];true;false;false;true;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: 170 ];true;false;false;true;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: 170 ];true;false;false;true;false;false;false;false +DK0OG;70cm 23el 500w;JN68GI;StringProperty [value: 432.365 ];true;false;true;false;false;false;false;false +OE3JPC;Hannes 70-23-13;JN87EW;StringProperty [value: 432365 ];true;false;true;false;false;false;false;false +DL2FQ;Tzetzo;JN49EW;StringProperty [value: 320 ];true;false;true;false;false;false;false;false +DG5NFF;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +OK2C;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OM3W;Club 70cm;JN99CH;StringProperty [value: 432.280 ];true;false;true;false;false;false;false;false +DL1AVF;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DM2EUN;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL0HAL;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 265 ];true;false;true;false;false;false;false;false +DL2NBU;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK2R;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DR7C;23cm;JO50WB;StringProperty [value: 170 ];true;false;true;true;false;false;false;false +OK1OPT;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DH1AKY/P;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DR1T;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DL5MO;Thomas 23-9;JO50JP;StringProperty [value: null];true;false;true;false;false;false;false;false +DL2LSM;Guenter;JO61GH;StringProperty [value: 180 ];true;false;true;false;false;false;false;false +DA0M;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +DO6NI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1NI;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DF2FQ;Holger;JN58UB;StringProperty [value: 285 ];true;false;true;false;false;false;false;false +;unknown;unknown;StringProperty [value: null];true;false;false;true;false;false;false;false +OL3Z;QRO 70cm;JN79FX;StringProperty [value: 432210 ];true;false;true;false;false;false;false;false +OK1KCB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DN5PW/P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +HB9P;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL8LR;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DK5DQ;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL4ZBG;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +G3M;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +DL0GM;Team G07;JO31UB;StringProperty [value: 432.215 ];true;false;true;false;false;false;false;false +DL8QS;Heiko;JO43KH;StringProperty [value: 175 ];true;false;true;false;false;false;false;false +DF4ZL;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false +OK1RDO;2m;JN69KL;StringProperty [value: null];true;false;true;false;false;false;false;false +OK6M;Martin;JN99CR;StringProperty [value: 325 ];true;false;true;false;false;false;false;false +DF0YY;Berlin.240;JO62GD;StringProperty [value: 240 ];true;false;true;false;false;false;false;false +VE3NNT;unknown;unknown;StringProperty [value: null];true;false;false;false;false;false;false;false +DD0VF;Steffen 2m/70/23;JO61TB;StringProperty [value: null];true;true;false;false;false;false;false;false +9A1I;Radio club;JN85FS;StringProperty [value: 360 ];true;true;false;false;false;false;false;false +F6HTJ;Michel;JN12KQ;StringProperty [value: null];true;true;false;false;false;false;false;false +F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: 263 ];true;true;false;false;false;false;false;false +F6CIS;Sylvain upto13cm;IN94WL;StringProperty [value: null];true;false;true;false;false;false;false;false +F6HZZ;Ric 2/70/23;JN23CP;StringProperty [value: 220 234 ];true;false;true;false;false;false;false;false +IK3SSG-432;Graziano;JN55XH;StringProperty [value: null];true;false;true;false;false;false;false;false +DF7KF;Dithmar;JO30FK;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8YE;Chris;JO32TC;StringProperty [value: null];true;true;false;false;false;false;false;false +;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DL8YE;Chris;JO32TC;StringProperty [value: null];true;true;false;false;false;false;false;false +DM2M;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0BG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DF0BG;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +DO5AMF;Marc;JN49FK;StringProperty [value: null];true;true;false;false;false;false;false;false +SM0KAK;Lasse;JO89XK;StringProperty [value: null];true;false;true;false;false;false;false;false +F5DYD;JLouis 144 only;IN86XW;StringProperty [value: null];true;true;false;false;false;false;false;false +F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null];false;true;false;false;false;false;false;false +F6HTJ;Michel 23>3cm;JN12KQ;StringProperty [value: null];false;true;false;false;false;false;false;false +DF5DF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false +F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null];true;true;false;false;false;false;false;false +F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null];true;true;true;false;false;false;false;false +F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null];true;true;true;true;false;false;false;false +DF0GEB;23 onlysked;JO51JL;StringProperty [value: null];true;true;false;false;false;false;false;false ; 2: 144/432 MHz +DO5AMF;unknown;unknown;StringProperty [value: null];true;true;false;false;false;false;false;false ; null +DF0GEB;unknown;unknown;StringProperty [value: null];true;false;true;false;false;false;false;false ; null +DR2X;Team 144.355;JO40QL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ7YP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HB9XC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: 432.285 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM5M;KST4Contest2nd;JO51IJ;StringProperty [value: 144.315.00 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL6MK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE1W;Team;JN77TX;StringProperty [value: 263 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK2A;Lada 70cm;JO60JJ;StringProperty [value: 432.175 ]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK2A;Lada 70cm;JO60JJ;StringProperty [value: 432.175 ]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2GBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ2IE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OL7C;Radio Club;JO60JJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL1C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5CAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HB9GT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OL4N;Club;JO60VR;StringProperty [value: 144.209 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1KCR;Big gun 2m;JN79VS;StringProperty [value: 144.162 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1DEZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1GK;2m 17ele 500W;JO70FA;StringProperty [value: 144.326,4 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OE5D;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK2PVF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK6R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OL7M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK5P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1NPF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF9PX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8RH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +ON4EI/P;Oli 2m 4x4+2x7;JO20EP;StringProperty [value: 366 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +TM5R;Didier;JN19BQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +PA1T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK0B;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF0LU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF8V;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF5RF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0BBK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5AI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB0DH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ5NE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5PD;Lothar 2m;JN39VV;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4M-70;Club 70cm;JO31QX;StringProperty [value: 432.278 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ5KW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DP4D;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK6WT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: t127 432.310 ]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.230 ]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL70KEA;70/23CM;JN89EJ;StringProperty [value: 144.389 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SN7L;144.180;JO70SS;StringProperty [value: 144.180 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2L;2m;JN68DT;StringProperty [value: 365 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL5EC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ7GS;Marek;JN47KW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO8PAT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +S59DEM;RC Proteus;JN75DS;StringProperty [value: 144.326 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK7CM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1HMP;Martin 2m QRO;JO70EB;StringProperty [value: 144.291 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +TM5R;Didier;JN19BQ;StringProperty [value: 144.279.81 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK5RA;Radek;JN89IW;StringProperty [value: 144,092 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1NPF;Roman 2m QRP;JO70UK;StringProperty [value: 144.330 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: 123 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG0VOG;xAC 2m qro;JO60QU;StringProperty [value: 163 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1TEH;Matej ssb/cw;JO70FD;StringProperty [value: 144.390 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1ALS;144307;JO44XX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1ALS;144307;JO44XX;StringProperty [value: 307 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +5P5T;Team;JO64GX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL5NEN;Tom;JN59OP;StringProperty [value: 144.380 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DA0FF;144.248;JO40XL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM2BKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1SUZ;Uwe 2m Contest;JO53UN;StringProperty [value: 275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +7S7V;Samir;JO65SN;StringProperty [value: 144345 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR7C;team 2m;JO50WB;StringProperty [value: 338 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OL7C;Radio Club;JO60JJ;StringProperty [value: 144,213 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL4N;Club;JO60VR;StringProperty [value: 237 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0YY;Berlin 144.325;JO62GD;StringProperty [value: 324 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0LU;DF0LU;JO43UA;StringProperty [value: 281 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR2L;144MHz;JO41PU;StringProperty [value: 144.227 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK2ADM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH8BQA;Olli;JO73CE;StringProperty [value: 144293 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR2X;Team 144.355;JO40QL;StringProperty [value: 355 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1KKD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6KDS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5M;KST4ContestVUHF;JO51IJ;StringProperty [value: 144.315.00 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SM7VUK;Bengt/2m/SSB;JO66LI;StringProperty [value: 197 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM5D;144.382.50;JO61OC;StringProperty [value: 144.369 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL3LAR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK0NA;club;JO50TI;StringProperty [value: 144.230 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG5BRE;Ronny 2m-9cm;JO62VM;StringProperty [value: 199 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7EE;Chris, 2m, 750W;JO52JJ;StringProperty [value: 144,177,3 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +PA9R;Rob;JO22JK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR1A;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DD0VF;Steffen 2-70-23;JO61TB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM2RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DH1WHM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +EA4SG;David;IN80CP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DH1WM;Mathias not qrv;JN49CD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +2E0WED;Daniel;IO83LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F5ODA;Eric;JN13LE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +G4KUX;Nick;IO94BP;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +M0IAM;Clive;IO91QE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +9A2HM;Kreso;JN82UQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK2T;Sven;JO41UM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SP6LX;Bob;JO42ON;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +9A2HM;Kreso;JN82UQ;StringProperty [value: 2320.184 ]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DM5M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF0GEB;test;JO51HK;StringProperty [value: 432.234]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OM5AW;Joe QRO144;JN98AH;StringProperty [value: 432.321]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +EI9BM;Alain;IO45PT;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL9L;Frank;JN36XE;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL1TEST;TestOp;JO50XX;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +EA1Q;Alain;IN61MD;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ3AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HB9CEV/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6KDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1SE;Michael 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL6UHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5UE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2CB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1RLB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM3ZF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO2TL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6CNG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM6AT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4PT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5OU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DD6YR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA1E;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DN9HC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH2UHE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK1WB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2AR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2RZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK1IJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8MLD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF7CP/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9ZX/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ9WJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SC7W;Torleif 2m only;JO65NK;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK2TN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2AQI/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4LAM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1MER;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA6KH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK8XY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1OIB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2OBF/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK1FY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2AKT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1RWO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO4HBK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM2CF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM6MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE5XXL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4WK/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4MN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1RDO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2TX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4SKH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6NEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK7VN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0CG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4NWM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6UAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1AYJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK2AF;Milan 2m;JN89AR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL0DLE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK6AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2IT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2LB;Torsten;JO53LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF3AS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA0UDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ1FZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0TZ;Clubstation;JN59LN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ4MH;Marco (2m);JO42BB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +ON5DRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1NGS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK1MF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2YCT;Chris (23cm);JO32RG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DK6QO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9FBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR5W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ3QB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5SBY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF7RA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK6SR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2YDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ8WK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ5KW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH3KR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1FDK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +ON6ZY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1PS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF2QZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM2BHG;Heinz;JO51MW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM5ML;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF9FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2FFW;70cm;JO50LQ;StringProperty [value: 144.28]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DB9OH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5OA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM3DG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF3OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1BFR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6CWM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1AAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 432.165]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG0PF;Gilbert 2m;JO50LQ;StringProperty [value: 432.16]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ7YP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1HBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO4CBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK7AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ9FC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO8THW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0YE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +PI4AMF/P;contest club;JO22XB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2HXE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2FQ;Tzetzo - 23;JN49EW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DC7QY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL7AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1HTT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF0WF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0MI;Horst;JO42KH;StringProperty [value: 1296.2]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DG6ME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3RHN;Rüdiger 2m;JO63PM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK1KWV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9BBD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA6SB;DG1HR/DA6SB 2/70;JO52HI;StringProperty [value: 144.273]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2FFW;2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2LSM;Guenter;JO61GH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL6ZXG;Klaus;JO51KU;StringProperty [value: 144.22]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DJ2FR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE5JSL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5DWF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6FKR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3LAR;Rolf;JO52GE;StringProperty [value: 1296.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DJ6OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ1OB;Olli 2m;JN48UG;StringProperty [value: 144.043]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO6XA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB8LE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2VK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5B;only 2m;JO62XE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4M-70;Club 70cm;JO31QX;StringProperty [value: 144.248]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL5RX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DC5IMM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.155]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DA2R;DA2R;JN69EM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL8TB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HB9TTY;10Y 144/432 650W;JN46BX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +HB9GF;Contest;JN47BC;StringProperty [value: 144.388]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1LN;Lada;JN79AI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4MW;Ralf 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +PA0C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2YEY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF1DS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM3KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F6IHC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA1AM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ2KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1ATI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9DBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG8BS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF2BR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR1T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR2L;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5AAZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO9MHG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK0AU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG6MC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2NDL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF0MU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9MKA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DN9MD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5MO;23;JO50LQ;StringProperty [value: 144.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK5RA;Radek;JN89IW;StringProperty [value: 144.317]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DD6OM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL2IKE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 144.165]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ8UHU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DG4OP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL1C;OT Club;JO72AL;StringProperty [value: 144.184]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DK6EE;Andreas;JO52KL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL8IK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL4ASK;Guenther 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF6KB;Kai 70cm only;JO42JC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF1HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1OPT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1SE;Michael 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO1MLH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6UJH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DQ8N-23;Ralf;JN59US;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL6ZEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9NM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4M;1296.230;JO31QX;StringProperty [value: 144.3745]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL1OKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH6ABE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM2D;2x8 2m only QRO;JO64ND;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF8OI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.225]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7EE;Chris 2m/70cm;JO52JJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DC7BK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6MIG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG0PF;Gilbert 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL5CAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5MO;70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DF7NX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DN9APW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3LAR;Rolf 23/13/9/6/3;JO52GE;StringProperty [value: 1296.115]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL5C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1KKI-70CM;ok1kki;JN79NF;StringProperty [value: 144.255]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH8WE;Frank;JO50TJ;StringProperty [value: 144.11]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE3EFS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1KCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF9YY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DM5F;Marcel 2/70/23;JO71ES;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE3UFC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1HSF;Micha 2m-24G;JO61FR;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK2PZ;Manfred 70cm;JN57MT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1FLC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SN6R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5EZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HA1CA;HA1CA;JN86HN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG1HR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM3MS;Tino;JO62IH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL4MFM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.24]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DC8RI;Uwe 23&13;JN68JQ;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DH4EAK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK6FX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1NPJ;Juergen-2m;JN59LE;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK5T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK9OG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1NAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK2L;Club;JN99BN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DM3OA;erni;JO63UW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DK4RA;Ragna 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1KKP;Lukas 23cm+3cm;JO70DG;StringProperty [value: 144.163]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK1IB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1AKY;Jens 70;JO50LQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4MA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1KCR;Club 2m;JN79VS;StringProperty [value: 144.162]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR7C;Team 10G and up;JO50WB;StringProperty [value: 144.298]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +IQ5NN;Monte Nerone;JN63GN;StringProperty [value: 144.205]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL1YDI;Dirk 9Ele/400W;JO42FA;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2NBU;Peter;JN59KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +PA0WMX;Wim 2m 70 23 3;JO21XI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DG6KBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3ABL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL5DBT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM7A;Team TUD;JO60OM;StringProperty [value: 144.175]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SN7L;144.185 Contest;JO91QF;StringProperty [value: 144.185]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL7M;Team 1100asl;JO80FG;StringProperty [value: 144.191]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK0PU-10;Team 10GHz;JO31JN;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +ON4EI/P;Oli 2M ONLY;JO20EP;StringProperty [value: 144.272]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +IO2V-432;Team 70cm;JN54WE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +G8T;144.153;JO01NC;StringProperty [value: 144.153]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +S51IV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1YGH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8SCD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +S59P-2;23,13,9,3 cm;JN86AO;StringProperty [value: 5760.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DK2R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1AKY;Jens 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ5NE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SQ3GJS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F8KID;Club;JN38AT;StringProperty [value: 144.254]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR2Q;Stefan;JO50SF;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0LU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 144.178]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL7C;Radio Club;JO60JJ;StringProperty [value: 144.212]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OL4N;Vlasta;JO60VR;StringProperty [value: 144.316]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DA6UU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SM6VTZ;Chris .235 .135;JO58UJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK1NPF;Roman 2m/ssb;JO70UK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DM7D;Ronald;JO62LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1MWW;Jiri 2m/70cm;JN89DW;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2WM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK7AM;Uwe;JN59SR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0A/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +G4PIQ;Andy 144.312;JO02OD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DR1D;Detlef;JO30JU;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK6FE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +G4LPP;Phil;JO02SS;StringProperty [value: 144.348]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL1SX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF1QR;Holger;JO31LJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4VAI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +ON5CLR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DL5ZBS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR1H;144.380;JN59OP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL6NDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OE8SDR;/P 2x10 QRO;JN76KO;StringProperty [value: 144.144303]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK0NA;Club;JO50TI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK2KOJ;OK2KOJ Club;JN89GF;StringProperty [value: 144.144]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK0A;Club 144.225;JN48CO;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL4FDD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO8PAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1JKO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5EI;Peter;JO43DC;StringProperty [value: 144.169]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DJ6VX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +PC2K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ7ZZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1DIX;Lada 2m;JN79IP;StringProperty [value: 144.087]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG7SCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3AAV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0PP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6MHX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6MHW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1DJJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8EX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3HAH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F0EGV/P;Thierry;JN19MI;StringProperty [value: 144.206]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG9ZA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM3AW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1YBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +ON4PS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8MV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5HQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2JST;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5JS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DA2M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1YEG;Uwe;JO42HG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO1GPP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2ROA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL2DRG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F1TRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG2SER;Carsten 2m;JN58OH;StringProperty [value: 144.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DR2Q;Stefan;JO50SF;StringProperty [value: 144.28]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK2BO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0XD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK2C;team 70cm-76GHz;JN99AJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DD5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5NUA;Klaus(70_only);JO63PO;StringProperty [value: 144.266]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK2ZO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM2EUN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL7ATR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH9FAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG4FCX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG5NFF;23cm10G24GMartin;JO50DO;StringProperty [value: 1296.15]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DO1XRK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG3AWN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SM7VUK;Bengt MGM FT8/4;JO66LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK7RC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG0ONW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1VDJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F6KFH;Radioclub;JN39OC;StringProperty [value: 432.257]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +HG7M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1PMA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM5CT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1DX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2EA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2WC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +HB9GF-70;HFI;JN47BC;StringProperty [value: 432.22]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SP6FXF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1BHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0NAU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF9ME/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO5STS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: 432.255]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL8AMB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OM3W;Club 2m only;JN99CH;StringProperty [value: 144.325]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL8R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1JHR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL25WIKI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG8MDA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8GL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1HQK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0AU/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1PAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2LA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1NGR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF1AK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG1E;DG1E-23 .240;JO31LE;StringProperty [value: 144.24]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DR3K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM4K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK7DCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG8KV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK2HZ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DN9SFM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1DT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF9OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1UZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF7JU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5BCQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +PA0DDB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL0RD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DM7KN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL5BAW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ4FV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL4YBZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL6GCK;Konrad 2m;JN47OR;StringProperty [value: 144.36]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL9FCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB4LL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK9CK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB7PN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF3TE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DN9RME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DC6HG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DF4WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK4REX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5EZ;George;JO31NH;StringProperty [value: 144.345]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL1FKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9RP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1EIP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OM5AW;Joe QRO144;JN98AH;StringProperty [value: 144.241]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DO6NI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +F8KGU;Didier;JN19BQ;StringProperty [value: 144.33]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DK0AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3SBA/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9UN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK8LQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DG5SP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB2DY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL9KDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK5II;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH1WM/P;Mathias 2m;JN49AG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL8FBX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DB5SM;Klaus-2m;JN59LE;StringProperty [value: 144.194]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DJ3SN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DH9ET;Tom 2m;JN57TS;StringProperty [value: 144.226]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DG4MNA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL1ARS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DL3BH;Holger;JN49SB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK1KFH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK6M;2m-18el. M2,4x6;JN99CR;StringProperty [value: 144.235]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OK1KJP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +OK1ADT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +SP7VVB;Maciek 2m;JO91VQ;StringProperty [value: 144.385]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SP9KDA;KST4Contest1263;JO90PP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK2KEA;Radioclub 2m;JN89EJ;StringProperty [value: 144.375]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SP6KEP;RC 2m only;JO90CK;StringProperty [value: 144.18]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DO2AD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +G2D;Club 144.320;JO01JA;StringProperty [value: 144.319]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DB1PA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +DK0TUI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null +9A1O;RC Osijek;JN95IS;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0BZ;Dave;JN05EL;StringProperty [value: 144.39]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +DF0U;Sven;JO34DD;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +DF1C;Bob;JO43LS;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave +OK8V;Alain;JN34TV;StringProperty [value: 432.115]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1BEF;Dan;JO46OE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ4VW;Arne;JO45UT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz +OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz \ No newline at end of file