mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-06-23 22:36:45 +02:00
Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now
This commit is contained in:
@@ -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)
|
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
|
* 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.logic.PriorityCalculator;
|
||||||
import kst4contest.model.*;
|
import kst4contest.model.*;
|
||||||
import kst4contest.test.MockKstServer;
|
import kst4contest.test.MockKstServer;
|
||||||
import kst4contest.utils.BoundedDequeObservableList;
|
|
||||||
import kst4contest.utils.PlayAudioUtils;
|
import kst4contest.utils.PlayAudioUtils;
|
||||||
import kst4contest.view.Kst4ContestApplication;
|
import kst4contest.view.Kst4ContestApplication;
|
||||||
|
|
||||||
@@ -33,6 +32,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
* 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!
|
// ******All abstract types below here are used by the messageprocessor!
|
||||||
// ***************
|
// ***************
|
||||||
|
|
||||||
private static final int MAX_CHAT_MESSAGES = 10000;
|
private ObservableList<ChatMessage> lst_globalChatMessageList = FXCollections.observableArrayList(); //All chatmessages will be put in there, later create filtered message lists
|
||||||
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_toAllMessageList = FXCollections.observableArrayList(); // directed to all
|
// private ObservableList<ChatMessage> lst_toAllMessageList = FXCollections.observableArrayList(); // directed to all
|
||||||
// (beacon)
|
// (beacon)
|
||||||
private FilteredList<ChatMessage> lst_toAllMessageList = new FilteredList<>(lst_globalChatMessageList); // directed to all
|
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<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||||
private ObservableList<ClusterMessage> lst_clusterMemberList = 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 ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
|
// 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;
|
this.lst_selectedCallSignInfofilteredMessageList = lst_selectedCallSignInfofilteredMessageList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addChatMessage(ChatMessage message) {
|
|
||||||
lst_globalChatMessageList.addFirst(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ObservableList<ChatMessage> getLst_globalChatMessageList() {
|
public ObservableList<ChatMessage> getLst_globalChatMessageList() {
|
||||||
return lst_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() {
|
public String getHostname() {
|
||||||
return hostname;
|
return hostname;
|
||||||
@@ -1482,6 +1641,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void initLst_toMeMessageList() {
|
private void initLst_toMeMessageList() {
|
||||||
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||||
|
|
||||||
@@ -1498,12 +1658,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
||||||
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
||||||
if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||||
(!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||||
|
//
|
||||||
|
// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||||
|
// chatMessage.setMessageText(msgText);
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
|
||||||
msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
if (isSniffedMessage(chatMessage)) {
|
||||||
chatMessage.setMessageText(msgText);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2388,4 +2552,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth);
|
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");
|
dummy.setCallSign("ALL");
|
||||||
newMessageArrived.setReceiver(dummy);
|
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 {
|
} else {
|
||||||
//message is directed to another chatmember, process as such!
|
//message is directed to another chatmember, process as such!
|
||||||
@@ -817,7 +819,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
if (newMessageArrived.getReceiver().getCallSign()
|
if (newMessageArrived.getReceiver().getCallSign()
|
||||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
.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()) {
|
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
|
||||||
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
||||||
@@ -960,14 +964,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
String originalMessage = newMessageArrived.getMessageText();
|
String originalMessage = newMessageArrived.getMessageText();
|
||||||
newMessageArrived
|
newMessageArrived
|
||||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
.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
|
// 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
|
// the "to me message list" with modified messagetext, added rxers callsign
|
||||||
@@ -1031,7 +1030,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
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());
|
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||||
}
|
}
|
||||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||||
@@ -1134,7 +1134,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
||||||
dxcMsg.setQrgSpotted(splittedMessageLine[5]);
|
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 ");
|
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
|
||||||
|
|
||||||
@@ -1173,7 +1174,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
||||||
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
||||||
|
|
||||||
this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
// this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||||
|
this.client.publishClusterMessage(dxcMsg2);
|
||||||
|
|
||||||
} else
|
} else
|
||||||
|
|
||||||
@@ -1203,8 +1205,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg3.setMessageInhibited("");
|
dxcMsg3.setMessageInhibited("");
|
||||||
dxcMsg3.setQrgSpotted("");
|
dxcMsg3.setQrgSpotted("");
|
||||||
|
|
||||||
this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
// this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||||
|
this.client.publishClusterMessage(dxcMsg3);
|
||||||
} else
|
} else
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1371,7 +1373,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dummy.setCallSign("ALL");
|
dummy.setCallSign("ALL");
|
||||||
newMessageArrived.setReceiver(dummy);
|
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 {
|
} else {
|
||||||
//message is directed to another chatmember, process as such!
|
//message is directed to another chatmember, process as such!
|
||||||
@@ -1415,7 +1418,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
if (newMessageArrived.getReceiver().getCallSign()
|
if (newMessageArrived.getReceiver().getCallSign()
|
||||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
.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() + ".");
|
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
|
||||||
|
|
||||||
@@ -1428,8 +1432,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
String originalMessage = newMessageArrived.getMessageText();
|
String originalMessage = newMessageArrived.getMessageText();
|
||||||
newMessageArrived
|
newMessageArrived
|
||||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
.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
|
// 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
|
// the "to me message list" with modified messagetext, added rxers callsign
|
||||||
|
|
||||||
@@ -1448,7 +1453,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
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());
|
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||||
}
|
}
|
||||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||||
@@ -1521,7 +1527,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++) {
|
for (int i = 0; i < 10; i++) {
|
||||||
client.addChatMessage(pwErrorMsg);
|
client.getLst_globalChatMessageList().add(pwErrorMsg);
|
||||||
// client.getLst_toMeMessageList().add(pwErrorMsg);
|
// client.getLst_toMeMessageList().add(pwErrorMsg);
|
||||||
// client.getLst_toAllMessageList().add(pwErrorMsg);
|
// client.getLst_toAllMessageList().add(pwErrorMsg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ public class ChatPreferences {
|
|||||||
double stn_maxQRBDefault = 900;
|
double stn_maxQRBDefault = 900;
|
||||||
double stn_qtfDefault = 135;
|
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 loginChatCategoryMain = new ChatCategory(2);
|
||||||
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
||||||
boolean loginToSecondChatEnabled;
|
boolean loginToSecondChatEnabled;
|
||||||
@@ -309,6 +326,9 @@ public class ChatPreferences {
|
|||||||
boolean guiOptions_defaultFilterPmToOther;
|
boolean guiOptions_defaultFilterPmToOther;
|
||||||
boolean guiOptions_defaultFilterPublicMsgs;
|
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);
|
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() {
|
public String getStn_loginNameSecondCat() {
|
||||||
return stn_loginNameSecondCat;
|
return stn_loginNameSecondCat;
|
||||||
}
|
}
|
||||||
@@ -547,6 +603,22 @@ public class ChatPreferences {
|
|||||||
this.loginToSecondChatEnabled = loginToSecondChatEnabled;
|
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() {
|
public boolean isGuiOptions_defaultFilterNothing() {
|
||||||
return guiOptions_defaultFilterNothing;
|
return guiOptions_defaultFilterNothing;
|
||||||
}
|
}
|
||||||
@@ -1245,6 +1317,63 @@ public class ChatPreferences {
|
|||||||
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
||||||
station.appendChild(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");
|
Element stn_bandActive144 = doc.createElement("stn_bandActive144");
|
||||||
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
||||||
station.appendChild(stn_bandActive144);
|
station.appendChild(stn_bandActive144);
|
||||||
@@ -1740,6 +1869,18 @@ public class ChatPreferences {
|
|||||||
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
||||||
guiOptions.appendChild(GUIpnl_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! *************************************
|
****************************** now write this XML! *************************************
|
||||||
****************************************************************************************/
|
****************************************************************************************/
|
||||||
@@ -1856,6 +1997,90 @@ public class ChatPreferences {
|
|||||||
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
||||||
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault");
|
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)
|
// Band activity flags (introduced later; if missing -> keep defaults)
|
||||||
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
||||||
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
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, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
||||||
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
||||||
|
|
||||||
|
parseSemicolonDoublesInto(
|
||||||
|
getText(element, null, "GUIstationMapStageSceneSizeHW"),
|
||||||
|
this.getGUIstationMapStageSceneSizeHW()
|
||||||
|
);
|
||||||
|
|
||||||
|
parseSemicolonDoublesInto(
|
||||||
|
getText(element, null, "GUIstationMapStagePositionXY"),
|
||||||
|
this.getGUIstationMapStagePositionXY()
|
||||||
|
);
|
||||||
|
|
||||||
// Splitpane divider positions
|
// Splitpane divider positions
|
||||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||||
if (s1 != null) {
|
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.Line;
|
||||||
import javafx.scene.shape.Polygon;
|
import javafx.scene.shape.Polygon;
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
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 {
|
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
|
||||||
// private static final Kst4ContestApplication dbcontroller = new DBController();
|
// 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 Button btnBandUpgradeIndicator = new Button("BAND+");
|
||||||
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
||||||
@@ -116,6 +121,73 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8];
|
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
|
* 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()));
|
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());
|
Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw());
|
||||||
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
||||||
@Override
|
@Override
|
||||||
public void handle(ActionEvent actionEvent) {
|
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());
|
chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection());
|
||||||
|
|
||||||
|
|
||||||
//TODO: Hier muss was hin
|
//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(selectedCallSignTurnAntBtn, 1,1,1,1);
|
||||||
|
|
||||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,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
|
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");
|
TableColumn<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||||
msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
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) {
|
public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> cellDataFeatures) {
|
||||||
SimpleStringProperty msg = new SimpleStringProperty();
|
SimpleStringProperty msg = new SimpleStringProperty();
|
||||||
|
|
||||||
if (cellDataFeatures.getValue().getMessageText() != null) {
|
if (cellDataFeatures.getValue() != null) {
|
||||||
|
msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue()));
|
||||||
msg.setValue(cellDataFeatures.getValue().getMessageText());
|
|
||||||
} else {
|
} else {
|
||||||
|
msg.setValue("");
|
||||||
msg.setValue("");// TODO: Prevents a bug of not setting all values as a default
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3777,7 +3868,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
|
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
|
||||||
|
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(true);
|
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);
|
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(false);
|
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");
|
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());
|
System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText());
|
||||||
chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(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() + "");
|
TextField txtFldstn_maxQRBDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||||
txtFldstn_maxQRBDefault.setFocusTraversable(false);
|
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());
|
System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText());
|
||||||
chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(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):");
|
Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):");
|
||||||
CheckBox chkBx_station_pstRotatorEnabled = new CheckBox();
|
CheckBox chkBx_station_pstRotatorEnabled = new CheckBox();
|
||||||
chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled());
|
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(choiceBxChatChategory, 1, 4);
|
||||||
grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5);
|
grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5);
|
||||||
grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 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("Own antenna height AGL:"), 0, 8);
|
||||||
grdPnlStation.add(new Label("Default filter QTF:"), 0, 7);
|
grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8);
|
||||||
grdPnlStation.add(txtFldstn_qtfDefault, 1, 7);
|
|
||||||
grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8);
|
grdPnlStation.add(new Label("DEM root directory:"), 0, 9);
|
||||||
grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8);
|
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();
|
VBox vbxStation = new VBox();
|
||||||
vbxStation.setPadding(new Insets(10, 10, 10, 10));
|
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_QRV5600);
|
||||||
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*************************************************************************************
|
/*************************************************************************************
|
||||||
* Log synch settings Tab
|
* Log synch settings Tab
|
||||||
*************************************************************************************/
|
*************************************************************************************/
|
||||||
@@ -8786,6 +9094,49 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
return null;
|
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);
|
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 jdk.xml.dom;
|
||||||
requires java.sql;
|
requires java.sql;
|
||||||
requires javafx.media;
|
requires javafx.media;
|
||||||
requires java.logging;
|
requires jdk.jsobject;
|
||||||
|
requires java.net.http;
|
||||||
|
requires java.desktop;
|
||||||
exports kst4contest.controller.interfaces;
|
exports kst4contest.controller.interfaces;
|
||||||
exports kst4contest.controller;
|
exports kst4contest.controller;
|
||||||
exports kst4contest.locatorUtils;
|
exports kst4contest.locatorUtils;
|
||||||
exports kst4contest.model;
|
exports kst4contest.model;
|
||||||
exports kst4contest.view;
|
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