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. + * + *
    + *
  1. Use the lowest band detected in this session.
  2. + *
  3. If no session band exists and the station is in the microwave category, use 1296 MHz.
  4. + *
  5. Otherwise use 144 MHz.
  6. + *
+ * + * @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: + *

    + *
  1. knownActiveBands frequency for the band
  2. + *
  3. current displayed QRG if it belongs to the same band
  4. + *
  5. band default frequency
  6. + *
+ */ + 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