mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-06-22 22:06:49 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce73553b4f |
@@ -0,0 +1,2 @@
|
||||
S53MM
|
||||
PA9R
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChatMessage> lst_globalChatMessageList = new BoundedDequeObservableList<>(MAX_CHAT_MESSAGES); //All chatmessages will be put in there, later create filtered message lists
|
||||
private ObservableList<ChatMessage> lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists
|
||||
// private ObservableList<ChatMessage> lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all
|
||||
// (beacon)
|
||||
private FilteredList<ChatMessage> lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all
|
||||
@@ -1129,6 +1129,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> 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<ChatMessage> pendingChatMessages = new ArrayList<>();
|
||||
private boolean chatMessageFlushScheduled = false;
|
||||
|
||||
/*
|
||||
* Same idea for DXCluster messages.
|
||||
*/
|
||||
private final Object pendingClusterMessagesLock = new Object();
|
||||
private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>();
|
||||
private boolean clusterMessageFlushScheduled = false;
|
||||
|
||||
private ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
|
||||
|
||||
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //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<ChatMessage> 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<ChatMessage> 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<ClusterMessage> 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<ChatMessage> 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<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||
|
||||
@@ -1498,12 +1658,16 @@ 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);
|
||||
if (isSniffedMessage(chatMessage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package kst4contest.service.path;
|
||||
|
||||
/**
|
||||
* Utility methods for Fresnel zone calculations.
|
||||
*
|
||||
* <p>This helper intentionally contains only pure mathematical functions.
|
||||
* It has no dependency on UI code or terrain providers.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>Formula:
|
||||
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
|
||||
*
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Preference order:
|
||||
* <ol>
|
||||
* <li>configured DEM root directory if it already exists</li>
|
||||
* <li>its parent directory if that exists</li>
|
||||
* <li>user home directory</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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<ActionEvent>() {
|
||||
@Override
|
||||
public void handle(ActionEvent actionEvent) {
|
||||
showSelectedCallsignOnMap();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw());
|
||||
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
||||
@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<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||
// msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
||||
//
|
||||
// @Override
|
||||
// public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> 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<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||
msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
||||
|
||||
@@ -2285,13 +2377,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> 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<ActionEvent>() {
|
||||
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<String>() {
|
||||
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> 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<Boolean>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> 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<File> 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<S, T> extends TableCell<S, T> {
|
||||
setGraphic(actionButton);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -8858,6 +9210,4 @@ class CheckBoxTableCell<S, T> extends TableCell<S, T> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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<TerrainProfileProvider> providers;
|
||||
|
||||
public ChainedTerrainProfileProvider(List<TerrainProfileProvider> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Assumptions of this reader:
|
||||
* <ul>
|
||||
* <li>local tiles already exist on disk</li>
|
||||
* <li>official DGED GeoTIFF filenames are used</li>
|
||||
* <li>tiles represent 1° x 1° geocells</li>
|
||||
* <li>the raster uses RasterPixelIsPoint semantics</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<String> demRootDirectorySupplier;
|
||||
private final OfflineDemManager offlineDemManager;
|
||||
|
||||
private final Map<Path, LoadedTile> loadedTileCache =
|
||||
new LinkedHashMap<>(16, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<Path, LoadedTile> eldest) {
|
||||
return size() > MAX_LOADED_TILES;
|
||||
}
|
||||
};
|
||||
|
||||
public CopernicusGlo30TerrainProfileProvider(Supplier<String> 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<PathProfilePoint> 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<ImageReader> 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.
|
||||
*
|
||||
* <p>The current reader assumes 1° x 1° geocells and derives raster
|
||||
* coordinates directly from the sample latitude/longitude.</p>
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GridCell> buildVisibleCells(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
int leafletZoom) {
|
||||
return buildVisibleCells(southLat, westLon, northLat, eastLon, precisionForZoom(leafletZoom));
|
||||
}
|
||||
|
||||
public static List<GridCell> 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<GridCell> build2CharFields(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> 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<GridCell> build4CharSquares(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> 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<GridCell> build6CharSubsquares(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> 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<String, String> 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<String, String> entry : lastKnownFrequenciesByBand.entrySet()) {
|
||||
joiner.add(entry.getKey() + ": " + entry.getValue());
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
}
|
||||
@@ -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<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
||||
ChatMember selectedChatMember) {
|
||||
|
||||
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String selectedCallsignRaw = normalizeCallsignRaw(
|
||||
selectedChatMember == null ? null : selectedChatMember.getCallSignRaw()
|
||||
);
|
||||
|
||||
Map<String, List<ChatMember>> groupedByCallsignRaw = new LinkedHashMap<>();
|
||||
|
||||
for (ChatMember chatMember : visibleChatMembers) {
|
||||
if (chatMember == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String callSignRaw = normalizeCallsignRaw(chatMember.getCallSignRaw());
|
||||
if (callSignRaw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupedByCallsignRaw
|
||||
.computeIfAbsent(callSignRaw, ignored -> new ArrayList<>())
|
||||
.add(chatMember);
|
||||
}
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = new ArrayList<>(groupedByCallsignRaw.size());
|
||||
|
||||
for (Map.Entry<String, List<ChatMember>> entry : groupedByCallsignRaw.entrySet()) {
|
||||
String callSignRaw = entry.getKey();
|
||||
List<ChatMember> variants = entry.getValue();
|
||||
|
||||
ChatMember representative = chooseRepresentative(variants);
|
||||
if (representative == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String locator6 = findBestLocator6(variants);
|
||||
if (locator6.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Location location = new Location(locator6);
|
||||
|
||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
|
||||
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<ChatMember> variants) {
|
||||
return variants.stream()
|
||||
.filter(chatMember -> chatMember != null && normalizeLocator6(chatMember.getQra()).length() == 6)
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElseGet(() -> variants.stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null));
|
||||
}
|
||||
|
||||
private String bestDisplayCallsign(List<ChatMember> variants, ChatMember representative) {
|
||||
if (representative != null && representative.getCallSign() != null && !representative.getCallSign().isBlank()) {
|
||||
return representative.getCallSign().trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant != null && variant.getCallSign() != null && !variant.getCallSign().isBlank()) {
|
||||
return variant.getCallSign().trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
return representative == null ? "" : normalizeCallsignRaw(representative.getCallSignRaw());
|
||||
}
|
||||
|
||||
private String findBestLocator6(List<ChatMember> variants) {
|
||||
return variants.stream()
|
||||
.sorted(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch).reversed())
|
||||
.map(ChatMember::getQra)
|
||||
.map(this::normalizeLocator6)
|
||||
.filter(locator -> locator.length() == 6)
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
|
||||
|
||||
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
|
||||
Band band = bandEntry.getKey();
|
||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
|
||||
|
||||
if (band == null || activeFrequencyInfo == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(band);
|
||||
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
|
||||
latestByBand.put(band, new FrequencyCandidate(
|
||||
band,
|
||||
formatFrequency(activeFrequencyInfo.frequency),
|
||||
activeFrequencyInfo.timestampEpoch
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
||||
latestByBand.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for stations where the current displayed QRG exists but the
|
||||
* knownActiveBands history has not yet been filled.
|
||||
*
|
||||
* This parsing is intentionally tolerant so strings like "144.300 MHz"
|
||||
* can still be used.
|
||||
*/
|
||||
private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map<Band, FrequencyCandidate> latestByBand) {
|
||||
if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawFrequency = variant.getFrequency().getValue().trim();
|
||||
if (rawFrequency.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency);
|
||||
if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Band detectedBand = Band.fromFrequency(parsedFrequencyMHz);
|
||||
if (detectedBand == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(detectedBand);
|
||||
if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestByBand.put(detectedBand, new FrequencyCandidate(
|
||||
detectedBand,
|
||||
formatFrequency(parsedFrequencyMHz),
|
||||
variant.getActivityTimeLastInEpoch()
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isWorkedAtAnyBand(ChatMember member) {
|
||||
return member.isWorked()
|
||||
|| member.isWorked50()
|
||||
|| member.isWorked70()
|
||||
|| member.isWorked144()
|
||||
|| member.isWorked432()
|
||||
|| member.isWorked1240()
|
||||
|| member.isWorked2300()
|
||||
|| member.isWorked3400()
|
||||
|| member.isWorked5600()
|
||||
|| member.isWorked10G()
|
||||
|| member.isWorked24G()
|
||||
|| member.isWorked47G()
|
||||
|| member.isWorked76G();
|
||||
}
|
||||
|
||||
private int extractReachableAirplanes(AirPlaneReflectionInfo airPlaneReflectionInfo) {
|
||||
return airPlaneReflectionInfo == null ? 0 : airPlaneReflectionInfo.getAirPlanesReachableCntr();
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
if (callSignRaw == null) {
|
||||
return "";
|
||||
}
|
||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
if (locator == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String normalized = locator.trim().toUpperCase(Locale.ROOT);
|
||||
if (normalized.length() >= 6) {
|
||||
normalized = normalized.substring(0, 6);
|
||||
}
|
||||
|
||||
if (!normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String toBandDisplayLabel(Band band) {
|
||||
return switch (band) {
|
||||
case B_144 -> "144";
|
||||
case B_432 -> "432";
|
||||
case B_1296 -> "1296";
|
||||
case B_2320 -> "2320";
|
||||
case B_3400 -> "3400";
|
||||
case B_5760 -> "5760";
|
||||
case B_10G -> "10368";
|
||||
case B_24G -> "24048";
|
||||
};
|
||||
}
|
||||
|
||||
private String formatFrequency(double frequencyMHz) {
|
||||
return String.format(Locale.US, "%.3f", frequencyMHz);
|
||||
}
|
||||
|
||||
private record FrequencyCandidate(Band band, String formattedFrequency, long timestampEpochMs) {
|
||||
}
|
||||
}
|
||||
@@ -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 """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KST4Contest Station Map</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="">
|
||||
<style>
|
||||
:root {
|
||||
--map-background: #ede9df;
|
||||
--station-label-bg: rgba(248, 248, 248, 0.95);
|
||||
--station-label-color: #1c1c1c;
|
||||
--station-label-border: rgba(0, 0, 0, 0.20);
|
||||
--grid-label-bg: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-color: rgba(20, 20, 20, 0.38);
|
||||
--control-bg: rgba(255, 255, 255, 0.96);
|
||||
--control-fg: #242424;
|
||||
--control-border: #b7b7b7;
|
||||
--attribution-bg: rgba(255, 255, 255, 0.88);
|
||||
--attribution-fg: #2d2d2d;
|
||||
--attribution-link: #145fa3;
|
||||
}
|
||||
|
||||
body.kst-theme-dark {
|
||||
--map-background: #23282d;
|
||||
--station-label-bg: rgba(36, 40, 45, 0.96);
|
||||
--station-label-color: #f1f3f5;
|
||||
--station-label-border: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-bg: rgba(34, 38, 43, 0.20);
|
||||
--grid-label-color: rgba(235, 240, 245, 0.42);
|
||||
--control-bg: rgba(55, 62, 67, 0.96);
|
||||
--control-fg: #e2e6ea;
|
||||
--control-border: #556068;
|
||||
--attribution-bg: rgba(34, 38, 43, 0.86);
|
||||
--attribution-fg: #d2d8dd;
|
||||
--attribution-link: #88c7ff;
|
||||
}
|
||||
|
||||
html, body, #map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--map-background);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background: var(--map-background);
|
||||
}
|
||||
|
||||
body.kst-theme-dark .leaflet-tile-pane {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.leaflet-bar {
|
||||
border: 1px solid var(--control-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover {
|
||||
background: var(--control-bg);
|
||||
color: var(--control-fg);
|
||||
border-bottom: 1px solid var(--control-border);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: var(--attribution-bg);
|
||||
color: var(--attribution-fg);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--attribution-link);
|
||||
}
|
||||
|
||||
.station-marker-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.station-marker-root {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.station-dot {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1d1d1d;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #4da6ff;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.station-dot.worked {
|
||||
border-color: #ffd24d;
|
||||
}
|
||||
|
||||
.station-dot.warning {
|
||||
border-color: #00ff66;
|
||||
}
|
||||
|
||||
.station-dot.selected {
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 3px;
|
||||
border-color: #ff9900;
|
||||
}
|
||||
|
||||
.station-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: -22px;
|
||||
display: inline-block;
|
||||
background: var(--station-label-bg);
|
||||
color: var(--station-label-color);
|
||||
border: 1px solid var(--station-label-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.station-label.warning {
|
||||
color: #00ff66;
|
||||
border-color: rgba(0, 255, 102, 0.75);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper .maidenhead-grid-label {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.maidenhead-grid-label {
|
||||
display: inline-block;
|
||||
background: var(--grid-label-bg);
|
||||
#color: var(--grid-label-color);
|
||||
color: #63067a;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.18);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="kst-theme-light">
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
window.kstMapApi = (function () {
|
||||
let map;
|
||||
let stationLayer;
|
||||
let gridLayer;
|
||||
let beamLayer;
|
||||
let connectionLayer;
|
||||
let profileHoverMarker;
|
||||
let markersByCallsignRaw = {};
|
||||
let activeTheme = 'light';
|
||||
let invalidateNotifyTimer = 0;
|
||||
|
||||
function jsLog(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsLog(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[KST map fallback log]', message, e);
|
||||
}
|
||||
}
|
||||
|
||||
function jsError(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsError(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KST map fallback error]', message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function notifyMapReady() {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onMapReady();
|
||||
}
|
||||
} catch (e) {
|
||||
jsError('notifyMapReady failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyViewport() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!window.javaMapBridge) {
|
||||
jsError('notifyViewport skipped: no javaMapBridge');
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
jsLog('notifyViewport south=' + bounds.getSouth()
|
||||
+ ' west=' + bounds.getWest()
|
||||
+ ' north=' + bounds.getNorth()
|
||||
+ ' east=' + bounds.getEast()
|
||||
+ ' zoom=' + zoom);
|
||||
|
||||
window.javaMapBridge.onViewportChanged(
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
);
|
||||
} catch (e) {
|
||||
jsError('notifyViewport failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function gridLineColor() {
|
||||
return activeTheme === 'dark' ? '#e1e7ec' : '#46586c';
|
||||
}
|
||||
|
||||
function gridLineOpacity() {
|
||||
return activeTheme === 'dark' ? 0.48 : 0.56;
|
||||
}
|
||||
|
||||
function connectionColor() {
|
||||
return activeTheme === 'dark' ? '#2fd7ff' : '#00a4cf';
|
||||
}
|
||||
|
||||
function applyThemeClass() {
|
||||
document.body.classList.remove('kst-theme-light', 'kst-theme-dark');
|
||||
document.body.classList.add(activeTheme === 'dark' ? 'kst-theme-dark' : 'kst-theme-light');
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
activeTheme = themeName === 'dark' ? 'dark' : 'light';
|
||||
applyThemeClass();
|
||||
jsLog('setTheme ' + activeTheme);
|
||||
}
|
||||
|
||||
function buildStationMarkerHtml(station) {
|
||||
let dotClasses = 'station-dot';
|
||||
if (station.selected) {
|
||||
dotClasses += ' selected';
|
||||
} else if (station.warningToMyDirection) {
|
||||
dotClasses += ' warning';
|
||||
} else if (station.worked) {
|
||||
dotClasses += ' worked';
|
||||
}
|
||||
|
||||
let labelClasses = 'station-label';
|
||||
if (station.warningToMyDirection) {
|
||||
labelClasses += ' warning';
|
||||
}
|
||||
|
||||
return '<div class="station-marker-root" data-callsignraw="' + escapeHtml(station.callSignRaw) + '">'
|
||||
+ '<div class="' + dotClasses + '"></div>'
|
||||
+ '<div class="' + labelClasses + '">' + escapeHtml(station.markerLabel) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (map) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyThemeClass();
|
||||
|
||||
map = L.map('map', {
|
||||
zoomControl: true
|
||||
}).setView([51.0, 10.0], 6);
|
||||
|
||||
jsLog('Leaflet map initialized');
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: '© OpenStreetMap'
|
||||
}).addTo(map);
|
||||
|
||||
map.createPane('beamPane');
|
||||
map.getPane('beamPane').style.zIndex = 410;
|
||||
map.getPane('beamPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridPane');
|
||||
map.getPane('gridPane').style.zIndex = 420;
|
||||
map.getPane('gridPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridLabelPane');
|
||||
map.getPane('gridLabelPane').style.zIndex = 430;
|
||||
map.getPane('gridLabelPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('connectionPane');
|
||||
map.getPane('connectionPane').style.zIndex = 440;
|
||||
map.getPane('connectionPane').style.pointerEvents = 'none';
|
||||
|
||||
stationLayer = L.layerGroup().addTo(map);
|
||||
gridLayer = L.layerGroup().addTo(map);
|
||||
beamLayer = L.layerGroup().addTo(map);
|
||||
connectionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
map.on('zoomend', function () {
|
||||
jsLog('zoomend -> ' + map.getZoom());
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
map.on('moveend', function () {
|
||||
jsLog('moveend');
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
notifyMapReady();
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
function invalidateSize() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('invalidateSize');
|
||||
|
||||
map.invalidateSize(false);
|
||||
|
||||
if (invalidateNotifyTimer) {
|
||||
window.clearTimeout(invalidateNotifyTimer);
|
||||
}
|
||||
|
||||
invalidateNotifyTimer = window.setTimeout(function () {
|
||||
notifyViewport();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() + 1, { animate: false });
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() - 1, { animate: false });
|
||||
}
|
||||
|
||||
function getViewportState() {
|
||||
if (!map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
return [
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function inspectPoint(x, y) {
|
||||
try {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) {
|
||||
return 'none||||';
|
||||
}
|
||||
|
||||
const stationRoot = el.closest('.station-marker-root');
|
||||
if (stationRoot) {
|
||||
const callSignRaw = stationRoot.getAttribute('data-callsignraw') || '';
|
||||
return 'station|' + callSignRaw + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomInButton = el.closest('.leaflet-control-zoom-in');
|
||||
if (zoomInButton) {
|
||||
return 'zoomIn||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomOutButton = el.closest('.leaflet-control-zoom-out');
|
||||
if (zoomOutButton) {
|
||||
return 'zoomOut||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
return 'none||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
} catch (e) {
|
||||
return 'error||ERROR|' + e + '|';
|
||||
}
|
||||
}
|
||||
|
||||
function setHome(lat, lon, zoom) {
|
||||
init();
|
||||
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
|
||||
map.setView([lat, lon], zoom);
|
||||
}
|
||||
|
||||
function setStations(stationsJson) {
|
||||
init();
|
||||
|
||||
stationLayer.clearLayers();
|
||||
markersByCallsignRaw = {};
|
||||
|
||||
const stations = JSON.parse(stationsJson);
|
||||
jsLog('setStations count=' + stations.length);
|
||||
|
||||
stations.forEach(station => {
|
||||
const marker = L.marker(
|
||||
[station.latitudeDeg, station.longitudeDeg],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-marker-wrapper',
|
||||
html: buildStationMarkerHtml(station),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
marker.addTo(stationLayer);
|
||||
markersByCallsignRaw[station.callSignRaw] = marker;
|
||||
});
|
||||
}
|
||||
|
||||
function setBeam(beamJson) {
|
||||
init();
|
||||
beamLayer.clearLayers();
|
||||
|
||||
if (!beamJson || beamJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(beamJson);
|
||||
if (!points || points.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setBeam points=' + points.length);
|
||||
|
||||
const latLngs = points.map(point => [point.lat, point.lon]);
|
||||
|
||||
L.polygon(latLngs, {
|
||||
pane: 'beamPane',
|
||||
color: '#ff4d4d',
|
||||
weight: 2,
|
||||
fillColor: '#ff4d4d',
|
||||
fillOpacity: 0.12,
|
||||
interactive: false
|
||||
}).addTo(beamLayer);
|
||||
}
|
||||
|
||||
function setConnection(connectionJson) {
|
||||
init();
|
||||
connectionLayer.clearLayers();
|
||||
|
||||
if (!connectionJson || connectionJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(connectionJson);
|
||||
if (!points || points.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setConnection');
|
||||
|
||||
L.polyline(points.map(point => [point.lat, point.lon]), {
|
||||
pane: 'connectionPane',
|
||||
color: connectionColor(),
|
||||
weight: 2,
|
||||
dashArray: '6,6',
|
||||
opacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(connectionLayer);
|
||||
}
|
||||
|
||||
function setProfileHoverPoint(point) {
|
||||
init();
|
||||
|
||||
if (profileHoverMarker) {
|
||||
map.removeLayer(profileHoverMarker);
|
||||
profileHoverMarker = null;
|
||||
}
|
||||
|
||||
if (!point || !isFinite(point.lat) || !isFinite(point.lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileHoverMarker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: 6,
|
||||
color: '#ffcc00',
|
||||
weight: 2,
|
||||
fillColor: '#ffcc00',
|
||||
fillOpacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
|
||||
if (point.label) {
|
||||
profileHoverMarker.bindTooltip(point.label, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
}).openTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function setGrid(gridJson) {
|
||||
init();
|
||||
gridLayer.clearLayers();
|
||||
|
||||
const cells = JSON.parse(gridJson);
|
||||
jsLog('setGrid cells=' + cells.length);
|
||||
|
||||
cells.forEach(cell => {
|
||||
const rectangle = L.rectangle(
|
||||
[
|
||||
[cell.southLat, cell.westLon],
|
||||
[cell.northLat, cell.eastLon]
|
||||
],
|
||||
{
|
||||
pane: 'gridPane',
|
||||
color: gridLineColor(),
|
||||
opacity: gridLineOpacity(),
|
||||
weight: 1.4,
|
||||
fillOpacity: 0.0,
|
||||
interactive: false
|
||||
}
|
||||
);
|
||||
|
||||
rectangle.addTo(gridLayer);
|
||||
|
||||
if (cell.showLabel) {
|
||||
const centerLat = (cell.southLat + cell.northLat) / 2.0;
|
||||
const centerLon = (cell.westLon + cell.eastLon) / 2.0;
|
||||
const labelFontPx = cell.labelFontPx || 12;
|
||||
|
||||
L.marker([centerLat, centerLon], {
|
||||
pane: 'gridLabelPane',
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'maidenhead-grid-label-wrapper',
|
||||
html: '<div class="maidenhead-grid-label" style="font-size:' + labelFontPx + 'px;">' + escapeHtml(cell.locatorLabel) + '</div>',
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}).addTo(gridLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusCallsignRaw(callSignRaw) {
|
||||
if (!map || !callSignRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = markersByCallsignRaw[callSignRaw];
|
||||
if (!marker) {
|
||||
jsLog('focusCallsignRaw skipped for ' + callSignRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('focusCallsignRaw ' + callSignRaw);
|
||||
|
||||
map.panTo(marker.getLatLng(), {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
init: init,
|
||||
invalidateSize: invalidateSize,
|
||||
zoomIn: zoomIn,
|
||||
zoomOut: zoomOut,
|
||||
inspectPoint: inspectPoint,
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
setGrid: setGrid,
|
||||
focusCallsignRaw: focusCallsignRaw,
|
||||
setTheme: setTheme
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This is intentionally the first practical step before adding a real network
|
||||
* downloader:
|
||||
* <ul>
|
||||
* <li>create a known default Copernicus DEM root directory below .praktiKST</li>
|
||||
* <li>copy manually selected local *_DEM.tif files into that directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>If the configured DEM root directory is blank, the default directory
|
||||
* below .praktiKST is used automatically.</p>
|
||||
*
|
||||
* @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<File> 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<String> skippedFilenames = new ArrayList<>();
|
||||
|
||||
for (File selectedFile : selectedFiles) {
|
||||
if (selectedFile == null || !selectedFile.isFile() || !selectedFile.canRead()) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile == null ? "<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<String> skippedFilenames,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This helper is intentionally public so that UI/import helpers can
|
||||
* validate manually selected files before copying them into the DEM root.</p>
|
||||
*
|
||||
* @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<String, Path> 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<String, Path> 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<String, Path> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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<String> demRootDirectorySupplier;
|
||||
private final Supplier<DemDataset> datasetSupplier;
|
||||
private final OfflineDemManager offlineDemManager;
|
||||
|
||||
public OfflineDemTerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
||||
Supplier<DemDataset> 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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<RequestCacheKey, TerrainProfileData> profileCache =
|
||||
new LinkedHashMap<>(32, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<RequestCacheKey, TerrainProfileData> 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<SamplePoint> 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<SamplePoint> samplePoints) {
|
||||
try {
|
||||
List<Double> elevations = fetchElevations(samplePoints);
|
||||
|
||||
if (elevations.size() != samplePoints.size()) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " returned an incomplete elevation array");
|
||||
}
|
||||
|
||||
List<PathProfilePoint> 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<SamplePoint> buildSamplePoints(TerrainProfileRequest request, int sampleCount) {
|
||||
List<SamplePoint> 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<Double> fetchElevations(List<SamplePoint> 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<String> 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<Double> 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<Double> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This result intentionally combines:
|
||||
* <ul>
|
||||
* <li>basic path metadata used by the detail panel</li>
|
||||
* <li>the enriched path profile used by the preview chart</li>
|
||||
* <li>formatted state helpers for concise UI binding</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The chart and the numeric evaluation both consume the same already
|
||||
* computed profile data to avoid drift between visual and numeric output.</p>
|
||||
*/
|
||||
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<PathProfilePoint> 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<PathProfilePoint> 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<PathProfilePoint> 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<PathProfilePoint> 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This class intentionally centralizes:
|
||||
* <ul>
|
||||
* <li>great-circle geometry</li>
|
||||
* <li>Earth curvature calculations</li>
|
||||
* <li>Fresnel calculations</li>
|
||||
* <li>adaptive profile sampling heuristics</li>
|
||||
* <li>default/fallback frequency handling</li>
|
||||
* <li>tolerant frequency parsing from UI/chat strings</li>
|
||||
* </ul>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>This is still only a geometric/refraction approximation. It does not model
|
||||
* troposcatter, aircraft scatter, ducting or diffraction loss numerically.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>This uses spherical linear interpolation (slerp) on the unit sphere.
|
||||
* It avoids the path distortion that appears when latitude and longitude
|
||||
* are interpolated independently.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Current heuristic:
|
||||
* <ul>
|
||||
* <li>target spacing about 0.5 km</li>
|
||||
* <li>minimum 121 samples</li>
|
||||
* <li>maximum 1201 samples</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Distances are given along the path in kilometers.</p>
|
||||
*
|
||||
* <p>Approximation:
|
||||
* bulge = d1 * d2 / (2 * R_eff)</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>k = 1.0 means optical/geometric Earth curvature. k = 4/3 is the common
|
||||
* standard radio-refraction approximation for VHF/UHF path previews.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>The result is based on the effective Earth radius model:
|
||||
* d = sqrt(2 * R_eff * h)</p>
|
||||
*
|
||||
* <p>This is a local tangent-horizon approximation. It is useful as an operator
|
||||
* hint, but it is not a complete propagation prediction.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Values outside a practical range are folded back to the standard default
|
||||
* so broken preferences or malformed future XML values cannot destabilize path
|
||||
* analysis.</p>
|
||||
*
|
||||
* @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:
|
||||
* <ul>
|
||||
* <li>"144.300"</li>
|
||||
* <li>"144,300"</li>
|
||||
* <li>"144.300 MHz"</li>
|
||||
* <li>"QRG 432.174 MHz"</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Strategy:
|
||||
* <ol>
|
||||
* <li>Prefer 144 MHz data if available</li>
|
||||
* <li>Otherwise use the first parsable known station frequency</li>
|
||||
* <li>Otherwise use the central default frequency</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param frequenciesByBand known frequencies grouped by band
|
||||
* @return resolved analysis frequency in MHz
|
||||
*/
|
||||
public static double resolveAnalysisFrequencyMHz(Map<String, String> 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.
|
||||
*
|
||||
* <p>Positive height means the obstruction is above the direct line of sight.
|
||||
* This method is useful for a rough single-knife-edge severity estimate.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>This is a rough operator-facing indicator. It must not be interpreted as a
|
||||
* complete path-loss model.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable summary of simple radio-horizon and terrain-profile horizon data.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* User/configuration values for the path link-budget estimate.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Bidirectional link-budget estimate for one path.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable terrain sample plus optional derived radio-path geometry.
|
||||
*
|
||||
* <p>The first four properties are the raw terrain profile payload:
|
||||
* <ul>
|
||||
* <li>distance along the path in kilometers</li>
|
||||
* <li>sample latitude in degrees</li>
|
||||
* <li>sample longitude in degrees</li>
|
||||
* <li>terrain elevation in meters above mean sea level</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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:
|
||||
* <ul>
|
||||
* <li>curvature-adjusted terrain elevation</li>
|
||||
* <li>direct line-of-sight height</li>
|
||||
* <li>upper / lower first Fresnel hull</li>
|
||||
* <li>LOS and Fresnel clearances</li>
|
||||
* <li>worst-intrusion support values</li>
|
||||
* </ul>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>This constructor is used by terrain providers and cache deserialization.
|
||||
* Derived geometry values remain unavailable until the path analysis service
|
||||
* enriches the profile.</p>
|
||||
*
|
||||
* @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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Operator-facing propagation assessment derived from geometric path analysis.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMember> chatMemberTable;
|
||||
private final StationMapView stationMapView;
|
||||
private final Consumer<ChatMember> 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<ChatMember> chatMemberTable,
|
||||
StationMapView stationMapView,
|
||||
Consumer<ChatMember> 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<ChatMember>) 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<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
|
||||
List<MapCallsignRawSnapshot> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PathProfilePoint> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The catalog intentionally contains the required attribution/license texts
|
||||
* so they can later be shown both in the client UI and in service responses.</p>
|
||||
*/
|
||||
public record TerrainCatalog(
|
||||
int schemaVersion,
|
||||
int catalogVersion,
|
||||
String generatedAtUtc,
|
||||
String regionSet,
|
||||
String packageBaseUrl,
|
||||
String sourceAttribution,
|
||||
String licenseNotice,
|
||||
String disclaimerNotice,
|
||||
List<TerrainCatalogPackageEntry> 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<TerrainCatalogPackageEntry> 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<TerrainCatalogPackageEntry> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The first implementation intentionally uses XML instead of JSON because:
|
||||
* <ul>
|
||||
* <li>the current project already contains XML parsing patterns</li>
|
||||
* <li>no additional JSON dependency is required</li>
|
||||
* <li>we can move faster toward a working package download/install flow</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The parsed result still maps into the shared terrain catalog model classes.</p>
|
||||
*/
|
||||
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<Path> 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<TerrainCatalogPackageEntry> packageEntries = new ArrayList<>();
|
||||
|
||||
if (packagesElement != null) {
|
||||
List<Element> packageElements = getDirectChildElements(packagesElement, "package");
|
||||
|
||||
for (Element packageElement : packageElements) {
|
||||
List<String> 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<Element> getDirectChildElements(Element parent, String tagName) {
|
||||
List<Element> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable catalog entry describing one downloadable terrain package.
|
||||
*
|
||||
* <p>This model is intentionally shared between the future server-side catalog
|
||||
* generation and the future desktop downloader.</p>
|
||||
*/
|
||||
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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This class is intentionally self-contained so it can later be reused
|
||||
* unchanged both in the desktop client and in a future Spring service.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>This is intentionally finer than the final profile sampling so that
|
||||
* package/tile coverage is not missed on diagonal or border-crossing paths.</p>
|
||||
*/
|
||||
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<String> requiredRegionIds = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> requiredPackageIds = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> 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<String> 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<String> resolveRequiredTileIdsForPath(PathAnalysisRequest request) {
|
||||
return resolveCoverageForPath(request).tileIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical package id for one region package.
|
||||
*
|
||||
* Example:
|
||||
* <ul>
|
||||
* <li>terrain-eu-jo22-v1</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<String> regionIds,
|
||||
List<String> packageIds,
|
||||
List<String> 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) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This implementation is intentionally simple and robust:
|
||||
* <ul>
|
||||
* <li>download packages by catalog entry</li>
|
||||
* <li>store them below terrain/packages</li>
|
||||
* <li>reuse existing files when the checksum already matches</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<String> requiredPackageIds) {
|
||||
List<PackageDownloadResult> 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<Path> 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<PackageDownloadResult> 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<PackageDownloadResult> 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<Path> successfulPackageFiles() {
|
||||
return itemResults.stream()
|
||||
.filter(PackageDownloadResult::success)
|
||||
.map(PackageDownloadResult::localPackageFile)
|
||||
.filter(path -> path != null)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>If no DEM root directory is configured, the default directory below
|
||||
* .praktiKST is used automatically.</p>
|
||||
*
|
||||
* @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<? extends ZipEntry> 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<TerrainTileMetadata> 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<Element> getDirectChildElements(Element parent, String tagName) {
|
||||
List<Element> 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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This model is the authoritative description of the package contents after
|
||||
* download and before/after installation.</p>
|
||||
*/
|
||||
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<TerrainTileMetadata> 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<TerrainTileMetadata> 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>This is the first end-to-end vertical slice that connects:
|
||||
* <ul>
|
||||
* <li>path coverage resolution</li>
|
||||
* <li>catalog download/load</li>
|
||||
* <li>package download</li>
|
||||
* <li>package installation into the local DEM directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
public final class TerrainPackageService {
|
||||
|
||||
/**
|
||||
* First default catalog URL for the future modular terrain service on hamradioonline.de.
|
||||
*
|
||||
* <p>This can later move into user preferences without changing the orchestration flow.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>Workflow:
|
||||
* <ol>
|
||||
* <li>resolve required package ids from the path geometry</li>
|
||||
* <li>download the catalog</li>
|
||||
* <li>fallback to the last local catalog if the download fails</li>
|
||||
* <li>download all matching required packages</li>
|
||||
* <li>install all successfully downloaded packages into the DEM root</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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<String> missingPackageIds = coverageSelection.packageIds().stream()
|
||||
.filter(packageId -> terrainCatalog.findPackageById(packageId).isEmpty())
|
||||
.toList();
|
||||
|
||||
List<String> 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<TerrainPackageInstaller.InstallResult> 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<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> 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<TerrainPackageInstaller.InstallResult> 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
|
||||
? "<unknown>"
|
||||
: 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<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> 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<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TerrainProfileData> 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<PathProfilePoint> 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<PathProfilePoint> 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<PathProfilePoint> deserializeProfile(String serializedProfile) {
|
||||
if (serializedProfile == null || serializedProfile.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String[] lines = serializedProfile.split("\\R+");
|
||||
List<PathProfilePoint> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable terrain/profile payload including source metadata.
|
||||
*/
|
||||
public record TerrainProfileData(
|
||||
List<PathProfilePoint> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Abstraction for terrain/profile retrieval.
|
||||
*/
|
||||
public interface TerrainProfileProvider {
|
||||
|
||||
TerrainProfileData loadProfile(TerrainProfileRequest request);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable metadata for one internal terrain tile.
|
||||
*
|
||||
* <p>This model intentionally describes the runtime-ready tile after ingestion
|
||||
* into the KST4Contest terrain format, not the original upstream GeoTIFF.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>The tile is interpreted as the 1° x 1° cell
|
||||
* [southDeg, southDeg+1) x [westDeg, westDeg+1).</p>
|
||||
*
|
||||
* @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:
|
||||
* <ul>
|
||||
* <li>N51_E007</li>
|
||||
* <li>N52_W003</li>
|
||||
* <li>S01_E010</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+2368
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user