diff --git a/SimpleLogFile.txt b/SimpleLogFile.txt
deleted file mode 100644
index 4b37091..0000000
--- a/SimpleLogFile.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-S53MM
-PA9R
\ No newline at end of file
diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md
index f0ce5c1..3db6b99 100644
--- a/github_docs/de-Changelog.md
+++ b/github_docs/de-Changelog.md
@@ -8,6 +8,18 @@ Versionsverlauf von KST4Contest / PraktiKST.
letzter Changelog bitte aus GitHub entnehmen. Der bisherige Changelog
+## v1.41
+**Stationskarte, Performance, Reaktionsfähiges UI**
+
+**Neu:**
+- **Stationskarte**: Interaktive OpenStreetMap-Karte zeigt die geografische Position aller aktiven Chatmember. Enthält Stationsmarker, Antennen-Kegel, Verbindungslinie zur ausgewählten Station, Maidenhead-Raster-Overlay und ein Wegprofil-Diagramm mit Geländehöhen-Analyse (Fresnel-Zonen, Horizonterkennung). Geländedaten aus Copernicus GLO-30, Open-Meteo API oder Offline-DEM-Import. Aircraft-Scatter-Weganalyse integriert. Funktioniert in AppImage und Flatpak ohne externe CDN-Verbindung (lokaler Tile-Proxy, eingebettetes Leaflet.js).
+
+**Geändert:**
+- **Nachrichten-Tabellen-Limit auf 30.000 erhöht**: Chat- und Nachrichtentabellen sind auf 30.000 Einträge begrenzt. Ältere Nachrichten werden automatisch verworfen, was die Performance bei mehrtägigem Contest-Betrieb stabil hält.
+- **Bildschirmgerechte Fenstergröße**: Beim Start wird das Hauptfenster auf den aktuellen Bildschirm angepasst. Wenn KST4Contest zuletzt auf einem größeren Monitor betrieben wurde, wird das Fenster automatisch verkleinert. Das UI-Layout ist kompakter und reaktionsfähiger auf kleineren Bildschirmen.
+
+---
+
## v1.40 (2026-02-16)
**Großes Feature-Release: Score-System, AP-Timeline, Win-Test, PSTRotator**
diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md
index c43d75e..78aad44 100644
--- a/github_docs/de-Funktionen.md
+++ b/github_docs/de-Funktionen.md
@@ -235,3 +235,39 @@ Dateibasierte Log-Auswertung per Regex. Details: [Log-Synchronisation](Log-Synch
Ein separates Fenster zeigt den QSO-Fluss zwischen anderen Stationen. Besonders interessant in ruhigeren Nacht-Stunden während des Contests, wenn weniger Verkehr herrscht.
Dieses Fenster kann miniaturisiert werden, wenn es nicht benötigt wird. Zukünftig geplant: Filterung auf Stationen im ausgewählten QTF.
+
+---
+
+## Stationskarte (ab v1.41)
+
+Eine interaktive OpenStreetMap-Karte zeigt die geografische Position aller aktiven Chatmember.
+
+**Funktionen:**
+
+- Stationsmarker mit Rufzeichen-Labels, farblich nach Aktivität und Sked-Status
+- **Antennen-Kegel** für die eigene Station
+- **Verbindungslinie** zur aktuell ausgewählten Station
+- **Maidenhead-Raster** (QRA-Locator-Gitter als Overlay)
+- **Wegprofil-Diagramm**: Geländehöhen-Querschnitt zwischen eigener und ausgewählter Station, inklusive Fresnel-Zonen-Analyse und Horizonterkennung
+- Mehrere Terrainquellen: **Copernicus GLO-30** (hochauflösendes DEM), **Open-Meteo API**, synthetischer Fallback und **Offline-DEM-Import** für den Betrieb ohne Internetverbindung
+- Aircraft-Scatter-Weganalyse verknüpft mit den Geländedaten
+
+Die Karte funktioniert in gepackten Umgebungen (AppImage, Flatpak) ohne Zugriff auf externe CDNs: Die Kartenkacheln werden über einen lokalen Tile-Proxy abgerufen, die Leaflet.js-Bibliothek ist in der Anwendung eingebettet.
+
+---
+
+## Optimierte Nachrichtenverarbeitung / 30.000-Nachrichten-Limit (ab v1.41)
+
+Die internen Chat- und Nachrichtentabellen sind auf **30.000 Einträge** begrenzt. Ältere Nachrichten werden automatisch verworfen, sobald das Limit erreicht wird. Damit bleiben Speicherverbrauch und Darstellungsperformance auch bei mehrtägigen Contest-Betrieb stabil.
+
+---
+
+## Bildschirmgerechte Fenstergröße (ab v1.41)
+
+Beim Programmstart berechnet KST4Contest eine bildschirmgerechte Startgröße für das Hauptfenster:
+
+- Die gespeicherte Fenstergröße aus der letzten Session wird verwendet – aber **niemals größer als der aktuelle Bildschirm**.
+- Wenn KST4Contest zuletzt auf einem größeren Monitor betrieben wurde, wird das Fenster automatisch auf die aktuelle Anzeige verkleinert.
+- Das UI-Layout ist **kompakter und reaktionsfähiger auf kleineren Bildschirmen**.
+
+Damit werden unbrauchbare, abgeschnittene Fenster beim Wechsel zwischen Geräten oder Monitoren verhindert.
diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md
index ea41bda..6b35b53 100644
--- a/github_docs/en-Changelog.md
+++ b/github_docs/en-Changelog.md
@@ -8,6 +8,18 @@ Version history of KST4Contest / PraktiKST.
For the latest changelog, please refer to GitHub. The previous changelog is below.
+## v1.41
+**Station Map, Performance, Responsive UI**
+
+**New:**
+- **Station Map**: Interactive OpenStreetMap-based map showing the geographic position of all active chat members. Includes station markers, antenna beam cone, connection line to the selected station, Maidenhead grid overlay, and a path profile chart with terrain elevation analysis (Fresnel zones, horizon detection). Terrain data from Copernicus GLO-30, Open-Meteo API, or offline DEM import. Aircraft scatter path analysis integrated. Works in AppImage and Flatpak without external CDN access (local tile proxy, bundled Leaflet.js).
+
+**Changed:**
+- **Message table limit raised to 30,000**: Chat and message tables are capped at 30,000 entries. Older messages are automatically discarded, keeping performance stable during multi-day contest operations.
+- **Screen-aware window sizing**: On startup, the main window is sized to fit the current screen. If KST4Contest was last used on a larger monitor, the window is automatically scaled down. The UI layout is more compact and responsive on smaller screens.
+
+---
+
## v1.40 (2026-02-16)
**Major Feature Release: Score System, AP Timeline, Win-Test, PSTRotator**
diff --git a/github_docs/en-Features.md b/github_docs/en-Features.md
index fe9c59c..d51ec46 100644
--- a/github_docs/en-Features.md
+++ b/github_docs/en-Features.md
@@ -233,3 +233,39 @@ File-based log evaluation using regex. Details: [Log Synchronisation](Log-Sync#m
## Cluster & QSO of Others
A separate window showing the QSO flow between other stations. Particularly interesting during quieter night-time hours of a contest. This window can be minimised when not needed. Future plan: filtering to stations in your selected QTF.
+
+---
+
+## Station Map (from v1.41)
+
+An interactive OpenStreetMap-based map showing the geographic position of all active chat members.
+
+**Features:**
+
+- Station markers with callsign labels, coloured by activity and sked state
+- Antenna **beam cone** visualisation for the own station
+- **Connection line** to the currently selected station
+- **Maidenhead grid** overlay (QRA locator grid)
+- **Path profile chart**: Terrain elevation cross-section between own station and the selected station, including Fresnel zone analysis and obstruction/horizon detection
+- Multiple terrain data sources: **Copernicus GLO-30** (high-resolution DEM), **Open-Meteo API**, synthetic fallback, and **offline DEM import** for air-gapped use
+- Aircraft scatter path analysis integrated with the terrain data
+
+The map works in packaged environments (AppImage, Flatpak) without internet access to external CDNs: map tiles are fetched via a local tile proxy, and the Leaflet.js library is bundled inside the application.
+
+---
+
+## Optimised Message Handling / 30,000 Message Limit (from v1.41)
+
+The internal chat and message tables are capped at **30,000 entries**. Older messages are automatically discarded when the limit is reached. This keeps memory usage and rendering performance stable during multi-day contest operations.
+
+---
+
+## Screen-Aware Window Sizing (from v1.41)
+
+On startup, KST4Contest calculates a screen-aware size for the main window:
+
+- The stored window size from the previous session is used – but **never larger than the current screen**.
+- If KST4Contest was last used on a larger monitor, the window is automatically scaled down to fit the current display without clipping.
+- The UI layout is more **compact and responsive on smaller screens**, showing the same information in less space.
+
+This prevents unusable oversized windows when switching between machines or monitors.
diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java
index 2388075..20de7ba 100644
--- a/src/main/java/kst4contest/controller/ChatController.java
+++ b/src/main/java/kst4contest/controller/ChatController.java
@@ -1077,6 +1077,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private DBController dbHandler;
+ private ReachabilityService reachabilityService;
+ private final WorkedGrossFieldCache workedGrossFieldCache = new WorkedGrossFieldCache();
+
+
private Socket socket;
private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client)
// private ServerSocketChannel cluster_telnetServerSocketChannel;
@@ -1201,12 +1205,26 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
- public void fireUserListUpdate(String reason) {
- if (statusListener != null) {
- // Da UI Updates im JavaFX Thread passieren müssen, hier oder im Listener Platform.runLater nutzen
- statusListener.onUserListUpdated(reason);
- }
- }
+ /**
+ * Notifies the UI that station-list derived values have changed.
+ *
+ *
This method may be called from parser threads, UDP listener threads and
+ * reachability worker threads. Therefore the listener is always invoked on the
+ * JavaFX application thread.
+ *
+ * @param reason short debug reason for the UI log
+ */
+ public void fireUserListUpdate(String reason) {
+ if (statusListener == null) {
+ return;
+ }
+
+ if (Platform.isFxApplicationThread()) {
+ statusListener.onUserListUpdated(reason);
+ } else {
+ Platform.runLater(() -> statusListener.onUserListUpdated(reason));
+ }
+ }
// /**
// * checks if the callsign-String of a given chatmember instance and a given list
@@ -1624,6 +1642,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
});
dbHandler = new DBController();
+ reachabilityService = new ReachabilityService(this);
+ rebuildWorkedGrossFieldCacheFromDatabase();
// chatPreferences = new ChatPreferences();
// chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted
@@ -2090,6 +2110,103 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
}
+
+ /**
+ * Returns the background reachability service used by the station table.
+ *
+ * @return reachability service
+ */
+ public ReachabilityService getReachabilityService() {
+ return reachabilityService;
+ }
+
+ /**
+ * Returns the runtime gross-field cache used by the new-locator filter.
+ *
+ * @return worked gross-field cache
+ */
+ public WorkedGrossFieldCache getWorkedGrossFieldCache() {
+ return workedGrossFieldCache;
+ }
+
+ /**
+ * Rebuilds the gross-field cache from persistent SQLite data and enriches it with
+ * legacy ChatMember worked/qra rows where possible.
+ */
+ public void rebuildWorkedGrossFieldCacheFromDatabase() {
+ if (dbHandler == null) {
+ return;
+ }
+
+ try {
+ workedGrossFieldCache.rebuildFromDatabaseSnapshot(dbHandler.fetchWorkedGrossFieldsFromDB());
+ workedGrossFieldCache.addWorkedBandsFromStoredChatMembers(dbHandler.fetchChatMemberWkdDataFromDB().values());
+ } catch (Exception exception) {
+ System.out.println("[ChatController, warning]: could not rebuild worked gross-field cache: "
+ + exception.getMessage());
+ }
+ }
+
+ /**
+ * Persists and caches one worked gross field after a logger QSO packet.
+ *
+ * @param band worked band
+ * @param locator6 six-character Maidenhead locator
+ * @param workedCall worked station
+ * @param source logger/source name
+ */
+ public void registerWorkedGrossField(Band band, String locator6, ChatMember workedCall, String source) {
+ if (band == null || locator6 == null) {
+ return;
+ }
+
+ String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);
+ if (normalizedLocator6 == null) {
+ return;
+ }
+
+ String callSignRaw = workedCall == null ? null : workedCall.getCallSignRaw();
+
+ try {
+ dbHandler.upsertWorkedGrossField(band, normalizedLocator6, callSignRaw, source);
+ } catch (Exception exception) {
+ System.out.println("[ChatController, warning]: could not persist worked gross field: "
+ + exception.getMessage());
+ }
+
+ workedGrossFieldCache.addWorked(band, normalizedLocator6);
+ fireUserListUpdate("Worked gross field updated");
+ }
+
+ /**
+ * Returns true when the member's gross field is not worked on at least one enabled
+ * station band. This is the predicate behind the "new locator" filter.
+ *
+ * @param member member to inspect
+ * @return true if the station is still a new locator on any enabled band
+ */
+ public boolean isNewLocatorOnAnyEnabledBand(ChatMember member) {
+ if (member == null || member.getQra() == null) {
+ return false;
+ }
+
+ EnumSet enabledBands = reachabilityService == null
+ ? getMyEnabledBandsFromPrefs(chatPreferences)
+ : reachabilityService.getEnabledStationBands();
+
+ if (enabledBands.isEmpty()) {
+ return false;
+ }
+
+ for (Band band : enabledBands) {
+ if (!workedGrossFieldCache.isGrossFieldWorked(band, member.getQra())) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public long getCurrentEpochTime() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
@@ -2368,6 +2485,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return;
}
+ rebuildWorkedGrossFieldCacheFromDatabase();
+
HashMap finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> {
diff --git a/src/main/java/kst4contest/controller/DBController.java b/src/main/java/kst4contest/controller/DBController.java
index b7184dc..3ec64e4 100644
--- a/src/main/java/kst4contest/controller/DBController.java
+++ b/src/main/java/kst4contest/controller/DBController.java
@@ -14,6 +14,12 @@ import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember;
import kst4contest.utils.ApplicationFileUtils;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Set;
+
+import kst4contest.model.Band;
+
public class DBController {
/**
@@ -37,7 +43,7 @@ public class DBController {
* marker. The marker is stored in SQLite PRAGMA user_version so the expensive
* normalization rebuild is executed only once per database file.
*/
- private static final int CURRENT_DATABASE_SCHEMA_VERSION = 13;
+ private static final int CURRENT_DATABASE_SCHEMA_VERSION = 14;
/**
* Minimum interval between two expiration cleanup runs. This avoids repeated full
@@ -136,6 +142,7 @@ public class DBController {
*/
private synchronized void ensureChatMemberTableCompatibility() {
createChatMemberTableIfRequired();
+ createWorkedGrossFieldTableIfRequired();
versionUpdateOfDBCheckAndChangeV11ToV12();
versionUpdateOfDBCheckAndChangeV12ToV13();
@@ -240,6 +247,30 @@ public class DBController {
}
}
+ /**
+ * Creates the persistent gross-field table used by the new-locator filter.
+ * The primary key is band + gross field because the filter only needs to know
+ * whether a large locator square has already been worked on a band.
+ */
+ private synchronized void createWorkedGrossFieldTableIfRequired() {
+
+ String createTableSql =
+ "CREATE TABLE IF NOT EXISTS WorkedGrossField ("
+ + "band TEXT NOT NULL, "
+ + "grossField TEXT NOT NULL, "
+ + "locator TEXT, "
+ + "callsign TEXT, "
+ + "source TEXT, "
+ + "lastWorkedEpochMs INTEGER NOT NULL, "
+ + "PRIMARY KEY (band, grossField)"
+ + ");";
+
+ try (Statement statement = connection.createStatement()) {
+ statement.executeUpdate(createTableSql);
+ } catch (SQLException e) {
+ throw new RuntimeException("[DBH, ERROR:] Could not create WorkedGrossField table", e);
+ }
+ }
/**
* Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if
* they are missing.
@@ -477,7 +508,15 @@ public class DBController {
} catch (SQLException e) {
throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", e);
}
- }
+
+ try (PreparedStatement deleteGrossFieldsStatement = connection.prepareStatement(
+ "DELETE FROM WorkedGrossField WHERE lastWorkedEpochMs > 0 AND lastWorkedEpochMs < ?;")) {
+ deleteGrossFieldsStatement.setLong(1, expirationThresholdEpochMs);
+ deleteGrossFieldsStatement.executeUpdate();
+ } catch (SQLException e) {
+ throw new RuntimeException(e);
+ }
+ }
/**
* Stores a chatmember with its metadata in the database. The unique key is always
@@ -612,8 +651,14 @@ public class DBController {
+ "lastFlagsChangeEpochMs = 0;";
try (Statement statement = connection.createStatement()) {
- return statement.executeUpdate(resetAllWorkedDataSql);
+// return statement.executeUpdate(resetAllWorkedDataSql);
+ int affectedRows = statement.executeUpdate(resetAllWorkedDataSql);
+ statement.executeUpdate("DELETE FROM WorkedGrossField;");
+ return affectedRows;
+
+
} catch (SQLException e) {
+
System.err.println("[DBH, ERROR:] Couldn't reset the worked data");
e.printStackTrace();
return -1;
@@ -641,12 +686,27 @@ public class DBController {
return false;
}
+// String updateWorkedSql =
+// "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
+
String updateWorkedSql =
- "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
+ "UPDATE ChatMember SET worked = 1, "
+ + workedBandColumnName
+ + " = 1, "
+ + "qra = CASE WHEN ? IS NOT NULL AND TRIM(?) <> '' AND LOWER(TRIM(?)) <> 'unknown' THEN ? ELSE qra END, "
+ + "lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) {
- preparedStatement.setLong(1, System.currentTimeMillis());
- preparedStatement.setString(2, chatMemberToStore.getCallSignRaw());
+// preparedStatement.setLong(1, System.currentTimeMillis());
+// preparedStatement.setString(2, chatMemberToStore.getCallSignRaw());
+
+ String qra = chatMemberToStore.getQra();
+ preparedStatement.setString(1, qra);
+ preparedStatement.setString(2, qra);
+ preparedStatement.setString(3, qra);
+ preparedStatement.setString(4, qra);
+ preparedStatement.setLong(5, System.currentTimeMillis());
+ preparedStatement.setString(6, chatMemberToStore.getCallSignRaw());
int affectedRows = preparedStatement.executeUpdate();
return affectedRows > 0;
@@ -955,4 +1015,79 @@ public class DBController {
// dbc.storeChatMember(dummy);
// dbc.updateWkdInfoOnChatMember(dummy);
}
+
+ /**
+ * Inserts or updates a worked gross field for the new-locator filter.
+ *
+ * @param band worked band
+ * @param locator6 six-character Maidenhead locator
+ * @param callsignRaw normalized raw callsign or null
+ * @param source source identifier such as UCXLOG or WINTEST
+ */
+ public synchronized void upsertWorkedGrossField(Band band, String locator6, String callsignRaw, String source) {
+
+ String grossField = WorkedGrossFieldCache.extractGrossField(locator6);
+ String normalizedLocator6 = WorkedGrossFieldCache.extractLocator6(locator6);
+
+ if (band == null || grossField == null) {
+ return;
+ }
+
+ String upsertSql =
+ "INSERT INTO WorkedGrossField (band, grossField, locator, callsign, source, lastWorkedEpochMs) "
+ + "VALUES (?, ?, ?, ?, ?, ?) "
+ + "ON CONFLICT(band, grossField) DO UPDATE SET "
+ + "locator = COALESCE(excluded.locator, WorkedGrossField.locator), "
+ + "callsign = COALESCE(excluded.callsign, WorkedGrossField.callsign), "
+ + "source = excluded.source, "
+ + "lastWorkedEpochMs = excluded.lastWorkedEpochMs;";
+
+ try (PreparedStatement preparedStatement = connection.prepareStatement(upsertSql)) {
+ preparedStatement.setString(1, band.name());
+ preparedStatement.setString(2, grossField);
+ preparedStatement.setString(3, normalizedLocator6);
+ preparedStatement.setString(4, callsignRaw);
+ preparedStatement.setString(5, source == null ? "UNKNOWN" : source);
+ preparedStatement.setLong(6, System.currentTimeMillis());
+ preparedStatement.executeUpdate();
+ } catch (SQLException e) {
+ throw new RuntimeException("[DBH, ERROR:] Could not upsert worked gross field", e);
+ }
+ }
+
+ /**
+ * Loads all non-expired worked gross fields from the database.
+ *
+ * @return map of band to worked gross fields
+ */
+ public synchronized Map> fetchWorkedGrossFieldsFromDB() {
+
+ resetExpiredWorkedDataIfRequired();
+
+ Map> result = new EnumMap<>(Band.class);
+
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery("SELECT band, grossField FROM WorkedGrossField ORDER BY band, grossField;")) {
+
+ while (resultSet.next()) {
+ Band band;
+ try {
+ band = Band.valueOf(resultSet.getString("band"));
+ } catch (Exception ignored) {
+ continue;
+ }
+
+ String grossField = WorkedGrossFieldCache.extractGrossField(resultSet.getString("grossField"));
+ if (grossField == null) {
+ continue;
+ }
+
+ result.computeIfAbsent(band, ignored -> new HashSet<>()).add(grossField);
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("[DBH, ERROR:] Could not fetch worked gross fields", e);
+ }
+
+ return result;
+ }
}
diff --git a/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
index eba1e07..3d74925 100644
--- a/src/main/java/kst4contest/controller/MessageBusManagementThread.java
+++ b/src/main/java/kst4contest/controller/MessageBusManagementThread.java
@@ -327,6 +327,8 @@ public class MessageBusManagementThread extends Thread {
// 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 {
@@ -335,6 +337,8 @@ public class MessageBusManagementThread extends Thread {
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) {
@@ -587,6 +591,8 @@ 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.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
+ // Reachability is calculated on demand only: map click, selected station, or manual request.
}
@@ -632,6 +638,7 @@ public class MessageBusManagementThread extends Thread {
newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember);
+// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(newMember);
}
@@ -1754,190 +1761,6 @@ public class MessageBusManagementThread extends Thread {
break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
// client.getMessageRXBus().clear();
}
- {
-// System.out.println("MessagebusmgtThread: Readthread is interrupted! Queue will be resetted");
-// this.interrupt();
-// client.getMessageRXBus().clear();
- }
-
-// if (client.getMessageRXBus().peek() == null) {
-//
-// Timer doNothingTimer = new Timer();
-// doNothingTimer.schedule(new TimerTask() {
-//
-// @Override
-// public void run() {
-//
-// //do nothing
-//
-// }
-// }, 100);// TODO: Temporary
-// }
-//
-//
-// if (client.getMessageRXBus().peek() == null && client.getMessageTXBus().peek() == null) {
-//
-// if (this.client.isDisconnectionPerformedByUser()) {
-// break;//TODO: what if it´s not the finally closage but a band channel change?
-// }
-// // do nothing
-//// try {
-//// this.sleep(20);
-//// } catch (InterruptedException e) {
-//// // TODO Auto-generated catch block
-//// e.printStackTrace();
-//// } catch (Exception e2) {
-//// // TODO Auto-generated catch block
-//// e2.printStackTrace();
-//// }
-// }
-// else
- {
-
-// messageLine = messageTextRaw.getMessageText();
-//
-// /***********************************************
-// * CASE RX
-// ***********************************************/
-//
-//// if (client.getMessageRXBus().peek() != null) {
-//
-//// try {
-//// messageTextRaw = client.getMessageRXBus().take();
-////
-////// System.out.println("MSBGBUS: rxed: " + messageTextRaw);
-//// } catch (InterruptedException e) {
-//// // TODO Auto-generated catch block
-//// e.printStackTrace();
-//// }
-//
-// if (messageTextRaw.getMessageText() == null) {
-// System.out.println("[MSGBUSMGT:] ERROR! got NULL message! BYE!");
-//// this.interrupt();
-//// break;
-// }
-//
-// messageLine = messageTextRaw.getMessageText();
-//
-//// try {
-//// bufwrtrRawMSGOut.write(messageLine + "\n");
-//// bufwrtrRawMSGOut.flush();
-////
-//// } catch (IOException e) {
-//// // TODO Auto-generated catch block
-//// e.printStackTrace();
-//// }
-//
-// System.out.println(messageTextRaw.getMessageText() + " <- RXed"); // Stdout at
-// // Console#######################################################TODO:Wichtig
-//
-// try {
-// processRXMessage23001(messageTextRaw);
-// } catch (IOException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// } catch (SQLException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-
-// } //end peek != null
-
- /**************************************************************
- * End of case RX
- **************************************************************/
-
- /**************************************************************
- * Start of case TX
- **************************************************************/
-
-// if (client.getMessageTXBus().peek() != null) {
-// /***********************************************
-// * CASE TX
-// ***********************************************/
-//
-// if (this.isServerready()) {
-// // then send the line
-//
-// try {
-// messageTextRaw = client.getMessageTXBus().take();
-//// this.setServerready(false); // after tx always wait for an answer prompt //23000
-// this.setServerready(true);
-//
-// } catch (InterruptedException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-//
-// messageLine = messageTextRaw.getMessageText();
-//
-// if (messageTextRaw.isMessageDirectedToServer()) {
-// /**
-// * We have to check if we only commands the server (keepalive) or want do talk
-// * to the community
-// */
-//
-// try {
-// client.getWriteThread().tx(messageTextRaw);
-// System.out.println("BUS: tx: " + messageTextRaw.getMessageText());
-//
-// } catch (InterruptedException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-//
-// //////////////////////// bgfx ab here//////////////////////////////
-//// try {
-//// bufwrtrRawMSGOut.write(messageLine + "\r");
-////// bw.write(messageLine + "\n");//kst4contest.test 4 23001
-//// bufwrtrRawMSGOut.flush();
-////
-//// } catch (IOException e) {
-//// // TODO Auto-generated catch block
-//// e.printStackTrace();
-//// }
-// ///////////////////////////////////////////////////////////////////
-// } else {
-//
-// ChatMessage ownMSG = new ChatMessage();
-//
-//// ownMSG.setMessageText(
-//// "MSG|" + this.client.getCategory().getCategoryNumber() + "|0|" + messageLine + "|0|");
-//
-// ownMSG.setMessageText(
-// "MSG|" + this.client.getChatPreferences().getLoginChatCategory().getCategoryNumber()
-// + "|0|" + messageLine + "|0|");
-//
-// try {
-// client.getWriteThread().tx(ownMSG);
-// System.out.println("BUS: tx: " + ownMSG.getMessageText());
-//
-// } catch (InterruptedException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-// }
-//
-// if (messageTextRaw.equals("/QUIT")) {
-// try {
-// this.client.getReadThread().terminateConnection();
-// this.client.getReadThread().interrupt();
-// this.client.getWriteThread().terminateConnection();
-// this.client.getWriteThread().interrupt();
-// this.interrupt();
-//
-// } catch (IOException e) {
-// // TODO Auto-generated catch block
-// e.printStackTrace();
-// }
-// }
-//
-// } else {
-//// System.out.println("msgbus no elements yet");
-// }
-// } //end tx.peek != null
- }
-
} // while true end
System.out.println("Msgbusmgt: interrupt");
diff --git a/src/main/java/kst4contest/controller/ReachabilityService.java b/src/main/java/kst4contest/controller/ReachabilityService.java
new file mode 100644
index 0000000..f4ce9f7
--- /dev/null
+++ b/src/main/java/kst4contest/controller/ReachabilityService.java
@@ -0,0 +1,657 @@
+package kst4contest.controller;
+import kst4contest.view.map.MapCallsignRawSnapshot;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import javafx.application.Platform;
+import kst4contest.locatorUtils.Location;
+import kst4contest.model.Band;
+import kst4contest.model.ChatCategory;
+import kst4contest.model.ChatMember;
+import kst4contest.model.ChatPreferences;
+import kst4contest.view.map.GeometryOnlyPathAnalysisService;
+
+import kst4contest.view.map.OpenMeteoTerrainProfileProvider;
+import kst4contest.view.map.PathAnalysisRequest;
+import kst4contest.view.map.PathAnalysisResult;
+import kst4contest.view.map.PathAnalysisService;
+import kst4contest.view.map.PathGeometryUtils;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.function.Consumer;
+
+/**
+ * Central service for tropo/path reachability calculations.
+ *
+ * This service is now the single calculation path for:
+ *
+ * - the station table Tropo column
+ * - the Tropo filter/sorter
+ * - the station map path-analysis detail panel
+ *
+ *
+ * The service always calculates a full {@link PathAnalysisResult}. When the
+ * result contains a usable link budget, the bidirectional SSB margin is copied
+ * into all matching {@link ChatMember} objects. The UI can then sort/filter by
+ * a simple number while the map still receives the full path result.
+ */
+public final class ReachabilityService {
+
+ private static final long FAILED_ANALYSIS_RETRY_DELAY_MS = 5L * 60L * 1000L;
+
+ private final ChatController chatController;
+ private final PathAnalysisService pathAnalysisService;
+ private final ExecutorService executor;
+
+ /**
+ * Prevents duplicate jobs for the same path while a calculation is already
+ * queued or running.
+ */
+ private final Set queuedCalculationKeys = ConcurrentHashMap.newKeySet();
+
+
+
+ /**
+ * Stores completed usable path-analysis results. Failed/no-budget results are
+ * not permanently cached so transient terrain/network problems can be retried.
+ */
+ private final Map pathAnalysisResultCache = new ConcurrentHashMap<>();
+
+ /**
+ * Temporary callback list for map requests waiting for an already running
+ * background calculation.
+ */
+ private final Map>> pendingMapCallbacksByKey = new ConcurrentHashMap<>();
+
+ /**
+ * Prevents immediate retry loops for failed/no-budget analyses, especially
+ * when a TableView cell repeatedly asks for the same value.
+ */
+ private final Map failedCalculationRetryAfterEpochMs = new ConcurrentHashMap<>();
+
+ public ReachabilityService(ChatController chatController) {
+ this.chatController = Objects.requireNonNull(chatController, "chatController");
+ this.pathAnalysisService = new GeometryOnlyPathAnalysisService(new OpenMeteoTerrainProfileProvider());
+ this.executor = Executors.newSingleThreadExecutor(new ReachabilityThreadFactory());
+ }
+
+ /**
+ * Ensures that the automatically selected reachability band is calculated.
+ *
+ * @param member chatmember to evaluate
+ */
+ public void ensureAutoTropoMarginCalculated(ChatMember member) {
+ ensureTropoMarginCalculated(member, resolveAutoBand(member));
+ }
+
+ /**
+ * Ensures that a station/band SSB-margin value exists if it is already cached.
+ *
+ * This method deliberately does not start online terrain analysis anymore.
+ * It is safe to call from table cells and filters because it will not consume
+ * API quota. Full calculations are now started only by explicit map/manual
+ * requests.
+ *
+ * @param member chatmember to evaluate
+ * @param band band to evaluate
+ */
+ public void ensureTropoMarginCalculated(ChatMember member, Band band) {
+ if (member == null || band == null) {
+ return;
+ }
+
+ if (member.hasFiniteTropoSsbMarginDb(band)) {
+ return;
+ }
+
+ PathAnalysisRequest request = buildRequestForChatMember(member, band);
+ if (request == null) {
+ return;
+ }
+
+ PathAnalysisResult cachedResult = pathAnalysisResultCache.get(buildCalculationKey(request));
+ if (cachedResult != null) {
+ storePathAnalysisResultInChatMembers(member, band, cachedResult);
+ }
+ }
+
+
+
+ /**
+ * Requests a full path analysis for the station map.
+ *
+ * This is the only normal automatic entry point for online/full terrain
+ * analysis. The map needs the full PathAnalysisResult, and the station table
+ * needs the SSB margin. Both are produced here through the same calculation.
+ *
+ * @param member best matching ChatMember, may be null when only a map snapshot exists
+ * @param selectedSnapshot selected map snapshot
+ * @param fxCallback callback executed on the JavaFX thread
+ */
+ public void requestPathAnalysisForMap(ChatMember member,
+ MapCallsignRawSnapshot selectedSnapshot,
+ Consumer fxCallback) {
+
+ String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
+
+ if (selectedSnapshot == null) {
+ dispatchFxCallback(fxCallback, PathAnalysisResult.waitingForSelection(ownLocator6));
+ return;
+ }
+
+ String targetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
+
+ if (ownLocator6.length() != 6) {
+ dispatchFxCallback(fxCallback,
+ PathAnalysisResult.waitingForValidHomeLocator(ownLocator6, targetLocator6));
+ return;
+ }
+
+ if (!selectedSnapshot.hasUsablePosition()) {
+ dispatchFxCallback(fxCallback,
+ PathAnalysisResult.waitingForValidTarget(ownLocator6, targetLocator6));
+ return;
+ }
+
+ double analysisFrequencyMHz = PathGeometryUtils.resolveAnalysisFrequencyMHz(
+ selectedSnapshot.lastKnownFrequenciesByBand()
+ );
+
+ if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
+ Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
+ analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, fallbackBand);
+ }
+
+ Band analysisBand = Band.fromFrequency(analysisFrequencyMHz);
+ if (analysisBand == null) {
+ analysisBand = member == null ? Band.B_144 : resolveAutoBand(member);
+ }
+
+ PathAnalysisRequest request = buildRequest(
+ ownLocator6,
+ selectedSnapshot.callSignRaw(),
+ targetLocator6,
+ selectedSnapshot.latitudeDeg(),
+ selectedSnapshot.longitudeDeg(),
+ analysisFrequencyMHz
+ );
+
+ requestPathAnalysisAndStore(member, analysisBand, request, fxCallback);
+ }
+
+
+
+ /**
+ * Starts a full on-demand reachability calculation for one station row.
+ *
+ * Use this for explicit operator actions only. It may call the online terrain
+ * API and therefore must not be used from TableView cell factories or automatic
+ * chat-join processing.
+ *
+ * @param member station to calculate
+ * @param band selected reachability band
+ */
+ public void calculateSelectedStationOnDemand(ChatMember member, Band band) {
+ if (member == null || band == null) {
+ return;
+ }
+
+ PathAnalysisRequest request = buildRequestForChatMember(member, band);
+ if (request == null) {
+ member.setTropoSsbMarginDb(band, Double.NaN);
+ chatController.fireUserListUpdate("Reachability unavailable");
+ return;
+ }
+
+ requestPathAnalysisAndStore(member, band, request, null);
+ }
+
+ /**
+ * Resolves the auto reachability band.
+ *
+ *
+ * - Use the lowest band detected in this session.
+ * - If no session band exists and the station is in the microwave category, use 1296 MHz.
+ * - Otherwise use 144 MHz.
+ *
+ *
+ * @param member member to inspect
+ * @return resolved band
+ */
+ public Band resolveAutoBand(ChatMember member) {
+ if (member != null && member.getKnownActiveBands() != null && !member.getKnownActiveBands().isEmpty()) {
+ return member.getKnownActiveBands().keySet().stream()
+ .filter(Objects::nonNull)
+ .min(Comparator.comparingDouble(Band::getDefaultAnalysisFrequencyMHz))
+ .orElse(Band.B_144);
+ }
+
+ if (member != null
+ && member.getChatCategory() != null
+ && member.getChatCategory().getCategoryNumber() == ChatCategory.MICROWAVE) {
+ return Band.B_1296;
+ }
+
+ return Band.B_144;
+ }
+
+ /**
+ * Returns the active own bands configured in the station preferences. High bands
+ * above 10 GHz are intentionally ignored for the first version.
+ *
+ * @return set of enabled bands
+ */
+ public EnumSet getEnabledStationBands() {
+ ChatPreferences preferences = chatController.getChatPreferences();
+ EnumSet enabledBands = EnumSet.noneOf(Band.class);
+
+ if (preferences == null) {
+ return enabledBands;
+ }
+
+ if (preferences.isStn_bandActive144()) enabledBands.add(Band.B_144);
+ if (preferences.isStn_bandActive432()) enabledBands.add(Band.B_432);
+ if (preferences.isStn_bandActive1240()) enabledBands.add(Band.B_1296);
+ if (preferences.isStn_bandActive2300()) enabledBands.add(Band.B_2320);
+ if (preferences.isStn_bandActive3400()) enabledBands.add(Band.B_3400);
+ if (preferences.isStn_bandActive5600()) enabledBands.add(Band.B_5760);
+ if (preferences.isStn_bandActive10G()) enabledBands.add(Band.B_10G);
+
+ return enabledBands;
+ }
+
+ /**
+ * Stops the background executor.
+ */
+ public void shutdown() {
+ executor.shutdownNow();
+ }
+
+ /**
+ * Starts or reuses one shared path-analysis calculation.
+ *
+ * @param member matching ChatMember, may be null for map-only snapshots
+ * @param band band under which the value is stored in ChatMember
+ * @param request full path-analysis request
+ * @param fxCallback optional map callback
+ */
+ private void requestPathAnalysisAndStore(ChatMember member,
+ Band band,
+ PathAnalysisRequest request,
+ Consumer fxCallback) {
+
+ if (request == null || band == null) {
+ return;
+ }
+
+ String calculationKey = buildCalculationKey(request);
+
+ PathAnalysisResult cachedResult = pathAnalysisResultCache.get(calculationKey);
+ if (cachedResult != null) {
+ storePathAnalysisResultInChatMembers(member, band, cachedResult);
+ dispatchFxCallback(fxCallback, cachedResult);
+ return;
+ }
+
+ Long retryAfterEpochMs = failedCalculationRetryAfterEpochMs.get(calculationKey);
+ if (retryAfterEpochMs != null && System.currentTimeMillis() < retryAfterEpochMs) {
+ dispatchFxCallback(fxCallback, createNoProfileResult(
+ request,
+ "Previous path analysis failed or the terrain API limit was reached. Retry is delayed briefly."
+ ));
+ return;
+ }
+
+ addPendingCallback(calculationKey, fxCallback);
+
+ if (!queuedCalculationKeys.add(calculationKey)) {
+ return;
+ }
+
+ executor.submit(() -> {
+ PathAnalysisResult result;
+
+ try {
+ result = pathAnalysisService.analyze(request);
+ if (result == null) {
+ result = createNoProfileResult(request, "Path analysis returned no result.");
+ }
+ } catch (Exception exception) {
+ result = createNoProfileResult(
+ request,
+ "Path analysis failed: " + exception.getMessage()
+ );
+ }
+
+ boolean usableBudget = hasUsableLinkBudget(result);
+
+ if (usableBudget) {
+ pathAnalysisResultCache.put(calculationKey, result);
+ failedCalculationRetryAfterEpochMs.remove(calculationKey);
+ } else {
+ failedCalculationRetryAfterEpochMs.put(
+ calculationKey,
+ System.currentTimeMillis() + FAILED_ANALYSIS_RETRY_DELAY_MS
+ );
+ }
+
+ queuedCalculationKeys.remove(calculationKey);
+
+ PathAnalysisResult finalResult = result;
+ Runnable updateTask = () -> {
+ storePathAnalysisResultInChatMembers(member, band, finalResult);
+ dispatchAndClearPendingCallbacks(calculationKey, finalResult);
+ chatController.fireUserListUpdate("Reachability calculated");
+ };
+
+ if (Platform.isFxApplicationThread()) {
+ updateTask.run();
+ } else {
+ Platform.runLater(updateTask);
+ }
+ });
+ }
+
+ /**
+ * Builds a path-analysis request from a ChatMember row.
+ *
+ * @param member station row
+ * @param band selected reachability band
+ * @return request or null if locators are not usable
+ */
+ private PathAnalysisRequest buildRequestForChatMember(ChatMember member, Band band) {
+ String ownLocator6 = normalizeLocator6(chatController.getChatPreferences().getStn_loginLocatorMainCat());
+ String targetLocator6 = normalizeLocator6(member.getQra());
+
+ if (ownLocator6.length() != 6 || targetLocator6.length() != 6) {
+ return null;
+ }
+
+ Location targetLocation = new Location(targetLocator6);
+ double analysisFrequencyMHz = resolveAnalysisFrequencyForBand(member, band);
+
+ return buildRequest(
+ ownLocator6,
+ member.getCallSignRaw(),
+ targetLocator6,
+ targetLocation.getLatitude().toDegrees(),
+ targetLocation.getLongitude().toDegrees(),
+ analysisFrequencyMHz
+ );
+ }
+
+ /**
+ * Builds the shared PathAnalysisRequest used by map and table/manual requests.
+ */
+ private PathAnalysisRequest buildRequest(String ownLocator6,
+ String targetCallsignRaw,
+ String targetLocator6,
+ double targetLatitudeDeg,
+ double targetLongitudeDeg,
+ double analysisFrequencyMHz) {
+
+ Location homeLocation = new Location(ownLocator6);
+
+ return new PathAnalysisRequest(
+ ownLocator6,
+ homeLocation.getLatitude().toDegrees(),
+ homeLocation.getLongitude().toDegrees(),
+ targetCallsignRaw,
+ targetLocator6,
+ targetLatitudeDeg,
+ targetLongitudeDeg,
+ analysisFrequencyMHz,
+ chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
+ chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
+ PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
+ chatController.getChatPreferences().buildPathLinkBudgetSettings()
+ );
+ }
+
+ /**
+ * Stores the SSB margin from a completed analysis in all matching ChatMember
+ * objects. Matching uses raw callsign and locator.
+ */
+ private void storePathAnalysisResultInChatMembers(ChatMember primaryMember,
+ Band band,
+ PathAnalysisResult result) {
+
+ if (band == null || result == null) {
+ return;
+ }
+
+ double marginDb = hasUsableLinkBudget(result)
+ ? result.linkBudgetSummary().bidirectionalSsbMarginDb()
+ : Double.NaN;
+
+ if (primaryMember != null) {
+ primaryMember.setTropoSsbMarginDb(band, marginDb);
+ }
+
+ String resultCallSignRaw = normalizeCallsignRaw(result.toCallsignRaw());
+ String resultLocator6 = normalizeLocator6(result.toLocator6());
+
+ for (ChatMember member : chatController.snapshotChatMembers()) {
+ if (member == null) {
+ continue;
+ }
+
+ String memberCallSignRaw = normalizeCallsignRaw(member.getCallSignRaw());
+ if (!resultCallSignRaw.isBlank() && !resultCallSignRaw.equals(memberCallSignRaw)) {
+ continue;
+ }
+
+ String memberLocator6 = normalizeLocator6(member.getQra());
+ if (!resultLocator6.isBlank() && !resultLocator6.equals(memberLocator6)) {
+ continue;
+ }
+
+ member.setTropoSsbMarginDb(band, marginDb);
+ }
+ }
+
+ /**
+ * Resolves the analysis frequency from a map snapshot first, because the map
+ * aggregates all visible ChatMember variants and often knows the best current
+ * frequency per band.
+ *
+ * @param member fallback member
+ * @param selectedSnapshot selected map snapshot
+ * @return analysis frequency in MHz
+ */
+ private double resolveAnalysisFrequencyForSnapshot(ChatMember member, MapCallsignRawSnapshot selectedSnapshot) {
+ if (selectedSnapshot != null) {
+ double snapshotFrequencyMHz =
+ PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
+
+ if (Double.isFinite(snapshotFrequencyMHz) && snapshotFrequencyMHz > 0.0) {
+ return snapshotFrequencyMHz;
+ }
+ }
+
+ Band fallbackBand = member == null ? Band.B_144 : resolveAutoBand(member);
+ return resolveAnalysisFrequencyForBand(member, fallbackBand);
+ }
+
+ /**
+ * Resolves the analysis frequency for one member/band pair.
+ *
+ * Preference order:
+ *
+ * - knownActiveBands frequency for the band
+ * - current displayed QRG if it belongs to the same band
+ * - band default frequency
+ *
+ */
+ private double resolveAnalysisFrequencyForBand(ChatMember member, Band band) {
+ if (member != null && member.getKnownActiveBands() != null) {
+ ChatMember.ActiveFrequencyInfo activeFrequencyInfo = member.getKnownActiveBands().get(band);
+ if (activeFrequencyInfo != null
+ && Double.isFinite(activeFrequencyInfo.frequency)
+ && activeFrequencyInfo.frequency > 0.0) {
+ return activeFrequencyInfo.frequency;
+ }
+ }
+
+ if (member != null && member.getFrequency() != null && member.getFrequency().getValue() != null) {
+ double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(member.getFrequency().getValue());
+ if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
+ Band parsedBand = Band.fromFrequency(parsedFrequencyMHz);
+ if (parsedBand == null || parsedBand == band) {
+ return parsedFrequencyMHz;
+ }
+ }
+ }
+
+ return band.getDefaultAnalysisFrequencyMHz();
+ }
+
+ /**
+ * Adds a map callback to the pending callback list for one calculation key.
+ *
+ * @param calculationKey path key
+ * @param fxCallback callback to add
+ */
+ private void addPendingCallback(String calculationKey, Consumer fxCallback) {
+ if (fxCallback == null) {
+ return;
+ }
+
+ pendingMapCallbacksByKey
+ .computeIfAbsent(
+ calculationKey,
+ ignored -> Collections.synchronizedList(new ArrayList<>())
+ )
+ .add(fxCallback);
+ }
+
+ /**
+ * Dispatches and removes all callbacks waiting for one completed calculation.
+ *
+ * @param calculationKey path key
+ * @param result completed result
+ */
+ private void dispatchAndClearPendingCallbacks(String calculationKey, PathAnalysisResult result) {
+ List> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
+ if (callbacks == null || callbacks.isEmpty()) {
+ return;
+ }
+
+ List> callbackSnapshot;
+ synchronized (callbacks) {
+ callbackSnapshot = new ArrayList<>(callbacks);
+ }
+
+ for (Consumer callback : callbackSnapshot) {
+ dispatchFxCallback(callback, result);
+ }
+ }
+
+ /**
+ * Executes a map callback on the JavaFX application thread.
+ *
+ * @param fxCallback callback to execute
+ * @param result result to pass
+ */
+ private void dispatchFxCallback(Consumer fxCallback, PathAnalysisResult result) {
+ if (fxCallback == null) {
+ return;
+ }
+
+ if (Platform.isFxApplicationThread()) {
+ fxCallback.accept(result);
+ } else {
+ Platform.runLater(() -> fxCallback.accept(result));
+ }
+ }
+
+ /**
+ * Checks whether the result contains a usable link-budget summary.
+ *
+ * @param result path-analysis result
+ * @return true if SSB margin can be read
+ */
+ private boolean hasUsableLinkBudget(PathAnalysisResult result) {
+ return result != null
+ && result.linkBudgetSummary() != null
+ && result.linkBudgetSummary().hasUsableBudget();
+ }
+
+ /**
+ * Creates a no-profile/no-budget result for failed service calculations.
+ *
+ * @param request original request
+ * @param statusText status shown in the map detail panel
+ * @return placeholder path result
+ */
+ private PathAnalysisResult createNoProfileResult(PathAnalysisRequest request, String statusText) {
+ return PathAnalysisResult.noProfile(
+ "Reachability",
+ request.fromLocator6(),
+ request.toLocator6(),
+ request.toCallsignRaw(),
+ Double.NaN,
+ Double.NaN,
+ request.homeAntennaHeightMeters(),
+ request.targetAntennaHeightMeters(),
+ request.frequencyMHz(),
+ statusText
+ );
+ }
+
+ /**
+ * Builds a stable calculation key.
+ *
+ * The key includes station, locators, frequency, antenna heights and link
+ * budget settings. If any relevant input changes, a new calculation is allowed.
+ */
+ private String buildCalculationKey(PathAnalysisRequest request) {
+ return normalizeLocator6(request.fromLocator6())
+ + "|"
+ + normalizeCallsignRaw(request.toCallsignRaw())
+ + "|"
+ + normalizeLocator6(request.toLocator6())
+ + "|"
+ + String.format(Locale.US, "%.5f", request.toLatitudeDeg())
+ + "|"
+ + String.format(Locale.US, "%.5f", request.toLongitudeDeg())
+ + "|"
+ + String.format(Locale.US, "%.3f", request.frequencyMHz())
+ + "|"
+ + String.format(Locale.US, "%.1f", request.homeAntennaHeightMeters())
+ + "|"
+ + String.format(Locale.US, "%.1f", request.targetAntennaHeightMeters())
+ + "|"
+ + request.linkBudgetSettings();
+ }
+
+ private String normalizeCallsignRaw(String callSignRaw) {
+ return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private String normalizeLocator6(String locator) {
+ return locator == null ? "" : locator.trim().toUpperCase(Locale.ROOT);
+ }
+
+
+
+ private static final class ReachabilityThreadFactory implements ThreadFactory {
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "reachability-service");
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java
index f53e1ff..f337ded 100644
--- a/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java
+++ b/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java
@@ -2,6 +2,7 @@ package kst4contest.controller;
import javafx.application.Platform;
import kst4contest.ApplicationConstants;
+import kst4contest.model.Band;
import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage;
import kst4contest.view.GuiUtils;
@@ -286,6 +287,72 @@ public class ReadUDPByWintestThread extends Thread {
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
// }
+
+ /**
+ * Resolves the project Band enum from Win-Test band IDs.
+ *
+ * Only bands that exist in the current Band enum are returned. 50/70 MHz are
+ * still represented as worked flags in ChatMember, but they are not part of the
+ * current Reachability/New-Locator band enum.
+ *
+ * @param bandId Win-Test band id from ADDQSO
+ * @return matching Band or null
+ */
+ private Band helper_resolveBandFromWinTestBandId(String bandId) {
+ if (bandId == null) {
+ return null;
+ }
+
+ switch (bandId.trim()) {
+ case "12": return Band.B_144;
+ case "14": return Band.B_432;
+ case "16": return Band.B_1296;
+ case "17": return Band.B_2320;
+ case "18": return Band.B_3400;
+ case "19": return Band.B_5760;
+ case "20": return Band.B_10G;
+ case "21": return Band.B_24G;
+ default: return null;
+ }
+ }
+
+ /**
+ * Extracts the locator from a Win-Test ADDQSO packet.
+ *
+ * Current parser model based on the existing split-by-quotes code:
+ *
+ * - {@code split("\"")[7]} = callsign
+ * - {@code split("\"")[11]} = received exchange, e.g. 599001
+ * - {@code split("\"")[13]} = locator, e.g. JO51UM
+ *
+ *
+ * If the dedicated locator field is empty, the exchange is used as fallback.
+ *
+ * @param msg raw ADDQSO message
+ * @return normalized six-character locator or null
+ */
+ private String helper_resolveLocatorFromWinTestAddQso(String msg) {
+ if (msg == null) {
+ return null;
+ }
+
+ String[] quotedParts = msg.split("\"");
+
+ if (quotedParts.length > 13) {
+ String locator = WorkedGrossFieldCache.extractLocator6(quotedParts[13]);
+ if (locator != null) {
+ return locator;
+ }
+ }
+
+ if (quotedParts.length > 11) {
+ return WorkedGrossFieldCache.extractLocator6(quotedParts[11]);
+ }
+
+ return null;
+ }
+
+
/**
* Catches add-qso messages of wintest if a new qso gets into the log
*
@@ -310,16 +377,22 @@ public class ReadUDPByWintestThread extends Thread {
// receivedQsos.put(qsoNumber, msg);
// lastKnownQso = Math.max(lastKnownQso, qsoNumber);
String callSignCatched = msg.split("\"") [7];
+ String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
ChatMember workedCall = new ChatMember();
workedCall.setCallSign(callSignCatched);
workedCall.setWorked(true); //its worked at this place, for sure!
+ if (locatorFromLogger != null) {
+ workedCall.setQra(locatorFromLogger);
+ }
+
ArrayList markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
String bandId;
bandId = msg.split("\"")[6].split(" ")[4].trim();
+ Band workedBand = helper_resolveBandFromWinTestBandId(bandId);
switch (bandId) {
case "10" -> workedCall.setWorked50(true);
case "11" -> workedCall.setWorked70(true);
@@ -336,6 +409,10 @@ public class ReadUDPByWintestThread extends Thread {
default -> System.out.println("[WinTestUDPRcvr: warning] Unbekannte Band-ID: " + bandId);
}
+ if (workedBand != null && locatorFromLogger != null) {
+ this.client.registerWorkedGrossField(workedBand, locatorFromLogger, workedCall, "WINTEST");
+ }
+
if (!markTheseChattersAsWorked.isEmpty()) {
//Worked call is part of the current chatmember list
@@ -345,6 +422,13 @@ public class ReadUDPByWintestThread extends Thread {
modifyThat.setWorked(true); //worked its for sure
+ if (locatorFromLogger != null
+ && (modifyThat.getQra() == null
+ || modifyThat.getQra().isBlank()
+ || "unknown".equalsIgnoreCase(modifyThat.getQra()))) {
+ modifyThat.setQra(locatorFromLogger);
+ }
+
if (workedCall.isWorked50()) {
modifyThat.setWorked50(true);
} else if (workedCall.isWorked70()) {
@@ -398,7 +482,11 @@ public class ReadUDPByWintestThread extends Thread {
if (!isInChat) {
workedCall.setName("unknown");
- workedCall.setQra("unknown");
+
+ if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
+ workedCall.setQra("unknown");
+ }
+
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
diff --git a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
index 42e690d..46d1cfe 100644
--- a/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
+++ b/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java
@@ -22,6 +22,9 @@ import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import kst4contest.model.ChatMember;
+import kst4contest.model.Band;
+
+import javafx.application.Platform;
/**
* This thread is responsible for reading server's input and printing it to the
@@ -63,7 +66,111 @@ public class ReadUDPbyUCXMessageThread extends Thread {
System.out.println("UCXUDPRDR: catched error " + e.getMessage());
}
}
-
+
+ /**
+ * Strips binary logger framing bytes before the XML payload. Some UCXLog packets
+ * contain transport bytes before the XML declaration.
+ *
+ * @param rawPacket raw UDP payload
+ * @return cleaned XML string or trimmed original text
+ */
+ private String helper_extractXmlPayload(String rawPacket) {
+ if (rawPacket == null) {
+ return "";
+ }
+
+ int xmlStart = rawPacket.indexOf("= 0
+ ? rawPacket.substring(xmlStart).trim()
+ : rawPacket.trim();
+ }
+
+ /**
+ * Reads an optional XML child node.
+ *
+ * @param element parent element
+ * @param tagName tag to read
+ * @return trimmed value or empty string
+ */
+ private String helper_getOptionalElementText(Element element, String tagName) {
+ if (element == null || tagName == null) {
+ return "";
+ }
+
+ NodeList nodeList = element.getElementsByTagName(tagName);
+ if (nodeList == null || nodeList.getLength() == 0 || nodeList.item(0) == null) {
+ return "";
+ }
+
+ String textContent = nodeList.item(0).getTextContent();
+ return textContent == null ? "" : textContent.trim();
+ }
+
+ /**
+ * Resolves the QSO locator from UCXLog contactinfo. gridsquare is preferred,
+ * rcvnr is used as fallback for exchanges such as 001JO41HK.
+ *
+ * @param element contactinfo XML element
+ * @return normalized six-character locator or null
+ */
+ private String helper_resolveLocatorFromContactInfo(Element element) {
+ String gridSquare = WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "gridsquare"));
+ if (gridSquare != null) {
+ return gridSquare;
+ }
+
+ return WorkedGrossFieldCache.extractLocator6(helper_getOptionalElementText(element, "rcvnr"));
+ }
+
+ /**
+ * Resolves the project Band enum from logger band values.
+ *
+ * @param band logger band text
+ * @return matching Band or null
+ */
+ private Band helper_resolveBandFromLoggerBand(String band) {
+ if (band == null) {
+ return null;
+ }
+
+ switch (band.trim()) {
+ case "144":
+ case "2m":
+ return Band.B_144;
+ case "432":
+ case "70cm":
+ return Band.B_432;
+ case "1240":
+ case "1296":
+ case "23cm":
+ return Band.B_1296;
+ case "2300":
+ case "2320":
+ case "13cm":
+ return Band.B_2320;
+ case "3400":
+ case "9cm":
+ return Band.B_3400;
+ case "5600":
+ case "5760":
+ case "6cm":
+ return Band.B_5760;
+ case "10G":
+ case "10368":
+ case "3cm":
+ return Band.B_10G;
+ default:
+ return null;
+ }
+ }
+
public void run() {
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
@@ -177,7 +284,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
File logUDPMessageToThisFile;
- String udpMsg = udpPacketToProcess;
+ String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
ChatMember modifyThat = null;
@@ -221,10 +328,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Element element = (Element) node;
String call = element.getElementsByTagName("call").item(0).getTextContent();
-// call = call.toLowerCase();
- String band = element.getElementsByTagName("band").item(0).getTextContent();
-
- String points = element.getElementsByTagName("points").item(0).getTextContent();
+ String band = helper_getOptionalElementText(element, "band");
+ String gridSquare = helper_resolveLocatorFromContactInfo(element);
+ String points = helper_getOptionalElementText(element, "points");
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
+ "call: " + call + " / " + band + " ----> " + points + " POINTS");
@@ -235,89 +341,69 @@ public class ReadUDPbyUCXMessageThread extends Thread {
workedCall.setCallSign(call);
workedCall.setWorked(true);
+ if (gridSquare != null) {
+ workedCall.setQra(gridSquare);
+ }
+
+ Band workedBand = helper_resolveBandFromLoggerBand(band);
+
switch (band) {
- case "144": {
+ case "144":
+ case "2m": //minos contest logger
+ {
workedCall.setWorked144(true);
break;
}
- case "432": {
- workedCall.setWorked432(true);
- break;
- }
+ case "432":
+ case "70cm":
+ {
+ workedCall.setWorked432(true);
+ break;
+ }
- case "1240": {
+ case "1240": //ucxlog style
+ case "1296": //used for n1mm / Dxlog
+ case "23cm": //minos contest logger
+ {
workedCall.setWorked1240(true);
break;
}
- case "2300": {
+ case "2300":
+ case "13cm":
+ {
workedCall.setWorked2300(true);
break;
}
- case "3400": {
+ case "3400":
+ case "9cm":
+ {
workedCall.setWorked3400(true);
break;
}
- case "5600": {
+ case "5600":
+ case "6cm":
+ {
workedCall.setWorked5600(true);
break;
}
- case "10G": {
+ case "10G":
+ case "3cm":
+ {
workedCall.setWorked10G(true);
break;
}
- /**
- * cases hotfix for MINOS logger, which tells band like "2m", not "144"
- */
- case "2m": {
- workedCall.setWorked144(true);
- break;
+
+ default:
+ System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
+ break;
}
- case "70cm": {
- workedCall.setWorked432(true);
- break;
- }
-
- case "23cm": {
- workedCall.setWorked1240(true);
- break;
- }
-
- case "13cm": {
- workedCall.setWorked2300(true);
- break;
- }
-
- case "9cm": {
- workedCall.setWorked3400(true);
- break;
- }
-
- case "6cm": {
- workedCall.setWorked5600(true);
- break;
- }
-
- case "3cm": {
- workedCall.setWorked10G(true);
-
- }
-
- default:
- System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
- break;
- }
-
-// if (!client.getMap_ucxLogInfoWorkedCalls().containsKey("call")) {
-// client.getMap_ucxLogInfoWorkedCalls().put(call, workedCall);
-
-// } else
{
/**
* That means, the station is worked already but maybe at another band. So we
@@ -452,14 +538,22 @@ public class ReadUDPbyUCXMessageThread extends Thread {
*/
}
+ if (workedBand != null && gridSquare != null) {
+ this.client.registerWorkedGrossField(workedBand, gridSquare, workedCall, "UCXLOG");
+ }
+
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will
// check, if an entry at the db had been modified. If not, then the worked
// station had not been stored. DBHandler will store the information then.
if (!isInChat) {
-
+
workedCall.setName("unknown");
- workedCall.setQra("unknown");
+
+ if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
+ workedCall.setQra("unknown");
+ }
+
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall);
}
@@ -550,8 +644,13 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// System.out.println("Radio Mode: " + mode);
// System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" );
- this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
-
+// this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
+
+ final String finalFormattedQRG = formattedQRG;
+ helper_runOnFxThread(() ->
+ this.client.getChatPreferences().getMYQRGFirstCat().set(finalFormattedQRG)
+ );
+
// System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
}
}
@@ -604,6 +703,28 @@ public class ReadUDPbyUCXMessageThread extends Thread {
this.socket.close();
return true;
+
+ }
+
+ /**
+ * Runs UI-bound changes on the JavaFX application thread.
+ *
+ * UCXLog UDP packets are processed in a background thread. Some preference
+ * properties are bound to JavaFX controls, so setting them directly from this
+ * thread can crash JavaFX with "Not on FX application thread".
+ *
+ * @param runnable UI-bound update
+ */
+ private void helper_runOnFxThread(Runnable runnable) {
+ if (runnable == null) {
+ return;
+ }
+
+ if (Platform.isFxApplicationThread()) {
+ runnable.run();
+ } else {
+ Platform.runLater(runnable);
+ }
}
}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/controller/ScoreService.java b/src/main/java/kst4contest/controller/ScoreService.java
index b4b565e..1267788 100644
--- a/src/main/java/kst4contest/controller/ScoreService.java
+++ b/src/main/java/kst4contest/controller/ScoreService.java
@@ -182,6 +182,7 @@ public final class ScoreService {
// 4) Publish to UI in ONE batched runLater
Platform.runLater(() -> {
+ applyScoreSnapshotToChatMembers(snap);
topCandidatesFx.setAll(snap.getTopCandidates());
updateSelectedScoreFromSnapshot(snap);
uiPulse.set(uiPulse.get() + 1);
@@ -237,6 +238,31 @@ public final class ScoreService {
return representative;
}
+ /**
+ * Projects the immutable score snapshot back into ChatMember display fields so
+ * the normal station table can sort/filter by score without knowing the score
+ * calculation internals.
+ *
+ * @param snap latest score snapshot
+ */
+ private void applyScoreSnapshotToChatMembers(ScoreSnapshot snap) {
+ if (snap == null) {
+ return;
+ }
+
+ Map scoreByCallSignRaw = snap.getScoreByCallSignRaw();
+
+ for (ChatMember member : controller.snapshotChatMembers()) {
+ if (member == null || member.getCallSignRaw() == null) {
+ continue;
+ }
+
+ Double score = scoreByCallSignRaw.get(normalizeCallRaw(member.getCallSignRaw()));
+ member.setCurrentPriorityScore(score == null ? 0.0 : score);
+ }
+
+ controller.fireUserListUpdate("Priority scores projected to ChatMember");
+ }
private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
if (snap == null || selectedCallSignRaw == null) {
diff --git a/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java
new file mode 100644
index 0000000..2bfa2ba
--- /dev/null
+++ b/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java
@@ -0,0 +1,175 @@
+package kst4contest.controller;
+
+import kst4contest.model.Band;
+import kst4contest.model.ChatMember;
+
+import java.util.Collection;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Runtime cache for worked Maidenhead gross fields per band.
+ *
+ * The cache is used by the "new locator" station filter. It is kept in memory
+ * for fast UI predicates and rebuilt from SQLite on startup/refresh. New QSO log
+ * packets update both SQLite and this cache immediately.
+ */
+public final class WorkedGrossFieldCache {
+
+ private static final Pattern MAIDENHEAD_6_PATTERN =
+ Pattern.compile("(?i)([A-R]{2}[0-9]{2}[A-X]{2})");
+
+ private final Map> workedGrossFieldsByBand = new EnumMap<>(Band.class);
+
+ /**
+ * Replaces the full cache content with data read from the database.
+ *
+ * @param databaseSnapshot map of band to worked gross fields
+ */
+ public synchronized void rebuildFromDatabaseSnapshot(Map> databaseSnapshot) {
+ workedGrossFieldsByBand.clear();
+
+ if (databaseSnapshot == null) {
+ return;
+ }
+
+ for (Map.Entry> entry : databaseSnapshot.entrySet()) {
+ Band band = entry.getKey();
+ Set grossFields = entry.getValue();
+
+ if (band == null || grossFields == null) {
+ continue;
+ }
+
+ Set normalizedGrossFields = workedGrossFieldsByBand.computeIfAbsent(band, ignored -> new HashSet<>());
+ for (String grossField : grossFields) {
+ String normalizedGrossField = normalizeGrossField(grossField);
+ if (normalizedGrossField != null) {
+ normalizedGrossFields.add(normalizedGrossField);
+ }
+ }
+ }
+ }
+
+ /**
+ * Adds one worked locator to the cache.
+ *
+ * @param band worked band
+ * @param locatorOrGrossField six-character locator or four-character gross field
+ */
+ public synchronized void addWorked(Band band, String locatorOrGrossField) {
+ String grossField = extractGrossField(locatorOrGrossField);
+ if (band == null || grossField == null) {
+ return;
+ }
+
+ workedGrossFieldsByBand
+ .computeIfAbsent(band, ignored -> new HashSet<>())
+ .add(grossField);
+ }
+
+ /**
+ * Adds all worked-band flags from stored ChatMember rows. This is only a fallback
+ * for legacy data before WorkedGrossField existed or when logger packets did not
+ * provide a locator.
+ *
+ * @param storedMembers stored ChatMember rows
+ */
+ public synchronized void addWorkedBandsFromStoredChatMembers(Collection storedMembers) {
+ if (storedMembers == null) {
+ return;
+ }
+
+ for (ChatMember member : storedMembers) {
+ if (member == null) {
+ continue;
+ }
+
+ String locator = member.getQra();
+ if (member.isWorked144()) addWorked(Band.B_144, locator);
+ if (member.isWorked432()) addWorked(Band.B_432, locator);
+ if (member.isWorked1240()) addWorked(Band.B_1296, locator);
+ if (member.isWorked2300()) addWorked(Band.B_2320, locator);
+ if (member.isWorked3400()) addWorked(Band.B_3400, locator);
+ if (member.isWorked5600()) addWorked(Band.B_5760, locator);
+ if (member.isWorked10G()) addWorked(Band.B_10G, locator);
+ }
+ }
+
+ /**
+ * Checks whether a locator gross field is already worked on a band.
+ *
+ * @param band band to check
+ * @param locatorOrGrossField six-character locator or four-character gross field
+ * @return true if the gross field is already worked on that band
+ */
+ public synchronized boolean isGrossFieldWorked(Band band, String locatorOrGrossField) {
+ String grossField = extractGrossField(locatorOrGrossField);
+ if (band == null || grossField == null) {
+ return false;
+ }
+
+ return workedGrossFieldsByBand
+ .getOrDefault(band, Set.of())
+ .contains(grossField);
+ }
+
+ /**
+ * Tries to normalize a locator. Accepts a plain six-character locator or extracts
+ * one from exchange strings such as "001JO41HK".
+ *
+ * @param rawLocatorOrExchange raw locator/exchange text
+ * @return normalized six-character locator, or null if none was found
+ */
+ public static String extractLocator6(String rawLocatorOrExchange) {
+ if (rawLocatorOrExchange == null || rawLocatorOrExchange.isBlank()) {
+ return null;
+ }
+
+ Matcher matcher = MAIDENHEAD_6_PATTERN.matcher(rawLocatorOrExchange.trim());
+ if (!matcher.find()) {
+ return null;
+ }
+
+ return matcher.group(1).toUpperCase(Locale.ROOT);
+ }
+
+ /**
+ * Extracts and normalizes the four-character gross field.
+ *
+ * @param rawLocatorOrGrossField six-character locator, four-character gross field or exchange text
+ * @return normalized gross field, or null if no valid value is available
+ */
+ public static String extractGrossField(String rawLocatorOrGrossField) {
+ if (rawLocatorOrGrossField == null || rawLocatorOrGrossField.isBlank()) {
+ return null;
+ }
+
+ String trimmed = rawLocatorOrGrossField.trim().toUpperCase(Locale.ROOT);
+
+ if (trimmed.matches("[A-R]{2}[0-9]{2}")) {
+ return trimmed;
+ }
+
+ String locator6 = extractLocator6(trimmed);
+ if (locator6 == null || locator6.length() < 4) {
+ return null;
+ }
+
+ return locator6.substring(0, 4);
+ }
+
+ private static String normalizeGrossField(String grossField) {
+ if (grossField == null) {
+ return null;
+ }
+
+ String normalized = grossField.trim().toUpperCase(Locale.ROOT);
+ return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/kst4contest/model/Band.java b/src/main/java/kst4contest/model/Band.java
index 9cfd866..94c3ccf 100644
--- a/src/main/java/kst4contest/model/Band.java
+++ b/src/main/java/kst4contest/model/Band.java
@@ -29,6 +29,35 @@ public enum Band {
return prefix;
}
+ /**
+ * Returns the lower edge used as practical analysis frequency when only the band
+ * is known. This keeps the batch reachability calculation deterministic.
+ *
+ * @return frequency in MHz
+ */
+ public double getDefaultAnalysisFrequencyMHz() {
+ return minFreq;
+ }
+
+ /**
+ * Returns a compact label for table display and filter controls.
+ *
+ * @return human readable band label
+ */
+ public String getDisplayLabel() {
+ switch (this) {
+ case B_144: return "144";
+ case B_432: return "432";
+ case B_1296: return "1296";
+ case B_2320: return "2320";
+ case B_3400: return "3400";
+ case B_5760: return "5760";
+ case B_10G: return "10G";
+ case B_24G: return "24G";
+ default: return prefix;
+ }
+ }
+
/**
* Checks if a specific frequency falls within this band's limits.
*/
diff --git a/src/main/java/kst4contest/model/ChatMember.java b/src/main/java/kst4contest/model/ChatMember.java
index 7522506..fdc223d 100644
--- a/src/main/java/kst4contest/model/ChatMember.java
+++ b/src/main/java/kst4contest/model/ChatMember.java
@@ -10,10 +10,13 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
+import java.util.OptionalDouble;
public class ChatMember {
+
+
long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB
// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty();
@@ -74,6 +77,10 @@ public class ChatMember {
// Stores the last known frequency per band (Context History)
private final Map knownActiveBands = new ConcurrentHashMap<>();
+ // Stores the calculated bidirectional SSB tropo margin per band.
+ // Values are calculated by the reachability backend and used only for UI sorting/filtering.
+ private final Map tropoSsbMarginDbByBand = new ConcurrentHashMap<>();
+
// --- INNER CLASS FOR QRG HISTORY ---
public class ActiveFrequencyInfo {
@@ -620,6 +627,108 @@ public class ChatMember {
return knownActiveBands;
}
+ /**
+ * Stores the calculated bidirectional SSB tropo margin for one band.
+ *
+ * The value is deliberately stored in ChatMember because the station table
+ * can then sort and filter directly without triggering a new RF calculation.
+ * The actual calculation stays outside ChatMember.
+ *
+ * @param band band for which the margin was calculated
+ * @param marginDb bidirectional SSB margin in dB; NaN marks an attempted but failed analysis
+ */
+ public void setTropoSsbMarginDb(Band band, double marginDb) {
+ if (band == null) {
+ return;
+ }
+
+ this.tropoSsbMarginDbByBand.put(band, marginDb);
+ }
+
+ /**
+ * Returns true if a tropo analysis result already exists for the given band.
+ *
+ * A stored NaN also counts as an existing result because it means the analysis
+ * was attempted and failed. This prevents endless retry loops when a terrain
+ * provider is temporarily unavailable.
+ *
+ * @param band band to check
+ * @return true if a value or NaN marker is stored
+ */
+ public boolean hasTropoSsbMarginDb(Band band) {
+ return band != null && this.tropoSsbMarginDbByBand.containsKey(band);
+ }
+
+
+
+ /**
+ * Returns true only when a finite, usable tropo SSB margin is stored for the band.
+ *
+ * This is intentionally different from {@link #hasTropoSsbMarginDb(Band)}:
+ * a stored NaN means that an analysis was attempted but did not produce a usable
+ * link budget. Such failed values should not permanently block later retries.
+ *
+ * @param band band to check
+ * @return true if a finite SSB margin exists
+ */
+ public boolean hasFiniteTropoSsbMarginDb(Band band) {
+ if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
+ return false;
+ }
+
+ Double storedValue = this.tropoSsbMarginDbByBand.get(band);
+ return storedValue != null && Double.isFinite(storedValue);
+ }
+
+ /**
+ * Reads the calculated tropo SSB margin for one band.
+ *
+ * @param band band to read
+ * @return OptionalDouble with the stored value, or empty if no calculation exists yet
+ */
+ public OptionalDouble getTropoSsbMarginDb(Band band) {
+ if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
+ return OptionalDouble.empty();
+ }
+
+ Double storedValue = this.tropoSsbMarginDbByBand.get(band);
+ if (storedValue == null) {
+ return OptionalDouble.empty();
+ }
+
+ return OptionalDouble.of(storedValue);
+ }
+
+
+ /**
+ * Formats the calculated tropo margin for direct table display.
+ *
+ * The method distinguishes three states:
+ *
+ * - {@code ... @144}: analysis has not run yet
+ * - {@code ? @144}: analysis ran, but no usable link budget was produced
+ * - {@code +8.4 dB @144}: finite SSB margin exists
+ *
+ *
+ * @param band selected/auto-resolved reachability band
+ * @return display text such as "+8.4 dB @144", "? @144" or "... @144"
+ */
+ public String formatTropoSsbMarginForBand(Band band) {
+ String bandLabel = band == null ? "?" : band.getDisplayLabel();
+
+ if (band == null || !this.tropoSsbMarginDbByBand.containsKey(band)) {
+ return "... @" + bandLabel;
+ }
+
+ Double storedValue = this.tropoSsbMarginDbByBand.get(band);
+
+ if (storedValue == null || !Double.isFinite(storedValue)) {
+ return "? @" + bandLabel;
+ }
+
+ return String.format(Locale.US, "%+.1f dB @%s", storedValue, bandLabel);
+ }
+
/**
* If a sked fails and the user tells this to the client, this counter will be increased to give the station a
diff --git a/src/main/java/kst4contest/view/GuiUtils.java b/src/main/java/kst4contest/view/GuiUtils.java
index 44225ab..148e9e9 100644
--- a/src/main/java/kst4contest/view/GuiUtils.java
+++ b/src/main/java/kst4contest/view/GuiUtils.java
@@ -51,26 +51,21 @@ public class GuiUtils {
}
}
- private static void triggerUpdate(ChatController chatController) {
- {
- //trick to trigger gui changes on property changes of obects
+ /**
+ * Requests a safe UI refresh of the filtered ChatMember list.
+ *
+ * Older versions used the trick of adding/removing a dummy predicate. That can
+ * break JavaFX SortedList internals when the table is sorted and a FilteredList
+ * refilter happens at the same time. The controller-level refresh path is safer
+ * because Kst4ContestApplication now re-applies the existing predicates directly.
+ *
+ * @param chatController central controller
+ */
+ private static void triggerUpdate(ChatController chatController) {
+ if (chatController == null) {
+ return;
+ }
- Predicate dummyPredicate = new Predicate() {
- @Override
- public boolean test(ChatMember chatMember) {
- return true;
- }
- };
-
- /**
- * //TODO: following 2 lines are a quick fix to making disappear worked chatmembers of the list
- * Thats uncomfortable due to this also causes selection changes,
- * Better way is to change all worked and qrv values to observables and then trigger the underlying
- * list to fire an invalidationevent. Really Todo!
- */
- chatController.getLst_chatMemberListFilterPredicates().add(dummyPredicate);
- chatController.getLst_chatMemberListFilterPredicates().remove(dummyPredicate);
-
- }
- }
+ chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
+ }
}
diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java
index 52a78fa..85fb58b 100644
--- a/src/main/java/kst4contest/view/Kst4ContestApplication.java
+++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java
@@ -61,6 +61,8 @@ import kst4contest.model.*;
import javafx.scene.shape.Line;
import javafx.scene.shape.Polygon;
+import javafx.stage.Screen;
+
import kst4contest.utils.ApplicationFileUtils;
import kst4contest.view.map.StationMapBridge;
import kst4contest.view.map.StationMapView;
@@ -69,6 +71,12 @@ import kst4contest.view.map.OfflineDemImportService;
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
// private static final Kst4ContestApplication dbcontroller = new DBController();
+ // Null means Auto: use the lowest session band, or category fallback.
+
+ private PauseTransition userListRefreshCoalescer;
+ private String pendingUserListUpdateReason = "";
+
+ private Band selectedReachabilityBandOverride = null;
private StationMapView stationMapView; //view class for the avl stn map
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
@@ -777,13 +785,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
});
selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection()));
- Button selectedCallSignShowOnMapBtn = new Button("Show on map");
+ Button selectedCallSignShowOnMapBtn = new Button("🧭 Show on map");
selectedCallSignShowOnMapBtn.setOnAction(new EventHandler() {
@Override
public void handle(ActionEvent actionEvent) {
showSelectedCallsignOnMap();
}
});
+ selectedCallSignShowOnMapBtn.setTooltip(new Tooltip("Show the selected station on the map"));
Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw());
@@ -824,10 +833,43 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZCqprofile, 1,3,1,1);
+ /*
+ * The old GridPane layout above is intentionally left in place because it
+ * creates all controls, bindings and event handlers in the established order.
+ * From here on we only change the presentation: clear the temporary GridPane
+ * and reassemble the same controls in a compact VBox/FlowPane layout.
+ */
+ selectedCallSignDownerSiteGridPane.getChildren().clear();
+ VBox selectedCallSignCompactControlsPane = initSelectedCallSignCompactControlsPane(
+ selectedCallSignInfoStageChatMember,
+ selectedCallSignChatCategoryLabelDesc,
+ selectedCallSignInfoLblQTFInfo,
+ selectedCallSignInfoLblQRBInfo,
+ lblDetectedRxBands,
+ priorityRow,
+ skedRow,
+ selectedCallSignPathAndMapButtons,
+ selectedCallSignTurnAntBtn,
+ selectedCallSignShowQRZprofile,
+ selectedCallSignShowQRZCqprofile,
+ furtherInfoPnl_chkbx_notQRV144,
+ furtherInfoPnl_chkbx_notQRV432,
+ furtherInfoPnl_chkbx_notQRV23,
+ furtherInfoPnl_chkbx_notQRV13,
+ furtherInfoPnl_chkbx_notQRV9,
+ furtherInfoPnl_chkbx_notQRV6,
+ furtherInfoPnl_chkbx_notQRV3,
+ furtherInfoPnl_chkbx_notQRVall
+ );
selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable);
- selectedCallSignSplitPane.getItems().add(selectedCallSignDownerSiteGridPane);
+ selectedCallSignSplitPane.getItems().add(selectedCallSignCompactControlsPane);
+
+
+
+// selectedCallSignSplitPane.getItems().add(initFurtherInfoAbtCallsignMSGTable);
+// selectedCallSignSplitPane.getItems().add(selectedCallSignDownerSiteGridPane);
//first initialize how much divider positions we need...
// chatcontroller.getChatPreferences().setGUIselectedCallSignSplitPane_dividerposition(new double[selectedCallSignSplitPane.getDividers().size()]);
@@ -917,7 +959,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return true;
} else return false;
} catch (NullPointerException SenderNull) {
- System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage());
+// System.out.println("KST4ContestApp, <<>>: Sender/receiver of the message is unknown, categorizing is impossible: " + SenderNull.getMessage());
return false;
}
@@ -1002,7 +1044,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} else return false;
} catch (Exception exception) {
- System.out.println("KST4ContestApplication <<>>>: cant get sender infos due to sender is not known yet" + exception.getMessage());
+// System.out.println("KST4ContestApplication <<>>>: cant get sender infos due to sender is not known yet" + exception.getMessage());
return false;
}
}
@@ -1018,6 +1060,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
+
+ /**
+ * Applies the currently selected station filters to the ChatMember FilteredList.
+ *
+ * This replaces the old predicate-property binding. The old binding worked
+ * for normal filter changes, but it became unstable when we tried to force a
+ * refresh by adding/removing a dummy predicate. Calling this method simply
+ * rebuilds the combined predicate and sets it directly on the FilteredList.
+ */
+ private void applyChatMemberFilterPredicates() {
+ if (chatcontroller == null
+ || chatcontroller.getLst_chatMemberListFiltered() == null
+ || chatcontroller.getLst_chatMemberListFilterPredicates() == null) {
+ return;
+ }
+
+ Predicate combinedPredicate =
+ chatcontroller.getLst_chatMemberListFilterPredicates()
+ .stream()
+ .reduce(chatMember -> true, Predicate::and);
+
+ chatcontroller.getLst_chatMemberListFiltered().setPredicate(combinedPredicate);
+ }
+
+
/**
* Helper method for furtherinfoPane
* @param s
@@ -1351,6 +1418,29 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
*/
+ TableColumn tropoCol = new TableColumn("Tropo");
+ tropoCol.setCellValueFactory(new Callback, ObservableValue>() {
+ @Override
+ public ObservableValue call(CellDataFeatures cellDataFeatures) {
+ ChatMember member = cellDataFeatures.getValue();
+ Band selectedBand = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand);
+ return new SimpleStringProperty(member.formatTropoSsbMarginForBand(selectedBand));
+ }
+ });
+ tropoCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right)));
+ tropoCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(13));
+
+ TableColumn priorityScoreCol = new TableColumn("Score");
+ priorityScoreCol.setCellValueFactory(new Callback, ObservableValue>() {
+ @Override
+ public ObservableValue call(CellDataFeatures cellDataFeatures) {
+ return new SimpleStringProperty(formatPriorityScore(cellDataFeatures.getValue()));
+ }
+ });
+ priorityScoreCol.setComparator((left, right) -> Double.compare(parseTableDouble(left), parseTableDouble(right)));
+ priorityScoreCol.prefWidthProperty().bind(tbl_chatMemberTable.widthProperty().divide(18));
+
TableColumn lastActCol = new TableColumn("Act");
lastActCol.setCellValueFactory(new Callback, ObservableValue>() {
@@ -1653,7 +1743,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
- tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+// tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+
+ tbl_chatMemberTable.getColumns().addAll(callSignCol, nameCol, qraCol, qrBCol, qtfCol, qrgCol, tropoCol, priorityScoreCol, lastActCol, airScoutCol, workedCol, notQRVCol, chatCategoryCol);
+
// tbl_chatMemberTable.setItems(chatcontroller.getLst_chatMemberListFiltered());
@@ -2060,6 +2153,166 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
+
+ /**
+ * Reassembles the selected-station controls into a compact, balanced layout.
+ *
+ * The selected-station message table stays immediately visible above this pane.
+ * This pane only contains the summary and controls below it:
+ * - compact station summary
+ * - action buttons such as Show path / Show on map / Turn antenna / lookups
+ * - score and sked controls
+ * - not-QRV tags
+ *
+ * Existing controls are passed in instead of recreated so all existing event
+ * handlers, bindings and comments in generateFurtherInfoAbtSelectedCallsignBP(...)
+ * remain intact.
+ */
+ private VBox initSelectedCallSignCompactControlsPane(
+ ChatMember selectedCallSignInfoStageChatMember,
+ Label selectedCallSignChatCategoryLabelDesc,
+ Label selectedCallSignInfoLblQTFInfo,
+ Label selectedCallSignInfoLblQRBInfo,
+ Label lblDetectedRxBands,
+ HBox priorityRow,
+ HBox skedRow,
+ HBox selectedCallSignPathAndMapButtons,
+ Button selectedCallSignTurnAntBtn,
+ Button selectedCallSignShowQRZprofile,
+ Button selectedCallSignShowQRZCqprofile,
+ CheckBox furtherInfoPnl_chkbx_notQRV144,
+ CheckBox furtherInfoPnl_chkbx_notQRV432,
+ CheckBox furtherInfoPnl_chkbx_notQRV23,
+ CheckBox furtherInfoPnl_chkbx_notQRV13,
+ CheckBox furtherInfoPnl_chkbx_notQRV9,
+ CheckBox furtherInfoPnl_chkbx_notQRV6,
+ CheckBox furtherInfoPnl_chkbx_notQRV3,
+ CheckBox furtherInfoPnl_chkbx_notQRVall
+ ) {
+
+ for (CheckBox cb : Arrays.asList(
+ furtherInfoPnl_chkbx_notQRV144,
+ furtherInfoPnl_chkbx_notQRV432,
+ furtherInfoPnl_chkbx_notQRV23,
+ furtherInfoPnl_chkbx_notQRV13,
+ furtherInfoPnl_chkbx_notQRV9,
+ furtherInfoPnl_chkbx_notQRV6,
+ furtherInfoPnl_chkbx_notQRV3,
+ furtherInfoPnl_chkbx_notQRVall
+ )) {
+ // Hidden band controls should not reserve space in the compact FlowPane.
+ cb.managedProperty().bind(cb.visibleProperty());
+ }
+
+ FlowPane stationSummaryFlow = new FlowPane();
+ stationSummaryFlow.setHgap(8);
+ stationSummaryFlow.setVgap(2);
+ stationSummaryFlow.setAlignment(Pos.CENTER_LEFT);
+ stationSummaryFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;");
+
+ stationSummaryFlow.getChildren().addAll(
+ selectedCallSignChatCategoryLabelDesc,
+ new Label("|"),
+ selectedCallSignInfoLblQTFInfo,
+ selectedCallSignInfoLblQRBInfo,
+ new Label("Last activity: " + new Utils4KST().time_convertEpochToReadable(
+ selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "")),
+ new Label("("
+ + Utils4KST.time_getSecondsBetweenEpochAndNow(
+ selectedCallSignInfoStageChatMember.getActivityTimeLastInEpoch() + "") / 60 % 60
+ + " min ago)"),
+ lblDetectedRxBands
+ );
+
+ FlowPane actionFlow = new FlowPane();
+ actionFlow.setHgap(5);
+ actionFlow.setVgap(3);
+ actionFlow.setAlignment(Pos.CENTER_LEFT);
+ actionFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;");
+ actionFlow.getChildren().addAll(
+ selectedCallSignPathAndMapButtons,
+ selectedCallSignTurnAntBtn,
+ selectedCallSignShowQRZprofile,
+ selectedCallSignShowQRZCqprofile
+ );
+
+ FlowPane scoreAndSkedFlow = new FlowPane();
+ scoreAndSkedFlow.setHgap(5);
+ scoreAndSkedFlow.setVgap(3);
+ scoreAndSkedFlow.setAlignment(Pos.CENTER_LEFT);
+ scoreAndSkedFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;");
+ scoreAndSkedFlow.getChildren().addAll(priorityRow, skedRow);
+
+ FlowPane notQrvFlow = new FlowPane();
+ notQrvFlow.setHgap(5);
+ notQrvFlow.setVgap(2);
+ notQrvFlow.setAlignment(Pos.CENTER_LEFT);
+ notQrvFlow.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;");
+ notQrvFlow.getChildren().addAll(
+ new Label("Not QRV:"),
+ furtherInfoPnl_chkbx_notQRV144,
+ furtherInfoPnl_chkbx_notQRV432,
+ furtherInfoPnl_chkbx_notQRV23,
+ furtherInfoPnl_chkbx_notQRV13,
+ furtherInfoPnl_chkbx_notQRV9,
+ furtherInfoPnl_chkbx_notQRV6,
+ furtherInfoPnl_chkbx_notQRV3,
+ furtherInfoPnl_chkbx_notQRVall
+ );
+
+ VBox selectedCallSignCompactControlsPane = new VBox(4);
+ selectedCallSignCompactControlsPane.setStyle("-fx-padding: 3;");
+ selectedCallSignCompactControlsPane.getChildren().addAll(
+ stationSummaryFlow,
+ actionFlow,
+ scoreAndSkedFlow,
+ notQrvFlow
+ );
+
+ return selectedCallSignCompactControlsPane;
+ }
+
+ /**
+ * Builds the lower global-message area.
+ *
+ * This TabPane is intentionally independent from the selected ChatMember.
+ * It contains global message streams only:
+ * - public/CQ messages
+ * - DXCluster messages
+ * - messages between other stations ("QSO of the other")
+ *
+ * The already initialized public-message table is passed in so its existing
+ * context menu and selection handling remain unchanged.
+ *
+ * DXCluster and QSO-of-the-other get their own TableViews on the same backing
+ * lists. This is important because the existing separate "Cluster & QSO of the other"
+ * window can keep using its own TableViews at the same time.
+ */
+ private TabPane initBottomGlobalMessageTabPane(TableView tbl_generalMessageTable) {
+
+ TabPane bottomMessageTabs = new TabPane();
+ bottomMessageTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
+ bottomMessageTabs.setTabMinHeight(22);
+ bottomMessageTabs.setTabMaxHeight(24);
+
+ Tab publicMessagesTab = new Tab("Public messages");
+ publicMessagesTab.setTooltip(new Tooltip("Public/CQ messages from the chat."));
+ publicMessagesTab.setContent(tbl_generalMessageTable);
+
+ Tab dxClusterMessagesTab = new Tab("DXCluster messages");
+ dxClusterMessagesTab.setTooltip(new Tooltip("DXCluster spots."));
+ dxClusterMessagesTab.setContent(initDXClusterTable());
+
+ Tab qsoOfTheOtherTab = new Tab("QSO of the other");
+ qsoOfTheOtherTab.setTooltip(new Tooltip("Messages between other stations. This view is not tied to the selected ChatMember."));
+ qsoOfTheOtherTab.setContent(initChatToOtherMSGTable());
+
+ bottomMessageTabs.getTabs().addAll(publicMessagesTab, dxClusterMessagesTab, qsoOfTheOtherTab);
+ bottomMessageTabs.getSelectionModel().select(publicMessagesTab);
+
+ return bottomMessageTabs;
+ }
+
/**
* initializes the tableview in which the cq- and beacon-texts are shown
*
@@ -3001,13 +3254,223 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
} // TODO: Textsnippets table
+// private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) {
+//
+// BorderPane pane = new BorderPane();
+// pane.setStyle("-fx-padding: 3;");
+//
+// Label header = new Label("Top priority candidates");
+// header.getStyleClass().add("label");
+//
+// ListView listView = new ListView<>();
+// listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx());
+//
+// listView.setCellFactory(lv -> new ListCell<>() {
+// @Override
+// protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) {
+// super.updateItem(item, empty);
+// if (empty || item == null) {
+// setText(null);
+// return;
+// }
+// // Keep it compact; score is mainly evaluated in FurtherInfo
+// setText(item.getDisplayCallSign() + " | score " + String.format(java.util.Locale.US, "%.0f", item.getScore()));
+// }
+// });
+//
+// listView.setOnMouseClicked(evt -> {
+// if (evt.getClickCount() < 1) return;
+// kst4contest.controller.ScoreService.TopCandidate c = listView.getSelectionModel().getSelectedItem();
+// if (c == null) return;
+//
+// ChatMember resolved = resolveChatMemberForTopCandidate(c);
+// if (resolved == null) return;
+//
+// // Try to select in table (reuses existing selection logic)
+// if (tbl_chatMember.getItems().contains(resolved)) {
+// tbl_chatMember.getSelectionModel().select(resolved);
+// tbl_chatMember.scrollTo(resolved);
+// } else {
+// // Fallback: if filtered out, still show FurtherInfo + prepare /cq
+// selectedCallSignInfoStageChatMember = resolved;
+// chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember);
+//
+// selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved));
+// txt_chatMessageUserInput.clear();
+// txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " ");
+// txt_chatMessageUserInput.requestFocus();
+// txt_chatMessageUserInput.selectEnd();
+//
+// // Keep ScoreService selection in sync
+// chatcontroller.getScoreService().setSelectedChatMember(resolved);
+// }
+// });
+//
+// pane.setTop(header);
+// pane.setCenter(listView);
+// return pane;
+// }
+
+ /**
+ * Builds the compact priority candidate area for the right side of the main UI.
+ *
+ * The former implementation showed the complete priority list permanently.
+ * That used a lot of vertical space although normally only the first one or two
+ * candidates are relevant during live operation.
+ *
+ * New behaviour:
+ * - show only Top 1 and Top 2 directly in the main window
+ * - click Top 1/Top 2 to select that candidate immediately
+ * - use "more" to open the full priority list in a separate window
+ */
private BorderPane initTopPriorityListPane(TableView tbl_chatMember, TextField txt_chatMessageUserInput) {
BorderPane pane = new BorderPane();
- pane.setStyle("-fx-padding: 3;");
+ pane.setStyle("-fx-padding: 3; -fx-border-color: lightgrey; -fx-border-width: 1;");
- Label header = new Label("Top priority candidates");
- header.getStyleClass().add("label");
+ Label header = new Label("Priority:");
+ header.setMinWidth(48);
+
+ Button top1Button = new Button("1 -");
+ Button top2Button = new Button("2 -");
+ Button moreButton = new Button("more");
+
+ top1Button.setMaxWidth(Double.MAX_VALUE);
+ top2Button.setMaxWidth(Double.MAX_VALUE);
+
+ HBox.setHgrow(top1Button, Priority.ALWAYS);
+ HBox.setHgrow(top2Button, Priority.ALWAYS);
+
+ HBox row = new HBox(4, header, top1Button, top2Button, moreButton);
+ row.setAlignment(Pos.CENTER_LEFT);
+
+ Runnable refreshButtons = () -> {
+ ObservableList items =
+ chatcontroller.getScoreService().getTopCandidatesFx();
+
+ updateTopCandidateButton(top1Button, items, 0);
+ updateTopCandidateButton(top2Button, items, 1);
+ };
+
+ chatcontroller.getScoreService().getTopCandidatesFx().addListener(
+ (ListChangeListener) change -> refreshButtons.run()
+ );
+
+ refreshButtons.run();
+
+ top1Button.setOnAction(e -> selectTopCandidateAt(0, tbl_chatMember, txt_chatMessageUserInput));
+ top2Button.setOnAction(e -> selectTopCandidateAt(1, tbl_chatMember, txt_chatMessageUserInput));
+ moreButton.setOnAction(e -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput));
+
+ pane.setCenter(row);
+ pane.setMinHeight(38);
+ pane.setPrefHeight(42);
+ pane.setMaxHeight(58);
+ SplitPane.setResizableWithParent(pane, Boolean.FALSE);
+
+ return pane;
+ }
+
+ /**
+ * Updates one of the compact priority buttons from the current TopCandidate list.
+ * Disabled buttons indicate that not enough candidates are currently available.
+ */
+ private void updateTopCandidateButton(
+ Button button,
+ ObservableList items,
+ int index
+ ) {
+ if (items == null || items.size() <= index || items.get(index) == null) {
+ button.setText((index + 1) + " -");
+ button.setDisable(true);
+ button.setTooltip(null);
+ return;
+ }
+
+ kst4contest.controller.ScoreService.TopCandidate candidate = items.get(index);
+
+ button.setText(String.format(
+ java.util.Locale.US,
+ "%d %s %.0f",
+ index + 1,
+ candidate.getDisplayCallSign(),
+ candidate.getScore()
+ ));
+ button.setDisable(false);
+ button.setTooltip(new Tooltip("Select " + candidate.getDisplayCallSign() + " from priority candidates"));
+ }
+
+ /**
+ * Selects the TopCandidate at the given index, if available.
+ */
+ private void selectTopCandidateAt(
+ int index,
+ TableView tbl_chatMember,
+ TextField txt_chatMessageUserInput
+ ) {
+ ObservableList items =
+ chatcontroller.getScoreService().getTopCandidatesFx();
+
+ if (items == null || items.size() <= index) {
+ return;
+ }
+
+ selectTopCandidate(items.get(index), tbl_chatMember, txt_chatMessageUserInput);
+ }
+
+ /**
+ * Resolves and selects a priority candidate.
+ *
+ * If the station is currently visible in the member table, selecting it there is
+ * preferred because the existing table-selection listener then keeps the rest of
+ * the UI in sync. If the station is filtered out, we still show FurtherInfo and
+ * prepare the /cq text so the priority list remains useful with active filters.
+ */
+ private void selectTopCandidate(
+ kst4contest.controller.ScoreService.TopCandidate candidate,
+ TableView tbl_chatMember,
+ TextField txt_chatMessageUserInput
+ ) {
+ if (candidate == null) {
+ return;
+ }
+
+ ChatMember resolved = resolveChatMemberForTopCandidate(candidate);
+
+ if (resolved == null) {
+ return;
+ }
+
+ if (tbl_chatMember.getItems().contains(resolved)) {
+ tbl_chatMember.getSelectionModel().select(resolved);
+ tbl_chatMember.scrollTo(resolved);
+ } else {
+ selectedCallSignInfoStageChatMember = resolved;
+ chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember);
+
+ selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved));
+ txt_chatMessageUserInput.clear();
+ txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " ");
+ txt_chatMessageUserInput.requestFocus();
+ txt_chatMessageUserInput.selectEnd();
+ }
+
+ // Keep ScoreService selection in sync even if the visible table selection path was used.
+ chatcontroller.getScoreService().setSelectedChatMember(resolved);
+ }
+
+ /**
+ * Opens the complete priority candidate list in a separate window.
+ *
+ * This keeps the main UI compact while still making the full list available when
+ * the operator wants to inspect more than the first two candidates.
+ */
+ private void showTopPriorityCandidatesWindow(
+ TableView tbl_chatMember,
+ TextField txt_chatMessageUserInput
+ ) {
+ Stage stage = new Stage();
+ stage.setTitle("Top priority candidates");
ListView listView = new ListView<>();
listView.setItems(chatcontroller.getScoreService().getTopCandidatesFx());
@@ -3016,48 +3479,44 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
@Override
protected void updateItem(kst4contest.controller.ScoreService.TopCandidate item, boolean empty) {
super.updateItem(item, empty);
+
if (empty || item == null) {
setText(null);
return;
}
- // Keep it compact; score is mainly evaluated in FurtherInfo
- setText(item.getDisplayCallSign() + " | score " + String.format(java.util.Locale.US, "%.0f", item.getScore()));
+
+ setText(item.getDisplayCallSign()
+ + " | score "
+ + String.format(java.util.Locale.US, "%.0f", item.getScore()));
}
});
listView.setOnMouseClicked(evt -> {
- if (evt.getClickCount() < 1) return;
- kst4contest.controller.ScoreService.TopCandidate c = listView.getSelectionModel().getSelectedItem();
- if (c == null) return;
-
- ChatMember resolved = resolveChatMemberForTopCandidate(c);
- if (resolved == null) return;
-
- // Try to select in table (reuses existing selection logic)
- if (tbl_chatMember.getItems().contains(resolved)) {
- tbl_chatMember.getSelectionModel().select(resolved);
- tbl_chatMember.scrollTo(resolved);
- } else {
- // Fallback: if filtered out, still show FurtherInfo + prepare /cq
- selectedCallSignInfoStageChatMember = resolved;
- chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember);
-
- selectedCallSignFurtherInfoPane.getChildren().setAll(generateFurtherInfoAbtSelectedCallsignBP(resolved));
- txt_chatMessageUserInput.clear();
- txt_chatMessageUserInput.setText("/cq " + resolved.getCallSign() + " ");
- txt_chatMessageUserInput.requestFocus();
- txt_chatMessageUserInput.selectEnd();
-
- // Keep ScoreService selection in sync
- chatcontroller.getScoreService().setSelectedChatMember(resolved);
+ if (evt.getClickCount() < 2) {
+ return;
}
+
+ kst4contest.controller.ScoreService.TopCandidate selected =
+ listView.getSelectionModel().getSelectedItem();
+
+ if (selected == null) {
+ return;
+ }
+
+ selectTopCandidate(selected, tbl_chatMember, txt_chatMessageUserInput);
+ stage.close();
});
- pane.setTop(header);
- pane.setCenter(listView);
- return pane;
+ BorderPane root = new BorderPane();
+ root.setStyle("-fx-padding: 5;");
+ root.setCenter(listView);
+ root.setBottom(new Label("Double-click a candidate to select it."));
+
+ stage.setScene(new Scene(root, 360, 500));
+ stage.show();
}
+
private ChatMember resolveChatMemberForTopCandidate(kst4contest.controller.ScoreService.TopCandidate c) {
String callRaw = c.getCallSignRaw();
@@ -4672,6 +5131,74 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
+
+ /**
+ * Calculates a screen-aware startup size for the main chat window.
+ *
+ * Preferences store the main scene size as:
+ * - index 0 = height
+ * - index 1 = width
+ *
+ * The stored size is used unchanged as long as it fits into the currently
+ * available primary screen area. If the application was last used on a larger
+ * monitor and is now started on a smaller screen, the size is reduced so the
+ * main window remains usable immediately after startup.
+ *
+ * Screen.getVisualBounds() is used instead of Screen.getBounds() because the
+ * visual bounds exclude task bars, docks and similar OS UI areas.
+ *
+ * The returned array keeps the same H/W order as ChatPreferences:
+ * - index 0 = corrected height
+ * - index 1 = corrected width
+ */
+ private double[] getScreenAwareMainSceneSizeHW(double[] storedSceneSizeHW) {
+
+ final double fallbackHeight = 768.0;
+ final double fallbackWidth = 1234.0;
+
+ /*
+ * Leave a little room for the native window decoration and screen edges.
+ * JavaFX Scene size does not include the full native Stage decoration.
+ */
+ final double screenMargin = 40.0;
+
+ double storedHeight = fallbackHeight;
+ double storedWidth = fallbackWidth;
+
+ if (storedSceneSizeHW != null && storedSceneSizeHW.length >= 2) {
+ if (Double.isFinite(storedSceneSizeHW[0]) && storedSceneSizeHW[0] > 0) {
+ storedHeight = storedSceneSizeHW[0];
+ }
+
+ if (Double.isFinite(storedSceneSizeHW[1]) && storedSceneSizeHW[1] > 0) {
+ storedWidth = storedSceneSizeHW[1];
+ }
+ }
+
+ Rectangle2D visualBounds = Screen.getPrimary().getVisualBounds();
+
+ double availableWidth = Math.max(1.0, visualBounds.getWidth() - screenMargin);
+ double availableHeight = Math.max(1.0, visualBounds.getHeight() - screenMargin);
+
+ double correctedWidth = Math.min(storedWidth, availableWidth);
+ double correctedHeight = Math.min(storedHeight, availableHeight);
+
+ if (correctedWidth != storedWidth || correctedHeight != storedHeight) {
+ System.out.println("[Main.java, Info]: Main window startup size reduced to fit current screen. "
+ + "storedWidth=" + storedWidth
+ + ", storedHeight=" + storedHeight
+ + ", availableWidth=" + availableWidth
+ + ", availableHeight=" + availableHeight
+ + ", correctedWidth=" + correctedWidth
+ + ", correctedHeight=" + correctedHeight);
+ }
+
+ return new double[] {
+ correctedHeight,
+ correctedWidth
+ };
+ }
+
@Override
public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException {
@@ -4818,9 +5345,25 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
BorderPane bPaneChatWindow = new BorderPane();
- scn_ChatwindowMainScene = new Scene(bPaneChatWindow, chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[1], chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()[0]);
+ /*
+ * Restore the main window size from preferences, but never start larger than
+ * the currently available screen area. This avoids unusable oversized windows
+ * when KST4Contest was last used on a larger monitor.
+ */
+ double[] screenAwareMainSceneSizeHW = getScreenAwareMainSceneSizeHW(
+ chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW()
+ );
+
+ scn_ChatwindowMainScene = new Scene(
+ bPaneChatWindow,
+ screenAwareMainSceneSizeHW[1],
+ screenAwareMainSceneSizeHW[0]
+ );
+
scn_ChatwindowMainScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
+
+
//add listeners for size changes to restore after startup
scn_ChatwindowMainScene.widthProperty().addListener(new ChangeListener() {
@Override
@@ -5413,8 +5956,19 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
- messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets,pnl_inputAndSendButtons, textInputFlowPane,
- tbl_generalMessageTable);
+// messageSectionSplitpane.getItems().addAll(privateMessageTable, flwPane_textSnippets,pnl_inputAndSendButtons, textInputFlowPane,
+// tbl_generalMessageTable);
+
+ TabPane bottomGlobalMessageTabPane = initBottomGlobalMessageTabPane(tbl_generalMessageTable);
+
+ messageSectionSplitpane.getItems().addAll(
+ privateMessageTable,
+ flwPane_textSnippets,
+ pnl_inputAndSendButtons,
+ textInputFlowPane,
+ bottomGlobalMessageTabPane
+ );
+
messageSectionSplitpane.setDividerPositions(chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition());
//first initialize how much divider positions we need...
@@ -5564,6 +6118,84 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
+
+
+ ToggleButton btnTglNewLocator = new ToggleButton("New locator");
+ Predicate newLocatorPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return chatcontroller.isNewLocatorOnAnyEnabledBand(chatMember);
+ }
+ };
+ btnTglNewLocator.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglNewLocator.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(newLocatorPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(newLocatorPredicate);
+ }
+ }
+ });
+ btnTglNewLocator.setTooltip(new Tooltip("Show only stations whose gross locator is still new on at least one active own band"));
+
+ ToggleButton btnTglReachableTropo = new ToggleButton("Tropo >=0dB");
+ Predicate reachableTropoPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return isReachableViaTropoFilterMatch(chatMember);
+ }
+ };
+ btnTglReachableTropo.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglReachableTropo.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(reachableTropoPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(reachableTropoPredicate);
+ }
+ }
+ });
+ btnTglReachableTropo.setTooltip(new Tooltip("Show stations with non-negative SSB tropo margin. Pending/failed calculations stay visible."));
+
+ ToggleButton btnTglNewBands = new ToggleButton("New bands");
+ Predicate newBandsPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return isNewBandOpportunity(chatMember);
+ }
+ };
+ btnTglNewBands.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglNewBands.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(newBandsPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(newBandsPredicate);
+ }
+ }
+ });
+ btnTglNewBands.setTooltip(new Tooltip("Show stations that are QRV on a known active band that has not been worked yet"));
+
+ ToggleButton btnTglAsNext5Min = new ToggleButton("AS next 5m");
+ Predicate asNext5MinPredicate = new Predicate() {
+ @Override
+ public boolean test(ChatMember chatMember) {
+ return hasAsWindowInNextMinutes(chatMember, 5);
+ }
+ };
+ btnTglAsNext5Min.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent actionEvent) {
+ if (btnTglAsNext5Min.isSelected()) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(asNext5MinPredicate);
+ } else {
+ chatcontroller.getLst_chatMemberListFilterPredicates().remove(asNext5MinPredicate);
+ }
+ }
+ });
+ btnTglAsNext5Min.setTooltip(new Tooltip("Show stations with any AirScout window now or within the next 5 minutes"));
+
ToggleButton tglBtnQRBEnable = new ToggleButton("Show only QRB [km] <= ");
tglBtnQRBEnable.selectedProperty().addListener(new ChangeListener() {
Predicate maxQrbPredicate = new Predicate() {
@@ -5788,8 +6420,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-color: lightgrey;");
- chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates()));
+// chatcontroller.getLst_chatMemberListFiltered().predicateProperty().bind(Bindings.createObjectBinding(() -> chatcontroller.getLst_chatMemberListFilterPredicates().stream().reduce(x -> true, Predicate::and), chatcontroller.getLst_chatMemberListFilterPredicates()));
+ applyChatMemberFilterPredicates();
+
+ chatcontroller.getLst_chatMemberListFilterPredicates().addListener(
+ (ListChangeListener>) change -> applyChatMemberFilterPredicates()
+ );
TextField chatMemberTableFilterTextField = new TextField("Find...");
chatMemberTableFilterTextField.setFocusTraversable(false);
@@ -5831,7 +6468,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
chatcontroller.getLst_chatMemberListFilterPredicates().remove(searchTextPredicate);
}
else {
- chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate);
+ if (!chatcontroller.getLst_chatMemberListFilterPredicates().contains(searchTextPredicate)) {
+ chatcontroller.getLst_chatMemberListFilterPredicates().add(searchTextPredicate);
+ }
}
System.out.println("KST4CApp " + chatMemberTableFilterTextField.textProperty().getValue().equals("") + " / " + !chatMemberTableFilterTextField.focusedProperty().getValue());
@@ -5840,6 +6479,66 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
HBox chatMemberTableFilterWorkedBandFiltersHbx = new HBox();
+
+ Button btnCalculateSelectedTropo = new Button("Calc selected");
+
+ /**
+ * Calculates full terrain/path reachability only for the currently selected
+ * station. This is intentionally operator-triggered to avoid API-limit problems.
+ */
+ btnCalculateSelectedTropo.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent event) {
+ ChatMember selectedMember = tbl_chatMember.getSelectionModel().getSelectedItem();
+
+ if (selectedMember == null && chatcontroller.getScoreService() != null) {
+ selectedMember = chatcontroller.getScoreService().getSelectedChatMember();
+ }
+
+ if (selectedMember == null) {
+ return;
+ }
+
+ Band selectedBand = resolveReachabilityBandForUi(selectedMember);
+ chatcontroller.getReachabilityService().calculateSelectedStationOnDemand(selectedMember, selectedBand);
+ }
+ });
+
+ btnCalculateSelectedTropo.setTooltip(new Tooltip("Calculate full Tropo/path analysis for the selected station only"));
+
+
+ ComboBox cmbReachabilityBand = new ComboBox<>();
+ cmbReachabilityBand.getItems().add("Auto");
+ for (Band band : chatcontroller.getReachabilityService().getEnabledStationBands()) {
+ cmbReachabilityBand.getItems().add(band.getDisplayLabel());
+ }
+ cmbReachabilityBand.getSelectionModel().select("Auto");
+ cmbReachabilityBand.setTooltip(new Tooltip("Reachability band for Tropo column/filter. Auto uses the station's lowest session band."));
+ cmbReachabilityBand.setOnAction(new EventHandler() {
+ @Override
+ public void handle(ActionEvent event) {
+ selectedReachabilityBandOverride = parseReachabilityBandSelection(cmbReachabilityBand.getValue());
+
+ // Changing the display band must not start batch terrain analysis.
+ // Existing cached values are shown; new values are calculated on map click
+ // or via the explicit "Calc selected" button.
+ chatcontroller.fireUserListUpdate("Reachability band changed");
+ }
+ });
+
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(new Label("Reachability:"));
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(cmbReachabilityBand);
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(btnCalculateSelectedTropo);
+
+ /**
+ * In order to work the filters needs the proper band settings, which should be worked
+ */
+ if (chatcontroller.getReachabilityService().getEnabledStationBands().isEmpty()) {
+ Label bandSetupWarning = new Label("Please enable at least one active station band for New Locator/New Band filters.");
+ bandSetupWarning.setStyle("-fx-text-fill: orange; -fx-font-weight: bold;");
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().add(bandSetupWarning);
+ }
+
ToggleButton btnTglwkd = new ToggleButton("wkd");
Predicate wkdPredicate = new Predicate() {
@@ -6105,6 +6804,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
"-fx-border-radius: 1;" +
"-fx-border-color: lightgrey;");
+
+ chatMemberTableFilterWorkedBandFiltersHbx.getChildren().addAll(
+ btnTglNewLocator,
+ btnTglReachableTropo,
+ btnTglNewBands,
+ btnTglAsNext5Min
+ );
+
// chatMemberTableFilterWorkedBandFilters
@@ -6194,6 +6901,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
primaryStage.setScene(scn_ChatwindowMainScene);
+ /*
+ * Safety net after the Scene has been attached to the Stage.
+ * Some platforms add native window decoration after setScene(...), so this
+ * second check prevents the Stage from extending beyond the visible screen.
+ */
+ ensureStageFitsPrimaryScreen(primaryStage);
+
primaryStage.show();
} catch (Exception e) {
@@ -6876,7 +7590,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlStation.add(new Label("DEM root directory:"), 0, 9);
grdPnlStation.add(txtFldstn_pathAnalysisDemRootDirectory, 1, 9);
- grdPnlStation.add(hbxDemDirectoryActions, 2, 7, 2, 1);
+ grdPnlStation.add(hbxDemDirectoryActions, 2, 9, 2, 1);
grdPnlStation.add(new Label("Default maximum QRB:"), 0, 10);
grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 10);
@@ -6896,8 +7610,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
grdPnlStation.add(new Label("DX OM ant. gain dBi:"), 0, 7);
grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi, 1, 7);
- grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 10);
- grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 10);
+ grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 12);
+ grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 12);
@@ -7391,8 +8105,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
// Unconditionally add listener to manually sync the textfield input to the button
// (this listener also fires correctly when the value is updated by the binding)
+// txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
+// MYQRGButton.textProperty().set(newValue);
+// });
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
- MYQRGButton.textProperty().set(newValue);
+ if (Platform.isFxApplicationThread()) {
+ MYQRGButton.textProperty().set(newValue);
+ } else {
+ Platform.runLater(() -> MYQRGButton.textProperty().set(newValue));
+ }
});
// That's the default behaviour of the myqrg textfield
@@ -8851,16 +9572,43 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
- @Override
- public void onUserListUpdated(String reason) {
- Platform.runLater(() -> {
-// tbl_chatMember.sort();
- tbl_chatMember.refresh();
+ /**
+ * Forces the station FilteredList to evaluate all active predicates again.
+ *
+ * Several filters depend on mutable ChatMember fields, for example worked
+ * flags, AirScout windows or calculated Tropo values. Updating such fields does
+ * not necessarily create a JavaFX list-change event. Therefore we explicitly
+ * re-apply the combined predicate instead of adding/removing a dummy predicate.
+ * The dummy-predicate trick can corrupt SortedList mapping in JavaFX.
+ */
+ private void forceChatMemberFilterRefresh() {
+ applyChatMemberFilterPredicates();
+ }
- System.out.println("KST4Capp, UI Update Trigger: " + reason);
- });
- }
+ @Override
+ public void onUserListUpdated(String reason) {
+ Platform.runLater(() -> {
+ pendingUserListUpdateReason = reason;
+
+ if (userListRefreshCoalescer == null) {
+ userListRefreshCoalescer = new PauseTransition(Duration.millis(300));
+ userListRefreshCoalescer.setOnFinished(event -> {
+ forceChatMemberFilterRefresh();
+
+ if (tbl_chatMember != null) {
+ tbl_chatMember.refresh();
+ }
+
+ refreshStationMapIfVisible();
+
+ System.out.println("KST4Capp, UI Update Trigger: " + pendingUserListUpdateReason);
+ });
+ }
+
+ userListRefreshCoalescer.playFromStart();
+ });
+ }
// public class MaidenheadLocatorMapPane extends Pane {
@@ -9137,6 +9885,225 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
return textField;
}
+ /**
+ * Ensures that the native Stage itself fits into the primary screen's visible
+ * area.
+ *
+ * This is a second safety net in addition to getScreenAwareMainSceneSizeHW(...).
+ * The first method corrects the JavaFX Scene size. This method corrects the
+ * actual Stage position and size, including platform-specific window behaviour.
+ */
+ private void ensureStageFitsPrimaryScreen(Stage stage) {
+ if (stage == null) {
+ return;
+ }
+
+ Rectangle2D visualBounds = Screen.getPrimary().getVisualBounds();
+
+ double correctedX = stage.getX();
+ double correctedY = stage.getY();
+ double correctedWidth = stage.getWidth();
+ double correctedHeight = stage.getHeight();
+
+ if (!Double.isFinite(correctedWidth) || correctedWidth <= 0) {
+ correctedWidth = visualBounds.getWidth();
+ }
+
+ if (!Double.isFinite(correctedHeight) || correctedHeight <= 0) {
+ correctedHeight = visualBounds.getHeight();
+ }
+
+ correctedWidth = Math.min(correctedWidth, visualBounds.getWidth());
+ correctedHeight = Math.min(correctedHeight, visualBounds.getHeight());
+
+ if (!Double.isFinite(correctedX)) {
+ correctedX = visualBounds.getMinX();
+ }
+
+ if (!Double.isFinite(correctedY)) {
+ correctedY = visualBounds.getMinY();
+ }
+
+ if (correctedX < visualBounds.getMinX()) {
+ correctedX = visualBounds.getMinX();
+ }
+
+ if (correctedY < visualBounds.getMinY()) {
+ correctedY = visualBounds.getMinY();
+ }
+
+ if (correctedX + correctedWidth > visualBounds.getMaxX()) {
+ correctedX = visualBounds.getMaxX() - correctedWidth;
+ }
+
+ if (correctedY + correctedHeight > visualBounds.getMaxY()) {
+ correctedY = visualBounds.getMaxY() - correctedHeight;
+ }
+
+ stage.setX(correctedX);
+ stage.setY(correctedY);
+ stage.setWidth(correctedWidth);
+ stage.setHeight(correctedHeight);
+ }
+
+ /**
+ * Resolves the currently selected UI reachability band. Null override means Auto.
+ *
+ * @param member station row
+ * @return manually selected band or automatic band
+ */
+ private Band resolveReachabilityBandForUi(ChatMember member) {
+ if (selectedReachabilityBandOverride != null) {
+ return selectedReachabilityBandOverride;
+ }
+ return chatcontroller.getReachabilityService().resolveAutoBand(member);
+ }
+
+ /**
+ * Parses the reachability ComboBox selection.
+ *
+ * @param selectedLabel selected UI text
+ * @return selected band, or null for Auto
+ */
+ private Band parseReachabilityBandSelection(String selectedLabel) {
+ if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) {
+ return null;
+ }
+
+ for (Band band : Band.values()) {
+ if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) {
+ return band;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Formats the projected priority score for the station table.
+ *
+ * @param member station row
+ * @return score text
+ */
+ private String formatPriorityScore(ChatMember member) {
+ if (member == null || !Double.isFinite(member.getCurrentPriorityScore())) {
+ return "-";
+ }
+ return String.format(Locale.US, "%.0f", member.getCurrentPriorityScore());
+ }
+
+ /**
+ * Parses a numeric table value for custom score sorting.
+ *
+ * @param value table text
+ * @return parsed value or negative infinity for missing values
+ */
+ private double parseTableDouble(String value) {
+ if (value == null || value.isBlank() || value.equals("-") || value.startsWith("- @")) {
+ return Double.NEGATIVE_INFINITY;
+ }
+
+ String normalized = value.replace(",", ".").replace("+", "").trim();
+ int firstSpace = normalized.indexOf(' ');
+ if (firstSpace > 0) {
+ normalized = normalized.substring(0, firstSpace);
+ }
+
+ try {
+ return Double.parseDouble(normalized);
+ } catch (NumberFormatException ignored) {
+ return Double.NEGATIVE_INFINITY;
+ }
+ }
+
+ /**
+ * Predicate helper for the tropo filter. Pending or failed calculations remain
+ * visible as requested; only finite negative margins are hidden.
+ *
+ * @param member station row
+ * @return true if station should remain visible under the tropo filter
+ */
+ private boolean isReachableViaTropoFilterMatch(ChatMember member) {
+ if (member == null || chatcontroller.getReachabilityService() == null) {
+ return true;
+ }
+
+ Band selectedBand = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService().ensureTropoMarginCalculated(member, selectedBand);
+
+ OptionalDouble marginDb = member.getTropoSsbMarginDb(selectedBand);
+ if (marginDb.isEmpty() || !Double.isFinite(marginDb.getAsDouble())) {
+ return true;
+ }
+
+ return marginDb.getAsDouble() >= 0.0;
+ }
+
+ /**
+ * Returns true when a station has any AirScout window now or within maxMinutes.
+ *
+ * @param member station row
+ * @param maxMinutes look-ahead window
+ * @return true if any AS window is available
+ */
+ private boolean hasAsWindowInNextMinutes(ChatMember member, int maxMinutes) {
+ if (member == null || member.getAirPlaneReflectInfo() == null
+ || member.getAirPlaneReflectInfo().getRisingAirplanes() == null) {
+ return false;
+ }
+
+ return member.getAirPlaneReflectInfo().getRisingAirplanes().stream()
+ .filter(Objects::nonNull)
+ .anyMatch(airPlane -> airPlane.getArrivingDurationMinutes() >= 0
+ && airPlane.getArrivingDurationMinutes() <= maxMinutes);
+ }
+
+ /**
+ * Returns true when the station is known to be QRV on at least one enabled own band
+ * that has not been worked yet.
+ *
+ * @param member station row
+ * @return true if there is a new-band opportunity
+ */
+ private boolean isNewBandOpportunity(ChatMember member) {
+ if (member == null || chatcontroller.getReachabilityService() == null
+ || member.getKnownActiveBands() == null || member.getKnownActiveBands().isEmpty()) {
+ return false;
+ }
+
+ EnumSet enabledBands = chatcontroller.getReachabilityService().getEnabledStationBands();
+ for (Band band : member.getKnownActiveBands().keySet()) {
+ if (band != null && enabledBands.contains(band) && !isWorkedOnBand(member, band)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Maps the existing per-band worked flags to the Band enum.
+ *
+ * @param member station row
+ * @param band band to inspect
+ * @return true if worked on that band
+ */
+ private boolean isWorkedOnBand(ChatMember member, Band band) {
+ if (member == null || band == null) {
+ return false;
+ }
+
+ switch (band) {
+ case B_144: return member.isWorked144();
+ case B_432: return member.isWorked432();
+ case B_1296: return member.isWorked1240();
+ case B_2320: return member.isWorked2300();
+ case B_3400: return member.isWorked3400();
+ case B_5760: return member.isWorked5600();
+ case B_10G: return member.isWorked10G();
+ case B_24G: return member.isWorked24G();
+ default: return false;
+ }
+ }
+
}
/**
@@ -9172,8 +10139,11 @@ class ActionButtonTableCell extends TableCell {
}
}
+
}
+
+
/**
* This cell type is used to declare buttons which can be placed in the tableview
*
diff --git a/src/main/java/kst4contest/view/map/StationMapBridge.java b/src/main/java/kst4contest/view/map/StationMapBridge.java
index 60d0ae5..4fab734 100644
--- a/src/main/java/kst4contest/view/map/StationMapBridge.java
+++ b/src/main/java/kst4contest/view/map/StationMapBridge.java
@@ -6,7 +6,6 @@ import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView;
import javafx.util.Duration;
import kst4contest.controller.ChatController;
-import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences;
@@ -15,12 +14,13 @@ import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
+
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Consumer;
+import kst4contest.model.Band;
+import java.util.function.Predicate;
+
/**
* Synchronizes the application state with the station map window.
*
@@ -32,7 +32,7 @@ import java.util.function.Consumer;
*/
public final class StationMapBridge {
- private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
+
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
private final ChatController chatController;
@@ -45,8 +45,7 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
-// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
- private final PathAnalysisService pathAnalysisService;
+
private String lastPathAnalysisRequestSignature = "";
@@ -64,9 +63,7 @@ public final class StationMapBridge {
this.refreshCoalescer.setOnFinished(event -> refreshNow());
- this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
- new OpenMeteoTerrainProfileProvider()
- );
+
}
public void install() {
@@ -85,6 +82,10 @@ public final class StationMapBridge {
(obs, oldValue, newValue) -> scheduleRefresh()
);
+ chatController.getLst_chatMemberListFilterPredicates().addListener(
+ (ListChangeListener>) change -> requestImmediateRefresh()
+ );
+
requestImmediateRefresh();
}
@@ -170,6 +171,17 @@ public final class StationMapBridge {
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
}
+ /**
+ * Requests the selected station path analysis through the central reachability
+ * service.
+ *
+ * The map no longer owns a separate PathAnalysisService. This ensures that
+ * the map detail panel, the Tropo table column and the station filters all use
+ * exactly the same path/link-budget calculation.
+ *
+ * @param ownLocator6 own locator from preferences
+ * @param selectedSnapshot selected map marker snapshot
+ */
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
@@ -202,20 +214,28 @@ public final class StationMapBridge {
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
);
- pathAnalysisExecutor.submit(() -> {
- PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
+ ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
- Platform.runLater(() -> {
- if (generation != pathAnalysisGeneration.get()) {
- return;
+ chatController.getReachabilityService().requestPathAnalysisForMap(
+ selectedMember,
+ selectedSnapshot,
+ result -> {
+ if (generation != pathAnalysisGeneration.get()) {
+ return;
+ }
+ stationMapView.setPathAnalysisResult(result);
}
- stationMapView.setPathAnalysisResult(result);
- });
- });
+ );
}
+ /**
+ * Invalidates pending map callbacks.
+ *
+ * The actual calculation executor is owned by ReachabilityService now, so
+ * this bridge no longer shuts down any path-analysis thread directly.
+ */
public void dispose() {
- pathAnalysisExecutor.shutdownNow();
+ pathAnalysisGeneration.incrementAndGet();
}
private void handleMapCallsignSelection(String callSignRaw) {
@@ -286,43 +306,6 @@ public final class StationMapBridge {
return callSignRaw.trim().toUpperCase(Locale.ROOT);
}
- private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
- String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
-
- if (selectedSnapshot == null) {
- return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6);
- }
-
- String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
-
- if (normalizedOwnLocator6.length() != 6) {
- return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6);
- }
-
- if (!selectedSnapshot.hasUsablePosition()) {
- return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6);
- }
-
- Location homeLocation = new Location(normalizedOwnLocator6);
- double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
-
- PathAnalysisRequest request = new PathAnalysisRequest(
- normalizedOwnLocator6,
- homeLocation.getLatitude().toDegrees(),
- homeLocation.getLongitude().toDegrees(),
- selectedSnapshot.callSignRaw(),
- normalizedTargetLocator6,
- selectedSnapshot.latitudeDeg(),
- selectedSnapshot.longitudeDeg(),
- analysisFrequencyMHz,
- chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
- chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
- PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
- chatController.getChatPreferences().buildPathLinkBudgetSettings()
- );
-
- return pathAnalysisService.analyze(request);
- }
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) {
@@ -376,12 +359,5 @@ public final class StationMapBridge {
return locator.trim().toUpperCase(Locale.ROOT);
}
- private static final class PathAnalysisThreadFactory implements ThreadFactory {
- @Override
- public Thread newThread(Runnable runnable) {
- Thread thread = new Thread(runnable, "station-map-path-analysis");
- thread.setDaemon(true);
- return thread;
- }
- }
+
}
\ No newline at end of file
diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java
index 3f7b95a..ba504ac 100644
--- a/src/main/java/module-info.java
+++ b/src/main/java/module-info.java
@@ -15,5 +15,6 @@ module praktiKST {
exports kst4contest.model;
exports kst4contest.view;
+
opens kst4contest.view.map to javafx.web;
}
\ No newline at end of file
diff --git a/udpReaderBackup.txt b/udpReaderBackup.txt
index 54efa10..071924c 100644
--- a/udpReaderBackup.txt
+++ b/udpReaderBackup.txt
@@ -2365,4 +2365,9 @@ OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd
OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
-OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
\ No newline at end of file
+OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
+DO5SA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
+9A2HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
+9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
+9A2HM;null;null;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300true; wkd3400false; wkd5600false; wkd10Gfalse ; null
+OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
\ No newline at end of file