25 Commits
Author SHA1 Message Date
Marc Froehlich 57c662c7ab Added grid square coloring function and fixed bug of message categorizing after entering the chat and not selected a chatmember 2026-06-30 02:38:11 +02:00
Marc Froehlich a841914fcd Fixed Map: leaflet rendering in Java 21 Webview (disabling 3D in leaflet) 2026-06-30 01:56:13 +02:00
Marc Froehlich 2da9ba8152 Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load 2026-06-30 00:48:52 +02:00
Rsclub2_2 2693c3191f delete not needed files from repo 2026-06-30 00:48:52 +02:00
Marc Froehlich 4650c77227 Fixed worked-23cm-tag when working with dxlog/n1mm logger 2026-06-30 00:48:52 +02:00
Marc Froehlich 69c870284d Added filters and sorters for Tropo reachability, AS availability and new big fields / locators. Added reachability band selector for tropo calculation based on a choosen band 2026-06-30 00:48:52 +02:00
Marc Froehlich 29857b8f1b changed UI design to save some space. Made the UI more reactive for smaller screens. Maximized resolution at startup to the possible maximum of the current display of the user 2026-06-30 00:48:38 +02:00
Rsclub2_2 13c60291cc fix Pipeline Modules n map Linux 2026-06-30 00:48:38 +02:00
Marc Froehlich 2d32eaf462 debugging map failure 2026-06-30 00:48:31 +02:00
Marc Froehlich 04b9e0e75c debugging map failure 2026-06-30 00:48:31 +02:00
Marc Froehlich f7cb26fed5 debugging map failure 2026-06-30 00:48:31 +02:00
Marc Froehlich e898f6875d Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now 2026-06-30 00:48:31 +02:00
Rsclub2_2 1ce468145c i dont know any message for this. Still would like that audio will work in Flatpak 2026-06-30 00:17:31 +02:00
Rsclub2_2 2f4aac8cf7 fix CI/CD Java 21 with more upgrade to JDK21 2026-06-30 00:02:03 +02:00
Rsclub2_2 94a5f6a9f7 hopefully fix CI/CD 2026-06-29 23:44:33 +02:00
Rsclub2_2 3070d03893 JavaFX21 2026-06-29 23:35:01 +02:00
Rsclub2_2 a53e6a420f fix Pacman Package and try to fix audio.
Audio did not work, but better debug handling.
2026-06-29 23:27:29 +02:00
Rsclub2_2 57deda4b94 Flatpak Audio 2026-06-29 22:06:47 +02:00
Rsclub2_2 3632a0fcc9 Options do not exist, fix 2026-06-29 21:37:00 +02:00
Rsclub2_2 ff6b8b75a6 JavaFX Media Build Process to fix Problems with Packaging
thx DB2DY for the info.

Dependencies for native Linux Packages added.
2026-06-29 21:28:37 +02:00
Rsclub2_2 eee1594d30 Flatpak add Filesystem for persistent Storage 2026-06-29 17:49:52 +02:00
Rsclub2_2andMarc Froehlich ba3ef41fa0 Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load (#43)
* Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now

* debugging map failure

* debugging map failure

* debugging map failure

* fix Pipeline Modules n map Linux

* changed UI design to save some space. Made the UI more reactive for smaller screens. Maximized resolution at startup to the possible maximum of the current display of the user

* Docs added for new Features

* Added filters and sorters for Tropo reachability, AS availability and new big fields / locators. Added reachability band selector for tropo calculation based on a choosen band

* Fixed worked-23cm-tag when working with dxlog/n1mm logger

* delete not needed files from repo

* Reduced opentopo api calls // added 4 char locator new-filter // added new band and tropo filter and priority sorter // decreased map load

---------

Co-authored-by: Marc Froehlich <praktimarc@gmail.com>
2026-06-28 19:29:25 +02:00
Rsclub2_2 631b661265 fix flatpak key again. 2026-06-27 21:11:13 +02:00
Rsclub2_2 42d397ffad feat(flatpak): multi-OSTree pipeline — nightly, beta, stable
fix(flatpak): regenerate summary in publish jobs, add flatpak tooling + gpg key

Author: Philipp Wagner <philipp@wagnersnetz.de>
2026-06-27 20:19:01 +02:00
Rsclub2_2andMarc Froehlich 8413a1296d Feature/map view and filters (#40)
Nightly Runtime Artifacts / Build Windows ZIP (push) Has been cancelled
Nightly Runtime Artifacts / Build Linux AppImage (push) Has been cancelled
Nightly Runtime Artifacts / Build Debian package (push) Has been cancelled
Nightly Runtime Artifacts / Build Fedora package (push) Has been cancelled
Nightly Runtime Artifacts / Build Arch Linux package (push) Has been cancelled
Nightly Runtime Artifacts / Build Flatpak (push) Has been cancelled
Nightly Runtime Artifacts / Build macOS DMG (macos-15-intel) (push) Has been cancelled
Nightly Runtime Artifacts / Build macOS DMG (macos-latest) (push) Has been cancelled
* Added a map to show where other stn are // refactored message adding to tables for performance, max 30.000 msg now

* debugging map failure

* debugging map failure

* debugging map failure

* fix Pipeline Modules n map Linux

---------

Co-authored-by: Marc Froehlich <praktimarc@gmail.com>
2026-06-23 01:12:02 +02:00
34 changed files with 5124 additions and 690 deletions
+7 -7
View File
@@ -58,7 +58,7 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql --dest dist jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec --dest dist
- name: Create Windows ZIP - name: Create Windows ZIP
shell: pwsh shell: pwsh
@@ -113,7 +113,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
@@ -198,7 +198,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
DEB="$(ls dist/*.deb | head -n 1)" DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
@@ -256,7 +256,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
RPM="$(ls dist/*.rpm | head -n 1)" RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
@@ -316,7 +316,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
@@ -413,7 +413,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest target/flatpak-src --dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
@@ -511,7 +511,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
env: env:
+3 -3
View File
@@ -11,18 +11,18 @@ env:
jobs: jobs:
compile: compile:
name: Compile (Java 17) name: Compile (Java 21)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4.1.7 uses: actions/checkout@v4.1.7
- name: Set up Java 17 - name: Set up Java 21
uses: actions/setup-java@v4.1.0 uses: actions/setup-java@v4.1.0
with: with:
distribution: temurin distribution: temurin
java-version: "17" java-version: "21"
- name: Ensure mvnw is executable - name: Ensure mvnw is executable
run: chmod +x mvnw run: chmod +x mvnw
+7 -7
View File
@@ -46,7 +46,7 @@ jobs:
shell: pwsh shell: pwsh
run: | run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null New-Item -ItemType Directory -Force -Path dist | Out-Null
jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql --dest dist jpackage --type app-image --name praktiKST --input target/dist-libs --main-jar app.jar --main-class kst4contest.view.Kst4ContestApplication --module-path target/dist-libs --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec --dest dist
- name: Create Windows ZIP - name: Create Windows ZIP
shell: pwsh shell: pwsh
@@ -94,7 +94,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
- name: Create AppDir metadata - name: Create AppDir metadata
@@ -172,7 +172,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
DEB="$(ls dist/*.deb | head -n 1)" DEB="$(ls dist/*.deb | head -n 1)"
if [ -z "$DEB" ]; then if [ -z "$DEB" ]; then
@@ -223,7 +223,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
RPM="$(ls dist/*.rpm | head -n 1)" RPM="$(ls dist/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then if [ -z "$RPM" ]; then
@@ -274,7 +274,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
- name: Build Arch Linux package artifact - name: Build Arch Linux package artifact
@@ -362,7 +362,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest target/flatpak-src --dest target/flatpak-src
- name: Create Flatpak manifest - name: Create Flatpak manifest
@@ -477,7 +477,7 @@ jobs:
--main-jar app.jar \ --main-jar app.jar \
--main-class kst4contest.view.Kst4ContestApplication \ --main-class kst4contest.view.Kst4ContestApplication \
--module-path target/dist-libs \ --module-path target/dist-libs \
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \ --add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql,java.net.http,jdk.crypto.ec \
--dest dist --dest dist
env: env:
-2
View File
@@ -1,2 +0,0 @@
S53MM
PA9R
+12
View File
@@ -8,6 +8,18 @@ Versionsverlauf von KST4Contest / PraktiKST.
letzter Changelog bitte aus GitHub entnehmen. Der bisherige Changelog 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) ## v1.40 (2026-02-16)
**Großes Feature-Release: Score-System, AP-Timeline, Win-Test, PSTRotator** **Großes Feature-Release: Score-System, AP-Timeline, Win-Test, PSTRotator**
+36
View File
@@ -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. 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. 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.
+37 -2
View File
@@ -116,12 +116,47 @@ Die Datei `de.x08.KST4Contest.flatpakref` aus dem [aktuellen Release](https://gi
flatpak install de.x08.KST4Contest.flatpakref flatpak install de.x08.KST4Contest.flatpakref
``` ```
Oder den Remote manuell hinzufügen: Oder den Remote manuell hinzufügen (empfohlen für Nightly/Beta-Zugriff):
```bash ```bash
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/ flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
flatpak install kst4contest de.x08.KST4Contest flatpak install kst4contest de.x08.KST4Contest
``` ```
#### Flatpak-Kanäle (nightly / beta / stable)
Das Flatpak-Repository enthält drei Kanäle, die alle im selben Remote liegen:
| Kanal | Inhalt | Wird gebaut bei |
|---|---|---|
| `stable` | Aktuelle stabile Version | Jedem normalen Release-Tag |
| `beta` | Vorab-Version | Tags mit `beta-` Präfix |
| `nightly` | Entwicklungsstand (main-Branch) | Täglich / jedem Push auf main |
Remote einmalig hinzufügen, dann den gewünschten Kanal installieren:
```bash
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
# Stable (Standard)
flatpak install kst4contest de.x08.KST4Contest
# Beta
flatpak install kst4contest de.x08.KST4Contest//beta
# Nightly
flatpak install kst4contest de.x08.KST4Contest//nightly
```
> **Hinweis:** Flatpak erlaubt nur eine installierte Version pro App-ID gleichzeitig. Um den Kanal zu wechseln, zuerst die aktuelle Version deinstallieren:
> ```bash
> flatpak uninstall de.x08.KST4Contest
> flatpak install kst4contest de.x08.KST4Contest//nightly
> ```
Updates erfolgen wie gewohnt mit `flatpak update`.
Die `flatpakref`-Dateien für Beta und Stable liegen jeweils im entsprechenden [GitHub-Release](https://github.com/praktimarc/kst4contest/releases). Das Nightly-`flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) ist als Artifact im [GitHub Actions](https://github.com/praktimarc/kst4contest/actions)-Tab verfügbar (wird 14 Tage aufbewahrt) der Remote-Weg oben ist für Nightly daher meist einfacher.
### macOS ### macOS
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel). 1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
2. DMG-Datei öffnen. 2. DMG-Datei öffnen.
+12
View File
@@ -8,6 +8,18 @@ Version history of KST4Contest / PraktiKST.
For the latest changelog, please refer to GitHub. The previous changelog is below. 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) ## v1.40 (2026-02-16)
**Major Feature Release: Score System, AP Timeline, Win-Test, PSTRotator** **Major Feature Release: Score System, AP Timeline, Win-Test, PSTRotator**
+36
View File
@@ -233,3 +233,39 @@ File-based log evaluation using regex. Details: [Log Synchronisation](Log-Sync#m
## Cluster & QSO of Others ## 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. 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.
+37 -2
View File
@@ -116,12 +116,47 @@ Download `de.x08.KST4Contest.flatpakref` from the [latest release](https://githu
flatpak install de.x08.KST4Contest.flatpakref flatpak install de.x08.KST4Contest.flatpakref
``` ```
Or add the remote manually: Or add the remote manually (recommended for nightly/beta access):
```bash ```bash
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/ flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
flatpak install kst4contest de.x08.KST4Contest flatpak install kst4contest de.x08.KST4Contest
``` ```
#### Flatpak Channels (nightly / beta / stable)
The Flatpak repository provides three channels, all served from the same remote:
| Channel | Content | Built on |
|---|---|---|
| `stable` | Current stable version | Every normal release tag |
| `beta` | Pre-release version | Tags prefixed with `beta-` |
| `nightly` | Development build (main branch) | Every push to main / daily |
Add the remote once, then install the channel you want:
```bash
flatpak remote-add --if-not-exists kst4contest https://praktimarc.github.io/kst4contest/kst4contest.flatpakrepo
# Stable (default)
flatpak install kst4contest de.x08.KST4Contest
# Beta
flatpak install kst4contest de.x08.KST4Contest//beta
# Nightly
flatpak install kst4contest de.x08.KST4Contest//nightly
```
> **Note:** Flatpak only allows one installed version per App-ID at a time. To switch channels, uninstall the current version first:
> ```bash
> flatpak uninstall de.x08.KST4Contest
> flatpak install kst4contest de.x08.KST4Contest//nightly
> ```
Updates work as usual with `flatpak update`.
The `flatpakref` files for beta and stable are attached to their respective [GitHub Releases](https://github.com/praktimarc/kst4contest/releases). The nightly `flatpakref` (`de.x08.KST4Contest.nightly.flatpakref`) is available as an artifact on the [GitHub Actions](https://github.com/praktimarc/kst4contest/actions) tab (kept for 14 days) — for nightly builds, the manual remote approach above is usually more convenient.
### macOS ### macOS
1. Download the DMG file for your architecture (Apple Silicon or Intel). 1. Download the DMG file for your architecture (Apple Silicon or Intel).
2. Open the DMG file. 2. Open the DMG file.
+14 -27
View File
@@ -24,12 +24,12 @@
<launcher>${project.artifactId}</launcher> <launcher>${project.artifactId}</launcher>
<appName>${project.artifactId}</appName> <appName>${project.artifactId}</appName>
<main.class>kst4contest.view.Kst4ContestApplication</main.class> <main.class>kst4contest.view.Kst4ContestApplication</main.class>
<java.version>17</java.version> <java.version>21</java.version>
<required.maven.version>3.6.3</required.maven.version> <required.maven.version>3.6.3</required.maven.version>
<jar.filename>${project.artifactId}-${project.version}</jar.filename> <jar.filename>${project.artifactId}-${project.version}</jar.filename>
<!-- Dependency versions --> <!-- Dependency versions -->
<javafx.version>19.0.2.1</javafx.version> <javafx.version>21.0.5</javafx.version>
<jetbrains.annotations.version>24.0.1</jetbrains.annotations.version> <jetbrains.annotations.version>24.0.1</jetbrains.annotations.version>
<junit.version>5.10.1</junit.version> <junit.version>5.10.1</junit.version>
<lombok.version>1.18.44</lombok.version> <lombok.version>1.18.44</lombok.version>
@@ -50,8 +50,8 @@
<maven.wrapper.plugin>3.2.0</maven.wrapper.plugin> <maven.wrapper.plugin>3.2.0</maven.wrapper.plugin>
<moditect.maven.plugin>1.0.0.RC2</moditect.maven.plugin> <moditect.maven.plugin>1.0.0.RC2</moditect.maven.plugin>
<jpackage.maven.plugin>0.1.3</jpackage.maven.plugin> <jpackage.maven.plugin>0.1.3</jpackage.maven.plugin>
<maven.pmd.version>3.21.2</maven.pmd.version> <maven.pmd.version>3.28.0</maven.pmd.version>
<pmd.version>6.55.0</pmd.version> <pmd.version>7.17.0</pmd.version>
<codehaus.version.plugin>2.16.1</codehaus.version.plugin> <codehaus.version.plugin>2.16.1</codehaus.version.plugin>
<javafx.maven.plugin>0.0.8</javafx.maven.plugin> <javafx.maven.plugin>0.0.8</javafx.maven.plugin>
<spotbugs.maven.plugin>4.9.8.2</spotbugs.maven.plugin> <spotbugs.maven.plugin>4.9.8.2</spotbugs.maven.plugin>
@@ -93,6 +93,13 @@
<version>${javafx.version}</version> <version>${javafx.version}</version>
</dependency> </dependency>
<!-- JLayer: pure-Java MP3 decoder, used instead of JavaFX Media for Flatpak audio compatibility -->
<dependency>
<groupId>javazoom</groupId>
<artifactId>jlayer</artifactId>
<version>1.0.1</version>
</dependency>
<!-- SQLite --> <!-- SQLite -->
<dependency> <dependency>
<groupId>org.xerial</groupId> <groupId>org.xerial</groupId>
@@ -280,30 +287,7 @@
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId> <artifactId>maven-pmd-plugin</artifactId>
<version>${maven.pmd.version}</version> <version>${maven.pmd.version}</version>
<dependencies>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-core</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-java</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-javascript</artifactId>
<version>${pmd.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.pmd</groupId>
<artifactId>pmd-jsp</artifactId>
<version>${pmd.version}</version>
</dependency>
</dependencies>
<configuration> <configuration>
<sourceEncoding>${project.build.sourceEncoding}</sourceEncoding>
<minimumTokens>100</minimumTokens> <minimumTokens>100</minimumTokens>
<targetJdk>${java.version}</targetJdk> <targetJdk>${java.version}</targetJdk>
<linkXRef>false</linkXRef> <linkXRef>false</linkXRef>
@@ -436,7 +420,10 @@
<addmodule>javafx.graphics</addmodule> <addmodule>javafx.graphics</addmodule>
<addmodule>javafx.fxml</addmodule> <addmodule>javafx.fxml</addmodule>
<addmodule>javafx.web</addmodule> <addmodule>javafx.web</addmodule>
<addmodule>javafx.media</addmodule>
<addmodule>java.sql</addmodule> <addmodule>java.sql</addmodule>
<addmodule>java.net.http</addmodule>
<addmodule>jdk.crypto.ec</addmodule>
</addmodules> </addmodules>
<mainclass>${main.class}</mainclass> <mainclass>${main.class}</mainclass>
<input>${project.build.directory}/modules</input> <input>${project.build.directory}/modules</input>
@@ -1077,6 +1077,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
private DBController dbHandler; private DBController dbHandler;
private ReachabilityService reachabilityService;
private final WorkedGrossFieldCache workedGrossFieldCache = new WorkedGrossFieldCache();
private Socket socket; private Socket socket;
private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client) private ServerSocket cluster_telnetServerSocket; // socket that accepts telnet client connects (cluster client)
// private ServerSocketChannel cluster_telnetServerSocketChannel; // private ServerSocketChannel cluster_telnetServerSocketChannel;
@@ -1201,12 +1205,26 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
} }
public void fireUserListUpdate(String reason) { /**
if (statusListener != null) { * Notifies the UI that station-list derived values have changed.
// Da UI Updates im JavaFX Thread passieren müssen, hier oder im Listener Platform.runLater nutzen *
statusListener.onUserListUpdated(reason); * <p>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.</p>
*
* @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 // * 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(); dbHandler = new DBController();
reachabilityService = new ReachabilityService(this);
rebuildWorkedGrossFieldCacheFromDatabase();
// chatPreferences = new ChatPreferences(); // chatPreferences = new ChatPreferences();
// chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted // chatPreferences.readPreferencesFromXmlFile(); // set the praktikst Prefs by file or default if file is corrupted
@@ -2090,6 +2110,110 @@ 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 four-character grid square has already been
* worked on any band.
*
* <p>This method intentionally ignores the currently enabled own bands. The grid
* status in the user list is meant to be a simple and robust any-band indicator.</p>
*
* @param member member to inspect
* @return true if the gross grid square was worked on any band
*/
public boolean isGridSquareWorkedAny(ChatMember member) {
if (member == null || member.getQra() == null) {
return false;
}
return workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
}
/**
* Returns true when the member's four-character grid square has not been worked
* on any band yet.
*
* <p>This is the predicate behind the optional "Only new grids" filter. It no
* longer depends on active station band settings.</p>
*
* @param member member to inspect
* @return true if the gross grid square is still new
*/
public boolean isNewGridSquare(ChatMember member) {
if (member == null || member.getQra() == null) {
return false;
}
return !workedGrossFieldCache.isGrossFieldWorkedAny(member.getQra());
}
public long getCurrentEpochTime() { public long getCurrentEpochTime() {
OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC); OffsetDateTime currentTimeInUtc = OffsetDateTime.now(ZoneOffset.UTC);
@@ -2368,6 +2492,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
return; return;
} }
rebuildWorkedGrossFieldCacheFromDatabase();
HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase; HashMap<String, ChatMember> finalWorkedDataFromDatabase = workedDataFromDatabase;
Platform.runLater(() -> { Platform.runLater(() -> {
@@ -14,6 +14,12 @@ import kst4contest.ApplicationConstants;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.utils.ApplicationFileUtils; import kst4contest.utils.ApplicationFileUtils;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.Set;
import kst4contest.model.Band;
public class DBController { public class DBController {
/** /**
@@ -37,7 +43,7 @@ public class DBController {
* marker. The marker is stored in SQLite PRAGMA user_version so the expensive * marker. The marker is stored in SQLite PRAGMA user_version so the expensive
* normalization rebuild is executed only once per database file. * 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 * Minimum interval between two expiration cleanup runs. This avoids repeated full
@@ -136,6 +142,7 @@ public class DBController {
*/ */
private synchronized void ensureChatMemberTableCompatibility() { private synchronized void ensureChatMemberTableCompatibility() {
createChatMemberTableIfRequired(); createChatMemberTableIfRequired();
createWorkedGrossFieldTableIfRequired();
versionUpdateOfDBCheckAndChangeV11ToV12(); versionUpdateOfDBCheckAndChangeV11ToV12();
versionUpdateOfDBCheckAndChangeV12ToV13(); 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 * Updates old v1.1 databases to the v1.2 schema by adding the not-QRV fields if
* they are missing. * they are missing.
@@ -477,7 +508,15 @@ public class DBController {
} catch (SQLException e) { } catch (SQLException e) {
throw new RuntimeException("[DBH, ERROR:] Could not reset expired worked data", 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 * Stores a chatmember with its metadata in the database. The unique key is always
@@ -612,8 +651,14 @@ public class DBController {
+ "lastFlagsChangeEpochMs = 0;"; + "lastFlagsChangeEpochMs = 0;";
try (Statement statement = connection.createStatement()) { 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) { } catch (SQLException e) {
System.err.println("[DBH, ERROR:] Couldn't reset the worked data"); System.err.println("[DBH, ERROR:] Couldn't reset the worked data");
e.printStackTrace(); e.printStackTrace();
return -1; return -1;
@@ -637,16 +682,30 @@ public class DBController {
String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore); String workedBandColumnName = helper_resolveWorkedBandColumnName(chatMemberToStore);
if (workedBandColumnName == null) { if (workedBandColumnName == null) {
System.out.println("[DBCtrl, Error]: unknown at which band the qso had been!"); return helper_updateWorkedAnyOnChatMember(chatMemberToStore);
return false;
} }
// String updateWorkedSql =
// "UPDATE ChatMember SET worked = 1, " + workedBandColumnName + " = 1, lastFlagsChangeEpochMs = ? WHERE callsign = ?;";
String updateWorkedSql = 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)) { try (PreparedStatement preparedStatement = connection.prepareStatement(updateWorkedSql)) {
preparedStatement.setLong(1, System.currentTimeMillis()); // preparedStatement.setLong(1, System.currentTimeMillis());
preparedStatement.setString(2, chatMemberToStore.getCallSignRaw()); // 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(); int affectedRows = preparedStatement.executeUpdate();
return affectedRows > 0; return affectedRows > 0;
@@ -657,6 +716,47 @@ public class DBController {
} }
} }
/**
* Updates only the global worked-any flag of a stored chatmember row.
*
* <p>This is used when an external logger confirms that a station was worked,
* but the software cannot reliably map the QSO to one of the persisted band
* columns. The UI status "x" is based on this global worked flag, so this method
* makes no-band or unsupported-band log entries persistent as worked-any.</p>
*
* @param chatMemberToStore chatmember that contains the worked call and optional locator
* @return true if an existing database row was updated
* @throws SQLException if the database write fails
*/
private synchronized boolean helper_updateWorkedAnyOnChatMember(ChatMember chatMemberToStore) throws SQLException {
if (chatMemberToStore == null
|| chatMemberToStore.getCallSignRaw() == null
|| chatMemberToStore.getCallSignRaw().isBlank()) {
return false;
}
String updateWorkedAnySql =
"UPDATE ChatMember SET worked = 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(updateWorkedAnySql)) {
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;
}
}
/** /**
* Updates all not-QRV flags for a chatmember row. The method uses the normalized * Updates all not-QRV flags for a chatmember row. The method uses the normalized
* raw callsign and updates the timestamp so that automatic contest cleanup can * raw callsign and updates the timestamp so that automatic contest cleanup can
@@ -955,4 +1055,79 @@ public class DBController {
// dbc.storeChatMember(dummy); // dbc.storeChatMember(dummy);
// dbc.updateWkdInfoOnChatMember(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<Band, Set<String>> fetchWorkedGrossFieldsFromDB() {
resetExpiredWorkedDataIfRequired();
Map<Band, Set<String>> 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;
}
} }
@@ -327,6 +327,8 @@ public class MessageBusManagementThread extends Thread {
// 1. Store in the new Map (for future context/history) // 1. Store in the new Map (for future context/history)
sender.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); 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) //propagate known frequency to all instances of the same callsign (callRaw may exist multiple times)
try { try {
@@ -335,6 +337,8 @@ public class MessageBusManagementThread extends Thread {
ChatMember cm = client.getLst_chatMemberList().get(idx); ChatMember cm = client.getLst_chatMemberList().get(idx);
if (cm != null && cm != sender) { if (cm != null && cm != sender) {
cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency); cm.addKnownFrequency(finalDetectedBand, finalDetectedFrequency);
// No automatic full terrain analysis here.
// Avoids exhausting the online terrain API when many stations mention QRGs.
} }
} }
} catch (Exception e) { } catch (Exception e) {
@@ -587,6 +591,8 @@ public class MessageBusManagementThread extends Thread {
if (!client.getChatPreferences().getStn_loginCallSign().equals(newMember.getCallSign())) { 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.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); newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember);
this.client.getLst_chatMemberList().add(newMember); this.client.getLst_chatMemberList().add(newMember);
// this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember);
this.client.getDbHandler().storeChatMember(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! break;// TODO Change at may24, avoid uncloadability. Check if this could lead to further errors on instable link!
// client.getMessageRXBus().clear(); // 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 } // while true end
System.out.println("Msgbusmgt: interrupt"); System.out.println("Msgbusmgt: interrupt");
@@ -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.
*
* <p>This service is now the single calculation path for:
* <ul>
* <li>the station table Tropo column</li>
* <li>the Tropo filter/sorter</li>
* <li>the station map path-analysis detail panel</li>
* </ul>
*
* <p>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.</p>
*/
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<String> 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<String, PathAnalysisResult> pathAnalysisResultCache = new ConcurrentHashMap<>();
/**
* Temporary callback list for map requests waiting for an already running
* background calculation.
*/
private final Map<String, List<Consumer<PathAnalysisResult>>> 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<String, Long> 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.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @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<PathAnalysisResult> 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.
*
* <p>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.</p>
*
* @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.
*
* <ol>
* <li>Use the lowest band detected in this session.</li>
* <li>If no session band exists and the station is in the microwave category, use 1296 MHz.</li>
* <li>Otherwise use 144 MHz.</li>
* </ol>
*
* @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<Band> getEnabledStationBands() {
ChatPreferences preferences = chatController.getChatPreferences();
EnumSet<Band> 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<PathAnalysisResult> 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.
*
* <p>Preference order:
* <ol>
* <li>knownActiveBands frequency for the band</li>
* <li>current displayed QRG if it belongs to the same band</li>
* <li>band default frequency</li>
* </ol>
*/
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<PathAnalysisResult> 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<Consumer<PathAnalysisResult>> callbacks = pendingMapCallbacksByKey.remove(calculationKey);
if (callbacks == null || callbacks.isEmpty()) {
return;
}
List<Consumer<PathAnalysisResult>> callbackSnapshot;
synchronized (callbacks) {
callbackSnapshot = new ArrayList<>(callbacks);
}
for (Consumer<PathAnalysisResult> 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<PathAnalysisResult> 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.
*
* <p>The key includes station, locators, frequency, antenna heights and link
* budget settings. If any relevant input changes, a new calculation is allowed.</p>
*/
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;
}
}
}
@@ -2,6 +2,7 @@ package kst4contest.controller;
import javafx.application.Platform; import javafx.application.Platform;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import kst4contest.model.Band;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ThreadStateMessage; import kst4contest.model.ThreadStateMessage;
import kst4contest.view.GuiUtils; import kst4contest.view.GuiUtils;
@@ -286,6 +287,72 @@ public class ReadUDPByWintestThread extends Thread {
// socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871)); // socket.send(new DatagramPacket(bytes, bytes.length, broadcast, 9871));
// } // }
/**
* Resolves the project Band enum from Win-Test band IDs.
*
* <p>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.</p>
*
* @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.
*
* <p>Current parser model based on the existing split-by-quotes code:
* <ul>
* <li>{@code split("\"")[7]} = callsign</li>
* <li>{@code split("\"")[11]} = received exchange, e.g. 599001</li>
* <li>{@code split("\"")[13]} = locator, e.g. JO51UM</li>
* </ul>
*
* <p>If the dedicated locator field is empty, the exchange is used as fallback.</p>
*
* @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<br/> * Catches add-qso messages of wintest if a new qso gets into the log<br/>
* *
@@ -310,16 +377,22 @@ public class ReadUDPByWintestThread extends Thread {
// receivedQsos.put(qsoNumber, msg); // receivedQsos.put(qsoNumber, msg);
// lastKnownQso = Math.max(lastKnownQso, qsoNumber); // lastKnownQso = Math.max(lastKnownQso, qsoNumber);
String callSignCatched = msg.split("\"") [7]; String callSignCatched = msg.split("\"") [7];
String locatorFromLogger = helper_resolveLocatorFromWinTestAddQso(msg);
ChatMember workedCall = new ChatMember(); ChatMember workedCall = new ChatMember();
workedCall.setCallSign(callSignCatched); workedCall.setCallSign(callSignCatched);
workedCall.setWorked(true); //its worked at this place, for sure! workedCall.setWorked(true); //its worked at this place, for sure!
if (locatorFromLogger != null) {
workedCall.setQra(locatorFromLogger);
}
ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall); ArrayList<Integer> markTheseChattersAsWorked = client.checkListForChatMemberIndexesByCallSign(workedCall);
String bandId; String bandId;
bandId = msg.split("\"")[6].split(" ")[4].trim(); bandId = msg.split("\"")[6].split(" ")[4].trim();
Band workedBand = helper_resolveBandFromWinTestBandId(bandId);
switch (bandId) { switch (bandId) {
case "10" -> workedCall.setWorked50(true); case "10" -> workedCall.setWorked50(true);
case "11" -> workedCall.setWorked70(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); 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()) { if (!markTheseChattersAsWorked.isEmpty()) {
//Worked call is part of the current chatmember list //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 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()) { if (workedCall.isWorked50()) {
modifyThat.setWorked50(true); modifyThat.setWorked50(true);
} else if (workedCall.isWorked70()) { } else if (workedCall.isWorked70()) {
@@ -398,7 +482,11 @@ public class ReadUDPByWintestThread extends Thread {
if (!isInChat) { if (!isInChat) {
workedCall.setName("unknown"); workedCall.setName("unknown");
workedCall.setQra("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall); this.client.getDbHandler().storeChatMember(workedCall);
} }
@@ -22,6 +22,9 @@ import org.xml.sax.InputSource;
import org.xml.sax.SAXException; import org.xml.sax.SAXException;
import kst4contest.model.ChatMember; 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 * This thread is responsible for reading server's input and printing it to the
@@ -64,6 +67,110 @@ public class ReadUDPbyUCXMessageThread extends Thread {
} }
} }
/**
* 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("<?xml");
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<contactinfo");
}
if (xmlStart < 0) {
xmlStart = rawPacket.indexOf("<RadioInfo");
}
return xmlStart >= 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() { public void run() {
System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP"); System.out.println("ReadUDPByUCXLogThread: started Thread for UCXLog getUDP");
@@ -177,7 +284,7 @@ public class ReadUDPbyUCXMessageThread extends Thread {
File logUDPMessageToThisFile; File logUDPMessageToThisFile;
String udpMsg = udpPacketToProcess; String udpMsg = helper_extractXmlPayload(udpPacketToProcess);
ChatMember modifyThat = null; ChatMember modifyThat = null;
@@ -221,10 +328,9 @@ public class ReadUDPbyUCXMessageThread extends Thread {
Element element = (Element) node; Element element = (Element) node;
String call = element.getElementsByTagName("call").item(0).getTextContent(); String call = element.getElementsByTagName("call").item(0).getTextContent();
// call = call.toLowerCase(); String band = helper_getOptionalElementText(element, "band");
String band = element.getElementsByTagName("band").item(0).getTextContent(); String gridSquare = helper_resolveLocatorFromContactInfo(element);
String points = helper_getOptionalElementText(element, "points");
String points = element.getElementsByTagName("points").item(0).getTextContent();
System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName() System.out.println("[Readudp, info ]: received Current Element :" + node.getNodeName()
+ "call: " + call + " / " + band + " ----> " + points + " POINTS"); + "call: " + call + " / " + band + " ----> " + points + " POINTS");
@@ -235,89 +341,69 @@ public class ReadUDPbyUCXMessageThread extends Thread {
workedCall.setCallSign(call); workedCall.setCallSign(call);
workedCall.setWorked(true); workedCall.setWorked(true);
if (gridSquare != null) {
workedCall.setQra(gridSquare);
}
Band workedBand = helper_resolveBandFromLoggerBand(band);
switch (band) { switch (band) {
case "144": { case "144":
case "2m": //minos contest logger
{
workedCall.setWorked144(true); workedCall.setWorked144(true);
break; break;
} }
case "432": { case "432":
workedCall.setWorked432(true); case "70cm":
break; {
} workedCall.setWorked432(true);
break;
}
case "1240": { case "1240": //ucxlog style
case "1296": //used for n1mm / Dxlog
case "23cm": //minos contest logger
{
workedCall.setWorked1240(true); workedCall.setWorked1240(true);
break; break;
} }
case "2300": { case "2300":
case "13cm":
{
workedCall.setWorked2300(true); workedCall.setWorked2300(true);
break; break;
} }
case "3400": { case "3400":
case "9cm":
{
workedCall.setWorked3400(true); workedCall.setWorked3400(true);
break; break;
} }
case "5600": { case "5600":
case "6cm":
{
workedCall.setWorked5600(true); workedCall.setWorked5600(true);
break; break;
} }
case "10G": { case "10G":
case "3cm":
{
workedCall.setWorked10G(true); workedCall.setWorked10G(true);
break; break;
} }
/**
* cases hotfix for MINOS logger, which tells band like "2m", not "144" default:
*/ System.out.println("[ReadUDPFromUCX, Error:] unexpected band value: \"" + band + "\"");
case "2m": { break;
workedCall.setWorked144(true);
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 * That means, the station is worked already but maybe at another band. So we
@@ -452,6 +538,10 @@ public class ReadUDPbyUCXMessageThread extends Thread {
*/ */
} }
if (workedBand != null && gridSquare != null) {
this.client.registerWorkedGrossField(workedBand, gridSquare, workedCall, "UCXLOG");
}
boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall); boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall);
// This will update the worked info on a worked chatmember. DBHandler will // 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 // check, if an entry at the db had been modified. If not, then the worked
@@ -459,7 +549,11 @@ public class ReadUDPbyUCXMessageThread extends Thread {
if (!isInChat) { if (!isInChat) {
workedCall.setName("unknown"); workedCall.setName("unknown");
workedCall.setQra("unknown");
if (workedCall.getQra() == null || workedCall.getQra().isBlank()) {
workedCall.setQra("unknown");
}
workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat());
this.client.getDbHandler().storeChatMember(workedCall); this.client.getDbHandler().storeChatMember(workedCall);
} }
@@ -550,7 +644,12 @@ public class ReadUDPbyUCXMessageThread extends Thread {
// System.out.println("Radio Mode: " + mode); // System.out.println("Radio Mode: " + mode);
// System.out.println("[ReadUDPFromUCX, Info:] Setted QRG pref to: \"" + qrg + "\"" ); // 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); // System.out.println("[ReadUDPbyUCXTh: ] Radioinfo processed: " + formattedQRG);
} }
@@ -604,6 +703,28 @@ public class ReadUDPbyUCXMessageThread extends Thread {
this.socket.close(); this.socket.close();
return true; return true;
}
/**
* Runs UI-bound changes on the JavaFX application thread.
*
* <p>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".</p>
*
* @param runnable UI-bound update
*/
private void helper_runOnFxThread(Runnable runnable) {
if (runnable == null) {
return;
}
if (Platform.isFxApplicationThread()) {
runnable.run();
} else {
Platform.runLater(runnable);
}
} }
} }
@@ -182,6 +182,7 @@ public final class ScoreService {
// 4) Publish to UI in ONE batched runLater // 4) Publish to UI in ONE batched runLater
Platform.runLater(() -> { Platform.runLater(() -> {
applyScoreSnapshotToChatMembers(snap);
topCandidatesFx.setAll(snap.getTopCandidates()); topCandidatesFx.setAll(snap.getTopCandidates());
updateSelectedScoreFromSnapshot(snap); updateSelectedScoreFromSnapshot(snap);
uiPulse.set(uiPulse.get() + 1); uiPulse.set(uiPulse.get() + 1);
@@ -237,6 +238,31 @@ public final class ScoreService {
return representative; 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<String, Double> 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) { private void updateSelectedScoreFromSnapshot(ScoreSnapshot snap) {
if (snap == null || selectedCallSignRaw == null) { if (snap == null || selectedCallSignRaw == null) {
@@ -0,0 +1,200 @@
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.
*
* <p>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.</p>
*/
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<Band, Set<String>> 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<Band, Set<String>> databaseSnapshot) {
workedGrossFieldsByBand.clear();
if (databaseSnapshot == null) {
return;
}
for (Map.Entry<Band, Set<String>> entry : databaseSnapshot.entrySet()) {
Band band = entry.getKey();
Set<String> grossFields = entry.getValue();
if (band == null || grossFields == null) {
continue;
}
Set<String> 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<ChatMember> 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);
}
/**
* Checks whether a locator gross field has already been worked on any band.
*
* <p>The UI grid status and the "Only new grids" filter use this any-band logic.
* This avoids user errors caused by wrong active-band settings and fits single
* band contests such as NAC better.</p>
*
* @param locatorOrGrossField six-character locator or four-character gross field
* @return true if the gross field exists in the cache on any band
*/
public synchronized boolean isGrossFieldWorkedAny(String locatorOrGrossField) {
String grossField = extractGrossField(locatorOrGrossField);
if (grossField == null) {
return false;
}
for (Set<String> workedGrossFields : workedGrossFieldsByBand.values()) {
if (workedGrossFields != null && workedGrossFields.contains(grossField)) {
return true;
}
}
return false;
}
/**
* 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;
}
}
+29
View File
@@ -29,6 +29,35 @@ public enum Band {
return prefix; 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. * Checks if a specific frequency falls within this band's limits.
*/ */
@@ -10,10 +10,13 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty; import javafx.beans.property.StringProperty;
import java.util.OptionalDouble;
public class ChatMember { public class ChatMember {
long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB long lastFlagsChangeEpochMs; // timestamp of the last worked/not-QRV flag change in the internal DB
// private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty(); // private final BooleanProperty workedInfoChangeFireListEventTrigger = new SimpleBooleanProperty();
@@ -74,6 +77,10 @@ public class ChatMember {
// Stores the last known frequency per band (Context History) // Stores the last known frequency per band (Context History)
private final Map<Band, ActiveFrequencyInfo> knownActiveBands = new ConcurrentHashMap<>(); private final Map<Band, ActiveFrequencyInfo> 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<Band, Double> tropoSsbMarginDbByBand = new ConcurrentHashMap<>();
// --- INNER CLASS FOR QRG HISTORY --- // --- INNER CLASS FOR QRG HISTORY ---
public class ActiveFrequencyInfo { public class ActiveFrequencyInfo {
@@ -620,6 +627,108 @@ public class ChatMember {
return knownActiveBands; return knownActiveBands;
} }
/**
* Stores the calculated bidirectional SSB tropo margin for one band.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @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.
*
* <p>The method distinguishes three states:
* <ul>
* <li>{@code ... @144}: analysis has not run yet</li>
* <li>{@code ? @144}: analysis ran, but no usable link budget was produced</li>
* <li>{@code +8.4 dB @144}: finite SSB margin exists</li>
* </ul>
*
* @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 * If a sked fails and the user tells this to the client, this counter will be increased to give the station a
@@ -1,13 +1,18 @@
package kst4contest.utils; package kst4contest.utils;
import javafx.scene.media.Media; import javazoom.jl.decoder.JavaLayerException;
import javafx.scene.media.MediaPlayer; import javazoom.jl.player.Player;
import kst4contest.ApplicationConstants; import kst4contest.ApplicationConstants;
import java.io.File; import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
import java.util.LinkedList;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br> * This part of the client drives the sounds. Its a singleton instance. All audio outs are directed to this instance.<br>
@@ -113,8 +118,16 @@ public class PlayAudioUtils {
} }
private Queue<Media> musicList = new LinkedList<Media>(); private final Queue<String> musicList = new ConcurrentLinkedQueue<>();
private MediaPlayer mediaPlayer ; /** True once audio fails; prevents repeated error logs and further play attempts. */
private final AtomicBoolean audioUnavailable = new AtomicBoolean(false);
/** True while the drain loop is running; prevents duplicate concurrent drains. */
private final AtomicBoolean playing = new AtomicBoolean(false);
private final ExecutorService audioThread = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "audio-player");
t.setDaemon(true);
return t;
});
/** /**
* Plays notification sounds out of the windws 95 box by given action character<br/> * Plays notification sounds out of the windws 95 box by given action character<br/>
@@ -131,40 +144,31 @@ public class PlayAudioUtils {
*/ */
public void playNoiseLauncher(char actionChar) { public void playNoiseLauncher(char actionChar) {
switch (actionChar){
case '-':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3"));
break;
case '!':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"));
break;
case 'C':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3"));
break;
case 'P':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3"));
break;
case 'E':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3"));
break;
case 'N':
musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3"));
break;
default:
// ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3"); System.out.println("[KST4ContestApp, warning, letter not defined!]");
switch (actionChar){
case '-':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/tick.mp3")).toURI().toString()));
break;
case '!':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISESTARTUP.mp3")).toURI().toString()));
break;
case 'C':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISECQWINDOW.mp3")).toURI().toString()));
break;
case 'P':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEPMWINDOW.mp3")).toURI().toString()));
break;
case 'E':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISEERROR.mp3")).toURI().toString()));
break;
case 'N':
musicList.add(new Media(new File (ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/NOISENOTIFY.mp3")).toURI().toString()));
break;
// case 'M':
// musicList.add(new Media(new File ("VOICE.mp3").toURI().toString()));
// break;
default:
System.out.println("[KST4ContestApp, warning, letter not defined!]");
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
@@ -183,125 +187,124 @@ public class PlayAudioUtils {
for (char letterToPlay: playThisInCW){ for (char letterToPlay: playThisInCW){
switch (letterToPlay){ switch (letterToPlay){
case 'A': case 'A':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRA.mp3"));
break; break;
case 'B': case 'B':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRB.mp3"));
break; break;
case 'C': case 'C':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRC.mp3"));
break; break;
case 'D': case 'D':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRD.mp3"));
break; break;
case 'E': case 'E':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRE.mp3"));
break; break;
case 'F': case 'F':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRF.mp3"));
break; break;
case 'G': case 'G':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRG.mp3"));
break; break;
case 'H': case 'H':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRH.mp3"));
break; break;
case 'I': case 'I':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRI.mp3"));
break; break;
case 'J': case 'J':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRJ.mp3"));
break; break;
case 'K': case 'K':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRK.mp3"));
break; break;
case 'L': case 'L':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRL.mp3"));
break; break;
case 'M': case 'M':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRM.mp3"));
break; break;
case 'N': case 'N':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRN.mp3"));
break; break;
case 'O': case 'O':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRO.mp3"));
break; break;
case 'P': case 'P':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRP.mp3"));
break; break;
case 'Q': case 'Q':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRQ.mp3"));
break; break;
case 'R': case 'R':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRR.mp3"));
break; break;
case 'S': case 'S':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRS.mp3"));
break; break;
case 'T': case 'T':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRT.mp3"));
break; break;
case 'U': case 'U':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRU.mp3"));
break; break;
case 'V': case 'V':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRV.mp3"));
break; break;
case 'W': case 'W':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRW.mp3"));
break; break;
case 'X': case 'X':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRX.mp3"));
break; break;
case 'Y': case 'Y':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRY.mp3"));
break; break;
case 'Z': case 'Z':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRZ.mp3"));
break; break;
case '1': case '1':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR1.mp3"));
break; break;
case '2': case '2':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR2.mp3"));
break; break;
case '3': case '3':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR3.mp3"));
break; break;
case '4': case '4':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR4.mp3"));
break; break;
case '5': case '5':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR5.mp3"));
break; break;
case '6': case '6':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR6.mp3"));
break; break;
case '7': case '7':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR7.mp3"));
break; break;
case '8': case '8':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR8.mp3"));
break; break;
case '9': case '9':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR9.mp3"));
break; break;
case '0': case '0':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTR0.mp3"));
break; break;
case '/': case '/':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSTROKE.mp3"));
break; break;
case ' ': case ' ':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/LTTRSPACE.mp3"));
break; break;
default: default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW)); System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(playThisInCW));
} }
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
@@ -323,162 +326,166 @@ public class PlayAudioUtils {
for (char letterToPlay: spellThisWithVoice){ for (char letterToPlay: spellThisWithVoice){
switch (letterToPlay){ switch (letterToPlay){
case '!': case '!':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEBELL.mp3"));
break; break;
case '?': case '?':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEYOUGOTMAIL.mp3"));
break; break;
case '#': case '#':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEHELLO.mp3"));
break; break;
case '*': case '*':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE73.mp3"));
break; break;
case '$': case '$':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKEPORTABLE.mp3"));
break; break;
case 'A': case 'A':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEA.mp3"));
break; break;
case 'B': case 'B':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEB.mp3"));
break; break;
case 'C': case 'C':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEC.mp3"));
break; break;
case 'D': case 'D':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICED.mp3"));
break; break;
case 'E': case 'E':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEE.mp3"));
break; break;
case 'F': case 'F':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEF.mp3"));
break; break;
case 'G': case 'G':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEG.mp3"));
break; break;
case 'H': case 'H':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEH.mp3"));
break; break;
case 'I': case 'I':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEI.mp3"));
break; break;
case 'J': case 'J':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEJ.mp3"));
break; break;
case 'K': case 'K':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEK.mp3"));
break; break;
case 'L': case 'L':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEL.mp3"));
break; break;
case 'M': case 'M':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEM.mp3"));
break; break;
case 'N': case 'N':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEN.mp3"));
break; break;
case 'O': case 'O':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEO.mp3"));
break; break;
case 'P': case 'P':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEP.mp3"));
break; break;
case 'Q': case 'Q':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEQ.mp3"));
break; break;
case 'R': case 'R':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICER.mp3"));
break; break;
case 'S': case 'S':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICES.mp3"));
break; break;
case 'T': case 'T':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICET.mp3"));
break; break;
case 'U': case 'U':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEU.mp3"));
break; break;
case 'V': case 'V':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEV.mp3"));
break; break;
case 'W': case 'W':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEW.mp3"));
break; break;
case 'X': case 'X':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEX.mp3"));
break; break;
case 'Y': case 'Y':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEY.mp3"));
break; break;
case 'Z': case 'Z':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICEZ.mp3"));
break; break;
case '1': case '1':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE1.mp3"));
break; break;
case '2': case '2':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE2.mp3"));
break; break;
case '3': case '3':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE3.mp3"));
break; break;
case '4': case '4':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE4.mp3"));
break; break;
case '5': case '5':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE5.mp3"));
break; break;
case '6': case '6':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE6.mp3"));
break; break;
case '7': case '7':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE7.mp3"));
break; break;
case '8': case '8':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE8.mp3"));
break; break;
case '9': case '9':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE9.mp3"));
break; break;
case '0': case '0':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICE0.mp3"));
break; break;
case '/': case '/':
musicList.add(new Media(new File(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3")).toURI().toString())); musicList.add(ApplicationFileUtils.getFilePath(ApplicationConstants.APPLICATION_NAME, "/VOICESTROKE.mp3"));
break; break;
// case ' ':
// musicList.add(new Media(new File ("VOICESPACE.mp3").toURI().toString()));
// break;
default: default:
System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice)); System.out.println("[KST4ContestApp, warning, letter not defined:] cwLetters = " + Arrays.toString(spellThisWithVoice));
} }
} }
playMusic(); playMusic();
// mediaPlayer.dispose();
} }
private void playMusic() {
// System.out.println("Kst4ContestApplication.playMusic"); private void playMusic() {
if(musicList.peek() == null) if (audioUnavailable.get()) {
{ musicList.clear();
return; return;
} }
mediaPlayer = new MediaPlayer(musicList.poll()); // Only start a drain loop if none is running; the running loop will pick up new items itself.
mediaPlayer.setRate(1.0); if (!playing.compareAndSet(false, true)) return;
audioThread.submit(() -> {
mediaPlayer.setOnReady(() -> { String path;
mediaPlayer.play(); while ((path = musicList.poll()) != null) {
mediaPlayer.setOnEndOfMedia(() -> { if (audioUnavailable.get()) break;
// mediaPlayer.dispose(); try (FileInputStream fis = new FileInputStream(path);
playMusic(); BufferedInputStream bis = new BufferedInputStream(fis)) {
if (musicList.isEmpty()) { new Player(bis).play();
// mediaPlayer.dispose(); } catch (IOException | JavaLayerException e) {
audioUnavailable.set(true);
musicList.clear();
System.out.println("[KST4ContestApp, warning, audio playback disabled (could not create media player): " + e + "]");
break;
} }
}); }
playing.set(false);
// Items may have been enqueued between the last poll() and playing.set(false);
// restart the drain loop if so.
if (!musicList.isEmpty() && !audioUnavailable.get()) {
playMusic();
}
}); });
} }
} }
+16 -21
View File
@@ -51,26 +51,21 @@ public class GuiUtils {
} }
} }
private static void triggerUpdate(ChatController chatController) { /**
{ * Requests a safe UI refresh of the filtered ChatMember list.
//trick to trigger gui changes on property changes of obects *
* <p>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.</p>
*
* @param chatController central controller
*/
private static void triggerUpdate(ChatController chatController) {
if (chatController == null) {
return;
}
Predicate<ChatMember> dummyPredicate = new Predicate<ChatMember>() { chatController.fireUserListUpdate("Forced filtered ChatMember refresh");
@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);
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,10 @@
package kst4contest.view.map; package kst4contest.view.map;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
/** /**
* HTML host for the JavaFX WebView map. * HTML host for the JavaFX WebView map.
* *
@@ -16,7 +21,21 @@ public final class MapHtmlResources {
private MapHtmlResources() { private MapHtmlResources() {
} }
public static String createStationMapHtml() { private static String readRequiredResource(String resourcePath) {
try (InputStream inputStream = MapHtmlResources.class.getResourceAsStream(resourcePath)) {
if (inputStream == null) {
throw new IllegalStateException("Missing map resource: " + resourcePath);
}
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException exception) {
throw new UncheckedIOException("Could not read map resource: " + resourcePath, exception);
}
}
public static String createStationMapHtml(int tileProxyPort) {
String leafletCss = readRequiredResource("/web/leaflet/leaflet.css");
String leafletJs = readRequiredResource("/web/leaflet/leaflet.js");
return """ return """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -24,10 +43,9 @@ public final class MapHtmlResources {
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KST4Contest Station Map</title> <title>KST4Contest Station Map</title>
<link rel="stylesheet" <style>
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" """ + leafletCss + """
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" </style>
crossorigin="">
<style> <style>
:root { :root {
--map-background: #ede9df; --map-background: #ede9df;
@@ -196,9 +214,15 @@ public final class MapHtmlResources {
<body class="kst-theme-light"> <body class="kst-theme-light">
<div id="map"></div> <div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" <script>
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" // JavaFX 21 WebView can render Leaflet's CSS translate3d tile positioning incorrectly.
crossorigin=""></script> // Disable Leaflet 3D transforms before leaflet.js is loaded to keep tile rendering stable.
window.L_DISABLE_3D = true;
</script>
<script>
""" + leafletJs + """
</script>
<script>window._kstTileProxyPort=__TILE_PROXY_PORT__;</script>
<script> <script>
@@ -336,21 +360,42 @@ public final class MapHtmlResources {
function init() { function init() {
if (map) { if (map) {
return; return true;
}
if (typeof L === 'undefined') {
jsError('Leaflet is not loaded. Station map cannot initialize.');
return false;
} }
applyThemeClass(); applyThemeClass();
map = L.map('map', { map = L.map('map', {
zoomControl: true zoomControl: true,
zoomAnimation: false,
fadeAnimation: false,
markerZoomAnimation: false,
inertia: false
}).setView([51.0, 10.0], 6); }).setView([51.0, 10.0], 6);
jsLog('Leaflet map initialized'); jsLog('Leaflet map initialized');
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { const tileLayer = L.tileLayer(
maxZoom: 18, 'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
attribution: '&copy; OpenStreetMap' {
}).addTo(map); maxZoom: 18,
updateWhenZooming: false,
keepBuffer: 4,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
);
tileLayer.on('tileerror', function (event) {
const source = event && event.tile ? event.tile.src : 'unknown';
jsError('OSM tile load failed: ' + source);
});
tileLayer.addTo(map);
map.createPane('beamPane'); map.createPane('beamPane');
map.getPane('beamPane').style.zIndex = 410; map.getPane('beamPane').style.zIndex = 410;
@@ -385,25 +430,51 @@ public final class MapHtmlResources {
notifyMapReady(); notifyMapReady();
notifyViewport(); notifyViewport();
return true;
} }
function invalidateSize() { function invalidateSize() {
if (!map) { if (!map) {
return; return;
} }
jsLog('invalidateSize'); const mapElement = document.getElementById('map');
const domWidth = mapElement ? mapElement.clientWidth : -1;
const domHeight = mapElement ? mapElement.clientHeight : -1;
map.invalidateSize(false); jsLog('invalidateSize dom=' + domWidth + 'x' + domHeight
+ ' leafletBefore=' + map.getSize().x + 'x' + map.getSize().y);
if (invalidateNotifyTimer) { window.requestAnimationFrame(function () {
window.clearTimeout(invalidateNotifyTimer); map.invalidateSize({
} animate: false,
pan: false,
debounceMoveend: true
});
invalidateNotifyTimer = window.setTimeout(function () { window.setTimeout(function () {
notifyViewport(); map.invalidateSize({
}, 120); animate: false,
} pan: false,
debounceMoveend: true
});
jsLog('invalidateSize leafletAfter=' + map.getSize().x + 'x' + map.getSize().y);
if (invalidateNotifyTimer) {
window.clearTimeout(invalidateNotifyTimer);
}
invalidateNotifyTimer = window.setTimeout(function () {
notifyViewport();
}, 120);
}, 80);
});
}
function resize() {
invalidateSize();
}
function zoomIn() { function zoomIn() {
if (!map) { if (!map) {
@@ -468,13 +539,17 @@ public final class MapHtmlResources {
} }
function setHome(lat, lon, zoom) { function setHome(lat, lon, zoom) {
init(); if (!init()) {
return;
}
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom); jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
map.setView([lat, lon], zoom); map.setView([lat, lon], zoom);
} }
function setStations(stationsJson) { function setStations(stationsJson) {
init(); if (!init()) {
return;
}
stationLayer.clearLayers(); stationLayer.clearLayers();
markersByCallsignRaw = {}; markersByCallsignRaw = {};
@@ -503,7 +578,9 @@ public final class MapHtmlResources {
} }
function setBeam(beamJson) { function setBeam(beamJson) {
init(); if (!init()) {
return;
}
beamLayer.clearLayers(); beamLayer.clearLayers();
if (!beamJson || beamJson === 'null') { if (!beamJson || beamJson === 'null') {
@@ -530,7 +607,9 @@ public final class MapHtmlResources {
} }
function setConnection(connectionJson) { function setConnection(connectionJson) {
init(); if (!init()) {
return;
}
connectionLayer.clearLayers(); connectionLayer.clearLayers();
if (!connectionJson || connectionJson === 'null') { if (!connectionJson || connectionJson === 'null') {
@@ -555,7 +634,9 @@ public final class MapHtmlResources {
} }
function setProfileHoverPoint(point) { function setProfileHoverPoint(point) {
init(); if (!init()) {
return;
}
if (profileHoverMarker) { if (profileHoverMarker) {
map.removeLayer(profileHoverMarker); map.removeLayer(profileHoverMarker);
@@ -584,7 +665,9 @@ public final class MapHtmlResources {
} }
function setGrid(gridJson) { function setGrid(gridJson) {
init(); if (!init()) {
return;
}
gridLayer.clearLayers(); gridLayer.clearLayers();
const cells = JSON.parse(gridJson); const cells = JSON.parse(gridJson);
@@ -653,6 +736,7 @@ public final class MapHtmlResources {
return { return {
init: init, init: init,
invalidateSize: invalidateSize, invalidateSize: invalidateSize,
resize: resize,
zoomIn: zoomIn, zoomIn: zoomIn,
zoomOut: zoomOut, zoomOut: zoomOut,
inspectPoint: inspectPoint, inspectPoint: inspectPoint,
@@ -670,6 +754,6 @@ public final class MapHtmlResources {
</script> </script>
</body> </body>
</html> </html>
"""; """.replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
} }
} }
@@ -6,7 +6,6 @@ import javafx.collections.ListChangeListener;
import javafx.scene.control.TableView; import javafx.scene.control.TableView;
import javafx.util.Duration; import javafx.util.Duration;
import kst4contest.controller.ChatController; import kst4contest.controller.ChatController;
import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatMember; import kst4contest.model.ChatMember;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
@@ -15,12 +14,13 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; 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.concurrent.atomic.AtomicLong;
import java.util.function.Consumer; import java.util.function.Consumer;
import kst4contest.model.Band;
import java.util.function.Predicate;
/** /**
* Synchronizes the application state with the station map window. * Synchronizes the application state with the station map window.
* *
@@ -32,7 +32,7 @@ import java.util.function.Consumer;
*/ */
public final class StationMapBridge { public final class StationMapBridge {
private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0); private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
private final ChatController chatController; private final ChatController chatController;
@@ -45,8 +45,7 @@ public final class StationMapBridge {
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder(); private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
private final PathAnalysisService pathAnalysisService;
private String lastPathAnalysisRequestSignature = ""; private String lastPathAnalysisRequestSignature = "";
@@ -64,9 +63,7 @@ public final class StationMapBridge {
this.refreshCoalescer.setOnFinished(event -> refreshNow()); this.refreshCoalescer.setOnFinished(event -> refreshNow());
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
new OpenMeteoTerrainProfileProvider()
);
} }
public void install() { public void install() {
@@ -85,6 +82,10 @@ public final class StationMapBridge {
(obs, oldValue, newValue) -> scheduleRefresh() (obs, oldValue, newValue) -> scheduleRefresh()
); );
chatController.getLst_chatMemberListFilterPredicates().addListener(
(ListChangeListener<Predicate<ChatMember>>) change -> requestImmediateRefresh()
);
requestImmediateRefresh(); requestImmediateRefresh();
} }
@@ -170,6 +171,17 @@ public final class StationMapBridge {
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot); requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
} }
/**
* Requests the selected station path analysis through the central reachability
* service.
*
* <p>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.</p>
*
* @param ownLocator6 own locator from preferences
* @param selectedSnapshot selected map marker snapshot
*/
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) { private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6); String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
@@ -202,20 +214,28 @@ public final class StationMapBridge {
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw) PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
); );
pathAnalysisExecutor.submit(() -> { ChatMember selectedMember = resolveBestChatMember(targetCallsignRaw);
PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
Platform.runLater(() -> { chatController.getReachabilityService().requestPathAnalysisForMap(
if (generation != pathAnalysisGeneration.get()) { selectedMember,
return; selectedSnapshot,
result -> {
if (generation != pathAnalysisGeneration.get()) {
return;
}
stationMapView.setPathAnalysisResult(result);
} }
stationMapView.setPathAnalysisResult(result); );
});
});
} }
/**
* Invalidates pending map callbacks.
*
* <p>The actual calculation executor is owned by ReachabilityService now, so
* this bridge no longer shuts down any path-analysis thread directly.</p>
*/
public void dispose() { public void dispose() {
pathAnalysisExecutor.shutdownNow(); pathAnalysisGeneration.incrementAndGet();
} }
private void handleMapCallsignSelection(String callSignRaw) { private void handleMapCallsignSelection(String callSignRaw) {
@@ -286,43 +306,6 @@ public final class StationMapBridge {
return callSignRaw.trim().toUpperCase(Locale.ROOT); 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) { private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
if (selectedSnapshot == null) { if (selectedSnapshot == null) {
@@ -376,12 +359,5 @@ public final class StationMapBridge {
return locator.trim().toUpperCase(Locale.ROOT); 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;
}
}
} }
@@ -18,6 +18,7 @@ import kst4contest.ApplicationConstants;
import kst4contest.locatorUtils.Location; import kst4contest.locatorUtils.Location;
import kst4contest.model.ChatPreferences; import kst4contest.model.ChatPreferences;
import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -49,6 +50,18 @@ public final class StationMapView {
private final WebView webView = new WebView(); private final WebView webView = new WebView();
private final WebEngine webEngine = webView.getEngine(); private final WebEngine webEngine = webView.getEngine();
/**
* Keep a strong Java reference to the bridge object.
*
* JavaFX WebView/JSObject does not guarantee that a Java object passed through
* window.setMember(...) remains strongly reachable from the Java side. Newer
* JavaFX/WebKit/GC combinations can otherwise lose callbacks after a while.
*/
private final JavaMapBridge javaMapBridge = new JavaMapBridge();
private TileProxyServer tileProxyServer;
private Scene scene; private Scene scene;
private BorderPane rootPane; private BorderPane rootPane;
private SplitPane mainSplitPane; private SplitPane mainSplitPane;
@@ -83,6 +96,22 @@ public final class StationMapView {
private List<MapCallsignRawSnapshot> lastSnapshots = List.of(); private List<MapCallsignRawSnapshot> lastSnapshots = List.of();
private MapCallsignRawSnapshot lastSelectedSnapshot; private MapCallsignRawSnapshot lastSelectedSnapshot;
private boolean filteredViewActive; private boolean filteredViewActive;
/**
* Last JSON payloads sent into Leaflet. Used to avoid clearing/recreating
* layers every score/table refresh. Rebuilding DOM markers every few seconds
* is visible as flicker in JavaFX 21 WebView.
*/
private String lastRenderedStationsJson = "";
private String lastRenderedBeamJson = "";
private String lastRenderedConnectionJson = "";
private String lastRenderedGridJson = "";
/**
* Prevent periodic refreshes from panning the map back to the selected station.
* Explicit calls to focusCallsignRaw(...) still pan immediately.
*/
private String lastAutoFocusedCallsignRaw = "";
private double homeLatitudeDeg = Double.NaN; private double homeLatitudeDeg = Double.NaN;
private double homeLongitudeDeg = Double.NaN; private double homeLongitudeDeg = Double.NaN;
@@ -127,6 +156,11 @@ public final class StationMapView {
public StationMapView(ChatPreferences chatPreferences) { public StationMapView(ChatPreferences chatPreferences) {
this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences"); this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences");
try {
tileProxyServer = new TileProxyServer();
} catch (IOException e) {
System.err.println("[StationMap] tile proxy failed to start: " + e.getMessage());
}
initializeUi(); initializeUi();
initializeWebView(); initializeWebView();
} }
@@ -185,8 +219,11 @@ public final class StationMapView {
return; return;
} }
String normalizedCallsignRaw = callSignRaw.trim().toUpperCase(Locale.ROOT);
lastAutoFocusedCallsignRaw = normalizedCallsignRaw;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(callSignRaw.trim().toUpperCase(Locale.ROOT)) + ");" "window.kstMapApi.focusCallsignRaw(" + toJsStringLiteral(normalizedCallsignRaw) + ");"
); );
} }
@@ -285,6 +322,8 @@ public final class StationMapView {
webView.setMinWidth(0); webView.setMinWidth(0);
webView.setMinHeight(220); webView.setMinHeight(220);
webView.setPrefHeight(420); webView.setPrefHeight(420);
webView.setMaxWidth(Double.MAX_VALUE);
webView.setMaxHeight(Double.MAX_VALUE);
VBox.setVgrow(webView, Priority.ALWAYS); VBox.setVgrow(webView, Priority.ALWAYS);
profileSection.setMinWidth(0); profileSection.setMinWidth(0);
@@ -616,7 +655,7 @@ public final class StationMapView {
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> { webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
if (newState == javafx.concurrent.Worker.State.SUCCEEDED) { if (newState == javafx.concurrent.Worker.State.SUCCEEDED) {
JSObject window = (JSObject) webEngine.executeScript("window"); JSObject window = (JSObject) webEngine.executeScript("window");
window.setMember("javaMapBridge", new JavaMapBridge()); window.setMember("javaMapBridge", javaMapBridge);
executeMapScriptSafely("window.kstMapApi.init();"); executeMapScriptSafely("window.kstMapApi.init();");
@@ -627,7 +666,8 @@ public final class StationMapView {
} }
}); });
webEngine.loadContent(MapHtmlResources.createStationMapHtml()); int proxyPort = tileProxyServer != null ? tileProxyServer.getPort() : 0;
webEngine.loadContent(MapHtmlResources.createStationMapHtml(proxyPort));
} }
private void requestMapInvalidateSize() { private void requestMapInvalidateSize() {
@@ -635,11 +675,13 @@ public final class StationMapView {
return; return;
} }
Platform.runLater(() -> Platform.runLater(() -> Platform.runLater(() ->
Platform.runLater(() -> executeMapScriptSafely("window.kstMapApi.resize();")
executeMapScriptSafely("window.kstMapApi.invalidateSize();"))); ));
} }
private void handleWebViewClick(MouseEvent event) { private void handleWebViewClick(MouseEvent event) {
logWebViewMouseEvent("MOUSE_CLICKED", event); logWebViewMouseEvent("MOUSE_CLICKED", event);
@@ -902,9 +944,6 @@ public final class StationMapView {
detailPathProfileChart.setObstructionSummary(result.obstructionSummary()); detailPathProfileChart.setObstructionSummary(result.obstructionSummary());
} }
private void renderAll() { private void renderAll() {
if (!mapReady) { if (!mapReady) {
return; return;
@@ -922,49 +961,78 @@ public final class StationMapView {
renderBeam(); renderBeam();
renderConnectionLine(); renderConnectionLine();
renderGridIfViewportKnown(); renderGridIfViewportKnown();
focusSelectedStationOnlyWhenSelectionChanged();
}
if (lastSelectedSnapshot != null) { private void focusSelectedStationOnlyWhenSelectionChanged() {
focusCallsignRaw(lastSelectedSnapshot.callSignRaw()); if (lastSelectedSnapshot == null || lastSelectedSnapshot.callSignRaw() == null) {
lastAutoFocusedCallsignRaw = "";
return;
} }
String selectedCallsignRaw = lastSelectedSnapshot.callSignRaw().trim().toUpperCase(Locale.ROOT);
if (selectedCallsignRaw.isBlank() || selectedCallsignRaw.equals(lastAutoFocusedCallsignRaw)) {
return;
}
focusCallsignRaw(selectedCallsignRaw);
} }
private void renderStations() { private void renderStations() {
String stationsJson = toStationsJson(lastSnapshots);
if (stationsJson.equals(lastRenderedStationsJson)) {
return;
}
lastRenderedStationsJson = stationsJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setStations(" + toJsStringLiteral(toStationsJson(lastSnapshots)) + ");" "window.kstMapApi.setStations(" + toJsStringLiteral(stationsJson) + ");"
); );
} }
private void renderBeam() { private void renderBeam() {
if (!Double.isFinite(homeLatitudeDeg) String beamJson = "null";
|| !Double.isFinite(homeLongitudeDeg)
|| beamWidthDeg <= 0.0
|| maxQrbKm <= 0.0) {
executeMapScriptSafely("window.kstMapApi.setBeam('null');"); if (Double.isFinite(homeLatitudeDeg)
return; && Double.isFinite(homeLongitudeDeg)
&& beamWidthDeg > 0.0
&& maxQrbKm > 0.0) {
List<double[]> sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm);
beamJson = toPointArrayJson(sectorPoints);
} }
List<double[]> sectorPoints = buildBeamPolygon(homeLatitudeDeg, homeLongitudeDeg, antennaAzimuthDeg, beamWidthDeg, maxQrbKm); if (beamJson.equals(lastRenderedBeamJson)) {
return;
}
lastRenderedBeamJson = beamJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setBeam(" + toJsStringLiteral(toPointArrayJson(sectorPoints)) + ");" "window.kstMapApi.setBeam(" + toJsStringLiteral(beamJson) + ");"
); );
} }
private void renderConnectionLine() { private void renderConnectionLine() {
if (lastSelectedSnapshot == null || !lastSelectedSnapshot.hasUsablePosition() String connectionJson = "null";
|| !Double.isFinite(homeLatitudeDeg) || !Double.isFinite(homeLongitudeDeg)) {
executeMapScriptSafely("window.kstMapApi.setConnection('null');"); if (lastSelectedSnapshot != null && lastSelectedSnapshot.hasUsablePosition()
return; && Double.isFinite(homeLatitudeDeg) && Double.isFinite(homeLongitudeDeg)) {
List<double[]> points = List.of(
new double[]{homeLatitudeDeg, homeLongitudeDeg},
new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()}
);
connectionJson = toPointArrayJson(points);
} }
List<double[]> points = List.of( if (connectionJson.equals(lastRenderedConnectionJson)) {
new double[]{homeLatitudeDeg, homeLongitudeDeg}, return;
new double[]{lastSelectedSnapshot.latitudeDeg(), lastSelectedSnapshot.longitudeDeg()} }
); lastRenderedConnectionJson = connectionJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setConnection(" + toJsStringLiteral(toPointArrayJson(points)) + ");" "window.kstMapApi.setConnection(" + toJsStringLiteral(connectionJson) + ");"
); );
} }
@@ -1003,11 +1071,19 @@ public final class StationMapView {
+ " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx()) + " cellPx=" + String.format(Locale.US, "%.1f/%.1f", renderPlan.estimatedCellWidthPx(), renderPlan.estimatedCellHeightPx())
+ " cells=" + visibleGridCells.size()); + " cells=" + visibleGridCells.size());
String gridJson = toGridJson(visibleGridCells, renderPlan);
if (gridJson.equals(lastRenderedGridJson)) {
return;
}
lastRenderedGridJson = gridJson;
executeMapScriptSafely( executeMapScriptSafely(
"window.kstMapApi.setGrid(" + toJsStringLiteral(toGridJson(visibleGridCells, renderPlan)) + ");" "window.kstMapApi.setGrid(" + toJsStringLiteral(gridJson) + ");"
); );
} }
private List<double[]> buildBeamPolygon(double startLatDeg, private List<double[]> buildBeamPolygon(double startLatDeg,
double startLonDeg, double startLonDeg,
double centerAzimuthDeg, double centerAzimuthDeg,
@@ -1093,6 +1169,7 @@ public final class StationMapView {
webEngine.executeScript(script); webEngine.executeScript(script);
} catch (Exception exception) { } catch (Exception exception) {
System.err.println("[StationMap] executeScript failed: " + exception.getMessage()); System.err.println("[StationMap] executeScript failed: " + exception.getMessage());
exception.printStackTrace();
} }
} }
@@ -0,0 +1,159 @@
package kst4contest.view.map;
import java.io.*;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Minimal HTTP server that proxies OSM tile requests via Java's HttpClient,
* so JavaFX WebView only needs to connect to localhost (avoids WebKit SSL/sandbox
* issues in AppImage and Flatpak packaging). Uses only java.base + java.net.http.
*/
final class TileProxyServer {
private static final int CACHE_MAX = 2048;
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
private static final int TILE_PROXY_THREADS = 12;
private static final int TILE_PROXY_BACKLOG = 128;
private final ServerSocket serverSocket;
private final ExecutorService executor;
private final HttpClient httpClient;
private final Map<String, byte[]> cache;
TileProxyServer() throws IOException {
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.cache = Collections.synchronizedMap(new LinkedHashMap<>(CACHE_MAX, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
return size() > CACHE_MAX;
}
});
this.serverSocket = new ServerSocket(0, TILE_PROXY_BACKLOG, InetAddress.getByName("127.0.0.1"));
this.executor = Executors.newFixedThreadPool(TILE_PROXY_THREADS, r -> {
Thread t = new Thread(r, "tile-proxy");
t.setDaemon(true);
return t;
});
executor.submit(this::acceptLoop);
}
int getPort() {
return serverSocket.getLocalPort();
}
void stop() {
try { serverSocket.close(); } catch (IOException ignored) {}
executor.shutdownNow();
}
private void acceptLoop() {
while (!serverSocket.isClosed()) {
try {
Socket client = serverSocket.accept();
executor.submit(() -> handleClient(client));
} catch (IOException e) {
if (!serverSocket.isClosed()) {
System.err.println("[TileProxy] accept error: " + e.getMessage());
}
}
}
}
private void handleClient(Socket client) {
try (client;
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
OutputStream out = client.getOutputStream()) {
String requestLine = in.readLine();
if (requestLine == null || !requestLine.startsWith("GET ")) {
sendError(out, 400, "Bad Request");
return;
}
// Drain headers
String line;
while ((line = in.readLine()) != null && !line.isEmpty()) { /* skip */ }
// Parse: GET /tiles/{s}/{z}/{x}/{y}.png HTTP/1.1
String[] parts = requestLine.split(" ");
if (parts.length < 2) { sendError(out, 400, "Bad Request"); return; }
String path = parts[1];
String[] segments = path.split("/");
// segments: ["", "tiles", s, z, x, "y.png"]
if (segments.length != 6
|| !segments[2].matches("[abc]")
|| !segments[3].matches("\\d{1,2}")
|| !segments[4].matches("\\d+")
|| !segments[5].matches("\\d+\\.png")) {
sendError(out, 404, "Not Found");
return;
}
String cacheKey = segments[2] + "/" + segments[3] + "/" + segments[4] + "/" + segments[5];
byte[] tileData = cache.get(cacheKey);
if (tileData == null) {
String tileUrl = "https://" + segments[2] + ".tile.openstreetmap.org/"
+ segments[3] + "/" + segments[4] + "/" + segments[5];
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tileUrl))
.header("User-Agent", USER_AGENT)
.timeout(Duration.ofSeconds(15))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
sendError(out, 502, "OSM returned " + response.statusCode());
return;
}
tileData = response.body();
cache.put(cacheKey, tileData);
}
String header = "HTTP/1.1 200 OK\r\n"
+ "Content-Type: image/png\r\n"
+ "Content-Length: " + tileData.length + "\r\n"
+ "Cache-Control: max-age=86400\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(header.getBytes());
out.write(tileData);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
System.err.println("[TileProxy] error: " + e.getMessage());
}
}
private static void sendError(OutputStream out, int code, String message) throws IOException {
byte[] body = message.getBytes();
String response = "HTTP/1.1 " + code + " " + message + "\r\n"
+ "Content-Length: " + body.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(response.getBytes());
out.write(body);
}
}
+3
View File
@@ -6,13 +6,16 @@ module praktiKST {
requires java.sql; requires java.sql;
requires javafx.media; requires javafx.media;
requires jdk.jsobject; requires jdk.jsobject;
requires jlayer;
requires java.net.http; requires java.net.http;
requires java.desktop; requires java.desktop;
requires jdk.crypto.ec;
exports kst4contest.controller.interfaces; exports kst4contest.controller.interfaces;
exports kst4contest.controller; exports kst4contest.controller;
exports kst4contest.locatorUtils; exports kst4contest.locatorUtils;
exports kst4contest.model; exports kst4contest.model;
exports kst4contest.view; exports kst4contest.view;
opens kst4contest.view.map to javafx.web; opens kst4contest.view.map to javafx.web;
} }
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
+5
View File
@@ -2366,3 +2366,8 @@ OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432f
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; 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 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 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