7 Commits

Author SHA1 Message Date
Marc Froehlich 6d3351175f Add interactive aircraft scatter hero effect 2026-07-12 23:19:27 +02:00
Marc Froehlich 674c5a62e3 Extending work at #51 for not adding users on UM3 message 2026-07-12 22:43:19 +02:00
Marc Froehlich 750d0a3be1 bugfix for #51. ManagebusManagementThread now never accesses or changes the JAVAFX view-backing ObservableList but using a concurrenthasmap which drives the Tableview backing list with snapshots 2026-07-12 01:24:07 +02:00
Marc Froehlich dc703fca9e Changes at the website 2026-07-11 23:21:02 +02:00
Marc Froehlich 77c9d34d64 Introduced clustering in the map 2026-07-10 00:30:15 +02:00
github-actions[bot] efd6ed6299 chore: rebuild website roadmap [skip ci] 2026-07-09 21:24:39 +00:00
github-actions[bot] de8e8ba471 chore: rebuild website roadmap [skip ci] 2026-07-09 21:24:26 +00:00
60 changed files with 2947 additions and 982 deletions
@@ -24,6 +24,7 @@ import kst4contest.logic.PriorityCalculator;
import kst4contest.model.*;
import kst4contest.test.MockKstServer;
import kst4contest.utils.PlayAudioUtils;
import kst4contest.locatorUtils.Location;
import kst4contest.view.Kst4ContestApplication;
import java.io.*;
@@ -34,6 +35,7 @@ import java.util.function.Predicate;
/**
*
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
@@ -318,10 +320,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
EnumSet<Band> offered = EnumSet.noneOf(Band.class);
synchronized (getLst_chatMemberList()) {
for (ChatMember cm : getLst_chatMemberList()) {
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
if (cm == null || cm.getCallSignRaw() == null) continue;
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
Map<Band, ChatMember.ActiveFrequencyInfo> map = cm.getKnownActiveBands();
if (map == null || map.isEmpty()) continue;
@@ -340,11 +340,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
+ " freq=" + e.getValue().frequency
+ " ageMs=" + age);
}
}
}
}
}
}
return offered;
}
@@ -356,10 +354,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
EnumSet<Band> worked = EnumSet.noneOf(Band.class);
synchronized (getLst_chatMemberList()) {
for (ChatMember cm : getLst_chatMemberList()) {
for (ChatMember cm : findActiveChatMembersByRawCall(callRaw)) {
if (cm == null || cm.getCallSignRaw() == null) continue;
if (!normalizeCallRaw(cm.getCallSignRaw()).equals(callRaw)) continue;
if (cm.isWorked144()) worked.add(Band.B_144);
if (cm.isWorked432()) worked.add(Band.B_432);
@@ -368,9 +364,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (cm.isWorked3400()) worked.add(Band.B_3400);
if (cm.isWorked5600()) worked.add(Band.B_5760);
if (cm.isWorked10G()) worked.add(Band.B_10G);
if (cm.isWorked24G()) worked.add(Band.B_24G); // optional, only if your Band enum supports it
}
if (cm.isWorked24G()) worked.add(Band.B_24G);
}
return worked;
}
@@ -645,7 +641,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
// rotatorClient.
this.lst_chatMemberList.clear();;
// this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
this.setDisconnected(true);
@@ -713,7 +710,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
} else if (action.equals(ApplicationConstants.DISCSTRING_DISCONNECTONLY)){
this.lst_chatMemberList.clear();;
this.clearActiveChatMembers();
this.lst_clusterMemberList.clear();
@@ -959,16 +956,9 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
return null;
}
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
synchronized (getLst_chatMemberList()) {
for (ChatMember member : getLst_chatMemberList()) {
if (member == null || member.getCallSignRaw() == null) continue;
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
return member;
}
}
}
return null;
List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
}
private String resolveSkedTargetLocator(String targetCallsignRaw) {
@@ -976,22 +966,43 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return null;
}
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
synchronized (getLst_chatMemberList()) {
for (ChatMember member : getLst_chatMemberList()) {
if (member == null || member.getCallSignRaw() == null) continue;
if (!normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) continue;
for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
String locator = member.getQra();
if (locator != null && !locator.isBlank()) {
return locator.trim().toUpperCase(Locale.ROOT);
}
}
}
return null;
}
// private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
// return null;
// }
//
// List<ChatMember> matchingMembers = findActiveChatMembersByRawCall(targetCallsignRaw);
// return matchingMembers.isEmpty() ? null : matchingMembers.get(0);
//
// }
//
// private String resolveSkedTargetLocator(String targetCallsignRaw) {
// if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
// return null;
// }
//
// String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
//
// for (ChatMember member : findActiveChatMembersByRawCall(targetCallsignRaw)) {
// String locator = member.getQra();
// if (locator != null && !locator.isBlank()) {
// return locator.trim().toUpperCase(Locale.ROOT);
// }
// }
//
// return null;
// }
public StationMetricsService getStationMetricsService() {
return stationMetricsService;
}
@@ -1014,10 +1025,14 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return new HashMap<>(lastInboundCategoryByCallSignRaw);
}
// public List<ChatMember> snapshotChatMembers() {
// synchronized (getLst_chatMemberList()) {
// return new ArrayList<>(getLst_chatMemberList());
// }
// }
public List<ChatMember> snapshotChatMembers() {
synchronized (getLst_chatMemberList()) {
return new ArrayList<>(getLst_chatMemberList());
}
return new ArrayList<>(activeChatMembersByCallAndCategory.values());
}
public List<ContestSked> snapshotActiveSkeds() {
@@ -1126,13 +1141,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private ObservableList<ChatMember> chatMemberList = FXCollections.observableArrayList(); // List of active stations
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
// of active stn in chat
private FilteredList<ChatMember> lst_chatMemberListFiltered = new FilteredList<ChatMember>(chatMemberList);
private SortedList<ChatMember> lst_chatMemberSortedFilteredList = new SortedList<ChatMember>(lst_chatMemberListFiltered);
private ObservableList<ChatMember> lst_chatMemberList = FXCollections.synchronizedObservableList(chatMemberList); // List
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
/*
* Thread-safe active-member model.
*
* MessageBusManagementThread must not use the JavaFX TableView backing list as
* its primary data model. Otherwise every add/remove fires FilteredList,
* SortedList and TableView selection listeners on the MessageBus thread and JavaFX
* can throw "Not on FX application thread" or internal listener NPEs.
*
* This map is the worker-thread safe source of truth. The ObservableList below
* remains a UI mirror and is mutated only on the JavaFX application thread.
*/
private final java.util.concurrent.ConcurrentMap<String, ChatMember> activeChatMembersByCallAndCategory =
new java.util.concurrent.ConcurrentHashMap<>();
/*
* Message table update buffers.
@@ -1161,6 +1190,254 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
// ******************************************************************************************************************************************
/**
* Executes UI-bound work on the JavaFX application thread.
*
* Worker threads may call this safely. If the caller is already on the FX
* thread, the action is executed immediately to preserve ordering.
*/
private void runOnFxThread(Runnable action) {
if (action == null) {
return;
}
if (Platform.isFxApplicationThread()) {
action.run();
} else {
Platform.runLater(action);
}
}
/**
* Builds the active-member key. The raw/base callsign alone is not enough
* because the same station can be logged into multiple ON4KST categories at
* the same time. Therefore the category number is part of the key.
*/
private String buildActiveChatMemberKey(String callSign, ChatCategory category) {
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSign);
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
return null;
}
int categoryNumber = category == null ? -1 : category.getCategoryNumber();
return normalizedCallsign.trim().toUpperCase(Locale.ROOT) + "|" + categoryNumber;
}
private String buildActiveChatMemberKey(ChatMember member) {
if (member == null) {
return null;
}
String callSign = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
return buildActiveChatMemberKey(callSign, member.getChatCategory());
}
/**
* Adds or replaces an active chat member in the worker-thread model and mirrors
* that change to the JavaFX list. This is the only supported path for ON4KST
* user-enter events.
*/
public void addOrUpdateActiveChatMember(ChatMember member) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return;
}
activeChatMembersByCallAndCategory.put(key, member);
runOnFxThread(() -> {
int existingIndex = findChatMemberIndexInUiListByKey(key);
if (existingIndex >= 0) {
lst_chatMemberList.set(existingIndex, member);
} else {
lst_chatMemberList.add(member);
}
});
}
/**
* Removes an active chat member from the worker-thread model and from the UI
* mirror. Removal from the ObservableList is always performed on the FX thread
* because the list is bound to FilteredList/SortedList/TableView.
*/
public boolean removeActiveChatMember(ChatMember member) {
String key = buildActiveChatMemberKey(member);
if (key == null) {
return false;
}
ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key);
runOnFxThread(() -> lst_chatMemberList.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))));
return removedMember != null;
}
/**
* Clears the active-member model and the JavaFX UI mirror. Use this instead of
* getLst_chatMemberList().clear() from timers or worker threads.
*/
public void clearActiveChatMembers() {
activeChatMembersByCallAndCategory.clear();
runOnFxThread(() -> lst_chatMemberList.clear());
}
/**
* Resolves a member from the thread-safe active model. This avoids reading the
* TableView backing list from MessageBusManagementThread.
*/
public ChatMember findActiveChatMember(ChatMember lookForThis) {
String key = buildActiveChatMemberKey(lookForThis);
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
}
public ChatMember findActiveChatMember(String callSign, ChatCategory category) {
String key = buildActiveChatMemberKey(callSign, category);
return key == null ? null : activeChatMembersByCallAndCategory.get(key);
}
public int getActiveChatMemberCount() {
return activeChatMembersByCallAndCategory.size();
}
/**
* Returns all active category variants of a callsign. This is used when a QRG or
* band hint should be propagated from one category instance to the same station
* in another category.
*/
public List<ChatMember> findActiveChatMembersByRawCall(String callSignRaw) {
String normalizedCallsign = ChatMember.normalizeCallSignToBaseCallSign(callSignRaw);
if (normalizedCallsign == null || normalizedCallsign.isBlank()) {
return Collections.emptyList();
}
String normalizedKeyPart = normalizedCallsign.trim().toUpperCase(Locale.ROOT);
List<ChatMember> matchingMembers = new ArrayList<>();
for (ChatMember member : activeChatMembersByCallAndCategory.values()) {
if (member == null) {
continue;
}
String memberCall = member.getCallSignRaw() != null ? member.getCallSignRaw() : member.getCallSign();
String normalizedMemberCall = ChatMember.normalizeCallSignToBaseCallSign(memberCall);
if (normalizedMemberCall != null && normalizedMemberCall.equalsIgnoreCase(normalizedKeyPart)) {
matchingMembers.add(member);
}
}
return matchingMembers;
}
/**
* Updates locator and derived direction values in the active model. The UI list
* contains the same member object, but the user-list refresh is still triggered
* on the JavaFX thread so TableView/map derived displays can repaint safely.
*/
public boolean updateActiveChatMemberLocator(ChatMember lookupMember, String newLocator) {
ChatMember activeMember = findActiveChatMember(lookupMember);
if (activeMember == null) {
return false;
}
activeMember.setQra(newLocator);
activeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(chatPreferences.getStn_loginLocatorMainCat(), newLocator));
activeMember.setQTFdirection(new Location(chatPreferences.getStn_loginLocatorMainCat()).getBearing(new Location(newLocator)));
fireUserListUpdate("Locator changed");
return true;
}
public boolean updateActiveChatMemberState(ChatMember lookupMember, int newState) {
ChatMember activeMember = findActiveChatMember(lookupMember);
if (activeMember == null) {
return false;
}
activeMember.setState(newState);
fireUserListUpdate("User state changed");
return true;
}
/**
* Applies a UM3-style user-info update only to an already active chat member.
*
* ON4KST may send UM3 messages for stations that are not logged into the
* current channel, for example when a user changes locator/profile data on the
* website. Such stations must not be added to the visible chat member list,
* because they cannot be addressed in the channel. Their full information will
* be delivered again with UA0/UA5/UA2 when they actually join.
*
* @return true if an active member was found and updated, false if the UM3
* information was intentionally ignored.
*/
public boolean updateActiveChatMemberInfoIfPresent(ChatMember updatedMember) {
String key = buildActiveChatMemberKey(updatedMember);
if (key == null) {
return false;
}
ChatMember activeMember = activeChatMembersByCallAndCategory.get(key);
if (activeMember == null) {
return false;
}
activeMember.setName(updatedMember.getName());
activeMember.setQra(updatedMember.getQra());
activeMember.setState(updatedMember.getState());
activeMember.setLastActivity(updatedMember.getLastActivity());
activeMember.setActivityTimeLastInEpoch(updatedMember.getActivityTimeLastInEpoch());
activeMember.setQrb(updatedMember.getQrb());
activeMember.setQTFdirection(updatedMember.getQTFdirection());
fireUserListUpdate("User info updated");
return true;
}
/**
* Backward-compatible wrapper for older call sites. Despite the historic name,
* this method no longer adds unknown UM3 users to the active member model.
*/
public boolean updateOrAddActiveChatMemberInfo(ChatMember updatedMember) {
return updateActiveChatMemberInfoIfPresent(updatedMember);
}
/**
* Stores a newly detected QRG/band on all active category variants of a station.
* The old StringProperty is still set for compatibility with existing TableView
* columns and DXCluster code.
*/
public void applyDetectedFrequencyToActiveMembers(ChatMember sender, Band detectedBand, double detectedFrequencyMhz) {
if (sender == null || detectedBand == null || detectedFrequencyMhz <= 0) {
return;
}
String displayFrequency = String.valueOf(detectedFrequencyMhz);
String rawCall = sender.getCallSignRaw() != null ? sender.getCallSignRaw() : sender.getCallSign();
for (ChatMember member : findActiveChatMembersByRawCall(rawCall)) {
if (member == null) {
continue;
}
member.addKnownFrequency(detectedBand, detectedFrequencyMhz);
member.setFrequency(new SimpleStringProperty(displayFrequency));
}
// Also cover dummy senders that are not in the active model, e.g. [n/a] senders.
sender.addKnownFrequency(detectedBand, detectedFrequencyMhz);
sender.setFrequency(new SimpleStringProperty(displayFrequency));
fireUserListUpdate("Frequency detected");
}
private int findChatMemberIndexInUiListByKey(String key) {
if (key == null) {
return -1;
}
for (int i = 0; i < lst_chatMemberList.size(); i++) {
if (key.equals(buildActiveChatMemberKey(lst_chatMemberList.get(i)))) {
return i;
}
}
return -1;
}
@@ -2011,7 +2288,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
socket.close();
chatController.setConnectedAndLoggedIn(false);
chatController.getLst_chatMemberList().clear();
chatController.clearActiveChatMembers();
System.out.println("[Chatcontroller, Warning: ] Socket closed or disconnected");
@@ -324,31 +324,12 @@ public class MessageBusManagementThread extends Thread {
// --- STEP 3: Process Result ---
if (finalDetectedBand != null && finalDetectedFrequency > 0) {
// 1. Store in the new Map (for future context/history)
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
// No automatic full terrain analysis here.
// The frequency is stored for later map/manual reachability requests.
//propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
try {
ArrayList<Integer> sameCallIdx = client.checkListForChatMemberIndexesByCallSign(sender);
for (int idx : sameCallIdx) {
ChatMember cm = client.getLst_chatMemberList().get(idx);
if (cm != null && cm != sender) {
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
// No automatic full terrain analysis here.
// Avoids exhausting the online terrain API when many stations mention QRGs.
}
}
} catch (Exception e) {
System.out.println("[SmartParser, warning]: failed to propagate known frequency across duplicates: " + e.getMessage());
}
// 2. Set the old String-Property for GUI compatibility
// We assume standard display format (MHz)
sender.setFrequency(new javafx.beans.property.SimpleStringProperty(String.valueOf(finalDetectedFrequency)));
/*
* Store the detected QRG in the thread-safe active-member model.
* The UI table is only a JavaFX mirror, so the MessageBus must not scan
* or mutate getLst_chatMemberList() here.
*/
client.applyDetectedFrequencyToActiveMembers(sender, finalDetectedBand, finalDetectedFrequency);
System.out.println("[SmartParser] Detected for " + sender.getCallSign() + ": " +
finalDetectedFrequency + " MHz (" + finalDetectedBand + ") " +
@@ -491,6 +472,92 @@ public class MessageBusManagementThread extends Thread {
return -1; // if it´s not found, the method will always end here and return -1
}
/**
* Creates a minimal receiver object for ON4KST broadcast messages.
* Keeping this as an object, not null, protects all downstream chat filters
* and debug logging from null receiver paths.
*/
private ChatMember createAllReceiver() {
ChatMember dummy = new ChatMember();
dummy.setCallSign("ALL");
dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return dummy;
}
/**
* Resolves a message sender from the thread-safe active-member model. If a
* CH/CR message arrives before the matching user-enter message, a marked
* fallback sender is used. The returned sender is never null.
*/
private ChatMember resolveInboundSender(String senderCallSign, ChatCategory category, ChatMessage message) {
ChatMember lookup = new ChatMember();
lookup.setCallSign(senderCallSign);
lookup.setChatCategory(category);
ChatMember senderObj = this.client.findActiveChatMember(lookup);
if (senderObj != null) {
senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime());
// Remember the last active category so later outgoing replies can be routed correctly.
this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory());
// Metrics influence priority scoring; process them after message text is known.
this.client.getStationMetricsService().onInboundMessage(
senderObj.getCallSignRaw(),
System.currentTimeMillis(),
message == null ? null : message.getMessageText(),
this.client.getChatPreferences(),
this.client.getChatPreferences().getStn_loginCallSign()
);
this.client.getScoreService().requestRecompute("rx-chat-message");
return senderObj;
}
ChatMember fallbackSender = new ChatMember();
String myCall = this.client.getChatPreferences().getStn_loginCallSign();
if (senderCallSign != null && senderCallSign.equalsIgnoreCase(myCall)) {
fallbackSender.setCallSign(myCall);
} else {
fallbackSender.setCallSign("[n/a]" + senderCallSign);
}
fallbackSender.setChatCategory(category);
fallbackSender.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return fallbackSender;
}
/**
* Resolves a message receiver from the active-member model. Unknown receivers
* are represented as explicit fallback objects instead of null. This keeps PM
* echo display and historic messages stable even if the target station already
* left the chat.
*/
private ChatMember resolveInboundReceiver(String receiverCallSign, ChatCategory category) {
if (receiverCallSign == null || receiverCallSign.equals("0")) {
return createAllReceiver();
}
ChatMember lookup = new ChatMember();
lookup.setCallSign(receiverCallSign);
lookup.setChatCategory(category);
ChatMember receiverObj = this.client.findActiveChatMember(lookup);
if (receiverObj != null) {
return receiverObj;
}
ChatMember fallbackReceiver = new ChatMember();
String myCall = this.client.getChatPreferences().getStn_loginCallSign();
if (receiverCallSign.equalsIgnoreCase(myCall)) {
fallbackReceiver.setCallSign(myCall);
} else {
fallbackReceiver.setCallSign(receiverCallSign + "(left)");
}
fallbackReceiver.setChatCategory(category);
fallbackReceiver.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
return fallbackReceiver;
}
/**
* Processes received messages via port 23001 (improved telnet Interface)
*
@@ -590,7 +657,7 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) {
this.client.getLst_chatMemberList().add(newMember); //the own call will not be in the list
this.client.addOrUpdateActiveChatMember(newMember); // the own call will not be in the list
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
// Reachability is calculated on demand only: map click, selected station, or manual request.
}
@@ -637,7 +704,7 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember);
this.client.addOrUpdateActiveChatMember(newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
@@ -665,17 +732,11 @@ public class MessageBusManagementThread extends Thread {
newMember.setCallSign(splittedMessageLine[2]);
System.out.println("[MSGBUSMGT, Info:] User left Chat and will be removed from list ["
+ this.client.getLst_chatMemberList().size() + "] :" + newMember.getCallSign());
try {
this.client.getLst_chatMemberList().remove(
checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), newMember));
+ this.client.getActiveChatMemberCount() + "] :" + newMember.getCallSign());
//since 1.26 new method design to detect chatcategory, too!
} catch (Exception e) {
System.out.println("[MSGBUSMGT, EXC!, Error:] User sent left chat but had not been there ... ["
+ this.client.getLst_chatMemberList().size() + "] :" + newMember.getCallSign() + "\n"
+ e.getStackTrace());
if (!this.client.removeActiveChatMember(newMember)) {
System.out.println("[MSGBUSMGT, Info:] User sent left chat but was not active: "
+ newMember.getCallSign() + " / " + newMember.getChatCategory());
}
} else
@@ -695,9 +756,12 @@ public class MessageBusManagementThread extends Thread {
chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory();
newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]);
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[3].equals("SERVER")) {
ChatMember dummy = new ChatMember();
dummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
dummy.setCallSign("SERVER");
dummy.setName("Sysop");
newMessageArrived.setSender(dummy);
@@ -707,77 +771,16 @@ public class MessageBusManagementThread extends Thread {
} else {
ChatMember sender = new ChatMember();
sender.setCallSign(splittedMessageLine[3]);
sender.setChatCategory(chategoryForMessageAndMessageSender);
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), sender);
if (index != -1) {
//user not found in the chatmember list
try {
// newMessageArrived.setSender(this.client.getLst_chatMemberList().get(index)); // set sender to member of
// this.client.getLst_chatMemberList().get(index).setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime());
ChatMember senderObj = this.client.getLst_chatMemberList().get(index);
newMessageArrived.setSender(senderObj);
senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime());
// Remember last inbound category per callsignRaw (required for correct send-routing later)
this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory());
// Metrics for scoring: momentum, response-time, no-reply, positive signals
this.client.getStationMetricsService().onInboundMessage(
senderObj.getCallSignRaw(),
System.currentTimeMillis(),
newMessageArrived.getMessageText(),
this.client.getChatPreferences(),
this.client.getChatPreferences().getStn_loginCallSign()
);
// Activity/category changes influence priority => request recompute
this.client.getScoreService().requestRecompute("rx-chat-message");
} catch (Exception exc) {
ChatMember aSenderDummy = new ChatMember();
aSenderDummy.setCallSign(splittedMessageLine[3] + "[n/a]");
aSenderDummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
newMessageArrived.setSender(aSenderDummy);
System.out.println("MsgBusmgtT: Catched Error! " + exc.getMessage() + " // " + splittedMessageLine[3] + " is not in the list! Faking sender!");
exc.printStackTrace();
}
// b4 init list
} else {
//user not found in chatmember list, mark it, sender can not be set
if (!sender.getCallSign().equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
sender.setCallSign("[n/a]" + sender.getCallSign());
// if someone sent a message without being in the userlist (cause
// on4kst missed implementing....), callsign will be marked
} else {
//that means, message was by own station, broadcasted to all other
ChatMember dummy = new ChatMember();
dummy.setCallSign("ALL");
newMessageArrived.setReceiver(dummy);
AirPlaneReflectionInfo preventNullpointerExc = new AirPlaneReflectionInfo();
preventNullpointerExc.setAirPlanesReachableCntr(0);
sender.setAirPlaneReflectInfo(preventNullpointerExc);
newMessageArrived.setSender(sender); //my own call is the sender
}
newMessageArrived.setSender(resolveInboundSender(
splittedMessageLine[3],
chategoryForMessageAndMessageSender,
newMessageArrived));
}
// newMessageArrived.setSender(this.client.getChatMemberTable().get(splittedMessageLine[3]));
}
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[7].equals("0")) {
// message is not directed to anyone, move it to the cq messages!
ChatMember dummy = new ChatMember();
dummy.setCallSign("ALL");
newMessageArrived.setReceiver(dummy);
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
@@ -786,36 +789,9 @@ public class MessageBusManagementThread extends Thread {
} else {
//message is directed to another chatmember, process as such!
ChatMember receiver = new ChatMember();
receiver.setChatCategory(chategoryForMessageAndMessageSender); //got out of message itself
receiver.setCallSign(splittedMessageLine[7]);
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), receiver);
if (index != -1) {
newMessageArrived.setReceiver(this.client.getLst_chatMemberList().get(index));// -1: Member left Chat
// before...
} else { //found in active member list
if (receiver.getCallSign().equals(client.getChatPreferences().getStn_loginCallSign())) {
/**
* If mycallsign sent a message to the server, server will publish that message and
* send it to all chatmember including me.
* As mycall is not in the userlist, the message would not been displayed if I handle
* it in the next case (marking left user, just for information). But I want an echo.
*/
receiver.setCallSign(client.getChatPreferences().getStn_loginCallSign());
newMessageArrived.setReceiver(receiver);
} else {
//this are user which left chat but had been adressed by this message
receiver.setCallSign(receiver.getCallSign() + "(left)");
newMessageArrived.setReceiver(receiver);
}
}
newMessageArrived.setReceiver(resolveInboundReceiver(
splittedMessageLine[7],
chategoryForMessageAndMessageSender));
// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase());
@@ -979,7 +955,21 @@ public class MessageBusManagementThread extends Thread {
// the "to me message list" with modified messagetext, added rxers callsign
} else {
//message sent to other user
/*
* Message sent from one other station to another other station.
*
* The old code reached this point only with members from the visible user list.
* With explicit fallback sender/receiver objects, this path may also see
* stations that already left the chat or arrived before their user-list entry.
* In that case locators may be missing, so angle/range analysis and DX spotting
* must be skipped while the chat message itself is still published.
*/
boolean senderHasLocator = newMessageArrived.getSender().getQra() != null
&& !newMessageArrived.getSender().getQra().isBlank();
boolean receiverHasLocator = newMessageArrived.getReceiver().getQra() != null
&& !newMessageArrived.getReceiver().getQra().isBlank();
if (senderHasLocator && receiverHasLocator) {
if (DirectionUtils.isInAngleAndRange(client.getChatPreferences().getStn_loginLocatorMainCat(),
newMessageArrived.getSender().getQra(),
newMessageArrived.getReceiver().getQra(),
@@ -998,7 +988,7 @@ public class MessageBusManagementThread extends Thread {
if (client.getChatPreferences().isNotify_dxClusterServerEnabled()) {
try {
if (newMessageArrived.getSender().getFrequency() != null) {
//TODO: testing for next version 3.33: addinitional information will be displayed in cluster if there is such an information
//TODO: testing for next version 3.33: additional information will be displayed in cluster if there is such information
ChatMember onlyForSpottingObject = new ChatMember();
onlyForSpottingObject.setCallSign(newMessageArrived.getSender().getCallSign());
onlyForSpottingObject.setFrequency(newMessageArrived.getSender().getFrequency());
@@ -1014,11 +1004,10 @@ public class MessageBusManagementThread extends Thread {
newMessageArrived.getSender().getAirPlaneReflectInfo().getRisingAirplanes().get(1).getPotential() + "%");
}
} else {
onlyForSpottingObject.setQra(newMessageArrived.getSender().getQra());
}
this.client.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(onlyForSpottingObject); //tells the DXCluster server to send a DXC message for this member to the logbook software
this.client.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(onlyForSpottingObject);
}
} catch (Exception exception) {
System.out.println("[MSGBUSMGT, ERROR:] DXCluster messageserver error while processing spot for 0: " + newMessageArrived.getSender().getCallSign() + " // " + exception.getMessage());
@@ -1036,14 +1025,21 @@ public class MessageBusManagementThread extends Thread {
" ; sender bearing to me: " + new Location(newMessageArrived.getSender().getQra()).getBearing(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())));
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
System.out.println("[MSGBUSMGT, Info:] Skipping angle/range analysis for message with missing locator: "
+ newMessageArrived.getSender().getCallSign() + " -> "
+ newMessageArrived.getReceiver().getCallSign());
}
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: " + referenceDeletedByUserLeftChatDuringMessageprocessing.getStackTrace());
// referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
}
// sdtout to me message-List
@@ -1062,7 +1058,8 @@ public class MessageBusManagementThread extends Thread {
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage() + "\n" + exceptionOccured.getStackTrace());
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
exceptionOccured.printStackTrace();
}
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
@@ -1085,24 +1082,21 @@ public class MessageBusManagementThread extends Thread {
temp4.setQra(splittedMessageLine[3]);
temp4.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), temp4);
if (index != -1) {
ChatMember foundThisInChatMemberList = this.client.findActiveChatMember(temp4);
if (foundThisInChatMemberList == null && this.client.getChatCategorySecondChat() != null) {
/*
* LOC messages do not carry the category. The old implementation checked
* only the main category. Try the second active category as a fallback so
* dual-channel operation can still update locator changes.
*/
temp4.setChatCategory(this.client.getChatCategorySecondChat());
foundThisInChatMemberList = this.client.findActiveChatMember(temp4);
}
if (foundThisInChatMemberList != null) {
System.out.println("[MSGBUSMGT:] Locator Change of [" + (splittedMessageLine[2] + "], old was: "
+ this.client.getLst_chatMemberList().get(index).getQra() + " new is: "
+ splittedMessageLine[3]));
ChatMember foundThisInChatMemberList = this.client.getLst_chatMemberList().get(index); //make less list accesses
// this.client.getLst_chatMemberList().get(index).setQra(splittedMessageLine[3]);
// this.client.getLst_chatMemberList().get(index).setQrb(new Location().getDistanceKmByTwoLocatorStrings(client.getChatPreferences().getLoginLocator(), splittedMessageLine[3]));
// this.client.getLst_chatMemberList().get(index).setQTFdirection(new Location(client.getChatPreferences().getLoginLocator()).getBearing(new Location(splittedMessageLine[3])));
foundThisInChatMemberList.setQra(splittedMessageLine[3]);
foundThisInChatMemberList.setQrb(new Location().getDistanceKmByTwoLocatorStrings(client.getChatPreferences().getStn_loginLocatorMainCat(), splittedMessageLine[3]));
foundThisInChatMemberList.setQTFdirection(new Location(client.getChatPreferences().getStn_loginLocatorMainCat()).getBearing(new Location(splittedMessageLine[3])));
+ foundThisInChatMemberList.getQra() + " new is: " + splittedMessageLine[3]));
this.client.updateActiveChatMemberLocator(temp4, splittedMessageLine[3]);
} else {
System.out.println("[MSGBUSMGT:] ERROR! Locator Change of ["
+ (splittedMessageLine[2] + "] is not possible, user is not in the Table!"));
@@ -1232,11 +1226,9 @@ public class MessageBusManagementThread extends Thread {
stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(),
stateChangeMember);
if (index != -1 && index != 0) {
this.client.getLst_chatMemberList().get(index).setState(stateChangeMember.getState());
if (!this.client.updateActiveChatMemberState(stateChangeMember, stateChangeMember.getState())) {
System.out.println("[MSGBUSMGT, Info:] State change for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
// this.client.getChatMemberTable().get(stateChangeMember.getCallSign())
@@ -1249,31 +1241,43 @@ public class MessageBusManagementThread extends Thread {
*/
if (splittedMessageLine[0].contains(USERINFOUPDATEORUSERISBACK)) {
if (splittedMessageLine.length < 6) {
System.out.println("[MSGBUSMGT, warning:] Malformed UM3 message ignored: "
+ messageToProcess.getMessageText());
} else {
ChatMember stateChangeMember = new ChatMember();
stateChangeMember.setChatCategory(util_getChatCategoryByCategoryNrString(splittedMessageLine[1]));
stateChangeMember.setCallSign(splittedMessageLine[2]);
stateChangeMember.setName(splittedMessageLine[3]);
stateChangeMember.setQra(splittedMessageLine[4]);
stateChangeMember.setState(Integer.parseInt(splittedMessageLine[5]));
stateChangeMember.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
stateChangeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(client.getChatPreferences().getStn_loginLocatorMainCat(), stateChangeMember.getQra()));
stateChangeMember.setQTFdirection(new Location(client.getChatPreferences().getStn_loginLocatorMainCat()).getBearing(new Location(stateChangeMember.getQra())));
stateChangeMember.setQrb(new Location().getDistanceKmByTwoLocatorStrings(
client.getChatPreferences().getStn_loginLocatorMainCat(),
stateChangeMember.getQra()));
stateChangeMember.setQTFdirection(new Location(client.getChatPreferences().getStn_loginLocatorMainCat())
.getBearing(new Location(stateChangeMember.getQra())));
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an
// upodate
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(),
stateChangeMember);
//-1 could be the case if mycall is processed
if (index != -1) {
this.client.getLst_chatMemberList().get(index).setName(stateChangeMember.getName());
this.client.getLst_chatMemberList().get(index).setQra(stateChangeMember.getQra());
this.client.getLst_chatMemberList().get(index).setState(stateChangeMember.getState());
/*
* UM3 is only an info/profile update. ON4KST also sends it for stations
* that are not logged into this channel. Such stations must not become
* visible chat members, otherwise the user could try to address an
* offline callsign and receive server-side errors.
*
* Therefore UM3 updates existing active members only. Unknown UM3 calls
* are intentionally ignored; if the station joins later, UA0/UA5/UA2
* will deliver the complete current user information again.
*/
if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(stateChangeMember.getCallSign())) {
boolean updatedActiveMember = this.client.updateActiveChatMemberInfoIfPresent(stateChangeMember);
if (updatedActiveMember) {
this.client.getDbHandler().storeChatMember(stateChangeMember); // TODO: not clean, it should be an update
} else {
System.out.println("[MSGBUSMGT, Info:] UM3 ignored for inactive user: "
+ stateChangeMember.getCallSign() + " / " + stateChangeMember.getChatCategory());
}
}
}
} else
@@ -1297,6 +1301,8 @@ public class MessageBusManagementThread extends Thread {
chategoryForMessageAndMessageSender = newMessageArrived.getChatCategory();
newMessageArrived.setMessageGeneratedTime(splittedMessageLine[2]);
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[3].equals("SERVER")) {
ChatMember dummy = new ChatMember();
@@ -1309,76 +1315,16 @@ public class MessageBusManagementThread extends Thread {
} else {
ChatMember sender = new ChatMember();
sender.setCallSign(splittedMessageLine[3]);
sender.setChatCategory(chategoryForMessageAndMessageSender);
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), sender);
if (index != -1) {
//user not found in the chatmember list
try {
// newMessageArrived.setSender(this.client.getLst_chatMemberList().get(index)); // set sender to member of
// this.client.getLst_chatMemberList().get(index).setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime());
ChatMember senderObj = this.client.getLst_chatMemberList().get(index);
newMessageArrived.setSender(senderObj);
senderObj.setActivityTimeLastInEpoch(new Utils4KST().time_generateCurrentEpochTime());
// Remember last inbound category per callsignRaw (required for correct send-routing later)
this.client.rememberLastInboundCategory(senderObj.getCallSignRaw(), senderObj.getChatCategory());
// Metrics for scoring: momentum, response-time, no-reply, positive signals
this.client.getStationMetricsService().onInboundMessage(
senderObj.getCallSignRaw(),
System.currentTimeMillis(),
newMessageArrived.getMessageText(),
this.client.getChatPreferences(),
this.client.getChatPreferences().getStn_loginCallSign()
);
// Activity/category changes influence priority => request recompute
this.client.getScoreService().requestRecompute("rx-chat-message");
} catch (Exception exc) {
ChatMember aSenderDummy = new ChatMember();
aSenderDummy.setCallSign(splittedMessageLine[3] + "[n/a]");
aSenderDummy.setAirPlaneReflectInfo(new AirPlaneReflectionInfo());
newMessageArrived.setSender(aSenderDummy);
System.out.println("MsgBusmgtT: Catched Error! " + exc.getMessage() + " // " + splittedMessageLine[3] + " is not in the list! Faking sender!");
exc.printStackTrace();
}
// b4 init list
} else {
//user not found in chatmember list, mark it, sender can not be set
if (!sender.getCallSign().equals(this.client.getChatPreferences().getStn_loginCallSign().toUpperCase())) {
sender.setCallSign("[n/a]" + sender.getCallSign());
// if someone sent a message without being in the userlist (cause
// on4kst missed implementing....), callsign will be marked
} else {
//that means, message was by own station, broadcasted to all other
ChatMember dummy = new ChatMember();
dummy.setCallSign("ALL");
newMessageArrived.setReceiver(dummy);
AirPlaneReflectionInfo preventNullpointerExc = new AirPlaneReflectionInfo();
preventNullpointerExc.setAirPlanesReachableCntr(0);
sender.setAirPlaneReflectInfo(preventNullpointerExc);
newMessageArrived.setSender(sender); //my own call is the sender
}
newMessageArrived.setSender(resolveInboundSender(
splittedMessageLine[3],
chategoryForMessageAndMessageSender,
newMessageArrived));
}
// newMessageArrived.setSender(this.client.getChatMemberTable().get(splittedMessageLine[3]));
}
newMessageArrived.setMessageSenderName(splittedMessageLine[4]);
newMessageArrived.setMessageText(splittedMessageLine[6]);
if (splittedMessageLine[7].equals("0")) {
// message is not directed to anyone, move it to the cq messages!
ChatMember dummy = new ChatMember();
dummy.setCallSign("ALL");
newMessageArrived.setReceiver(dummy);
// message is not directed to anyone, move it to the cq messages.
newMessageArrived.setReceiver(createAllReceiver());
// 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)
@@ -1386,35 +1332,9 @@ public class MessageBusManagementThread extends Thread {
} else {
//message is directed to another chatmember, process as such!
ChatMember receiver = new ChatMember();
receiver.setChatCategory(chategoryForMessageAndMessageSender); //got out of message itself
receiver.setCallSign(splittedMessageLine[7]);
int index = checkListForChatMemberIndexByCallSign(this.client.getLst_chatMemberList(), receiver);
if (index != -1) {
newMessageArrived.setReceiver(this.client.getLst_chatMemberList().get(index));// -1: Member left Chat
// before...
} else { //found in active member list
if (receiver.getCallSign().equals(client.getChatPreferences().getStn_loginCallSign())) {
/**
* If mycallsign sent a message to the server, server will publish that message and
* send it to all chatmember including me.
* As mycall is not in the userlist, the message would not been displayed if I handle
* it in the next case (marking left user, just for information). But I want an echo.
*/
receiver.setCallSign(client.getChatPreferences().getStn_loginCallSign());
newMessageArrived.setReceiver(receiver);
} else {
//this are user which left chat but had been adressed by this message
receiver.setCallSign(receiver.getCallSign() + "(left)");
newMessageArrived.setReceiver(receiver);
}
}
newMessageArrived.setReceiver(resolveInboundReceiver(
splittedMessageLine[7],
chategoryForMessageAndMessageSender));
// System.out.println("message directed to: " + newMessageArrived.getReceiver().getCallSign() + ". EQ?: " + this.client.getownChatMemberObject().getCallSign() + " sent by: " + newMessageArrived.getSender().getCallSign().toUpperCase() + " -> EQ?: "+ this.client.getChatPreferences().getLoginCallSign().toUpperCase());
@@ -1447,7 +1367,16 @@ public class MessageBusManagementThread extends Thread {
} else {
//message sent to other user
if (DirectionUtils.isInAngleAndRange(client.getChatPreferences().getStn_loginLocatorMainCat(),
// message sent from one other station to another other station
boolean senderHasLocator = newMessageArrived.getSender().getQra() != null
&& !newMessageArrived.getSender().getQra().isBlank();
boolean receiverHasLocator = newMessageArrived.getReceiver().getQra() != null
&& !newMessageArrived.getReceiver().getQra().isBlank();
if (senderHasLocator && receiverHasLocator) {
if (DirectionUtils.isInAngleAndRange(
client.getChatPreferences().getStn_loginLocatorMainCat(),
newMessageArrived.getSender().getQra(),
newMessageArrived.getReceiver().getQra(),
client.getChatPreferences().getStn_maxQRBDefault(),
@@ -1456,17 +1385,24 @@ public class MessageBusManagementThread extends Thread {
newMessageArrived.getSender().setInAngleAndRange(true);
} else {
newMessageArrived.getSender().setInAngleAndRange(false);
}
} else {
/*
* Historic fallback senders/receivers may not have locators.
* The message is still valid and should be displayed, but angle/range
* analysis cannot be calculated without both locators.
*/
newMessageArrived.getSender().setInAngleAndRange(false);
}
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
this.client.publishChatMessage(newMessageArrived);
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
}
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: " + referenceDeletedByUserLeftChatDuringMessageprocessing.getStackTrace());
// referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
System.out.println("MSGBS bgfx, <<<catched error>>>: referenced user left the chat during messageprocessing or message got before user entered chat message: "
+ referenceDeletedByUserLeftChatDuringMessageprocessing.getMessage());
referenceDeletedByUserLeftChatDuringMessageprocessing.printStackTrace();
}
// sdtout to me message-List
@@ -1479,7 +1415,8 @@ public class MessageBusManagementThread extends Thread {
+ " " + newMessageArrived.getSender().getCallSign() + ", " + newMessageArrived.getMessageSenderName() + " -> "
+ newMessageArrived.getReceiver().getCallSign() + ": " + newMessageArrived.getMessageText());
} catch (Exception exceptionOccured) {
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage() + "\n" + exceptionOccured.getStackTrace());
System.out.println("[MSGMgtBus: ERROR CHATCHED ON MAYBE NULL ISSUE]: " + exceptionOccured.getMessage());
exceptionOccured.printStackTrace();
}
// --- Band/QRG recognition (fills ChatMember.knownActiveBands) ---
@@ -1534,7 +1471,7 @@ public class MessageBusManagementThread extends Thread {
for (int i = 0; i < 10; i++) {
client.getLst_globalChatMessageList().add(pwErrorMsg);
client.publishChatMessage(pwErrorMsg);
// client.getLst_toMeMessageList().add(pwErrorMsg);
// client.getLst_toAllMessageList().add(pwErrorMsg);
}
@@ -1546,6 +1483,10 @@ public class MessageBusManagementThread extends Thread {
// this.client.disconnect();
}
else if (splittedMessageLine[0].equals("DE")) {
// DXCluster delimiter/end marker; intentionally ignored.
}
else {
// bufwrtrDBGMSGOut.write(new Utils4KST().time_generateCurrentMMDDhhmmTimeString()
@@ -1751,6 +1692,10 @@ public class MessageBusManagementThread extends Thread {
} catch (SQLException e) {
System.out.println("MsgBusMgt: process23001 went wrong / SQL Error");
e.printStackTrace();
} catch (RuntimeException e) {
System.out.println("MsgBusMgt: process23001 went wrong / Runtime Error while processing: "
+ messageTextRaw.getMessageText());
e.printStackTrace();
}
}
@@ -5016,7 +5016,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
help2.setOnAction(new EventHandler<ActionEvent>() {
public void handle(ActionEvent event) {
getHostServices().showDocument("https://www.paypal.com/paypalme/do5amf");
getHostServices().showDocument("https://ko-fi.com/praktimarc");
}
});
@@ -13,8 +13,12 @@ import java.nio.charset.StandardCharsets;
* - 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
* - JavaScript errors are forwarded to Java through javaMapBridge
* - setTheme(light|dark) aligns the map with the JavaFX application theme
*
* Important:
* This version intentionally uses integer Leaflet zoom levels again.
* Fractional zoom in JavaFX WebView caused unreliable marker positioning.
*/
public final class MapHtmlResources {
@@ -184,6 +188,111 @@ public final class MapHtmlResources {
font-weight: 800;
}
.station-cluster-wrapper {
background: transparent;
border: none;
box-shadow: none;
}
.station-cluster-root {
position: relative;
width: 1px;
height: 1px;
pointer-events: auto;
cursor: pointer;
user-select: none;
}
/*
* Cluster design aligned with normal KST4Contest station markers:
* - dark center like station-dot
* - blue border for normal clustered stations
* - yellow border when the cluster contains worked stations
* - orange hover border similar to selected station state
*/
.station-cluster-bubble {
position: absolute;
left: -16px;
top: -16px;
min-width: 32px;
height: 32px;
padding: 0 7px;
border-radius: 18px;
background: #1d1d1d;
color: #f4f7fa;
border: 2px solid #4da6ff;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.35),
0 2px 7px rgba(0, 0, 0, 0.42);
box-sizing: border-box;
font-size: 13px;
font-weight: 800;
line-height: 28px;
text-align: center;
white-space: nowrap;
}
.station-cluster-bubble.medium {
left: -18px;
top: -18px;
min-width: 36px;
height: 36px;
border-radius: 20px;
font-size: 14px;
line-height: 32px;
}
.station-cluster-bubble.large {
left: -21px;
top: -21px;
min-width: 42px;
height: 42px;
border-radius: 23px;
font-size: 15px;
line-height: 38px;
}
.station-cluster-bubble.worked {
border-color: #ffd24d;
}
.station-cluster-bubble.warning {
border-color: #00ff66;
color: #00ff66;
}
.station-cluster-root:hover .station-cluster-bubble {
border-color: #ff9900;
box-shadow:
0 0 0 2px rgba(255, 153, 0, 0.28),
0 2px 9px rgba(0, 0, 0, 0.48);
}
body.kst-theme-dark .station-cluster-bubble {
background: #202428;
color: #f1f3f5;
border-color: #4da6ff;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.12),
0 2px 8px rgba(0, 0, 0, 0.58);
}
body.kst-theme-dark .station-cluster-bubble.worked {
border-color: #ffd24d;
}
body.kst-theme-dark .station-cluster-bubble.warning {
border-color: #00ff66;
color: #00ff66;
}
body.kst-theme-dark .station-cluster-root:hover .station-cluster-bubble {
border-color: #ff9900;
box-shadow:
0 0 0 2px rgba(255, 153, 0, 0.32),
0 2px 10px rgba(0, 0, 0, 0.65);
}
.maidenhead-grid-label-wrapper {
background: transparent;
border: none;
@@ -225,9 +334,6 @@ public final class MapHtmlResources {
<script>window._kstTileProxyPort=__TILE_PROXY_PORT__;</script>
<script>
window.kstMapApi = (function () {
let map;
let stationLayer;
@@ -235,11 +341,77 @@ public final class MapHtmlResources {
let beamLayer;
let connectionLayer;
let profileHoverMarker;
/*
* Currently visible individual station markers.
*
* A callsign is present here only when it is rendered individually.
* If the station is inside a cluster, stationsByCallsignRaw is used
* for focus/zoom operations.
*/
let markersByCallsignRaw = {};
/*
* All station data as received from Java.
*/
let stationData = [];
let stationsByCallsignRaw = {};
let clustersById = {};
let clusterSequence = 0;
let activeTheme = 'light';
let invalidateNotifyTimer = 0;
/*
* Integer zoom only.
*
* Fractional zoom is intentionally disabled because JavaFX WebView
* and Leaflet marker positioning were unreliable at intermediate
* zoom levels.
*/
const KST_ZOOM_STEP = 1;
const KST_MIN_ZOOM = 3;
const KST_MAX_ZOOM = 18;
/*
* Enables verbose JavaScript map logging.
*
* Keep this false for normal operation. jsError() still reports
* real errors.
*/
const KST_MAP_DEBUG = false;
/*
* Cluster settings.
*
* With integer zoom, clustering is disabled at zoom >= 8.
* At zoom 7 only very close stations are grouped.
*/
const KST_CLUSTER_DISABLE_ZOOM = 8;
/*
* Do not cluster pairs immediately. Two nearby stations are still
* readable and should remain individually clickable.
*/
const KST_CLUSTER_MIN_STATIONS = 3;
/*
* Cluster cell sizes in screen pixels.
*
* Smaller cells make clustering less aggressive. Stations must be
* closer together on screen before they are grouped.
*/
const KST_CLUSTER_CELL_SIZE_HIGH_ZOOM = 55;
const KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM = 70;
const KST_CLUSTER_CELL_SIZE_LOW_ZOOM = 95;
const KST_CLUSTER_CELL_SIZE_VERY_LOW_ZOOM = 125;
function jsLog(message) {
if (!KST_MAP_DEBUG) {
return;
}
try {
if (window.javaMapBridge) {
window.javaMapBridge.onJsLog(String(message));
@@ -358,6 +530,331 @@ public final class MapHtmlResources {
+ '</div>';
}
function getStationCallsignKey(station) {
if (!station || station.callSignRaw === null || station.callSignRaw === undefined) {
return '';
}
return String(station.callSignRaw);
}
function isStationPositionValid(station) {
if (!station) {
return false;
}
return isFinite(station.latitudeDeg) && isFinite(station.longitudeDeg);
}
/**
* Returns true for stations that must not be hidden inside a cluster.
*
* These stations remain individually visible even at low zoom levels
* because they are operationally important during contest operation.
*/
function shouldRenderStationIndividually(station) {
if (!station) {
return false;
}
if (station.selected) {
return true;
}
if (station.warningToMyDirection) {
return true;
}
return false;
}
/**
* Returns the cluster grid size in screen pixels for the current zoom level.
*
* The cluster calculation is screen-based, not locator-based. This makes
* the decision match the real visual problem: too many labels in the same
* screen area.
*/
function getClusterCellSizePx() {
if (!map) {
return KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM;
}
const zoom = Number(map.getZoom());
if (zoom >= 7) {
return KST_CLUSTER_CELL_SIZE_HIGH_ZOOM;
}
if (zoom >= 6) {
return KST_CLUSTER_CELL_SIZE_MEDIUM_ZOOM;
}
if (zoom >= 5) {
return KST_CLUSTER_CELL_SIZE_LOW_ZOOM;
}
return KST_CLUSTER_CELL_SIZE_VERY_LOW_ZOOM;
}
function buildClusterMarkerHtml(clusterId, clusterStations) {
const count = clusterStations ? clusterStations.length : 0;
let bubbleClasses = 'station-cluster-bubble';
if (count >= 20) {
bubbleClasses += ' large';
} else if (count >= 8) {
bubbleClasses += ' medium';
}
const containsWorkedStation = clusterStations
&& clusterStations.some(station => station && station.worked);
const containsWarningStation = clusterStations
&& clusterStations.some(station => station && station.warningToMyDirection);
if (containsWarningStation) {
bubbleClasses += ' warning';
} else if (containsWorkedStation) {
bubbleClasses += ' worked';
}
return '<div class="station-cluster-root" data-cluster-id="' + escapeHtml(clusterId) + '">'
+ '<div class="' + bubbleClasses + '">' + count + '</div>'
+ '</div>';
}
function buildClusterTooltipHtml(clusterStations) {
if (!clusterStations || clusterStations.length === 0) {
return '';
}
const callsigns = clusterStations
.map(station => station.markerLabel || station.callSignRaw || station.callSign || '')
.filter(value => value !== null && value !== undefined && String(value).trim() !== '')
.map(value => String(value).trim())
.sort();
const maxPreviewCount = 20;
const preview = callsigns
.slice(0, maxPreviewCount)
.map(value => escapeHtml(value))
.join('<br>');
const remainingCount = Math.max(0, callsigns.length - maxPreviewCount);
let html = '<b>' + clusterStations.length + ' stations</b>';
if (preview) {
html += '<br>' + preview;
}
if (remainingCount > 0) {
html += '<br>+' + remainingCount + ' more';
}
html += '<br><i>Click to zoom in</i>';
return html;
}
function addStationMarker(station) {
if (!stationLayer || !isStationPositionValid(station)) {
return;
}
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);
const callSignKey = getStationCallsignKey(station);
if (callSignKey) {
markersByCallsignRaw[callSignKey] = marker;
}
}
function addClusterMarker(clusterStations) {
if (!stationLayer || !clusterStations || clusterStations.length === 0) {
return;
}
let latSum = 0.0;
let lonSum = 0.0;
let validCount = 0;
clusterStations.forEach(station => {
if (isStationPositionValid(station)) {
latSum += Number(station.latitudeDeg);
lonSum += Number(station.longitudeDeg);
validCount++;
}
});
if (validCount === 0) {
return;
}
const centerLat = latSum / validCount;
const centerLon = lonSum / validCount;
const clusterId = 'cluster-' + (++clusterSequence);
clustersById[clusterId] = clusterStations;
const clusterMarker = L.marker(
[centerLat, centerLon],
{
interactive: true,
keyboard: false,
icon: L.divIcon({
className: 'station-cluster-wrapper',
html: buildClusterMarkerHtml(clusterId, clusterStations),
iconSize: [1, 1],
iconAnchor: [0, 0]
})
}
);
clusterMarker.on('click', function () {
zoomToCluster(clusterStations);
});
clusterMarker.bindTooltip(buildClusterTooltipHtml(clusterStations), {
direction: 'top',
sticky: true,
opacity: 0.95
});
clusterMarker.addTo(stationLayer);
}
function renderAllStationsIndividually() {
stationData.forEach(station => {
addStationMarker(station);
});
}
/**
* Renders stations with simple screen-grid clustering.
*
* Important stations such as the currently selected station and warning
* stations are rendered individually before clustering. They remain
* visible even at low zoom levels.
*/
function renderClusteredStations() {
const clusterCellSizePx = getClusterCellSizePx();
const buckets = {};
stationData.forEach(station => {
if (!isStationPositionValid(station)) {
return;
}
if (shouldRenderStationIndividually(station)) {
addStationMarker(station);
return;
}
const point = map.latLngToContainerPoint([station.latitudeDeg, station.longitudeDeg]);
const cellX = Math.floor(point.x / clusterCellSizePx);
const cellY = Math.floor(point.y / clusterCellSizePx);
const key = cellX + ':' + cellY;
if (!buckets[key]) {
buckets[key] = [];
}
buckets[key].push(station);
});
Object.keys(buckets).forEach(key => {
const bucketStations = buckets[key];
if (bucketStations.length >= KST_CLUSTER_MIN_STATIONS) {
addClusterMarker(bucketStations);
} else {
bucketStations.forEach(station => addStationMarker(station));
}
});
}
/**
* Re-renders station markers for the current zoom and viewport.
*
* This is called when stations arrive from Java and after zoom/move
* events, because screen-grid clusters depend on the current viewport.
*/
function renderStationMarkers() {
if (!map || !stationLayer) {
return;
}
stationLayer.clearLayers();
markersByCallsignRaw = {};
clustersById = {};
clusterSequence = 0;
if (!stationData || stationData.length === 0) {
return;
}
if (Number(map.getZoom()) >= KST_CLUSTER_DISABLE_ZOOM) {
renderAllStationsIndividually();
} else {
renderClusteredStations();
}
}
/**
* Zooms into a cluster.
*
* A cluster click does not select one station. It moves the map towards
* the cluster center and increases zoom until individual stations become
* visible.
*/
function zoomToCluster(clusterStations) {
if (!map || !clusterStations || clusterStations.length === 0) {
return;
}
const latLngs = [];
clusterStations.forEach(station => {
if (isStationPositionValid(station)) {
latLngs.push(L.latLng(station.latitudeDeg, station.longitudeDeg));
}
});
if (latLngs.length === 0) {
return;
}
const bounds = L.latLngBounds(latLngs);
const currentZoom = Number(map.getZoom());
const targetZoom = clampZoomToLeafletLimits(
Math.min(KST_CLUSTER_DISABLE_ZOOM, currentZoom + 1)
);
map.setView(bounds.getCenter(), targetZoom, {
animate: false
});
notifyViewport();
}
function init() {
if (map) {
return true;
@@ -372,6 +869,17 @@ public final class MapHtmlResources {
map = L.map('map', {
zoomControl: true,
minZoom: KST_MIN_ZOOM,
maxZoom: KST_MAX_ZOOM,
/*
* Integer zoom only.
* This keeps station positions stable in JavaFX WebView.
*/
zoomSnap: 1,
zoomDelta: 1,
wheelPxPerZoomLevel: 120,
zoomAnimation: false,
fadeAnimation: false,
markerZoomAnimation: false,
@@ -383,7 +891,8 @@ public final class MapHtmlResources {
const tileLayer = L.tileLayer(
'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
{
maxZoom: 18,
minZoom: KST_MIN_ZOOM,
maxZoom: KST_MAX_ZOOM,
updateWhenZooming: false,
keepBuffer: 4,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@@ -419,12 +928,12 @@ public final class MapHtmlResources {
connectionLayer = L.layerGroup().addTo(map);
map.on('zoomend', function () {
jsLog('zoomend -> ' + map.getZoom());
renderStationMarkers();
notifyViewport();
});
map.on('moveend', function () {
jsLog('moveend');
renderStationMarkers();
notifyViewport();
});
@@ -476,20 +985,39 @@ public final class MapHtmlResources {
invalidateSize();
}
function zoomIn() {
/**
* Keeps the zoom value inside the explicit KST zoom limits.
*/
function clampZoomToLeafletLimits(zoom) {
return Math.max(KST_MIN_ZOOM, Math.min(KST_MAX_ZOOM, zoom));
}
/**
* Changes the zoom in stable integer steps.
*/
function changeZoomByKstStep(delta) {
if (!map) {
return;
}
map.setZoom(map.getZoom() + 1, { animate: false });
const currentZoom = Number(map.getZoom());
const nextZoom = clampZoomToLeafletLimits(currentZoom + delta);
if (nextZoom === currentZoom) {
return;
}
map.setZoom(nextZoom, {
animate: false
});
}
function zoomIn() {
changeZoomByKstStep(KST_ZOOM_STEP);
}
function zoomOut() {
if (!map) {
return;
}
map.setZoom(map.getZoom() - 1, { animate: false });
changeZoomByKstStep(-KST_ZOOM_STEP);
}
function getViewportState() {
@@ -522,6 +1050,12 @@ public final class MapHtmlResources {
return 'station|' + callSignRaw + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
}
const clusterRoot = el.closest('.station-cluster-root');
if (clusterRoot) {
const clusterId = clusterRoot.getAttribute('data-cluster-id') || '';
return 'cluster|' + clusterId + '|' + 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();
@@ -542,8 +1076,8 @@ public final class MapHtmlResources {
if (!init()) {
return;
}
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
map.setView([lat, lon], zoom);
map.setView([lat, lon], clampZoomToLeafletLimits(Math.round(Number(zoom))));
}
function setStations(stationsJson) {
@@ -551,30 +1085,17 @@ public final class MapHtmlResources {
return;
}
stationLayer.clearLayers();
markersByCallsignRaw = {};
stationData = JSON.parse(stationsJson);
stationsByCallsignRaw = {};
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]
})
stationData.forEach(station => {
const callSignKey = getStationCallsignKey(station);
if (callSignKey) {
stationsByCallsignRaw[callSignKey] = station;
}
);
marker.addTo(stationLayer);
markersByCallsignRaw[station.callSignRaw] = marker;
});
renderStationMarkers();
}
function setBeam(beamJson) {
@@ -592,8 +1113,6 @@ public final class MapHtmlResources {
return;
}
jsLog('setBeam points=' + points.length);
const latLngs = points.map(point => [point.lat, point.lon]);
L.polygon(latLngs, {
@@ -621,8 +1140,6 @@ public final class MapHtmlResources {
return;
}
jsLog('setConnection');
L.polyline(points.map(point => [point.lat, point.lon]), {
pane: 'connectionPane',
color: connectionColor(),
@@ -671,7 +1188,6 @@ public final class MapHtmlResources {
gridLayer.clearLayers();
const cells = JSON.parse(gridJson);
jsLog('setGrid cells=' + cells.length);
cells.forEach(cell => {
const rectangle = L.rectangle(
@@ -717,21 +1233,33 @@ public final class MapHtmlResources {
}
const marker = markersByCallsignRaw[callSignRaw];
if (!marker) {
jsLog('focusCallsignRaw skipped for ' + callSignRaw);
return;
}
jsLog('focusCallsignRaw ' + callSignRaw);
if (marker) {
map.panTo(marker.getLatLng(), {
animate: false
});
notifyViewport();
return;
}
/*
* If the station is currently hidden inside a cluster, there is no
* individual marker. Use the raw station data and zoom in far enough
* so clustering is disabled and the station becomes visible.
*/
const station = stationsByCallsignRaw[callSignRaw];
if (station && isStationPositionValid(station)) {
const focusZoom = clampZoomToLeafletLimits(
Math.max(Number(map.getZoom()), KST_CLUSTER_DISABLE_ZOOM)
);
map.setView([station.latitudeDeg, station.longitudeDeg], focusZoom, {
animate: false
});
notifyViewport();
}
}
return {
init: init,
@@ -41,6 +41,14 @@ import javafx.scene.layout.ColumnConstraints;
*/
public final class StationMapView {
/**
* Enables verbose map debug output.
*
* Keep this false for normal operation because scroll and viewport logs are very
* noisy during map interaction.
*/
private static final boolean MAP_DEBUG_LOGGING = false;
private final PathProfileChart detailPathProfileChart = new PathProfileChart();
private final Label detailPathModeValue = new Label("-");
@@ -287,12 +295,17 @@ public final class StationMapView {
webView.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> handleWebViewClick(event));
webView.addEventHandler(ScrollEvent.SCROLL, event -> {
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] SCROLL x=" + (int) event.getX()
+ " y=" + (int) event.getY()
+ " deltaY=" + event.getDeltaY());
}
InteractiveTarget target = inspectInteractiveTarget(event.getX(), event.getY());
if (MAP_DEBUG_LOGGING) {
System.out.println("[StationMap FX] inspect scroll -> " + target);
}
if (event.getDeltaY() > 0) {
executeMapScriptSafely("window.kstMapApi.zoomIn();");
@@ -650,6 +650,8 @@ svg.leaflet-image-layer.leaflet-interactive path {
border-right-color: #fff;
}
/* Printing */
@media print {
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="About KST4Contest">
<meta name="twitter:description" content="About KST4Contest and its contest-oriented ON4KST workflow.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Contact | KST4Contest">
<meta name="twitter:description" content="Contact options for KST4Contest.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Download KST4Contest">
<meta name="twitter:description" content="Download KST4Contest releases for Windows, Linux and macOS.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest FAQ">
<meta name="twitter:description" content="Frequently asked questions about KST4Contest, ON4KST contest operation, AirScout integration and VHF/UHF/SHF contest workflow.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="AirScout Integration">
<meta name="twitter:description" content="AirScout information helps operators evaluate AP windows, aircraft scatter timing and candidate stations during VHF/UHF/SHF contests.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Dual Chat Categories">
<meta name="twitter:description" content="Dual category support gives contest operators better overview when working across multiple ON4KST chat rooms.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="DXCluster Server">
<meta name="twitter:description" content="KST4Contest includes DXCluster functionality to support situational awareness during contest operation.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Features">
<meta name="twitter:description" content="Contest-optimized ON4KST features for VHF, UHF and SHF operation.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Log Synchronization">
<meta name="twitter:description" content="Log synchronization helps KST4Contest understand worked stations, active bands and contest context.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Macros and Variables">
<meta name="twitter:description" content="Macros reduce repetitive typing and help operators respond quickly during active ON4KST contest sessions.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Priority Score System">
<meta name="twitter:description" content="KST4Contest ranks stations by activity, direction, sked context, band information, QRG hints and operator workflow signals.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Sked Reminder">
<meta name="twitter:description" content="KST4Contest keeps skeds in focus while the operator handles chat traffic, logging, bands and aircraft scatter timing.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Timeline View">
<meta name="twitter:description" content="The timeline view helps operators understand short propagation windows and candidate timing during active contest operation.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Contest-Optimized ON4KST Chat Client">
<meta name="twitter:description" content="KST4Contest is a modern ON4KST chat client built for VHF, UHF and SHF contest operation.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Legal Notice | KST4Contest">
<meta name="twitter:description" content="Legal notice for the KST4Contest website.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Deutsches Handbuch">
<meta name="twitter:description" content="Deutsches Benutzerhandbuch für KST4Contest.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest English Manual">
<meta name="twitter:description" content="English user manual for KST4Contest.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="{{ manual.title }} | KST4Contest Manual">
<meta name="twitter:description" content="KST4Contest manual page: {{ manual.title }}">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Manual">
<meta name="twitter:description" content="Online manual for KST4Contest, the contest-optimized ON4KST chat client.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Hotfix Version 1.41.1">
<meta name="twitter:description" content="Contest-optimized ON4KST chat client for VHF, UHF and SHF ham radio contests.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Website Launch">
<meta name="twitter:description" content="Contest-optimized ON4KST chat client for VHF, UHF and SHF ham radio contests.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest News">
<meta name="twitter:description" content="News, release updates and development notes for KST4Contest.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="Privacy Policy | KST4Contest">
<meta name="twitter:description" content="Privacy policy for the KST4Contest website.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+5 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Roadmap">
<meta name="twitter:description" content="Planned enhancements for upcoming KST4Contest versions, generated from GitHub milestones and issues.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
@@ -148,6 +148,10 @@
<ul>
<li>
<a href="https://github.com/praktimarc/kst4contest/issues/59">[FEATURE] window control</a>
</li>
<li>
<a href="https://github.com/praktimarc/kst4contest/issues/55">[FEATURE] Reset-Button for the map</a>
</li>
+1 -1
View File
@@ -17,7 +17,7 @@
<meta name="twitter:title" content="KST4Contest Screenshots">
<meta name="twitter:description" content="Screenshots of KST4Contest, the contest-optimized ON4KST chat client for VHF, UHF and SHF operators.">
<link rel="stylesheet" href="/assets/css/main.css?v=1783615687631">
<link rel="stylesheet" href="/assets/css/main.css?v=1783632276803">
<link rel="alternate" type="application/rss+xml" title="KST4Contest News" href="/feed.xml">
<meta property="og:image" content="https://kst4contest.hamradioonline.de/assets/social/kst4contest-og.png">
+3 -1
View File
@@ -6,5 +6,7 @@ module.exports = [
{ title: "News", url: "/news/" },
{ title: "Roadmap", url: "/roadmap/" },
{ title: "About", url: "/about/" },
{ title: "FAQ", url: "/faq/" }
{ title: "FAQ", url: "/faq/" },
{ title: "Support", url: "/support/"
}
];
+6
View File
@@ -71,6 +71,7 @@
<p><a href="https://github.com/praktimarc/kst4contest">GitHub Repository</a></p>
<p><a href="https://github.com/praktimarc/kst4contest/issues">Issues</a></p>
<p><a href="/contact/">Contact</a></p>
<p><a href="/support/">❤️ Support KST4Contest</a></p>
</div>
<div>
@@ -80,5 +81,10 @@
</div>
</div>
</footer>
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
</body>
</html>
+15
View File
@@ -26,5 +26,20 @@ description: About KST4Contest and its contest-oriented ON4KST workflow.
The project is open source and focused on practical contest station workflows.
Features are designed around real operating pressure, not theoretical UI concepts.
</p>
<h2>Community supported</h2>
<p>
KST4Contest is developed independently.
Community support helps finance hosting,
online services,
testing across multiple operating systems
and future contest-oriented features.
</p>
</article>
</section>
+198 -8
View File
@@ -7,10 +7,22 @@
--text: #f8fafc;
--muted: #aab6ca;
--soft: #64748b;
--accent: #38bdf8;
--accent2: #a855f7;
--accent3: #22c55e;
/*--accent: #38bdf8;*/
/*--accent2: #a855f7;*/
/*--accent3: #22c55e;*/
--shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
--accent: #54d63d;
--accent2: #8aef64;
--accent3: #31b54a;
--fx-plane: #79ff73;
--fx-plane-muted: rgba(121, 255, 115, 0.42);
--fx-beam: #52ff65;
--fx-beam-glow: rgba(82, 255, 101, 0.28);
--fx-reflection: #d2ffc8;
--fx-trail: rgba(121, 255, 115, 0.17);
--fx-grid: rgba(121, 255, 115, 0.055);
}
* {
@@ -25,13 +37,39 @@ html {
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 14% 12%, rgba(168, 85, 247, 0.30), transparent 28%),
radial-gradient(circle at 82% 16%, rgba(56, 189, 248, 0.24), transparent 30%),
radial-gradient(circle at 50% 85%, rgba(34, 197, 94, 0.11), transparent 35%),
linear-gradient(180deg, #050816 0%, #07111f 100%);
radial-gradient(
circle at 14% 12%,
rgba(52, 165, 57, 0.20),
transparent 30%
),
radial-gradient(
circle at 82% 16%,
rgba(95, 218, 82, 0.14),
transparent 32%
),
radial-gradient(
circle at 50% 85%,
rgba(32, 126, 55, 0.12),
transparent 36%
),
linear-gradient(
180deg,
#070b09 0%,
#0b1410 100%
);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
line-height: 1.65;
overflow-x: hidden;
}
@@ -562,3 +600,155 @@ a:hover {
grid-template-columns: 1fr;
}
}
/*
* Interactive radio / aircraft-scatter hero layer
*/
.hero-radio-fx {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.76;
}
.hero-fx-vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
rgba(7, 11, 9, 0.92) 0%,
rgba(7, 11, 9, 0.68) 34%,
rgba(7, 11, 9, 0.16) 64%,
rgba(7, 11, 9, 0.42) 100%
),
linear-gradient(
180deg,
rgba(7, 11, 9, 0.10),
rgba(7, 11, 9, 0.58)
);
}
.hero-split > *:not(.hero-radio-fx):not(.hero-fx-vignette) {
position: relative;
z-index: 2;
}
/*
* Operator-console badge styling
*/
.badge,
.eyebrow {
color: #caffc0;
background: rgba(52, 165, 57, 0.12);
border-color: rgba(105, 232, 88, 0.32);
box-shadow: 0 0 22px rgba(82, 255, 101, 0.08);
}
/*
* Light-mode identity:
* white/grey UI with red contest accents
*/
html[data-theme="light"] {
--bg: #f4f4f2;
--bg2: #ffffff;
--panel: rgba(255, 255, 255, 0.88);
--panel2: rgba(242, 242, 239, 0.92);
--border: rgba(91, 91, 86, 0.20);
--text: #252522;
--muted: #656560;
--soft: #888881;
--accent: #d33732;
--accent2: #f25a51;
--accent3: #ae2727;
--fx-plane: #df3732;
--fx-plane-muted: rgba(211, 55, 50, 0.38);
--fx-beam: #ef4039;
--fx-beam-glow: rgba(239, 64, 57, 0.22);
--fx-reflection: #ff8d84;
--fx-trail: rgba(211, 55, 50, 0.15);
--fx-grid: rgba(185, 47, 43, 0.045);
}
html[data-theme="light"] body {
background:
radial-gradient(
circle at 14% 12%,
rgba(211, 55, 50, 0.12),
transparent 30%
),
radial-gradient(
circle at 82% 16%,
rgba(242, 90, 81, 0.09),
transparent 32%
),
linear-gradient(
180deg,
#ffffff 0%,
#f0f0ed 100%
);
}
html[data-theme="light"] .hero-fx-vignette {
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 255, 255, 0.76) 34%,
rgba(255, 255, 255, 0.18) 66%,
rgba(255, 255, 255, 0.48) 100%
),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(240, 240, 237, 0.62)
);
}
html[data-theme="light"] .badge,
html[data-theme="light"] .eyebrow {
color: #962a27;
background: rgba(211, 55, 50, 0.08);
border-color: rgba(211, 55, 50, 0.24);
}
@media (max-width: 900px) {
.hero-radio-fx {
opacity: 0.48;
}
.hero-fx-vignette {
background:
linear-gradient(
180deg,
rgba(7, 11, 9, 0.82),
rgba(7, 11, 9, 0.34)
);
}
html[data-theme="light"] .hero-fx-vignette {
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.90),
rgba(255, 255, 255, 0.42)
);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-radio-fx {
opacity: 0.28;
}
}
+710
View File
@@ -0,0 +1,710 @@
(() => {
"use strict";
const canvas = document.querySelector("[data-hero-radio-fx]");
if (!(canvas instanceof HTMLCanvasElement)) {
return;
}
const context = canvas.getContext("2d", {
alpha: true,
desynchronized: true
});
if (!context) {
return;
}
const hero = canvas.closest(".hero");
if (!(hero instanceof HTMLElement)) {
return;
}
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
const coarsePointer = window.matchMedia(
"(pointer: coarse)"
);
const state = {
width: 0,
height: 0,
dpr: 1,
running: false,
visible: true,
lastFrame: 0,
spawnAccumulator: 0,
beamAccumulator: 0,
animationFrameId: 0,
pointer: {
x: 0,
y: 0,
active: false
},
planes: [],
beams: [],
reflections: []
};
const clamp = (value, minimum, maximum) =>
Math.max(minimum, Math.min(maximum, value));
const randomBetween = (minimum, maximum) =>
minimum + Math.random() * (maximum - minimum);
function getCssColor(name, fallback) {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}
function getPalette() {
return {
plane: getCssColor("--fx-plane", "#79ff73"),
planeMuted: getCssColor("--fx-plane-muted", "rgba(121,255,115,.42)"),
beam: getCssColor("--fx-beam", "#52ff65"),
beamGlow: getCssColor("--fx-beam-glow", "rgba(82,255,101,.28)"),
reflection: getCssColor("--fx-reflection", "#c8ffbf"),
trail: getCssColor("--fx-trail", "rgba(121,255,115,.16)"),
grid: getCssColor("--fx-grid", "rgba(121,255,115,.055)")
};
}
function resizeCanvas() {
const bounds = hero.getBoundingClientRect();
state.width = Math.max(1, Math.round(bounds.width));
state.height = Math.max(1, Math.round(bounds.height));
state.dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.round(state.width * state.dpr);
canvas.height = Math.round(state.height * state.dpr);
canvas.style.width = `${state.width}px`;
canvas.style.height = `${state.height}px`;
context.setTransform(
state.dpr,
0,
0,
state.dpr,
0,
0
);
if (!state.pointer.active) {
state.pointer.x = state.width * 0.54;
state.pointer.y = state.height * 0.42;
}
}
function getPlaneLimit() {
if (coarsePointer.matches) {
return 5;
}
if (state.width < 900) {
return 7;
}
return 12;
}
function createPlane() {
const margin = 55;
const edge = Math.floor(Math.random() * 4);
let x;
let y;
switch (edge) {
case 0:
x = randomBetween(0, state.width);
y = -margin;
break;
case 1:
x = state.width + margin;
y = randomBetween(0, state.height);
break;
case 2:
x = randomBetween(0, state.width);
y = state.height + margin;
break;
default:
x = -margin;
y = randomBetween(0, state.height);
break;
}
const targetX = state.pointer.active
? state.pointer.x
: state.width * randomBetween(0.38, 0.72);
const targetY = state.pointer.active
? state.pointer.y
: state.height * randomBetween(0.2, 0.72);
const angle = Math.atan2(targetY - y, targetX - x);
const speed = randomBetween(18, 34);
return {
x,
y,
previousX: x,
previousY: y,
velocityX: Math.cos(angle) * speed,
velocityY: Math.sin(angle) * speed,
angle,
size: randomBetween(5.5, 9),
age: 0,
maximumAge: randomBetween(12, 20),
steering: randomBetween(0.65, 1.2),
wobble: randomBetween(0, Math.PI * 2),
wobbleSpeed: randomBetween(0.7, 1.4),
hit: 0
};
}
function updatePlane(plane, deltaSeconds) {
plane.age += deltaSeconds;
plane.wobble += deltaSeconds * plane.wobbleSpeed;
plane.previousX = plane.x;
plane.previousY = plane.y;
const targetX = state.pointer.active
? state.pointer.x
: state.width * 0.56;
const targetY = state.pointer.active
? state.pointer.y
: state.height * 0.44;
const desiredAngle = Math.atan2(
targetY - plane.y,
targetX - plane.x
);
let angleDifference = desiredAngle - plane.angle;
while (angleDifference > Math.PI) {
angleDifference -= Math.PI * 2;
}
while (angleDifference < -Math.PI) {
angleDifference += Math.PI * 2;
}
plane.angle += angleDifference * plane.steering * deltaSeconds;
const speed = Math.hypot(
plane.velocityX,
plane.velocityY
);
const wobble = Math.sin(plane.wobble) * 0.14;
plane.velocityX =
Math.cos(plane.angle + wobble) * speed;
plane.velocityY =
Math.sin(plane.angle + wobble) * speed;
plane.x += plane.velocityX * deltaSeconds;
plane.y += plane.velocityY * deltaSeconds;
plane.hit = Math.max(0, plane.hit - deltaSeconds * 1.8);
}
function isPlaneExpired(plane) {
const margin = 180;
return (
plane.age > plane.maximumAge ||
plane.x < -margin ||
plane.x > state.width + margin ||
plane.y < -margin ||
plane.y > state.height + margin
);
}
function chooseBeamTarget() {
if (state.planes.length === 0) {
return null;
}
const targetX = state.pointer.active
? state.pointer.x
: state.width * 0.56;
const targetY = state.pointer.active
? state.pointer.y
: state.height * 0.44;
const candidates = state.planes
.map((plane) => ({
plane,
distance: Math.hypot(
plane.x - targetX,
plane.y - targetY
)
}))
.filter((entry) => entry.distance < 260)
.sort((a, b) => a.distance - b.distance);
if (candidates.length > 0) {
return candidates[0].plane;
}
return state.planes[
Math.floor(Math.random() * state.planes.length)
];
}
function createBeam(target) {
const leftStation = {
x: state.width * 0.12,
y: state.height * 0.82
};
const rightStation = {
x: state.width * 0.88,
y: state.height * 0.78
};
const source =
Math.abs(target.x - leftStation.x) <
Math.abs(target.x - rightStation.x)
? leftStation
: rightStation;
state.beams.push({
startX: source.x,
startY: source.y,
endX: target.x,
endY: target.y,
age: 0,
maximumAge: 0.62
});
target.hit = 1;
state.reflections.push({
x: target.x,
y: target.y,
age: 0,
maximumAge: 0.9,
radius: randomBetween(8, 15)
});
}
function updateEffects(deltaSeconds) {
for (const beam of state.beams) {
beam.age += deltaSeconds;
}
state.beams = state.beams.filter(
(beam) => beam.age < beam.maximumAge
);
for (const reflection of state.reflections) {
reflection.age += deltaSeconds;
reflection.radius += deltaSeconds * 30;
}
state.reflections = state.reflections.filter(
(reflection) =>
reflection.age < reflection.maximumAge
);
}
function drawGrid(palette) {
context.save();
context.strokeStyle = palette.grid;
context.lineWidth = 1;
const spacing = 54;
for (
let x = spacing;
x < state.width;
x += spacing
) {
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, state.height);
context.stroke();
}
for (
let y = spacing;
y < state.height;
y += spacing
) {
context.beginPath();
context.moveTo(0, y);
context.lineTo(state.width, y);
context.stroke();
}
context.restore();
}
function drawRadarTarget(palette) {
const x = state.pointer.active
? state.pointer.x
: state.width * 0.56;
const y = state.pointer.active
? state.pointer.y
: state.height * 0.44;
context.save();
context.strokeStyle = palette.planeMuted;
context.lineWidth = 1;
for (const radius of [24, 48, 82]) {
context.globalAlpha = 0.24 - radius / 500;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.stroke();
}
context.globalAlpha = 0.2;
context.beginPath();
context.moveTo(x - 100, y);
context.lineTo(x + 100, y);
context.moveTo(x, y - 100);
context.lineTo(x, y + 100);
context.stroke();
context.restore();
}
function drawPlane(plane, palette) {
context.save();
context.translate(plane.x, plane.y);
context.rotate(plane.angle);
const alpha = clamp(
Math.min(
plane.age * 1.8,
(plane.maximumAge - plane.age) * 1.8
),
0,
1
);
context.globalAlpha = alpha;
context.strokeStyle = palette.trail;
context.lineWidth = 1;
context.beginPath();
context.moveTo(-plane.size * 4.5, 0);
context.lineTo(-plane.size * 1.4, 0);
context.stroke();
context.fillStyle =
plane.hit > 0
? palette.reflection
: palette.plane;
context.shadowColor =
plane.hit > 0
? palette.reflection
: palette.beamGlow;
context.shadowBlur =
plane.hit > 0
? 16
: 5;
const size = plane.size;
context.beginPath();
context.moveTo(size * 1.65, 0);
context.lineTo(-size * 0.7, size * 0.34);
context.lineTo(-size * 0.15, size * 1.05);
context.lineTo(-size * 0.65, size * 1.08);
context.lineTo(-size * 1.35, size * 0.3);
context.lineTo(-size * 1.7, size * 0.28);
context.lineTo(-size * 1.12, 0);
context.lineTo(-size * 1.7, -size * 0.28);
context.lineTo(-size * 1.35, -size * 0.3);
context.lineTo(-size * 0.65, -size * 1.08);
context.lineTo(-size * 0.15, -size * 1.05);
context.lineTo(-size * 0.7, -size * 0.34);
context.closePath();
context.fill();
context.restore();
}
function drawBeam(beam, palette) {
const progress = beam.age / beam.maximumAge;
const alpha = Math.sin(progress * Math.PI);
context.save();
context.globalAlpha = alpha;
context.strokeStyle = palette.beamGlow;
context.lineWidth = 6;
context.shadowColor = palette.beam;
context.shadowBlur = 15;
context.beginPath();
context.moveTo(beam.startX, beam.startY);
context.lineTo(beam.endX, beam.endY);
context.stroke();
context.strokeStyle = palette.beam;
context.lineWidth = 1.4;
context.shadowBlur = 5;
context.beginPath();
context.moveTo(beam.startX, beam.startY);
context.lineTo(beam.endX, beam.endY);
context.stroke();
context.restore();
}
function drawReflection(reflection, palette) {
const progress =
reflection.age / reflection.maximumAge;
context.save();
context.globalAlpha = 1 - progress;
context.strokeStyle = palette.reflection;
context.lineWidth = 1.5;
context.shadowColor = palette.beam;
context.shadowBlur = 12;
context.beginPath();
context.arc(
reflection.x,
reflection.y,
reflection.radius,
0,
Math.PI * 2
);
context.stroke();
context.restore();
}
function drawStaticScene() {
const palette = getPalette();
context.clearRect(
0,
0,
state.width,
state.height
);
drawGrid(palette);
drawRadarTarget(palette);
}
function drawScene() {
const palette = getPalette();
context.clearRect(
0,
0,
state.width,
state.height
);
drawGrid(palette);
drawRadarTarget(palette);
for (const plane of state.planes) {
drawPlane(plane, palette);
}
for (const beam of state.beams) {
drawBeam(beam, palette);
}
for (const reflection of state.reflections) {
drawReflection(reflection, palette);
}
}
function animate(timestamp) {
if (!state.running) {
return;
}
const deltaSeconds = clamp(
(timestamp - state.lastFrame) / 1000 || 0,
0,
0.05
);
state.lastFrame = timestamp;
if (!state.visible || document.hidden) {
state.animationFrameId =
requestAnimationFrame(animate);
return;
}
state.spawnAccumulator += deltaSeconds;
state.beamAccumulator += deltaSeconds;
const planeLimit = getPlaneLimit();
const spawnInterval =
coarsePointer.matches ? 2.2 : 1.15;
if (
state.spawnAccumulator >= spawnInterval &&
state.planes.length < planeLimit
) {
state.spawnAccumulator = 0;
state.planes.push(createPlane());
}
const beamInterval =
coarsePointer.matches ? 3.3 : 1.8;
if (
state.beamAccumulator >= beamInterval &&
state.planes.length > 0
) {
state.beamAccumulator = 0;
const target = chooseBeamTarget();
if (target) {
createBeam(target);
}
}
for (const plane of state.planes) {
updatePlane(plane, deltaSeconds);
}
state.planes = state.planes.filter(
(plane) => !isPlaneExpired(plane)
);
updateEffects(deltaSeconds);
drawScene();
state.animationFrameId =
requestAnimationFrame(animate);
}
function start() {
cancelAnimationFrame(state.animationFrameId);
resizeCanvas();
if (reducedMotion.matches) {
state.running = false;
drawStaticScene();
return;
}
state.running = true;
state.lastFrame = performance.now();
if (state.planes.length === 0) {
const initialPlanes =
coarsePointer.matches ? 2 : 5;
for (let index = 0; index < initialPlanes; index++) {
const plane = createPlane();
plane.age = Math.random() * 2;
state.planes.push(plane);
}
}
state.animationFrameId =
requestAnimationFrame(animate);
}
function updatePointer(event) {
const bounds = hero.getBoundingClientRect();
state.pointer.x = clamp(
event.clientX - bounds.left,
0,
bounds.width
);
state.pointer.y = clamp(
event.clientY - bounds.top,
0,
bounds.height
);
state.pointer.active = true;
}
hero.addEventListener("pointermove", updatePointer, {
passive: true
});
hero.addEventListener("pointerleave", () => {
state.pointer.active = false;
});
window.addEventListener("resize", resizeCanvas, {
passive: true
});
document.addEventListener("visibilitychange", () => {
state.lastFrame = performance.now();
});
reducedMotion.addEventListener("change", start);
const resizeObserver = new ResizeObserver(() => {
resizeCanvas();
if (reducedMotion.matches) {
drawStaticScene();
}
});
resizeObserver.observe(hero);
const intersectionObserver = new IntersectionObserver(
(entries) => {
state.visible =
entries[0]?.isIntersecting ?? true;
state.lastFrame = performance.now();
},
{
threshold: 0.05
}
);
intersectionObserver.observe(hero);
start();
})();
+28
View File
@@ -57,3 +57,31 @@ description: Download KST4Contest releases for Windows, Linux and macOS.
</div>
</div>
</section>
<section class="section">
<div class="card content-card">
<h2>Enjoying KST4Contest?</h2>
<p>
If KST4Contest makes contesting easier for you,
please consider supporting future development.
</p>
<div class="actions">
<a class="button"
href="/support/">
❤️ Support Development
</a>
</div>
</div>
</section>
+10
View File
@@ -2,9 +2,18 @@
layout: base.njk
title: KST4Contest Contest-Optimized ON4KST Chat Client
description: KST4Contest is a modern ON4KST chat client built for VHF, UHF and SHF contest operation.
heroFx: true
---
<section class="hero hero-split">
<canvas
class="hero-radio-fx"
data-hero-radio-fx
aria-hidden="true">
</canvas>
<div class="hero-fx-vignette" aria-hidden="true"></div>
<div>
<p class="badge">The contest-optimized ON4KST client</p>
<h1>KST4Contest</h1>
@@ -73,6 +82,7 @@ description: KST4Contest is a modern ON4KST chat client built for VHF, UHF and S
<div class="actions">
<a class="button" href="/download/">Download KST4Contest</a>
<a class="button secondary" href="/features/">Explore features</a>
<a class="button ghost" href="/support/">❤️ Support Development</a>
</div>
</div>
</section>
+16
View File
@@ -44,6 +44,22 @@ description: Privacy policy for the KST4Contest website.
GitHub is responsible for its own data processing.
</p>
<h2>
Support via Ko-fi
</h2>
<p>
This website links to Ko-fi for voluntary financial support.
Payments are processed entirely by Ko-fi and its payment providers.
KST4Contest itself never receives or processes payment information.
</p>
<h2>Your rights</h2>
<p>
You may have the right to access, rectification, erasure, restriction of processing,
+43
View File
@@ -48,3 +48,46 @@ description: Planned enhancements for upcoming KST4Contest versions, generated f
</article>
{% endfor %}
</section>
<section class="section">
<div class="cta-panel">
<p class="eyebrow">
Community
</p>
<h2>
Development is driven by the community.
</h2>
<p>
Many roadmap items require
time,
online services
and infrastructure.
Community support helps accelerate development.
</p>
<div class="actions">
<a class="button"
href="/support/">
❤️ Support Development
</a>
</div>
</div>
</section>
+176
View File
@@ -0,0 +1,176 @@
---
layout: base.njk
title: Support KST4Contest
description: Help support the ongoing development of KST4Contest.
---
<section class="hero">
<p class="badge">Support Development</p>
<h1>Help shape the future of KST4Contest.</h1>
<p class="lead">
KST4Contest is an independent open-source project developed in my free time.
Your support helps cover infrastructure costs and enables new contest-oriented
features that would otherwise be difficult to implement.
</p>
<div class="actions">
<a class="button"
href="https://ko-fi.com/praktimarc"
target="_blank"
rel="noopener">
❤️ Support on Ko-fi
</a>
<a class="button secondary"
href="https://github.com/praktimarc/kst4contest">
View Source Code
</a>
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">What previous support has enabled</p>
<h2>Real improvements funded by the community.</h2>
</div>
<div class="grid">
<article class="card">
<h3>🛰 PSTRotator Integration</h3>
<p>
Support helped justify the investment into rotor hardware and the required
PSTRotator license. Today KST4Contest can automatically control antennas
during contest operation.
</p>
</article>
<article class="card">
<h3>🗺 Interactive Map</h3>
<p>
The interactive station map required commercial map API usage.
Community support helped cover those API costs and made the feature possible.
</p>
</article>
</div>
</section>
<section class="section">
<div class="section-heading">
<p class="eyebrow">Future goals</p>
<h2>What future support will fund</h2>
</div>
<div class="grid">
<article class="card">
<h3>☁ Map API</h3>
<p>
Keeping the online map service available requires ongoing API usage.
</p>
</article>
<article class="card">
<h3>📡 Tropo Server</h3>
<p>
Future propagation prediction services and server-side calculations
will require dedicated hosting infrastructure.
</p>
</article>
<article class="card">
<h3>🔌 New Integrations</h3>
<p>
Development of additional interfaces, logging software integrations,
contest tools and automation features.
</p>
</article>
<article class="card">
<h3>🚀 Faster Development</h3>
<p>
More development time means more contest-oriented features,
better testing and better documentation.
</p>
</article>
</div>
</section>
<section class="section">
<div class="cta-panel">
<p class="eyebrow">Every contribution helps</p>
<h2>Whether it's one coffee or ongoing support.</h2>
<p>
Your support directly improves KST4Contest.
Unlike commercial software, every new feature is driven by real contest
experience and community feedback.
</p>
<div class="actions">
<a class="button"
href="https://ko-fi.com/praktimarc"
target="_blank"
rel="noopener">
❤️ Support KST4Contest
</a>
</div>
</div>
</section>