mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-06-23 06:16:37 +02:00
Compare commits
1 Commits
main
..
a647e7a429
| Author | SHA1 | Date | |
|---|---|---|---|
| a647e7a429 |
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a problem with KST4Contest / Ein Problem mit KST4Contest melden
|
||||
title: "[BUG] "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**DE:** Bitte fülle alle Felder so vollständig wie möglich aus. Das hilft, den Fehler schneller zu finden.
|
||||
**EN:** Please fill in all fields as completely as possible. This helps to find the bug faster.
|
||||
|
||||
## Description / Beschreibung
|
||||
|
||||
<!-- EN: A clear and concise description of the bug. -->
|
||||
<!-- DE: Eine klare und präzise Beschreibung des Problems. -->
|
||||
|
||||
## Steps to reproduce / Schritte zum Reproduzieren
|
||||
|
||||
<!-- EN: Step-by-step instructions to reproduce the bug. -->
|
||||
<!-- DE: Schritt-für-Schritt-Anleitung, um den Fehler zu reproduzieren. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behaviour / Erwartetes Verhalten
|
||||
|
||||
<!-- EN: What did you expect to happen? / DE: Was hätte passieren sollen? -->
|
||||
|
||||
## Actual behaviour / Tatsächliches Verhalten
|
||||
|
||||
<!-- EN: What actually happened? / DE: Was ist stattdessen passiert? -->
|
||||
|
||||
## Log file content / Inhalt der Logdatei
|
||||
|
||||
**EN:** Please paste the content of the error log file here. It is written automatically and contains error messages only — no personal data.
|
||||
**DE:** Bitte füge hier den Inhalt der Fehler-Logdatei ein. Sie wird automatisch geschrieben und enthält nur Fehlermeldungen — keine persönlichen Daten.
|
||||
|
||||
| OS | Path / Pfad |
|
||||
|----|-------------|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
```
|
||||
Paste log content here / Loginhalt hier einfügen
|
||||
```
|
||||
|
||||
## Version
|
||||
|
||||
**KST4Contest version / Version** (e.g. 1.41.0):
|
||||
|
||||
**Java version / Java-Version** (`java -version`, e.g. 17.0.9):
|
||||
|
||||
**Operating system / Betriebssystem:** <!-- Linux / Windows / macOS -->
|
||||
|
||||
**Logging software / Logprogramm** (if applicable / falls relevant, e.g. UCXLog, N1MM+, WinTest):
|
||||
|
||||
## Checklist / Checkliste
|
||||
|
||||
- [ ] I have attached the log file / Ich habe die Logdatei angehängt
|
||||
- [ ] I have checked that this issue has not been reported before / Ich habe geprüft, dass dieses Problem noch nicht gemeldet wurde
|
||||
@@ -1 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or improvement / Neue Funktion oder Verbesserung vorschlagen
|
||||
title: "[FEATURE] "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**DE:** Bitte beschreibe deine Idee so genau wie möglich.
|
||||
**EN:** Please describe your idea as precisely as possible.
|
||||
|
||||
## Summary / Zusammenfassung
|
||||
|
||||
<!-- EN: A short summary of the feature you'd like. -->
|
||||
<!-- DE: Eine kurze Zusammenfassung der gewünschten Funktion. -->
|
||||
|
||||
## Motivation / Begründung
|
||||
|
||||
<!-- EN: Why would this feature be useful? What problem does it solve? -->
|
||||
<!-- DE: Warum wäre diese Funktion nützlich? Welches Problem löst sie? -->
|
||||
|
||||
## Detailed description / Detaillierte Beschreibung
|
||||
|
||||
<!-- EN: Describe the feature in detail. How should it work? -->
|
||||
<!-- DE: Beschreibe die Funktion im Detail. Wie soll sie funktionieren? -->
|
||||
|
||||
## Alternatives considered / Geprüfte Alternativen
|
||||
|
||||
<!-- EN: Have you considered any alternative solutions or workarounds? -->
|
||||
<!-- DE: Hast du alternative Lösungen oder Workarounds in Betracht gezogen? -->
|
||||
|
||||
## Checklist / Checkliste
|
||||
|
||||
- [ ] I have checked that this feature has not been requested before / Ich habe geprüft, dass diese Funktion noch nicht angefragt wurde
|
||||
@@ -239,7 +239,7 @@ $endif$
|
||||
{\fontsize{22}{28}\selectfont\color{white!75!brand-green}pratiKST (ON4KST Chat Client)}\\[2.8cm]
|
||||
\color{white!40!brand-green}\rule{10cm}{0.6pt}\\[1.8cm]
|
||||
{\LARGE\bfseries\color{white}$title$}\\[1cm]
|
||||
$if(version)${\large\color{white!80!brand-green}Version:\space{}$version$}\\[0.6cm]$endif$
|
||||
$if(version)${\large\color{white!80!brand-green}Version:\space$version$}\\[0.6cm]$endif$
|
||||
\vfill
|
||||
{\large\color{white}DO5AMF · Marc Fröhlich · DM5M · DN9APW · Philipp Wagner}\\[0.4cm]
|
||||
{\color{white!70!brand-green}\today}\\[2cm]
|
||||
|
||||
@@ -11,10 +11,6 @@ on:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-windows-zip:
|
||||
name: Build Windows ZIP
|
||||
@@ -58,7 +54,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
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,java.net.http,jdk.crypto.ec --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 --dest dist
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
@@ -87,7 +83,9 @@ jobs:
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||
echo "ASSET_BASENAME=praktiKST-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
@@ -108,426 +106,49 @@ jobs:
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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 \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
- name: Create AppDir metadata
|
||||
run: |
|
||||
rm -rf target/KST4Contest.AppDir
|
||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
||||
rm -rf target/praktiKST.AppDir
|
||||
cp -a dist/praktiKST target/praktiKST.AppDir
|
||||
|
||||
cat > target/KST4Contest.AppDir/AppRun << 'EOF'
|
||||
cat > target/praktiKST.AppDir/AppRun << 'EOF'
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$HERE/bin/KST4Contest" "$@"
|
||||
exec "$HERE/bin/praktiKST" "$@"
|
||||
EOF
|
||||
chmod +x target/KST4Contest.AppDir/AppRun
|
||||
chmod +x target/praktiKST.AppDir/AppRun
|
||||
|
||||
cat > target/KST4Contest.AppDir/KST4Contest.desktop << 'EOF'
|
||||
cat > target/praktiKST.AppDir/praktiKST.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Name=praktiKST
|
||||
Exec=praktiKST
|
||||
Icon=praktiKST
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
if [ -f target/KST4Contest.AppDir/lib/KST4Contest.png ]; then
|
||||
cp target/KST4Contest.AppDir/lib/KST4Contest.png target/KST4Contest.AppDir/KST4Contest.png
|
||||
if [ -f target/praktiKST.AppDir/lib/praktiKST.png ]; then
|
||||
cp target/praktiKST.AppDir/lib/praktiKST.png target/praktiKST.AppDir/praktiKST.png
|
||||
fi
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x target/appimagetool.AppImage
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/praktiKST.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
||||
retention-days: 14
|
||||
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
DEB="$(ls dist/*.deb | head -n 1)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "No DEB produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
|
||||
|
||||
- name: Upload Debian artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-debian
|
||||
path: dist/KST4Contest-*-debian-amd64.deb
|
||||
retention-days: 14
|
||||
|
||||
build-linux-rpm:
|
||||
name: Build Fedora package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
||||
if [ -z "$RPM" ]; then
|
||||
echo "No RPM produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
|
||||
|
||||
- name: Upload Fedora artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-fedora
|
||||
path: dist/KST4Contest-*-fedora-x86_64.rpm
|
||||
retention-days: 14
|
||||
|
||||
build-linux-arch:
|
||||
name: Build Arch Linux package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends zstd
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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: Build Arch Linux package artifact
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
PKGVER=$(printf '%s' "${VERSION}-${SHORT_SHA}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||
PKGROOT="target/archpkg"
|
||||
rm -rf "$PKGROOT"
|
||||
mkdir -p "$PKGROOT/usr/lib/KST4Contest" "$PKGROOT/usr/bin"
|
||||
cp -a dist/KST4Contest/. "$PKGROOT/usr/lib/KST4Contest/"
|
||||
cat > "$PKGROOT/usr/bin/KST4Contest" << 'EOF'
|
||||
#!/bin/sh
|
||||
exec /usr/lib/KST4Contest/bin/KST4Contest "$@"
|
||||
EOF
|
||||
chmod 755 "$PKGROOT/usr/bin/KST4Contest"
|
||||
mkdir -p "$PKGROOT/usr/share/applications" "$PKGROOT/usr/share/icons/hicolor/256x256/apps"
|
||||
cat > "$PKGROOT/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
|
||||
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
INSTALLED_SIZE=$(du -sk "$PKGROOT" | cut -f1)
|
||||
cat > "$PKGROOT/.PKGINFO" << EOF
|
||||
pkgname = kst4contest
|
||||
pkgbase = kst4contest
|
||||
pkgver = ${PKGVER}-1
|
||||
pkgdesc = KST4Contest amateur radio contest logger
|
||||
url = https://github.com/${{ github.repository }}
|
||||
builddate = $(date +%s)
|
||||
packager = GitHub Actions
|
||||
size = ${INSTALLED_SIZE}
|
||||
arch = ${ARCH}
|
||||
license = custom
|
||||
depend = java-runtime
|
||||
EOF
|
||||
tar --zstd -cf "dist/${ASSET_BASENAME}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
||||
|
||||
- name: Upload Arch Linux artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-arch
|
||||
path: dist/KST4Contest-*-archlinux-*.pkg.tar.zst
|
||||
retention-days: 14
|
||||
|
||||
build-flatpak:
|
||||
name: Build Flatpak
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
mkdir -p target/flatpak-src
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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 target/flatpak-src
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||
app-id: de.x08.KST4Contest
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: KST4Contest
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
modules:
|
||||
- name: kst4contest
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -d /app/lib/KST4Contest /app/bin /app/share/applications
|
||||
- cp -a . /app/lib/KST4Contest/
|
||||
- printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest
|
||||
- chmod 755 /app/bin/KST4Contest
|
||||
- echo '[Desktop Entry]' > /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Type=Application' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Name=KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Comment=ON4KST Chat Client for VHF/UHF contest operation' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Exec=KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Icon=de.x08.KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- printf 'Categories=Network;HamRadio;\n' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Terminal=false' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- test -f /app/lib/KST4Contest/lib/KST4Contest.png && install -Dm644 /app/lib/KST4Contest/lib/KST4Contest.png /app/share/icons/hicolor/256x256/apps/de.x08.KST4Contest.png || true
|
||||
sources:
|
||||
- type: dir
|
||||
path: flatpak-src/KST4Contest
|
||||
EOF
|
||||
|
||||
- name: Build Flatpak bundle
|
||||
run: |
|
||||
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
||||
flatpak build-export target/flatpak-repo target/flatpak-build
|
||||
flatpak build-bundle target/flatpak-repo "dist/${ASSET_BASENAME}-linux-x86_64.flatpak" de.x08.KST4Contest
|
||||
|
||||
- name: Upload Flatpak artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-flatpak
|
||||
path: dist/KST4Contest-*-linux-x86_64.flatpak
|
||||
retention-days: 14
|
||||
|
||||
build-macos-dmg:
|
||||
name: Build macOS DMG (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Resolve nightly version info
|
||||
run: |
|
||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
ARCH=$(uname -m)
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build macOS DMG with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type dmg \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
|
||||
- name: Rename DMG artifact
|
||||
run: |
|
||||
DMG=$(ls dist/*.dmg | head -n 1)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "No DMG produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: macos-dmg-${{ matrix.os }}
|
||||
path: dist/KST4Contest-*-macos-*.dmg
|
||||
path: dist/praktiKST-*-linux-x86_64.AppImage
|
||||
retention-days: 14
|
||||
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -46,7 +45,7 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
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,java.net.http,jdk.crypto.ec --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 --dest dist
|
||||
|
||||
- name: Create Windows ZIP
|
||||
shell: pwsh
|
||||
@@ -89,414 +88,51 @@ jobs:
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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 \
|
||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
||||
--dest dist
|
||||
|
||||
- name: Create AppDir metadata
|
||||
run: |
|
||||
rm -rf target/KST4Contest.AppDir
|
||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
||||
rm -rf target/praktiKST.AppDir
|
||||
cp -a dist/praktiKST target/praktiKST.AppDir
|
||||
|
||||
cat > target/KST4Contest.AppDir/AppRun << 'EOF'
|
||||
cat > target/praktiKST.AppDir/AppRun << 'EOF'
|
||||
#!/bin/sh
|
||||
HERE="$(dirname "$(readlink -f "$0")")"
|
||||
exec "$HERE/bin/KST4Contest" "$@"
|
||||
exec "$HERE/bin/praktiKST" "$@"
|
||||
EOF
|
||||
chmod +x target/KST4Contest.AppDir/AppRun
|
||||
chmod +x target/praktiKST.AppDir/AppRun
|
||||
|
||||
cat > target/KST4Contest.AppDir/KST4Contest.desktop << 'EOF'
|
||||
cat > target/praktiKST.AppDir/praktiKST.desktop << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Name=praktiKST
|
||||
Exec=praktiKST
|
||||
Icon=praktiKST
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
|
||||
if [ -f target/KST4Contest.AppDir/lib/KST4Contest.png ]; then
|
||||
cp target/KST4Contest.AppDir/lib/KST4Contest.png target/KST4Contest.AppDir/KST4Contest.png
|
||||
if [ -f target/praktiKST.AppDir/lib/praktiKST.png ]; then
|
||||
cp target/praktiKST.AppDir/lib/praktiKST.png target/praktiKST.AppDir/praktiKST.png
|
||||
fi
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||
chmod +x target/appimagetool.AppImage
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/praktiKST.AppDir dist/praktiKST-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
|
||||
build-linux-deb:
|
||||
name: Build Debian package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends fakeroot
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Debian package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type deb \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
DEB="$(ls dist/*.deb | head -n 1)"
|
||||
if [ -z "$DEB" ]; then
|
||||
echo "No DEB produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
|
||||
|
||||
- name: Upload Debian artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-debian
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb
|
||||
|
||||
build-linux-rpm:
|
||||
name: Build Fedora package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends rpm
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build Fedora package
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type rpm \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
RPM="$(ls dist/*.rpm | head -n 1)"
|
||||
if [ -z "$RPM" ]; then
|
||||
echo "No RPM produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
|
||||
|
||||
- name: Upload Fedora artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-fedora
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm
|
||||
|
||||
build-linux-arch:
|
||||
name: Build Arch Linux package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends zstd
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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: Build Arch Linux package artifact
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
PKGVER=$(printf '%s' "${{ github.ref_name }}" | sed 's/[^[:alnum:].+_]/_/g')
|
||||
PKGROOT="target/archpkg"
|
||||
rm -rf "$PKGROOT"
|
||||
mkdir -p "$PKGROOT/usr/lib/KST4Contest" "$PKGROOT/usr/bin"
|
||||
cp -a dist/KST4Contest/. "$PKGROOT/usr/lib/KST4Contest/"
|
||||
cat > "$PKGROOT/usr/bin/KST4Contest" << 'EOF'
|
||||
#!/bin/sh
|
||||
exec /usr/lib/KST4Contest/bin/KST4Contest "$@"
|
||||
EOF
|
||||
chmod 755 "$PKGROOT/usr/bin/KST4Contest"
|
||||
mkdir -p "$PKGROOT/usr/share/applications" "$PKGROOT/usr/share/icons/hicolor/256x256/apps"
|
||||
cat > "$PKGROOT/usr/share/applications/KST4Contest.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=KST4Contest
|
||||
Comment=ON4KST Chat Client for VHF/UHF contest operation
|
||||
Exec=KST4Contest
|
||||
Icon=KST4Contest
|
||||
Categories=Network;HamRadio;
|
||||
Terminal=false
|
||||
EOF
|
||||
if [ -f "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" ]; then
|
||||
cp "$PKGROOT/usr/lib/KST4Contest/lib/KST4Contest.png" "$PKGROOT/usr/share/icons/hicolor/256x256/apps/KST4Contest.png"
|
||||
fi
|
||||
INSTALLED_SIZE=$(du -sk "$PKGROOT" | cut -f1)
|
||||
cat > "$PKGROOT/.PKGINFO" << EOF
|
||||
pkgname = kst4contest
|
||||
pkgbase = kst4contest
|
||||
pkgver = ${PKGVER}-1
|
||||
pkgdesc = KST4Contest amateur radio contest logger
|
||||
url = https://github.com/${{ github.repository }}
|
||||
builddate = $(date +%s)
|
||||
packager = GitHub Actions
|
||||
size = ${INSTALLED_SIZE}
|
||||
arch = ${ARCH}
|
||||
license = custom
|
||||
depend = java-runtime
|
||||
EOF
|
||||
tar --zstd -cf "dist/KST4Contest-${{ github.ref_name }}-archlinux-${ARCH}.pkg.tar.zst" -C "$PKGROOT" .
|
||||
|
||||
- name: Upload Arch Linux artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: linux-arch
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst
|
||||
|
||||
build-flatpak:
|
||||
name: Build Flatpak
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Install Flatpak tooling
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder elfutils
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
|
||||
|
||||
- name: Build app-image with jpackage
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
mkdir -p target/flatpak-src
|
||||
jpackage \
|
||||
--type app-image \
|
||||
--name KST4Contest \
|
||||
--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 target/flatpak-src
|
||||
|
||||
- name: Create Flatpak manifest
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cat > target/de.x08.KST4Contest.yml << 'EOF'
|
||||
app-id: de.x08.KST4Contest
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: KST4Contest
|
||||
finish-args:
|
||||
- --socket=wayland
|
||||
- --socket=x11
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --device=dri
|
||||
modules:
|
||||
- name: kst4contest
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -d /app/lib/KST4Contest /app/bin /app/share/applications
|
||||
- cp -a . /app/lib/KST4Contest/
|
||||
- printf '#!/bin/sh\nexec /app/lib/KST4Contest/bin/KST4Contest "$@"\n' > /app/bin/KST4Contest
|
||||
- chmod 755 /app/bin/KST4Contest
|
||||
- echo '[Desktop Entry]' > /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Type=Application' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Name=KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Comment=ON4KST Chat Client for VHF/UHF contest operation' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Exec=KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Icon=de.x08.KST4Contest' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- printf 'Categories=Network;HamRadio;\n' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- echo 'Terminal=false' >> /app/share/applications/de.x08.KST4Contest.desktop
|
||||
- test -f /app/lib/KST4Contest/lib/KST4Contest.png && install -Dm644 /app/lib/KST4Contest/lib/KST4Contest.png /app/share/icons/hicolor/256x256/apps/de.x08.KST4Contest.png || true
|
||||
sources:
|
||||
- type: dir
|
||||
path: flatpak-src/KST4Contest
|
||||
EOF
|
||||
|
||||
- name: Import Flatpak signing key
|
||||
run: |
|
||||
echo "${{ secrets.FLATPAK_GPG_PRIVATE_KEY }}" | gpg --batch --import
|
||||
FLATPAK_GPG_KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}')
|
||||
echo "FLATPAK_GPG_KEY_ID=$FLATPAK_GPG_KEY_ID" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Flatpak repo
|
||||
run: |
|
||||
flatpak-builder --force-clean target/flatpak-build target/de.x08.KST4Contest.yml
|
||||
flatpak build-export --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo target/flatpak-build stable
|
||||
flatpak build-update-repo --gpg-sign="$FLATPAK_GPG_KEY_ID" target/flatpak-repo
|
||||
|
||||
- name: Create flatpakref
|
||||
run: |
|
||||
REPO_NAME="${GITHUB_REPOSITORY#*/}"
|
||||
PAGES_URL="https://${GITHUB_REPOSITORY_OWNER}.github.io/${REPO_NAME}/"
|
||||
GPG_KEY_B64=$(gpg --export "$FLATPAK_GPG_KEY_ID" | base64 -w 0)
|
||||
cat > "dist/de.x08.KST4Contest.flatpakref" << EOF
|
||||
[Flatpak Ref]
|
||||
Name=de.x08.KST4Contest
|
||||
Branch=stable
|
||||
Title=KST4Contest – ON4KST Chat Client
|
||||
Url=${PAGES_URL}
|
||||
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
GPGKey=${GPG_KEY_B64}
|
||||
IsRuntime=false
|
||||
EOF
|
||||
|
||||
- name: Upload flatpakref
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: flatpakref
|
||||
path: dist/de.x08.KST4Contest.flatpakref
|
||||
|
||||
- name: Upload Flatpak OSTree repo
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: flatpak-ostree-repo
|
||||
path: target/flatpak-repo/
|
||||
|
||||
build-macos-dmg:
|
||||
name: Build macOS DMG (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, macos-15-intel]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.1.7
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4.1.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Ensure mvnw is executable
|
||||
run: chmod +x mvnw
|
||||
|
||||
- name: Build JAR and copy runtime dependencies
|
||||
run: |
|
||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
||||
|
||||
- name: Build macOS DMG with jpackage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
jpackage \
|
||||
--type dmg \
|
||||
--name KST4Contest \
|
||||
--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
|
||||
|
||||
env:
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
|
||||
- name: Rename DMG artifact
|
||||
run: |
|
||||
ARCH=$(uname -m)
|
||||
DMG=$(ls dist/*.dmg | head -n 1)
|
||||
if [ -z "$DMG" ]; then
|
||||
echo "No DMG produced by jpackage" && exit 1
|
||||
fi
|
||||
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
|
||||
|
||||
- name: Upload macOS artifact
|
||||
uses: actions/upload-artifact@v4.3.4
|
||||
with:
|
||||
name: macos-dmg-${{ matrix.os }}
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
|
||||
path: dist/praktiKST-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||
|
||||
build-docs-pdf:
|
||||
name: Build Documentation PDF
|
||||
@@ -575,47 +211,13 @@ jobs:
|
||||
name: docs-pdf
|
||||
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.pdf
|
||||
|
||||
publish-flatpak-repo:
|
||||
name: Publish Flatpak OSTree Repo to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-flatpak
|
||||
|
||||
steps:
|
||||
- name: Download OSTree repo artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpak-ostree-repo
|
||||
path: flatpak-ostree-repo/
|
||||
|
||||
- name: Download flatpakref
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpakref
|
||||
path: flatpak-ostree-repo/
|
||||
|
||||
- name: Push to flatpak-repo branch
|
||||
run: |
|
||||
cd flatpak-ostree-repo
|
||||
git init
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config user.name "github-actions[bot]"
|
||||
git add -A
|
||||
git commit -m "Flatpak repo: ${{ github.ref_name }}"
|
||||
git push --force https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git HEAD:flatpak-repo
|
||||
|
||||
release-tag:
|
||||
name: Publish Tagged Release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-windows-zip
|
||||
- build-linux-appimage
|
||||
- build-linux-deb
|
||||
- build-linux-rpm
|
||||
- build-linux-arch
|
||||
- build-macos-dmg
|
||||
- build-flatpak
|
||||
- build-docs-pdf
|
||||
- publish-flatpak-repo
|
||||
|
||||
steps:
|
||||
- name: Download Windows artifact
|
||||
@@ -630,37 +232,6 @@ jobs:
|
||||
name: linux-appimage
|
||||
path: release-assets/linux
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
pattern: macos-dmg-*
|
||||
merge-multiple: true
|
||||
path: release-assets/macos
|
||||
|
||||
- name: Download Debian artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-debian
|
||||
path: release-assets/debian
|
||||
|
||||
- name: Download Fedora artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-fedora
|
||||
path: release-assets/fedora
|
||||
|
||||
- name: Download Arch Linux artifact
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: linux-arch
|
||||
path: release-assets/archlinux
|
||||
|
||||
- name: Download flatpakref
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
name: flatpakref
|
||||
path: release-assets/flatpakref
|
||||
|
||||
- name: Download PDF manuals
|
||||
uses: actions/download-artifact@v4.1.3
|
||||
with:
|
||||
@@ -680,11 +251,6 @@ jobs:
|
||||
generateReleaseNotes: true
|
||||
artifacts: >-
|
||||
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
||||
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
|
||||
release-assets/debian/KST4Contest-${{ github.ref_name }}-debian-amd64.deb,
|
||||
release-assets/fedora/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm,
|
||||
release-assets/archlinux/KST4Contest-${{ github.ref_name }}-archlinux-*.pkg.tar.zst,
|
||||
release-assets/flatpakref/de.x08.KST4Contest.flatpakref,
|
||||
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
||||
release-assets/linux/praktiKST-${{ github.ref_name }}-linux-x86_64.AppImage,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
S53MM
|
||||
PA9R
|
||||
dr2x
|
||||
oe3cin
|
||||
+15832
File diff suppressed because it is too large
Load Diff
@@ -44,17 +44,6 @@ Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
### Fehler melden (Issue erstellen)
|
||||
|
||||
Beim Melden eines Fehlers bitte **immer die Logdatei anhängen**. KST4Contest schreibt automatisch eine Fehler-Logdatei (nur Fehlermeldungen, keine persönlichen Daten):
|
||||
|
||||
| Betriebssystem | Pfad zur Logdatei |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<Benutzername>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
Beim Erstellen eines Issues auf GitHub steht eine Vorlage bereit, die alle wichtigen Felder abfragt.
|
||||
|
||||
---
|
||||
|
||||
## Danksagungen
|
||||
|
||||
@@ -42,29 +42,11 @@ Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
||||
|
||||
### Linux
|
||||
|
||||
Mehrere Paketformate stehen auf der Releases-Seite zur Verfügung:
|
||||
Die aktuelle Version kann als AppImage heruntergeladen werden:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Dateiname | Geeignet für |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` | Alle Distributionen |
|
||||
| Debian-Paket | `KST4Contest-v<Version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | Alle Distributionen mit Flatpak |
|
||||
|
||||
> **Empfehlung für Linux:** Die Flatpak-Installation ist der einfachste Weg, immer aktuell zu bleiben – `flatpak update` erledigt alle zukünftigen Updates automatisch. Das Repository ist GPG-signiert.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort-Support:** macOS-Builds werden als zusätzliche Option bereitgestellt, sind aber **nicht umfassend getestet**. Wir bauen und veröffentlichen macOS-Binaries mit jedem Release, können allerdings nicht alle Szenarien unter macOS testen. Bei Problemen freuen wir uns über eine Rückmeldung – wir versuchen unser Bestes, können aber nicht den gleichen Support-Umfang wie für Windows und Linux garantieren.
|
||||
|
||||
Die aktuelle Version kann als DMG-Disk-Image heruntergeladen werden (für Apple-Silicon- und Intel-Macs verfügbar):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.dmg`, wobei `<Architektur>` entweder `arm64` (Apple Silicon) oder `x86_64` (Intel) ist.
|
||||
Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-linux-x86_64.AppImage`.
|
||||
|
||||
|
||||
---
|
||||
@@ -80,56 +62,10 @@ Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.
|
||||
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
||||
|
||||
### Linux
|
||||
|
||||
Die Einstellungen werden immer unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
#### AppImage
|
||||
|
||||
1. AppImage herunterladen.
|
||||
2. Ausführbar machen: `chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage`
|
||||
3. Starten.
|
||||
|
||||
#### Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Oder die `.deb`-Datei im Dateimanager doppelklicken.
|
||||
|
||||
#### Fedora / RHEL
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
#### Arch Linux
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
#### Flatpak
|
||||
|
||||
Die Datei `de.x08.KST4Contest.flatpakref` aus dem [aktuellen Release](https://github.com/praktimarc/kst4contest/releases/latest) herunterladen und öffnen, oder:
|
||||
```bash
|
||||
flatpak install de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
|
||||
Oder den Remote manuell hinzufügen:
|
||||
```bash
|
||||
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### macOS
|
||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
||||
2. DMG-Datei öffnen.
|
||||
3. `KST4Contest.app` in den **Programme**-Ordner ziehen.
|
||||
4. Beim ersten Start zeigt macOS ggf. eine Warnung, da die App nicht notarisiert ist. Zum Öffnen:
|
||||
- Rechtsklick (oder Ctrl-Klick) auf `KST4Contest.app` im Finder → **Öffnen** wählen.
|
||||
- Alternativ: **Systemeinstellungen → Datenschutz & Sicherheit** → **Trotzdem öffnen** klicken.
|
||||
5. KST4Contest aus dem Programme-Ordner oder dem Launchpad starten.
|
||||
2. AppImage in gewünschten Ordner entpacken.
|
||||
3. AppImage ausführbar machen (geht im Terminal mit `chmod +x praktiKST-v<Versionsnummer>-linux-x86_64.AppImage`)
|
||||
4. AppImage ausführen.
|
||||
|
||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||
|
||||
@@ -157,17 +93,10 @@ Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerord
|
||||
|
||||
#### Linux
|
||||
|
||||
- **AppImage**: Neues AppImage herunterladen, ausführbar machen (`chmod +x`), altes optional löschen.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (Repository)**: `flatpak update` – aktualisiert alle Flatpak-Apps einschließlich KST4Contest.
|
||||
|
||||
#### macOS
|
||||
|
||||
1. Neue DMG-Datei herunterladen.
|
||||
2. DMG öffnen.
|
||||
3. Die neue `KST4Contest.app` in den **Programme**-Ordner ziehen und die alte Version ersetzen.
|
||||
Derzeit folgendermaßen:
|
||||
1. neues AppImage herunterladen
|
||||
2. neues AppImage ausführbar makieren
|
||||
3. (optional) altes AppImage löschen.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -87,22 +87,16 @@ Win-Test wird mit einem dedizierten UDP-Netzwerk-Listener unterstützt, der das
|
||||
|
||||
**Vorteile der Win-Test Integration:**
|
||||
- Automatische QSO-Synchronisation zur Markierung gearbeiteter Stationen.
|
||||
- **Sked-Übergabe (ADDSKED):** Über den Button "Create sked" im Stationsinfo-Panel wird nicht nur in KST4Contest ein Sked angelegt, sondern dieser auch *direkt per UDP an das Win-Test Netzwerk als ADDSKED-Paket gesendet* – automatisch, sobald der Listener aktiv ist.
|
||||
- **Sked-Übergabe (ADDSKED):** Über den Button "Create sked" im Stationsinfo-Panel wird nicht nur in KST4Contest ein Sked angelegt, sondern dieses auch *direkt per UDP an das Win-Test Netzwerk als ADDSKED-Paket gesendet*.
|
||||
- Es kann zwischen den Sked-Modi "AUTO", "SSB" oder "CW" gewählt werden.
|
||||
- **Automatische QRG-Auflösung für SKEDs:** KST4Contest wählt die Sked-Frequenz intelligent:
|
||||
1. Hat die Gegenstation in einer Chat-Nachricht ihre QRG genannt, wird diese verwendet.
|
||||
2. Sonst wird die eigene aktuelle QRG verwendet (aus Win-Test STATUS oder manueller Eingabe).
|
||||
|
||||
**Einstellungen im Reiter „Log-Synchronisation":**
|
||||
- `Receive Win-Test network based UDP log messages` aktivieren.
|
||||
**Notwendige Einstellungen in KST4Contest:**
|
||||
- `UDP-Port for Win-Test listener` (Standard: 9871).
|
||||
- `Receive Win-Test network based UDP log messages` aktivieren.
|
||||
- `Win-Test sked transmission (push via ADDSKED to Win-Test network)` aktivieren.
|
||||
- `KST station name in Win-Test network (src of SKED packets)`: Legt fest, unter welchem Stationsnamen KST4Contest im WT-Netzwerk auftritt (z.B. "KST").
|
||||
- `Win-Test network broadcast address`: Wird i.d.R. automatisch erkannt; erforderlich für das Senden von Sked-Paketen.
|
||||
|
||||
**Einstellungen im Reiter „TRX-Synchronisation":**
|
||||
- `Win-Test STATUS QRG Sync`: Wenn aktiviert, übernimmt KST4Contest die aktuelle Transceiverfrequenz aus dem Win-Test STATUS-Paket als eigene QRG (MYQRG).
|
||||
- `Use pass frequency from Win-Test STATUS`: Statt der eigenen TRX-QRG wird die im STATUS-Paket enthaltene Pass-Frequenz als MYQRG verwendet (für Multi-Op-Setups, bei denen mit einer Pass-QRG gearbeitet wird).
|
||||
- `Win-Test station name filter`: Wird hier ein Name eingetragen (z.B. "STN1"), verarbeitet KST4Contest nur Pakete dieser Win-Test-Instanz. Leer lassen, um alle zu akzeptieren.
|
||||
- `Win-Test station name filter`: Wenn hier ein Name eingetragen wird (z.B. "STN1"), werden nur QSOs von dieser bestimmten Win-Test Instanz verarbeitet. Leer lassen, um alle zu akzeptieren.
|
||||
- `Win-Test network broadcast address`: Wird idR automatisch erkannt und ist erforderlich, um die Sked-Pakete ins Netzwerk zu senden.
|
||||
|
||||
**Einstellungen in Win-Test:**
|
||||
- Das Netzwerk in Win-Test muss aktiv sein.
|
||||
@@ -118,11 +112,6 @@ Neben der QSO-Synchronisation übertragen UCXLog und andere Programme auch die *
|
||||
|
||||
**Ergebnis**: Die eigene QRG muss im Chat nie mehr manuell eingegeben werden – ein Klick auf den MYQRG-Button oder die Verwendung der Variable im Beacon genügt.
|
||||
|
||||
**Quellen für die eigene QRG (MYQRG):**
|
||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP-Port 12060
|
||||
- Win-Test STATUS-Paket (optional, konfigurierbar im Reiter „TRX-Synchronisation" unter „Win-Test STATUS QRG Sync")
|
||||
- Manuelle Eingabe im QRG-Feld
|
||||
|
||||
> **Hinweis für Multi-Setup**: Bei zwei Logprogrammen an zwei Computern sollte nur **eines** die Frequenzpakete senden. KST4Contest kann nicht zwischen den Quellen unterscheiden und verarbeitet alle eingehenden Pakete.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,17 +44,6 @@ The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher ban
|
||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
||||
|
||||
### Reporting a bug (creating an issue)
|
||||
|
||||
When reporting a bug, please **always attach the log file**. KST4Contest automatically writes an error log (error messages only, no personal data):
|
||||
|
||||
| Operating system | Log file location |
|
||||
|---|---|
|
||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
||||
|
||||
When opening an issue on GitHub, a template is provided that guides you through all relevant fields.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -42,29 +42,11 @@ The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
||||
|
||||
### Linux
|
||||
|
||||
Multiple package formats are available from the releases page:
|
||||
The latest version can be downloaded as an AppImage:
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
| Format | Filename | Suitable for |
|
||||
|---|---|---|
|
||||
| AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` | All distributions |
|
||||
| Debian package | `KST4Contest-v<version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
||||
| RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
||||
| Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | All distributions with Flatpak |
|
||||
|
||||
> **Recommended for Linux:** The Flatpak installation is the easiest way to stay up to date — `flatpak update` handles all future updates automatically. The repository is GPG-signed for security.
|
||||
|
||||
### macOS
|
||||
|
||||
> ⚠️ **Best-Effort Support:** macOS builds are provided as a convenience but are **not fully tested**. We build and release macOS binaries with every release, but we cannot test every scenario on macOS. If you encounter issues, please report them – we will do our best to address them, but cannot guarantee the same level of support as for Windows and Linux.
|
||||
|
||||
The latest version can be downloaded as a DMG disk image (available for both Apple Silicon and Intel Macs):
|
||||
|
||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||
|
||||
The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, where `<arch>` is `arm64` (Apple Silicon) or `x86_64` (Intel).
|
||||
The filename has the format `praktiKST-v<version_number>-linux-x86_64.AppImage`.
|
||||
|
||||
|
||||
---
|
||||
@@ -80,56 +62,10 @@ The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, wh
|
||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||
|
||||
### Linux
|
||||
|
||||
Settings are always stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
#### AppImage
|
||||
|
||||
1. Download the AppImage.
|
||||
2. Make it executable: `chmod +x KST4Contest-v<version>-linux-x86_64.AppImage`
|
||||
3. Run it.
|
||||
|
||||
#### Debian / Ubuntu
|
||||
|
||||
```bash
|
||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
||||
```
|
||||
|
||||
Or double-click the `.deb` file in your file manager.
|
||||
|
||||
#### Fedora / RHEL
|
||||
|
||||
```bash
|
||||
sudo dnf install ./KST4Contest-v<version>-fedora-x86_64.rpm
|
||||
```
|
||||
|
||||
#### Arch Linux
|
||||
|
||||
```bash
|
||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
#### Flatpak
|
||||
|
||||
Download `de.x08.KST4Contest.flatpakref` from the [latest release](https://github.com/praktimarc/kst4contest/releases/latest) and open it, or run:
|
||||
```bash
|
||||
flatpak install de.x08.KST4Contest.flatpakref
|
||||
```
|
||||
|
||||
Or add the remote manually:
|
||||
```bash
|
||||
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/
|
||||
flatpak install kst4contest de.x08.KST4Contest
|
||||
```
|
||||
|
||||
### macOS
|
||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
||||
2. Open the DMG file.
|
||||
3. Drag `KST4Contest.app` into your **Applications** folder.
|
||||
4. On first launch, macOS may show a warning because the app is not notarised. To open it:
|
||||
- Right-click (or Control-click) on `KST4Contest.app` in Finder and choose **Open**.
|
||||
- Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway**.
|
||||
5. Run KST4Contest from your Applications folder or Launchpad.
|
||||
2. Unzip the AppImage into a folder of your choice.
|
||||
3. Make the AppImage executable (in the terminal with `chmod +x praktiKST-v<version_number>-linux-x86_64.AppImage`)
|
||||
4. Run the AppImage.
|
||||
|
||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||
|
||||
@@ -157,17 +93,10 @@ The settings file (`preferences.xml`) is preserved because it is stored in the u
|
||||
|
||||
#### Linux
|
||||
|
||||
- **AppImage**: Download the new AppImage, make it executable (`chmod +x`), optionally delete the old one.
|
||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<version>-debian-amd64.deb`
|
||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm`
|
||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst`
|
||||
- **Flatpak (repository)**: `flatpak update` – updates all Flatpak apps including KST4Contest.
|
||||
|
||||
#### macOS
|
||||
|
||||
1. Download the new DMG file.
|
||||
2. Open the DMG.
|
||||
3. Drag the new `KST4Contest.app` into your **Applications** folder, replacing the old version.
|
||||
Currently as follows:
|
||||
1. Download the new AppImage
|
||||
2. Mark the new AppImage as executable
|
||||
3. (optional) Delete the old AppImage.
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -87,22 +87,16 @@ Win-Test is supported with a dedicated UDP network listener that understands the
|
||||
|
||||
**Advantages of Win-Test Integration:**
|
||||
- Automatic QSO synchronization to mark worked stations.
|
||||
- **Sked Handover (ADDSKED):** Using the "Create sked" button in the station info panel not only creates a sked in KST4Contest but also *sends it directly via UDP to the Win-Test network as an ADDSKED packet* – automatically, as soon as the listener is active. No separate toggle is needed.
|
||||
- **Sked Handover (ADDSKED):** Using the "Create sked" button in the station info panel not only creates a sked in KST4Contest but also *sends it directly via UDP to the Win-Test network as an ADDSKED packet*.
|
||||
- You can choose between "AUTO", "SSB", or "CW" sked modes.
|
||||
- **Automatic QRG resolution for SKEDs:** KST4Contest selects the sked frequency intelligently:
|
||||
1. If the other station mentioned their QRG in a recent chat message, that frequency is used.
|
||||
2. Otherwise, your own current QRG is used (from Win-Test STATUS or manual entry).
|
||||
|
||||
**Settings in the "Log Synchronisation" tab:**
|
||||
**Required Settings in KST4Contest:**
|
||||
- `UDP-Port for Win-Test listener` (Default: 9871).
|
||||
- Enable `Receive Win-Test network based UDP log messages`.
|
||||
- `UDP-Port for Win-Test listener` (default: 9871).
|
||||
- `KST station name in Win-Test network (src of SKED packets)`: Defines the station name KST4Contest uses in the WT network (e.g. "KST").
|
||||
- `Win-Test network broadcast address`: Usually detected automatically; required to send sked packets to the network.
|
||||
|
||||
**Settings in the "TRX Synchronisation" tab:**
|
||||
- `Win-Test STATUS QRG Sync`: When enabled, KST4Contest takes the current transceiver frequency from the Win-Test STATUS packet and uses it as your own QRG (MYQRG).
|
||||
- `Use pass frequency from Win-Test STATUS`: Instead of the main TRX frequency, the pass frequency contained in the STATUS packet is used as MYQRG (useful for multi-op setups that operate with a dedicated pass QRG).
|
||||
- `Win-Test station name filter`: If a name is entered here (e.g. "STN1"), KST4Contest only processes packets from that specific Win-Test instance. Leave empty to accept all.
|
||||
- Enable `Win-Test sked transmission (push via ADDSKED to Win-Test network)`.
|
||||
- `KST station name in Win-Test network (src of SKED packets)`: Defines the station name KST4Contest uses in the WT network (e.g., "KST").
|
||||
- `Win-Test station name filter`: If a name is entered here (e.g., "STN1"), only QSOs from that specific Win-Test instance will be processed. Leave empty to accept all.
|
||||
- `Win-Test network broadcast address`: Is usually detected automatically and is required to send sked packets to the network.
|
||||
|
||||
**Settings in Win-Test:**
|
||||
- The network in Win-Test must be active.
|
||||
@@ -118,11 +112,6 @@ In addition to QSO synchronisation, UCXLog and other programs also transmit the
|
||||
|
||||
**Result**: Your own QRG never needs to be typed manually in the chat – clicking the MYQRG button or using the variable in the beacon is sufficient.
|
||||
|
||||
**Sources for your own QRG (MYQRG):**
|
||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP port 12060
|
||||
- Win-Test STATUS packet (optional, configurable in the "TRX Synchronisation" tab under "Win-Test STATUS QRG Sync")
|
||||
- Manual entry in the QRG field
|
||||
|
||||
> **Note for multi-setup**: With two logging programs on two computers, only **one** should send frequency packets. KST4Contest cannot distinguish between sources and processes all incoming packets.
|
||||
|
||||
---
|
||||
|
||||
@@ -437,8 +437,6 @@
|
||||
<addmodule>javafx.fxml</addmodule>
|
||||
<addmodule>javafx.web</addmodule>
|
||||
<addmodule>java.sql</addmodule>
|
||||
<addmodule>java.net.http</addmodule>
|
||||
<addmodule>jdk.crypto.ec</addmodule>
|
||||
</addmodules>
|
||||
<mainclass>${main.class}</mainclass>
|
||||
<input>${project.build.directory}/modules</input>
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import random
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# =====================================
|
||||
# KST-Server-Simulator / DO5AMF
|
||||
# Usage: change configuration below and
|
||||
# run. Enter 127.0.0.1 : 23001 as a
|
||||
# target in KST4Contest or another
|
||||
# KST chat client.
|
||||
# =====================================
|
||||
|
||||
# ==========================================
|
||||
# KONFIGURATION
|
||||
# ==========================================
|
||||
|
||||
PORT = 23001
|
||||
HOST = '127.0.0.1'
|
||||
|
||||
MSG_TO_USER_INTERVAL = 300.0
|
||||
LOGIN_LOGOUT_INTERVAL = 60.0
|
||||
KEEP_ALIVE_INTERVAL = 10.0
|
||||
CLIENT_WARMUP_TIME = 5.0
|
||||
|
||||
PROB_INACTIVE = 0.10
|
||||
PROB_REACTIVE = 0.20
|
||||
|
||||
# QSY Wahrscheinlichkeit (Wie oft wechselt ein User seine Frequenz?)
|
||||
# 0.05 = 5% Chance pro Nachricht, dass er die Frequenz ändert. Sonst bleibt er stabil.
|
||||
PROB_QSY = 0.05
|
||||
|
||||
BANDS_VHF = { "2m": (144.150, 144.400), "70cm": (432.100, 432.300) }
|
||||
BANDS_UHF = { "23cm": (1296.100, 1296.300), "3cm": (10368.100, 10368.250) }
|
||||
|
||||
CHANNELS_SETUP = {
|
||||
"2": {
|
||||
"NAME": "144/432 MHz",
|
||||
"NUM_USERS": 777,
|
||||
"BANDS": BANDS_VHF,
|
||||
"RATES": {"PUBLIC": 0.5, "DIRECTED": 3.0},
|
||||
"PERMANENT": [
|
||||
{"call": "DK5EW", "name": "Erwin", "loc": "JN47NX"},
|
||||
{"call": "DL1TEST", "name": "TestOp", "loc": "JO50XX"}
|
||||
]
|
||||
},
|
||||
"3": {
|
||||
"NAME": "Microwave",
|
||||
"NUM_USERS": 333,
|
||||
"BANDS": BANDS_UHF,
|
||||
"RATES": {"PUBLIC": 0.2, "DIRECTED": 0.5},
|
||||
"PERMANENT": [
|
||||
{"call": "ON4KST", "name": "Alain", "loc": "JO20HI"},
|
||||
{"call": "G4CBW", "name": "MwTest", "loc": "IO83AA"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
COUNTRY_MAPPING = {
|
||||
"DL": ["JO", "JN"], "DA": ["JO", "JN"], "DF": ["JO", "JN"], "DJ": ["JO", "JN"], "DK": ["JO", "JN"], "DO": ["JO", "JN"],
|
||||
"F": ["JN", "IN", "JO"], "G": ["IO", "JO"], "M": ["IO", "JO"], "2E": ["IO", "JO"],
|
||||
"PA": ["JO"], "ON": ["JO"], "OZ": ["JO"], "SM": ["JO", "JP"], "LA": ["JO", "JP"],
|
||||
"OH": ["KP"], "SP": ["JO", "KO"], "OK": ["JO", "JN"], "OM": ["JN", "KN"],
|
||||
"HA": ["JN", "KN"], "S5": ["JN"], "9A": ["JN"], "HB9": ["JN"], "OE": ["JN"],
|
||||
"I": ["JN", "JM"], "IK": ["JN", "JM"], "IU": ["JN", "JM"], "EA": ["IN", "IM"],
|
||||
"CT": ["IM"], "EI": ["IO"], "GM": ["IO"], "GW": ["IO"], "YO": ["KN"],
|
||||
"YU": ["KN"], "LZ": ["KN"], "SV": ["KM", "KN"], "UR": ["KO", "KN"],
|
||||
"LY": ["KO"], "YL": ["KO"], "ES": ["KO"]
|
||||
}
|
||||
|
||||
NAMES = ["Hans", "Peter", "Jo", "Alain", "Mike", "Sven", "Ole", "Jean", "Bob", "Tom", "Giovanni", "Mario", "Frank", "Steve", "Dave"]
|
||||
|
||||
MSG_TEMPLATES_WITH_FREQ = [
|
||||
"QSY {freq}", "PSE QSY {freq}", "Calling CQ on {freq}", "I am QRV on {freq}",
|
||||
"Listening on {freq}", "Can you try {freq}?", "Signals strong on {freq}",
|
||||
"Scattering on {freq}", "Please go to {freq}", "Running test on {freq}",
|
||||
"Any takers for {freq}?", "Back to {freq}", "QRG {freq}?", "Aircraft scatter {freq}"
|
||||
]
|
||||
|
||||
MSG_TEMPLATES_TEXT_ONLY = [
|
||||
"TNX for QSO", "73 all", "Anyone for sked?", "Good conditions",
|
||||
"Nothing heard", "Rain scatter?", "Waiting for moonrise", "CQ Contest",
|
||||
"QRZ?", "My locator is {loc}", "Band is open"
|
||||
]
|
||||
|
||||
REPLY_TEMPLATES = [
|
||||
"Hello {user}, 599 here", "Rgr {user}, tnx for report", "Yes {user}, QSY?",
|
||||
"Sorry {user}, no copy", "Pse wait 5 min {user}", "Ok {user}, 73",
|
||||
"Locator is {loc}", "Go to {freq} please", "Rgr {user}, gl"
|
||||
]
|
||||
|
||||
# ==========================================
|
||||
# CLIENT WRAPPER
|
||||
# ==========================================
|
||||
|
||||
class ConnectedClient:
|
||||
def __init__(self, sock, addr):
|
||||
self.sock = sock
|
||||
self.addr = addr
|
||||
self.call = f"GUEST_{random.randint(1000,9999)}"
|
||||
self.channels = {"2"}
|
||||
self.login_time = time.time()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def send_safe(self, data_str):
|
||||
if not data_str: return True
|
||||
with self.lock:
|
||||
try:
|
||||
self.sock.sendall(data_str.encode('latin-1', errors='replace'))
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
try: self.sock.close()
|
||||
except: pass
|
||||
|
||||
# ==========================================
|
||||
# LOGIK KLASSEN
|
||||
# ==========================================
|
||||
|
||||
class MessageFactory:
|
||||
@staticmethod
|
||||
def get_stable_frequency(user, band_name, min_f, max_f):
|
||||
"""Liefert eine stabile Frequenz für diesen User auf diesem Band"""
|
||||
# Wenn noch keine Frequenz da ist ODER Zufall zuschlägt (QSY)
|
||||
if band_name not in user['freqs'] or random.random() < PROB_QSY:
|
||||
freq_val = round(random.uniform(min_f, max_f), 3)
|
||||
user['freqs'][band_name] = f"{freq_val:.3f}"
|
||||
|
||||
return user['freqs'][band_name]
|
||||
|
||||
@staticmethod
|
||||
def get_chat_message(bands_config, user):
|
||||
try:
|
||||
# Entscheidung: Text mit Frequenz oder ohne?
|
||||
if random.random() < 0.7:
|
||||
# Wähle zufälliges Band aus den verfügbaren
|
||||
band_name = random.choice(list(bands_config.keys()))
|
||||
min_f, max_f = bands_config[band_name]
|
||||
|
||||
# Hole STABILE Frequenz für diesen User
|
||||
freq_str = MessageFactory.get_stable_frequency(user, band_name, min_f, max_f)
|
||||
|
||||
return random.choice(MSG_TEMPLATES_WITH_FREQ).format(freq=freq_str)
|
||||
else:
|
||||
return random.choice(MSG_TEMPLATES_TEXT_ONLY).format(loc=user['loc'])
|
||||
except: return "TNX 73"
|
||||
|
||||
@staticmethod
|
||||
def get_reply_msg(bands, target_call, my_loc):
|
||||
try:
|
||||
tmpl = random.choice(REPLY_TEMPLATES)
|
||||
freq_str = "QSY?"
|
||||
# Bei Replies simulieren wir oft nur "QSY?" ohne konkrete Frequenz,
|
||||
# oder nutzen eine zufällige, da der Kontext fehlt.
|
||||
if "{freq}" in tmpl and bands:
|
||||
band_name = random.choice(list(bands.keys()))
|
||||
min_f, max_f = bands[band_name]
|
||||
freq_str = f"{round(random.uniform(min_f, max_f), 3):.3f}"
|
||||
return tmpl.format(user=target_call, loc=my_loc, freq=freq_str)
|
||||
except: return "TNX 73"
|
||||
|
||||
class UserFactory:
|
||||
registry = {}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_user(cls, channel_id, current_channel_users):
|
||||
# 1. Reuse existing
|
||||
candidates = [u for call, u in cls.registry.items() if call not in current_channel_users]
|
||||
if candidates and random.random() < 0.5:
|
||||
return random.choice(candidates)
|
||||
|
||||
# 2. Create new
|
||||
return cls._create_new_unique_user(channel_id, current_channel_users)
|
||||
|
||||
@classmethod
|
||||
def _create_new_unique_user(cls, channel_id, current_channel_users):
|
||||
while True:
|
||||
prefix = random.choice(list(COUNTRY_MAPPING.keys()))
|
||||
num = random.randint(0, 9)
|
||||
suffix = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=random.randint(1,3)))
|
||||
call = f"{prefix}{num}{suffix}"
|
||||
|
||||
if call in current_channel_users: continue
|
||||
if call in cls.registry: return cls.registry[call]
|
||||
|
||||
valid_grids = COUNTRY_MAPPING[prefix]
|
||||
grid_prefix = random.choice(valid_grids)
|
||||
sq_num = f"{random.randint(0,99):02d}"
|
||||
sub = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=2))
|
||||
loc = f"{grid_prefix}{sq_num}{sub}"
|
||||
|
||||
name = random.choice(NAMES)
|
||||
rand = random.random()
|
||||
if rand < PROB_INACTIVE: role = "INACTIVE"
|
||||
elif rand < (PROB_INACTIVE + PROB_REACTIVE): role = "REACTIVE"
|
||||
else: role = "ACTIVE"
|
||||
|
||||
# Neu V31: Frequenz-Gedächtnis
|
||||
user_data = {
|
||||
"call": call,
|
||||
"name": name,
|
||||
"loc": loc,
|
||||
"role": role,
|
||||
"freqs": {} # Speicher für { '2m': '144.300' }
|
||||
}
|
||||
|
||||
cls.registry[call] = user_data
|
||||
return user_data
|
||||
|
||||
@classmethod
|
||||
def register_permanent(cls, user_data):
|
||||
# Sicherstellen, dass auch Permanent User Freq-Memory haben
|
||||
if "freqs" not in user_data:
|
||||
user_data["freqs"] = {}
|
||||
cls.registry[user_data['call']] = user_data
|
||||
|
||||
# ==========================================
|
||||
# CHANNEL INSTANCE
|
||||
# ==========================================
|
||||
|
||||
class ChannelInstance:
|
||||
def __init__(self, cid, config, server):
|
||||
self.id = cid
|
||||
self.config = config
|
||||
self.server = server
|
||||
|
||||
self.users_pool = []
|
||||
self.online_users = {}
|
||||
self.history_chat = []
|
||||
|
||||
self.last_pub = time.time()
|
||||
self.last_dir = time.time()
|
||||
self.last_me = time.time()
|
||||
self.last_login = time.time()
|
||||
|
||||
self.rate_pub = 1.0 / config["RATES"]["PUBLIC"]
|
||||
self.rate_dir = 1.0 / config["RATES"]["DIRECTED"]
|
||||
|
||||
self._init_data()
|
||||
|
||||
def _init_data(self):
|
||||
print(f"[*] Init Channel {self.id} ({self.config['NAME']})...")
|
||||
|
||||
for u in self.config["PERMANENT"]:
|
||||
u_full = u.copy()
|
||||
u_full["role"] = "ACTIVE"
|
||||
UserFactory.register_permanent(u_full)
|
||||
self.online_users[u['call']] = u_full
|
||||
|
||||
for _ in range(self.config["NUM_USERS"]):
|
||||
new_u = UserFactory.get_or_create_user(self.id, self.online_users.keys())
|
||||
self.users_pool.append(new_u)
|
||||
|
||||
fill = int(self.config["NUM_USERS"] * 0.9)
|
||||
for i in range(fill):
|
||||
u = self.users_pool[i]
|
||||
if u['call'] not in self.online_users:
|
||||
self.online_users[u['call']] = u
|
||||
|
||||
print(f"[*] Channel {self.id} ready: {len(self.online_users)} Users.")
|
||||
self._prefill_history()
|
||||
|
||||
def _prefill_history(self):
|
||||
actives = [u for u in self.online_users.values() if u['role'] == "ACTIVE"]
|
||||
if not actives: return
|
||||
start = datetime.now() - timedelta(minutes=15)
|
||||
for i in range(30):
|
||||
msg_time = start + timedelta(seconds=i*30)
|
||||
ts = str(int(msg_time.timestamp()))
|
||||
sender = random.choice(actives)
|
||||
if i % 2 == 0:
|
||||
text = MessageFactory.get_chat_message(self.config["BANDS"], sender)
|
||||
frame = f"CH|{self.id}|{ts}|{sender['call']}|{sender['name']}|0|{text}|0|\r\n"
|
||||
else:
|
||||
target = random.choice(list(self.online_users.values()))
|
||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], target['call'], sender['loc'])
|
||||
frame = f"CH|{self.id}|{ts}|{sender['call']}|{sender['name']}|0|{text}|{target['call']}|\r\n"
|
||||
self.history_chat.append(frame)
|
||||
|
||||
def tick(self, now):
|
||||
actives = [u for u in self.online_users.values() if u['role'] == "ACTIVE"]
|
||||
if not actives: return
|
||||
|
||||
# PUBLIC
|
||||
if now - self.last_pub > self.rate_pub:
|
||||
self.last_pub = now
|
||||
u = random.choice(actives)
|
||||
# V31: Nutzt jetzt get_chat_message, das das Freq-Memory abfragt
|
||||
text = MessageFactory.get_chat_message(self.config["BANDS"], u)
|
||||
ts = str(int(now))
|
||||
frame = f"CH|{self.id}|{ts}|{u['call']}|{u['name']}|0|{text}|0|\r\n"
|
||||
self._add_hist(frame)
|
||||
self.server.broadcast_to_channel(self.id, frame)
|
||||
|
||||
# DIRECTED
|
||||
if now - self.last_dir > self.rate_dir:
|
||||
self.last_dir = now
|
||||
if len(actives) > 5:
|
||||
u1 = random.choice(actives)
|
||||
u2 = random.choice(list(self.online_users.values()))
|
||||
if u1 != u2:
|
||||
if random.random() < 0.5:
|
||||
# Auch hier Frequenzstabilität beachten
|
||||
text = MessageFactory.get_chat_message(self.config["BANDS"], u1)
|
||||
else:
|
||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], u2['call'], u1['loc'])
|
||||
ts = str(int(now))
|
||||
frame = f"CH|{self.id}|{ts}|{u1['call']}|{u1['name']}|0|{text}|{u2['call']}|\r\n"
|
||||
self.server.broadcast_to_channel(self.id, frame)
|
||||
if u2['role'] != "INACTIVE":
|
||||
threading.Thread(target=self._schedule_reply, args=(u2['call'], u1['call']), daemon=True).start()
|
||||
|
||||
# MSG TO YOU
|
||||
if now - self.last_me > MSG_TO_USER_INTERVAL:
|
||||
self.last_me = now
|
||||
target_client = self.server.get_random_subscriber(self.id)
|
||||
if target_client and actives:
|
||||
if not target_client.call.startswith("GUEST"):
|
||||
sender = random.choice(actives)
|
||||
text = MessageFactory.get_chat_message(self.config["BANDS"], sender)
|
||||
print(f"[SIM Ch{self.id}] MSG TO YOU ({target_client.call})")
|
||||
self.process_msg(sender['call'], sender['name'], text, target_client.call)
|
||||
|
||||
# LOGIN/LOGOUT
|
||||
if now - self.last_login > LOGIN_LOGOUT_INTERVAL:
|
||||
self.last_login = now
|
||||
if random.choice(['IN', 'OUT']) == 'OUT' and len(self.online_users) > 20:
|
||||
cands = [c for c in self.online_users if c not in [p['call'] for p in self.config["PERMANENT"]]]
|
||||
if cands:
|
||||
l = random.choice(cands)
|
||||
del self.online_users[l]
|
||||
self.server.broadcast_to_channel(self.id, f"UR6|{self.id}|{l}|\r\n")
|
||||
else:
|
||||
candidates = [u for u in self.users_pool if u['call'] not in self.online_users]
|
||||
if candidates:
|
||||
n = random.choice(candidates)
|
||||
self.online_users[n['call']] = n
|
||||
self.server.broadcast_to_channel(self.id, f"UA5|{self.id}|{n['call']}|{n['name']}|{n['loc']}|2|\r\n")
|
||||
|
||||
def process_msg(self, sender, name, text, target):
|
||||
ts = str(int(time.time()))
|
||||
frame = f"CH|{self.id}|{ts}|{sender}|{name}|0|{text}|{target}|\r\n"
|
||||
if target == "0": self._add_hist(frame)
|
||||
self.server.broadcast_to_channel(self.id, frame)
|
||||
if target in self.online_users:
|
||||
threading.Thread(target=self._schedule_reply, args=(target, sender), daemon=True).start()
|
||||
|
||||
def _schedule_reply(self, sim_sender, real_target):
|
||||
if sim_sender not in self.online_users: return
|
||||
u = self.online_users[sim_sender]
|
||||
if u['role'] == "INACTIVE": return
|
||||
|
||||
time.sleep(random.uniform(2.0, 5.0))
|
||||
if sim_sender in self.online_users:
|
||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], real_target, u['loc'])
|
||||
ts = str(int(time.time()))
|
||||
|
||||
if self.server.is_real_user(real_target):
|
||||
print(f"[REPLY Ch{self.id}] {sim_sender} -> {real_target}")
|
||||
|
||||
frame = f"CH|{self.id}|{ts}|{sim_sender}|{u['name']}|0|{text}|{real_target}|\r\n"
|
||||
self.server.broadcast_to_channel(self.id, frame)
|
||||
|
||||
def _add_hist(self, frame):
|
||||
self.history_chat.append(frame)
|
||||
if len(self.history_chat) > 50: self.history_chat.pop(0)
|
||||
|
||||
def get_full_init_blob(self):
|
||||
blob = ""
|
||||
for u in self.online_users.values():
|
||||
blob += f"UA0|{self.id}|{u['call']}|{u['name']}|{u['loc']}|0|\r\n"
|
||||
for h in self.history_chat: blob += h
|
||||
blob += f"UE|{self.id}|{len(self.online_users)}|\r\n"
|
||||
return blob.encode('latin-1', errors='replace')
|
||||
|
||||
# ==========================================
|
||||
# SERVER
|
||||
# ==========================================
|
||||
|
||||
class KSTServerV31:
|
||||
def __init__(self):
|
||||
self.lock = threading.Lock()
|
||||
self.running = True
|
||||
self.clients = {}
|
||||
self.channels = {}
|
||||
|
||||
for cid, cfg in CHANNELS_SETUP.items():
|
||||
self.channels[cid] = ChannelInstance(cid, cfg, self)
|
||||
|
||||
def start(self):
|
||||
threading.Thread(target=self._sim_loop, daemon=True).start()
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind((HOST, PORT))
|
||||
s.listen(5)
|
||||
s.settimeout(1.0)
|
||||
print(f"[*] ON4KST V31 (Stable Frequencies) running on {HOST}:{PORT}")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
sock, addr = s.accept()
|
||||
print(f"[*] CONNECT: {addr}")
|
||||
threading.Thread(target=self._handle_client, args=(sock,), daemon=True).start()
|
||||
except socket.timeout: continue
|
||||
except OSError: break
|
||||
except KeyboardInterrupt:
|
||||
print("\n[!] Stop.")
|
||||
finally:
|
||||
self.running = False
|
||||
try: s.close()
|
||||
except: pass
|
||||
|
||||
def _handle_client(self, sock):
|
||||
client_obj = ConnectedClient(sock, None)
|
||||
with self.lock:
|
||||
self.clients[sock] = client_obj
|
||||
|
||||
buffer = ""
|
||||
try:
|
||||
while self.running:
|
||||
try: data = sock.recv(2048)
|
||||
except: break
|
||||
if not data: break
|
||||
|
||||
buffer += data.decode('latin-1', errors='replace')
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
|
||||
parts = line.split('|')
|
||||
cmd = parts[0]
|
||||
|
||||
if cmd == 'LOGIN' or cmd == 'LOGINC':
|
||||
if len(parts) > 1:
|
||||
client_obj.call = parts[1].strip().upper()
|
||||
print(f"[LOGIN] {client_obj.call} (Ch 2)")
|
||||
|
||||
client_obj.send_safe(f"LOGSTAT|100|2|PySimV31|KEY|Conf|3|\r\n")
|
||||
if cmd == 'LOGIN':
|
||||
self._send_channel_init(client_obj, "2")
|
||||
|
||||
elif cmd == 'SDONE':
|
||||
self._send_channel_init(client_obj, "2")
|
||||
|
||||
elif cmd.startswith('ACHAT'):
|
||||
if len(parts) >= 2:
|
||||
new_chan = parts[1]
|
||||
if new_chan in self.channels:
|
||||
client_obj.channels.add(new_chan)
|
||||
print(f"[ACHAT] {client_obj.call} -> Ch {new_chan}")
|
||||
self._send_channel_init(client_obj, new_chan)
|
||||
|
||||
elif cmd == 'MSG':
|
||||
if len(parts) >= 4:
|
||||
cid = parts[1]
|
||||
target = parts[2]
|
||||
text = parts[3]
|
||||
if text.lower().startswith("/cq"):
|
||||
spl = text.split(' ', 2)
|
||||
if len(spl) >= 3:
|
||||
target = spl[1]; text = spl[2]
|
||||
if cid in self.channels:
|
||||
self.channels[cid].process_msg(client_obj.call, "Me", text, target)
|
||||
|
||||
elif cmd == 'CK': pass
|
||||
except Exception as e:
|
||||
print(f"[!] Err: {e}")
|
||||
finally:
|
||||
with self.lock:
|
||||
if sock in self.clients: del self.clients[sock]
|
||||
client_obj.close()
|
||||
|
||||
def _send_channel_init(self, client_obj, cid):
|
||||
if cid in self.channels:
|
||||
full_blob = self.channels[cid].get_full_init_blob()
|
||||
client_obj.send_safe(full_blob.decode('latin-1'))
|
||||
|
||||
def broadcast_to_channel(self, cid, frame):
|
||||
now = time.time()
|
||||
with self.lock:
|
||||
targets = list(self.clients.values())
|
||||
|
||||
for c in targets:
|
||||
if cid in c.channels:
|
||||
if now - c.login_time > CLIENT_WARMUP_TIME:
|
||||
c.send_safe(frame)
|
||||
|
||||
def get_random_subscriber(self, cid):
|
||||
with self.lock:
|
||||
subs = [c for c in self.clients.values() if cid in c.channels and not c.call.startswith("GUEST")]
|
||||
return random.choice(subs) if subs else None
|
||||
|
||||
def is_real_user(self, call):
|
||||
with self.lock:
|
||||
for c in self.clients.values():
|
||||
if c.call.upper() == call.upper() and not c.call.startswith("GUEST"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _sim_loop(self):
|
||||
print("[*] Sim Loop running...")
|
||||
last_ka = time.time()
|
||||
while self.running:
|
||||
now = time.time()
|
||||
time.sleep(0.02)
|
||||
|
||||
for c in self.channels.values():
|
||||
c.tick(now)
|
||||
|
||||
if now - last_ka > KEEP_ALIVE_INTERVAL:
|
||||
last_ka = now
|
||||
self.broadcast_global("CK|\r\n")
|
||||
|
||||
def broadcast_global(self, frame):
|
||||
with self.lock:
|
||||
targets = list(self.clients.values())
|
||||
for c in targets:
|
||||
c.send_safe(frame)
|
||||
|
||||
if __name__ == '__main__':
|
||||
KSTServerV31().start()
|
||||
@@ -38,24 +38,7 @@ public class ApplicationConstants {
|
||||
|
||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
||||
|
||||
/**
|
||||
* UI message retention limits.
|
||||
*
|
||||
* The global chat message list is the backing list for several FilteredLists
|
||||
* and TableViews. It must not grow without limit during long contest runs.
|
||||
*
|
||||
* The list is kept in newest-first order:
|
||||
* index 0 = newest message
|
||||
* last index = oldest message
|
||||
*/
|
||||
public static final int CHAT_MESSAGE_STORE_MAX_SIZE = 30000;
|
||||
public static final int CHAT_MESSAGE_STORE_TRIM_TO_SIZE = 25000;
|
||||
|
||||
/**
|
||||
* DXCluster table retention limits.
|
||||
*/
|
||||
public static final int CLUSTER_MESSAGE_STORE_MAX_SIZE = 10000;
|
||||
public static final int CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE = 8000;
|
||||
|
||||
/**
|
||||
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
|
||||
|
||||
+6
-9
@@ -9,8 +9,6 @@ import java.net.NoRouteToHostException;
|
||||
import java.net.SocketException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.TimerTask;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javafx.collections.ObservableList;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
@@ -19,7 +17,6 @@ import kst4contest.model.ChatMember;
|
||||
|
||||
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName());
|
||||
private ChatController client;
|
||||
|
||||
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
||||
@@ -58,7 +55,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
||||
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
||||
}
|
||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||
|
||||
@@ -108,13 +105,13 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (UnknownHostException e1) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
||||
e1.printStackTrace();
|
||||
} catch (NoRouteToHostException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
||||
e.printStackTrace();
|
||||
} catch (SocketException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
||||
e.printStackTrace();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||
|
||||
@@ -139,7 +136,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||
dsocket.send(packet);
|
||||
dsocket.close();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||
|
||||
@@ -32,8 +32,6 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
||||
@@ -812,24 +810,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/** Tracks the last time WE sent a message containing a QRG to a specific callsign (UPPERCASE).
|
||||
* Compared against knownActiveBands.timestampEpoch to decide whose QRG to use in a SKED. */
|
||||
private final Map<String, Long> lastSentQRGToCallsign =
|
||||
new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
/** Call this whenever we send a PM to {@code receiverCallsign} that contains our QRG. */
|
||||
public void recordOutboundQRG(String receiverCallsign) {
|
||||
if (receiverCallsign == null) return;
|
||||
lastSentQRGToCallsign.put(receiverCallsign.trim().toUpperCase(), System.currentTimeMillis());
|
||||
System.out.println("[ChatController] Recorded outbound QRG to: " + receiverCallsign);
|
||||
}
|
||||
|
||||
/** Returns epoch-ms of when we last sent our QRG to this callsign, or 0 if never. */
|
||||
public long getLastSentQRGTimestamp(String callsign) {
|
||||
if (callsign == null) return 0L;
|
||||
return lastSentQRGToCallsign.getOrDefault(callsign.trim().toUpperCase(), 0L);
|
||||
}
|
||||
|
||||
private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15);
|
||||
private ScheduledExecutorService scoreScheduler;
|
||||
private final StationMetricsService stationMetricsService = new StationMetricsService();
|
||||
@@ -847,7 +827,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
});
|
||||
|
||||
// Push sked to Win-Test via UDP if enabled
|
||||
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
|
||||
if (chatPreferences.isLogsynch_wintestNetworkSkedPushEnabled()
|
||||
&& chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
|
||||
pushSkedToWinTest(sked);
|
||||
}
|
||||
}
|
||||
@@ -866,69 +847,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this);
|
||||
|
||||
// Frequency resolution:
|
||||
// Compare WHO sent a QRG most recently in the PM conversation:
|
||||
// - OM sent their QRG last → use OM's Last Known QRG (ChatMember.frequency)
|
||||
// - WE sent our QRG last → use our own Win-Test QRG (MYQRG)
|
||||
// Fallback chain if no timestamps exist: OM's Last Known QRG → hardcoded default
|
||||
double freqKHz = -1.0;
|
||||
final long SKED_FREQ_MAX_AGE_MS = 60 * 60 * 1000L; // 60 minutes
|
||||
|
||||
ChatMember targetMember = resolveSkedTargetMember(sked.getTargetCallsign());
|
||||
|
||||
// Collect timestamps: when did the OM last mention their QRG? When did WE last send ours?
|
||||
long omLastQRGTimestamp = 0L;
|
||||
double omLastQRGMhz = 0.0;
|
||||
if (targetMember != null && sked.getBand() != null) {
|
||||
ChatMember.ActiveFrequencyInfo fi = targetMember.getKnownActiveBands().get(sked.getBand());
|
||||
if (fi != null && fi.frequency > 0
|
||||
&& (System.currentTimeMillis() - fi.timestampEpoch) <= SKED_FREQ_MAX_AGE_MS) {
|
||||
omLastQRGTimestamp = fi.timestampEpoch;
|
||||
omLastQRGMhz = fi.frequency;
|
||||
}
|
||||
}
|
||||
long ourLastQRGTimestamp = getLastSentQRGTimestamp(sked.getTargetCallsign());
|
||||
|
||||
// Decision: who was more recent?
|
||||
if (omLastQRGTimestamp > 0 && omLastQRGTimestamp >= ourLastQRGTimestamp) {
|
||||
// OM mentioned their QRG MORE RECENTLY (or at same time) → use their QRG
|
||||
freqKHz = omLastQRGMhz * 1000.0;
|
||||
System.out.println("[ChatController] SKED freq: OM sent last → "
|
||||
+ omLastQRGMhz + " MHz → " + freqKHz + " kHz");
|
||||
|
||||
} else if (ourLastQRGTimestamp > 0) {
|
||||
// WE sent our QRG more recently → use our Win-Test QRG
|
||||
// Get current frequency from QRG property (set by Win-Test STATUS or user)
|
||||
double freqKHz = 144300.0; // fallback default
|
||||
try {
|
||||
String qrgStr = chatPreferences.getMYQRGFirstCat().get();
|
||||
if (qrgStr != null && !qrgStr.isBlank()) {
|
||||
// QRG is in display format like "144.300.00" – strip dots → "14430000" → / 100 → 144300.0 kHz
|
||||
String cleaned = qrgStr.trim().replace(".", "");
|
||||
double parsed = Double.parseDouble(cleaned) / 100.0;
|
||||
if (parsed > 50000) {
|
||||
freqKHz = parsed;
|
||||
System.out.println("[ChatController] SKED freq: WE sent last → "
|
||||
+ freqKHz + " kHz (raw: " + qrgStr + ")");
|
||||
}
|
||||
freqKHz = Double.parseDouble(cleaned) / 100.0;
|
||||
}
|
||||
} catch (NumberFormatException ignored) { }
|
||||
}
|
||||
|
||||
// Fallback A: OM's Last Known QRG from KST field (if no PM QRG exchange found at all)
|
||||
if (freqKHz < 0 && targetMember != null) {
|
||||
try {
|
||||
String memberQrg = targetMember.getFrequency().get();
|
||||
if (memberQrg != null && !memberQrg.isBlank()) {
|
||||
double mhz = Double.parseDouble(memberQrg.trim());
|
||||
freqKHz = mhz * 1000.0;
|
||||
System.out.println("[ChatController] SKED freq: fallback Last Known QRG → "
|
||||
+ mhz + " MHz → " + freqKHz + " kHz");
|
||||
}
|
||||
} catch (NumberFormatException ignored) { }
|
||||
}
|
||||
|
||||
// Fallback B: hardcoded default
|
||||
if (freqKHz < 0) {
|
||||
freqKHz = 144300.0;
|
||||
}
|
||||
|
||||
// Build notes string with target locator/azimuth info like reference: [JO02OB - 279°]
|
||||
String targetLocator = resolveSkedTargetLocator(sked.getTargetCallsign());
|
||||
@@ -955,22 +883,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
}, "WinTestSkedPush").start();
|
||||
}
|
||||
|
||||
private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
|
||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
||||
synchronized (getLst_chatMemberList()) {
|
||||
for (ChatMember member : getLst_chatMemberList()) {
|
||||
if (member == null || member.getCallSignRaw() == null) continue;
|
||||
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
|
||||
return member;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
@@ -1129,27 +1041,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||
private ObservableList<ClusterMessage> lst_clusterMemberList = FXCollections.observableArrayList();
|
||||
|
||||
|
||||
/*
|
||||
* Message table update buffers.
|
||||
*
|
||||
* Do not write directly to lst_globalChatMessageList from worker threads.
|
||||
* Use publishChatMessage(...) instead.
|
||||
*
|
||||
* The actual ObservableList mutation is batched and executed on the JavaFX
|
||||
* application thread. The visible list order remains newest-first.
|
||||
*/
|
||||
private final Object pendingChatMessagesLock = new Object();
|
||||
private final List<ChatMessage> pendingChatMessages = new ArrayList<>();
|
||||
private boolean chatMessageFlushScheduled = false;
|
||||
|
||||
/*
|
||||
* Same idea for DXCluster messages.
|
||||
*/
|
||||
private final Object pendingClusterMessagesLock = new Object();
|
||||
private final List<ClusterMessage> pendingClusterMessages = new ArrayList<>();
|
||||
private boolean clusterMessageFlushScheduled = false;
|
||||
|
||||
private ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
|
||||
|
||||
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
|
||||
@@ -1265,145 +1156,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
return lst_globalChatMessageList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chat message to the UI message store.
|
||||
*
|
||||
* Important:
|
||||
* - This method may be called from worker threads.
|
||||
* - The ObservableList is modified only on the JavaFX application thread.
|
||||
* - The backing list remains newest-first.
|
||||
* - Old messages are trimmed to avoid unlimited memory growth.
|
||||
*/
|
||||
public void publishChatMessage(ChatMessage message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (pendingChatMessagesLock) {
|
||||
pendingChatMessages.add(message);
|
||||
|
||||
if (chatMessageFlushScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
chatMessageFlushScheduled = true;
|
||||
}
|
||||
|
||||
Platform.runLater(this::flushPendingChatMessagesToUi);
|
||||
}
|
||||
|
||||
private void flushPendingChatMessagesToUi() {
|
||||
List<ChatMessage> batch;
|
||||
|
||||
synchronized (pendingChatMessagesLock) {
|
||||
batch = new ArrayList<>(pendingChatMessages);
|
||||
pendingChatMessages.clear();
|
||||
chatMessageFlushScheduled = false;
|
||||
}
|
||||
|
||||
if (batch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* pendingChatMessages is collected in arrival order:
|
||||
* old -> new
|
||||
*
|
||||
* lst_globalChatMessageList must stay newest-first:
|
||||
* new -> old
|
||||
*/
|
||||
Collections.reverse(batch);
|
||||
|
||||
lst_globalChatMessageList.addAll(0, batch);
|
||||
|
||||
trimGlobalChatMessageListIfNeeded();
|
||||
}
|
||||
|
||||
private void trimGlobalChatMessageListIfNeeded() {
|
||||
int maxSize = ApplicationConstants.CHAT_MESSAGE_STORE_MAX_SIZE;
|
||||
int trimToSize = ApplicationConstants.CHAT_MESSAGE_STORE_TRIM_TO_SIZE;
|
||||
|
||||
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentSize = lst_globalChatMessageList.size();
|
||||
|
||||
if (currentSize <= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* List order is newest-first.
|
||||
* Therefore old messages are at the end of the list.
|
||||
*/
|
||||
lst_globalChatMessageList.remove(trimToSize, currentSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a DXCluster message to the UI cluster message store.
|
||||
*
|
||||
* Same policy as for chat messages:
|
||||
* - batched UI update
|
||||
* - JavaFX thread only for ObservableList mutation
|
||||
* - newest-first visible order
|
||||
* - bounded list size
|
||||
*/
|
||||
public void publishClusterMessage(ClusterMessage message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (pendingClusterMessagesLock) {
|
||||
pendingClusterMessages.add(message);
|
||||
|
||||
if (clusterMessageFlushScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
clusterMessageFlushScheduled = true;
|
||||
}
|
||||
|
||||
Platform.runLater(this::flushPendingClusterMessagesToUi);
|
||||
}
|
||||
|
||||
private void flushPendingClusterMessagesToUi() {
|
||||
List<ClusterMessage> batch;
|
||||
|
||||
synchronized (pendingClusterMessagesLock) {
|
||||
batch = new ArrayList<>(pendingClusterMessages);
|
||||
pendingClusterMessages.clear();
|
||||
clusterMessageFlushScheduled = false;
|
||||
}
|
||||
|
||||
if (batch.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Collections.reverse(batch);
|
||||
|
||||
lst_clusterMemberList.addAll(0, batch);
|
||||
|
||||
trimClusterMessageListIfNeeded();
|
||||
}
|
||||
|
||||
private void trimClusterMessageListIfNeeded() {
|
||||
int maxSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_MAX_SIZE;
|
||||
int trimToSize = ApplicationConstants.CLUSTER_MESSAGE_STORE_TRIM_TO_SIZE;
|
||||
|
||||
if (maxSize <= 0 || trimToSize <= 0 || trimToSize >= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
int currentSize = lst_clusterMemberList.size();
|
||||
|
||||
if (currentSize <= maxSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
lst_clusterMemberList.remove(trimToSize, currentSize);
|
||||
}
|
||||
|
||||
public void setLst_globalChatMessageList(ObservableList<ChatMessage> lst_globalChatMessageList) {
|
||||
this.lst_globalChatMessageList = lst_globalChatMessageList;
|
||||
}
|
||||
@@ -1641,7 +1393,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
}
|
||||
|
||||
|
||||
private void initLst_toMeMessageList() {
|
||||
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||
|
||||
@@ -1658,16 +1409,12 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
||||
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
||||
// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||
// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||
// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||
//
|
||||
// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||
// chatMessage.setMessageText(msgText);
|
||||
// return true;
|
||||
// }
|
||||
if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||
(!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||
|
||||
if (isSniffedMessage(chatMessage)) {
|
||||
msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||
chatMessage.setMessageText(msgText);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2552,69 +2299,4 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
||||
|
||||
return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* decides if a message in the in-queue is directed to me or if its directed to another station and sniffed
|
||||
* @param chatMessage
|
||||
* @return
|
||||
*/
|
||||
public boolean isSniffedMessage(ChatMessage chatMessage) {
|
||||
if (chatMessage == null || chatMessage.getSender() == null || chatMessage.getReceiver() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String senderCall = chatMessage.getSender().getCallSign();
|
||||
String receiverCall = chatMessage.getReceiver().getCallSign();
|
||||
|
||||
if (senderCall == null || receiverCall == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lstNotify_QSOSniffer_sniffedCallSignList == null || lstNotify_QSOSniffer_sniffedCallSignList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean observedCall =
|
||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall)
|
||||
|| lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall);
|
||||
|
||||
if (!observedCall) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String myCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSign() : null;
|
||||
String myRawCall = getChatPreferences() != null ? getChatPreferences().getStn_loginCallSignRaw() : null;
|
||||
|
||||
/*
|
||||
* Sniffed messages should appear in the private table only if they are not
|
||||
* already direct messages to my own callsign.
|
||||
*/
|
||||
return !receiverCall.equals(myCall) && !receiverCall.equals(myRawCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* changes the chatmessage if it had been a sniffed one and not directed to me. Only for marking.
|
||||
* @param chatMessage
|
||||
* @return
|
||||
*/
|
||||
public String formatChatMessageTextForDisplay(ChatMessage chatMessage) {
|
||||
if (chatMessage == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String msgText = chatMessage.getMessageText();
|
||||
|
||||
if (msgText == null) {
|
||||
msgText = "";
|
||||
}
|
||||
|
||||
if (!isSniffedMessage(chatMessage)) {
|
||||
return msgText;
|
||||
}
|
||||
|
||||
String senderCall = chatMessage.getSender() != null ? chatMessage.getSender().getCallSign() : "";
|
||||
String receiverCall = chatMessage.getReceiver() != null ? chatMessage.getReceiver().getCallSign() : "";
|
||||
|
||||
return "Sniffed: (" + senderCall + " > " + receiverCall + ") " + msgText;
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,9 @@ import java.net.Socket;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
public class DXClusterThreadPooledServer implements Runnable{
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterThreadPooledServer.class.getName());
|
||||
private List<Socket> clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients
|
||||
|
||||
private ThreadStatusCallback callBackToController;
|
||||
@@ -114,7 +111,8 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue());
|
||||
System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ");
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e);
|
||||
System.out.println("DXCThPooledServer: Error accessing value in chatmember object: " + e.getMessage());
|
||||
// e.printStackTrace();
|
||||
}
|
||||
|
||||
for (Socket socket : clientSockets) {
|
||||
@@ -148,7 +146,8 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e);
|
||||
e.printStackTrace();
|
||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -160,7 +159,6 @@ public class DXClusterThreadPooledServer implements Runnable{
|
||||
|
||||
class DXClusterServerWorkerRunnable implements Runnable{
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
|
||||
protected Socket clientSocket = null;
|
||||
protected String serverText = null;
|
||||
private ChatController client = null;
|
||||
@@ -199,13 +197,14 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
||||
output.write(("\r\n").getBytes());
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e);
|
||||
e.printStackTrace();
|
||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||
dXCkeepAliveTimer.purge();
|
||||
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex);
|
||||
ex.printStackTrace();
|
||||
}
|
||||
finally {
|
||||
this.cancel();
|
||||
@@ -225,7 +224,7 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
||||
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e);
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
synchronized(dxClusterClientSocketsConnectedList) {
|
||||
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
||||
|
||||
@@ -2,8 +2,6 @@ package kst4contest.controller;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import kst4contest.model.ChatMessage;
|
||||
|
||||
@@ -15,7 +13,6 @@ import kst4contest.model.ChatMessage;
|
||||
* No need for it as it´s not longer a console application
|
||||
*/
|
||||
public class InputReaderThread extends Thread {
|
||||
private static final Logger LOGGER = Logger.getLogger(InputReaderThread.class.getName());
|
||||
private PrintWriter writer;
|
||||
private Socket socket;
|
||||
private ChatController client;
|
||||
@@ -42,7 +39,8 @@ public class InputReaderThread extends Thread {
|
||||
try {
|
||||
sendThisMessage23001 = reader.readLine();
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
||||
@@ -55,8 +53,8 @@ public class InputReaderThread extends Thread {
|
||||
try {
|
||||
this.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
|
||||
Thread.currentThread().interrupt();
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -772,9 +772,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dummy.setCallSign("ALL");
|
||||
newMessageArrived.setReceiver(dummy);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
|
||||
} else {
|
||||
//message is directed to another chatmember, process as such!
|
||||
@@ -819,9 +817,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
if (newMessageArrived.getReceiver().getCallSign()
|
||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
|
||||
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
||||
@@ -964,9 +960,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
String originalMessage = newMessageArrived.getMessageText();
|
||||
newMessageArrived
|
||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
||||
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
|
||||
this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
|
||||
// if you sent the message to another station, it will be sorted in to
|
||||
// the "to me message list" with modified messagetext, added rxers callsign
|
||||
@@ -1030,8 +1024,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||
}
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||
}
|
||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||
@@ -1134,8 +1127,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
||||
dxcMsg.setQrgSpotted(splittedMessageLine[5]);
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
||||
this.client.publishClusterMessage(dxcMsg);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
||||
|
||||
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
|
||||
|
||||
@@ -1174,8 +1166,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
||||
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||
this.client.publishClusterMessage(dxcMsg2);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||
|
||||
} else
|
||||
|
||||
@@ -1205,8 +1196,8 @@ public class MessageBusManagementThread extends Thread {
|
||||
dxcMsg3.setMessageInhibited("");
|
||||
dxcMsg3.setQrgSpotted("");
|
||||
|
||||
// this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||
this.client.publishClusterMessage(dxcMsg3);
|
||||
this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||
|
||||
} else
|
||||
|
||||
/**
|
||||
@@ -1373,8 +1364,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
dummy.setCallSign("ALL");
|
||||
newMessageArrived.setReceiver(dummy);
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||
|
||||
} else {
|
||||
//message is directed to another chatmember, process as such!
|
||||
@@ -1418,8 +1408,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
if (newMessageArrived.getReceiver().getCallSign()
|
||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
|
||||
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
|
||||
|
||||
@@ -1432,9 +1421,8 @@ public class MessageBusManagementThread extends Thread {
|
||||
String originalMessage = newMessageArrived.getMessageText();
|
||||
newMessageArrived
|
||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
||||
// this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
this.client.getLst_globalChatMessageList().add(0,newMessageArrived);
|
||||
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
// if you sent the message to another station, it will be sorted in to
|
||||
// the "to me message list" with modified messagetext, added rxers callsign
|
||||
|
||||
@@ -1453,8 +1441,7 @@ public class MessageBusManagementThread extends Thread {
|
||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||
}
|
||||
|
||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived);
|
||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||
}
|
||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||
|
||||
@@ -3,8 +3,6 @@ package kst4contest.controller;
|
||||
import java.io.*;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import kst4contest.model.ChatMessage;
|
||||
|
||||
@@ -16,7 +14,6 @@ import kst4contest.model.ChatMessage;
|
||||
* @author www.codejava.net
|
||||
*/
|
||||
public class ReadThread extends Thread {
|
||||
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
|
||||
private BufferedReader reader;
|
||||
private Socket socket;
|
||||
private ChatController client;
|
||||
@@ -46,7 +43,8 @@ public class ReadThread extends Thread {
|
||||
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
||||
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
|
||||
System.out.println("Error getting input stream: " + ex.getMessage());
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +82,15 @@ public class ReadThread extends Thread {
|
||||
|
||||
}
|
||||
catch (Exception sexc) {
|
||||
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
|
||||
System.out.println("[ReadThread, CRITICAL: ] Socket geschlossen: " + sexc.getMessage());
|
||||
try {
|
||||
this.client.getSocket().close();
|
||||
this.interrupt();
|
||||
break;
|
||||
|
||||
} catch (IOException e) {
|
||||
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
|
||||
// TODO Auto-generated catch block
|
||||
e.printStackTrace();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ThreadStateMessage;
|
||||
@@ -76,10 +75,9 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
socket = new DatagramSocket(null); //first init with null, then make ready for reuse
|
||||
socket.setReuseAddress(true);
|
||||
// socket = new DatagramSocket(PORT);
|
||||
int boundPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
|
||||
socket.bind(new InetSocketAddress(boundPort));
|
||||
socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_wintestNetworkPort()));
|
||||
socket.setSoTimeout(3000);
|
||||
System.out.println("[WinTest UDP listener] started at port: " + boundPort);
|
||||
System.out.println("[WinTest UDP listener] started at port: " + PORT);
|
||||
} catch (SocketException e) {
|
||||
e.printStackTrace();
|
||||
return;
|
||||
@@ -226,43 +224,9 @@ public class ReadUDPByWintestThread extends Thread {
|
||||
} else {
|
||||
formattedQRG = String.format(Locale.US, "%.1f", freqFloat); // fallback
|
||||
}
|
||||
// Parse pass frequency from parts[11] if available (WT STATUS format)
|
||||
String formattedPassQRG = null;
|
||||
if (parts.size() > 11) {
|
||||
try {
|
||||
String passFreqRaw = parts.get(11);
|
||||
double passFreqFloat = Integer.parseInt(passFreqRaw) / 10.0;
|
||||
if (passFreqFloat > 100) { // Must be a valid radio frequency (> 100 kHz), protects against parsing boolean flag tokens
|
||||
long passFreqHzTimes100 = Math.round(passFreqFloat * 100.0);
|
||||
String passHzStr = String.valueOf(passFreqHzTimes100);
|
||||
if (passHzStr.length() == 8) {
|
||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 3), passHzStr.substring(3, 6), passHzStr.substring(6, 8));
|
||||
} else if (passHzStr.length() == 9) {
|
||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 4), passHzStr.substring(4, 7), passHzStr.substring(7, 9));
|
||||
} else if (passHzStr.length() == 7) {
|
||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 2), passHzStr.substring(2, 5), passHzStr.substring(5, 7));
|
||||
} else if (passHzStr.length() == 6) {
|
||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 1), passHzStr.substring(1, 4), passHzStr.substring(4, 6));
|
||||
} else {
|
||||
formattedPassQRG = String.format(Locale.US, "%.1f", passFreqFloat);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// parts[11] not a valid frequency, leave formattedPassQRG as null
|
||||
}
|
||||
}
|
||||
this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
|
||||
|
||||
if (this.client.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) {
|
||||
final String qrgToSet = (this.client.getChatPreferences().isLogsynch_wintestUsePassQrg() && formattedPassQRG != null)
|
||||
? formattedPassQRG
|
||||
: formattedQRG;
|
||||
// JavaFX StringProperty must be updated on the FX Application Thread
|
||||
Platform.runLater(() -> this.client.getChatPreferences().getMYQRGFirstCat().set(qrgToSet));
|
||||
}
|
||||
|
||||
System.out.println("[WinTest STATUS] stn=" + stn + ", mode=" + mode + ", qrg=" + formattedQRG
|
||||
+ (formattedPassQRG != null ? ", passQrg=" + formattedPassQRG : "")
|
||||
+ ", syncActive=" + this.client.getChatPreferences().isLogsynch_wintestQrgSyncEnabled());
|
||||
System.out.println("[WinTest STATUS] stn=" + stn + ", mode=" + mode + ", qrg=" + formattedQRG);
|
||||
} catch (Exception e) {
|
||||
System.out.println("[WinTest] STATUS parsing error: " + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -173,23 +173,6 @@ public class ChatPreferences {
|
||||
double stn_maxQRBDefault = 900;
|
||||
double stn_qtfDefault = 135;
|
||||
|
||||
double stn_pathAnalysisOwnAntennaHeightMeters = 10.0;
|
||||
double stn_pathAnalysisDefaultTargetAntennaHeightMeters = 10.0;
|
||||
String stn_pathAnalysisDemRootDirectory = "";
|
||||
String stn_pathAnalysisDemDatasetId = "copernicus_glo_30";
|
||||
double stn_pathAnalysisOwnTxPowerWatts = 750.0;
|
||||
double stn_pathAnalysisOwnAntennaGainDbi = 8.0;
|
||||
double stn_pathAnalysisDefaultTargetTxPowerWatts = 100.0;
|
||||
double stn_pathAnalysisDefaultTargetAntennaGainDbi = 8.0;
|
||||
|
||||
double stn_pathAnalysisVhfFeederLossPerStationDb = 2.0;
|
||||
double stn_pathAnalysisFeederLossIncreaseDbPer200MHz = 2.0;
|
||||
double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = 20.0;
|
||||
|
||||
double stn_pathAnalysisRequiredSsbSignalDbm = -126.0;
|
||||
double stn_pathAnalysisRequiredCwSignalDbm = -132.0;
|
||||
double stn_pathAnalysisContestMarginDb = 6.0;
|
||||
|
||||
ChatCategory loginChatCategoryMain = new ChatCategory(2);
|
||||
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
||||
boolean loginToSecondChatEnabled;
|
||||
@@ -221,8 +204,6 @@ public class ChatPreferences {
|
||||
String logsynch_wintestNetworkBroadcastAddress = "255.255.255.255"; // UDP broadcast address for sending to Win-Test
|
||||
boolean logsynch_wintestNetworkSkedPushEnabled = false; // push SKEDs to Win-Test via UDP
|
||||
String logsynch_wintestSkedMode = "SSB"; // CW, SSB or AUTO
|
||||
boolean logsynch_wintestQrgSyncEnabled = true; // sync QRG from Win-Test STATUS packet
|
||||
boolean logsynch_wintestUsePassQrg = false; // use pass frequency instead of main QRG from STATUS packet
|
||||
|
||||
|
||||
|
||||
@@ -326,9 +307,6 @@ public class ChatPreferences {
|
||||
boolean guiOptions_defaultFilterPmToOther;
|
||||
boolean guiOptions_defaultFilterPublicMsgs;
|
||||
|
||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||
|
||||
|
||||
/*********************************************************************************
|
||||
*
|
||||
@@ -391,42 +369,6 @@ public class ChatPreferences {
|
||||
this.MYQRGFirstCat.set(MYQRGFirstCat);
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnAntennaHeightMeters() {
|
||||
return stn_pathAnalysisOwnAntennaHeightMeters;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnAntennaHeightMeters(double stn_pathAnalysisOwnAntennaHeightMeters) {
|
||||
this.stn_pathAnalysisOwnAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisOwnAntennaHeightMeters);
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetAntennaHeightMeters() {
|
||||
return stn_pathAnalysisDefaultTargetAntennaHeightMeters;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetAntennaHeightMeters(double stn_pathAnalysisDefaultTargetAntennaHeightMeters) {
|
||||
this.stn_pathAnalysisDefaultTargetAntennaHeightMeters = Math.max(0.0, stn_pathAnalysisDefaultTargetAntennaHeightMeters);
|
||||
}
|
||||
|
||||
public String getStn_pathAnalysisDemRootDirectory() {
|
||||
return stn_pathAnalysisDemRootDirectory;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDemRootDirectory(String stn_pathAnalysisDemRootDirectory) {
|
||||
this.stn_pathAnalysisDemRootDirectory = stn_pathAnalysisDemRootDirectory == null
|
||||
? ""
|
||||
: stn_pathAnalysisDemRootDirectory.trim();
|
||||
}
|
||||
|
||||
public String getStn_pathAnalysisDemDatasetId() {
|
||||
return stn_pathAnalysisDemDatasetId;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDemDatasetId(String stn_pathAnalysisDemDatasetId) {
|
||||
this.stn_pathAnalysisDemDatasetId = (stn_pathAnalysisDemDatasetId == null || stn_pathAnalysisDemDatasetId.isBlank())
|
||||
? "copernicus_glo_30"
|
||||
: stn_pathAnalysisDemDatasetId.trim().toLowerCase();
|
||||
}
|
||||
|
||||
public String getStn_loginNameSecondCat() {
|
||||
return stn_loginNameSecondCat;
|
||||
}
|
||||
@@ -539,22 +481,6 @@ public class ChatPreferences {
|
||||
this.logsynch_wintestSkedMode = logsynch_wintestSkedMode;
|
||||
}
|
||||
|
||||
public boolean isLogsynch_wintestQrgSyncEnabled() {
|
||||
return logsynch_wintestQrgSyncEnabled;
|
||||
}
|
||||
|
||||
public void setLogsynch_wintestQrgSyncEnabled(boolean logsynch_wintestQrgSyncEnabled) {
|
||||
this.logsynch_wintestQrgSyncEnabled = logsynch_wintestQrgSyncEnabled;
|
||||
}
|
||||
|
||||
public boolean isLogsynch_wintestUsePassQrg() {
|
||||
return logsynch_wintestUsePassQrg;
|
||||
}
|
||||
|
||||
public void setLogsynch_wintestUsePassQrg(boolean logsynch_wintestUsePassQrg) {
|
||||
this.logsynch_wintestUsePassQrg = logsynch_wintestUsePassQrg;
|
||||
}
|
||||
|
||||
public String getStn_loginLocatorSecondCat() {
|
||||
return stn_loginLocatorSecondCat;
|
||||
}
|
||||
@@ -603,22 +529,6 @@ public class ChatPreferences {
|
||||
this.loginToSecondChatEnabled = loginToSecondChatEnabled;
|
||||
}
|
||||
|
||||
public double[] getGUIstationMapStageSceneSizeHW() {
|
||||
return GUIstationMapStageSceneSizeHW;
|
||||
}
|
||||
|
||||
public void setGUIstationMapStageSceneSizeHW(double[] GUIstationMapStageSceneSizeHW) {
|
||||
this.GUIstationMapStageSceneSizeHW = GUIstationMapStageSceneSizeHW;
|
||||
}
|
||||
|
||||
public double[] getGUIstationMapStagePositionXY() {
|
||||
return GUIstationMapStagePositionXY;
|
||||
}
|
||||
|
||||
public void setGUIstationMapStagePositionXY(double[] GUIstationMapStagePositionXY) {
|
||||
this.GUIstationMapStagePositionXY = GUIstationMapStagePositionXY;
|
||||
}
|
||||
|
||||
public boolean isGuiOptions_defaultFilterNothing() {
|
||||
return guiOptions_defaultFilterNothing;
|
||||
}
|
||||
@@ -1317,63 +1227,6 @@ public class ChatPreferences {
|
||||
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
||||
station.appendChild(stn_qtfDefault);
|
||||
|
||||
Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters");
|
||||
stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + "");
|
||||
station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetAntennaHeightMeters = doc.createElement("stn_pathAnalysisDefaultTargetAntennaHeightMeters");
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaHeightMeters + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetAntennaHeightMeters);
|
||||
|
||||
Element stn_pathAnalysisDemRootDirectory = doc.createElement("stn_pathAnalysisDemRootDirectory");
|
||||
stn_pathAnalysisDemRootDirectory.setTextContent(this.stn_pathAnalysisDemRootDirectory);
|
||||
station.appendChild(stn_pathAnalysisDemRootDirectory);
|
||||
|
||||
Element stn_pathAnalysisDemDatasetId = doc.createElement("stn_pathAnalysisDemDatasetId");
|
||||
stn_pathAnalysisDemDatasetId.setTextContent(this.stn_pathAnalysisDemDatasetId);
|
||||
station.appendChild(stn_pathAnalysisDemDatasetId);
|
||||
Element stn_pathAnalysisOwnTxPowerWatts = doc.createElement("stn_pathAnalysisOwnTxPowerWatts");
|
||||
stn_pathAnalysisOwnTxPowerWatts.setTextContent(this.stn_pathAnalysisOwnTxPowerWatts + "");
|
||||
station.appendChild(stn_pathAnalysisOwnTxPowerWatts);
|
||||
|
||||
Element stn_pathAnalysisOwnAntennaGainDbi = doc.createElement("stn_pathAnalysisOwnAntennaGainDbi");
|
||||
stn_pathAnalysisOwnAntennaGainDbi.setTextContent(this.stn_pathAnalysisOwnAntennaGainDbi + "");
|
||||
station.appendChild(stn_pathAnalysisOwnAntennaGainDbi);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetTxPowerWatts = doc.createElement("stn_pathAnalysisDefaultTargetTxPowerWatts");
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts.setTextContent(this.stn_pathAnalysisDefaultTargetTxPowerWatts + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetTxPowerWatts);
|
||||
|
||||
Element stn_pathAnalysisDefaultTargetAntennaGainDbi = doc.createElement("stn_pathAnalysisDefaultTargetAntennaGainDbi");
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi.setTextContent(this.stn_pathAnalysisDefaultTargetAntennaGainDbi + "");
|
||||
station.appendChild(stn_pathAnalysisDefaultTargetAntennaGainDbi);
|
||||
|
||||
Element stn_pathAnalysisVhfFeederLossPerStationDb = doc.createElement("stn_pathAnalysisVhfFeederLossPerStationDb");
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisVhfFeederLossPerStationDb + "");
|
||||
station.appendChild(stn_pathAnalysisVhfFeederLossPerStationDb);
|
||||
|
||||
Element stn_pathAnalysisFeederLossIncreaseDbPer200MHz = doc.createElement("stn_pathAnalysisFeederLossIncreaseDbPer200MHz");
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz.setTextContent(this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz + "");
|
||||
station.appendChild(stn_pathAnalysisFeederLossIncreaseDbPer200MHz);
|
||||
|
||||
Element stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = doc.createElement("stn_pathAnalysisMaxEstimatedFeederLossPerStationDb");
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb.setTextContent(this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb + "");
|
||||
station.appendChild(stn_pathAnalysisMaxEstimatedFeederLossPerStationDb);
|
||||
|
||||
Element stn_pathAnalysisRequiredSsbSignalDbm = doc.createElement("stn_pathAnalysisRequiredSsbSignalDbm");
|
||||
stn_pathAnalysisRequiredSsbSignalDbm.setTextContent(this.stn_pathAnalysisRequiredSsbSignalDbm + "");
|
||||
station.appendChild(stn_pathAnalysisRequiredSsbSignalDbm);
|
||||
|
||||
Element stn_pathAnalysisRequiredCwSignalDbm = doc.createElement("stn_pathAnalysisRequiredCwSignalDbm");
|
||||
stn_pathAnalysisRequiredCwSignalDbm.setTextContent(this.stn_pathAnalysisRequiredCwSignalDbm + "");
|
||||
station.appendChild(stn_pathAnalysisRequiredCwSignalDbm);
|
||||
|
||||
Element stn_pathAnalysisContestMarginDb = doc.createElement("stn_pathAnalysisContestMarginDb");
|
||||
stn_pathAnalysisContestMarginDb.setTextContent(this.stn_pathAnalysisContestMarginDb + "");
|
||||
station.appendChild(stn_pathAnalysisContestMarginDb);
|
||||
|
||||
|
||||
|
||||
Element stn_bandActive144 = doc.createElement("stn_bandActive144");
|
||||
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
||||
station.appendChild(stn_bandActive144);
|
||||
@@ -1485,14 +1338,6 @@ public class ChatPreferences {
|
||||
logsynch_wintestSkedMode.setTextContent(this.logsynch_wintestSkedMode);
|
||||
logsynch.appendChild(logsynch_wintestSkedMode);
|
||||
|
||||
Element logsynch_wintestQrgSyncEnabled = doc.createElement("logsynch_wintestQrgSyncEnabled");
|
||||
logsynch_wintestQrgSyncEnabled.setTextContent(this.logsynch_wintestQrgSyncEnabled + "");
|
||||
logsynch.appendChild(logsynch_wintestQrgSyncEnabled);
|
||||
|
||||
Element logsynch_wintestUsePassQrg = doc.createElement("logsynch_wintestUsePassQrg");
|
||||
logsynch_wintestUsePassQrg.setTextContent(this.logsynch_wintestUsePassQrg + "");
|
||||
logsynch.appendChild(logsynch_wintestUsePassQrg);
|
||||
|
||||
|
||||
/**
|
||||
* trxSynchUCX
|
||||
@@ -1869,18 +1714,6 @@ public class ChatPreferences {
|
||||
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
||||
guiOptions.appendChild(GUIpnl_directedMSGWin_dividerpositionDefault);
|
||||
|
||||
Element GUIstationMapStageSceneSizeHW = doc.createElement("GUIstationMapStageSceneSizeHW");
|
||||
GUIstationMapStageSceneSizeHW.setTextContent(
|
||||
this.getGUIstationMapStageSceneSizeHW()[0] + ";" + this.getGUIstationMapStageSceneSizeHW()[1]
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapStageSceneSizeHW);
|
||||
|
||||
Element GUIstationMapStagePositionXY = doc.createElement("GUIstationMapStagePositionXY");
|
||||
GUIstationMapStagePositionXY.setTextContent(
|
||||
this.getGUIstationMapStagePositionXY()[0] + ";" + this.getGUIstationMapStagePositionXY()[1]
|
||||
);
|
||||
guiOptions.appendChild(GUIstationMapStagePositionXY);
|
||||
|
||||
/****************************************************************************************
|
||||
****************************** now write this XML! *************************************
|
||||
****************************************************************************************/
|
||||
@@ -1997,90 +1830,6 @@ public class ChatPreferences {
|
||||
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
||||
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault");
|
||||
|
||||
stn_pathAnalysisOwnAntennaHeightMeters = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnAntennaHeightMeters,
|
||||
"stn_pathAnalysisOwnAntennaHeightMeters"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetAntennaHeightMeters,
|
||||
"stn_pathAnalysisDefaultTargetAntennaHeightMeters"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDemRootDirectory = getText(
|
||||
stationEl,
|
||||
stn_pathAnalysisDemRootDirectory,
|
||||
"stn_pathAnalysisDemRootDirectory"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDemDatasetId = getText(
|
||||
stationEl,
|
||||
stn_pathAnalysisDemDatasetId,
|
||||
"stn_pathAnalysisDemDatasetId"
|
||||
);
|
||||
|
||||
stn_pathAnalysisOwnTxPowerWatts = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnTxPowerWatts,
|
||||
"stn_pathAnalysisOwnTxPowerWatts"
|
||||
);
|
||||
|
||||
stn_pathAnalysisOwnAntennaGainDbi = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisOwnAntennaGainDbi,
|
||||
"stn_pathAnalysisOwnAntennaGainDbi"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts,
|
||||
"stn_pathAnalysisDefaultTargetTxPowerWatts"
|
||||
);
|
||||
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi,
|
||||
"stn_pathAnalysisDefaultTargetAntennaGainDbi"
|
||||
);
|
||||
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb,
|
||||
"stn_pathAnalysisVhfFeederLossPerStationDb"
|
||||
);
|
||||
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz,
|
||||
"stn_pathAnalysisFeederLossIncreaseDbPer200MHz"
|
||||
);
|
||||
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb,
|
||||
"stn_pathAnalysisMaxEstimatedFeederLossPerStationDb"
|
||||
);
|
||||
|
||||
stn_pathAnalysisRequiredSsbSignalDbm = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisRequiredSsbSignalDbm,
|
||||
"stn_pathAnalysisRequiredSsbSignalDbm"
|
||||
);
|
||||
|
||||
stn_pathAnalysisRequiredCwSignalDbm = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisRequiredCwSignalDbm,
|
||||
"stn_pathAnalysisRequiredCwSignalDbm"
|
||||
);
|
||||
|
||||
stn_pathAnalysisContestMarginDb = getDouble(
|
||||
stationEl,
|
||||
stn_pathAnalysisContestMarginDb,
|
||||
"stn_pathAnalysisContestMarginDb"
|
||||
);
|
||||
|
||||
// Band activity flags (introduced later; if missing -> keep defaults)
|
||||
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
||||
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
||||
@@ -2163,16 +1912,6 @@ public class ChatPreferences {
|
||||
logsynch_wintestSkedMode,
|
||||
"logsynch_wintestSkedMode");
|
||||
|
||||
logsynch_wintestQrgSyncEnabled = getBoolean(
|
||||
logsynchEl,
|
||||
logsynch_wintestQrgSyncEnabled,
|
||||
"logsynch_wintestQrgSyncEnabled");
|
||||
|
||||
logsynch_wintestUsePassQrg = getBoolean(
|
||||
logsynchEl,
|
||||
logsynch_wintestUsePassQrg,
|
||||
"logsynch_wintestUsePassQrg");
|
||||
|
||||
System.out.println(
|
||||
"[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled);
|
||||
System.out.println(
|
||||
@@ -2489,16 +2228,6 @@ public class ChatPreferences {
|
||||
parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
||||
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
||||
|
||||
parseSemicolonDoublesInto(
|
||||
getText(element, null, "GUIstationMapStageSceneSizeHW"),
|
||||
this.getGUIstationMapStageSceneSizeHW()
|
||||
);
|
||||
|
||||
parseSemicolonDoublesInto(
|
||||
getText(element, null, "GUIstationMapStagePositionXY"),
|
||||
this.getGUIstationMapStagePositionXY()
|
||||
);
|
||||
|
||||
// Splitpane divider positions
|
||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||
if (s1 != null) {
|
||||
@@ -2895,100 +2624,6 @@ public class ChatPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnTxPowerWatts() {
|
||||
return stn_pathAnalysisOwnTxPowerWatts;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnTxPowerWatts(double stn_pathAnalysisOwnTxPowerWatts) {
|
||||
this.stn_pathAnalysisOwnTxPowerWatts = stn_pathAnalysisOwnTxPowerWatts;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisOwnAntennaGainDbi() {
|
||||
return stn_pathAnalysisOwnAntennaGainDbi;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisOwnAntennaGainDbi(double stn_pathAnalysisOwnAntennaGainDbi) {
|
||||
this.stn_pathAnalysisOwnAntennaGainDbi = stn_pathAnalysisOwnAntennaGainDbi;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetTxPowerWatts() {
|
||||
return stn_pathAnalysisDefaultTargetTxPowerWatts;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetTxPowerWatts(double stn_pathAnalysisDefaultTargetTxPowerWatts) {
|
||||
this.stn_pathAnalysisDefaultTargetTxPowerWatts = stn_pathAnalysisDefaultTargetTxPowerWatts;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisDefaultTargetAntennaGainDbi() {
|
||||
return stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisDefaultTargetAntennaGainDbi(double stn_pathAnalysisDefaultTargetAntennaGainDbi) {
|
||||
this.stn_pathAnalysisDefaultTargetAntennaGainDbi = stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisVhfFeederLossPerStationDb() {
|
||||
return stn_pathAnalysisVhfFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisVhfFeederLossPerStationDb(double stn_pathAnalysisVhfFeederLossPerStationDb) {
|
||||
this.stn_pathAnalysisVhfFeederLossPerStationDb = stn_pathAnalysisVhfFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisFeederLossIncreaseDbPer200MHz() {
|
||||
return stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisFeederLossIncreaseDbPer200MHz(double stn_pathAnalysisFeederLossIncreaseDbPer200MHz) {
|
||||
this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz = stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisMaxEstimatedFeederLossPerStationDb() {
|
||||
return stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisMaxEstimatedFeederLossPerStationDb(double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb) {
|
||||
this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisRequiredSsbSignalDbm() {
|
||||
return stn_pathAnalysisRequiredSsbSignalDbm;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisRequiredSsbSignalDbm(double stn_pathAnalysisRequiredSsbSignalDbm) {
|
||||
this.stn_pathAnalysisRequiredSsbSignalDbm = stn_pathAnalysisRequiredSsbSignalDbm;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisRequiredCwSignalDbm() {
|
||||
return stn_pathAnalysisRequiredCwSignalDbm;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisRequiredCwSignalDbm(double stn_pathAnalysisRequiredCwSignalDbm) {
|
||||
this.stn_pathAnalysisRequiredCwSignalDbm = stn_pathAnalysisRequiredCwSignalDbm;
|
||||
}
|
||||
|
||||
public double getStn_pathAnalysisContestMarginDb() {
|
||||
return stn_pathAnalysisContestMarginDb;
|
||||
}
|
||||
|
||||
public void setStn_pathAnalysisContestMarginDb(double stn_pathAnalysisContestMarginDb) {
|
||||
this.stn_pathAnalysisContestMarginDb = stn_pathAnalysisContestMarginDb;
|
||||
}
|
||||
|
||||
public kst4contest.view.map.PathLinkBudgetSettings buildPathLinkBudgetSettings() {
|
||||
return new kst4contest.view.map.PathLinkBudgetSettings(
|
||||
stn_pathAnalysisOwnTxPowerWatts,
|
||||
stn_pathAnalysisOwnAntennaGainDbi,
|
||||
stn_pathAnalysisDefaultTargetTxPowerWatts,
|
||||
stn_pathAnalysisDefaultTargetAntennaGainDbi,
|
||||
stn_pathAnalysisVhfFeederLossPerStationDb,
|
||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz,
|
||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb,
|
||||
stn_pathAnalysisRequiredSsbSignalDbm,
|
||||
stn_pathAnalysisRequiredCwSignalDbm,
|
||||
stn_pathAnalysisContestMarginDb
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package kst4contest.service.path;
|
||||
|
||||
/**
|
||||
* Utility methods for Fresnel zone calculations.
|
||||
*
|
||||
* <p>This helper intentionally contains only pure mathematical functions.
|
||||
* It has no dependency on UI code or terrain providers.</p>
|
||||
*/
|
||||
public final class FresnelMathUtils {
|
||||
|
||||
/**
|
||||
* Speed of light in vacuum in meters per second.
|
||||
*/
|
||||
public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
||||
|
||||
private FresnelMathUtils() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the wavelength in meters for the given frequency.
|
||||
*
|
||||
* @param frequencyHz signal frequency in Hz
|
||||
* @return wavelength in meters, or 0 if the input is invalid
|
||||
*/
|
||||
public static double computeWavelengthMeters(final double frequencyHz) {
|
||||
if (frequencyHz <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the radius of the first Fresnel zone at a specific point
|
||||
* along the path.
|
||||
*
|
||||
* <p>Formula:
|
||||
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
|
||||
*
|
||||
* @param frequencyHz signal frequency in Hz
|
||||
* @param distanceFromTxMeters distance from TX to the current point in meters
|
||||
* @param totalPathDistanceMeters full TX-to-RX path length in meters
|
||||
* @return Fresnel radius in meters, or 0 at invalid inputs / path ends
|
||||
*/
|
||||
public static double computeFirstFresnelRadiusMeters(
|
||||
final double frequencyHz,
|
||||
final double distanceFromTxMeters,
|
||||
final double totalPathDistanceMeters) {
|
||||
|
||||
if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final double clampedDistanceFromTxMeters = Math.max(
|
||||
0.0,
|
||||
Math.min(distanceFromTxMeters, totalPathDistanceMeters)
|
||||
);
|
||||
|
||||
final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters;
|
||||
|
||||
if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
final double wavelengthMeters = computeWavelengthMeters(frequencyHz);
|
||||
|
||||
return Math.sqrt(
|
||||
wavelengthMeters
|
||||
* clampedDistanceFromTxMeters
|
||||
* distanceFromPointToRxMeters
|
||||
/ totalPathDistanceMeters
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,12 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* This class has utility methods to handle application files inside the home directory.
|
||||
*/
|
||||
public class ApplicationFileUtils {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(ApplicationFileUtils.class.getName());
|
||||
|
||||
/**
|
||||
* Gets the path of a file inside the home directory of the user.
|
||||
* @param applicationName Name off the application which is used for the hidden directory
|
||||
@@ -65,7 +61,8 @@ public class ApplicationFileUtils {
|
||||
|
||||
resourceStream.transferTo(fileOutputStream);
|
||||
} catch (IOException ex) {
|
||||
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
|
||||
System.err.println("Exception when copying Application file: " + ex.getMessage());
|
||||
ex.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
package kst4contest.utils;
|
||||
|
||||
import javafx.collections.ObservableListBase;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A bounded ObservableList backed by a circular buffer (ring buffer).
|
||||
* <p>
|
||||
* Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1)
|
||||
* random access via {@link #get}. When the list reaches {@code maxCapacity},
|
||||
* adding a new element at the front automatically evicts the oldest element
|
||||
* at the back — and vice versa.
|
||||
* <p>
|
||||
* This is a drop-in replacement for {@code FXCollections.observableArrayList()}
|
||||
* wherever elements are prepended frequently, e.g. chat message lists.
|
||||
*/
|
||||
public class BoundedDequeObservableList<E> extends ObservableListBase<E> {
|
||||
|
||||
private final int maxCapacity;
|
||||
private final Object[] elements;
|
||||
private int head = 0;
|
||||
private int size = 0;
|
||||
|
||||
public BoundedDequeObservableList(int maxCapacity) {
|
||||
if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0");
|
||||
this.maxCapacity = maxCapacity;
|
||||
this.elements = new Object[maxCapacity];
|
||||
}
|
||||
|
||||
// ── read access ──────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public int size() {
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public E get(int index) {
|
||||
checkIndex(index);
|
||||
return (E) elements[physicalIndex(index)];
|
||||
}
|
||||
|
||||
// ── O(1) deque operations ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inserts {@code element} at index 0 (newest-first order).
|
||||
* If the list is already at capacity the oldest element (last index) is
|
||||
* removed first — both changes are reported as a single compound change.
|
||||
*/
|
||||
public void addFirst(E element) {
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
// evict last element
|
||||
int lastPhysical = physicalIndex(size - 1);
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[lastPhysical];
|
||||
elements[lastPhysical] = null;
|
||||
size--;
|
||||
nextRemove(size, evicted); // index after decrement == old last index
|
||||
}
|
||||
head = (head - 1 + maxCapacity) % maxCapacity;
|
||||
elements[head] = element;
|
||||
size++;
|
||||
nextAdd(0, 1);
|
||||
endChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends {@code element} at the last index (oldest-first order).
|
||||
* If the list is already at capacity the newest element (index 0) is
|
||||
* removed first.
|
||||
*/
|
||||
public void addLast(E element) {
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
// evict first element
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[head];
|
||||
elements[head] = null;
|
||||
head = (head + 1) % maxCapacity;
|
||||
size--;
|
||||
nextRemove(0, evicted);
|
||||
}
|
||||
elements[physicalIndex(size)] = element;
|
||||
size++;
|
||||
nextAdd(size - 1, size);
|
||||
endChange();
|
||||
}
|
||||
|
||||
// ── standard List mutation (O(n) — use addFirst/addLast for hot path) ─────
|
||||
|
||||
@Override
|
||||
public void add(int index, E element) {
|
||||
if (index == 0) {
|
||||
addFirst(element);
|
||||
return;
|
||||
}
|
||||
if (index == size) {
|
||||
addLast(element);
|
||||
return;
|
||||
}
|
||||
checkIndexForAdd(index);
|
||||
beginChange();
|
||||
if (size == maxCapacity) {
|
||||
int lastPhysical = physicalIndex(size - 1);
|
||||
@SuppressWarnings("unchecked")
|
||||
E evicted = (E) elements[lastPhysical];
|
||||
elements[lastPhysical] = null;
|
||||
size--;
|
||||
nextRemove(size, evicted);
|
||||
}
|
||||
// shift elements [index .. size-1] one position towards the end
|
||||
for (int i = size; i > index; i--) {
|
||||
elements[physicalIndex(i)] = elements[physicalIndex(i - 1)];
|
||||
}
|
||||
elements[physicalIndex(index)] = element;
|
||||
size++;
|
||||
nextAdd(index, index + 1);
|
||||
endChange();
|
||||
}
|
||||
|
||||
@Override
|
||||
public E remove(int index) {
|
||||
checkIndex(index);
|
||||
beginChange();
|
||||
@SuppressWarnings("unchecked")
|
||||
E removed = (E) elements[physicalIndex(index)];
|
||||
// shift elements [index+1 .. size-1] one position towards the front
|
||||
for (int i = index; i < size - 1; i++) {
|
||||
elements[physicalIndex(i)] = elements[physicalIndex(i + 1)];
|
||||
}
|
||||
elements[physicalIndex(size - 1)] = null;
|
||||
size--;
|
||||
nextRemove(index, removed);
|
||||
endChange();
|
||||
return removed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public E set(int index, E element) {
|
||||
checkIndex(index);
|
||||
beginChange();
|
||||
@SuppressWarnings("unchecked")
|
||||
E old = (E) elements[physicalIndex(index)];
|
||||
elements[physicalIndex(index)] = element;
|
||||
nextSet(index, old);
|
||||
endChange();
|
||||
return old;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private int physicalIndex(int virtualIndex) {
|
||||
return (head + virtualIndex) % maxCapacity;
|
||||
}
|
||||
|
||||
private void checkIndex(int index) {
|
||||
if (index < 0 || index >= size)
|
||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
||||
}
|
||||
|
||||
private void checkIndexForAdd(int index) {
|
||||
if (index < 0 || index > size)
|
||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,7 @@ package kst4contest.view;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.logging.FileHandler;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.logging.SimpleFormatter;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@@ -62,16 +57,11 @@ import kst4contest.model.*;
|
||||
import javafx.scene.shape.Line;
|
||||
import javafx.scene.shape.Polygon;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
import kst4contest.view.map.StationMapBridge;
|
||||
import kst4contest.view.map.StationMapView;
|
||||
import kst4contest.view.map.OfflineDemImportService;
|
||||
|
||||
|
||||
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
|
||||
// private static final Kst4ContestApplication dbcontroller = new DBController();
|
||||
|
||||
private StationMapView stationMapView; //view class for the avl stn map
|
||||
private StationMapBridge stationMapBridge; //bridge for mapping actions between map and view
|
||||
|
||||
private final Button btnBandUpgradeIndicator = new Button("BAND+");
|
||||
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
||||
@@ -121,73 +111,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8];
|
||||
|
||||
private void ensureStationMapSupportInitialized() {
|
||||
if (stationMapView != null && stationMapBridge != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
stationMapView = new StationMapView(chatcontroller.getChatPreferences());
|
||||
stationMapBridge = new StationMapBridge(
|
||||
chatcontroller,
|
||||
tbl_chatMember,
|
||||
stationMapView,
|
||||
this::focusChatMemberAndPrepareCq
|
||||
);
|
||||
stationMapBridge.install();
|
||||
}
|
||||
|
||||
private void toggleStationMapWindow() {
|
||||
ensureStationMapSupportInitialized();
|
||||
stationMapBridge.toggleWindow();
|
||||
}
|
||||
|
||||
private void showSelectedCallsignOnMap() {
|
||||
ensureStationMapSupportInitialized();
|
||||
|
||||
if (selectedCallSignInfoStageChatMember != null) {
|
||||
chatcontroller.getScoreService().setSelectedChatMember(selectedCallSignInfoStageChatMember);
|
||||
}
|
||||
|
||||
stationMapBridge.focusSelectedCallsign();
|
||||
}
|
||||
|
||||
private void refreshStationMapIfVisible() {
|
||||
if (stationMapBridge != null && stationMapView != null && stationMapView.isShowing()) {
|
||||
stationMapBridge.requestImmediateRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a reasonable initial directory for the DEM tile file chooser.
|
||||
*
|
||||
* <p>Preference order:
|
||||
* <ol>
|
||||
* <li>configured DEM root directory if it already exists</li>
|
||||
* <li>its parent directory if that exists</li>
|
||||
* <li>user home directory</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param configuredDemRootDirectory current DEM root directory text
|
||||
* @return usable initial directory or null
|
||||
*/
|
||||
private File resolveInitialDirectoryForDemImport(String configuredDemRootDirectory) {
|
||||
if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) {
|
||||
File configuredDirectory = new File(configuredDemRootDirectory.trim());
|
||||
|
||||
if (configuredDirectory.isDirectory()) {
|
||||
return configuredDirectory;
|
||||
}
|
||||
|
||||
File parentDirectory = configuredDirectory.getParentFile();
|
||||
if (parentDirectory != null && parentDirectory.isDirectory()) {
|
||||
return parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
File userHomeDirectory = new File(System.getProperty("user.home"));
|
||||
return userHomeDirectory.isDirectory() ? userHomeDirectory : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* helper DTO for planes and arriving time in minutes. Maybe
|
||||
*/
|
||||
@@ -777,21 +700,23 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
});
|
||||
selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection()));
|
||||
|
||||
Button selectedCallSignShowOnMapBtn = new Button("Show on map");
|
||||
selectedCallSignShowOnMapBtn.setOnAction(new EventHandler<ActionEvent>() {
|
||||
@Override
|
||||
public void handle(ActionEvent actionEvent) {
|
||||
showSelectedCallsignOnMap();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw());
|
||||
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
||||
@Override
|
||||
public void handle(ActionEvent actionEvent) {
|
||||
// chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember);
|
||||
// Alert a = new Alert(AlertType.INFORMATION);
|
||||
//
|
||||
// a.setTitle("Not yet implemented!");
|
||||
// a.setHeaderText("kst4Contest " + ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER + ": This is a todo!");
|
||||
// a.setContentText("Mach mal hinne!");
|
||||
// a.show();
|
||||
// chatcontroller.stopRotator(); //if it´s running, stop it firstly, then set the new value
|
||||
// chatcontroller.stopRotator();
|
||||
chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection());
|
||||
|
||||
|
||||
//TODO: Hier muss was hin
|
||||
}
|
||||
});
|
||||
@@ -813,11 +738,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
}
|
||||
});
|
||||
|
||||
// selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1);
|
||||
|
||||
HBox selectedCallSignPathAndMapButtons = new HBox(10, selectedCallSignShowAsPathBtn, selectedCallSignShowOnMapBtn);
|
||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignPathAndMapButtons, 1,0,1,1);
|
||||
|
||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1);
|
||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1);
|
||||
|
||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1);
|
||||
@@ -2352,24 +2273,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty
|
||||
|
||||
|
||||
// TableColumn<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||
// msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
||||
//
|
||||
// @Override
|
||||
// public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> cellDataFeatures) {
|
||||
// SimpleStringProperty msg = new SimpleStringProperty();
|
||||
//
|
||||
// if (cellDataFeatures.getValue().getMessageText() != null) {
|
||||
//
|
||||
// msg.setValue(cellDataFeatures.getValue().getMessageText());
|
||||
// } else {
|
||||
//
|
||||
// msg.setValue("");// TODO: Prevents a bug of not setting all values as a default
|
||||
// }
|
||||
// return msg;
|
||||
// }
|
||||
// });
|
||||
|
||||
TableColumn<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||
msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
||||
|
||||
@@ -2377,12 +2280,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> cellDataFeatures) {
|
||||
SimpleStringProperty msg = new SimpleStringProperty();
|
||||
|
||||
if (cellDataFeatures.getValue() != null) {
|
||||
msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue()));
|
||||
} else {
|
||||
msg.setValue("");
|
||||
}
|
||||
if (cellDataFeatures.getValue().getMessageText() != null) {
|
||||
|
||||
msg.setValue(cellDataFeatures.getValue().getMessageText());
|
||||
} else {
|
||||
|
||||
msg.setValue("");// TODO: Prevents a bug of not setting all values as a default
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
});
|
||||
@@ -3678,57 +3582,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
Menu fileMenu = new Menu("File");
|
||||
|
||||
// build "Connect to <configured chat>" label from saved preferences
|
||||
ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain();
|
||||
String connectLabel = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber());
|
||||
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) {
|
||||
ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond();
|
||||
if (secCat != null) {
|
||||
connectLabel += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber());
|
||||
}
|
||||
}
|
||||
menuItemFileConnect = new MenuItem(connectLabel);
|
||||
menuItemFileConnect.setDisable(false);
|
||||
|
||||
if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) {
|
||||
menuItemFileConnect.setDisable(true);
|
||||
}
|
||||
|
||||
menuItemFileConnect.setOnAction(event -> {
|
||||
System.out.println("[Info] File menu: Connect clicked, using saved preferences");
|
||||
|
||||
String call = chatcontroller.getChatPreferences().getStn_loginCallSign();
|
||||
String pass = chatcontroller.getChatPreferences().getStn_loginPassword();
|
||||
|
||||
if (call == null || call.isBlank() || pass == null || pass.isBlank()) {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING);
|
||||
alert.setTitle("Cannot connect");
|
||||
alert.setHeaderText("Login credentials missing");
|
||||
alert.setContentText("Please configure your callsign and password in Settings first.");
|
||||
alert.show();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
chatcontroller.execute();
|
||||
|
||||
menuItemFileConnect.setDisable(true);
|
||||
menuItemFileDisconnect.setDisable(false);
|
||||
menuItemOptionsAwayBack.setDisable(false);
|
||||
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
||||
|
||||
chatcontroller.setConnectedAndLoggedIn(true);
|
||||
chatcontroller.setDisconnected(false);
|
||||
|
||||
} catch (InterruptedException | IOException e) {
|
||||
e.printStackTrace();
|
||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
||||
alert.setTitle("Connection failed");
|
||||
alert.setContentText("Could not connect: " + e.getMessage());
|
||||
alert.show();
|
||||
}
|
||||
});
|
||||
|
||||
// create menuitems
|
||||
menuItemFileDisconnect = new MenuItem("Disconnect");
|
||||
menuItemFileDisconnect.setDisable(true);
|
||||
|
||||
@@ -3741,7 +3595,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
public void handle(ActionEvent event) {
|
||||
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY);
|
||||
menuItemFileDisconnect.setDisable(true);
|
||||
menuItemFileConnect.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3754,7 +3607,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
});
|
||||
|
||||
// add menu items to menu
|
||||
fileMenu.getItems().add(menuItemFileConnect);
|
||||
fileMenu.getItems().add(menuItemFileDisconnect);
|
||||
fileMenu.getItems().add(m10);
|
||||
|
||||
@@ -3868,9 +3720,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
|
||||
|
||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(true);
|
||||
if (stationMapBridge != null) {
|
||||
stationMapBridge.applyThemeFromPreferences(); //dark mode for the map
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3889,32 +3739,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(false);
|
||||
|
||||
if (stationMapBridge != null) {
|
||||
stationMapBridge.applyThemeFromPreferences();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
MenuItem window50 = new MenuItem("Show / hide station map");
|
||||
window50.setOnAction(new EventHandler<ActionEvent>() {
|
||||
public void handle(ActionEvent event) {
|
||||
toggleStationMapWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// windowMenu.getItems().addAll(window1, window20, window30, window40, window50);
|
||||
|
||||
windowMenu.getItems().addAll(
|
||||
window1,
|
||||
window20,
|
||||
new SeparatorMenuItem(),
|
||||
window50,
|
||||
new SeparatorMenuItem(),
|
||||
window30,
|
||||
window40
|
||||
);
|
||||
windowMenu.getItems().addAll(window1, window20, window30, window40);
|
||||
|
||||
Menu helpMenu = new Menu("Info");
|
||||
|
||||
@@ -4182,7 +4010,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
Scene clusterAndQSOMonScene;
|
||||
Scene settingsScene;
|
||||
|
||||
MenuItem menuItemFileConnect;
|
||||
MenuItem menuItemFileDisconnect;
|
||||
MenuItem menuItemOptionsAwayBack;
|
||||
|
||||
@@ -4343,15 +4170,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
timer_updatePrivatemessageTable.purge();
|
||||
timer_updatePrivatemessageTable.cancel();
|
||||
|
||||
try {
|
||||
chatcontroller.disconnect("CLOSEALL");
|
||||
} catch (Exception e) {
|
||||
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Platform.exit();
|
||||
System.exit(0);
|
||||
|
||||
}
|
||||
|
||||
private Queue<Media> musicList = new LinkedList<Media>();
|
||||
@@ -5560,7 +5382,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
FlowPane chatMemberTableFilterQRBHBox = new FlowPane();
|
||||
chatMemberTableFilterQRBHBox.setAlignment(Pos.CENTER_LEFT);
|
||||
chatMemberTableFilterQRBHBox.setHgap(2);
|
||||
chatMemberTableFilterQRBHBox.setPrefWidth(225);
|
||||
chatMemberTableFilterQRBHBox.setPrefWidth(210);
|
||||
|
||||
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
|
||||
@@ -5609,7 +5431,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
// HBox chatMemberTableFilterQTFHBox = new HBox();
|
||||
FlowPane chatMemberTableFilterQTFHBox = new FlowPane();
|
||||
chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT);
|
||||
chatMemberTableFilterQTFHBox.setPrefWidth(525);
|
||||
chatMemberTableFilterQTFHBox.setPrefWidth(490);
|
||||
chatMemberTableFilterQTFHBox.setHgap(2);
|
||||
|
||||
CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:");
|
||||
@@ -6390,7 +6212,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
*
|
||||
****************************************************************************/
|
||||
settingsStage = new Stage();
|
||||
settingsStage.setTitle("Change Client Settings");
|
||||
settingsStage.setTitle("Change Client seetings");
|
||||
|
||||
BorderPane optionsPanel = new BorderPane();
|
||||
|
||||
@@ -6451,14 +6273,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
}
|
||||
});
|
||||
|
||||
boolean isSecondChatEnabled = this.chatcontroller.getChatPreferences().isLoginToSecondChatEnabled();
|
||||
Label lblNameSecondCat = new Label("Name in Chat 2:");
|
||||
lblNameSecondCat.setVisible(isSecondChatEnabled);
|
||||
lblNameSecondCat.setDisable(!isSecondChatEnabled);
|
||||
lblNameSecondCat.setVisible(false);
|
||||
TextField txtFldNameInChatSecondCat = new TextField(this.chatcontroller.getChatPreferences().getStn_loginNameSecondCat());
|
||||
txtFldNameInChatSecondCat.setFocusTraversable(false);
|
||||
txtFldNameInChatSecondCat.setVisible(isSecondChatEnabled);
|
||||
txtFldNameInChatSecondCat.setDisable(!isSecondChatEnabled);
|
||||
txtFldNameInChatSecondCat.setVisible(false);
|
||||
|
||||
txtFldNameInChatSecondCat.textProperty().addListener(new ChangeListener<String>() {
|
||||
|
||||
@@ -6578,12 +6397,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
|
||||
CheckBox station_chkBxEnableSecondChat = new CheckBox("2nd Chat: ");
|
||||
boolean isSecondChatEnabledForCheckbox = chatcontroller.getChatPreferences().isLoginToSecondChatEnabled();
|
||||
station_chkBxEnableSecondChat.setSelected(isSecondChatEnabledForCheckbox);
|
||||
station_chkBxEnableSecondChat.setSelected(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||
|
||||
|
||||
|
||||
stn_choiceBxChatChategorySecond.setDisable(!isSecondChatEnabledForCheckbox);
|
||||
stn_choiceBxChatChategorySecond.setDisable(true);
|
||||
station_chkBxEnableSecondChat.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
||||
@@ -6613,7 +6431,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
}
|
||||
});
|
||||
|
||||
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) {
|
||||
stn_choiceBxChatChategorySecond.setVisible(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||
stn_choiceBxChatChategorySecond.setDisable(!chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||
txtFldNameInChatSecondCat.setVisible(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||
|
||||
}
|
||||
|
||||
TextField txtFldstn_antennaBeamWidthDeg = new TextField(this.chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + "");
|
||||
txtFldstn_antennaBeamWidthDeg.setFocusTraversable(false);
|
||||
@@ -6633,36 +6456,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText());
|
||||
chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(txtFldstn_antennaBeamWidthDeg.getText()));
|
||||
refreshStationMapIfVisible(); //updates the mapview
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
TextField txtFldstn_pathAnalysisOwnTxPowerWatts = createDoublePreferenceTextField(
|
||||
this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnTxPowerWatts(),
|
||||
"Own TX power in watts used for path link-budget estimates.",
|
||||
value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnTxPowerWatts(value)
|
||||
);
|
||||
|
||||
TextField txtFldstn_pathAnalysisOwnAntennaGainDbi = createDoublePreferenceTextField(
|
||||
this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaGainDbi(),
|
||||
"Own antenna gain in dBi used for path link-budget estimates. 12 dBd = 14.15 dBi.",
|
||||
value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaGainDbi(value)
|
||||
);
|
||||
|
||||
TextField txtFldstn_pathAnalysisDefaultTargetTxPowerWatts = createDoublePreferenceTextField(
|
||||
this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetTxPowerWatts(),
|
||||
"Assumed default DX station TX power in watts. Used when no station-specific data exists.",
|
||||
value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetTxPowerWatts(value)
|
||||
);
|
||||
|
||||
TextField txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi = createDoublePreferenceTextField(
|
||||
this.chatcontroller.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaGainDbi(),
|
||||
"Assumed default DX antenna gain in dBi. 8-10 dBi is realistic for many 2m contest stations.",
|
||||
value -> this.chatcontroller.getChatPreferences().setStn_pathAnalysisDefaultTargetAntennaGainDbi(value)
|
||||
);
|
||||
|
||||
|
||||
TextField txtFldstn_maxQRBDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||
txtFldstn_maxQRBDefault.setFocusTraversable(false);
|
||||
|
||||
@@ -6681,7 +6477,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText());
|
||||
chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(txtFldstn_maxQRBDefault.getText()));
|
||||
refreshStationMapIfVisible();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6709,144 +6504,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
});
|
||||
|
||||
|
||||
TextField txtFldstn_pathAnalysisOwnAntennaHeightMeters =
|
||||
new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters() + "");
|
||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setFocusTraversable(false);
|
||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setTooltip(new Tooltip(
|
||||
"Own antenna height above local ground in meters.\nThis value is used for path analysis."
|
||||
));
|
||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.textProperty().addListener(new ChangeListener<String>() {
|
||||
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends String> observed, String oldString, String newString) {
|
||||
|
||||
if (newString.equals("")) {
|
||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText("0");
|
||||
}
|
||||
|
||||
if (!newString.matches("\\d*(\\.\\d*)?")) {
|
||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText(newString.replaceAll("[^\\d.]", ""));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
double value = Double.parseDouble(txtFldstn_pathAnalysisOwnAntennaHeightMeters.getText());
|
||||
chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaHeightMeters(value);
|
||||
refreshStationMapIfVisible();
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TextField txtFldstn_pathAnalysisDemRootDirectory =
|
||||
new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisDemRootDirectory());
|
||||
txtFldstn_pathAnalysisDemRootDirectory.setFocusTraversable(false);
|
||||
txtFldstn_pathAnalysisDemRootDirectory.setTooltip(new Tooltip(
|
||||
"Root directory that contains locally extracted Copernicus GLO-30 DEM tiles.\n" +
|
||||
"The program scans this directory recursively for *_DEM.tif tiles."
|
||||
));
|
||||
txtFldstn_pathAnalysisDemRootDirectory.focusedProperty().addListener(new ChangeListener<Boolean>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> observableValue, Boolean oldValue, Boolean newValue) {
|
||||
if (!newValue) {
|
||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
||||
);
|
||||
refreshStationMapIfVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
OfflineDemImportService offlineDemImportService = new OfflineDemImportService();
|
||||
|
||||
Button btnUseDefaultDemDirectory = new Button("Default");
|
||||
btnUseDefaultDemDirectory.setFocusTraversable(false);
|
||||
btnUseDefaultDemDirectory.setTooltip(new Tooltip(
|
||||
"Creates and uses the default local Copernicus DEM directory below .praktiKST.\n" +
|
||||
"This does not download tiles yet, it only prepares the folder."
|
||||
));
|
||||
btnUseDefaultDemDirectory.setOnAction(event -> {
|
||||
OfflineDemImportService.ImportResult importResult =
|
||||
offlineDemImportService.ensureDefaultCopernicusRootDirectory();
|
||||
|
||||
if (importResult.targetRootDirectory() != null) {
|
||||
txtFldstn_pathAnalysisDemRootDirectory.setText(
|
||||
importResult.targetRootDirectory().toAbsolutePath().toString()
|
||||
);
|
||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
||||
);
|
||||
refreshStationMapIfVisible();
|
||||
}
|
||||
|
||||
Alert alert = new Alert(importResult.success() ? AlertType.INFORMATION : AlertType.WARNING);
|
||||
alert.setTitle("DEM directory");
|
||||
alert.setHeaderText(importResult.success()
|
||||
? "Local Copernicus DEM directory is ready"
|
||||
: "DEM directory could not be prepared");
|
||||
alert.setContentText(importResult.message());
|
||||
alert.show();
|
||||
});
|
||||
|
||||
Button btnImportDemTiles = new Button("Import tiles...");
|
||||
btnImportDemTiles.setFocusTraversable(false);
|
||||
btnImportDemTiles.setTooltip(new Tooltip(
|
||||
"Copies manually selected Copernicus *_DEM.tif files into the configured DEM root directory.\n" +
|
||||
"If no DEM root directory is configured yet, the default .praktiKST/dem/copernicus_glo30 directory is used."
|
||||
));
|
||||
btnImportDemTiles.setOnAction(event -> {
|
||||
FileChooser fileChooser = new FileChooser();
|
||||
fileChooser.setTitle("Import Copernicus GLO-30 DEM tiles");
|
||||
fileChooser.getExtensionFilters().add(
|
||||
new FileChooser.ExtensionFilter("GeoTIFF DEM tiles", "*.tif", "*.tiff")
|
||||
);
|
||||
|
||||
File initialDirectory = resolveInitialDirectoryForDemImport(
|
||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
||||
);
|
||||
if (initialDirectory != null) {
|
||||
fileChooser.setInitialDirectory(initialDirectory);
|
||||
}
|
||||
|
||||
List<File> selectedFiles = fileChooser.showOpenMultipleDialog(primaryStage);
|
||||
if (selectedFiles == null || selectedFiles.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
OfflineDemImportService.ImportResult importResult =
|
||||
offlineDemImportService.importTiles(
|
||||
selectedFiles,
|
||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
||||
);
|
||||
|
||||
if (importResult.targetRootDirectory() != null) {
|
||||
txtFldstn_pathAnalysisDemRootDirectory.setText(
|
||||
importResult.targetRootDirectory().toAbsolutePath().toString()
|
||||
);
|
||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
||||
);
|
||||
refreshStationMapIfVisible();
|
||||
}
|
||||
|
||||
Alert alert = new Alert(
|
||||
importResult.success() && importResult.importedFileCount() > 0
|
||||
? AlertType.INFORMATION
|
||||
: AlertType.WARNING
|
||||
);
|
||||
alert.setTitle("DEM tile import");
|
||||
alert.setHeaderText(
|
||||
importResult.success() && importResult.importedFileCount() > 0
|
||||
? "DEM tiles imported"
|
||||
: "No DEM tiles were imported"
|
||||
);
|
||||
alert.setContentText(importResult.message());
|
||||
alert.show();
|
||||
});
|
||||
|
||||
HBox hbxDemDirectoryActions = new HBox(8.0, btnUseDefaultDemDirectory, btnImportDemTiles);
|
||||
|
||||
Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):");
|
||||
CheckBox chkBx_station_pstRotatorEnabled = new CheckBox();
|
||||
chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled());
|
||||
@@ -6870,36 +6527,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
grdPnlStation.add(choiceBxChatChategory, 1, 4);
|
||||
grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5);
|
||||
grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 5);
|
||||
|
||||
grdPnlStation.add(new Label("Own antenna height AGL:"), 0, 8);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8);
|
||||
|
||||
grdPnlStation.add(new Label("DEM root directory:"), 0, 9);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisDemRootDirectory, 1, 9);
|
||||
grdPnlStation.add(hbxDemDirectoryActions, 2, 7, 2, 1);
|
||||
|
||||
grdPnlStation.add(new Label("Default maximum QRB:"), 0, 10);
|
||||
grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 10);
|
||||
|
||||
grdPnlStation.add(new Label("Default filter QTF:"), 0, 11);
|
||||
grdPnlStation.add(txtFldstn_qtfDefault, 1, 11);
|
||||
|
||||
grdPnlStation.add(new Label("Own TX power W:"), 2, 5);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisOwnTxPowerWatts, 3, 5);
|
||||
|
||||
grdPnlStation.add(new Label("Own ant. gain dBi:"), 0, 6);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaGainDbi, 1, 6);
|
||||
|
||||
grdPnlStation.add(new Label("DX OM TX power W:"), 2, 6);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetTxPowerWatts, 3, 6);
|
||||
|
||||
grdPnlStation.add(new Label("DX OM ant. gain dBi:"), 0, 7);
|
||||
grdPnlStation.add(txtFldstn_pathAnalysisDefaultTargetAntennaGainDbi, 1, 7);
|
||||
|
||||
grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 10);
|
||||
grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 10);
|
||||
|
||||
|
||||
grdPnlStation.add(new Label("Default maximum QRB:"), 0, 6);
|
||||
grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 6);
|
||||
grdPnlStation.add(new Label("Default filter QTF:"), 0, 7);
|
||||
grdPnlStation.add(txtFldstn_qtfDefault, 1, 7);
|
||||
grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8);
|
||||
grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8);
|
||||
|
||||
VBox vbxStation = new VBox();
|
||||
vbxStation.setPadding(new Insets(10, 10, 10, 10));
|
||||
@@ -7034,6 +6667,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
grdPnlStation_bands.add(settings_chkbx_QRV3400, 1, 2);
|
||||
grdPnlStation_bands.add(settings_chkbx_QRV5600, 2, 2);
|
||||
grdPnlStation_bands.add(settings_chkbx_QRV10G, 0, 3);
|
||||
grdPnlStation_bands.setMaxWidth(555.0);
|
||||
|
||||
grdPnlStation_bands.setStyle(" -fx-border-color: lightgray;\n" +
|
||||
" -fx-vgap: 5;\n" +
|
||||
@@ -7051,9 +6685,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
// vbxStation.getChildren().add(settings_chkbx_QRV5600);
|
||||
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
||||
|
||||
|
||||
|
||||
|
||||
/*************************************************************************************
|
||||
* Log synch settings Tab
|
||||
*************************************************************************************/
|
||||
@@ -7251,32 +6882,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
grdPnlLog.add(lblUDPByWintest, 0, 8);
|
||||
grdPnlLog.add(txtFldUDPPortforWintest, 1, 8);
|
||||
|
||||
// --- QRG sync from Win-Test STATUS ---
|
||||
Label lblWtQrgSync = new Label("Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)");
|
||||
CheckBox chkBxWtQrgSync = new CheckBox();
|
||||
chkBxWtQrgSync.setSelected(
|
||||
this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()
|
||||
// --- Win-Test SKED push settings ---
|
||||
Label lblEnableSkedPush = new Label("Push SKEDs to Win-Test via UDP (ADDSKED)");
|
||||
CheckBox chkBxEnableSkedPush = new CheckBox();
|
||||
chkBxEnableSkedPush.setSelected(
|
||||
this.chatcontroller.getChatPreferences().isLogsynch_wintestNetworkSkedPushEnabled()
|
||||
);
|
||||
chkBxWtQrgSync.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
||||
chatcontroller.getChatPreferences().setLogsynch_wintestQrgSyncEnabled(newVal);
|
||||
System.out.println("[Main.java, Info]: Win-Test QRG sync enabled: " + newVal);
|
||||
boolean anyActive = chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || newVal;
|
||||
if (!anyActive) {
|
||||
txt_ownqrgMainCategory.textProperty().unbind();
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)"));
|
||||
} else {
|
||||
txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||
}
|
||||
});
|
||||
Label lblWtUsePassQrg = new Label("Use pass frequency from Win-Test STATUS (instead of own QRG)");
|
||||
CheckBox chkBxWtUsePassQrg = new CheckBox();
|
||||
chkBxWtUsePassQrg.setSelected(
|
||||
this.chatcontroller.getChatPreferences().isLogsynch_wintestUsePassQrg()
|
||||
);
|
||||
chkBxWtUsePassQrg.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
||||
chatcontroller.getChatPreferences().setLogsynch_wintestUsePassQrg(newVal);
|
||||
System.out.println("[Main.java, Info]: Win-Test use pass QRG: " + newVal);
|
||||
chkBxEnableSkedPush.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
||||
chatcontroller.getChatPreferences().setLogsynch_wintestNetworkSkedPushEnabled(newVal);
|
||||
System.out.println("[Main.java, Info]: Win-Test SKED push enabled: " + newVal);
|
||||
});
|
||||
|
||||
Label lblWtStationName = new Label("KST station name in Win-Test network (src of SKED packets)");
|
||||
@@ -7321,8 +6935,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
}
|
||||
});
|
||||
|
||||
grdPnlLog.add(lblWtStationName, 0, 9);
|
||||
grdPnlLog.add(txtFldWtStationName, 1, 9);
|
||||
grdPnlLog.add(lblEnableSkedPush, 0, 9);
|
||||
grdPnlLog.add(chkBxEnableSkedPush, 1, 9);
|
||||
|
||||
grdPnlLog.add(lblWtStationName, 0, 11);
|
||||
grdPnlLog.add(txtFldWtStationName, 1, 11);
|
||||
grdPnlLog.add(lblWtStationFilter, 0, 12);
|
||||
grdPnlLog.add(txtFldWtStationFilter, 1, 12);
|
||||
|
||||
// Auto-detect subnet broadcast if preference is still the default
|
||||
String currentBroadcast = this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress();
|
||||
@@ -7340,8 +6959,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
// Re-read (may have been auto-detected)
|
||||
txtFldWtBroadcastAddr.setText(this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
|
||||
|
||||
grdPnlLog.add(lblWtBroadcastAddr, 0, 10);
|
||||
grdPnlLog.add(txtFldWtBroadcastAddr, 1, 10);
|
||||
grdPnlLog.add(lblWtBroadcastAddr, 0, 13);
|
||||
grdPnlLog.add(txtFldWtBroadcastAddr, 1, 13);
|
||||
|
||||
VBox vbxLog = new VBox();
|
||||
vbxLog.setPadding(new Insets(10, 10, 10, 10));
|
||||
@@ -7376,45 +6995,51 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
chkBxEnableTRXMsgbyUCX.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
||||
@Override
|
||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
||||
chatcontroller.getChatPreferences().setTrxSynch_ucxLogUDPListenerEnabled(newValue);
|
||||
boolean anyActive = newValue || chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled();
|
||||
if (!anyActive) {
|
||||
// chk2.setSelected(!newValue);
|
||||
if (!newValue) {
|
||||
chatcontroller.getChatPreferences()
|
||||
.setTrxSynch_ucxLogUDPListenerEnabled(chkBxEnableTRXMsgbyUCX.isSelected());
|
||||
txt_ownqrgMainCategory.textProperty().unbind();
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)"));
|
||||
System.out.println("[Main.java, Info]: MYQRG will be changed only by User input");
|
||||
System.out.println("[Main.java, Info]: setted the trx-frequency updated by ucxlog to: "
|
||||
+ chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled());
|
||||
|
||||
} else {
|
||||
chatcontroller.getChatPreferences()
|
||||
.setTrxSynch_ucxLogUDPListenerEnabled(chkBxEnableTRXMsgbyUCX.isSelected());
|
||||
txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||
System.out.println("[Main.java, Info]: setted the trx-frequency updated by ucxlog to: "
|
||||
+ chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Unconditionally add listener to manually sync the textfield input to the button
|
||||
// (this listener also fires correctly when the value is updated by the binding)
|
||||
// Thats the default behaviour of the myqrg textfield
|
||||
if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||
txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat());// TODO: Bind darf nur
|
||||
// gemacht werden, wenn
|
||||
// ucxlog-Frequenznachrichten
|
||||
// ausgewerttet werden!
|
||||
// System.out.println("[Main.java, Info]: MYQRG will be changed only by UCXListener");
|
||||
} else {
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here"));
|
||||
// System.out.println("[Main.java, Info]: MYQRG will be changed only by User input");
|
||||
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
|
||||
System.out.println(
|
||||
"[Main.java, Info]: MYQRG Text changed from " + oldValue + " to " + newValue + " by hand");
|
||||
MYQRGButton.textProperty().set(newValue);
|
||||
});
|
||||
|
||||
// That's the default behaviour of the myqrg textfield
|
||||
if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) {
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||
txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
||||
} else {
|
||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here"));
|
||||
}
|
||||
|
||||
grdPnltrx.add(generateLabeledSeparator(100, "Receive UCXLog TRX info"), 0, 0, 2, 1);
|
||||
grdPnltrx.add(lblEnableTRXMsgbyUCX, 0, 1);
|
||||
grdPnltrx.add(chkBxEnableTRXMsgbyUCX, 1, 1);
|
||||
|
||||
grdPnltrx.add(generateLabeledSeparator(100, "Win-Test TRX sync"), 0, 2, 2, 1);
|
||||
grdPnltrx.add(lblWtQrgSync, 0, 3);
|
||||
grdPnltrx.add(chkBxWtQrgSync, 1, 3);
|
||||
grdPnltrx.add(lblWtUsePassQrg, 0, 4);
|
||||
grdPnltrx.add(chkBxWtUsePassQrg, 1, 4);
|
||||
grdPnltrx.add(lblWtStationFilter, 0, 5);
|
||||
grdPnltrx.add(txtFldWtStationFilter, 1, 5);
|
||||
|
||||
VBox vbxTRXSynch = new VBox();
|
||||
vbxTRXSynch.setPadding(new Insets(10, 10, 10, 10));
|
||||
vbxTRXSynch.getChildren().addAll(grdPnltrx);
|
||||
@@ -8499,7 +8124,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
else if (chatcontroller.isConnectedAndLoggedIn()) {
|
||||
btnOptionspnlDisconnectOnly.setDisable(false);
|
||||
menuItemFileDisconnect.setDisable(false);
|
||||
menuItemFileConnect.setDisable(true);
|
||||
menuItemOptionsAwayBack.setDisable(false);
|
||||
}
|
||||
|
||||
@@ -8523,19 +8147,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
txtFldstn_maxQRBDefault.setDisable(false);
|
||||
menuItemOptionsSetFrequencyAsName.setDisable(true);
|
||||
menuItemOptionsAwayBack.setDisable(true);
|
||||
menuItemFileConnect.setDisable(false);
|
||||
station_chkBxEnableSecondChat.setDisable(false);
|
||||
stn_choiceBxChatChategorySecond.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
String btnText = "Connect to " + chatcontroller.getChatPreferences().getLoginChatCategoryMain()
|
||||
.getChatCategoryName(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber());
|
||||
ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond();
|
||||
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled() && secCat != null) {
|
||||
btnText += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber());
|
||||
}
|
||||
btnOptionspnlConnect = new Button(btnText);
|
||||
btnOptionspnlConnect = new Button("Connect to " + chatcontroller.getChatPreferences().getLoginChatCategoryMain()
|
||||
.getChatCategoryName(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()));
|
||||
btnOptionspnlConnect.setOnAction(new EventHandler<ActionEvent>() {
|
||||
@Override
|
||||
public void handle(ActionEvent event) {
|
||||
@@ -8567,7 +8185,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
btnOptionspnlDisconnectOnly.setDisable(false);
|
||||
menuItemFileDisconnect.setDisable(false);
|
||||
menuItemFileConnect.setDisable(true);
|
||||
menuItemOptionsAwayBack.setDisable(false);
|
||||
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
||||
|
||||
@@ -8817,24 +8434,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
|
||||
|
||||
public static void main(String[] args) {
|
||||
setupFileLogging();
|
||||
launch(args);
|
||||
}
|
||||
|
||||
private static void setupFileLogging() {
|
||||
try {
|
||||
String logDir = Path.of(System.getProperty("user.home"), ".praktiKST").toString();
|
||||
new File(logDir).mkdirs();
|
||||
FileHandler fileHandler = new FileHandler(logDir + "/kst4contest-errors.log", true);
|
||||
fileHandler.setLevel(Level.SEVERE);
|
||||
fileHandler.setFormatter(new SimpleFormatter());
|
||||
Logger rootLogger = Logger.getLogger("");
|
||||
rootLogger.addHandler(fileHandler);
|
||||
} catch (IOException e) {
|
||||
System.err.println("Could not set up file logging: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
|
||||
|
||||
@@ -9094,49 +8696,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for creating station double preferences textfields
|
||||
* @param initialValue
|
||||
* @param tooltipText
|
||||
* @param valueConsumer
|
||||
* @return
|
||||
*/
|
||||
private TextField createDoublePreferenceTextField(double initialValue,
|
||||
String tooltipText,
|
||||
java.util.function.DoubleConsumer valueConsumer) {
|
||||
TextField textField = new TextField(String.valueOf(initialValue));
|
||||
textField.setFocusTraversable(false);
|
||||
textField.setTooltip(new Tooltip(tooltipText));
|
||||
|
||||
textField.focusedProperty().addListener((observable, oldValue, focused) -> {
|
||||
if (!focused) {
|
||||
try {
|
||||
String normalizedText = textField.getText().trim().replace(",", ".");
|
||||
double parsedValue = Double.parseDouble(normalizedText);
|
||||
valueConsumer.accept(parsedValue);
|
||||
textField.setText(String.valueOf(parsedValue));
|
||||
refreshStationMapIfVisible();
|
||||
} catch (NumberFormatException exception) {
|
||||
textField.setText(String.valueOf(initialValue));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
textField.setOnAction(event -> {
|
||||
try {
|
||||
String normalizedText = textField.getText().trim().replace(",", ".");
|
||||
double parsedValue = Double.parseDouble(normalizedText);
|
||||
valueConsumer.accept(parsedValue);
|
||||
textField.setText(String.valueOf(parsedValue));
|
||||
refreshStationMapIfVisible();
|
||||
} catch (NumberFormatException exception) {
|
||||
textField.setText(String.valueOf(initialValue));
|
||||
}
|
||||
});
|
||||
|
||||
return textField;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9171,7 +8730,6 @@ class ActionButtonTableCell<S, T> extends TableCell<S, T> {
|
||||
setGraphic(actionButton);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9210,4 +8768,6 @@ class CheckBoxTableCell<S, T> extends TableCell<S, T> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Tries terrain providers in order and returns the first usable profile.
|
||||
*/
|
||||
public final class ChainedTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private final List<TerrainProfileProvider> providers;
|
||||
|
||||
public ChainedTerrainProfileProvider(List<TerrainProfileProvider> providers) {
|
||||
this.providers = providers == null ? List.of() : List.copyOf(providers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
TerrainProfileData lastResult = TerrainProfileData.empty("No terrain provider");
|
||||
|
||||
for (TerrainProfileProvider provider : providers) {
|
||||
if (provider == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
TerrainProfileData currentResult = provider.loadProfile(request);
|
||||
if (currentResult != null) {
|
||||
lastResult = currentResult;
|
||||
if (currentResult.hasUsableProfile()) {
|
||||
return currentResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult;
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.image.Raster;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Offline terrain provider for locally extracted Copernicus GLO-30 DGED DEM tiles.
|
||||
*
|
||||
* <p>Assumptions of this reader:
|
||||
* <ul>
|
||||
* <li>local tiles already exist on disk</li>
|
||||
* <li>official DGED GeoTIFF filenames are used</li>
|
||||
* <li>tiles represent 1° x 1° geocells</li>
|
||||
* <li>the raster uses RasterPixelIsPoint semantics</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The active improvement step uses great-circle interpolation for the
|
||||
* sampled path points. This avoids the path distortion of simple linear
|
||||
* latitude/longitude interpolation on longer Europe-wide paths.</p>
|
||||
*/
|
||||
public final class CopernicusGlo30TerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private static final String SOURCE_NAME = "Copernicus GLO-30 offline DEM";
|
||||
private static final double NODATA_VALUE = -32767.0;
|
||||
private static final int MAX_LOADED_TILES = 8;
|
||||
|
||||
private final Supplier<String> demRootDirectorySupplier;
|
||||
private final OfflineDemManager offlineDemManager;
|
||||
|
||||
private final Map<Path, LoadedTile> loadedTileCache =
|
||||
new LinkedHashMap<>(16, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<Path, LoadedTile> eldest) {
|
||||
return size() > MAX_LOADED_TILES;
|
||||
}
|
||||
};
|
||||
|
||||
public CopernicusGlo30TerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
||||
OfflineDemManager offlineDemManager) {
|
||||
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
|
||||
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME);
|
||||
}
|
||||
|
||||
OfflineDemManager.OfflineDemIndex demIndex =
|
||||
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), DemDataset.COPERNICUS_GLO_30);
|
||||
|
||||
if (!demIndex.usable()) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " unavailable");
|
||||
}
|
||||
|
||||
int sampleCount = Math.max(2, request.requestedSampleCount());
|
||||
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
|
||||
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
||||
|
||||
PathGeometryUtils.GeoPoint interpolatedPoint =
|
||||
PathGeometryUtils.interpolateGreatCirclePoint(
|
||||
request.fromLatitudeDeg(),
|
||||
request.fromLongitudeDeg(),
|
||||
request.toLatitudeDeg(),
|
||||
request.toLongitudeDeg(),
|
||||
t
|
||||
);
|
||||
|
||||
double latitudeDeg = interpolatedPoint.latitudeDeg();
|
||||
double longitudeDeg = interpolatedPoint.longitudeDeg();
|
||||
double distanceKm = request.totalDistanceKm() * t;
|
||||
|
||||
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " path interpolation failed");
|
||||
}
|
||||
|
||||
Path tilePath = demIndex.findTilePath(latitudeDeg, longitudeDeg);
|
||||
if (tilePath == null) {
|
||||
return TerrainProfileData.empty(String.format(
|
||||
Locale.US,
|
||||
"%s missing required tile(s) near %.5f / %.5f",
|
||||
SOURCE_NAME,
|
||||
latitudeDeg,
|
||||
longitudeDeg
|
||||
));
|
||||
}
|
||||
|
||||
LoadedTile loadedTile = getOrLoadTile(tilePath);
|
||||
if (loadedTile == null) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " tile read failed");
|
||||
}
|
||||
|
||||
double elevationMeters = sampleElevationMeters(loadedTile, latitudeDeg, longitudeDeg);
|
||||
if (!Double.isFinite(elevationMeters)) {
|
||||
return TerrainProfileData.empty(String.format(
|
||||
Locale.US,
|
||||
"%s contains no-data sample(s) near %.5f / %.5f",
|
||||
SOURCE_NAME,
|
||||
latitudeDeg,
|
||||
longitudeDeg
|
||||
));
|
||||
}
|
||||
|
||||
points.add(new PathProfilePoint(
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters
|
||||
));
|
||||
}
|
||||
|
||||
return new TerrainProfileData(points, SOURCE_NAME, false);
|
||||
}
|
||||
|
||||
private synchronized LoadedTile getOrLoadTile(Path tilePath) {
|
||||
LoadedTile cachedTile = loadedTileCache.get(tilePath);
|
||||
if (cachedTile != null) {
|
||||
return cachedTile;
|
||||
}
|
||||
|
||||
LoadedTile loadedTile = loadTile(tilePath);
|
||||
if (loadedTile != null) {
|
||||
loadedTileCache.put(tilePath, loadedTile);
|
||||
}
|
||||
|
||||
return loadedTile;
|
||||
}
|
||||
|
||||
private LoadedTile loadTile(Path tilePath) {
|
||||
if (tilePath == null || !Files.isRegularFile(tilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(tilePath.toFile())) {
|
||||
if (imageInputStream == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
|
||||
if (!readers.hasNext()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ImageReader imageReader = readers.next();
|
||||
try {
|
||||
imageReader.setInput(imageInputStream, true, true);
|
||||
Raster raster = imageReader.readRaster(0, null);
|
||||
|
||||
if (raster == null || raster.getWidth() < 2 || raster.getHeight() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new LoadedTile(
|
||||
tilePath,
|
||||
raster,
|
||||
raster.getWidth(),
|
||||
raster.getHeight(),
|
||||
parseSouthDeg(tilePath.getFileName().toString()),
|
||||
parseWestDeg(tilePath.getFileName().toString())
|
||||
);
|
||||
} finally {
|
||||
imageReader.dispose();
|
||||
}
|
||||
} catch (IOException exception) {
|
||||
System.err.println("[StationMap] Could not read DEM tile " + tilePath + ": " + exception.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseSouthDeg(String filename) {
|
||||
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
|
||||
return key == null ? 0 : key.southDeg();
|
||||
}
|
||||
|
||||
private int parseWestDeg(String filename) {
|
||||
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
|
||||
return key == null ? 0 : key.westDeg();
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples one DEM tile using bilinear interpolation.
|
||||
*
|
||||
* <p>The current reader assumes 1° x 1° geocells and derives raster
|
||||
* coordinates directly from the sample latitude/longitude.</p>
|
||||
*
|
||||
* @param tile loaded DEM tile
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @return interpolated elevation in meters or NaN
|
||||
*/
|
||||
private double sampleElevationMeters(LoadedTile tile, double latitudeDeg, double longitudeDeg) {
|
||||
if (tile == null) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double x = (longitudeDeg - tile.westDeg()) * (tile.width() - 1);
|
||||
double y = ((tile.southDeg() + 1.0) - latitudeDeg) * (tile.height() - 1);
|
||||
|
||||
x = clamp(x, 0.0, tile.width() - 1.0);
|
||||
y = clamp(y, 0.0, tile.height() - 1.0);
|
||||
|
||||
int x0 = (int) Math.floor(x);
|
||||
int y0 = (int) Math.floor(y);
|
||||
int x1 = Math.min(x0 + 1, tile.width() - 1);
|
||||
int y1 = Math.min(y0 + 1, tile.height() - 1);
|
||||
|
||||
double q11 = readSample(tile.raster(), x0, y0);
|
||||
double q21 = readSample(tile.raster(), x1, y0);
|
||||
double q12 = readSample(tile.raster(), x0, y1);
|
||||
double q22 = readSample(tile.raster(), x1, y1);
|
||||
|
||||
if (!Double.isFinite(q11) || !Double.isFinite(q21) || !Double.isFinite(q12) || !Double.isFinite(q22)) {
|
||||
double nearest = readSample(tile.raster(), (int) Math.round(x), (int) Math.round(y));
|
||||
return Double.isFinite(nearest) ? nearest : Double.NaN;
|
||||
}
|
||||
|
||||
double dx = x - x0;
|
||||
double dy = y - y0;
|
||||
|
||||
double top = q11 + (q21 - q11) * dx;
|
||||
double bottom = q12 + (q22 - q12) * dx;
|
||||
|
||||
return top + (bottom - top) * dy;
|
||||
}
|
||||
|
||||
private double readSample(Raster raster, int x, int y) {
|
||||
double value = raster.getSampleDouble(x, y, 0);
|
||||
if (!Double.isFinite(value) || value <= NODATA_VALUE) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private double clamp(double value, double minValue, double maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
private record LoadedTile(
|
||||
Path path,
|
||||
Raster raster,
|
||||
int width,
|
||||
int height,
|
||||
int southDeg,
|
||||
int westDeg
|
||||
) {
|
||||
}
|
||||
|
||||
private record ParsedTileKey(int southDeg, int westDeg) {
|
||||
|
||||
private static final java.util.regex.Pattern TILE_PATTERN =
|
||||
java.util.regex.Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
|
||||
|
||||
static ParsedTileKey fromFilename(String filename) {
|
||||
if (filename == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var matcher = TILE_PATTERN.matcher(filename);
|
||||
if (!matcher.matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int south = signed(matcher.group(1), matcher.group(2));
|
||||
int west = signed(matcher.group(4), matcher.group(5));
|
||||
|
||||
return new ParsedTileKey(south, west);
|
||||
}
|
||||
|
||||
private static int signed(String direction, String degrees) {
|
||||
int value = Integer.parseInt(degrees);
|
||||
if ("S".equalsIgnoreCase(direction) || "W".equalsIgnoreCase(direction)) {
|
||||
return -value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Supported offline DEM datasets.
|
||||
*
|
||||
* First real offline target:
|
||||
* Copernicus DEM GLO-30 DGED GeoTIFF.
|
||||
*/
|
||||
public enum DemDataset {
|
||||
|
||||
COPERNICUS_GLO_30("copernicus_glo_30", "Copernicus DEM GLO-30");
|
||||
|
||||
private final String id;
|
||||
private final String displayName;
|
||||
|
||||
DemDataset(String id, String displayName) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public String id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String displayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public static DemDataset fromId(String id) {
|
||||
if (id == null || id.isBlank()) {
|
||||
return COPERNICUS_GLO_30;
|
||||
}
|
||||
|
||||
String normalized = id.trim().toLowerCase(Locale.ROOT);
|
||||
for (DemDataset dataset : values()) {
|
||||
if (dataset.id.equals(normalized)) {
|
||||
return dataset;
|
||||
}
|
||||
}
|
||||
|
||||
return COPERNICUS_GLO_30;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Terrain provider wrapper that tries a primary source first and falls back
|
||||
* to a secondary provider when the primary source returns no usable profile.
|
||||
*/
|
||||
public final class FallbackTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private final TerrainProfileProvider primaryProvider;
|
||||
private final TerrainProfileProvider fallbackProvider;
|
||||
|
||||
public FallbackTerrainProfileProvider(TerrainProfileProvider primaryProvider,
|
||||
TerrainProfileProvider fallbackProvider) {
|
||||
this.primaryProvider = Objects.requireNonNull(primaryProvider, "primaryProvider");
|
||||
this.fallbackProvider = Objects.requireNonNull(fallbackProvider, "fallbackProvider");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
TerrainProfileData primaryData = safeLoad(primaryProvider, request);
|
||||
if (primaryData.hasUsableProfile()) {
|
||||
return primaryData;
|
||||
}
|
||||
|
||||
TerrainProfileData fallbackData = safeLoad(fallbackProvider, request);
|
||||
if (fallbackData.hasUsableProfile()) {
|
||||
return fallbackData;
|
||||
}
|
||||
|
||||
return fallbackData.profilePoints().isEmpty() ? primaryData : fallbackData;
|
||||
}
|
||||
|
||||
private TerrainProfileData safeLoad(TerrainProfileProvider provider, TerrainProfileRequest request) {
|
||||
try {
|
||||
TerrainProfileData result = provider.loadProfile(request);
|
||||
return result == null
|
||||
? TerrainProfileData.empty(provider.getClass().getSimpleName())
|
||||
: result;
|
||||
} catch (Exception exception) {
|
||||
System.err.println("[StationMap] Terrain provider failed: "
|
||||
+ provider.getClass().getSimpleName()
|
||||
+ " -> " + exception.getMessage());
|
||||
return TerrainProfileData.empty(provider.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,228 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import static kst4contest.view.map.MaidenheadGridUtils.GridPrecision;
|
||||
|
||||
/**
|
||||
* Chooses a grid rendering strategy for the current viewport.
|
||||
*
|
||||
* Goals:
|
||||
* - keep the current zoom based progression as a baseline
|
||||
* - avoid overly dense grid rendering on unlucky viewport sizes
|
||||
* - make label visibility depend on actual on-screen cell size
|
||||
* - expose row/column strides so labels form a stable raster pattern
|
||||
*/
|
||||
public final class MaidenheadGridRenderPlanner {
|
||||
|
||||
private static final int MAX_SUBSQUARE_CELLS = 4500;
|
||||
private static final int MAX_SQUARE_CELLS = 2500;
|
||||
|
||||
private static final double MIN_SUBSQUARE_CELL_WIDTH_PX = 8.0;
|
||||
private static final double MIN_SUBSQUARE_CELL_HEIGHT_PX = 7.0;
|
||||
private static final double MIN_SQUARE_CELL_WIDTH_PX = 10.0;
|
||||
private static final double MIN_SQUARE_CELL_HEIGHT_PX = 9.0;
|
||||
|
||||
private MaidenheadGridRenderPlanner() {
|
||||
}
|
||||
|
||||
public static GridRenderPlan createPlan(int leafletZoom,
|
||||
double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
double viewportWidthPx,
|
||||
double viewportHeightPx) {
|
||||
|
||||
double safeViewportWidthPx = Math.max(1.0, viewportWidthPx);
|
||||
double safeViewportHeightPx = Math.max(1.0, viewportHeightPx);
|
||||
|
||||
GridPrecision requestedPrecision = MaidenheadGridUtils.precisionForZoom(leafletZoom);
|
||||
GridPrecision effectivePrecision = chooseEffectivePrecision(
|
||||
requestedPrecision,
|
||||
southLat,
|
||||
westLon,
|
||||
northLat,
|
||||
eastLon,
|
||||
safeViewportWidthPx,
|
||||
safeViewportHeightPx
|
||||
);
|
||||
|
||||
double estimatedCellWidthPx = estimateCellWidthPx(effectivePrecision, westLon, eastLon, safeViewportWidthPx);
|
||||
double estimatedCellHeightPx = estimateCellHeightPx(effectivePrecision, southLat, northLat, safeViewportHeightPx);
|
||||
|
||||
boolean showLabels = shouldShowLabels(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx);
|
||||
int labelColumnStride = showLabels ? computeStride(estimatedCellWidthPx, desiredLabelWidthPx(effectivePrecision)) : Integer.MAX_VALUE;
|
||||
int labelRowStride = showLabels ? computeStride(estimatedCellHeightPx, desiredLabelHeightPx(effectivePrecision)) : Integer.MAX_VALUE;
|
||||
double labelFontSizePx = estimateLabelFontSizePx(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx);
|
||||
|
||||
return new GridRenderPlan(
|
||||
effectivePrecision,
|
||||
showLabels,
|
||||
labelRowStride,
|
||||
labelColumnStride,
|
||||
estimatedCellWidthPx,
|
||||
estimatedCellHeightPx,
|
||||
labelFontSizePx
|
||||
);
|
||||
}
|
||||
|
||||
private static GridPrecision chooseEffectivePrecision(GridPrecision requestedPrecision,
|
||||
double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
double viewportWidthPx,
|
||||
double viewportHeightPx) {
|
||||
|
||||
GridPrecision effectivePrecision = requestedPrecision;
|
||||
|
||||
if (effectivePrecision == GridPrecision.SUBSQUARE_6
|
||||
&& !canRenderSubsquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) {
|
||||
effectivePrecision = GridPrecision.SQUARE_4;
|
||||
}
|
||||
|
||||
if (effectivePrecision == GridPrecision.SQUARE_4
|
||||
&& !canRenderSquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) {
|
||||
effectivePrecision = GridPrecision.FIELD_2;
|
||||
}
|
||||
|
||||
return effectivePrecision;
|
||||
}
|
||||
|
||||
private static boolean canRenderSubsquareGrid(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
double viewportWidthPx,
|
||||
double viewportHeightPx) {
|
||||
|
||||
double cellWidthPx = estimateCellWidthPx(GridPrecision.SUBSQUARE_6, westLon, eastLon, viewportWidthPx);
|
||||
double cellHeightPx = estimateCellHeightPx(GridPrecision.SUBSQUARE_6, southLat, northLat, viewportHeightPx);
|
||||
int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SUBSQUARE_6, southLat, westLon, northLat, eastLon);
|
||||
|
||||
return cellWidthPx >= MIN_SUBSQUARE_CELL_WIDTH_PX
|
||||
&& cellHeightPx >= MIN_SUBSQUARE_CELL_HEIGHT_PX
|
||||
&& estimatedCellCount <= MAX_SUBSQUARE_CELLS;
|
||||
}
|
||||
|
||||
private static boolean canRenderSquareGrid(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
double viewportWidthPx,
|
||||
double viewportHeightPx) {
|
||||
|
||||
double cellWidthPx = estimateCellWidthPx(GridPrecision.SQUARE_4, westLon, eastLon, viewportWidthPx);
|
||||
double cellHeightPx = estimateCellHeightPx(GridPrecision.SQUARE_4, southLat, northLat, viewportHeightPx);
|
||||
int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SQUARE_4, southLat, westLon, northLat, eastLon);
|
||||
|
||||
return cellWidthPx >= MIN_SQUARE_CELL_WIDTH_PX
|
||||
&& cellHeightPx >= MIN_SQUARE_CELL_HEIGHT_PX
|
||||
&& estimatedCellCount <= MAX_SQUARE_CELLS;
|
||||
}
|
||||
|
||||
private static int estimateVisibleCellCount(GridPrecision precision,
|
||||
double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon) {
|
||||
|
||||
double lonSpanDeg = Math.max(1e-6, eastLon - westLon);
|
||||
double latSpanDeg = Math.max(1e-6, northLat - southLat);
|
||||
|
||||
int columns = Math.max(1, (int) Math.ceil(lonSpanDeg / precision.cellWidthDeg()));
|
||||
int rows = Math.max(1, (int) Math.ceil(latSpanDeg / precision.cellHeightDeg()));
|
||||
return columns * rows;
|
||||
}
|
||||
|
||||
private static double estimateCellWidthPx(GridPrecision precision,
|
||||
double westLon,
|
||||
double eastLon,
|
||||
double viewportWidthPx) {
|
||||
|
||||
double lonSpanDeg = Math.max(1e-6, eastLon - westLon);
|
||||
double visibleColumns = Math.max(1.0, lonSpanDeg / precision.cellWidthDeg());
|
||||
return viewportWidthPx / visibleColumns;
|
||||
}
|
||||
|
||||
private static double estimateCellHeightPx(GridPrecision precision,
|
||||
double southLat,
|
||||
double northLat,
|
||||
double viewportHeightPx) {
|
||||
|
||||
double latSpanDeg = Math.max(1e-6, northLat - southLat);
|
||||
double visibleRows = Math.max(1.0, latSpanDeg / precision.cellHeightDeg());
|
||||
return viewportHeightPx / visibleRows;
|
||||
}
|
||||
|
||||
private static boolean shouldShowLabels(GridPrecision precision, double cellWidthPx, double cellHeightPx) {
|
||||
return switch (precision) {
|
||||
case FIELD_2 -> cellWidthPx >= 28.0 && cellHeightPx >= 14.0;
|
||||
case SQUARE_4 -> cellWidthPx >= 22.0 && cellHeightPx >= 14.0;
|
||||
case SUBSQUARE_6 -> cellWidthPx >= 18.0 && cellHeightPx >= 11.0;
|
||||
};
|
||||
}
|
||||
|
||||
private static double desiredLabelWidthPx(GridPrecision precision) {
|
||||
return switch (precision) {
|
||||
case FIELD_2 -> 30.0;
|
||||
case SQUARE_4 -> 44.0;
|
||||
case SUBSQUARE_6 -> 56.0;
|
||||
};
|
||||
}
|
||||
|
||||
private static double desiredLabelHeightPx(GridPrecision precision) {
|
||||
return switch (precision) {
|
||||
case FIELD_2 -> 18.0;
|
||||
case SQUARE_4 -> 18.0;
|
||||
case SUBSQUARE_6 -> 16.0;
|
||||
};
|
||||
}
|
||||
|
||||
private static double estimateLabelFontSizePx(GridPrecision precision,
|
||||
double cellWidthPx,
|
||||
double cellHeightPx) {
|
||||
|
||||
double minFontSizePx = switch (precision) {
|
||||
case FIELD_2 -> 14.0;
|
||||
case SQUARE_4 -> 11.5;
|
||||
case SUBSQUARE_6 -> 10.5;
|
||||
};
|
||||
|
||||
double maxFontSizePx = switch (precision) {
|
||||
case FIELD_2 -> 18.0;
|
||||
case SQUARE_4 -> 15.0;
|
||||
case SUBSQUARE_6 -> 13.5;
|
||||
};
|
||||
|
||||
double estimatedFontSizePx = Math.min(cellHeightPx * 0.55, cellWidthPx * 0.24);
|
||||
return clamp(estimatedFontSizePx, minFontSizePx, maxFontSizePx);
|
||||
}
|
||||
|
||||
private static int computeStride(double cellSizePx, double desiredLabelSizePx) {
|
||||
return Math.max(1, (int) Math.ceil(desiredLabelSizePx / Math.max(1.0, cellSizePx)));
|
||||
}
|
||||
|
||||
private static double clamp(double value, double min, double max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
public record GridRenderPlan(
|
||||
GridPrecision precision,
|
||||
boolean showLabels,
|
||||
int labelRowStride,
|
||||
int labelColumnStride,
|
||||
double estimatedCellWidthPx,
|
||||
double estimatedCellHeightPx,
|
||||
double labelFontSizePx
|
||||
) {
|
||||
|
||||
public boolean shouldShowLabel(MaidenheadGridUtils.GridCell cell) {
|
||||
if (!showLabels || cell == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (cell.rowIndex() % labelRowStride) == 0
|
||||
&& (cell.columnIndex() % labelColumnStride) == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Utility methods for generating visible Maidenhead grid rectangles.
|
||||
*
|
||||
* Supported levels:
|
||||
* - 2 characters (fields)
|
||||
* - 4 characters (squares)
|
||||
* - 6 characters (subsquares)
|
||||
*/
|
||||
public final class MaidenheadGridUtils {
|
||||
|
||||
private static final double EPSILON = 1e-9;
|
||||
|
||||
private MaidenheadGridUtils() {
|
||||
}
|
||||
|
||||
public enum GridPrecision {
|
||||
FIELD_2(2, 20.0, 10.0),
|
||||
SQUARE_4(4, 2.0, 1.0),
|
||||
SUBSQUARE_6(6, 5.0 / 60.0, 2.5 / 60.0);
|
||||
|
||||
private final int locatorLength;
|
||||
private final double cellWidthDeg;
|
||||
private final double cellHeightDeg;
|
||||
|
||||
GridPrecision(int locatorLength, double cellWidthDeg, double cellHeightDeg) {
|
||||
this.locatorLength = locatorLength;
|
||||
this.cellWidthDeg = cellWidthDeg;
|
||||
this.cellHeightDeg = cellHeightDeg;
|
||||
}
|
||||
|
||||
public int locatorLength() {
|
||||
return locatorLength;
|
||||
}
|
||||
|
||||
public double cellWidthDeg() {
|
||||
return cellWidthDeg;
|
||||
}
|
||||
|
||||
public double cellHeightDeg() {
|
||||
return cellHeightDeg;
|
||||
}
|
||||
}
|
||||
|
||||
public record GridCell(
|
||||
String locatorLabel,
|
||||
double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
int rowIndex,
|
||||
int columnIndex
|
||||
) {
|
||||
}
|
||||
|
||||
public static GridPrecision precisionForZoom(int leafletZoom) {
|
||||
if (leafletZoom <= 5) {
|
||||
return GridPrecision.FIELD_2;
|
||||
}
|
||||
if (leafletZoom <= 7) {
|
||||
return GridPrecision.SQUARE_4;
|
||||
}
|
||||
return GridPrecision.SUBSQUARE_6;
|
||||
}
|
||||
|
||||
public static List<GridCell> buildVisibleCells(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
int leafletZoom) {
|
||||
return buildVisibleCells(southLat, westLon, northLat, eastLon, precisionForZoom(leafletZoom));
|
||||
}
|
||||
|
||||
public static List<GridCell> buildVisibleCells(double southLat,
|
||||
double westLon,
|
||||
double northLat,
|
||||
double eastLon,
|
||||
GridPrecision precision) {
|
||||
|
||||
if (westLon > eastLon) {
|
||||
// Anti-meridian handling is not needed for Europe in this project stage.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
double clampedSouth = clamp(southLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
||||
double clampedNorth = clamp(northLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
||||
double clampedWest = clamp(westLon, -180.0 + EPSILON, 180.0 - EPSILON);
|
||||
double clampedEast = clamp(eastLon, -180.0 + EPSILON, 180.0 - EPSILON);
|
||||
|
||||
return switch (precision) {
|
||||
case FIELD_2 -> build2CharFields(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
||||
case SQUARE_4 -> build4CharSquares(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
||||
case SUBSQUARE_6 -> build6CharSubsquares(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
||||
};
|
||||
}
|
||||
|
||||
private static List<GridCell> build2CharFields(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> cells = new ArrayList<>();
|
||||
|
||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 20.0), 0, 17);
|
||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 20.0), 0, 17);
|
||||
|
||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 10.0), 0, 17);
|
||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 10.0), 0, 17);
|
||||
|
||||
for (int lonIndex = lonStart; lonIndex <= lonEnd; lonIndex++) {
|
||||
for (int latIndex = latStart; latIndex <= latEnd; latIndex++) {
|
||||
double west = -180.0 + lonIndex * 20.0;
|
||||
double east = west + 20.0;
|
||||
double south = -90.0 + latIndex * 10.0;
|
||||
double north = south + 10.0;
|
||||
|
||||
String label = "" + (char) ('A' + lonIndex) + (char) ('A' + latIndex);
|
||||
cells.add(new GridCell(label, south, west, north, east, latIndex, lonIndex));
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static List<GridCell> build4CharSquares(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> cells = new ArrayList<>();
|
||||
|
||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 2.0), 0, 179);
|
||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 2.0), 0, 179);
|
||||
|
||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 1.0), 0, 179);
|
||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 1.0), 0, 179);
|
||||
|
||||
for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) {
|
||||
for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) {
|
||||
|
||||
int lonFieldIndex = lonTotalIndex / 10;
|
||||
int lonSquareIndex = lonTotalIndex % 10;
|
||||
|
||||
int latFieldIndex = latTotalIndex / 10;
|
||||
int latSquareIndex = latTotalIndex % 10;
|
||||
|
||||
double west = -180.0 + lonTotalIndex * 2.0;
|
||||
double east = west + 2.0;
|
||||
double south = -90.0 + latTotalIndex;
|
||||
double north = south + 1.0;
|
||||
|
||||
String label = String.format(
|
||||
Locale.ROOT,
|
||||
"%c%c%d%d",
|
||||
(char) ('A' + lonFieldIndex),
|
||||
(char) ('A' + latFieldIndex),
|
||||
lonSquareIndex,
|
||||
latSquareIndex
|
||||
);
|
||||
|
||||
cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex));
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static List<GridCell> build6CharSubsquares(double southLat, double westLon, double northLat, double eastLon) {
|
||||
List<GridCell> cells = new ArrayList<>();
|
||||
|
||||
double lonStepDeg = 5.0 / 60.0;
|
||||
double latStepDeg = 2.5 / 60.0;
|
||||
|
||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / lonStepDeg), 0, 4319);
|
||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / lonStepDeg), 0, 4319);
|
||||
|
||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / latStepDeg), 0, 4319);
|
||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / latStepDeg), 0, 4319);
|
||||
|
||||
for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) {
|
||||
for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) {
|
||||
|
||||
int lonFieldIndex = lonTotalIndex / 240;
|
||||
int lonWithinField = lonTotalIndex % 240;
|
||||
int lonSquareIndex = lonWithinField / 24;
|
||||
int lonSubsquareIndex = lonWithinField % 24;
|
||||
|
||||
int latFieldIndex = latTotalIndex / 240;
|
||||
int latWithinField = latTotalIndex % 240;
|
||||
int latSquareIndex = latWithinField / 24;
|
||||
int latSubsquareIndex = latWithinField % 24;
|
||||
|
||||
double west = -180.0 + lonTotalIndex * lonStepDeg;
|
||||
double east = west + lonStepDeg;
|
||||
double south = -90.0 + latTotalIndex * latStepDeg;
|
||||
double north = south + latStepDeg;
|
||||
|
||||
String label = String.format(
|
||||
Locale.ROOT,
|
||||
"%c%c%d%d%c%c",
|
||||
(char) ('A' + lonFieldIndex),
|
||||
(char) ('A' + latFieldIndex),
|
||||
lonSquareIndex,
|
||||
latSquareIndex,
|
||||
(char) ('a' + lonSubsquareIndex),
|
||||
(char) ('a' + latSubsquareIndex)
|
||||
);
|
||||
|
||||
cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex));
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
private static int clampIndex(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private static double clamp(double value, double min, double max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Immutable map render model.
|
||||
*
|
||||
* One snapshot represents exactly one visible marker on the map,
|
||||
* aggregated by callSignRaw.
|
||||
*/
|
||||
public record MapCallsignRawSnapshot(
|
||||
String callSignRaw,
|
||||
String displayCallSign,
|
||||
String locator6,
|
||||
double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
String bandSummary,
|
||||
Map<String, String> lastKnownFrequenciesByBand,
|
||||
boolean warningToMyDirection,
|
||||
boolean worked,
|
||||
boolean selected,
|
||||
double qrbKm,
|
||||
double qtfDeg,
|
||||
int reachableAirplanes,
|
||||
long lastActivityEpochMs
|
||||
) {
|
||||
|
||||
public MapCallsignRawSnapshot {
|
||||
callSignRaw = normalizeUpper(callSignRaw);
|
||||
displayCallSign = (displayCallSign == null || displayCallSign.isBlank())
|
||||
? callSignRaw
|
||||
: displayCallSign.trim();
|
||||
|
||||
locator6 = locator6 == null ? "" : locator6.trim().toUpperCase(Locale.ROOT);
|
||||
bandSummary = bandSummary == null ? "" : bandSummary.trim();
|
||||
|
||||
LinkedHashMap<String, String> orderedFrequencies = new LinkedHashMap<>();
|
||||
if (lastKnownFrequenciesByBand != null) {
|
||||
orderedFrequencies.putAll(lastKnownFrequenciesByBand);
|
||||
}
|
||||
lastKnownFrequenciesByBand = Collections.unmodifiableMap(orderedFrequencies);
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public boolean hasUsablePosition() {
|
||||
return !locator6.isBlank()
|
||||
&& Double.isFinite(latitudeDeg)
|
||||
&& Double.isFinite(longitudeDeg);
|
||||
}
|
||||
|
||||
public String markerLabel() {
|
||||
return bandSummary.isBlank()
|
||||
? displayCallSign
|
||||
: displayCallSign + " (" + bandSummary + ")";
|
||||
}
|
||||
|
||||
public String detailFrequencyText() {
|
||||
if (lastKnownFrequenciesByBand.isEmpty()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
for (Map.Entry<String, String> entry : lastKnownFrequenciesByBand.entrySet()) {
|
||||
joiner.add(entry.getKey() + ": " + entry.getValue());
|
||||
}
|
||||
return joiner.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.AirPlaneReflectionInfo;
|
||||
import kst4contest.model.Band;
|
||||
import kst4contest.model.ChatMember;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Builds immutable map snapshots from the currently visible chat members.
|
||||
*
|
||||
* The aggregation key is callSignRaw because the map shall show exactly one marker
|
||||
* per base callsign, even if the same station exists in multiple chat categories.
|
||||
*/
|
||||
public final class MapCallsignRawSnapshotBuilder {
|
||||
|
||||
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
||||
ChatMember selectedChatMember) {
|
||||
|
||||
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String selectedCallsignRaw = normalizeCallsignRaw(
|
||||
selectedChatMember == null ? null : selectedChatMember.getCallSignRaw()
|
||||
);
|
||||
|
||||
Map<String, List<ChatMember>> groupedByCallsignRaw = new LinkedHashMap<>();
|
||||
|
||||
for (ChatMember chatMember : visibleChatMembers) {
|
||||
if (chatMember == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String callSignRaw = normalizeCallsignRaw(chatMember.getCallSignRaw());
|
||||
if (callSignRaw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupedByCallsignRaw
|
||||
.computeIfAbsent(callSignRaw, ignored -> new ArrayList<>())
|
||||
.add(chatMember);
|
||||
}
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = new ArrayList<>(groupedByCallsignRaw.size());
|
||||
|
||||
for (Map.Entry<String, List<ChatMember>> entry : groupedByCallsignRaw.entrySet()) {
|
||||
String callSignRaw = entry.getKey();
|
||||
List<ChatMember> variants = entry.getValue();
|
||||
|
||||
ChatMember representative = chooseRepresentative(variants);
|
||||
if (representative == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String locator6 = findBestLocator6(variants);
|
||||
if (locator6.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Location location = new Location(locator6);
|
||||
|
||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
|
||||
String bandSummary = String.join(", ", frequenciesByBand.keySet());
|
||||
|
||||
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
|
||||
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
|
||||
boolean selected = callSignRaw.equals(selectedCallsignRaw);
|
||||
|
||||
double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0;
|
||||
double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0;
|
||||
|
||||
int reachableAirplanes = variants.stream()
|
||||
.map(ChatMember::getAirPlaneReflectInfo)
|
||||
.mapToInt(this::extractReachableAirplanes)
|
||||
.max()
|
||||
.orElse(0);
|
||||
|
||||
snapshots.add(new MapCallsignRawSnapshot(
|
||||
callSignRaw,
|
||||
bestDisplayCallsign(variants, representative),
|
||||
locator6,
|
||||
location.getLatitude().toDegrees(),
|
||||
location.getLongitude().toDegrees(),
|
||||
bandSummary,
|
||||
frequenciesByBand,
|
||||
warningToMyDirection,
|
||||
worked,
|
||||
selected,
|
||||
qrbKm,
|
||||
qtfDeg,
|
||||
reachableAirplanes,
|
||||
representative.getActivityTimeLastInEpoch()
|
||||
));
|
||||
}
|
||||
|
||||
snapshots.sort(Comparator.comparing(
|
||||
MapCallsignRawSnapshot::displayCallSign,
|
||||
String.CASE_INSENSITIVE_ORDER
|
||||
));
|
||||
|
||||
return List.copyOf(snapshots);
|
||||
}
|
||||
|
||||
private ChatMember chooseRepresentative(List<ChatMember> variants) {
|
||||
return variants.stream()
|
||||
.filter(chatMember -> chatMember != null && normalizeLocator6(chatMember.getQra()).length() == 6)
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElseGet(() -> variants.stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null));
|
||||
}
|
||||
|
||||
private String bestDisplayCallsign(List<ChatMember> variants, ChatMember representative) {
|
||||
if (representative != null && representative.getCallSign() != null && !representative.getCallSign().isBlank()) {
|
||||
return representative.getCallSign().trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant != null && variant.getCallSign() != null && !variant.getCallSign().isBlank()) {
|
||||
return variant.getCallSign().trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
return representative == null ? "" : normalizeCallsignRaw(representative.getCallSignRaw());
|
||||
}
|
||||
|
||||
private String findBestLocator6(List<ChatMember> variants) {
|
||||
return variants.stream()
|
||||
.sorted(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch).reversed())
|
||||
.map(ChatMember::getQra)
|
||||
.map(this::normalizeLocator6)
|
||||
.filter(locator -> locator.length() == 6)
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
|
||||
|
||||
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
|
||||
|
||||
for (ChatMember variant : variants) {
|
||||
if (variant == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
|
||||
Band band = bandEntry.getKey();
|
||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
|
||||
|
||||
if (band == null || activeFrequencyInfo == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(band);
|
||||
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
|
||||
latestByBand.put(band, new FrequencyCandidate(
|
||||
band,
|
||||
formatFrequency(activeFrequencyInfo.frequency),
|
||||
activeFrequencyInfo.timestampEpoch
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
||||
latestByBand.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for stations where the current displayed QRG exists but the
|
||||
* knownActiveBands history has not yet been filled.
|
||||
*
|
||||
* This parsing is intentionally tolerant so strings like "144.300 MHz"
|
||||
* can still be used.
|
||||
*/
|
||||
private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map<Band, FrequencyCandidate> latestByBand) {
|
||||
if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String rawFrequency = variant.getFrequency().getValue().trim();
|
||||
if (rawFrequency.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency);
|
||||
if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Band detectedBand = Band.fromFrequency(parsedFrequencyMHz);
|
||||
if (detectedBand == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FrequencyCandidate previous = latestByBand.get(detectedBand);
|
||||
if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestByBand.put(detectedBand, new FrequencyCandidate(
|
||||
detectedBand,
|
||||
formatFrequency(parsedFrequencyMHz),
|
||||
variant.getActivityTimeLastInEpoch()
|
||||
));
|
||||
}
|
||||
|
||||
private boolean isWorkedAtAnyBand(ChatMember member) {
|
||||
return member.isWorked()
|
||||
|| member.isWorked50()
|
||||
|| member.isWorked70()
|
||||
|| member.isWorked144()
|
||||
|| member.isWorked432()
|
||||
|| member.isWorked1240()
|
||||
|| member.isWorked2300()
|
||||
|| member.isWorked3400()
|
||||
|| member.isWorked5600()
|
||||
|| member.isWorked10G()
|
||||
|| member.isWorked24G()
|
||||
|| member.isWorked47G()
|
||||
|| member.isWorked76G();
|
||||
}
|
||||
|
||||
private int extractReachableAirplanes(AirPlaneReflectionInfo airPlaneReflectionInfo) {
|
||||
return airPlaneReflectionInfo == null ? 0 : airPlaneReflectionInfo.getAirPlanesReachableCntr();
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
if (callSignRaw == null) {
|
||||
return "";
|
||||
}
|
||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
if (locator == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String normalized = locator.trim().toUpperCase(Locale.ROOT);
|
||||
if (normalized.length() >= 6) {
|
||||
normalized = normalized.substring(0, 6);
|
||||
}
|
||||
|
||||
if (!normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String toBandDisplayLabel(Band band) {
|
||||
return switch (band) {
|
||||
case B_144 -> "144";
|
||||
case B_432 -> "432";
|
||||
case B_1296 -> "1296";
|
||||
case B_2320 -> "2320";
|
||||
case B_3400 -> "3400";
|
||||
case B_5760 -> "5760";
|
||||
case B_10G -> "10368";
|
||||
case B_24G -> "24048";
|
||||
};
|
||||
}
|
||||
|
||||
private String formatFrequency(double frequencyMHz) {
|
||||
return String.format(Locale.US, "%.3f", frequencyMHz);
|
||||
}
|
||||
|
||||
private record FrequencyCandidate(Band band, String formattedFrequency, long timestampEpochMs) {
|
||||
}
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
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.
|
||||
*
|
||||
* This version keeps DOM-based station markers, but exposes helper APIs so the
|
||||
* JavaFX WebView can decide interactions directly:
|
||||
* - inspectPoint(x,y) returns what is under the cursor
|
||||
* - zoomIn()/zoomOut() are callable from Java
|
||||
* - grid / beam / connection use non-interactive panes
|
||||
* - JavaScript logs are forwarded to Java through javaMapBridge
|
||||
* - setTheme(light|dark) aligns the map with the JavaFX application theme
|
||||
*/
|
||||
public final class MapHtmlResources {
|
||||
|
||||
private MapHtmlResources() {
|
||||
}
|
||||
|
||||
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 """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KST4Contest Station Map</title>
|
||||
<style>
|
||||
""" + leafletCss + """
|
||||
</style>
|
||||
<style>
|
||||
:root {
|
||||
--map-background: #ede9df;
|
||||
--station-label-bg: rgba(248, 248, 248, 0.95);
|
||||
--station-label-color: #1c1c1c;
|
||||
--station-label-border: rgba(0, 0, 0, 0.20);
|
||||
--grid-label-bg: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-color: rgba(20, 20, 20, 0.38);
|
||||
--control-bg: rgba(255, 255, 255, 0.96);
|
||||
--control-fg: #242424;
|
||||
--control-border: #b7b7b7;
|
||||
--attribution-bg: rgba(255, 255, 255, 0.88);
|
||||
--attribution-fg: #2d2d2d;
|
||||
--attribution-link: #145fa3;
|
||||
}
|
||||
|
||||
body.kst-theme-dark {
|
||||
--map-background: #23282d;
|
||||
--station-label-bg: rgba(36, 40, 45, 0.96);
|
||||
--station-label-color: #f1f3f5;
|
||||
--station-label-border: rgba(255, 255, 255, 0.18);
|
||||
--grid-label-bg: rgba(34, 38, 43, 0.20);
|
||||
--grid-label-color: rgba(235, 240, 245, 0.42);
|
||||
--control-bg: rgba(55, 62, 67, 0.96);
|
||||
--control-fg: #e2e6ea;
|
||||
--control-border: #556068;
|
||||
--attribution-bg: rgba(34, 38, 43, 0.86);
|
||||
--attribution-fg: #d2d8dd;
|
||||
--attribution-link: #88c7ff;
|
||||
}
|
||||
|
||||
html, body, #map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--map-background);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background: var(--map-background);
|
||||
}
|
||||
|
||||
body.kst-theme-dark .leaflet-tile-pane {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.leaflet-bar {
|
||||
border: 1px solid var(--control-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.leaflet-bar a,
|
||||
.leaflet-bar a:hover {
|
||||
background: var(--control-bg);
|
||||
color: var(--control-fg);
|
||||
border-bottom: 1px solid var(--control-border);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: var(--attribution-bg);
|
||||
color: var(--attribution-fg);
|
||||
}
|
||||
|
||||
.leaflet-control-attribution a {
|
||||
color: var(--attribution-link);
|
||||
}
|
||||
|
||||
.station-marker-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.station-marker-root {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.station-dot {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #1d1d1d;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #4da6ff;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.station-dot.worked {
|
||||
border-color: #ffd24d;
|
||||
}
|
||||
|
||||
.station-dot.warning {
|
||||
border-color: #00ff66;
|
||||
}
|
||||
|
||||
.station-dot.selected {
|
||||
left: -8px;
|
||||
top: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 3px;
|
||||
border-color: #ff9900;
|
||||
}
|
||||
|
||||
.station-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: -22px;
|
||||
display: inline-block;
|
||||
background: var(--station-label-bg);
|
||||
color: var(--station-label-color);
|
||||
border: 1px solid var(--station-label-border);
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.station-label.warning {
|
||||
color: #00ff66;
|
||||
border-color: rgba(0, 255, 102, 0.75);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.maidenhead-grid-label-wrapper .maidenhead-grid-label {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.maidenhead-grid-label {
|
||||
display: inline-block;
|
||||
background: var(--grid-label-bg);
|
||||
#color: var(--grid-label-color);
|
||||
color: #63067a;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.18);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="kst-theme-light">
|
||||
<div id="map"></div>
|
||||
|
||||
<script>
|
||||
""" + leafletJs + """
|
||||
</script>
|
||||
<script>window._kstTileProxyPort=__TILE_PROXY_PORT__;</script>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
|
||||
window.kstMapApi = (function () {
|
||||
let map;
|
||||
let stationLayer;
|
||||
let gridLayer;
|
||||
let beamLayer;
|
||||
let connectionLayer;
|
||||
let profileHoverMarker;
|
||||
let markersByCallsignRaw = {};
|
||||
let activeTheme = 'light';
|
||||
let invalidateNotifyTimer = 0;
|
||||
|
||||
function jsLog(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsLog(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[KST map fallback log]', message, e);
|
||||
}
|
||||
}
|
||||
|
||||
function jsError(message) {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onJsError(String(message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[KST map fallback error]', message);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function notifyMapReady() {
|
||||
try {
|
||||
if (window.javaMapBridge) {
|
||||
window.javaMapBridge.onMapReady();
|
||||
}
|
||||
} catch (e) {
|
||||
jsError('notifyMapReady failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyViewport() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!window.javaMapBridge) {
|
||||
jsError('notifyViewport skipped: no javaMapBridge');
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
jsLog('notifyViewport south=' + bounds.getSouth()
|
||||
+ ' west=' + bounds.getWest()
|
||||
+ ' north=' + bounds.getNorth()
|
||||
+ ' east=' + bounds.getEast()
|
||||
+ ' zoom=' + zoom);
|
||||
|
||||
window.javaMapBridge.onViewportChanged(
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
);
|
||||
} catch (e) {
|
||||
jsError('notifyViewport failed: ' + e);
|
||||
}
|
||||
}
|
||||
|
||||
function gridLineColor() {
|
||||
return activeTheme === 'dark' ? '#e1e7ec' : '#46586c';
|
||||
}
|
||||
|
||||
function gridLineOpacity() {
|
||||
return activeTheme === 'dark' ? 0.48 : 0.56;
|
||||
}
|
||||
|
||||
function connectionColor() {
|
||||
return activeTheme === 'dark' ? '#2fd7ff' : '#00a4cf';
|
||||
}
|
||||
|
||||
function applyThemeClass() {
|
||||
document.body.classList.remove('kst-theme-light', 'kst-theme-dark');
|
||||
document.body.classList.add(activeTheme === 'dark' ? 'kst-theme-dark' : 'kst-theme-light');
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
activeTheme = themeName === 'dark' ? 'dark' : 'light';
|
||||
applyThemeClass();
|
||||
jsLog('setTheme ' + activeTheme);
|
||||
}
|
||||
|
||||
function buildStationMarkerHtml(station) {
|
||||
let dotClasses = 'station-dot';
|
||||
if (station.selected) {
|
||||
dotClasses += ' selected';
|
||||
} else if (station.warningToMyDirection) {
|
||||
dotClasses += ' warning';
|
||||
} else if (station.worked) {
|
||||
dotClasses += ' worked';
|
||||
}
|
||||
|
||||
let labelClasses = 'station-label';
|
||||
if (station.warningToMyDirection) {
|
||||
labelClasses += ' warning';
|
||||
}
|
||||
|
||||
return '<div class="station-marker-root" data-callsignraw="' + escapeHtml(station.callSignRaw) + '">'
|
||||
+ '<div class="' + dotClasses + '"></div>'
|
||||
+ '<div class="' + labelClasses + '">' + escapeHtml(station.markerLabel) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (map) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof L === 'undefined') {
|
||||
jsError('Leaflet is not loaded. Station map cannot initialize.');
|
||||
return false;
|
||||
}
|
||||
|
||||
applyThemeClass();
|
||||
|
||||
map = L.map('map', {
|
||||
zoomControl: true
|
||||
}).setView([51.0, 10.0], 6);
|
||||
|
||||
jsLog('Leaflet map initialized');
|
||||
|
||||
L.tileLayer(
|
||||
'http://127.0.0.1:' + window._kstTileProxyPort + '/tiles/{s}/{z}/{x}/{y}.png',
|
||||
{
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}
|
||||
).addTo(map);
|
||||
|
||||
map.createPane('beamPane');
|
||||
map.getPane('beamPane').style.zIndex = 410;
|
||||
map.getPane('beamPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridPane');
|
||||
map.getPane('gridPane').style.zIndex = 420;
|
||||
map.getPane('gridPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('gridLabelPane');
|
||||
map.getPane('gridLabelPane').style.zIndex = 430;
|
||||
map.getPane('gridLabelPane').style.pointerEvents = 'none';
|
||||
|
||||
map.createPane('connectionPane');
|
||||
map.getPane('connectionPane').style.zIndex = 440;
|
||||
map.getPane('connectionPane').style.pointerEvents = 'none';
|
||||
|
||||
stationLayer = L.layerGroup().addTo(map);
|
||||
gridLayer = L.layerGroup().addTo(map);
|
||||
beamLayer = L.layerGroup().addTo(map);
|
||||
connectionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
map.on('zoomend', function () {
|
||||
jsLog('zoomend -> ' + map.getZoom());
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
map.on('moveend', function () {
|
||||
jsLog('moveend');
|
||||
notifyViewport();
|
||||
});
|
||||
|
||||
notifyMapReady();
|
||||
notifyViewport();
|
||||
return true;
|
||||
}
|
||||
|
||||
function invalidateSize() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('invalidateSize');
|
||||
|
||||
map.invalidateSize(false);
|
||||
|
||||
if (invalidateNotifyTimer) {
|
||||
window.clearTimeout(invalidateNotifyTimer);
|
||||
}
|
||||
|
||||
invalidateNotifyTimer = window.setTimeout(function () {
|
||||
notifyViewport();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() + 1, { animate: false });
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.setZoom(map.getZoom() - 1, { animate: false });
|
||||
}
|
||||
|
||||
function getViewportState() {
|
||||
if (!map) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
return [
|
||||
bounds.getSouth(),
|
||||
bounds.getWest(),
|
||||
bounds.getNorth(),
|
||||
bounds.getEast(),
|
||||
zoom
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function inspectPoint(x, y) {
|
||||
try {
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (!el) {
|
||||
return 'none||||';
|
||||
}
|
||||
|
||||
const stationRoot = el.closest('.station-marker-root');
|
||||
if (stationRoot) {
|
||||
const callSignRaw = stationRoot.getAttribute('data-callsignraw') || '';
|
||||
return 'station|' + callSignRaw + '|' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomInButton = el.closest('.leaflet-control-zoom-in');
|
||||
if (zoomInButton) {
|
||||
return 'zoomIn||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
const zoomOutButton = el.closest('.leaflet-control-zoom-out');
|
||||
if (zoomOutButton) {
|
||||
return 'zoomOut||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
}
|
||||
|
||||
return 'none||' + el.tagName + '|' + (el.className || '') + '|' + (el.textContent || '').trim();
|
||||
} catch (e) {
|
||||
return 'error||ERROR|' + e + '|';
|
||||
}
|
||||
}
|
||||
|
||||
function setHome(lat, lon, zoom) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
|
||||
map.setView([lat, lon], zoom);
|
||||
}
|
||||
|
||||
function setStations(stationsJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
|
||||
stationLayer.clearLayers();
|
||||
markersByCallsignRaw = {};
|
||||
|
||||
const stations = JSON.parse(stationsJson);
|
||||
jsLog('setStations count=' + stations.length);
|
||||
|
||||
stations.forEach(station => {
|
||||
const marker = L.marker(
|
||||
[station.latitudeDeg, station.longitudeDeg],
|
||||
{
|
||||
interactive: true,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'station-marker-wrapper',
|
||||
html: buildStationMarkerHtml(station),
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
marker.addTo(stationLayer);
|
||||
markersByCallsignRaw[station.callSignRaw] = marker;
|
||||
});
|
||||
}
|
||||
|
||||
function setBeam(beamJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
beamLayer.clearLayers();
|
||||
|
||||
if (!beamJson || beamJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(beamJson);
|
||||
if (!points || points.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setBeam points=' + points.length);
|
||||
|
||||
const latLngs = points.map(point => [point.lat, point.lon]);
|
||||
|
||||
L.polygon(latLngs, {
|
||||
pane: 'beamPane',
|
||||
color: '#ff4d4d',
|
||||
weight: 2,
|
||||
fillColor: '#ff4d4d',
|
||||
fillOpacity: 0.12,
|
||||
interactive: false
|
||||
}).addTo(beamLayer);
|
||||
}
|
||||
|
||||
function setConnection(connectionJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
connectionLayer.clearLayers();
|
||||
|
||||
if (!connectionJson || connectionJson === 'null') {
|
||||
return;
|
||||
}
|
||||
|
||||
const points = JSON.parse(connectionJson);
|
||||
if (!points || points.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('setConnection');
|
||||
|
||||
L.polyline(points.map(point => [point.lat, point.lon]), {
|
||||
pane: 'connectionPane',
|
||||
color: connectionColor(),
|
||||
weight: 2,
|
||||
dashArray: '6,6',
|
||||
opacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(connectionLayer);
|
||||
}
|
||||
|
||||
function setProfileHoverPoint(point) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (profileHoverMarker) {
|
||||
map.removeLayer(profileHoverMarker);
|
||||
profileHoverMarker = null;
|
||||
}
|
||||
|
||||
if (!point || !isFinite(point.lat) || !isFinite(point.lon)) {
|
||||
return;
|
||||
}
|
||||
|
||||
profileHoverMarker = L.circleMarker([point.lat, point.lon], {
|
||||
radius: 6,
|
||||
color: '#ffcc00',
|
||||
weight: 2,
|
||||
fillColor: '#ffcc00',
|
||||
fillOpacity: 0.85,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
|
||||
if (point.label) {
|
||||
profileHoverMarker.bindTooltip(point.label, {
|
||||
permanent: false,
|
||||
direction: 'top'
|
||||
}).openTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
function setGrid(gridJson) {
|
||||
if (!init()) {
|
||||
return;
|
||||
}
|
||||
gridLayer.clearLayers();
|
||||
|
||||
const cells = JSON.parse(gridJson);
|
||||
jsLog('setGrid cells=' + cells.length);
|
||||
|
||||
cells.forEach(cell => {
|
||||
const rectangle = L.rectangle(
|
||||
[
|
||||
[cell.southLat, cell.westLon],
|
||||
[cell.northLat, cell.eastLon]
|
||||
],
|
||||
{
|
||||
pane: 'gridPane',
|
||||
color: gridLineColor(),
|
||||
opacity: gridLineOpacity(),
|
||||
weight: 1.4,
|
||||
fillOpacity: 0.0,
|
||||
interactive: false
|
||||
}
|
||||
);
|
||||
|
||||
rectangle.addTo(gridLayer);
|
||||
|
||||
if (cell.showLabel) {
|
||||
const centerLat = (cell.southLat + cell.northLat) / 2.0;
|
||||
const centerLon = (cell.westLon + cell.eastLon) / 2.0;
|
||||
const labelFontPx = cell.labelFontPx || 12;
|
||||
|
||||
L.marker([centerLat, centerLon], {
|
||||
pane: 'gridLabelPane',
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({
|
||||
className: 'maidenhead-grid-label-wrapper',
|
||||
html: '<div class="maidenhead-grid-label" style="font-size:' + labelFontPx + 'px;">' + escapeHtml(cell.locatorLabel) + '</div>',
|
||||
iconSize: [1, 1],
|
||||
iconAnchor: [0, 0]
|
||||
})
|
||||
}).addTo(gridLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusCallsignRaw(callSignRaw) {
|
||||
if (!map || !callSignRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = markersByCallsignRaw[callSignRaw];
|
||||
if (!marker) {
|
||||
jsLog('focusCallsignRaw skipped for ' + callSignRaw);
|
||||
return;
|
||||
}
|
||||
|
||||
jsLog('focusCallsignRaw ' + callSignRaw);
|
||||
|
||||
map.panTo(marker.getLatLng(), {
|
||||
animate: false
|
||||
});
|
||||
|
||||
notifyViewport();
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
init: init,
|
||||
invalidateSize: invalidateSize,
|
||||
zoomIn: zoomIn,
|
||||
zoomOut: zoomOut,
|
||||
inspectPoint: inspectPoint,
|
||||
getViewportState: getViewportState,
|
||||
setHome: setHome,
|
||||
setStations: setStations,
|
||||
setBeam: setBeam,
|
||||
setConnection: setConnection,
|
||||
setProfileHoverPoint: setProfileHoverPoint,
|
||||
setGrid: setGrid,
|
||||
focusCallsignRaw: focusCallsignRaw,
|
||||
setTheme: setTheme
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__TILE_PROXY_PORT__", String.valueOf(tileProxyPort));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Placeholder terrain provider.
|
||||
*/
|
||||
public final class NoOpTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
return TerrainProfileData.empty("No terrain provider");
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Small helper service for preparing and filling the local offline DEM directory.
|
||||
*
|
||||
* <p>This is intentionally the first practical step before adding a real network
|
||||
* downloader:
|
||||
* <ul>
|
||||
* <li>create a known default Copernicus DEM root directory below .praktiKST</li>
|
||||
* <li>copy manually selected local *_DEM.tif files into that directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The active terrain provider already scans the configured root directory
|
||||
* recursively. Therefore it is sufficient to import valid Copernicus DGED
|
||||
* GeoTIFF files into one dedicated folder tree.</p>
|
||||
*/
|
||||
public final class OfflineDemImportService {
|
||||
|
||||
/**
|
||||
* Default relative DEM directory below the application's hidden home folder.
|
||||
*/
|
||||
public static final String DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY = "dem/copernicus_glo30";
|
||||
|
||||
/**
|
||||
* Resolves the default local Copernicus DEM root directory below .praktiKST.
|
||||
*
|
||||
* @return default DEM root directory path
|
||||
*/
|
||||
public Path resolveDefaultCopernicusRootDirectory() {
|
||||
return Path.of(
|
||||
ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the default local Copernicus DEM root directory exists.
|
||||
*
|
||||
* @return preparation result including the effective target directory
|
||||
*/
|
||||
public ImportResult ensureDefaultCopernicusRootDirectory() {
|
||||
Path targetRootDirectory = resolveDefaultCopernicusRootDirectory();
|
||||
return ensureTargetDirectoryExists(targetRootDirectory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports manually selected Copernicus DGED GeoTIFF tiles into the configured
|
||||
* DEM root directory.
|
||||
*
|
||||
* <p>If the configured DEM root directory is blank, the default directory
|
||||
* below .praktiKST is used automatically.</p>
|
||||
*
|
||||
* @param selectedFiles selected local files from the file chooser
|
||||
* @param configuredDemRootDirectory current configured DEM root directory text
|
||||
* @return import result with counts and a user-friendly summary message
|
||||
*/
|
||||
public ImportResult importTiles(List<File> selectedFiles, String configuredDemRootDirectory) {
|
||||
Path targetRootDirectory = resolveEffectiveTargetRootDirectory(configuredDemRootDirectory);
|
||||
|
||||
ImportResult directoryPreparationResult = ensureTargetDirectoryExists(targetRootDirectory);
|
||||
if (!directoryPreparationResult.success()) {
|
||||
return directoryPreparationResult;
|
||||
}
|
||||
|
||||
if (selectedFiles == null || selectedFiles.isEmpty()) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"No files were selected for import."
|
||||
);
|
||||
}
|
||||
|
||||
int importedFileCount = 0;
|
||||
int skippedFileCount = 0;
|
||||
List<String> skippedFilenames = new ArrayList<>();
|
||||
|
||||
for (File selectedFile : selectedFiles) {
|
||||
if (selectedFile == null || !selectedFile.isFile() || !selectedFile.canRead()) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile == null ? "<null>" : selectedFile.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(selectedFile.getName())) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile.getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.copy(
|
||||
selectedFile.toPath(),
|
||||
targetRootDirectory.resolve(selectedFile.getName()),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
);
|
||||
importedFileCount++;
|
||||
} catch (Exception exception) {
|
||||
skippedFileCount++;
|
||||
skippedFilenames.add(selectedFile.getName());
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"Imported %d DEM tile(s) into:%n%s",
|
||||
importedFileCount,
|
||||
targetRootDirectory.toAbsolutePath()
|
||||
));
|
||||
|
||||
if (skippedFileCount > 0) {
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"%n%nSkipped %d file(s) because they were unreadable or did not match the supported Copernicus *_DEM.tif naming scheme.",
|
||||
skippedFileCount
|
||||
));
|
||||
|
||||
int previewCount = Math.min(8, skippedFilenames.size());
|
||||
if (previewCount > 0) {
|
||||
message.append(String.format(Locale.US, "%n%nSkipped examples:%n"));
|
||||
for (int i = 0; i < previewCount; i++) {
|
||||
message.append("- ").append(skippedFilenames.get(i)).append(System.lineSeparator());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
importedFileCount,
|
||||
skippedFileCount,
|
||||
List.copyOf(skippedFilenames),
|
||||
message.toString().trim()
|
||||
);
|
||||
}
|
||||
|
||||
private Path resolveEffectiveTargetRootDirectory(String configuredDemRootDirectory) {
|
||||
if (configuredDemRootDirectory == null || configuredDemRootDirectory.isBlank()) {
|
||||
return resolveDefaultCopernicusRootDirectory();
|
||||
}
|
||||
|
||||
return Path.of(configuredDemRootDirectory.trim());
|
||||
}
|
||||
|
||||
private ImportResult ensureTargetDirectoryExists(Path targetRootDirectory) {
|
||||
if (targetRootDirectory == null) {
|
||||
return new ImportResult(
|
||||
null,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"DEM target directory is undefined."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Files.exists(targetRootDirectory) && !Files.isDirectory(targetRootDirectory)) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Configured DEM root path exists but is not a directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
);
|
||||
}
|
||||
|
||||
Files.createDirectories(targetRootDirectory);
|
||||
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Using local Copernicus DEM directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
+ "\n\nYou can now import extracted Copernicus *_DEM.tif tiles into this folder."
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new ImportResult(
|
||||
targetRootDirectory,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
List.of(),
|
||||
"Could not create DEM root directory:\n"
|
||||
+ targetRootDirectory.toAbsolutePath()
|
||||
+ "\n\nReason: "
|
||||
+ exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable result of a DEM directory preparation or tile import action.
|
||||
*
|
||||
* @param targetRootDirectory effective DEM root directory
|
||||
* @param success true if the action succeeded
|
||||
* @param importedFileCount number of imported files
|
||||
* @param skippedFileCount number of skipped files
|
||||
* @param skippedFilenames skipped filenames for diagnostics
|
||||
* @param message user-friendly summary message
|
||||
*/
|
||||
public record ImportResult(
|
||||
Path targetRootDirectory,
|
||||
boolean success,
|
||||
int importedFileCount,
|
||||
int skippedFileCount,
|
||||
List<String> skippedFilenames,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Small manager for offline DEM discovery and indexing.
|
||||
*
|
||||
* This class intentionally supports both intermediate API shapes that appeared
|
||||
* during development:
|
||||
*
|
||||
* - old style:
|
||||
* inspect(...) -> OfflineDemStatus
|
||||
*
|
||||
* - new style:
|
||||
* inspectAndIndex(...) -> OfflineDemIndex
|
||||
*
|
||||
* This keeps the current codebase compilable even if older helper/provider
|
||||
* classes are still present in the source tree.
|
||||
*/
|
||||
public final class OfflineDemManager {
|
||||
|
||||
/**
|
||||
* Matches official DGED DEM filenames like:
|
||||
* Copernicus_DSM_10_N50_00_E020_00_DEM.tif
|
||||
*/
|
||||
private static final Pattern COPERNICUS_GLO_30_DEM_FILE_PATTERN =
|
||||
Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
|
||||
|
||||
/**
|
||||
* Returns true if the filename matches the currently supported Copernicus
|
||||
* GLO-30 DGED GeoTIFF tile naming scheme.
|
||||
*
|
||||
* <p>This helper is intentionally public so that UI/import helpers can
|
||||
* validate manually selected files before copying them into the DEM root.</p>
|
||||
*
|
||||
* @param filename candidate filename
|
||||
* @return true if the filename looks like a supported local DEM tile
|
||||
*/
|
||||
public static boolean isSupportedCopernicusGlo30DemFilename(String filename) {
|
||||
return filename != null
|
||||
&& COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename).matches();
|
||||
}
|
||||
|
||||
private String lastIndexedRootDirectory = null;
|
||||
private DemDataset lastIndexedDataset = null;
|
||||
private OfflineDemIndex lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
||||
|
||||
/**
|
||||
* Newer API used by the active Copernicus provider.
|
||||
*/
|
||||
public synchronized OfflineDemIndex inspectAndIndex(String demRootDirectory, DemDataset dataset) {
|
||||
DemDataset effectiveDataset = dataset == null ? DemDataset.COPERNICUS_GLO_30 : dataset;
|
||||
|
||||
if (demRootDirectory == null || demRootDirectory.isBlank()) {
|
||||
lastIndexedRootDirectory = demRootDirectory;
|
||||
lastIndexedDataset = effectiveDataset;
|
||||
lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
String normalizedRootDirectory = demRootDirectory.trim();
|
||||
|
||||
if (normalizedRootDirectory.equals(lastIndexedRootDirectory) && effectiveDataset == lastIndexedDataset) {
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
lastIndexedRootDirectory = normalizedRootDirectory;
|
||||
lastIndexedDataset = effectiveDataset;
|
||||
lastIndex = buildIndex(normalizedRootDirectory, effectiveDataset);
|
||||
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Older compatibility API still referenced by older intermediate provider code.
|
||||
*/
|
||||
public synchronized OfflineDemStatus inspect(String demRootDirectory, DemDataset dataset) {
|
||||
OfflineDemIndex index = inspectAndIndex(demRootDirectory, dataset);
|
||||
|
||||
return new OfflineDemStatus(
|
||||
index.dataset(),
|
||||
index.rootDirectory(),
|
||||
demRootDirectory != null && !demRootDirectory.isBlank(),
|
||||
index.usable(),
|
||||
index.message()
|
||||
);
|
||||
}
|
||||
|
||||
private OfflineDemIndex buildIndex(String demRootDirectory, DemDataset dataset) {
|
||||
Path rootPath = Paths.get(demRootDirectory);
|
||||
|
||||
if (!Files.exists(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root directory does not exist.");
|
||||
}
|
||||
|
||||
if (!Files.isDirectory(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root path is not a directory.");
|
||||
}
|
||||
|
||||
if (!Files.isReadable(rootPath)) {
|
||||
return OfflineDemIndex.empty("Configured DEM root directory is not readable.");
|
||||
}
|
||||
|
||||
Map<String, Path> tilePathByGeocellKey = new LinkedHashMap<>();
|
||||
|
||||
try (var pathStream = Files.walk(rootPath)) {
|
||||
pathStream
|
||||
.filter(Files::isRegularFile)
|
||||
.forEach(path -> tryRegisterTile(path, tilePathByGeocellKey));
|
||||
} catch (IOException exception) {
|
||||
return OfflineDemIndex.empty("Scanning DEM root directory failed: " + exception.getMessage());
|
||||
}
|
||||
|
||||
if (tilePathByGeocellKey.isEmpty()) {
|
||||
return new OfflineDemIndex(
|
||||
dataset,
|
||||
rootPath.toAbsolutePath().toString(),
|
||||
false,
|
||||
Collections.emptyMap(),
|
||||
"No local Copernicus GLO-30 DEM tiles were found below the configured root directory."
|
||||
);
|
||||
}
|
||||
|
||||
return new OfflineDemIndex(
|
||||
dataset,
|
||||
rootPath.toAbsolutePath().toString(),
|
||||
true,
|
||||
Map.copyOf(tilePathByGeocellKey),
|
||||
"Found " + tilePathByGeocellKey.size() + " local Copernicus GLO-30 DEM tiles."
|
||||
);
|
||||
}
|
||||
|
||||
private void tryRegisterTile(Path path, Map<String, Path> tilePathByGeocellKey) {
|
||||
String filename = path.getFileName().toString();
|
||||
Matcher matcher = COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename);
|
||||
|
||||
if (!matcher.matches()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int southDeg = signedDegrees(matcher.group(1), matcher.group(2), matcher.group(3));
|
||||
int westDeg = signedDegrees(matcher.group(4), matcher.group(5), matcher.group(6));
|
||||
|
||||
tilePathByGeocellKey.put(toGeocellKey(southDeg, westDeg), path.toAbsolutePath());
|
||||
}
|
||||
|
||||
private int signedDegrees(String hemisphereOrDirection, String integerPart, String decimalPart) {
|
||||
int sign = ("S".equalsIgnoreCase(hemisphereOrDirection) || "W".equalsIgnoreCase(hemisphereOrDirection)) ? -1 : 1;
|
||||
int degrees = Integer.parseInt(integerPart);
|
||||
|
||||
if (!"00".equals(decimalPart)) {
|
||||
// The current reader expects whole-degree LL-corner geocells.
|
||||
return sign * degrees;
|
||||
}
|
||||
|
||||
return sign * degrees;
|
||||
}
|
||||
|
||||
private String toGeocellKey(int southDeg, int westDeg) {
|
||||
return southDeg + ":" + westDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Newer indexed offline DEM state used by the active Copernicus provider.
|
||||
*/
|
||||
public record OfflineDemIndex(
|
||||
DemDataset dataset,
|
||||
String rootDirectory,
|
||||
boolean usable,
|
||||
Map<String, Path> tilePathByGeocellKey,
|
||||
String message
|
||||
) {
|
||||
|
||||
public static OfflineDemIndex empty(String message) {
|
||||
return new OfflineDemIndex(
|
||||
DemDataset.COPERNICUS_GLO_30,
|
||||
"",
|
||||
false,
|
||||
Collections.emptyMap(),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
public Path findTilePath(double latitudeDeg, double longitudeDeg) {
|
||||
int southDeg = (int) Math.floor(latitudeDeg);
|
||||
int westDeg = (int) Math.floor(longitudeDeg);
|
||||
return tilePathByGeocellKey.get(southDeg + ":" + westDeg);
|
||||
}
|
||||
|
||||
public int availableTileCount() {
|
||||
return tilePathByGeocellKey.size();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Older compatibility status shape still referenced by older intermediate code.
|
||||
*/
|
||||
public record OfflineDemStatus(
|
||||
DemDataset dataset,
|
||||
String demRootDirectory,
|
||||
boolean configured,
|
||||
boolean usableRootDirectory,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Compatibility offline-first provider.
|
||||
*
|
||||
* This class is currently not part of the active StationMap pipeline anymore,
|
||||
* but it is kept compilable so the source tree stays consistent while the
|
||||
* Copernicus provider is the primary offline implementation.
|
||||
*/
|
||||
public final class OfflineDemTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private final Supplier<String> demRootDirectorySupplier;
|
||||
private final Supplier<DemDataset> datasetSupplier;
|
||||
private final OfflineDemManager offlineDemManager;
|
||||
|
||||
public OfflineDemTerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
||||
Supplier<DemDataset> datasetSupplier,
|
||||
OfflineDemManager offlineDemManager) {
|
||||
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
|
||||
this.datasetSupplier = Objects.requireNonNull(datasetSupplier, "datasetSupplier");
|
||||
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
DemDataset dataset = datasetSupplier.get();
|
||||
if (dataset == null) {
|
||||
dataset = DemDataset.COPERNICUS_GLO_30;
|
||||
}
|
||||
|
||||
OfflineDemManager.OfflineDemIndex index =
|
||||
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), dataset);
|
||||
|
||||
if (!index.usable()) {
|
||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM unavailable");
|
||||
}
|
||||
|
||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM indexed but not used by this compatibility provider");
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Terrain provider using the Open-Meteo Elevation API.
|
||||
*
|
||||
* <p>Important API constraint: Open-Meteo accepts up to 100 coordinate pairs per
|
||||
* elevation request. Earlier KST4Contest profile calculations could ask for up
|
||||
* to 1201 samples, which forced many HTTP requests for one visible profile. This
|
||||
* provider intentionally clamps the online profile to one API request. The
|
||||
* offline Copernicus path can later keep using the denser local sample count.</p>
|
||||
*
|
||||
* <p>The provider keeps a small in-memory cache for the running session. This is
|
||||
* deliberately local and lightweight: it avoids repeated API calls when the user
|
||||
* re-selects the same station, but it does not replace the existing long-term
|
||||
* offline DEM/download architecture.</p>
|
||||
*/
|
||||
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90";
|
||||
private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation";
|
||||
|
||||
/**
|
||||
* Open-Meteo elevation requests accept up to 100 coordinate pairs. Keeping the
|
||||
* online provider at exactly one request per path makes limit behavior
|
||||
* predictable and avoids request bursts while clicking through stations.
|
||||
*/
|
||||
private static final int MAX_COORDINATES_PER_REQUEST = 100;
|
||||
private static final int MAX_ONLINE_PROFILE_SAMPLE_COUNT = MAX_COORDINATES_PER_REQUEST;
|
||||
private static final int MIN_ONLINE_PROFILE_SAMPLE_COUNT = 2;
|
||||
|
||||
private static final int MAX_CACHED_PROFILES = 256;
|
||||
|
||||
/**
|
||||
* The free tier allows many more calls per minute, but this soft limiter keeps
|
||||
* accidental click/refresh bursts polite and easier to diagnose.
|
||||
*/
|
||||
private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250);
|
||||
|
||||
private static final Pattern ELEVATION_ARRAY_PATTERN =
|
||||
Pattern.compile("\\\"elevation\\\"\\s*:\\s*\\[(.*?)]", Pattern.DOTALL);
|
||||
|
||||
private static final Pattern ERROR_REASON_PATTERN =
|
||||
Pattern.compile("\\\"reason\\\"\\s*:\\s*\\\"(.*?)\\\"", Pattern.DOTALL);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final Duration requestTimeout;
|
||||
private final String baseUrl;
|
||||
|
||||
private final Map<RequestCacheKey, TerrainProfileData> profileCache =
|
||||
new LinkedHashMap<>(32, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<RequestCacheKey, TerrainProfileData> eldest) {
|
||||
return size() > MAX_CACHED_PROFILES;
|
||||
}
|
||||
};
|
||||
|
||||
private long lastRequestStartEpochMillis = 0L;
|
||||
|
||||
public OpenMeteoTerrainProfileProvider() {
|
||||
this(
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(4))
|
||||
.build(),
|
||||
Duration.ofSeconds(8),
|
||||
DEFAULT_BASE_URL
|
||||
);
|
||||
}
|
||||
|
||||
public OpenMeteoTerrainProfileProvider(HttpClient httpClient,
|
||||
Duration requestTimeout,
|
||||
String baseUrl) {
|
||||
this.httpClient = httpClient;
|
||||
this.requestTimeout = requestTimeout == null ? Duration.ofSeconds(8) : requestTimeout;
|
||||
this.baseUrl = (baseUrl == null || baseUrl.isBlank()) ? DEFAULT_BASE_URL : baseUrl.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME);
|
||||
}
|
||||
|
||||
int sampleCount = resolveOnlineSampleCount(request.requestedSampleCount());
|
||||
RequestCacheKey cacheKey = RequestCacheKey.from(request, sampleCount);
|
||||
|
||||
TerrainProfileData cachedProfile = loadFromCache(cacheKey);
|
||||
if (cachedProfile != null) {
|
||||
return cachedProfile;
|
||||
}
|
||||
|
||||
List<SamplePoint> samplePoints = buildSamplePoints(request, sampleCount);
|
||||
TerrainProfileData profileData = fetchProfile(samplePoints);
|
||||
|
||||
if (profileData.hasUsableProfile()) {
|
||||
saveToCache(cacheKey, profileData);
|
||||
}
|
||||
|
||||
return profileData;
|
||||
}
|
||||
|
||||
private int resolveOnlineSampleCount(int requestedSampleCount) {
|
||||
return Math.max(
|
||||
MIN_ONLINE_PROFILE_SAMPLE_COUNT,
|
||||
Math.min(requestedSampleCount, MAX_ONLINE_PROFILE_SAMPLE_COUNT)
|
||||
);
|
||||
}
|
||||
|
||||
private TerrainProfileData loadFromCache(RequestCacheKey cacheKey) {
|
||||
synchronized (profileCache) {
|
||||
return profileCache.get(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveToCache(RequestCacheKey cacheKey, TerrainProfileData profileData) {
|
||||
synchronized (profileCache) {
|
||||
profileCache.put(cacheKey, profileData);
|
||||
}
|
||||
}
|
||||
|
||||
private TerrainProfileData fetchProfile(List<SamplePoint> samplePoints) {
|
||||
try {
|
||||
List<Double> elevations = fetchElevations(samplePoints);
|
||||
|
||||
if (elevations.size() != samplePoints.size()) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " returned an incomplete elevation array");
|
||||
}
|
||||
|
||||
List<PathProfilePoint> profilePoints = new ArrayList<>(samplePoints.size());
|
||||
|
||||
for (int i = 0; i < samplePoints.size(); i++) {
|
||||
SamplePoint samplePoint = samplePoints.get(i);
|
||||
double elevationMeters = elevations.get(i);
|
||||
|
||||
if (!Double.isFinite(elevationMeters)) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " returned no-data elevation samples");
|
||||
}
|
||||
|
||||
profilePoints.add(new PathProfilePoint(
|
||||
samplePoint.distanceKm(),
|
||||
samplePoint.latitudeDeg(),
|
||||
samplePoint.longitudeDeg(),
|
||||
elevationMeters
|
||||
));
|
||||
}
|
||||
|
||||
return new TerrainProfileData(profilePoints, SOURCE_NAME, false);
|
||||
} catch (Exception exception) {
|
||||
return TerrainProfileData.empty(SOURCE_NAME + " failed: " + buildShortFailureMessage(exception));
|
||||
}
|
||||
}
|
||||
|
||||
private List<SamplePoint> buildSamplePoints(TerrainProfileRequest request, int sampleCount) {
|
||||
List<SamplePoint> points = new ArrayList<>(sampleCount);
|
||||
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
||||
|
||||
PathGeometryUtils.GeoPoint interpolatedPoint =
|
||||
PathGeometryUtils.interpolateGreatCirclePoint(
|
||||
request.fromLatitudeDeg(),
|
||||
request.fromLongitudeDeg(),
|
||||
request.toLatitudeDeg(),
|
||||
request.toLongitudeDeg(),
|
||||
t
|
||||
);
|
||||
|
||||
double latitudeDeg = interpolatedPoint.latitudeDeg();
|
||||
double longitudeDeg = interpolatedPoint.longitudeDeg();
|
||||
|
||||
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
points.add(new SamplePoint(
|
||||
request.totalDistanceKm() * t,
|
||||
latitudeDeg,
|
||||
longitudeDeg
|
||||
));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private List<Double> fetchElevations(List<SamplePoint> samplePoints) throws Exception {
|
||||
if (samplePoints == null || samplePoints.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
if (samplePoints.size() > MAX_COORDINATES_PER_REQUEST) {
|
||||
throw new IllegalArgumentException("Open-Meteo request would contain more than 100 coordinates");
|
||||
}
|
||||
|
||||
waitForRateLimitSlot();
|
||||
|
||||
String latitudeParameter = samplePoints.stream()
|
||||
.map(point -> formatCoordinate(point.latitudeDeg()))
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
String longitudeParameter = samplePoints.stream()
|
||||
.map(point -> formatCoordinate(point.longitudeDeg()))
|
||||
.collect(Collectors.joining(","));
|
||||
|
||||
String requestUrl = baseUrl
|
||||
+ "?latitude=" + latitudeParameter
|
||||
+ "&longitude=" + longitudeParameter;
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(requestUrl))
|
||||
.timeout(requestTimeout)
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "KST4Contest path-analysis")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
String reason = extractErrorReason(response.body());
|
||||
throw new IOException("HTTP " + response.statusCode() + (reason.isBlank() ? "" : " - " + reason));
|
||||
}
|
||||
|
||||
return parseElevationArray(response.body());
|
||||
}
|
||||
|
||||
private synchronized void waitForRateLimitSlot() throws InterruptedException {
|
||||
long now = System.currentTimeMillis();
|
||||
long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis);
|
||||
|
||||
if (waitMillis > 0L) {
|
||||
Thread.sleep(waitMillis);
|
||||
}
|
||||
|
||||
lastRequestStartEpochMillis = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
private List<Double> parseElevationArray(String responseBody) throws IOException {
|
||||
if (responseBody == null || responseBody.isBlank()) {
|
||||
throw new IOException("Empty response body");
|
||||
}
|
||||
|
||||
Matcher elevationArrayMatcher = ELEVATION_ARRAY_PATTERN.matcher(responseBody);
|
||||
if (!elevationArrayMatcher.find()) {
|
||||
String reason = extractErrorReason(responseBody);
|
||||
throw new IOException("No elevation array found" + (reason.isBlank() ? "" : ": " + reason));
|
||||
}
|
||||
|
||||
String arrayContent = elevationArrayMatcher.group(1).trim();
|
||||
if (arrayContent.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String[] parts = arrayContent.split("\\s*,\\s*");
|
||||
List<Double> elevations = new ArrayList<>(parts.length);
|
||||
|
||||
for (String part : parts) {
|
||||
String value = part.trim();
|
||||
|
||||
if (value.isBlank()
|
||||
|| "null".equalsIgnoreCase(value)
|
||||
|| "nan".equalsIgnoreCase(value)) {
|
||||
elevations.add(Double.NaN);
|
||||
} else {
|
||||
elevations.add(Double.parseDouble(value));
|
||||
}
|
||||
}
|
||||
|
||||
return elevations;
|
||||
}
|
||||
|
||||
private String extractErrorReason(String responseBody) {
|
||||
Matcher matcher = ERROR_REASON_PATTERN.matcher(responseBody == null ? "" : responseBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String buildShortFailureMessage(Exception exception) {
|
||||
if (exception == null) {
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
Throwable current = exception;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
|
||||
String message = current.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
message = exception.getMessage();
|
||||
}
|
||||
|
||||
return message == null || message.isBlank()
|
||||
? current.getClass().getSimpleName()
|
||||
: message;
|
||||
}
|
||||
|
||||
private String formatCoordinate(double value) {
|
||||
return String.format(Locale.US, "%.6f", value);
|
||||
}
|
||||
|
||||
private record SamplePoint(double distanceKm, double latitudeDeg, double longitudeDeg) {
|
||||
}
|
||||
|
||||
private record RequestCacheKey(
|
||||
String fromLatitudeDeg,
|
||||
String fromLongitudeDeg,
|
||||
String toLatitudeDeg,
|
||||
String toLongitudeDeg,
|
||||
int sampleCount
|
||||
) {
|
||||
private static RequestCacheKey from(TerrainProfileRequest request, int sampleCount) {
|
||||
return new RequestCacheKey(
|
||||
normalizeCoordinate(request.fromLatitudeDeg()),
|
||||
normalizeCoordinate(request.fromLongitudeDeg()),
|
||||
normalizeCoordinate(request.toLatitudeDeg()),
|
||||
normalizeCoordinate(request.toLongitudeDeg()),
|
||||
sampleCount
|
||||
);
|
||||
}
|
||||
|
||||
private static String normalizeCoordinate(double value) {
|
||||
return String.format(Locale.US, "%.6f", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable input for one path analysis run.
|
||||
*/
|
||||
public record PathAnalysisRequest(
|
||||
String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
String toCallsignRaw,
|
||||
String toLocator6,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double frequencyMHz,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathLinkBudgetSettings linkBudgetSettings
|
||||
) {
|
||||
|
||||
public PathAnalysisRequest(String fromLocator6,
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
String toCallsignRaw,
|
||||
String toLocator6,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double frequencyMHz,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters) {
|
||||
this(
|
||||
fromLocator6,
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toCallsignRaw,
|
||||
toLocator6,
|
||||
toLatitudeDeg,
|
||||
toLongitudeDeg,
|
||||
frequencyMHz,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
PathLinkBudgetSettings.defaults()
|
||||
);
|
||||
}
|
||||
|
||||
public PathAnalysisRequest {
|
||||
fromLocator6 = normalizeLocator(fromLocator6);
|
||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
||||
toLocator6 = normalizeLocator(toLocator6);
|
||||
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
frequencyMHz = Double.NaN;
|
||||
}
|
||||
|
||||
if (!Double.isFinite(homeAntennaHeightMeters) || homeAntennaHeightMeters < 0.0) {
|
||||
homeAntennaHeightMeters = Double.NaN;
|
||||
}
|
||||
|
||||
if (!Double.isFinite(targetAntennaHeightMeters) || targetAntennaHeightMeters < 0.0) {
|
||||
targetAntennaHeightMeters = Double.NaN;
|
||||
}
|
||||
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
|
||||
linkBudgetSettings = linkBudgetSettings == null
|
||||
? PathLinkBudgetSettings.defaults()
|
||||
: linkBudgetSettings;
|
||||
}
|
||||
|
||||
private static String normalizeLocator(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public boolean hasUsableHome() {
|
||||
return fromLocator6.length() == 6
|
||||
&& Double.isFinite(fromLatitudeDeg)
|
||||
&& Double.isFinite(fromLongitudeDeg);
|
||||
}
|
||||
|
||||
public boolean hasUsableTarget() {
|
||||
return toLocator6.length() == 6
|
||||
&& Double.isFinite(toLatitudeDeg)
|
||||
&& Double.isFinite(toLongitudeDeg);
|
||||
}
|
||||
|
||||
public boolean hasUsableFrequency() {
|
||||
return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,710 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable UI-facing result for one path analysis run.
|
||||
*
|
||||
* <p>This result intentionally combines:
|
||||
* <ul>
|
||||
* <li>basic path metadata used by the detail panel</li>
|
||||
* <li>the enriched path profile used by the preview chart</li>
|
||||
* <li>formatted state helpers for concise UI binding</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The chart and the numeric evaluation both consume the same already
|
||||
* computed profile data to avoid drift between visual and numeric output.</p>
|
||||
*/
|
||||
public record PathAnalysisResult(
|
||||
String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathHorizonSummary horizonSummary,
|
||||
PathObstructionSummary obstructionSummary,
|
||||
PathPropagationAssessment propagationAssessment,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints,
|
||||
PathLinkBudgetSummary linkBudgetSummary
|
||||
) {
|
||||
|
||||
public PathAnalysisResult {
|
||||
analysisMode = normalizeText(analysisMode);
|
||||
fromLocator6 = normalizeUpper(fromLocator6);
|
||||
toLocator6 = normalizeUpper(toLocator6);
|
||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
||||
statusText = normalizeText(statusText);
|
||||
profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints);
|
||||
effectiveEarthRadiusFactor =
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
|
||||
horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary;
|
||||
obstructionSummary = obstructionSummary == null ? PathObstructionSummary.empty() : obstructionSummary;
|
||||
|
||||
propagationAssessment = propagationAssessment == null
|
||||
? PathPropagationAssessment.unknown()
|
||||
: propagationAssessment;
|
||||
|
||||
linkBudgetSummary = linkBudgetSummary == null
|
||||
? PathLinkBudgetSummary.empty()
|
||||
: linkBudgetSummary;
|
||||
}
|
||||
|
||||
public PathAnalysisResult(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
this(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
PathHorizonSummary.empty(),
|
||||
PathObstructionSummary.empty(),
|
||||
PathPropagationAssessment.unknown(),
|
||||
statusText,
|
||||
profilePoints,
|
||||
PathLinkBudgetSummary.empty()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown while no target station is selected.
|
||||
*
|
||||
* @param fromLocator6 normalized own locator if known
|
||||
* @return waiting result
|
||||
*/
|
||||
public static PathAnalysisResult waitingForSelection(String fromLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
"",
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Select a station to prepare path and terrain analysis.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown while analysis is currently running.
|
||||
*
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator if known
|
||||
* @param toCallsignRaw target callsign if known
|
||||
* @return loading result
|
||||
*/
|
||||
public static PathAnalysisResult loading(String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw) {
|
||||
return new PathAnalysisResult(
|
||||
"Loading",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Loading terrain/profile data and evaluating the path.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown when the own locator is not usable.
|
||||
*
|
||||
* @param fromLocator6 own locator candidate
|
||||
* @param toLocator6 target locator candidate
|
||||
* @return waiting result with home-locator warning
|
||||
*/
|
||||
public static PathAnalysisResult waitingForValidHomeLocator(String fromLocator6,
|
||||
String toLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Own locator is missing or invalid. Path analysis cannot start yet.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a placeholder result shown when the selected target has no usable position.
|
||||
*
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator candidate
|
||||
* @return waiting result with target-position warning
|
||||
*/
|
||||
public static PathAnalysisResult waitingForValidTarget(String fromLocator6,
|
||||
String toLocator6) {
|
||||
return new PathAnalysisResult(
|
||||
"Waiting",
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
"",
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
"Selected station has no usable locator/position for path analysis.",
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a finished result without a usable terrain profile.
|
||||
*
|
||||
* @param analysisMode source/mode text
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator
|
||||
* @param toCallsignRaw target callsign
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param bearingDeg initial bearing in degrees
|
||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
||||
* @param statusText human-readable status text
|
||||
* @return finished result without profile points
|
||||
*/
|
||||
public static PathAnalysisResult noProfile(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
String statusText) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
false,
|
||||
false,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
statusText,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully evaluated result.
|
||||
*
|
||||
* @param analysisMode source/mode text
|
||||
* @param fromLocator6 own locator
|
||||
* @param toLocator6 target locator
|
||||
* @param toCallsignRaw target callsign
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param bearingDeg initial bearing in degrees
|
||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
||||
* @param lineOfSightClear true if the direct path is clear
|
||||
* @param fresnelClear true if the lower Fresnel hull is not intruded
|
||||
* @param minimumLineOfSightClearanceMeters minimum LOS clearance in meters
|
||||
* @param minimumLowerFresnelClearanceMeters minimum lower Fresnel clearance in meters
|
||||
* @param worstFresnelIntrusionMeters maximum lower Fresnel intrusion in meters
|
||||
* @param worstFresnelIntrusionRatio intrusion divided by local Fresnel radius
|
||||
* @param worstFresnelDistanceKm distance of the worst intrusion from TX in kilometers
|
||||
* @param worstFresnelSampleIndex sample index of the worst intrusion
|
||||
* @param statusText human-readable status text
|
||||
* @param profilePoints enriched profile points
|
||||
* @return completed path analysis result
|
||||
*/
|
||||
public static PathAnalysisResult completed(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
statusText,
|
||||
profilePoints
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully evaluated result including horizon/refraction metadata.
|
||||
*
|
||||
* @return completed path analysis result
|
||||
*/
|
||||
public static PathAnalysisResult completed(String analysisMode,
|
||||
String fromLocator6,
|
||||
String toLocator6,
|
||||
String toCallsignRaw,
|
||||
double distanceKm,
|
||||
double bearingDeg,
|
||||
double homeAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
double analysisFrequencyMHz,
|
||||
boolean lineOfSightClear,
|
||||
boolean fresnelClear,
|
||||
double minimumLineOfSightClearanceMeters,
|
||||
double minimumLowerFresnelClearanceMeters,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio,
|
||||
double worstFresnelDistanceKm,
|
||||
int worstFresnelSampleIndex,
|
||||
double effectiveEarthRadiusFactor,
|
||||
PathHorizonSummary horizonSummary,
|
||||
PathObstructionSummary obstructionSummary,
|
||||
PathLinkBudgetSummary linkBudgetSummary,
|
||||
PathPropagationAssessment propagationAssessment,
|
||||
String statusText,
|
||||
List<PathProfilePoint> profilePoints) {
|
||||
return new PathAnalysisResult(
|
||||
analysisMode,
|
||||
fromLocator6,
|
||||
toLocator6,
|
||||
toCallsignRaw,
|
||||
distanceKm,
|
||||
bearingDeg,
|
||||
homeAntennaHeightMeters,
|
||||
targetAntennaHeightMeters,
|
||||
analysisFrequencyMHz,
|
||||
lineOfSightClear,
|
||||
fresnelClear,
|
||||
minimumLineOfSightClearanceMeters,
|
||||
minimumLowerFresnelClearanceMeters,
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionRatio,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelSampleIndex,
|
||||
effectiveEarthRadiusFactor,
|
||||
horizonSummary,
|
||||
obstructionSummary,
|
||||
propagationAssessment,
|
||||
statusText,
|
||||
profilePoints,
|
||||
linkBudgetSummary = linkBudgetSummary == null
|
||||
? PathLinkBudgetSummary.empty()
|
||||
: linkBudgetSummary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the result contains a profile usable for charting/evaluation.
|
||||
*
|
||||
* @return true if at least two profile points are available
|
||||
*/
|
||||
public boolean hasUsableProfile() {
|
||||
return profilePoints.size() >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the dominant obstruction / diffraction candidate text.
|
||||
*
|
||||
* @return obstruction summary text
|
||||
*/
|
||||
public String obstructionText() {
|
||||
return obstructionSummary == null
|
||||
? "-"
|
||||
: obstructionSummary.obstructionText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for direct LOS state.
|
||||
*
|
||||
* @return LOS summary text
|
||||
*/
|
||||
public String losText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return lineOfSightClear
|
||||
? String.format(Locale.US, "Geometric LOS clear (min %+,.1f m)", minimumLineOfSightClearanceMeters)
|
||||
: String.format(Locale.US, "Geometric LOS blocked (min %+,.1f m)", minimumLineOfSightClearanceMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the minimum LOS clearance.
|
||||
*
|
||||
* @return worst direct-path clearance text
|
||||
*/
|
||||
public String worstClearanceText() {
|
||||
if (!hasUsableProfile() || !Double.isFinite(minimumLineOfSightClearanceMeters)) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.1f m", minimumLineOfSightClearanceMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the analysis frequency.
|
||||
*
|
||||
* @return frequency text or "-"
|
||||
*/
|
||||
public String analysisFrequencyText() {
|
||||
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.3f MHz", analysisFrequencyMHz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the Fresnel state.
|
||||
*
|
||||
* @return Fresnel summary text
|
||||
*/
|
||||
public String fresnelText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (fresnelClear) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"1st Fresnel clear (min %+,.1f m)",
|
||||
minimumLowerFresnelClearanceMeters
|
||||
);
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"1st Fresnel intruded (worst %.1f m)",
|
||||
worstFresnelIntrusionMeters
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a short UI text for the worst Fresnel intrusion.
|
||||
*
|
||||
* @return worst Fresnel intrusion text
|
||||
*/
|
||||
public String worstFresnelClearanceText() {
|
||||
if (!hasUsableProfile()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (Double.isFinite(worstFresnelIntrusionMeters) && worstFresnelIntrusionMeters > 0.0) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"%.1f m @ %.1f km (%.0f%%)",
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelDistanceKm,
|
||||
worstFresnelIntrusionRatio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
if (Double.isFinite(minimumLowerFresnelClearanceMeters)) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"No intrusion (min %.1f m)",
|
||||
minimumLowerFresnelClearanceMeters
|
||||
);
|
||||
}
|
||||
|
||||
return "-";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the effective Earth radius / refraction model text used by this
|
||||
* analysis result.
|
||||
*
|
||||
* @return k-factor text
|
||||
*/
|
||||
public String effectiveEarthRadiusText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.effectiveEarthRadiusText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a concise simple radio-horizon summary for both endpoint antenna
|
||||
* heights.
|
||||
*
|
||||
* @return radio-horizon summary
|
||||
*/
|
||||
public String radioHorizonText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.simpleRadioHorizonText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the actual terrain horizon derived from the current terrain profile.
|
||||
*
|
||||
* @return terrain-horizon summary
|
||||
*/
|
||||
public String terrainHorizonText() {
|
||||
return horizonSummary == null
|
||||
? "-"
|
||||
: horizonSummary.terrainHorizonText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the distance as formatted text.
|
||||
*
|
||||
* @return distance text or "-"
|
||||
*/
|
||||
public String distanceText() {
|
||||
if (!Double.isFinite(distanceKm) || distanceKm < 0.0) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.1f km", distanceKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the initial path bearing as formatted text.
|
||||
*
|
||||
* @return bearing text or "-"
|
||||
*/
|
||||
public String bearingText() {
|
||||
if (!Double.isFinite(bearingDeg)) {
|
||||
return "-";
|
||||
}
|
||||
return String.format(Locale.US, "%.0f°", bearingDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a concise endpoint summary with ground level and configured antenna heights.
|
||||
*
|
||||
* @return endpoint/antenna summary text
|
||||
*/
|
||||
public String endpointSummaryText() {
|
||||
PathProfilePoint firstPoint = firstProfilePoint();
|
||||
PathProfilePoint lastPoint = lastProfilePoint();
|
||||
|
||||
boolean hasHomeGround = firstPoint != null && Double.isFinite(firstPoint.elevationMeters());
|
||||
boolean hasTargetGround = lastPoint != null && Double.isFinite(lastPoint.elevationMeters());
|
||||
|
||||
boolean hasHomeAntenna = Double.isFinite(homeAntennaHeightMeters);
|
||||
boolean hasTargetAntenna = Double.isFinite(targetAntennaHeightMeters);
|
||||
|
||||
String homeText;
|
||||
if (hasHomeGround && hasHomeAntenna) {
|
||||
homeText = String.format(
|
||||
Locale.US,
|
||||
"Home %.0f m ASL + %.0f m AGL",
|
||||
firstPoint.elevationMeters(),
|
||||
homeAntennaHeightMeters
|
||||
);
|
||||
} else if (hasHomeAntenna) {
|
||||
homeText = String.format(Locale.US, "Home +%.0f m AGL", homeAntennaHeightMeters);
|
||||
} else {
|
||||
homeText = "Home -";
|
||||
}
|
||||
|
||||
String targetText;
|
||||
if (hasTargetGround && hasTargetAntenna) {
|
||||
targetText = String.format(
|
||||
Locale.US,
|
||||
"DX %.0f m ASL + %.0f m AGL",
|
||||
lastPoint.elevationMeters(),
|
||||
targetAntennaHeightMeters
|
||||
);
|
||||
} else if (hasTargetAntenna) {
|
||||
targetText = String.format(Locale.US, "DX +%.0f m AGL", targetAntennaHeightMeters);
|
||||
} else {
|
||||
targetText = "DX -";
|
||||
}
|
||||
|
||||
return homeText + " | " + targetText;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the operator-facing propagation assessment.
|
||||
*
|
||||
* @return short propagation assessment text
|
||||
*/
|
||||
public String propagationAssessmentText() {
|
||||
return propagationAssessment == null
|
||||
? "-"
|
||||
: propagationAssessment.shortText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the likely propagation mechanisms suggested by the current assessment.
|
||||
*
|
||||
* @return mechanism summary
|
||||
*/
|
||||
public String propagationMechanismsText() {
|
||||
return propagationAssessment == null
|
||||
? "-"
|
||||
: propagationAssessment.likelyMechanisms();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a numeric severity level from 0 to 5.
|
||||
*
|
||||
* @return severity level
|
||||
*/
|
||||
public int propagationSeverityLevel() {
|
||||
return propagationAssessment == null
|
||||
? 0
|
||||
: propagationAssessment.severityLevel();
|
||||
}
|
||||
|
||||
private PathProfilePoint firstProfilePoint() {
|
||||
return profilePoints.isEmpty() ? null : profilePoints.get(0);
|
||||
}
|
||||
|
||||
private PathProfilePoint lastProfilePoint() {
|
||||
return profilePoints.isEmpty() ? null : profilePoints.get(profilePoints.size() - 1);
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public String linkBudgetText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.ssbMarginText();
|
||||
}
|
||||
|
||||
public String linkBudgetRxPowerText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.rxPowerText();
|
||||
}
|
||||
|
||||
public String linkBudgetDetailText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.linkBudgetDetailText();
|
||||
}
|
||||
|
||||
public String cwHintText() {
|
||||
return linkBudgetSummary == null
|
||||
? "-"
|
||||
: linkBudgetSummary.cwHintText();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Abstraction for path analysis.
|
||||
*
|
||||
* First implementation can be geometry-only.
|
||||
* Later this can call terrain APIs, caching layers, LOS checks, etc.
|
||||
*/
|
||||
public interface PathAnalysisService {
|
||||
|
||||
PathAnalysisResult analyze(PathAnalysisRequest request);
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Shared helper methods for path geometry and radio-path calculations.
|
||||
*
|
||||
* <p>This class intentionally centralizes:
|
||||
* <ul>
|
||||
* <li>great-circle geometry</li>
|
||||
* <li>Earth curvature calculations</li>
|
||||
* <li>Fresnel calculations</li>
|
||||
* <li>adaptive profile sampling heuristics</li>
|
||||
* <li>default/fallback frequency handling</li>
|
||||
* <li>tolerant frequency parsing from UI/chat strings</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PathGeometryUtils {
|
||||
|
||||
private static final double EARTH_RADIUS_METERS = 6_371_009.0;
|
||||
private static final double EARTH_RADIUS_KM = EARTH_RADIUS_METERS / 1000.0;
|
||||
private static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
||||
|
||||
/**
|
||||
* Default effective Earth radius factor used for VHF/UHF path geometry.
|
||||
*
|
||||
* <p>k = 4/3 is the common standard-atmosphere approximation. It bends the
|
||||
* radio path slightly with the atmosphere and therefore reduces the apparent
|
||||
* Earth bulge compared with pure optical geometry.</p>
|
||||
*
|
||||
* <p>This is still only a geometric/refraction approximation. It does not model
|
||||
* troposcatter, aircraft scatter, ducting or diffraction loss numerically.</p>
|
||||
*/
|
||||
public static final double DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR = 4.0 / 3.0;
|
||||
|
||||
/**
|
||||
* Central fallback frequency for path analysis when no station frequency
|
||||
* could be extracted from the chat member / aggregated marker data.
|
||||
*
|
||||
* Easy to change later if users mainly work on another band.
|
||||
*/
|
||||
public static final double DEFAULT_ANALYSIS_FREQUENCY_MHZ = 144.0;
|
||||
|
||||
/**
|
||||
* Common practical recommendation: at least 60% of the first Fresnel zone
|
||||
* should remain clear.
|
||||
*/
|
||||
public static final double DEFAULT_FRESNEL_CLEARANCE_FACTOR = 0.60;
|
||||
|
||||
/**
|
||||
* Current sampling heuristic for terrain/profile analysis.
|
||||
*
|
||||
* <p>The active goal is to sample approximately every 0.5 km while
|
||||
* keeping the UI responsive. A later batch/service variant can use
|
||||
* different limits.</p>
|
||||
*/
|
||||
public static final double DEFAULT_TARGET_SAMPLE_STEP_KM = 0.5;
|
||||
public static final int MIN_PROFILE_SAMPLE_COUNT = 121;
|
||||
public static final int MAX_PROFILE_SAMPLE_COUNT = 1201;
|
||||
|
||||
private static final Pattern FREQUENCY_MHZ_PATTERN =
|
||||
Pattern.compile("(?i)(\\d{2,6}(?:[\\.,]\\d+)?)\\s*(?:mhz)?");
|
||||
|
||||
private PathGeometryUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in kilometers using the haversine formula.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return great-circle distance in kilometers
|
||||
*/
|
||||
public static double calculateGreatCircleDistanceKm(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double deltaLatitude = toLatitudeRad - fromLatitudeRad;
|
||||
double deltaLongitude = toLongitudeRad - fromLongitudeRad;
|
||||
|
||||
double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0)
|
||||
+ Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad)
|
||||
* Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0);
|
||||
|
||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a)));
|
||||
|
||||
return EARTH_RADIUS_KM * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial great-circle bearing in degrees from north.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return initial bearing in degrees within [0, 360)
|
||||
*/
|
||||
public static double calculateInitialBearingDeg(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double deltaLongitudeRad = Math.toRadians(toLongitudeDeg - fromLongitudeDeg);
|
||||
|
||||
double y = Math.sin(deltaLongitudeRad) * Math.cos(toLatitudeRad);
|
||||
double x = Math.cos(fromLatitudeRad) * Math.sin(toLatitudeRad)
|
||||
- Math.sin(fromLatitudeRad) * Math.cos(toLatitudeRad) * Math.cos(deltaLongitudeRad);
|
||||
|
||||
double bearingDeg = Math.toDegrees(Math.atan2(y, x));
|
||||
|
||||
return normalizeBearingDeg(bearingDeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates one point on the great-circle path between two endpoints.
|
||||
*
|
||||
* <p>This uses spherical linear interpolation (slerp) on the unit sphere.
|
||||
* It avoids the path distortion that appears when latitude and longitude
|
||||
* are interpolated independently.</p>
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @param t interpolation factor in [0, 1]
|
||||
* @return interpolated great-circle point
|
||||
*/
|
||||
public static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double t) {
|
||||
|
||||
if (!Double.isFinite(fromLatitudeDeg)
|
||||
|| !Double.isFinite(fromLongitudeDeg)
|
||||
|| !Double.isFinite(toLatitudeDeg)
|
||||
|| !Double.isFinite(toLongitudeDeg)
|
||||
|| !Double.isFinite(t)) {
|
||||
return new GeoPoint(Double.NaN, Double.NaN);
|
||||
}
|
||||
|
||||
double clampedT = clamp(t, 0.0, 1.0);
|
||||
|
||||
if (clampedT <= 0.0) {
|
||||
return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg));
|
||||
}
|
||||
|
||||
if (clampedT >= 1.0) {
|
||||
return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg));
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad);
|
||||
double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad);
|
||||
double z1 = Math.sin(fromLatitudeRad);
|
||||
|
||||
double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad);
|
||||
double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad);
|
||||
double z2 = Math.sin(toLatitudeRad);
|
||||
|
||||
double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0);
|
||||
double omega = Math.acos(dot);
|
||||
|
||||
if (omega < 1e-12) {
|
||||
double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT;
|
||||
double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT);
|
||||
return new GeoPoint(latitudeDeg, longitudeDeg);
|
||||
}
|
||||
|
||||
double sinOmega = Math.sin(omega);
|
||||
double a = Math.sin((1.0 - clampedT) * omega) / sinOmega;
|
||||
double b = Math.sin(clampedT * omega) / sinOmega;
|
||||
|
||||
double x = a * x1 + b * x2;
|
||||
double y = a * y1 + b * y2;
|
||||
double z = a * z1 + b * z2;
|
||||
|
||||
double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y));
|
||||
double longitudeRad = Math.atan2(y, x);
|
||||
|
||||
return new GeoPoint(
|
||||
Math.toDegrees(latitudeRad),
|
||||
normalizeLongitudeDeg(Math.toDegrees(longitudeRad))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an adaptive terrain/profile sample count from the full path distance.
|
||||
*
|
||||
* <p>Current heuristic:
|
||||
* <ul>
|
||||
* <li>target spacing about 0.5 km</li>
|
||||
* <li>minimum 121 samples</li>
|
||||
* <li>maximum 1201 samples</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @return clamped sample count
|
||||
*/
|
||||
public static int resolveAdaptiveSampleCount(double totalDistanceKm) {
|
||||
if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) {
|
||||
return MIN_PROFILE_SAMPLE_COUNT;
|
||||
}
|
||||
|
||||
int computedSampleCount = (int) Math.ceil(totalDistanceKm / DEFAULT_TARGET_SAMPLE_STEP_KM) + 1;
|
||||
|
||||
return clampInt(computedSampleCount, MIN_PROFILE_SAMPLE_COUNT, MAX_PROFILE_SAMPLE_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Earth bulge above the straight endpoint chord at one point
|
||||
* along the path.
|
||||
*
|
||||
* <p>Distances are given along the path in kilometers.</p>
|
||||
*
|
||||
* <p>Approximation:
|
||||
* bulge = d1 * d2 / (2 * R_eff)</p>
|
||||
*/
|
||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm) {
|
||||
return calculateEarthBulgeMeters(
|
||||
distanceFromStartKm,
|
||||
totalDistanceKm,
|
||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
||||
);
|
||||
}
|
||||
|
||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
|
||||
if (!Double.isFinite(distanceFromStartKm)
|
||||
|| !Double.isFinite(totalDistanceKm)
|
||||
|| !Double.isFinite(effectiveEarthRadiusFactor)
|
||||
|| totalDistanceKm <= 0.0
|
||||
|| effectiveEarthRadiusFactor <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
||||
|
||||
double distanceFromStartMeters = clampedDistanceFromStartKm * 1000.0;
|
||||
double distanceToTargetMeters = distanceToTargetKm * 1000.0;
|
||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * effectiveEarthRadiusFactor;
|
||||
|
||||
return (distanceFromStartMeters * distanceToTargetMeters) / (2.0 * effectiveEarthRadiusMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns terrain height plus Earth curvature using the default effective Earth
|
||||
* radius factor.
|
||||
*
|
||||
* @param point terrain/profile point
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @return curvature-adjusted terrain height in meters
|
||||
*/
|
||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
||||
double totalDistanceKm) {
|
||||
return calculateCurvatureAdjustedElevationMeters(
|
||||
point,
|
||||
totalDistanceKm,
|
||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns terrain height plus Earth curvature using the given effective Earth
|
||||
* radius factor.
|
||||
*
|
||||
* <p>k = 1.0 means optical/geometric Earth curvature. k = 4/3 is the common
|
||||
* standard radio-refraction approximation for VHF/UHF path previews.</p>
|
||||
*
|
||||
* @param point terrain/profile point
|
||||
* @param totalDistanceKm full path distance in kilometers
|
||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
||||
* @return curvature-adjusted terrain height in meters
|
||||
*/
|
||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
||||
double totalDistanceKm,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
if (point == null || !Double.isFinite(point.elevationMeters())) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return point.elevationMeters()
|
||||
+ calculateEarthBulgeMeters(
|
||||
point.distanceKm(),
|
||||
totalDistanceKm,
|
||||
sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the simple radio horizon distance from antenna height above ground.
|
||||
*
|
||||
* <p>The result is based on the effective Earth radius model:
|
||||
* d = sqrt(2 * R_eff * h)</p>
|
||||
*
|
||||
* <p>This is a local tangent-horizon approximation. It is useful as an operator
|
||||
* hint, but it is not a complete propagation prediction.</p>
|
||||
*
|
||||
* @param antennaHeightMeters antenna height above local ground in meters
|
||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
||||
* @return radio horizon distance in kilometers
|
||||
*/
|
||||
public static double calculateRadioHorizonDistanceKm(double antennaHeightMeters,
|
||||
double effectiveEarthRadiusFactor) {
|
||||
if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double sanitizedFactor = sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * sanitizedFactor;
|
||||
|
||||
return Math.sqrt(2.0 * effectiveEarthRadiusMeters * antennaHeightMeters) / 1000.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the apparent elevation angle from an observer height to a target
|
||||
* height over a given distance.
|
||||
*
|
||||
* @param observerHeightMeters observer height in the already curvature-adjusted profile space
|
||||
* @param targetHeightMeters target height in the same profile space
|
||||
* @param distanceKm distance between observer and target in kilometers
|
||||
* @return elevation angle in degrees
|
||||
*/
|
||||
public static double calculateElevationAngleDeg(double observerHeightMeters,
|
||||
double targetHeightMeters,
|
||||
double distanceKm) {
|
||||
if (!Double.isFinite(observerHeightMeters)
|
||||
|| !Double.isFinite(targetHeightMeters)
|
||||
|| !Double.isFinite(distanceKm)
|
||||
|| distanceKm <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double distanceMeters = distanceKm * 1000.0;
|
||||
return Math.toDegrees(Math.atan2(targetHeightMeters - observerHeightMeters, distanceMeters));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sanitizes the effective Earth radius factor.
|
||||
*
|
||||
* <p>Values outside a practical range are folded back to the standard default
|
||||
* so broken preferences or malformed future XML values cannot destabilize path
|
||||
* analysis.</p>
|
||||
*
|
||||
* @param effectiveEarthRadiusFactor raw k-factor
|
||||
* @return usable k-factor
|
||||
*/
|
||||
public static double sanitizeEffectiveEarthRadiusFactor(double effectiveEarthRadiusFactor) {
|
||||
if (!Double.isFinite(effectiveEarthRadiusFactor)
|
||||
|| effectiveEarthRadiusFactor < 0.5
|
||||
|| effectiveEarthRadiusFactor > 10.0) {
|
||||
return DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR;
|
||||
}
|
||||
|
||||
return effectiveEarthRadiusFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a frequency in MHz to wavelength in meters.
|
||||
*
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return wavelength in meters
|
||||
*/
|
||||
public static double calculateWavelengthMeters(double frequencyMHz) {
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / (frequencyMHz * 1_000_000.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* First Fresnel zone radius at one point along the path.
|
||||
*
|
||||
* @param distanceFromStartKm distance from TX in kilometers
|
||||
* @param totalDistanceKm total path distance in kilometers
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return first Fresnel radius in meters
|
||||
*/
|
||||
public static double calculateFirstFresnelRadiusMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double frequencyMHz) {
|
||||
|
||||
if (!Double.isFinite(distanceFromStartKm)
|
||||
|| !Double.isFinite(totalDistanceKm)
|
||||
|| !Double.isFinite(frequencyMHz)
|
||||
|| totalDistanceKm <= 0.0
|
||||
|| frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
||||
|
||||
double d1Meters = clampedDistanceFromStartKm * 1000.0;
|
||||
double d2Meters = distanceToTargetKm * 1000.0;
|
||||
|
||||
if (d1Meters <= 0.0 || d2Meters <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return Math.sqrt((wavelengthMeters * d1Meters * d2Meters) / (d1Meters + d2Meters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended minimum clearance, currently 60% of the first Fresnel zone.
|
||||
*
|
||||
* @param distanceFromStartKm distance from TX in kilometers
|
||||
* @param totalDistanceKm total path distance in kilometers
|
||||
* @param frequencyMHz signal frequency in MHz
|
||||
* @return recommended clearance in meters
|
||||
*/
|
||||
public static double calculateRecommendedFresnelClearanceMeters(double distanceFromStartKm,
|
||||
double totalDistanceKm,
|
||||
double frequencyMHz) {
|
||||
|
||||
double firstFresnelRadiusMeters =
|
||||
calculateFirstFresnelRadiusMeters(distanceFromStartKm, totalDistanceKm, frequencyMHz);
|
||||
|
||||
if (!Double.isFinite(firstFresnelRadiusMeters)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return firstFresnelRadiusMeters * DEFAULT_FRESNEL_CLEARANCE_FACTOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to extract a MHz frequency from strings such as:
|
||||
* <ul>
|
||||
* <li>"144.300"</li>
|
||||
* <li>"144,300"</li>
|
||||
* <li>"144.300 MHz"</li>
|
||||
* <li>"QRG 432.174 MHz"</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param rawText free-form raw text
|
||||
* @return parsed frequency in MHz or NaN
|
||||
*/
|
||||
public static double tryParseFrequencyMHz(String rawText) {
|
||||
if (rawText == null || rawText.isBlank()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
Matcher matcher = FREQUENCY_MHZ_PATTERN.matcher(rawText.trim());
|
||||
if (!matcher.find()) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
String numericText = matcher.group(1).replace(',', '.');
|
||||
|
||||
try {
|
||||
double parsedFrequencyMHz = Double.parseDouble(numericText);
|
||||
return parsedFrequencyMHz > 0.0 ? parsedFrequencyMHz : Double.NaN;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return Double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one usable analysis frequency from aggregated marker frequency data.
|
||||
*
|
||||
* <p>Strategy:
|
||||
* <ol>
|
||||
* <li>Prefer 144 MHz data if available</li>
|
||||
* <li>Otherwise use the first parsable known station frequency</li>
|
||||
* <li>Otherwise use the central default frequency</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param frequenciesByBand known frequencies grouped by band
|
||||
* @return resolved analysis frequency in MHz
|
||||
*/
|
||||
public static double resolveAnalysisFrequencyMHz(Map<String, String> frequenciesByBand) {
|
||||
if (frequenciesByBand != null && !frequenciesByBand.isEmpty()) {
|
||||
String band144Text = frequenciesByBand.get("144");
|
||||
double parsed144 = tryParseFrequencyMHz(band144Text);
|
||||
if (Double.isFinite(parsed144) && parsed144 > 0.0) {
|
||||
return parsed144;
|
||||
}
|
||||
|
||||
for (String value : frequenciesByBand.values()) {
|
||||
double parsedFrequencyMHz = tryParseFrequencyMHz(value);
|
||||
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
|
||||
return parsedFrequencyMHz;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_ANALYSIS_FREQUENCY_MHZ;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small immutable geographic point used by great-circle interpolation.
|
||||
*
|
||||
* @param latitudeDeg latitude in degrees
|
||||
* @param longitudeDeg longitude in degrees
|
||||
*/
|
||||
public record GeoPoint(double latitudeDeg, double longitudeDeg) {
|
||||
}
|
||||
|
||||
private static double normalizeLongitudeDeg(double longitudeDeg) {
|
||||
if (!Double.isFinite(longitudeDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double normalized = longitudeDeg % 360.0;
|
||||
if (normalized > 180.0) {
|
||||
normalized -= 360.0;
|
||||
} else if (normalized <= -180.0) {
|
||||
normalized += 360.0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static double normalizeBearingDeg(double bearingDeg) {
|
||||
if (!Double.isFinite(bearingDeg)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double normalized = bearingDeg % 360.0;
|
||||
if (normalized < 0.0) {
|
||||
normalized += 360.0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minValue, double maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
private static int clampInt(int value, int minValue, int maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Calculates the Fresnel-Kirchhoff diffraction parameter v for a single
|
||||
* obstruction.
|
||||
*
|
||||
* <p>Positive height means the obstruction is above the direct line of sight.
|
||||
* This method is useful for a rough single-knife-edge severity estimate.</p>
|
||||
*
|
||||
* @param heightAboveLosMeters obstruction height above direct LOS in meters
|
||||
* @param distanceFromHomeKm distance from home endpoint to obstruction
|
||||
* @param distanceFromTargetKm distance from target endpoint to obstruction
|
||||
* @param frequencyMHz analysis frequency in MHz
|
||||
* @return diffraction parameter v
|
||||
*/
|
||||
public static double calculateKnifeEdgeVParameter(double heightAboveLosMeters,
|
||||
double distanceFromHomeKm,
|
||||
double distanceFromTargetKm,
|
||||
double frequencyMHz) {
|
||||
if (!Double.isFinite(heightAboveLosMeters)
|
||||
|| !Double.isFinite(distanceFromHomeKm)
|
||||
|| !Double.isFinite(distanceFromTargetKm)
|
||||
|| distanceFromHomeKm <= 0.0
|
||||
|| distanceFromTargetKm <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
double d1Meters = distanceFromHomeKm * 1000.0;
|
||||
double d2Meters = distanceFromTargetKm * 1000.0;
|
||||
|
||||
return heightAboveLosMeters
|
||||
* Math.sqrt(2.0 * (d1Meters + d2Meters) / (wavelengthMeters * d1Meters * d2Meters));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the additional diffraction loss for a single knife edge.
|
||||
*
|
||||
* <p>This is a rough operator-facing indicator. It must not be interpreted as a
|
||||
* complete path-loss model.</p>
|
||||
*
|
||||
* @param v Fresnel-Kirchhoff diffraction parameter
|
||||
* @return estimated additional loss in dB
|
||||
*/
|
||||
public static double calculateSingleKnifeEdgeLossDb(double v) {
|
||||
if (!Double.isFinite(v)) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
if (v <= -0.78) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return 6.9 + 20.0 * Math.log10(
|
||||
Math.sqrt(Math.pow(v - 0.1, 2.0) + 1.0) + v - 0.1
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts RF power from watts to dBm.
|
||||
*
|
||||
* @param watts power in watts
|
||||
* @return power in dBm
|
||||
*/
|
||||
public static double wattsToDbm(double watts) {
|
||||
if (!Double.isFinite(watts) || watts <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return 10.0 * Math.log10(watts * 1000.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates free-space path loss.
|
||||
*
|
||||
* @param distanceKm path distance in kilometers
|
||||
* @param frequencyMHz frequency in MHz
|
||||
* @return free-space path loss in dB
|
||||
*/
|
||||
public static double calculateFreeSpacePathLossDb(double distanceKm, double frequencyMHz) {
|
||||
if (!Double.isFinite(distanceKm)
|
||||
|| !Double.isFinite(frequencyMHz)
|
||||
|| distanceKm <= 0.0
|
||||
|| frequencyMHz <= 0.0) {
|
||||
return Double.NaN;
|
||||
}
|
||||
|
||||
return 32.44 + 20.0 * Math.log10(distanceKm) + 20.0 * Math.log10(frequencyMHz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates per-station feeder loss from a simple VHF baseline and a frequency
|
||||
* dependent increase.
|
||||
*
|
||||
* <p>The result is capped because a linear MHz-based feeder heuristic becomes
|
||||
* unrealistic on microwave bands where transverters are often placed near the
|
||||
* antenna. Later this should become a per-band user setting.</p>
|
||||
*
|
||||
* @param frequencyMHz frequency in MHz
|
||||
* @param settings link-budget settings
|
||||
* @return estimated per-station feeder loss in dB
|
||||
*/
|
||||
public static double estimateFeederLossPerStationDb(double frequencyMHz,
|
||||
PathLinkBudgetSettings settings) {
|
||||
PathLinkBudgetSettings safeSettings = settings == null
|
||||
? PathLinkBudgetSettings.defaults()
|
||||
: settings;
|
||||
|
||||
double baseLossDb = safeSettings.vhfFeederLossPerStationDb();
|
||||
|
||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
||||
return baseLossDb;
|
||||
}
|
||||
|
||||
double additionalLossDb = Math.max(0.0, (frequencyMHz - 144.0) / 200.0)
|
||||
* safeSettings.feederLossIncreaseDbPer200MHz();
|
||||
|
||||
double estimatedLossDb = baseLossDb + additionalLossDb;
|
||||
|
||||
return Math.min(estimatedLossDb, safeSettings.maxEstimatedFeederLossPerStationDb());
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable summary of simple radio-horizon and terrain-profile horizon data.
|
||||
*
|
||||
* <p>The simple radio horizon is based only on antenna height and effective
|
||||
* Earth radius. The terrain horizon is derived from the actual path profile and
|
||||
* therefore represents the highest apparent terrain angle from each endpoint.</p>
|
||||
*/
|
||||
public record PathHorizonSummary(
|
||||
double effectiveEarthRadiusFactor,
|
||||
|
||||
double homeSimpleRadioHorizonKm,
|
||||
double targetSimpleRadioHorizonKm,
|
||||
double combinedSimpleRadioHorizonKm,
|
||||
|
||||
double homeTerrainHorizonPathDistanceKm,
|
||||
double homeTerrainHorizonElevationAngleDeg,
|
||||
int homeTerrainHorizonSampleIndex,
|
||||
|
||||
double targetTerrainHorizonPathDistanceKm,
|
||||
double targetTerrainHorizonDistanceFromTargetKm,
|
||||
double targetTerrainHorizonElevationAngleDeg,
|
||||
int targetTerrainHorizonSampleIndex
|
||||
) {
|
||||
|
||||
public PathHorizonSummary {
|
||||
effectiveEarthRadiusFactor =
|
||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
||||
}
|
||||
|
||||
public static PathHorizonSummary empty() {
|
||||
return new PathHorizonSummary(
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasHomeTerrainHorizon() {
|
||||
return homeTerrainHorizonSampleIndex >= 0
|
||||
&& Double.isFinite(homeTerrainHorizonPathDistanceKm)
|
||||
&& Double.isFinite(homeTerrainHorizonElevationAngleDeg);
|
||||
}
|
||||
|
||||
public boolean hasTargetTerrainHorizon() {
|
||||
return targetTerrainHorizonSampleIndex >= 0
|
||||
&& Double.isFinite(targetTerrainHorizonPathDistanceKm)
|
||||
&& Double.isFinite(targetTerrainHorizonDistanceFromTargetKm)
|
||||
&& Double.isFinite(targetTerrainHorizonElevationAngleDeg);
|
||||
}
|
||||
|
||||
public String effectiveEarthRadiusText() {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"k = %.2f effective Earth radius",
|
||||
effectiveEarthRadiusFactor
|
||||
);
|
||||
}
|
||||
|
||||
public String simpleRadioHorizonText() {
|
||||
if (!Double.isFinite(homeSimpleRadioHorizonKm)
|
||||
|| !Double.isFinite(targetSimpleRadioHorizonKm)
|
||||
|| !Double.isFinite(combinedSimpleRadioHorizonKm)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"Home %.1f km | DX %.1f km | combined %.1f km",
|
||||
homeSimpleRadioHorizonKm,
|
||||
targetSimpleRadioHorizonKm,
|
||||
combinedSimpleRadioHorizonKm
|
||||
);
|
||||
}
|
||||
|
||||
public String terrainHorizonText() {
|
||||
String homeText = hasHomeTerrainHorizon()
|
||||
? String.format(
|
||||
Locale.US,
|
||||
"Home %.1f km / %+.2f°",
|
||||
homeTerrainHorizonPathDistanceKm,
|
||||
homeTerrainHorizonElevationAngleDeg
|
||||
)
|
||||
: "Home -";
|
||||
|
||||
String targetText = hasTargetTerrainHorizon()
|
||||
? String.format(
|
||||
Locale.US,
|
||||
"DX %.1f km from DX / %+.2f°",
|
||||
targetTerrainHorizonDistanceFromTargetKm,
|
||||
targetTerrainHorizonElevationAngleDeg
|
||||
)
|
||||
: "DX -";
|
||||
|
||||
return homeText + " | " + targetText;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* User/configuration values for the path link-budget estimate.
|
||||
*
|
||||
* <p>All antenna gains are dBi. If the user knows dBd values, add 2.15 dB
|
||||
* before entering them as dBi. Example: 12 dBd = 14.15 dBi.</p>
|
||||
*/
|
||||
public record PathLinkBudgetSettings(
|
||||
double ownTxPowerWatts,
|
||||
double ownAntennaGainDbi,
|
||||
double targetTxPowerWatts,
|
||||
double targetAntennaGainDbi,
|
||||
double vhfFeederLossPerStationDb,
|
||||
double feederLossIncreaseDbPer200MHz,
|
||||
double maxEstimatedFeederLossPerStationDb,
|
||||
double requiredSsbSignalDbm,
|
||||
double requiredCwSignalDbm,
|
||||
double contestMarginDb
|
||||
) {
|
||||
|
||||
public static PathLinkBudgetSettings defaults() {
|
||||
return new PathLinkBudgetSettings(
|
||||
750.0,
|
||||
8.0,
|
||||
100.0,
|
||||
8.0,
|
||||
2.0,
|
||||
2.0,
|
||||
20.0,
|
||||
-126.0,
|
||||
-132.0,
|
||||
6.0
|
||||
);
|
||||
}
|
||||
|
||||
public PathLinkBudgetSettings {
|
||||
ownTxPowerWatts = sanitizePositive(ownTxPowerWatts, 750.0);
|
||||
ownAntennaGainDbi = sanitizeFinite(ownAntennaGainDbi, 8.0);
|
||||
|
||||
targetTxPowerWatts = sanitizePositive(targetTxPowerWatts, 100.0);
|
||||
targetAntennaGainDbi = sanitizeFinite(targetAntennaGainDbi, 8.0);
|
||||
|
||||
vhfFeederLossPerStationDb = sanitizeNonNegative(vhfFeederLossPerStationDb, 2.0);
|
||||
feederLossIncreaseDbPer200MHz = sanitizeNonNegative(feederLossIncreaseDbPer200MHz, 2.0);
|
||||
maxEstimatedFeederLossPerStationDb = sanitizeNonNegative(maxEstimatedFeederLossPerStationDb, 20.0);
|
||||
|
||||
requiredSsbSignalDbm = sanitizeFinite(requiredSsbSignalDbm, -126.0);
|
||||
requiredCwSignalDbm = sanitizeFinite(requiredCwSignalDbm, -132.0);
|
||||
contestMarginDb = sanitizeNonNegative(contestMarginDb, 6.0);
|
||||
}
|
||||
|
||||
private static double sanitizePositive(double value, double fallback) {
|
||||
return Double.isFinite(value) && value > 0.0 ? value : fallback;
|
||||
}
|
||||
|
||||
private static double sanitizeNonNegative(double value, double fallback) {
|
||||
return Double.isFinite(value) && value >= 0.0 ? value : fallback;
|
||||
}
|
||||
|
||||
private static double sanitizeFinite(double value, double fallback) {
|
||||
return Double.isFinite(value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Bidirectional link-budget estimate for one path.
|
||||
*
|
||||
* <p>The estimate is intentionally operator-facing. It combines free-space
|
||||
* loss, the current diffraction/obstruction estimate, antenna gain, feeder loss,
|
||||
* TX power and required SSB/CW receiver levels.</p>
|
||||
*/
|
||||
public record PathLinkBudgetSummary(
|
||||
double frequencyMHz,
|
||||
|
||||
double ownTxPowerDbm,
|
||||
double targetTxPowerDbm,
|
||||
double ownAntennaGainDbi,
|
||||
double targetAntennaGainDbi,
|
||||
|
||||
double ownFeederLossDb,
|
||||
double targetFeederLossDb,
|
||||
double freeSpacePathLossDb,
|
||||
double diffractionLossDb,
|
||||
|
||||
double homeToTargetRxPowerDbm,
|
||||
double targetToHomeRxPowerDbm,
|
||||
|
||||
double requiredSsbSignalDbm,
|
||||
double requiredCwSignalDbm,
|
||||
double contestMarginDb,
|
||||
|
||||
double homeToTargetSsbMarginDb,
|
||||
double targetToHomeSsbMarginDb,
|
||||
double bidirectionalSsbMarginDb,
|
||||
|
||||
double homeToTargetCwMarginDb,
|
||||
double targetToHomeCwMarginDb,
|
||||
double bidirectionalCwMarginDb
|
||||
) {
|
||||
|
||||
public static PathLinkBudgetSummary empty() {
|
||||
return new PathLinkBudgetSummary(
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasUsableBudget() {
|
||||
return Double.isFinite(bidirectionalSsbMarginDb);
|
||||
}
|
||||
|
||||
public String ssbMarginText() {
|
||||
if (!hasUsableBudget()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"SSB QSO margin %+.1f dB | H→DX %+.1f dB | DX→H %+.1f dB",
|
||||
bidirectionalSsbMarginDb,
|
||||
homeToTargetSsbMarginDb,
|
||||
targetToHomeSsbMarginDb
|
||||
);
|
||||
}
|
||||
|
||||
public String rxPowerText() {
|
||||
if (!Double.isFinite(homeToTargetRxPowerDbm)
|
||||
|| !Double.isFinite(targetToHomeRxPowerDbm)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"H→DX %.1f dBm | DX→H %.1f dBm",
|
||||
homeToTargetRxPowerDbm,
|
||||
targetToHomeRxPowerDbm
|
||||
);
|
||||
}
|
||||
|
||||
public String linkBudgetDetailText() {
|
||||
if (!hasUsableBudget()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"FSPL %.1f dB, diffraction %.1f dB, feeder H %.1f dB / DX %.1f dB, required SSB %.1f dBm + %.1f dB margin",
|
||||
freeSpacePathLossDb,
|
||||
diffractionLossDb,
|
||||
ownFeederLossDb,
|
||||
targetFeederLossDb,
|
||||
requiredSsbSignalDbm,
|
||||
contestMarginDb
|
||||
);
|
||||
}
|
||||
|
||||
public String cwHintText() {
|
||||
if (!Double.isFinite(bidirectionalCwMarginDb)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bidirectionalSsbMarginDb >= 0.0) {
|
||||
return String.format(Locale.US, "SSB workable; CW margin %+.1f dB.", bidirectionalCwMarginDb);
|
||||
}
|
||||
|
||||
if (bidirectionalCwMarginDb >= 0.0) {
|
||||
return String.format(Locale.US, "SSB marginal/weak, but CW may still be workable (%+.1f dB).", bidirectionalCwMarginDb);
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "Even CW budget is negative (%+.1f dB).", bidirectionalCwMarginDb);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Summary of the dominant terrain obstruction and a rough single-knife-edge
|
||||
* diffraction estimate.
|
||||
*
|
||||
* <p>This is intentionally only a severity indicator. It does not replace a full
|
||||
* propagation model with multiple diffraction edges, clutter, refractivity,
|
||||
* troposcatter, aircraft scatter or ducting.</p>
|
||||
*/
|
||||
public record PathObstructionSummary(
|
||||
double analysisFrequencyMHz,
|
||||
|
||||
int dominantObstructionSampleIndex,
|
||||
double dominantObstructionPathDistanceKm,
|
||||
double dominantObstructionHeightAboveLosMeters,
|
||||
double localFirstFresnelRadiusMeters,
|
||||
double obstructionFresnelRatio,
|
||||
double diffractionVParameter,
|
||||
double estimatedKnifeEdgeLossDb,
|
||||
|
||||
int worstFresnelIntrusionSampleIndex,
|
||||
double worstFresnelIntrusionPathDistanceKm,
|
||||
double worstFresnelIntrusionMeters,
|
||||
double worstFresnelIntrusionRatio
|
||||
) {
|
||||
|
||||
public static PathObstructionSummary empty() {
|
||||
return new PathObstructionSummary(
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
-1,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasDominantLosObstruction() {
|
||||
return dominantObstructionSampleIndex >= 0
|
||||
&& Double.isFinite(dominantObstructionPathDistanceKm)
|
||||
&& Double.isFinite(dominantObstructionHeightAboveLosMeters)
|
||||
&& dominantObstructionHeightAboveLosMeters > 0.0;
|
||||
}
|
||||
|
||||
public boolean hasFresnelIntrusion() {
|
||||
return worstFresnelIntrusionSampleIndex >= 0
|
||||
&& Double.isFinite(worstFresnelIntrusionMeters)
|
||||
&& worstFresnelIntrusionMeters > 0.0;
|
||||
}
|
||||
|
||||
public String obstructionText() {
|
||||
if (hasDominantLosObstruction()) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"%.1f km | %.1f m above LOS | %.2f Fresnel radii | knife-edge ≈ %.1f dB (%s)",
|
||||
dominantObstructionPathDistanceKm,
|
||||
dominantObstructionHeightAboveLosMeters,
|
||||
obstructionFresnelRatio,
|
||||
estimatedKnifeEdgeLossDb,
|
||||
severityText()
|
||||
);
|
||||
}
|
||||
|
||||
if (hasFresnelIntrusion()) {
|
||||
return String.format(
|
||||
Locale.US,
|
||||
"No LOS-blocking edge. Fresnel intrusion %.1f m at %.1f km (%.0f%%).",
|
||||
worstFresnelIntrusionMeters,
|
||||
worstFresnelIntrusionPathDistanceKm,
|
||||
worstFresnelIntrusionRatio * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
return "No dominant LOS obstruction detected.";
|
||||
}
|
||||
|
||||
public String severityText() {
|
||||
if (!Double.isFinite(estimatedKnifeEdgeLossDb)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 6.0) {
|
||||
return "low";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 15.0) {
|
||||
return "moderate";
|
||||
}
|
||||
|
||||
if (estimatedKnifeEdgeLossDb < 25.0) {
|
||||
return "high";
|
||||
}
|
||||
|
||||
return "severe";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable terrain sample plus optional derived radio-path geometry.
|
||||
*
|
||||
* <p>The first four properties are the raw terrain profile payload:
|
||||
* <ul>
|
||||
* <li>distance along the path in kilometers</li>
|
||||
* <li>sample latitude in degrees</li>
|
||||
* <li>sample longitude in degrees</li>
|
||||
* <li>terrain elevation in meters above mean sea level</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The remaining properties are optional derived values filled by
|
||||
* {@link GeometryOnlyPathAnalysisService}. They are all expressed in the
|
||||
* same chart/analysis space as the curvature-adjusted terrain profile:
|
||||
* <ul>
|
||||
* <li>curvature-adjusted terrain elevation</li>
|
||||
* <li>direct line-of-sight height</li>
|
||||
* <li>upper / lower first Fresnel hull</li>
|
||||
* <li>LOS and Fresnel clearances</li>
|
||||
* <li>worst-intrusion support values</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class PathProfilePoint {
|
||||
|
||||
private final int sampleIndex;
|
||||
private final double distanceKm;
|
||||
private final double latitudeDeg;
|
||||
private final double longitudeDeg;
|
||||
private final double elevationMeters;
|
||||
|
||||
private final double curvatureAdjustedElevationMeters;
|
||||
private final double lineOfSightHeightMeters;
|
||||
private final double fresnelUpperHeightMeters;
|
||||
private final double fresnelLowerHeightMeters;
|
||||
private final double lineOfSightClearanceMeters;
|
||||
private final double lowerFresnelClearanceMeters;
|
||||
private final double fresnelIntrusionMeters;
|
||||
|
||||
/**
|
||||
* Creates a raw terrain profile sample without derived LOS/Fresnel values.
|
||||
*
|
||||
* <p>This constructor is used by terrain providers and cache deserialization.
|
||||
* Derived geometry values remain unavailable until the path analysis service
|
||||
* enriches the profile.</p>
|
||||
*
|
||||
* @param distanceKm distance from the path origin in kilometers
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @param elevationMeters terrain elevation in meters above mean sea level
|
||||
*/
|
||||
public PathProfilePoint(double distanceKm,
|
||||
double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
double elevationMeters) {
|
||||
this(
|
||||
-1,
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN,
|
||||
Double.NaN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a terrain profile sample enriched with derived radio-path geometry.
|
||||
*
|
||||
* @param sampleIndex zero-based sample index in the analyzed path
|
||||
* @param distanceKm distance from the path origin in kilometers
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @param elevationMeters raw terrain elevation in meters above mean sea level
|
||||
* @param curvatureAdjustedElevationMeters terrain elevation plus Earth bulge in meters
|
||||
* @param lineOfSightHeightMeters direct path height in the same chart space
|
||||
* @param fresnelUpperHeightMeters upper first Fresnel hull in the same chart space
|
||||
* @param fresnelLowerHeightMeters lower first Fresnel hull in the same chart space
|
||||
* @param lineOfSightClearanceMeters direct LOS clearance against curvature-adjusted terrain
|
||||
* @param lowerFresnelClearanceMeters lower Fresnel clearance against curvature-adjusted terrain
|
||||
* @param fresnelIntrusionMeters terrain intrusion into the lower Fresnel hull
|
||||
*/
|
||||
public PathProfilePoint(int sampleIndex,
|
||||
double distanceKm,
|
||||
double latitudeDeg,
|
||||
double longitudeDeg,
|
||||
double elevationMeters,
|
||||
double curvatureAdjustedElevationMeters,
|
||||
double lineOfSightHeightMeters,
|
||||
double fresnelUpperHeightMeters,
|
||||
double fresnelLowerHeightMeters,
|
||||
double lineOfSightClearanceMeters,
|
||||
double lowerFresnelClearanceMeters,
|
||||
double fresnelIntrusionMeters) {
|
||||
|
||||
this.sampleIndex = sampleIndex;
|
||||
this.distanceKm = distanceKm;
|
||||
this.latitudeDeg = latitudeDeg;
|
||||
this.longitudeDeg = longitudeDeg;
|
||||
this.elevationMeters = elevationMeters;
|
||||
this.curvatureAdjustedElevationMeters = curvatureAdjustedElevationMeters;
|
||||
this.lineOfSightHeightMeters = lineOfSightHeightMeters;
|
||||
this.fresnelUpperHeightMeters = fresnelUpperHeightMeters;
|
||||
this.fresnelLowerHeightMeters = fresnelLowerHeightMeters;
|
||||
this.lineOfSightClearanceMeters = lineOfSightClearanceMeters;
|
||||
this.lowerFresnelClearanceMeters = lowerFresnelClearanceMeters;
|
||||
this.fresnelIntrusionMeters = fresnelIntrusionMeters;
|
||||
}
|
||||
|
||||
public int sampleIndex() {
|
||||
return sampleIndex;
|
||||
}
|
||||
|
||||
public double distanceKm() {
|
||||
return distanceKm;
|
||||
}
|
||||
|
||||
public double latitudeDeg() {
|
||||
return latitudeDeg;
|
||||
}
|
||||
|
||||
public double longitudeDeg() {
|
||||
return longitudeDeg;
|
||||
}
|
||||
|
||||
public double elevationMeters() {
|
||||
return elevationMeters;
|
||||
}
|
||||
|
||||
public double curvatureAdjustedElevationMeters() {
|
||||
return curvatureAdjustedElevationMeters;
|
||||
}
|
||||
|
||||
public double lineOfSightHeightMeters() {
|
||||
return lineOfSightHeightMeters;
|
||||
}
|
||||
|
||||
public double fresnelUpperHeightMeters() {
|
||||
return fresnelUpperHeightMeters;
|
||||
}
|
||||
|
||||
public double fresnelLowerHeightMeters() {
|
||||
return fresnelLowerHeightMeters;
|
||||
}
|
||||
|
||||
public double lineOfSightClearanceMeters() {
|
||||
return lineOfSightClearanceMeters;
|
||||
}
|
||||
|
||||
public double lowerFresnelClearanceMeters() {
|
||||
return lowerFresnelClearanceMeters;
|
||||
}
|
||||
|
||||
public double fresnelIntrusionMeters() {
|
||||
return fresnelIntrusionMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if enriched LOS/Fresnel geometry is available.
|
||||
*
|
||||
* @return true if this sample was enriched by the path analysis service
|
||||
*/
|
||||
public boolean hasDerivedGeometry() {
|
||||
return Double.isFinite(curvatureAdjustedElevationMeters)
|
||||
|| Double.isFinite(lineOfSightHeightMeters)
|
||||
|| Double.isFinite(fresnelUpperHeightMeters)
|
||||
|| Double.isFinite(fresnelLowerHeightMeters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the terrain blocks the direct line of sight at this sample.
|
||||
*
|
||||
* @return true if the LOS clearance is negative
|
||||
*/
|
||||
public boolean isLineOfSightBlocked() {
|
||||
return Double.isFinite(lineOfSightClearanceMeters) && lineOfSightClearanceMeters < 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the terrain intrudes into the lower Fresnel hull.
|
||||
*
|
||||
* @return true if Fresnel intrusion is positive
|
||||
*/
|
||||
public boolean hasFresnelIntrusion() {
|
||||
return Double.isFinite(fresnelIntrusionMeters) && fresnelIntrusionMeters > 0.0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"PathProfilePoint{index=%d, distanceKm=%.3f, lat=%.6f, lon=%.6f, elevation=%.2f, curvature=%.2f, los=%.2f, fresnelLower=%.2f, intrusion=%.2f}",
|
||||
sampleIndex,
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters,
|
||||
curvatureAdjustedElevationMeters,
|
||||
lineOfSightHeightMeters,
|
||||
fresnelLowerHeightMeters,
|
||||
fresnelIntrusionMeters
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Operator-facing propagation assessment derived from geometric path analysis.
|
||||
*
|
||||
* <p>This class deliberately avoids binary "possible/impossible" wording.
|
||||
* VHF/UHF paths can work despite blocked geometric LOS due to diffraction,
|
||||
* troposcatter, enhanced tropospheric refraction, ducting or aircraft scatter.</p>
|
||||
*/
|
||||
public record PathPropagationAssessment(
|
||||
String category,
|
||||
String shortText,
|
||||
String detailText,
|
||||
String likelyMechanisms,
|
||||
int severityLevel
|
||||
) {
|
||||
|
||||
public static PathPropagationAssessment unknown() {
|
||||
return new PathPropagationAssessment(
|
||||
"Unknown",
|
||||
"No propagation assessment available.",
|
||||
"No usable terrain/profile data is available.",
|
||||
"-",
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment directFavorable() {
|
||||
return new PathPropagationAssessment(
|
||||
"Direct path favorable",
|
||||
"Direct path likely",
|
||||
"The geometric line of sight and the first Fresnel zone are clear. A direct tropospheric path is plausible.",
|
||||
"Direct tropospheric path",
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment directLossy(double fresnelIntrusionRatio) {
|
||||
return new PathPropagationAssessment(
|
||||
"Direct path lossy",
|
||||
"Direct path plausible, but lossy",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The direct line of sight is clear, but the first Fresnel zone is obstructed by about %.0f%% of the local Fresnel radius. Expect additional loss.",
|
||||
fresnelIntrusionRatio * 100.0
|
||||
),
|
||||
"Direct path with Fresnel loss, possible mild diffraction",
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment diffractionPlausible(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Diffraction plausible",
|
||||
"Obstructed, diffraction may still be plausible",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The direct geometric path is blocked, but the rough single-knife-edge estimate is moderate at about %.1f dB. A QSO may still be possible with sufficient antennas, power and conditions.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Terrain diffraction, tropo enhancement",
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment obstructedNeedsHelp(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Obstructed",
|
||||
"Obstructed, enhanced propagation likely required",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The geometric path is blocked and the rough single-knife-edge estimate is high at about %.1f dB. Direct diffraction alone may be weak; tropo enhancement or scatter mechanisms become more relevant.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Diffraction, troposcatter, tropo enhancement, aircraft scatter",
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment severelyObstructed(double knifeEdgeLossDb) {
|
||||
return new PathPropagationAssessment(
|
||||
"Severely obstructed",
|
||||
"Severely obstructed, special propagation probably required",
|
||||
String.format(
|
||||
Locale.US,
|
||||
"The geometric path is strongly blocked. The rough single-knife-edge estimate is about %.1f dB, so a normal direct path is unlikely. This does not mean impossible on VHF/UHF, but special propagation is probably needed.",
|
||||
knifeEdgeLossDb
|
||||
),
|
||||
"Aircraft scatter, tropo ducting/enhancement, troposcatter, strong diffraction only in exceptional cases",
|
||||
5
|
||||
);
|
||||
}
|
||||
|
||||
public static PathPropagationAssessment blockedNoLossEstimate() {
|
||||
return new PathPropagationAssessment(
|
||||
"Blocked",
|
||||
"Geometrically blocked",
|
||||
"The geometric line of sight is blocked. No reliable diffraction-loss estimate is available for this profile.",
|
||||
"Diffraction, tropo enhancement, troposcatter, aircraft scatter",
|
||||
4
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import javafx.animation.PauseTransition;
|
||||
import javafx.application.Platform;
|
||||
import javafx.collections.ListChangeListener;
|
||||
import javafx.scene.control.TableView;
|
||||
import javafx.util.Duration;
|
||||
import kst4contest.controller.ChatController;
|
||||
import kst4contest.locatorUtils.Location;
|
||||
import kst4contest.model.ChatMember;
|
||||
import kst4contest.model.ChatPreferences;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Synchronizes the application state with the station map window.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - observes the visible filtered table content
|
||||
* - observes the central selection
|
||||
* - forwards marker clicks back into the main application
|
||||
* - triggers explicit DXCluster spots from the map detail panel
|
||||
*/
|
||||
public final class StationMapBridge {
|
||||
|
||||
private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
|
||||
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
|
||||
|
||||
private final ChatController chatController;
|
||||
private final TableView<ChatMember> chatMemberTable;
|
||||
private final StationMapView stationMapView;
|
||||
private final Consumer<ChatMember> focusChatMemberConsumer;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
|
||||
// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
|
||||
private final PathAnalysisService pathAnalysisService;
|
||||
|
||||
private String lastPathAnalysisRequestSignature = "";
|
||||
|
||||
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
|
||||
|
||||
public StationMapBridge(ChatController chatController,
|
||||
TableView<ChatMember> chatMemberTable,
|
||||
StationMapView stationMapView,
|
||||
Consumer<ChatMember> focusChatMemberConsumer) {
|
||||
|
||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
||||
this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable");
|
||||
this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView");
|
||||
this.focusChatMemberConsumer = Objects.requireNonNull(focusChatMemberConsumer, "focusChatMemberConsumer");
|
||||
|
||||
this.refreshCoalescer.setOnFinished(event -> refreshNow());
|
||||
|
||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
|
||||
new OpenMeteoTerrainProfileProvider()
|
||||
);
|
||||
}
|
||||
|
||||
public void install() {
|
||||
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
||||
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
||||
|
||||
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
||||
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
||||
);
|
||||
|
||||
chatController.getScoreService().selectedChatMemberProperty().addListener(
|
||||
(obs, oldValue, newValue) -> requestImmediateRefresh()
|
||||
);
|
||||
|
||||
chatController.getChatPreferences().getActualQTF().addListener(
|
||||
(obs, oldValue, newValue) -> scheduleRefresh()
|
||||
);
|
||||
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
public void showWindow() {
|
||||
stationMapView.showWindow();
|
||||
requestImmediateRefresh();
|
||||
}
|
||||
|
||||
public void hideWindow() {
|
||||
stationMapView.hideWindow();
|
||||
}
|
||||
|
||||
public void toggleWindow() {
|
||||
if (stationMapView.isShowing()) {
|
||||
hideWindow();
|
||||
} else {
|
||||
showWindow();
|
||||
}
|
||||
}
|
||||
|
||||
public void requestImmediateRefresh() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
refreshNow();
|
||||
} else {
|
||||
Platform.runLater(this::refreshNow);
|
||||
}
|
||||
}
|
||||
|
||||
public void focusSelectedCallsign() {
|
||||
showWindow();
|
||||
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
||||
stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw());
|
||||
}
|
||||
}
|
||||
|
||||
public void applyThemeFromPreferences() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
stationMapView.applyThemeFromPreferences();
|
||||
} else {
|
||||
Platform.runLater(stationMapView::applyThemeFromPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleRefresh() {
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
refreshCoalescer.playFromStart();
|
||||
} else {
|
||||
Platform.runLater(() -> refreshCoalescer.playFromStart());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshNow() {
|
||||
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
|
||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember);
|
||||
|
||||
MapCallsignRawSnapshot selectedSnapshot = null;
|
||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
||||
String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
|
||||
selectedSnapshot = snapshots.stream()
|
||||
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
|
||||
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
|
||||
stationMapView.refreshMap(
|
||||
snapshots,
|
||||
selectedSnapshot,
|
||||
preferences.getStn_loginLocatorMainCat(),
|
||||
preferences.getActualQTF().get(),
|
||||
preferences.getStn_antennaBeamWidthDeg(),
|
||||
preferences.getStn_maxQRBDefault(),
|
||||
filteredViewActive
|
||||
);
|
||||
|
||||
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
|
||||
}
|
||||
|
||||
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
||||
|
||||
String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot);
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
lastPathAnalysisRequestSignature = "";
|
||||
} else if (requestSignature.equals(lastPathAnalysisRequestSignature)) {
|
||||
return;
|
||||
} else {
|
||||
lastPathAnalysisRequestSignature = requestSignature;
|
||||
}
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
||||
Platform.runLater(() -> {
|
||||
if (generation == pathAnalysisGeneration.get()) {
|
||||
stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
||||
String targetCallsignRaw = selectedSnapshot.callSignRaw();
|
||||
|
||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
||||
|
||||
stationMapView.setPathAnalysisResult(
|
||||
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
|
||||
);
|
||||
|
||||
pathAnalysisExecutor.submit(() -> {
|
||||
PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if (generation != pathAnalysisGeneration.get()) {
|
||||
return;
|
||||
}
|
||||
stationMapView.setPathAnalysisResult(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
pathAnalysisExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private void handleMapCallsignSelection(String callSignRaw) {
|
||||
System.out.println("########################### map selected callsign " + callSignRaw);
|
||||
|
||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
chatController.getScoreService().setSelectedChatMember(resolved);
|
||||
|
||||
chatMemberTable.getSelectionModel().select(resolved);
|
||||
chatMemberTable.scrollTo(resolved);
|
||||
|
||||
focusChatMemberConsumer.accept(resolved);
|
||||
requestImmediateRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
private void handleExplicitClusterSpot(String callSignRaw) {
|
||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
||||
if (resolved == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chatController.getDxClusterServer() != null) {
|
||||
chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatMember resolveBestChatMember(String callSignRaw) {
|
||||
String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw);
|
||||
if (normalizedCallsignRaw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
||||
if (selectedChatMember != null
|
||||
&& normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) {
|
||||
return selectedChatMember;
|
||||
}
|
||||
|
||||
ChatMember visibleBest = chatMemberTable.getItems().stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null);
|
||||
|
||||
if (visibleBest != null) {
|
||||
return visibleBest;
|
||||
}
|
||||
|
||||
synchronized (chatController.getLst_chatMemberList()) {
|
||||
return chatController.getLst_chatMemberList().stream()
|
||||
.filter(chatMember -> chatMember != null)
|
||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
if (callSignRaw == null) {
|
||||
return "";
|
||||
}
|
||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
||||
|
||||
if (selectedSnapshot == null) {
|
||||
return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6);
|
||||
}
|
||||
|
||||
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
||||
|
||||
if (normalizedOwnLocator6.length() != 6) {
|
||||
return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6);
|
||||
}
|
||||
|
||||
if (!selectedSnapshot.hasUsablePosition()) {
|
||||
return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6);
|
||||
}
|
||||
|
||||
Location homeLocation = new Location(normalizedOwnLocator6);
|
||||
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
|
||||
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
normalizedOwnLocator6,
|
||||
homeLocation.getLatitude().toDegrees(),
|
||||
homeLocation.getLongitude().toDegrees(),
|
||||
selectedSnapshot.callSignRaw(),
|
||||
normalizedTargetLocator6,
|
||||
selectedSnapshot.latitudeDeg(),
|
||||
selectedSnapshot.longitudeDeg(),
|
||||
analysisFrequencyMHz,
|
||||
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
|
||||
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
||||
chatController.getChatPreferences().buildPathLinkBudgetSettings()
|
||||
);
|
||||
|
||||
return pathAnalysisService.analyze(request);
|
||||
}
|
||||
|
||||
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
|
||||
if (selectedSnapshot == null) {
|
||||
return PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ;
|
||||
}
|
||||
|
||||
return PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
|
||||
}
|
||||
|
||||
|
||||
|
||||
private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
||||
if (selectedSnapshot == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
|
||||
ChatPreferences preferences = chatController.getChatPreferences();
|
||||
|
||||
return normalizeLocator6(ownLocator6)
|
||||
+ "|"
|
||||
+ selectedSnapshot.callSignRaw()
|
||||
+ "|"
|
||||
+ normalizeLocator6(selectedSnapshot.locator6())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
|
||||
+ "|"
|
||||
+ preferences.getStn_pathAnalysisDemRootDirectory()
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts())
|
||||
+ "|"
|
||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi());
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator) {
|
||||
if (locator == null) {
|
||||
return "";
|
||||
}
|
||||
return locator.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static final class PathAnalysisThreadFactory implements ThreadFactory {
|
||||
@Override
|
||||
public Thread newThread(Runnable runnable) {
|
||||
Thread thread = new Thread(runnable, "station-map-path-analysis");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Deterministic synthetic fallback profile provider for UI/testing.
|
||||
*
|
||||
* This provider intentionally stays fully offline and deterministic.
|
||||
* It is only used when no real offline DEM data is available.
|
||||
*/
|
||||
public final class SyntheticTerrainProfileProvider implements TerrainProfileProvider {
|
||||
|
||||
@Override
|
||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
||||
return TerrainProfileData.empty("Synthetic fallback profile");
|
||||
}
|
||||
|
||||
int sampleCount = Math.max(2, request.requestedSampleCount());
|
||||
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
|
||||
|
||||
for (int i = 0; i < sampleCount; i++) {
|
||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
||||
|
||||
double latitudeDeg = interpolate(request.fromLatitudeDeg(), request.toLatitudeDeg(), t);
|
||||
double longitudeDeg = interpolate(request.fromLongitudeDeg(), request.toLongitudeDeg(), t);
|
||||
double distanceKm = request.totalDistanceKm() * t;
|
||||
|
||||
double elevationMeters = syntheticElevationMeters(t, request.totalDistanceKm());
|
||||
|
||||
points.add(new PathProfilePoint(
|
||||
distanceKm,
|
||||
latitudeDeg,
|
||||
longitudeDeg,
|
||||
elevationMeters
|
||||
));
|
||||
}
|
||||
|
||||
return new TerrainProfileData(points, "Synthetic fallback profile", true);
|
||||
}
|
||||
|
||||
private double interpolate(double start, double end, double t) {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
private double syntheticElevationMeters(double t, double totalDistanceKm) {
|
||||
double baseLevelMeters = 80.0 + Math.min(60.0, totalDistanceKm * 0.12);
|
||||
|
||||
double broadHill = 90.0 * Math.sin(Math.PI * t);
|
||||
double window = Math.pow(Math.sin(Math.PI * t), 1.35);
|
||||
double secondaryShape = window * 28.0 * Math.sin(3.0 * Math.PI * t + 0.55);
|
||||
double fineStructure = window * 10.0 * Math.cos(7.0 * Math.PI * t + 0.25);
|
||||
|
||||
return Math.max(0.0, baseLevelMeters + broadHill + secondaryShape + fineStructure);
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immutable terrain catalog describing all downloadable terrain packages
|
||||
* currently offered by the service.
|
||||
*
|
||||
* <p>The catalog intentionally contains the required attribution/license texts
|
||||
* so they can later be shown both in the client UI and in service responses.</p>
|
||||
*/
|
||||
public record TerrainCatalog(
|
||||
int schemaVersion,
|
||||
int catalogVersion,
|
||||
String generatedAtUtc,
|
||||
String regionSet,
|
||||
String packageBaseUrl,
|
||||
String sourceAttribution,
|
||||
String licenseNotice,
|
||||
String disclaimerNotice,
|
||||
List<TerrainCatalogPackageEntry> packages
|
||||
) {
|
||||
|
||||
public TerrainCatalog {
|
||||
generatedAtUtc = normalizeText(generatedAtUtc);
|
||||
regionSet = normalizeLower(regionSet);
|
||||
packageBaseUrl = normalizeText(packageBaseUrl);
|
||||
sourceAttribution = normalizeText(sourceAttribution);
|
||||
licenseNotice = normalizeText(licenseNotice);
|
||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
||||
packages = packages == null ? List.of() : List.copyOf(packages);
|
||||
|
||||
if (schemaVersion < 0) {
|
||||
schemaVersion = 0;
|
||||
}
|
||||
|
||||
if (catalogVersion < 0) {
|
||||
catalogVersion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the catalog contains at least one package entry.
|
||||
*
|
||||
* @return true if the catalog is non-empty
|
||||
*/
|
||||
public boolean hasPackages() {
|
||||
return !packages.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one package entry by its canonical package id.
|
||||
*
|
||||
* @param packageId canonical package id
|
||||
* @return matching catalog entry if found
|
||||
*/
|
||||
public Optional<TerrainCatalogPackageEntry> findPackageById(String packageId) {
|
||||
String normalizedPackageId = normalizeLower(packageId);
|
||||
|
||||
return packages.stream()
|
||||
.filter(entry -> entry.packageId().equals(normalizedPackageId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one package entry by region type and region id.
|
||||
*
|
||||
* @param regionType region type, e.g. "maidenhead4"
|
||||
* @param regionId region id, e.g. "JO22"
|
||||
* @return matching package entry if found
|
||||
*/
|
||||
public Optional<TerrainCatalogPackageEntry> findPackageByRegion(String regionType, String regionId) {
|
||||
String normalizedRegionType = normalizeLower(regionType);
|
||||
String normalizedRegionId = normalizeUpper(regionId);
|
||||
|
||||
return packages.stream()
|
||||
.filter(entry -> entry.regionType().equals(normalizedRegionType))
|
||||
.filter(entry -> entry.regionId().equals(normalizedRegionId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeLower(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Downloads and parses the terrain package catalog.
|
||||
*
|
||||
* <p>The first implementation intentionally uses XML instead of JSON because:
|
||||
* <ul>
|
||||
* <li>the current project already contains XML parsing patterns</li>
|
||||
* <li>no additional JSON dependency is required</li>
|
||||
* <li>we can move faster toward a working package download/install flow</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The parsed result still maps into the shared terrain catalog model classes.</p>
|
||||
*/
|
||||
public final class TerrainCatalogClient {
|
||||
|
||||
private static final String LOCAL_TERRAIN_CATALOG_RELATIVE_PATH = "terrain/catalog/terrain-catalog-v1.xml";
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public TerrainCatalogClient() {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the remote XML catalog and stores it below the local .praktiKST directory.
|
||||
*
|
||||
* @param catalogUrl full catalog URL
|
||||
* @return download result
|
||||
*/
|
||||
public CatalogDownloadResult downloadCatalog(String catalogUrl) {
|
||||
Path localCatalogFile = resolveLocalCatalogFile();
|
||||
|
||||
try {
|
||||
Files.createDirectories(localCatalogFile.getParent());
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(catalogUrl))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> response = httpClient.send(
|
||||
httpRequest,
|
||||
HttpResponse.BodyHandlers.ofFile(localCatalogFile)
|
||||
);
|
||||
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
return new CatalogDownloadResult(
|
||||
localCatalogFile,
|
||||
false,
|
||||
"Catalog download failed with HTTP status " + response.statusCode() + "."
|
||||
);
|
||||
}
|
||||
|
||||
return new CatalogDownloadResult(
|
||||
localCatalogFile,
|
||||
true,
|
||||
"Catalog downloaded successfully to:\n" + localCatalogFile.toAbsolutePath()
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new CatalogDownloadResult(
|
||||
localCatalogFile,
|
||||
false,
|
||||
"Catalog download failed: " + exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the previously downloaded local catalog file.
|
||||
*
|
||||
* @return parsed catalog load result
|
||||
*/
|
||||
public CatalogLoadResult loadLocalCatalog() {
|
||||
return loadCatalog(resolveLocalCatalogFile());
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and parses one XML catalog file.
|
||||
*
|
||||
* @param catalogFile local catalog XML file
|
||||
* @return parsed catalog load result
|
||||
*/
|
||||
public CatalogLoadResult loadCatalog(Path catalogFile) {
|
||||
if (catalogFile == null || !Files.isRegularFile(catalogFile)) {
|
||||
return new CatalogLoadResult(
|
||||
null,
|
||||
catalogFile,
|
||||
false,
|
||||
"Local terrain catalog file does not exist."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
Document document = parseXmlDocument(catalogFile.toFile());
|
||||
TerrainCatalog terrainCatalog = parseTerrainCatalog(document);
|
||||
|
||||
return new CatalogLoadResult(
|
||||
terrainCatalog,
|
||||
catalogFile,
|
||||
terrainCatalog != null && terrainCatalog.hasPackages(),
|
||||
terrainCatalog != null && terrainCatalog.hasPackages()
|
||||
? "Terrain catalog loaded successfully."
|
||||
: "Terrain catalog was loaded but contains no packages."
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new CatalogLoadResult(
|
||||
null,
|
||||
catalogFile,
|
||||
false,
|
||||
"Could not parse terrain catalog: " + exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveLocalCatalogFile() {
|
||||
return Path.of(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
LOCAL_TERRAIN_CATALOG_RELATIVE_PATH
|
||||
));
|
||||
}
|
||||
|
||||
private Document parseXmlDocument(File xmlFile) throws Exception {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
||||
return documentBuilder.parse(xmlFile);
|
||||
}
|
||||
|
||||
private TerrainCatalog parseTerrainCatalog(Document document) {
|
||||
Element root = document.getDocumentElement();
|
||||
if (root == null || !"terrainCatalog".equals(root.getTagName())) {
|
||||
throw new IllegalArgumentException("Unexpected catalog root element.");
|
||||
}
|
||||
|
||||
int schemaVersion = parseIntAttribute(root, "schemaVersion", 1);
|
||||
int catalogVersion = parseIntAttribute(root, "catalogVersion", 1);
|
||||
String generatedAtUtc = root.getAttribute("generatedAtUtc");
|
||||
String regionSet = root.getAttribute("regionSet");
|
||||
String packageBaseUrl = root.getAttribute("packageBaseUrl");
|
||||
|
||||
String sourceAttribution = getDirectChildText(root, "sourceAttribution");
|
||||
String licenseNotice = getDirectChildText(root, "licenseNotice");
|
||||
String disclaimerNotice = getDirectChildText(root, "disclaimerNotice");
|
||||
|
||||
Element packagesElement = getFirstDirectChild(root, "packages");
|
||||
List<TerrainCatalogPackageEntry> packageEntries = new ArrayList<>();
|
||||
|
||||
if (packagesElement != null) {
|
||||
List<Element> packageElements = getDirectChildElements(packagesElement, "package");
|
||||
|
||||
for (Element packageElement : packageElements) {
|
||||
List<String> tileIds = new ArrayList<>();
|
||||
|
||||
Element tileIdsElement = getFirstDirectChild(packageElement, "tileIds");
|
||||
if (tileIdsElement != null) {
|
||||
for (Element tileIdElement : getDirectChildElements(tileIdsElement, "tileId")) {
|
||||
tileIds.add(tileIdElement.getTextContent());
|
||||
}
|
||||
}
|
||||
|
||||
packageEntries.add(new TerrainCatalogPackageEntry(
|
||||
packageElement.getAttribute("packageId"),
|
||||
packageElement.getAttribute("regionType"),
|
||||
packageElement.getAttribute("regionId"),
|
||||
parseIntAttribute(packageElement, "packageVersion", 1),
|
||||
packageElement.getAttribute("downloadUrl"),
|
||||
parseLongAttribute(packageElement, "sizeBytes", 0L),
|
||||
packageElement.getAttribute("sha256"),
|
||||
parseDoubleAttribute(packageElement, "minLatitudeDeg", Double.NaN),
|
||||
parseDoubleAttribute(packageElement, "maxLatitudeDeg", Double.NaN),
|
||||
parseDoubleAttribute(packageElement, "minLongitudeDeg", Double.NaN),
|
||||
parseDoubleAttribute(packageElement, "maxLongitudeDeg", Double.NaN),
|
||||
tileIds,
|
||||
packageElement.getAttribute("sourceDataset"),
|
||||
getDirectChildText(packageElement, "sourceAttribution"),
|
||||
getDirectChildText(packageElement, "derivedProductNotice"),
|
||||
getDirectChildText(packageElement, "disclaimerNotice")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new TerrainCatalog(
|
||||
schemaVersion,
|
||||
catalogVersion,
|
||||
generatedAtUtc,
|
||||
regionSet,
|
||||
packageBaseUrl,
|
||||
sourceAttribution,
|
||||
licenseNotice,
|
||||
disclaimerNotice,
|
||||
packageEntries
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Element> getDirectChildElements(Element parent, String tagName) {
|
||||
List<Element> result = new ArrayList<>();
|
||||
|
||||
if (parent == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (int index = 0; index < parent.getChildNodes().getLength(); index++) {
|
||||
Node node = parent.getChildNodes().item(index);
|
||||
if (node instanceof Element element && tagName.equals(element.getTagName())) {
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Element getFirstDirectChild(Element parent, String tagName) {
|
||||
for (Element element : getDirectChildElements(parent, tagName)) {
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getDirectChildText(Element parent, String tagName) {
|
||||
Element child = getFirstDirectChild(parent, tagName);
|
||||
return child == null ? "" : child.getTextContent().trim();
|
||||
}
|
||||
|
||||
private static int parseIntAttribute(Element element, String attributeName, int defaultValue) {
|
||||
try {
|
||||
return Integer.parseInt(element.getAttribute(attributeName));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseLongAttribute(Element element, String attributeName, long defaultValue) {
|
||||
try {
|
||||
return Long.parseLong(element.getAttribute(attributeName));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) {
|
||||
try {
|
||||
return Double.parseDouble(element.getAttribute(attributeName));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download result for the XML terrain catalog.
|
||||
*
|
||||
* @param localCatalogFile target local file path
|
||||
* @param success true if the download succeeded
|
||||
* @param message human-readable result text
|
||||
*/
|
||||
public record CatalogDownloadResult(
|
||||
Path localCatalogFile,
|
||||
boolean success,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse/load result for one terrain catalog.
|
||||
*
|
||||
* @param terrainCatalog parsed catalog, or null
|
||||
* @param localCatalogFile source file path
|
||||
* @param success true if a usable catalog was loaded
|
||||
* @param message human-readable result text
|
||||
*/
|
||||
public record CatalogLoadResult(
|
||||
TerrainCatalog terrainCatalog,
|
||||
Path localCatalogFile,
|
||||
boolean success,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable catalog entry describing one downloadable terrain package.
|
||||
*
|
||||
* <p>This model is intentionally shared between the future server-side catalog
|
||||
* generation and the future desktop downloader.</p>
|
||||
*/
|
||||
public record TerrainCatalogPackageEntry(
|
||||
String packageId,
|
||||
String regionType,
|
||||
String regionId,
|
||||
int packageVersion,
|
||||
String downloadUrl,
|
||||
long sizeBytes,
|
||||
String sha256,
|
||||
double minLatitudeDeg,
|
||||
double maxLatitudeDeg,
|
||||
double minLongitudeDeg,
|
||||
double maxLongitudeDeg,
|
||||
List<String> tileIds,
|
||||
String sourceDataset,
|
||||
String sourceAttribution,
|
||||
String derivedProductNotice,
|
||||
String disclaimerNotice
|
||||
) {
|
||||
|
||||
public TerrainCatalogPackageEntry {
|
||||
packageId = normalizeLower(packageId);
|
||||
regionType = normalizeLower(regionType);
|
||||
regionId = normalizeUpper(regionId);
|
||||
downloadUrl = normalizeText(downloadUrl);
|
||||
sha256 = normalizeLower(sha256);
|
||||
tileIds = tileIds == null ? List.of() : tileIds.stream()
|
||||
.map(TerrainCatalogPackageEntry::normalizeUpper)
|
||||
.filter(value -> !value.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
sourceDataset = normalizeLower(sourceDataset);
|
||||
sourceAttribution = normalizeText(sourceAttribution);
|
||||
derivedProductNotice = normalizeText(derivedProductNotice);
|
||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
||||
|
||||
if (packageVersion < 0) {
|
||||
packageVersion = 0;
|
||||
}
|
||||
|
||||
if (sizeBytes < 0L) {
|
||||
sizeBytes = 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the entry contains enough information for download/install logic.
|
||||
*
|
||||
* @return true if the entry is usable
|
||||
*/
|
||||
public boolean isUsable() {
|
||||
return !packageId.isBlank()
|
||||
&& !regionType.isBlank()
|
||||
&& !regionId.isBlank()
|
||||
&& !downloadUrl.isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the package matches the given region identifier.
|
||||
*
|
||||
* @param expectedRegionType region type, e.g. "maidenhead4"
|
||||
* @param expectedRegionId region id, e.g. "JO22"
|
||||
* @return true if both match
|
||||
*/
|
||||
public boolean matchesRegion(String expectedRegionType, String expectedRegionId) {
|
||||
return regionType.equals(normalizeLower(expectedRegionType))
|
||||
&& regionId.equals(normalizeUpper(expectedRegionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the bounding box covers the given point.
|
||||
*
|
||||
* @param latitudeDeg latitude in degrees
|
||||
* @param longitudeDeg longitude in degrees
|
||||
* @return true if the point lies inside the package coverage box
|
||||
*/
|
||||
public boolean covers(double latitudeDeg, double longitudeDeg) {
|
||||
return Double.isFinite(latitudeDeg)
|
||||
&& Double.isFinite(longitudeDeg)
|
||||
&& latitudeDeg >= minLatitudeDeg
|
||||
&& latitudeDeg <= maxLatitudeDeg
|
||||
&& longitudeDeg >= minLongitudeDeg
|
||||
&& longitudeDeg <= maxLongitudeDeg;
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeLower(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Resolves which terrain packages and which 1° tiles are required for
|
||||
* a path analysis request.
|
||||
*
|
||||
* <p>This class is intentionally self-contained so it can later be reused
|
||||
* unchanged both in the desktop client and in a future Spring service.</p>
|
||||
*/
|
||||
public final class TerrainCoverageResolver {
|
||||
|
||||
/**
|
||||
* The current package scheme groups terrain downloads by Maidenhead-4 region.
|
||||
*/
|
||||
public static final String REGION_TYPE_MAIDENHEAD4 = "maidenhead4";
|
||||
|
||||
/**
|
||||
* Fixed region set label for the first Europe-wide terrain service generation.
|
||||
*/
|
||||
public static final String REGION_SET_EU = "eu";
|
||||
|
||||
/**
|
||||
* Sampling step used to determine required coverage along a path.
|
||||
*
|
||||
* <p>This is intentionally finer than the final profile sampling so that
|
||||
* package/tile coverage is not missed on diagonal or border-crossing paths.</p>
|
||||
*/
|
||||
private static final double COVERAGE_SAMPLE_STEP_KM = 10.0;
|
||||
|
||||
/**
|
||||
* Safety minimum number of sampling points per path.
|
||||
*/
|
||||
private static final int MIN_COVERAGE_SAMPLE_COUNT = 32;
|
||||
|
||||
public TerrainCoverageResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all package and tile requirements for the given path request.
|
||||
*
|
||||
* @param request immutable path analysis request
|
||||
* @return combined package/tile coverage selection
|
||||
*/
|
||||
public TerrainCoverageSelection resolveCoverageForPath(PathAnalysisRequest request) {
|
||||
if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) {
|
||||
return TerrainCoverageSelection.empty();
|
||||
}
|
||||
|
||||
return resolveCoverageForPath(
|
||||
request.fromLatitudeDeg(),
|
||||
request.fromLongitudeDeg(),
|
||||
request.toLatitudeDeg(),
|
||||
request.toLongitudeDeg()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves all package and tile requirements for the given geographic path.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return combined package/tile coverage selection
|
||||
*/
|
||||
public TerrainCoverageSelection resolveCoverageForPath(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
if (!areUsableCoordinates(fromLatitudeDeg, fromLongitudeDeg)
|
||||
|| !areUsableCoordinates(toLatitudeDeg, toLongitudeDeg)) {
|
||||
return TerrainCoverageSelection.empty();
|
||||
}
|
||||
|
||||
double totalDistanceKm = calculateGreatCircleDistanceKm(
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toLatitudeDeg,
|
||||
toLongitudeDeg
|
||||
);
|
||||
|
||||
int coverageSampleCount = resolveCoverageSampleCount(totalDistanceKm);
|
||||
|
||||
LinkedHashSet<String> requiredRegionIds = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> requiredPackageIds = new LinkedHashSet<>();
|
||||
LinkedHashSet<String> requiredTileIds = new LinkedHashSet<>();
|
||||
|
||||
for (int sampleIndex = 0; sampleIndex < coverageSampleCount; sampleIndex++) {
|
||||
double t = coverageSampleCount == 1
|
||||
? 0.0
|
||||
: (double) sampleIndex / (double) (coverageSampleCount - 1);
|
||||
|
||||
GeoPoint point = interpolateGreatCirclePoint(
|
||||
fromLatitudeDeg,
|
||||
fromLongitudeDeg,
|
||||
toLatitudeDeg,
|
||||
toLongitudeDeg,
|
||||
t
|
||||
);
|
||||
|
||||
if (!areUsableCoordinates(point.latitudeDeg(), point.longitudeDeg())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String maidenhead4 = toMaidenhead4(point.latitudeDeg(), point.longitudeDeg());
|
||||
if (!maidenhead4.isBlank()) {
|
||||
requiredRegionIds.add(maidenhead4);
|
||||
requiredPackageIds.add(buildPackageId(REGION_SET_EU, maidenhead4, 1));
|
||||
}
|
||||
|
||||
int southDeg = floorDegree(point.latitudeDeg());
|
||||
int westDeg = floorDegree(point.longitudeDeg());
|
||||
requiredTileIds.add(TerrainTileMetadata.buildTileId(southDeg, westDeg));
|
||||
}
|
||||
|
||||
return new TerrainCoverageSelection(
|
||||
List.copyOf(requiredRegionIds),
|
||||
List.copyOf(requiredPackageIds),
|
||||
List.copyOf(requiredTileIds),
|
||||
totalDistanceKm,
|
||||
coverageSampleCount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves only the required package ids for the given path.
|
||||
*
|
||||
* @param request immutable path analysis request
|
||||
* @return ordered distinct package ids
|
||||
*/
|
||||
public List<String> resolveRequiredPackageIdsForPath(PathAnalysisRequest request) {
|
||||
return resolveCoverageForPath(request).packageIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves only the required tile ids for the given path.
|
||||
*
|
||||
* @param request immutable path analysis request
|
||||
* @return ordered distinct tile ids
|
||||
*/
|
||||
public List<String> resolveRequiredTileIdsForPath(PathAnalysisRequest request) {
|
||||
return resolveCoverageForPath(request).tileIds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical package id for one region package.
|
||||
*
|
||||
* Example:
|
||||
* <ul>
|
||||
* <li>terrain-eu-jo22-v1</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param regionSet region set, e.g. "eu"
|
||||
* @param regionId region id, e.g. "JO22"
|
||||
* @param packageVersion package version
|
||||
* @return canonical package id
|
||||
*/
|
||||
public static String buildPackageId(String regionSet, String regionId, int packageVersion) {
|
||||
String normalizedRegionSet = regionSet == null ? "" : regionSet.trim().toLowerCase(Locale.ROOT);
|
||||
String normalizedRegionId = regionId == null ? "" : regionId.trim().toLowerCase(Locale.ROOT);
|
||||
int normalizedPackageVersion = Math.max(0, packageVersion);
|
||||
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"terrain-%s-%s-v%d",
|
||||
normalizedRegionSet,
|
||||
normalizedRegionId,
|
||||
normalizedPackageVersion
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Maidenhead-4 region id for the given point.
|
||||
*
|
||||
* @param latitudeDeg latitude in degrees
|
||||
* @param longitudeDeg longitude in degrees
|
||||
* @return Maidenhead-4 id such as JO22, or empty string if conversion failed
|
||||
*/
|
||||
public static String toMaidenhead4(double latitudeDeg, double longitudeDeg) {
|
||||
if (!areUsableCoordinates(latitudeDeg, longitudeDeg)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String maidenhead6 = Location.toMaidenhead(latitudeDeg, longitudeDeg);
|
||||
if (maidenhead6 == null || maidenhead6.length() < 4) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return maidenhead6.substring(0, 4).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static int resolveCoverageSampleCount(double totalDistanceKm) {
|
||||
if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) {
|
||||
return MIN_COVERAGE_SAMPLE_COUNT;
|
||||
}
|
||||
|
||||
int computedSampleCount = (int) Math.ceil(totalDistanceKm / COVERAGE_SAMPLE_STEP_KM) + 1;
|
||||
return Math.max(MIN_COVERAGE_SAMPLE_COUNT, computedSampleCount);
|
||||
}
|
||||
|
||||
private static int floorDegree(double value) {
|
||||
return (int) Math.floor(value);
|
||||
}
|
||||
|
||||
private static boolean areUsableCoordinates(double latitudeDeg, double longitudeDeg) {
|
||||
return Double.isFinite(latitudeDeg)
|
||||
&& Double.isFinite(longitudeDeg)
|
||||
&& latitudeDeg >= -90.0
|
||||
&& latitudeDeg <= 90.0
|
||||
&& longitudeDeg >= -180.0
|
||||
&& longitudeDeg <= 180.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Great-circle distance in kilometers using the haversine formula.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @return great-circle distance in kilometers
|
||||
*/
|
||||
private static double calculateGreatCircleDistanceKm(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg) {
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double deltaLatitude = toLatitudeRad - fromLatitudeRad;
|
||||
double deltaLongitude = toLongitudeRad - fromLongitudeRad;
|
||||
|
||||
double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0)
|
||||
+ Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad)
|
||||
* Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0);
|
||||
|
||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a)));
|
||||
|
||||
return 6371.009 * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates one point on the great-circle path between the given endpoints.
|
||||
*
|
||||
* @param fromLatitudeDeg source latitude in degrees
|
||||
* @param fromLongitudeDeg source longitude in degrees
|
||||
* @param toLatitudeDeg target latitude in degrees
|
||||
* @param toLongitudeDeg target longitude in degrees
|
||||
* @param t interpolation factor within [0, 1]
|
||||
* @return interpolated geographic point
|
||||
*/
|
||||
private static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double t) {
|
||||
|
||||
double clampedT = clamp(t, 0.0, 1.0);
|
||||
|
||||
if (clampedT <= 0.0) {
|
||||
return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg));
|
||||
}
|
||||
|
||||
if (clampedT >= 1.0) {
|
||||
return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg));
|
||||
}
|
||||
|
||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
||||
|
||||
double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad);
|
||||
double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad);
|
||||
double z1 = Math.sin(fromLatitudeRad);
|
||||
|
||||
double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad);
|
||||
double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad);
|
||||
double z2 = Math.sin(toLatitudeRad);
|
||||
|
||||
double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0);
|
||||
double omega = Math.acos(dot);
|
||||
|
||||
if (omega < 1e-12) {
|
||||
double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT;
|
||||
double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT);
|
||||
return new GeoPoint(latitudeDeg, longitudeDeg);
|
||||
}
|
||||
|
||||
double sinOmega = Math.sin(omega);
|
||||
double a = Math.sin((1.0 - clampedT) * omega) / sinOmega;
|
||||
double b = Math.sin(clampedT * omega) / sinOmega;
|
||||
|
||||
double x = a * x1 + b * x2;
|
||||
double y = a * y1 + b * y2;
|
||||
double z = a * z1 + b * z2;
|
||||
|
||||
double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y));
|
||||
double longitudeRad = Math.atan2(y, x);
|
||||
|
||||
return new GeoPoint(
|
||||
Math.toDegrees(latitudeRad),
|
||||
normalizeLongitudeDeg(Math.toDegrees(longitudeRad))
|
||||
);
|
||||
}
|
||||
|
||||
private static double normalizeLongitudeDeg(double longitudeDeg) {
|
||||
double normalized = longitudeDeg % 360.0;
|
||||
|
||||
if (normalized > 180.0) {
|
||||
normalized -= 360.0;
|
||||
} else if (normalized <= -180.0) {
|
||||
normalized += 360.0;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static double clamp(double value, double minValue, double maxValue) {
|
||||
return Math.max(minValue, Math.min(maxValue, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined path coverage selection.
|
||||
*
|
||||
* @param regionIds ordered distinct Maidenhead-4 region ids
|
||||
* @param packageIds ordered distinct package ids
|
||||
* @param tileIds ordered distinct 1° tile ids
|
||||
* @param totalDistanceKm great-circle path distance in kilometers
|
||||
* @param coverageSampleCount number of sampling points used for coverage resolution
|
||||
*/
|
||||
public record TerrainCoverageSelection(
|
||||
List<String> regionIds,
|
||||
List<String> packageIds,
|
||||
List<String> tileIds,
|
||||
double totalDistanceKm,
|
||||
int coverageSampleCount
|
||||
) {
|
||||
public TerrainCoverageSelection {
|
||||
regionIds = regionIds == null ? List.of() : List.copyOf(regionIds);
|
||||
packageIds = packageIds == null ? List.of() : List.copyOf(packageIds);
|
||||
tileIds = tileIds == null ? List.of() : List.copyOf(tileIds);
|
||||
|
||||
if (coverageSampleCount < 0) {
|
||||
coverageSampleCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static TerrainCoverageSelection empty() {
|
||||
return new TerrainCoverageSelection(List.of(), List.of(), List.of(), Double.NaN, 0);
|
||||
}
|
||||
|
||||
public boolean hasCoverage() {
|
||||
return !packageIds.isEmpty() || !tileIds.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small immutable geographic point for great-circle interpolation.
|
||||
*
|
||||
* @param latitudeDeg latitude in degrees
|
||||
* @param longitudeDeg longitude in degrees
|
||||
*/
|
||||
private record GeoPoint(double latitudeDeg, double longitudeDeg) {
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Downloads terrain package archives (*.tpak) to the local .praktiKST directory.
|
||||
*
|
||||
* <p>This implementation is intentionally simple and robust:
|
||||
* <ul>
|
||||
* <li>download packages by catalog entry</li>
|
||||
* <li>store them below terrain/packages</li>
|
||||
* <li>reuse existing files when the checksum already matches</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class TerrainPackageDownloader {
|
||||
|
||||
private static final String LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY = "terrain/packages";
|
||||
|
||||
private final HttpClient httpClient;
|
||||
|
||||
public TerrainPackageDownloader() {
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads all required package ids that are present in the catalog.
|
||||
*
|
||||
* @param terrainCatalog current terrain catalog
|
||||
* @param requiredPackageIds ordered package ids
|
||||
* @return batch download result
|
||||
*/
|
||||
public BatchDownloadResult downloadPackages(TerrainCatalog terrainCatalog, List<String> requiredPackageIds) {
|
||||
List<PackageDownloadResult> itemResults = new ArrayList<>();
|
||||
|
||||
if (terrainCatalog == null || requiredPackageIds == null || requiredPackageIds.isEmpty()) {
|
||||
return new BatchDownloadResult(List.of(), "No terrain packages were requested.");
|
||||
}
|
||||
|
||||
for (String packageId : requiredPackageIds) {
|
||||
PackageDownloadResult itemResult = terrainCatalog.findPackageById(packageId)
|
||||
.map(this::downloadPackage)
|
||||
.orElseGet(() -> new PackageDownloadResult(
|
||||
packageId,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
"Package id is not present in the loaded catalog."
|
||||
));
|
||||
|
||||
itemResults.add(itemResult);
|
||||
}
|
||||
|
||||
return new BatchDownloadResult(itemResults, buildBatchMessage(itemResults));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads one package archive from one catalog entry.
|
||||
*
|
||||
* @param packageEntry catalog package entry
|
||||
* @return download result
|
||||
*/
|
||||
public PackageDownloadResult downloadPackage(TerrainCatalogPackageEntry packageEntry) {
|
||||
if (packageEntry == null || !packageEntry.isUsable()) {
|
||||
return new PackageDownloadResult(
|
||||
"",
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
"Terrain package entry is missing or incomplete."
|
||||
);
|
||||
}
|
||||
|
||||
Path packagesDirectory = resolveLocalPackagesDirectory();
|
||||
Path localPackageFile = packagesDirectory.resolve(packageEntry.packageId() + ".tpak");
|
||||
|
||||
try {
|
||||
Files.createDirectories(packagesDirectory);
|
||||
|
||||
if (Files.isRegularFile(localPackageFile)
|
||||
&& !packageEntry.sha256().isBlank()
|
||||
&& packageEntry.sha256().equalsIgnoreCase(computeSha256(localPackageFile))) {
|
||||
return new PackageDownloadResult(
|
||||
packageEntry.packageId(),
|
||||
localPackageFile,
|
||||
true,
|
||||
false,
|
||||
"Terrain package is already present locally and matches the expected checksum."
|
||||
);
|
||||
}
|
||||
|
||||
Path tempFile = packagesDirectory.resolve(packageEntry.packageId() + ".download");
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(packageEntry.downloadUrl()))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<Path> response = httpClient.send(
|
||||
httpRequest,
|
||||
HttpResponse.BodyHandlers.ofFile(tempFile)
|
||||
);
|
||||
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
Files.deleteIfExists(tempFile);
|
||||
|
||||
return new PackageDownloadResult(
|
||||
packageEntry.packageId(),
|
||||
localPackageFile,
|
||||
false,
|
||||
false,
|
||||
"Terrain package download failed with HTTP status " + response.statusCode() + "."
|
||||
);
|
||||
}
|
||||
|
||||
if (!packageEntry.sha256().isBlank()) {
|
||||
String actualSha256 = computeSha256(tempFile);
|
||||
if (!packageEntry.sha256().equalsIgnoreCase(actualSha256)) {
|
||||
Files.deleteIfExists(tempFile);
|
||||
|
||||
return new PackageDownloadResult(
|
||||
packageEntry.packageId(),
|
||||
localPackageFile,
|
||||
false,
|
||||
false,
|
||||
"Downloaded terrain package checksum does not match the catalog."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Files.move(tempFile, localPackageFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
return new PackageDownloadResult(
|
||||
packageEntry.packageId(),
|
||||
localPackageFile,
|
||||
true,
|
||||
true,
|
||||
"Terrain package downloaded successfully."
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new PackageDownloadResult(
|
||||
packageEntry.packageId(),
|
||||
localPackageFile,
|
||||
false,
|
||||
false,
|
||||
"Terrain package download failed: " + exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveLocalPackagesDirectory() {
|
||||
return Path.of(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY
|
||||
));
|
||||
}
|
||||
|
||||
private String computeSha256(Path file) throws Exception {
|
||||
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
try (InputStream inputStream = Files.newInputStream(file)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
|
||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
||||
messageDigest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
return HexFormat.of().formatHex(messageDigest.digest());
|
||||
}
|
||||
|
||||
private String buildBatchMessage(List<PackageDownloadResult> itemResults) {
|
||||
int successfulCount = 0;
|
||||
int downloadedCount = 0;
|
||||
int reusedCount = 0;
|
||||
|
||||
for (PackageDownloadResult itemResult : itemResults) {
|
||||
if (itemResult.success()) {
|
||||
successfulCount++;
|
||||
}
|
||||
if (itemResult.downloadedNow()) {
|
||||
downloadedCount++;
|
||||
} else if (itemResult.success()) {
|
||||
reusedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return "Terrain packages processed: "
|
||||
+ itemResults.size()
|
||||
+ ", successful: "
|
||||
+ successfulCount
|
||||
+ ", downloaded now: "
|
||||
+ downloadedCount
|
||||
+ ", reused locally: "
|
||||
+ reusedCount
|
||||
+ ".";
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of one terrain package download attempt.
|
||||
*
|
||||
* @param packageId canonical package id
|
||||
* @param localPackageFile local target package file
|
||||
* @param success true if a usable local package file is available afterward
|
||||
* @param downloadedNow true if the package was downloaded in this run
|
||||
* @param message human-readable result text
|
||||
*/
|
||||
public record PackageDownloadResult(
|
||||
String packageId,
|
||||
Path localPackageFile,
|
||||
boolean success,
|
||||
boolean downloadedNow,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a batch terrain package download attempt.
|
||||
*
|
||||
* @param itemResults one result per requested package
|
||||
* @param message human-readable summary text
|
||||
*/
|
||||
public record BatchDownloadResult(
|
||||
List<PackageDownloadResult> itemResults,
|
||||
String message
|
||||
) {
|
||||
public BatchDownloadResult {
|
||||
itemResults = itemResults == null ? List.of() : List.copyOf(itemResults);
|
||||
}
|
||||
|
||||
public boolean allSuccessful() {
|
||||
return !itemResults.isEmpty() && itemResults.stream().allMatch(PackageDownloadResult::success);
|
||||
}
|
||||
|
||||
public List<Path> successfulPackageFiles() {
|
||||
return itemResults.stream()
|
||||
.filter(PackageDownloadResult::success)
|
||||
.map(PackageDownloadResult::localPackageFile)
|
||||
.filter(path -> path != null)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
/**
|
||||
* Installs one downloaded terrain package into the local DEM directory.
|
||||
*
|
||||
* <p>This first vertical slice intentionally extracts raw Copernicus *_DEM.tif
|
||||
* files, because the current runtime already knows how to scan and load them.
|
||||
* That means we get an end-to-end working package flow quickly without first
|
||||
* introducing a second runtime terrain format.</p>
|
||||
*/
|
||||
public final class TerrainPackageInstaller {
|
||||
|
||||
private static final String DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY = "dem/copernicus_glo30";
|
||||
|
||||
/**
|
||||
* Installs one terrain package into the configured DEM root directory.
|
||||
*
|
||||
* <p>If no DEM root directory is configured, the default directory below
|
||||
* .praktiKST is used automatically.</p>
|
||||
*
|
||||
* @param packageFile local *.tpak file
|
||||
* @param configuredDemRootDirectory user-configured DEM root directory text
|
||||
* @return installation result
|
||||
*/
|
||||
public InstallResult installPackage(Path packageFile, String configuredDemRootDirectory) {
|
||||
if (packageFile == null || !Files.isRegularFile(packageFile)) {
|
||||
return new InstallResult(
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
"Terrain package file does not exist."
|
||||
);
|
||||
}
|
||||
|
||||
try (ZipFile zipFile = new ZipFile(packageFile.toFile())) {
|
||||
ZipEntry manifestEntry = zipFile.getEntry("manifest.xml");
|
||||
if (manifestEntry == null) {
|
||||
return new InstallResult(
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
"Terrain package does not contain manifest.xml."
|
||||
);
|
||||
}
|
||||
|
||||
TerrainPackageManifest terrainPackageManifest;
|
||||
try (InputStream manifestInputStream = zipFile.getInputStream(manifestEntry)) {
|
||||
terrainPackageManifest = parseManifest(manifestInputStream);
|
||||
}
|
||||
|
||||
if (terrainPackageManifest == null || !terrainPackageManifest.hasUsableTiles()) {
|
||||
return new InstallResult(
|
||||
terrainPackageManifest,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
"Terrain package manifest is missing or contains no usable tiles."
|
||||
);
|
||||
}
|
||||
|
||||
Path demRootDirectory = resolveEffectiveDemRootDirectory(configuredDemRootDirectory);
|
||||
Path targetPackageDirectory = demRootDirectory
|
||||
.resolve("packages")
|
||||
.resolve(terrainPackageManifest.packageId());
|
||||
|
||||
Files.createDirectories(targetPackageDirectory);
|
||||
|
||||
int installedTileCount = 0;
|
||||
|
||||
List<? extends ZipEntry> zipEntries = zipFile.stream().toList();
|
||||
for (ZipEntry zipEntry : zipEntries) {
|
||||
if (zipEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String entryName = zipEntry.getName();
|
||||
String fileName = Path.of(entryName).getFileName().toString();
|
||||
|
||||
if ("manifest.xml".equalsIgnoreCase(fileName)) {
|
||||
Path targetFile = targetPackageDirectory.resolve("manifest.xml");
|
||||
extractZipEntry(zipFile, zipEntry, targetFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Path targetFile = targetPackageDirectory.resolve(fileName);
|
||||
extractZipEntry(zipFile, zipEntry, targetFile);
|
||||
installedTileCount++;
|
||||
}
|
||||
|
||||
if (installedTileCount <= 0) {
|
||||
return new InstallResult(
|
||||
terrainPackageManifest,
|
||||
targetPackageDirectory,
|
||||
0,
|
||||
false,
|
||||
"Terrain package did not contain any supported Copernicus *_DEM.tif files."
|
||||
);
|
||||
}
|
||||
|
||||
return new InstallResult(
|
||||
terrainPackageManifest,
|
||||
targetPackageDirectory,
|
||||
installedTileCount,
|
||||
true,
|
||||
"Installed "
|
||||
+ installedTileCount
|
||||
+ " DEM tile(s) into:\n"
|
||||
+ targetPackageDirectory.toAbsolutePath()
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new InstallResult(
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
"Terrain package installation failed: " + exception.getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveEffectiveDemRootDirectory(String configuredDemRootDirectory) {
|
||||
if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) {
|
||||
return Path.of(configuredDemRootDirectory.trim());
|
||||
}
|
||||
|
||||
return Path.of(ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY
|
||||
));
|
||||
}
|
||||
|
||||
private void extractZipEntry(ZipFile zipFile, ZipEntry zipEntry, Path targetFile) throws Exception {
|
||||
Path normalizedTarget = targetFile.normalize();
|
||||
|
||||
if (normalizedTarget.getParent() != null) {
|
||||
Files.createDirectories(normalizedTarget.getParent());
|
||||
}
|
||||
|
||||
try (InputStream entryInputStream = zipFile.getInputStream(zipEntry)) {
|
||||
Files.copy(entryInputStream, normalizedTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private TerrainPackageManifest parseManifest(InputStream inputStream) throws Exception {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
||||
Document document = documentBuilder.parse(inputStream);
|
||||
|
||||
Element root = document.getDocumentElement();
|
||||
if (root == null || !"terrainPackageManifest".equals(root.getTagName())) {
|
||||
throw new IllegalArgumentException("Unexpected package manifest root element.");
|
||||
}
|
||||
|
||||
int schemaVersion = parseIntAttribute(root, "schemaVersion", 1);
|
||||
String packageId = root.getAttribute("packageId");
|
||||
int packageVersion = parseIntAttribute(root, "packageVersion", 1);
|
||||
String regionType = root.getAttribute("regionType");
|
||||
String regionId = root.getAttribute("regionId");
|
||||
|
||||
double minLatitudeDeg = parseDoubleAttribute(root, "minLatitudeDeg", Double.NaN);
|
||||
double maxLatitudeDeg = parseDoubleAttribute(root, "maxLatitudeDeg", Double.NaN);
|
||||
double minLongitudeDeg = parseDoubleAttribute(root, "minLongitudeDeg", Double.NaN);
|
||||
double maxLongitudeDeg = parseDoubleAttribute(root, "maxLongitudeDeg", Double.NaN);
|
||||
|
||||
String primaryDataset = root.getAttribute("primaryDataset");
|
||||
String fallbackDataset = root.getAttribute("fallbackDataset");
|
||||
String packageBuiltAtUtc = root.getAttribute("packageBuiltAtUtc");
|
||||
String packageSha256 = root.getAttribute("packageSha256");
|
||||
|
||||
String sourceAttribution = getDirectChildText(root, "sourceAttribution");
|
||||
String derivedProductNotice = getDirectChildText(root, "derivedProductNotice");
|
||||
String disclaimerNotice = getDirectChildText(root, "disclaimerNotice");
|
||||
|
||||
List<TerrainTileMetadata> tiles = new ArrayList<>();
|
||||
Element tilesElement = getFirstDirectChild(root, "tiles");
|
||||
|
||||
if (tilesElement != null) {
|
||||
for (Element tileElement : getDirectChildElements(tilesElement, "tile")) {
|
||||
tiles.add(new TerrainTileMetadata(
|
||||
tileElement.getAttribute("tileId"),
|
||||
tileElement.getAttribute("fileName"),
|
||||
parseIntAttribute(tileElement, "southDeg", 0),
|
||||
parseIntAttribute(tileElement, "westDeg", 0),
|
||||
parseIntAttribute(tileElement, "width", 3601),
|
||||
parseIntAttribute(tileElement, "height", 3601),
|
||||
parseIntAttribute(tileElement, "arcSecondResolution", 1),
|
||||
(short) parseIntAttribute(tileElement, "noDataValue", -32768),
|
||||
tileElement.getAttribute("sourceDataset"),
|
||||
tileElement.getAttribute("sha256")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return new TerrainPackageManifest(
|
||||
schemaVersion,
|
||||
packageId,
|
||||
packageVersion,
|
||||
regionType,
|
||||
regionId,
|
||||
minLatitudeDeg,
|
||||
maxLatitudeDeg,
|
||||
minLongitudeDeg,
|
||||
maxLongitudeDeg,
|
||||
primaryDataset,
|
||||
fallbackDataset,
|
||||
packageBuiltAtUtc,
|
||||
sourceAttribution,
|
||||
derivedProductNotice,
|
||||
disclaimerNotice,
|
||||
packageSha256,
|
||||
tiles
|
||||
);
|
||||
}
|
||||
|
||||
private static List<Element> getDirectChildElements(Element parent, String tagName) {
|
||||
List<Element> result = new ArrayList<>();
|
||||
|
||||
if (parent == null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (int index = 0; index < parent.getChildNodes().getLength(); index++) {
|
||||
Node node = parent.getChildNodes().item(index);
|
||||
if (node instanceof Element element && tagName.equals(element.getTagName())) {
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Element getFirstDirectChild(Element parent, String tagName) {
|
||||
for (Element element : getDirectChildElements(parent, tagName)) {
|
||||
return element;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getDirectChildText(Element parent, String tagName) {
|
||||
Element child = getFirstDirectChild(parent, tagName);
|
||||
return child == null ? "" : child.getTextContent().trim();
|
||||
}
|
||||
|
||||
private static int parseIntAttribute(Element element, String attributeName, int defaultValue) {
|
||||
try {
|
||||
return Integer.parseInt(element.getAttribute(attributeName));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) {
|
||||
try {
|
||||
return Double.parseDouble(element.getAttribute(attributeName));
|
||||
} catch (Exception ignored) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of one terrain package installation.
|
||||
*
|
||||
* @param terrainPackageManifest parsed manifest, or null
|
||||
* @param targetPackageDirectory extraction target directory
|
||||
* @param installedTileCount number of extracted DEM GeoTIFF files
|
||||
* @param success true if at least one usable tile was installed
|
||||
* @param message human-readable result text
|
||||
*/
|
||||
public record InstallResult(
|
||||
TerrainPackageManifest terrainPackageManifest,
|
||||
Path targetPackageDirectory,
|
||||
int installedTileCount,
|
||||
boolean success,
|
||||
String message
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immutable manifest describing one locally installable terrain package.
|
||||
*
|
||||
* <p>This model is the authoritative description of the package contents after
|
||||
* download and before/after installation.</p>
|
||||
*/
|
||||
public record TerrainPackageManifest(
|
||||
int schemaVersion,
|
||||
String packageId,
|
||||
int packageVersion,
|
||||
String regionType,
|
||||
String regionId,
|
||||
double minLatitudeDeg,
|
||||
double maxLatitudeDeg,
|
||||
double minLongitudeDeg,
|
||||
double maxLongitudeDeg,
|
||||
String primaryDataset,
|
||||
String fallbackDataset,
|
||||
String packageBuiltAtUtc,
|
||||
String sourceAttribution,
|
||||
String derivedProductNotice,
|
||||
String disclaimerNotice,
|
||||
String packageSha256,
|
||||
List<TerrainTileMetadata> tiles
|
||||
) {
|
||||
|
||||
public TerrainPackageManifest {
|
||||
packageId = normalizeLower(packageId);
|
||||
regionType = normalizeLower(regionType);
|
||||
regionId = normalizeUpper(regionId);
|
||||
primaryDataset = normalizeLower(primaryDataset);
|
||||
fallbackDataset = normalizeLower(fallbackDataset);
|
||||
packageBuiltAtUtc = normalizeText(packageBuiltAtUtc);
|
||||
sourceAttribution = normalizeText(sourceAttribution);
|
||||
derivedProductNotice = normalizeText(derivedProductNotice);
|
||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
||||
packageSha256 = normalizeLower(packageSha256);
|
||||
tiles = tiles == null ? List.of() : List.copyOf(tiles);
|
||||
|
||||
if (schemaVersion < 0) {
|
||||
schemaVersion = 0;
|
||||
}
|
||||
|
||||
if (packageVersion < 0) {
|
||||
packageVersion = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the manifest contains at least one usable tile.
|
||||
*
|
||||
* @return true if the manifest appears installable
|
||||
*/
|
||||
public boolean hasUsableTiles() {
|
||||
return tiles.stream().anyMatch(TerrainTileMetadata::isUsable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one tile metadata entry by canonical tile id.
|
||||
*
|
||||
* @param tileId canonical tile id
|
||||
* @return tile metadata if found
|
||||
*/
|
||||
public Optional<TerrainTileMetadata> findTile(String tileId) {
|
||||
String normalizedTileId = normalizeUpper(tileId);
|
||||
|
||||
return tiles.stream()
|
||||
.filter(tile -> tile.tileId().equals(normalizedTileId))
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the manifest belongs to the given region.
|
||||
*
|
||||
* @param expectedRegionType region type
|
||||
* @param expectedRegionId region id
|
||||
* @return true if region type and id match
|
||||
*/
|
||||
public boolean matchesRegion(String expectedRegionType, String expectedRegionId) {
|
||||
return regionType.equals(normalizeLower(expectedRegionType))
|
||||
&& regionId.equals(normalizeUpper(expectedRegionId));
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeLower(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.locatorUtils.Location;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* High-level orchestration service for terrain package preparation.
|
||||
*
|
||||
* <p>This is the first end-to-end vertical slice that connects:
|
||||
* <ul>
|
||||
* <li>path coverage resolution</li>
|
||||
* <li>catalog download/load</li>
|
||||
* <li>package download</li>
|
||||
* <li>package installation into the local DEM directory</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The current implementation intentionally installs Copernicus *_DEM.tif files
|
||||
* into the DEM root that is already used by the existing terrain provider chain.
|
||||
* That allows us to reach a working automated download flow with very few
|
||||
* intermediate refactorings.</p>
|
||||
*/
|
||||
public final class TerrainPackageService {
|
||||
|
||||
/**
|
||||
* First default catalog URL for the future modular terrain service on hamradioonline.de.
|
||||
*
|
||||
* <p>This can later move into user preferences without changing the orchestration flow.</p>
|
||||
*/
|
||||
public static final String DEFAULT_TERRAIN_CATALOG_URL =
|
||||
"https://terrain.hamradioonline.de/catalog/terrain-catalog-v1.xml";
|
||||
|
||||
private final TerrainCoverageResolver terrainCoverageResolver;
|
||||
private final TerrainCatalogClient terrainCatalogClient;
|
||||
private final TerrainPackageDownloader terrainPackageDownloader;
|
||||
private final TerrainPackageInstaller terrainPackageInstaller;
|
||||
|
||||
public TerrainPackageService() {
|
||||
this(
|
||||
new TerrainCoverageResolver(),
|
||||
new TerrainCatalogClient(),
|
||||
new TerrainPackageDownloader(),
|
||||
new TerrainPackageInstaller()
|
||||
);
|
||||
}
|
||||
|
||||
public TerrainPackageService(TerrainCoverageResolver terrainCoverageResolver,
|
||||
TerrainCatalogClient terrainCatalogClient,
|
||||
TerrainPackageDownloader terrainPackageDownloader,
|
||||
TerrainPackageInstaller terrainPackageInstaller) {
|
||||
this.terrainCoverageResolver = terrainCoverageResolver;
|
||||
this.terrainCatalogClient = terrainCatalogClient;
|
||||
this.terrainPackageDownloader = terrainPackageDownloader;
|
||||
this.terrainPackageInstaller = terrainPackageInstaller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares all terrain packages required for the given path using the default catalog URL.
|
||||
*
|
||||
* @param request path analysis request
|
||||
* @param configuredDemRootDirectory currently configured DEM root directory
|
||||
* @return combined terrain preparation result
|
||||
*/
|
||||
public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request,
|
||||
String configuredDemRootDirectory) {
|
||||
return prepareTerrainForPath(
|
||||
request,
|
||||
DEFAULT_TERRAIN_CATALOG_URL,
|
||||
configuredDemRootDirectory
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares all terrain packages required for the given path.
|
||||
*
|
||||
* <p>Workflow:
|
||||
* <ol>
|
||||
* <li>resolve required package ids from the path geometry</li>
|
||||
* <li>download the catalog</li>
|
||||
* <li>fallback to the last local catalog if the download fails</li>
|
||||
* <li>download all matching required packages</li>
|
||||
* <li>install all successfully downloaded packages into the DEM root</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param request path analysis request
|
||||
* @param catalogUrl terrain catalog URL
|
||||
* @param configuredDemRootDirectory currently configured DEM root directory
|
||||
* @return combined terrain preparation result
|
||||
*/
|
||||
public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request,
|
||||
String catalogUrl,
|
||||
String configuredDemRootDirectory) {
|
||||
|
||||
if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) {
|
||||
return TerrainPreparationResult.failure(
|
||||
request,
|
||||
TerrainCoverageResolver.TerrainCoverageSelection.empty(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
"Path request is missing or does not contain usable endpoints."
|
||||
);
|
||||
}
|
||||
|
||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection =
|
||||
terrainCoverageResolver.resolveCoverageForPath(request);
|
||||
|
||||
if (!coverageSelection.hasCoverage()) {
|
||||
return TerrainPreparationResult.failure(
|
||||
request,
|
||||
coverageSelection,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
"No terrain coverage could be resolved for the requested path."
|
||||
);
|
||||
}
|
||||
|
||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult =
|
||||
terrainCatalogClient.downloadCatalog(catalogUrl);
|
||||
|
||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult =
|
||||
terrainCatalogClient.loadLocalCatalog();
|
||||
|
||||
if (!catalogLoadResult.success() || catalogLoadResult.terrainCatalog() == null) {
|
||||
StringBuilder message = new StringBuilder();
|
||||
message.append("Terrain catalog is not available.");
|
||||
|
||||
if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) {
|
||||
message.append("\n\nDownload: ").append(catalogDownloadResult.message());
|
||||
}
|
||||
|
||||
if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) {
|
||||
message.append("\n\nLoad: ").append(catalogLoadResult.message());
|
||||
}
|
||||
|
||||
return TerrainPreparationResult.failure(
|
||||
request,
|
||||
coverageSelection,
|
||||
catalogDownloadResult,
|
||||
catalogLoadResult,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
message.toString()
|
||||
);
|
||||
}
|
||||
|
||||
TerrainCatalog terrainCatalog = catalogLoadResult.terrainCatalog();
|
||||
|
||||
List<String> missingPackageIds = coverageSelection.packageIds().stream()
|
||||
.filter(packageId -> terrainCatalog.findPackageById(packageId).isEmpty())
|
||||
.toList();
|
||||
|
||||
List<String> availablePackageIds = coverageSelection.packageIds().stream()
|
||||
.filter(packageId -> terrainCatalog.findPackageById(packageId).isPresent())
|
||||
.toList();
|
||||
|
||||
if (availablePackageIds.isEmpty()) {
|
||||
return TerrainPreparationResult.failure(
|
||||
request,
|
||||
coverageSelection,
|
||||
catalogDownloadResult,
|
||||
catalogLoadResult,
|
||||
null,
|
||||
missingPackageIds,
|
||||
List.of(),
|
||||
"None of the required terrain packages are present in the loaded catalog."
|
||||
);
|
||||
}
|
||||
|
||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult =
|
||||
terrainPackageDownloader.downloadPackages(terrainCatalog, availablePackageIds);
|
||||
|
||||
List<TerrainPackageInstaller.InstallResult> installResults = new ArrayList<>();
|
||||
for (Path packageFile : batchDownloadResult.successfulPackageFiles()) {
|
||||
installResults.add(
|
||||
terrainPackageInstaller.installPackage(packageFile, configuredDemRootDirectory)
|
||||
);
|
||||
}
|
||||
|
||||
String message = buildSummaryMessage(
|
||||
coverageSelection,
|
||||
catalogDownloadResult,
|
||||
catalogLoadResult,
|
||||
batchDownloadResult,
|
||||
missingPackageIds,
|
||||
installResults
|
||||
);
|
||||
|
||||
boolean success = missingPackageIds.isEmpty()
|
||||
&& batchDownloadResult != null
|
||||
&& batchDownloadResult.allSuccessful()
|
||||
&& installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success);
|
||||
|
||||
return new TerrainPreparationResult(
|
||||
request,
|
||||
coverageSelection,
|
||||
catalogDownloadResult,
|
||||
catalogLoadResult,
|
||||
batchDownloadResult,
|
||||
List.copyOf(missingPackageIds),
|
||||
List.copyOf(installResults),
|
||||
success,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper for the likely future main workflow where only locators
|
||||
* are available from the chat/channel context.
|
||||
*
|
||||
* @param ownLocator6 own 6-digit Maidenhead locator
|
||||
* @param targetLocator6 target 6-digit Maidenhead locator
|
||||
* @param targetCallsignRaw target callsign
|
||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
||||
* @param ownAntennaHeightMeters own antenna height in meters AGL
|
||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
||||
* @param configuredDemRootDirectory configured DEM root directory
|
||||
* @return combined terrain preparation result
|
||||
*/
|
||||
public TerrainPreparationResult prepareTerrainForLocators(String ownLocator6,
|
||||
String targetLocator6,
|
||||
String targetCallsignRaw,
|
||||
double analysisFrequencyMHz,
|
||||
double ownAntennaHeightMeters,
|
||||
double targetAntennaHeightMeters,
|
||||
String configuredDemRootDirectory) {
|
||||
|
||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
||||
String normalizedTargetLocator6 = normalizeLocator6(targetLocator6);
|
||||
|
||||
if (normalizedOwnLocator6.length() != 6 || normalizedTargetLocator6.length() != 6) {
|
||||
return TerrainPreparationResult.failure(
|
||||
null,
|
||||
TerrainCoverageResolver.TerrainCoverageSelection.empty(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of(),
|
||||
List.of(),
|
||||
"Own locator or target locator is missing/invalid."
|
||||
);
|
||||
}
|
||||
|
||||
Location ownLocation = new Location(normalizedOwnLocator6);
|
||||
Location targetLocation = new Location(normalizedTargetLocator6);
|
||||
|
||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
||||
normalizedOwnLocator6,
|
||||
ownLocation.getLatitude().toDegrees(),
|
||||
ownLocation.getLongitude().toDegrees(),
|
||||
normalizeCallsignRaw(targetCallsignRaw),
|
||||
normalizedTargetLocator6,
|
||||
targetLocation.getLatitude().toDegrees(),
|
||||
targetLocation.getLongitude().toDegrees(),
|
||||
Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0
|
||||
? analysisFrequencyMHz
|
||||
: PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ,
|
||||
sanitizeAntennaHeightMeters(ownAntennaHeightMeters),
|
||||
sanitizeAntennaHeightMeters(targetAntennaHeightMeters)
|
||||
);
|
||||
|
||||
return prepareTerrainForPath(request, configuredDemRootDirectory);
|
||||
}
|
||||
|
||||
private String buildSummaryMessage(TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
||||
List<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> installResults) {
|
||||
|
||||
int successfulInstallCount = (int) installResults.stream()
|
||||
.filter(TerrainPackageInstaller.InstallResult::success)
|
||||
.count();
|
||||
|
||||
int installedTileCount = installResults.stream()
|
||||
.filter(TerrainPackageInstaller.InstallResult::success)
|
||||
.mapToInt(TerrainPackageInstaller.InstallResult::installedTileCount)
|
||||
.sum();
|
||||
|
||||
StringBuilder message = new StringBuilder();
|
||||
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"Required coverage: %d package(s), %d tile(s), %.1f km path, %d coverage samples.",
|
||||
coverageSelection.packageIds().size(),
|
||||
coverageSelection.tileIds().size(),
|
||||
coverageSelection.totalDistanceKm(),
|
||||
coverageSelection.coverageSampleCount()
|
||||
));
|
||||
|
||||
if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) {
|
||||
message.append("\n\nCatalog download: ").append(catalogDownloadResult.message());
|
||||
}
|
||||
|
||||
if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) {
|
||||
message.append("\n\nCatalog load: ").append(catalogLoadResult.message());
|
||||
}
|
||||
|
||||
if (batchDownloadResult != null && batchDownloadResult.message() != null && !batchDownloadResult.message().isBlank()) {
|
||||
message.append("\n\nPackage download: ").append(batchDownloadResult.message());
|
||||
}
|
||||
|
||||
message.append(String.format(
|
||||
Locale.US,
|
||||
"\n\nInstallation: %d package(s) installed successfully, %d DEM tile(s) extracted.",
|
||||
successfulInstallCount,
|
||||
installedTileCount
|
||||
));
|
||||
|
||||
if (missingPackageIds != null && !missingPackageIds.isEmpty()) {
|
||||
message.append("\n\nMissing package ids in catalog:");
|
||||
for (String packageId : missingPackageIds) {
|
||||
message.append("\n- ").append(packageId);
|
||||
}
|
||||
}
|
||||
|
||||
List<TerrainPackageInstaller.InstallResult> failedInstalls = installResults.stream()
|
||||
.filter(result -> !result.success())
|
||||
.toList();
|
||||
|
||||
if (!failedInstalls.isEmpty()) {
|
||||
message.append("\n\nFailed installations:");
|
||||
for (TerrainPackageInstaller.InstallResult failedInstall : failedInstalls) {
|
||||
String packageId = failedInstall.terrainPackageManifest() == null
|
||||
? "<unknown>"
|
||||
: failedInstall.terrainPackageManifest().packageId();
|
||||
message.append("\n- ").append(packageId).append(": ").append(failedInstall.message());
|
||||
}
|
||||
}
|
||||
|
||||
return message.toString().trim();
|
||||
}
|
||||
|
||||
private String normalizeLocator6(String locator6) {
|
||||
if (locator6 == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String trimmed = locator6.trim().toUpperCase(Locale.ROOT);
|
||||
return trimmed.length() >= 6 ? trimmed.substring(0, 6) : trimmed;
|
||||
}
|
||||
|
||||
private String normalizeCallsignRaw(String callSignRaw) {
|
||||
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private double sanitizeAntennaHeightMeters(double antennaHeightMeters) {
|
||||
if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return antennaHeightMeters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined result of one terrain preparation run.
|
||||
*
|
||||
* @param request original path request
|
||||
* @param coverageSelection resolved required coverage
|
||||
* @param catalogDownloadResult catalog download result
|
||||
* @param catalogLoadResult catalog load result
|
||||
* @param batchDownloadResult package download result
|
||||
* @param missingPackageIds required package ids that are not present in the catalog
|
||||
* @param installResults installation results for downloaded packages
|
||||
* @param success true if full required package coverage was available and at least one package was installed successfully
|
||||
* @param message human-readable summary message
|
||||
*/
|
||||
public record TerrainPreparationResult(
|
||||
PathAnalysisRequest request,
|
||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
||||
List<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> installResults,
|
||||
boolean success,
|
||||
String message
|
||||
) {
|
||||
public TerrainPreparationResult {
|
||||
missingPackageIds = missingPackageIds == null ? List.of() : List.copyOf(missingPackageIds);
|
||||
installResults = installResults == null ? List.of() : List.copyOf(installResults);
|
||||
}
|
||||
|
||||
public static TerrainPreparationResult failure(PathAnalysisRequest request,
|
||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
||||
List<String> missingPackageIds,
|
||||
List<TerrainPackageInstaller.InstallResult> installResults,
|
||||
String message) {
|
||||
return new TerrainPreparationResult(
|
||||
request,
|
||||
coverageSelection,
|
||||
catalogDownloadResult,
|
||||
catalogLoadResult,
|
||||
batchDownloadResult,
|
||||
missingPackageIds,
|
||||
installResults,
|
||||
false,
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
public boolean hasInstalledAnything() {
|
||||
return installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.controller.DBController;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Persistent terrain profile cache stored in the application's existing SQLite database.
|
||||
*
|
||||
* The cache is intentionally owner-bound:
|
||||
* if the configured own callsign or own locator changes, all cached terrain
|
||||
* profiles are cleared automatically.
|
||||
*/
|
||||
public final class TerrainProfileCacheRepository {
|
||||
|
||||
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw";
|
||||
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6";
|
||||
|
||||
private final String databasePath;
|
||||
|
||||
public TerrainProfileCacheRepository() {
|
||||
ApplicationFileUtils.copyResourceIfRequired(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DBController.DATABASE_RESOURCE,
|
||||
DBController.DATABASE_FILE
|
||||
);
|
||||
|
||||
this.databasePath = ApplicationFileUtils.getFilePath(
|
||||
ApplicationConstants.APPLICATION_NAME,
|
||||
DBController.DATABASE_FILE
|
||||
);
|
||||
}
|
||||
|
||||
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
|
||||
String ownerLocator6,
|
||||
String targetCallsignRaw,
|
||||
String targetLocator6,
|
||||
int sampleCount,
|
||||
String providerId) {
|
||||
|
||||
try (Connection connection = openConnection()) {
|
||||
ensureSchema(connection);
|
||||
ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6);
|
||||
|
||||
String sql = """
|
||||
SELECT profile_points_text, source_name, synthetic
|
||||
FROM TerrainProfileCache
|
||||
WHERE owner_callsign_raw = ?
|
||||
AND owner_locator6 = ?
|
||||
AND target_callsign_raw = ?
|
||||
AND target_locator6 = ?
|
||||
AND sample_count = ?
|
||||
AND provider_id = ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalize(ownerCallsignRaw));
|
||||
statement.setString(2, normalize(ownerLocator6));
|
||||
statement.setString(3, normalize(targetCallsignRaw));
|
||||
statement.setString(4, normalize(targetLocator6));
|
||||
statement.setInt(5, sampleCount);
|
||||
statement.setString(6, normalize(providerId));
|
||||
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
if (!resultSet.next()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String serializedProfile = resultSet.getString("profile_points_text");
|
||||
String sourceName = resultSet.getString("source_name");
|
||||
boolean synthetic = resultSet.getInt("synthetic") != 0;
|
||||
|
||||
List<PathProfilePoint> points = deserializeProfile(serializedProfile);
|
||||
TerrainProfileData result = new TerrainProfileData(points, sourceName, synthetic);
|
||||
|
||||
return result.hasUsableProfile() ? Optional.of(result) : Optional.empty();
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
System.err.println("[StationMap] Terrain cache load failed: " + exception.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void save(String ownerCallsignRaw,
|
||||
String ownerLocator6,
|
||||
String targetCallsignRaw,
|
||||
String targetLocator6,
|
||||
int sampleCount,
|
||||
String providerId,
|
||||
TerrainProfileData terrainProfileData) {
|
||||
|
||||
if (terrainProfileData == null || !terrainProfileData.hasUsableProfile() || terrainProfileData.synthetic()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try (Connection connection = openConnection()) {
|
||||
ensureSchema(connection);
|
||||
ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO TerrainProfileCache (
|
||||
owner_callsign_raw,
|
||||
owner_locator6,
|
||||
target_callsign_raw,
|
||||
target_locator6,
|
||||
sample_count,
|
||||
provider_id,
|
||||
profile_points_text,
|
||||
source_name,
|
||||
synthetic,
|
||||
created_at_epoch_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(
|
||||
owner_callsign_raw,
|
||||
owner_locator6,
|
||||
target_callsign_raw,
|
||||
target_locator6,
|
||||
sample_count,
|
||||
provider_id
|
||||
) DO UPDATE SET
|
||||
profile_points_text = excluded.profile_points_text,
|
||||
source_name = excluded.source_name,
|
||||
synthetic = excluded.synthetic,
|
||||
created_at_epoch_ms = excluded.created_at_epoch_ms
|
||||
""";
|
||||
|
||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
statement.setString(1, normalize(ownerCallsignRaw));
|
||||
statement.setString(2, normalize(ownerLocator6));
|
||||
statement.setString(3, normalize(targetCallsignRaw));
|
||||
statement.setString(4, normalize(targetLocator6));
|
||||
statement.setInt(5, sampleCount);
|
||||
statement.setString(6, normalize(providerId));
|
||||
statement.setString(7, serializeProfile(terrainProfileData.profilePoints()));
|
||||
statement.setString(8, terrainProfileData.sourceName());
|
||||
statement.setInt(9, terrainProfileData.synthetic() ? 1 : 0);
|
||||
statement.setLong(10, System.currentTimeMillis());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
System.err.println("[StationMap] Terrain cache save failed: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Connection openConnection() throws Exception {
|
||||
return DriverManager.getConnection("jdbc:sqlite:" + databasePath);
|
||||
}
|
||||
|
||||
private void ensureSchema(Connection connection) throws Exception {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS TerrainProfileCache (
|
||||
owner_callsign_raw TEXT NOT NULL,
|
||||
owner_locator6 TEXT NOT NULL,
|
||||
target_callsign_raw TEXT NOT NULL,
|
||||
target_locator6 TEXT NOT NULL,
|
||||
sample_count INTEGER NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
profile_points_text TEXT NOT NULL,
|
||||
source_name TEXT NOT NULL,
|
||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_epoch_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
owner_callsign_raw,
|
||||
owner_locator6,
|
||||
target_callsign_raw,
|
||||
target_locator6,
|
||||
sample_count,
|
||||
provider_id
|
||||
)
|
||||
)
|
||||
""");
|
||||
|
||||
statement.executeUpdate("""
|
||||
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
|
||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
||||
meta_value TEXT NOT NULL
|
||||
)
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureOwnerIdentity(Connection connection,
|
||||
String currentOwnerCallsignRaw,
|
||||
String currentOwnerLocator6) throws Exception {
|
||||
|
||||
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
|
||||
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
|
||||
|
||||
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
|
||||
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
|
||||
|
||||
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
|
||||
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
|
||||
|
||||
if (callsignChanged || locatorChanged) {
|
||||
clearTerrainCache(connection);
|
||||
}
|
||||
|
||||
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
|
||||
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
|
||||
}
|
||||
|
||||
private void clearTerrainCache(Connection connection) throws Exception {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate("DELETE FROM TerrainProfileCache");
|
||||
}
|
||||
}
|
||||
|
||||
private String readMetaValue(Connection connection, String key) throws Exception {
|
||||
try (PreparedStatement statement = connection.prepareStatement(
|
||||
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
|
||||
statement.setString(1, key);
|
||||
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
return resultSet.next() ? resultSet.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
|
||||
try (PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
|
||||
""")) {
|
||||
statement.setString(1, key);
|
||||
statement.setString(2, value == null ? "" : value);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private String serializeProfile(List<PathProfilePoint> profilePoints) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (PathProfilePoint point : profilePoints) {
|
||||
if (point == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!builder.isEmpty()) {
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
builder.append(String.format(
|
||||
Locale.US,
|
||||
"%.6f;%.8f;%.8f;%.3f",
|
||||
point.distanceKm(),
|
||||
point.latitudeDeg(),
|
||||
point.longitudeDeg(),
|
||||
point.elevationMeters()
|
||||
));
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private List<PathProfilePoint> deserializeProfile(String serializedProfile) {
|
||||
if (serializedProfile == null || serializedProfile.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
String[] lines = serializedProfile.split("\\R+");
|
||||
List<PathProfilePoint> points = new ArrayList<>(lines.length);
|
||||
|
||||
for (String line : lines) {
|
||||
String[] parts = line.split(";");
|
||||
if (parts.length != 4) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
points.add(new PathProfilePoint(
|
||||
Double.parseDouble(parts[0]),
|
||||
Double.parseDouble(parts[1]),
|
||||
Double.parseDouble(parts[2]),
|
||||
Double.parseDouble(parts[3])
|
||||
));
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
return List.copyOf(points);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable terrain/profile payload including source metadata.
|
||||
*/
|
||||
public record TerrainProfileData(
|
||||
List<PathProfilePoint> profilePoints,
|
||||
String sourceName,
|
||||
boolean synthetic
|
||||
) {
|
||||
|
||||
public TerrainProfileData {
|
||||
profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints);
|
||||
sourceName = sourceName == null ? "" : sourceName.trim();
|
||||
}
|
||||
|
||||
public static TerrainProfileData empty(String sourceName) {
|
||||
return new TerrainProfileData(List.of(), sourceName, false);
|
||||
}
|
||||
|
||||
public boolean hasUsableProfile() {
|
||||
return profilePoints.size() >= 2;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Abstraction for terrain/profile retrieval.
|
||||
*/
|
||||
public interface TerrainProfileProvider {
|
||||
|
||||
TerrainProfileData loadProfile(TerrainProfileRequest request);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
/**
|
||||
* Immutable request for terrain/profile sampling between two endpoints.
|
||||
*/
|
||||
public record TerrainProfileRequest(
|
||||
double fromLatitudeDeg,
|
||||
double fromLongitudeDeg,
|
||||
double toLatitudeDeg,
|
||||
double toLongitudeDeg,
|
||||
double totalDistanceKm,
|
||||
int requestedSampleCount
|
||||
) {
|
||||
public TerrainProfileRequest {
|
||||
requestedSampleCount = Math.max(0, requestedSampleCount);
|
||||
}
|
||||
|
||||
public boolean hasUsableEndpoints() {
|
||||
return Double.isFinite(fromLatitudeDeg)
|
||||
&& Double.isFinite(fromLongitudeDeg)
|
||||
&& Double.isFinite(toLatitudeDeg)
|
||||
&& Double.isFinite(toLongitudeDeg)
|
||||
&& Double.isFinite(totalDistanceKm)
|
||||
&& totalDistanceKm >= 0.0;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Immutable metadata for one internal terrain tile.
|
||||
*
|
||||
* <p>This model intentionally describes the runtime-ready tile after ingestion
|
||||
* into the KST4Contest terrain format, not the original upstream GeoTIFF.</p>
|
||||
*/
|
||||
public record TerrainTileMetadata(
|
||||
String tileId,
|
||||
String fileName,
|
||||
int southDeg,
|
||||
int westDeg,
|
||||
int width,
|
||||
int height,
|
||||
int arcSecondResolution,
|
||||
short noDataValue,
|
||||
String sourceDataset,
|
||||
String sha256
|
||||
) {
|
||||
|
||||
public TerrainTileMetadata {
|
||||
tileId = normalizeUpper(tileId);
|
||||
fileName = normalizeText(fileName);
|
||||
sourceDataset = normalizeText(sourceDataset);
|
||||
sha256 = normalizeLower(sha256);
|
||||
|
||||
if (width < 0) {
|
||||
width = 0;
|
||||
}
|
||||
|
||||
if (height < 0) {
|
||||
height = 0;
|
||||
}
|
||||
|
||||
if (arcSecondResolution < 0) {
|
||||
arcSecondResolution = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the northern edge of the covered 1° x 1° geocell.
|
||||
*
|
||||
* @return north edge latitude in degrees
|
||||
*/
|
||||
public int northDeg() {
|
||||
return southDeg + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the eastern edge of the covered 1° x 1° geocell.
|
||||
*
|
||||
* @return east edge longitude in degrees
|
||||
*/
|
||||
public int eastDeg() {
|
||||
return westDeg + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this metadata appears complete enough for installation/runtime.
|
||||
*
|
||||
* @return true if the essential fields are usable
|
||||
*/
|
||||
public boolean isUsable() {
|
||||
return !tileId.isBlank()
|
||||
&& !fileName.isBlank()
|
||||
&& width > 0
|
||||
&& height > 0
|
||||
&& arcSecondResolution > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the tile covers the given geographic sample point.
|
||||
*
|
||||
* <p>The tile is interpreted as the 1° x 1° cell
|
||||
* [southDeg, southDeg+1) x [westDeg, westDeg+1).</p>
|
||||
*
|
||||
* @param latitudeDeg sample latitude in degrees
|
||||
* @param longitudeDeg sample longitude in degrees
|
||||
* @return true if the point lies inside this tile
|
||||
*/
|
||||
public boolean covers(double latitudeDeg, double longitudeDeg) {
|
||||
return Double.isFinite(latitudeDeg)
|
||||
&& Double.isFinite(longitudeDeg)
|
||||
&& latitudeDeg >= southDeg
|
||||
&& latitudeDeg < northDeg()
|
||||
&& longitudeDeg >= westDeg
|
||||
&& longitudeDeg < eastDeg();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical internal tile id for one 1° x 1° cell.
|
||||
*
|
||||
* Examples:
|
||||
* <ul>
|
||||
* <li>N51_E007</li>
|
||||
* <li>N52_W003</li>
|
||||
* <li>S01_E010</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param southDeg southern cell boundary in degrees
|
||||
* @param westDeg western cell boundary in degrees
|
||||
* @return canonical tile id
|
||||
*/
|
||||
public static String buildTileId(int southDeg, int westDeg) {
|
||||
String latPrefix = southDeg >= 0 ? "N" : "S";
|
||||
String lonPrefix = westDeg >= 0 ? "E" : "W";
|
||||
|
||||
return String.format(
|
||||
Locale.ROOT,
|
||||
"%s%02d_%s%03d",
|
||||
latPrefix,
|
||||
Math.abs(southDeg),
|
||||
lonPrefix,
|
||||
Math.abs(westDeg)
|
||||
);
|
||||
}
|
||||
|
||||
private static String normalizeText(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String normalizeUpper(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static String normalizeLower(String value) {
|
||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
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 = 512;
|
||||
private static final String USER_AGENT = "kst4contest/1.0 amateur-radio-contest-tool";
|
||||
|
||||
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, 16, InetAddress.getByName("127.0.0.1"));
|
||||
|
||||
this.executor = Executors.newFixedThreadPool(4, 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"
|
||||
+ "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);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package kst4contest.view.map;
|
||||
|
||||
public class mapTest {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
TerrainPackageService terrainPackageService = new TerrainPackageService();
|
||||
|
||||
TerrainPackageService.TerrainPreparationResult result =
|
||||
terrainPackageService.prepareTerrainForLocators(
|
||||
"JO51IJ",
|
||||
"JO22JK",
|
||||
"TEST",
|
||||
144.300,
|
||||
10.0,
|
||||
10.0,
|
||||
""
|
||||
);
|
||||
|
||||
System.out.println(result.success());
|
||||
System.out.println(result.message());
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,11 @@
|
||||
module praktiKST {
|
||||
requires javafx.controls;
|
||||
requires javafx.fxml;
|
||||
requires javafx.web;
|
||||
requires jdk.xml.dom;
|
||||
requires java.sql;
|
||||
requires javafx.media;
|
||||
requires jdk.jsobject;
|
||||
requires java.net.http;
|
||||
requires java.desktop;
|
||||
requires jdk.crypto.ec;
|
||||
exports kst4contest.controller.interfaces;
|
||||
exports kst4contest.controller;
|
||||
exports kst4contest.locatorUtils;
|
||||
exports kst4contest.model;
|
||||
exports kst4contest.view;
|
||||
|
||||
opens kst4contest.view.map to javafx.web;
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
/* 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
@@ -1,661 +0,0 @@
|
||||
/* 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
@@ -1781,588 +1781,3 @@ DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.230 ]; wkd true; wkd144 false
|
||||
OL70KEA;70/23CM;JN89EJ;StringProperty [value: 144.389 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SN7L;144.180;JO70SS;StringProperty [value: 144.180 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2L;2m;JN68DT;StringProperty [value: 365 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5EC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ7GS;Marek;JN47KW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO8PAT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
S59DEM;RC Proteus;JN75DS;StringProperty [value: 144.326 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK7CM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1HMP;Martin 2m QRO;JO70EB;StringProperty [value: 144.291 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
TM5R;Didier;JN19BQ;StringProperty [value: 144.279.81 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5RA;Radek;JN89IW;StringProperty [value: 144,092 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1NPF;Roman 2m QRP;JO70UK;StringProperty [value: 144.330 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: 123 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG0VOG;xAC 2m qro;JO60QU;StringProperty [value: 163 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1TEH;Matej ssb/cw;JO70FD;StringProperty [value: 144.390 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1ALS;144307;JO44XX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1ALS;144307;JO44XX;StringProperty [value: 307 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
5P5T;Team;JO64GX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5NEN;Tom;JN59OP;StringProperty [value: 144.380 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DA0FF;144.248;JO40XL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2BKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SUZ;Uwe 2m Contest;JO53UN;StringProperty [value: 275 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
7S7V;Samir;JO65SN;StringProperty [value: 144345 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR7C;team 2m;JO50WB;StringProperty [value: 338 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SYA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OL7C;Radio Club;JO60JJ;StringProperty [value: 144,213 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL4N;Club;JO60VR;StringProperty [value: 237 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0YY;Berlin 144.325;JO62GD;StringProperty [value: 324 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0LU;DF0LU;JO43UA;StringProperty [value: 281 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2L;144MHz;JO41PU;StringProperty [value: 144.227 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK2ADM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH8BQA;Olli;JO73CE;StringProperty [value: 144293 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR2X;Team 144.355;JO40QL;StringProperty [value: 355 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KKD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6KDS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5M;KST4ContestVUHF;JO51IJ;StringProperty [value: 144.315.00 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SM7VUK;Bengt/2m/SSB;JO66LI;StringProperty [value: 197 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM5D;144.382.50;JO61OC;StringProperty [value: 144.369 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL3LAR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0NA;club;JO50TI;StringProperty [value: 144.230 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG5BRE;Ronny 2m-9cm;JO62VM;StringProperty [value: 199 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7EE;Chris, 2m, 750W;JO52JJ;StringProperty [value: 144,177,3 ]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA9R;Rob;JO22JK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR1A;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DD0VF;Steffen 2-70-23;JO61TB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F5DYD;JLouis 23/3;IN86XW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DH1WHM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
EA4SG;David;IN80CP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DH1WM;Mathias not qrv;JN49CD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
2E0WED;Daniel;IO83LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F5ODA;Eric;JN13LE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G4KUX;Nick;IO94BP;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9SJV;Ben;JN36FL;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8ONK;Tony 432.390;IO83MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
M0IAM;Clive;IO91QE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OY4TN;Trygvi 11EL/100W;IP62NB;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F2CT;Guy 432.225;IN93GJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
9A2HM;Kreso;JN82UQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI3KD;Mark 6/2/70/23;IO51VW;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2T;Sven;JO41UM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6LX;Bob;JO42ON;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
9A2HM;Kreso;JN82UQ;StringProperty [value: 2320.184 ]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0GEB;test;JO51HK;StringProperty [value: 432.234]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OM5AW;Joe QRO144;JN98AH;StringProperty [value: 432.321]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EI9BM;Alain;IO45PT;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1NZC;Jean-Louis JN15;JN15MR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F6HTJ;Michel not QRV;JN12KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL9L;Frank;JN36XE;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1TEST;TestOp;JO50XX;StringProperty [value: 144.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
EA1Q;Alain;IN61MD;StringProperty [value: 432.599]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ3AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9CEV/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6KDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SE;Michael 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6UHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5UE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2CB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1RLB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3ZF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO2TL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6CNG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM6AT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4PT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5OU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DD6YR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA1E;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9HC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH2UHE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1WB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2AR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2RZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1IJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8MLD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7CP/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9ZX/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ9WJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SC7W;Torleif 2m only;JO65NK;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2TN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2AQI/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4LAM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1MER;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA6KH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK8XY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1OIB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2OBF/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1FY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2AKT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1RWO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4HBK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2CF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM6MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE5XXL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4WK/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4MN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1RDO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2TX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4SKH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6NEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7VN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0CG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4NWM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6UAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1AYJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2AF;Milan 2m;JN89AR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0DLE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2IT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2LB;Torsten;JO53LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF3AS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA0UDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ1FZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0TZ;Clubstation;JN59LN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ4MH;Marco (2m);JO42BB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
ON5DRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1NGS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK1MF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2YCT;Chris (23cm);JO32RG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK6QO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9FBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR5W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ3QB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5SBY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7RA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6SR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2YDS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ8WK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ5KW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH3KR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1FDK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON6ZY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1PS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF2QZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2BHG;Heinz;JO51MW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM5ML;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2FFW;70cm;JO50LQ;StringProperty [value: 144.28]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DB9OH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5OA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3DG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF3OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1BFR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6CWM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1AAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 432.165]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG0PF;Gilbert 2m;JO50LQ;StringProperty [value: 432.16]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ7YP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4CBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7AC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ9FC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO8THW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0YE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PI4AMF/P;contest club;JO22XB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2HXE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2FQ;Tzetzo - 23;JN49EW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DC7QY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7AX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HTT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0WF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0MI;Horst;JO42KH;StringProperty [value: 1296.2]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG6ME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3RHN;Rüdiger 2m;JO63PM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1KWV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9BBD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA6SB;DG1HR/DA6SB 2/70;JO52HI;StringProperty [value: 144.273]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2FFW;2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2LSM;Guenter;JO61GH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6ZXG;Klaus;JO51KU;StringProperty [value: 144.22]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DJ2FR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE5JSL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5DWF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6FKR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3LAR;Rolf;JO52GE;StringProperty [value: 1296.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DJ6OL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ1OB;Olli 2m;JN48UG;StringProperty [value: 144.043]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO6XA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE1W;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB8LE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2VK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5B;only 2m;JO62XE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4M-70;Club 70cm;JO31QX;StringProperty [value: 144.248]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5RX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DC5IMM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.155]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DA2R;DA2R;JN69EM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8TB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9TTY;10Y 144/432 650W;JN46BX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HB9GF;Contest;JN47BC;StringProperty [value: 144.388]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1LN;Lada;JN79AI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4MW;Ralf 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA0C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2YEY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1DS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F6IHC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA1AM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ2KP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1ATI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9DBF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8BS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF2BR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR1T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2L;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5AAZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YDR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO9MHG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0AU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG6MC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2NDL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF0MU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9MKA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9MD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5MO;23;JO50LQ;StringProperty [value: 144.38]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL3IAS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5RA;Radek;JN89IW;StringProperty [value: 144.317]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DD6OM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL2IKE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-70;Team Wetzstein;JO50RK;StringProperty [value: 144.165]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ8UHU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG4OP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL1C;OT Club;JO72AL;StringProperty [value: 144.184]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR5W;Club;JO61OX;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK6EE;Andreas;JO52KL;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL8IK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7YS;23/13cm 80W@LYs;JO62NM;StringProperty [value: 1296.19]; wkd true; wkd144 true; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL4ASK;Guenther 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF6KB;Kai 70cm only;JO42JC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF1HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5KT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1OPT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1SE;Michael 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO1MLH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6UJH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DQ8N-23;Ralf;JN59US;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL6ZEJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9NM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4M;1296.230;JO31QX;StringProperty [value: 144.3745]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL1OKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8LG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH6ABE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2D;2x8 2m only QRO;JO64ND;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF8OI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.225]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7EE;Chris 2m/70cm;JO52JJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DC7BK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MIG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG0PF;Gilbert 70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5CAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5MO;70cm;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF7NX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9APW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3LAR;Rolf 23/13/9/6/3;JO52GE;StringProperty [value: 1296.115]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL5C;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KKI-70CM;ok1kki;JN79NF;StringProperty [value: 144.255]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH8WE;Frank;JO50TJ;StringProperty [value: 144.11]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5HF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE3EFS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9YY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5F;Marcel 2/70/23;JO71ES;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM2DXG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE3UFC/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1HSF;Micha 2m-24G;JO61FR;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
SP6CPF;Franek 2/70/23CW;JO71PD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2PZ;Manfred 70cm;JN57MT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1FLC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SN6R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO4ADK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6ON;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5EZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HA1CA;HA1CA;JN86HN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG1HR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3MS;Tino;JO62IH;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL4MFM/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0NF;Harry 13 cw;JN59PL;StringProperty [value: 432.24]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DC8RI;Uwe 23&13;JN68JQ;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DH4EAK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK6FX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1NPJ;Juergen-2m;JN59LE;StringProperty [value: 144.31]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4DAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4VW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0WSF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK5T;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK9OG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1NAO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2L;Club;JN99BN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0YY-3;9-6-3-1,2;JO62GD;StringProperty [value: 432.1]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DM3OA;erni;JO63UW;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PI4GN-23;Club;JO33II;StringProperty [value: 144.235]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK4RA;Ragna 2m;JO50KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KKP;Lukas 23cm+3cm;JO70DG;StringProperty [value: 144.163]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1IB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1AKY;Jens 70;JO50LQ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4MA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KCR;Club 2m;JN79VS;StringProperty [value: 144.162]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR7C;Team 10G and up;JO50WB;StringProperty [value: 144.298]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
IQ5NN;Monte Nerone;JN63GN;StringProperty [value: 144.205]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1YDI;Dirk 9Ele/400W;JO42FA;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2NBU;Peter;JN59KQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 432.175]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
PA0WMX;Wim 2m 70 23 3;JO21XI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG6KBG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3ABL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0NA;Club;JO50TI;StringProperty [value: 10368.1]; wkd true; wkd144 false; wkd432true; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL5DBT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM7A;Team TUD;JO60OM;StringProperty [value: 144.175]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SN7L;144.185 Contest;JO91QF;StringProperty [value: 144.185]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL7M;Team 1100asl;JO80FG;StringProperty [value: 144.191]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0PU-10;Team 10GHz;JO31JN;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
ON4EI/P;Oli 2M ONLY;JO20EP;StringProperty [value: 144.272]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
IO2V-432;Team 70cm;JN54WE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
G8T;144.153;JO01NC;StringProperty [value: 144.153]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
S51IV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1YGH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8SCD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
S59P-2;23,13,9,3 cm;JN86AO;StringProperty [value: 5760.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DK2R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1AKY;Jens 2m;JO50LQ;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ5NE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SQ3GJS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F8KID;Club;JN38AT;StringProperty [value: 144.254]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR2Q;Stefan;JO50SF;StringProperty [value: 144.1]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0LU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1KAD;Karel 23cm;JO60LJ;StringProperty [value: 144.178]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL7C;Radio Club;JO60JJ;StringProperty [value: 144.212]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OL4N;Vlasta;JO60VR;StringProperty [value: 144.316]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DA6UU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SM6VTZ;Chris .235 .135;JO58UJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1NPF;Roman 2m/ssb;JO70UK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DM7D;Ronald;JO62LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1MWW;Jiri 2m/70cm;JN89DW;StringProperty [value: 144.27]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2WM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7AM;Uwe;JN59SR;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0A/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G4PIQ;Andy 144.312;JO02OD;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DR1D;Detlef;JO30JU;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK6FE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G4LPP;Phil;JO02SS;StringProperty [value: 144.348]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1SX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1QR;Holger;JO31LJ;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4VAI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON5CLR/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4MH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5D-2;DM5D-2m;JO61OC;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DL5ZBS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR1H;144.380;JN59OP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL6NDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OE8SDR;/P 2x10 QRO;JN76KO;StringProperty [value: 144.144303]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0NA;Club;JO50TI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK2KOJ;OK2KOJ Club;JN89GF;StringProperty [value: 144.144]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DG5BRE;Ronny 2m-6cm;JO62VM;StringProperty [value: null]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OL3Z-7;club 432,210;JN79FX;StringProperty [value: 432.21]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0A;Club 144.225;JN48CO;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL4FDD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO8PAT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1JKO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5EI;Peter;JO43DC;StringProperty [value: 144.169]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DJ6VX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PC2K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ7ZZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1DIX;Lada 2m;JN79IP;StringProperty [value: 144.087]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG7SCB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1FD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3AAV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0PP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MHX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6MHW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DJJ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8EX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3HAH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F0EGV/P;Thierry;JN19MI;StringProperty [value: 144.206]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG9ZA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM3AW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1YBN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
ON4PS/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8MV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5HQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2JST;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5JS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DA2M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1YEG;Uwe;JO42HG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO1GPP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2ROA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL2DRG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F1TRE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG2SER;Carsten 2m;JN58OH;StringProperty [value: 144.215]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF8TM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DR2Q;Stefan;JO50SF;StringProperty [value: 144.28]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2BO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0XD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK2C;team 70cm-76GHz;JN99AJ;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DD5AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5NUA;Klaus(70_only);JO63PO;StringProperty [value: 144.266]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK2ZO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO1DW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM2EUN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL7ATR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH9FAW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG4FCX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG5NFF;23cm10G24GMartin;JO50DO;StringProperty [value: 1296.15]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DO1XRK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG3AWN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SM7VUK;Bengt MGM FT8/4;JO66LI;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK7RC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5HM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG0ONW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1VDJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F6KFH;Radioclub;JN39OC;StringProperty [value: 432.257]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
HG7M;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1PMA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM5CT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1DX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2EA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2WC;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
HB9GF-70;HFI;JN47BC;StringProperty [value: 432.22]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6FXF;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1BHA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0NAU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9ME/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO5STS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: 432.255]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8AMB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OM3W;Club 2m only;JN99CH;StringProperty [value: 144.325]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8R;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1JHR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL25WIKI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8MDA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8GL/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1HQK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0AU/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1PAL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2LA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1NGR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF1AK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG1E;DG1E-23 .240;JO31LE;StringProperty [value: 144.24]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DR3K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM4K;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK7DCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG8KV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL8LR;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK2HZ/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9SFM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1DT/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF9OO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1UZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF7JU;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5BCQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
PA0DDB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL0RD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DM7KN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL5BAW/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ4FV;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL4YBZ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL6GCK;Konrad 2m;JN47OR;StringProperty [value: 144.36]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL9FCM;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB4LL;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK9CK;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB7PN/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF3TE;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DN9RME;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DC6HG;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DF4WO;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK4REX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5EZ;George;JO31NH;StringProperty [value: 144.345]; wkd true; wkd144 true; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL1FKB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9RP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1EIP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OM5AW;Joe QRO144;JN98AH;StringProperty [value: 144.241]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DO6NI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
F8KGU;Didier;JN19BQ;StringProperty [value: 144.33]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DK0AJ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3SBA/P;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9UN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK8LQ;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DG5SP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB2DY;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL9KDW;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK5II;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH1WM/P;Mathias 2m;JN49AG;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL8FBX;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DB5SM;Klaus-2m;JN59LE;StringProperty [value: 144.194]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2MS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DJ3SN;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DH9ET;Tom 2m;JN57TS;StringProperty [value: 144.226]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DG4MNA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL1ARS;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DL3BH;Holger;JN49SB;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK1KFH;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK6M;2m-18el. M2,4x6;JN99CR;StringProperty [value: 144.235]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OK1KJP;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
OK1ADT;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
SP7VVB;Maciek 2m;JO91VQ;StringProperty [value: 144.385]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP9KDA;KST4Contest1263;JO90PP;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK2KEA;Radioclub 2m;JN89EJ;StringProperty [value: 144.375]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SP6KEP;RC 2m only;JO90CK;StringProperty [value: 144.18]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
F1PHB;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DO2AD;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
G2D;Club 144.320;JO01JA;StringProperty [value: 144.319]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DB1PA;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
DK0TUI;unknown;unknown;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; null
|
||||
9A1O;RC Osijek;JN95IS;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0BZ;Dave;JN05EL;StringProperty [value: 144.39]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0N;Frank;JN21IY;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DL2V;Bob;JO82AM;StringProperty [value: 144.179]; wkd true; wkd144 true; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
DF0U;Sven;JO34DD;StringProperty [value: 1296.292]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
DF1C;Bob;JO43LS;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||
OK8V;Alain;JN34TV;StringProperty [value: 432.115]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OV3T;Thomas;JO46CM;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1BEF;Dan;JO46OE;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ4VW;Arne;JO45UT;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1DLD/P;Bent;JO45SK;StringProperty [value: 144.285]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1FDH;Claus;JO55QX;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ7KJ;Skive Club;JO46ML;StringProperty [value: 144.225]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
SM7VUK;Bengt;JO66LI;StringProperty [value: 144.304]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
OZ1HDF;Ken;JO55UN;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||
Reference in New Issue
Block a user