Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf6f04c910 | ||
|
|
3160f9558d | ||
|
|
30da16b1ac | ||
|
|
62e1096ef9
|
||
|
|
aa016d4772 | ||
|
|
4b9ffa5c5c | ||
|
|
8d83f797df |
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
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]
|
{\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]
|
\color{white!40!brand-green}\rule{10cm}{0.6pt}\\[1.8cm]
|
||||||
{\LARGE\bfseries\color{white}$title$}\\[1cm]
|
{\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
|
\vfill
|
||||||
{\large\color{white}DO5AMF · Marc Fröhlich · DM5M · DN9APW · Philipp Wagner}\\[0.4cm]
|
{\large\color{white}DO5AMF · Marc Fröhlich · DM5M · DN9APW · Philipp Wagner}\\[0.4cm]
|
||||||
{\color{white!70!brand-green}\today}\\[2cm]
|
{\color{white!70!brand-green}\today}\\[2cm]
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ on:
|
|||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-zip:
|
build-windows-zip:
|
||||||
name: Build Windows ZIP
|
name: Build Windows ZIP
|
||||||
@@ -83,8 +87,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
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"
|
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 17
|
- name: Set up Java 17
|
||||||
@@ -153,6 +155,317 @@ jobs:
|
|||||||
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
||||||
retention-days: 14
|
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 \
|
||||||
|
--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 \
|
||||||
|
--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 \
|
||||||
|
--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 \
|
||||||
|
--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:
|
build-macos-dmg:
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
name: Build macOS DMG (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
@@ -134,6 +135,313 @@ jobs:
|
|||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.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 \
|
||||||
|
--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 \
|
||||||
|
--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 \
|
||||||
|
--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 \
|
||||||
|
--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:
|
build-macos-dmg:
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
name: Build macOS DMG (${{ matrix.os }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -267,14 +575,47 @@ jobs:
|
|||||||
name: docs-pdf
|
name: docs-pdf
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-manual-*.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:
|
release-tag:
|
||||||
name: Publish Tagged Release
|
name: Publish Tagged Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- build-windows-zip
|
- build-windows-zip
|
||||||
- build-linux-appimage
|
- build-linux-appimage
|
||||||
|
- build-linux-deb
|
||||||
|
- build-linux-rpm
|
||||||
|
- build-linux-arch
|
||||||
- build-macos-dmg
|
- build-macos-dmg
|
||||||
|
- build-flatpak
|
||||||
- build-docs-pdf
|
- build-docs-pdf
|
||||||
|
- publish-flatpak-repo
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
@@ -296,6 +637,30 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: release-assets/macos
|
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
|
- name: Download PDF manuals
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v4.1.3
|
||||||
with:
|
with:
|
||||||
@@ -316,6 +681,10 @@ jobs:
|
|||||||
artifacts: >-
|
artifacts: >-
|
||||||
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
release-assets/windows/praktiKST-${{ github.ref_name }}-windows-x64.zip,
|
||||||
release-assets/linux/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage,
|
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/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
||||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-en.pdf,
|
||||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
dr2x
|
S53MM
|
||||||
oe3cin
|
PA9R
|
||||||
-15832
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,17 @@ Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-
|
|||||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
- **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
|
## Danksagungen
|
||||||
|
|||||||
@@ -42,11 +42,19 @@ Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Die aktuelle Version kann als AppImage heruntergeladen werden:
|
Mehrere Paketformate stehen auf der Releases-Seite zur Verfügung:
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||||
|
|
||||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`.
|
| 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
|
### macOS
|
||||||
|
|
||||||
@@ -72,12 +80,47 @@ Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.
|
|||||||
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
Die Einstellungen werden unter `%USERPROFILE%\.praktikst\preferences.xml` gespeichert.
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
1. AppImage herunterladen.
|
|
||||||
2. AppImage in gewünschten Ordner entpacken.
|
|
||||||
3. AppImage ausführbar machen (geht im Terminal mit `chmod +x KST4Contest-v<Versionsnummer>-linux-x86_64.AppImage`)
|
|
||||||
4. AppImage ausführen.
|
|
||||||
|
|
||||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
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
|
### macOS
|
||||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
||||||
@@ -114,10 +157,11 @@ Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerord
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
Derzeit folgendermaßen:
|
- **AppImage**: Neues AppImage herunterladen, ausführbar machen (`chmod +x`), altes optional löschen.
|
||||||
1. neues AppImage herunterladen
|
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb`
|
||||||
2. neues AppImage ausführbar makieren
|
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm`
|
||||||
3. (optional) altes AppImage löschen.
|
- **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
|
#### macOS
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher ban
|
|||||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
- **GitHub**: https://github.com/praktimarc/kst4contest
|
||||||
- **Download**: https://github.com/praktimarc/kst4contest/releases/latest
|
- **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
|
## Acknowledgements
|
||||||
|
|||||||
@@ -42,11 +42,19 @@ The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
The latest version can be downloaded as an AppImage:
|
Multiple package formats are available from the releases page:
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||||
|
|
||||||
The filename has the format `KST4Contest-v<version_number>-linux-x86_64.AppImage`.
|
| 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
|
### macOS
|
||||||
|
|
||||||
@@ -72,12 +80,47 @@ The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, wh
|
|||||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
1. Download the AppImage.
|
|
||||||
2. Unzip the AppImage into a folder of your choice.
|
|
||||||
3. Make the AppImage executable (in the terminal with `chmod +x KST4Contest-v<version_number>-linux-x86_64.AppImage`)
|
|
||||||
4. Run the AppImage.
|
|
||||||
|
|
||||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
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
|
### macOS
|
||||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
||||||
@@ -114,10 +157,11 @@ The settings file (`preferences.xml`) is preserved because it is stored in the u
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
Currently as follows:
|
- **AppImage**: Download the new AppImage, make it executable (`chmod +x`), optionally delete the old one.
|
||||||
1. Download the new AppImage
|
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<version>-debian-amd64.deb`
|
||||||
2. Mark the new AppImage as executable
|
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm`
|
||||||
3. (optional) Delete the old AppImage.
|
- **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
|
#### macOS
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,24 @@ public class ApplicationConstants {
|
|||||||
|
|
||||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
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
|
* generates a unique runtime id per session. Its used to feed the poison pill in order to kill only this one and
|
||||||
|
|||||||
+9
-6
@@ -9,6 +9,8 @@ import java.net.NoRouteToHostException;
|
|||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
import java.net.UnknownHostException;
|
import java.net.UnknownHostException;
|
||||||
import java.util.TimerTask;
|
import java.util.TimerTask;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import javafx.collections.ObservableList;
|
import javafx.collections.ObservableList;
|
||||||
import kst4contest.locatorUtils.Location;
|
import kst4contest.locatorUtils.Location;
|
||||||
@@ -17,6 +19,7 @@ import kst4contest.model.ChatMember;
|
|||||||
|
|
||||||
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(AirScoutPeriodicalAPReflectionInquirerTask.class.getName());
|
||||||
private ChatController client;
|
private ChatController client;
|
||||||
|
|
||||||
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
public AirScoutPeriodicalAPReflectionInquirerTask(ChatController client) {
|
||||||
@@ -55,7 +58,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
||||||
}
|
}
|
||||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||||
|
|
||||||
@@ -105,13 +108,13 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
dsocket.send(packet);
|
dsocket.send(packet);
|
||||||
dsocket.close();
|
dsocket.close();
|
||||||
} catch (UnknownHostException e1) {
|
} catch (UnknownHostException e1) {
|
||||||
e1.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
||||||
} catch (NoRouteToHostException e) {
|
} catch (NoRouteToHostException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
||||||
} catch (SocketException e) {
|
} catch (SocketException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
||||||
}
|
}
|
||||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
dsocket.send(packet);
|
dsocket.send(packet);
|
||||||
dsocket.close();
|
dsocket.close();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
* Central Chat kst4contest.controller. Instantiate only one time per category of kst Chat.
|
||||||
@@ -1127,6 +1129,27 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
private ObservableList<Predicate<ChatMember>> lst_chatMemberListFilterPredicates = FXCollections.observableArrayList();
|
||||||
private ObservableList<ClusterMessage> lst_clusterMemberList = 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 ObservableList<ChatMember> lst_DBBasedWkdCallSignList = FXCollections.observableArrayList();
|
||||||
|
|
||||||
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
|
// private HashMap<String, ChatMember> map_ucxLogInfoWorkedCalls = new HashMap<String, ChatMember>(); //Destination of ucx-log worked-messages
|
||||||
@@ -1242,6 +1265,145 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
return lst_globalChatMessageList;
|
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) {
|
public void setLst_globalChatMessageList(ObservableList<ChatMessage> lst_globalChatMessageList) {
|
||||||
this.lst_globalChatMessageList = lst_globalChatMessageList;
|
this.lst_globalChatMessageList = lst_globalChatMessageList;
|
||||||
}
|
}
|
||||||
@@ -1479,6 +1641,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void initLst_toMeMessageList() {
|
private void initLst_toMeMessageList() {
|
||||||
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
// ObservableList<String> sniffedList = chatPreferences.getLstNotify_QSOSniffer_sniffedCallSignList();
|
||||||
|
|
||||||
@@ -1495,14 +1658,18 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
// --- NEUE LOGIK: Sniffer Liste prüfen ---
|
||||||
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
// Wenn Absender ODER Empfänger in der Beobachtungsliste stehen -> Anzeigen
|
||||||
if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
// if ((lstNotify_QSOSniffer_sniffedCallSignList.contains(senderCall) ||
|
||||||
lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
// lstNotify_QSOSniffer_sniffedCallSignList.contains(receiverCall)) &&
|
||||||
(!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
// (!receiverCall.equals(this.getChatPreferences().getStn_loginCallSignRaw()))) {
|
||||||
|
//
|
||||||
|
// msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||||
|
// chatMessage.setMessageText(msgText);
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
|
||||||
msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
if (isSniffedMessage(chatMessage)) {
|
||||||
chatMessage.setMessageText(msgText);
|
return true;
|
||||||
return true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- BESTEHENDE LOGIK ---
|
// --- BESTEHENDE LOGIK ---
|
||||||
|
|
||||||
@@ -2385,4 +2552,69 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
return DirectionUtils.isAngleInRange(targetAz, myAz, beamWidth);
|
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,9 +10,12 @@ import java.net.Socket;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
public class DXClusterThreadPooledServer implements Runnable{
|
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 List<Socket> clientSockets = Collections.synchronizedList(new ArrayList<>()); //list of all connected clients
|
||||||
|
|
||||||
private ThreadStatusCallback callBackToController;
|
private ThreadStatusCallback callBackToController;
|
||||||
@@ -111,8 +114,7 @@ public class DXClusterThreadPooledServer implements Runnable{
|
|||||||
System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue());
|
System.out.println("-------------> ORIGINALEE VAL: " + aChatMember.getFrequency().getValue());
|
||||||
System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ");
|
System.out.println("-------------> NORMALIZED VAL: " + Utils4KST.normalizeFrequencyString(aChatMember.getFrequency().getValue(), chatController.getChatPreferences().getNotify_optionalFrequencyPrefix()) + " ");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("DXCThPooledServer: Error accessing value in chatmember object: " + e.getMessage());
|
LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e);
|
||||||
// e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Socket socket : clientSockets) {
|
for (Socket socket : clientSockets) {
|
||||||
@@ -146,8 +148,7 @@ public class DXClusterThreadPooledServer implements Runnable{
|
|||||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e);
|
||||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +160,7 @@ public class DXClusterThreadPooledServer implements Runnable{
|
|||||||
|
|
||||||
class DXClusterServerWorkerRunnable implements Runnable{
|
class DXClusterServerWorkerRunnable implements Runnable{
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(DXClusterServerWorkerRunnable.class.getName());
|
||||||
protected Socket clientSocket = null;
|
protected Socket clientSocket = null;
|
||||||
protected String serverText = null;
|
protected String serverText = null;
|
||||||
private ChatController client = null;
|
private ChatController client = null;
|
||||||
@@ -197,14 +199,13 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
|||||||
output.write(("\r\n").getBytes());
|
output.write(("\r\n").getBytes());
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e);
|
||||||
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
|
||||||
dXCkeepAliveTimer.purge();
|
dXCkeepAliveTimer.purge();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.close();
|
socket.close();
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
ex.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex);
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
this.cancel();
|
this.cancel();
|
||||||
@@ -224,7 +225,7 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
|||||||
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e);
|
||||||
} finally {
|
} finally {
|
||||||
synchronized(dxClusterClientSocketsConnectedList) {
|
synchronized(dxClusterClientSocketsConnectedList) {
|
||||||
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package kst4contest.controller;
|
|||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import kst4contest.model.ChatMessage;
|
import kst4contest.model.ChatMessage;
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ import kst4contest.model.ChatMessage;
|
|||||||
* No need for it as it´s not longer a console application
|
* No need for it as it´s not longer a console application
|
||||||
*/
|
*/
|
||||||
public class InputReaderThread extends Thread {
|
public class InputReaderThread extends Thread {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(InputReaderThread.class.getName());
|
||||||
private PrintWriter writer;
|
private PrintWriter writer;
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private ChatController client;
|
private ChatController client;
|
||||||
@@ -39,8 +42,7 @@ public class InputReaderThread extends Thread {
|
|||||||
try {
|
try {
|
||||||
sendThisMessage23001 = reader.readLine();
|
sendThisMessage23001 = reader.readLine();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// TODO Auto-generated catch block
|
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
|
||||||
e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
||||||
@@ -53,8 +55,8 @@ public class InputReaderThread extends Thread {
|
|||||||
try {
|
try {
|
||||||
this.sleep(500);
|
this.sleep(500);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
// TODO Auto-generated catch block
|
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
|
||||||
e.printStackTrace();
|
Thread.currentThread().interrupt();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -772,7 +772,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dummy.setCallSign("ALL");
|
dummy.setCallSign("ALL");
|
||||||
newMessageArrived.setReceiver(dummy);
|
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 {
|
} else {
|
||||||
//message is directed to another chatmember, process as such!
|
//message is directed to another chatmember, process as such!
|
||||||
@@ -817,7 +819,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
if (newMessageArrived.getReceiver().getCallSign()
|
if (newMessageArrived.getReceiver().getCallSign()
|
||||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||||
|
|
||||||
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 (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
|
if (this.client.getChatPreferences().isNotify_playSimpleSounds()) {
|
||||||
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
this.client.getPlayAudioUtils().playNoiseLauncher('P');
|
||||||
@@ -960,14 +964,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
String originalMessage = newMessageArrived.getMessageText();
|
String originalMessage = newMessageArrived.getMessageText();
|
||||||
newMessageArrived
|
newMessageArrived
|
||||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
.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 our message contained a frequency (e.g. "QRG is: 144.375"), record that
|
|
||||||
// WE sent our QRG to this OM – used by SKED frequency resolution.
|
|
||||||
if (originalMessage != null && newMessageArrived.getReceiver() != null
|
|
||||||
&& originalMessage.matches(".*\\b\\d{3,5}[.,]\\d{1,3}.*")) {
|
|
||||||
this.client.recordOutboundQRG(newMessageArrived.getReceiver().getCallSign());
|
|
||||||
}
|
|
||||||
|
|
||||||
// if you sent the message to another station, it will be sorted in to
|
// 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
|
// the "to me message list" with modified messagetext, added rxers callsign
|
||||||
@@ -1031,7 +1030,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||||
}
|
}
|
||||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||||
@@ -1134,7 +1134,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
dxcMsg.setMessageInhibited(splittedMessageLine[7]);
|
||||||
dxcMsg.setQrgSpotted(splittedMessageLine[5]);
|
dxcMsg.setQrgSpotted(splittedMessageLine[5]);
|
||||||
|
|
||||||
this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
// this.client.getLst_clusterMemberList().add(0, dxcMsg);
|
||||||
|
this.client.publishClusterMessage(dxcMsg);
|
||||||
|
|
||||||
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
|
// System.out.println("[MSGBUSMGT:] DXCluster Message detected ");
|
||||||
|
|
||||||
@@ -1173,7 +1174,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
dxcMsg2.setMessageInhibited(splittedMessageLine[6]);
|
||||||
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
dxcMsg2.setQrgSpotted(splittedMessageLine[4]);
|
||||||
|
|
||||||
this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
// this.client.getLst_clusterMemberList().add(0, dxcMsg2);
|
||||||
|
this.client.publishClusterMessage(dxcMsg2);
|
||||||
|
|
||||||
} else
|
} else
|
||||||
|
|
||||||
@@ -1203,8 +1205,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dxcMsg3.setMessageInhibited("");
|
dxcMsg3.setMessageInhibited("");
|
||||||
dxcMsg3.setQrgSpotted("");
|
dxcMsg3.setQrgSpotted("");
|
||||||
|
|
||||||
this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
// this.client.getLst_clusterMemberList().add(0, dxcMsg3);
|
||||||
|
this.client.publishClusterMessage(dxcMsg3);
|
||||||
} else
|
} else
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1371,7 +1373,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dummy.setCallSign("ALL");
|
dummy.setCallSign("ALL");
|
||||||
newMessageArrived.setReceiver(dummy);
|
newMessageArrived.setReceiver(dummy);
|
||||||
|
|
||||||
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
// 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)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
//message is directed to another chatmember, process as such!
|
//message is directed to another chatmember, process as such!
|
||||||
@@ -1415,7 +1418,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
if (newMessageArrived.getReceiver().getCallSign()
|
if (newMessageArrived.getReceiver().getCallSign()
|
||||||
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
.equals(this.client.getChatPreferences().getStn_loginCallSign())) {
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
|
System.out.println("Historic message directed to me: " + newMessageArrived.getReceiver().getCallSign() + ".");
|
||||||
|
|
||||||
@@ -1428,8 +1432,9 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
String originalMessage = newMessageArrived.getMessageText();
|
String originalMessage = newMessageArrived.getMessageText();
|
||||||
newMessageArrived
|
newMessageArrived
|
||||||
.setMessageText("(>" + newMessageArrived.getReceiver().getCallSign() + ")" + originalMessage);
|
.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
|
// 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
|
// the "to me message list" with modified messagetext, added rxers callsign
|
||||||
|
|
||||||
@@ -1448,7 +1453,8 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
newMessageArrived.getSender().setInAngleAndRange(false);
|
newMessageArrived.getSender().setInAngleAndRange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
// System.out.println("MSGBS bgfx: tx call = " + newMessageArrived.getSender().getCallSign() + " / rx call = " + newMessageArrived.getReceiver().getCallSign());
|
||||||
}
|
}
|
||||||
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
} catch (NullPointerException referenceDeletedByUserLeftChatDuringMessageprocessing) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package kst4contest.controller;
|
|||||||
import java.io.*;
|
import java.io.*;
|
||||||
import java.net.*;
|
import java.net.*;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
import kst4contest.model.ChatMessage;
|
import kst4contest.model.ChatMessage;
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ import kst4contest.model.ChatMessage;
|
|||||||
* @author www.codejava.net
|
* @author www.codejava.net
|
||||||
*/
|
*/
|
||||||
public class ReadThread extends Thread {
|
public class ReadThread extends Thread {
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(ReadThread.class.getName());
|
||||||
private BufferedReader reader;
|
private BufferedReader reader;
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private ChatController client;
|
private ChatController client;
|
||||||
@@ -43,8 +46,7 @@ public class ReadThread extends Thread {
|
|||||||
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
|
||||||
|
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.out.println("Error getting input stream: " + ex.getMessage());
|
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
|
||||||
ex.printStackTrace();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +84,14 @@ public class ReadThread extends Thread {
|
|||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception sexc) {
|
catch (Exception sexc) {
|
||||||
System.out.println("[ReadThread, CRITICAL: ] Socket geschlossen: " + sexc.getMessage());
|
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
|
||||||
try {
|
try {
|
||||||
this.client.getSocket().close();
|
this.client.getSocket().close();
|
||||||
this.interrupt();
|
this.interrupt();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// TODO Auto-generated catch block
|
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
|
||||||
e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ public class ChatPreferences {
|
|||||||
double stn_maxQRBDefault = 900;
|
double stn_maxQRBDefault = 900;
|
||||||
double stn_qtfDefault = 135;
|
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 loginChatCategoryMain = new ChatCategory(2);
|
||||||
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
ChatCategory loginChatCategorySecond = new ChatCategory(3);
|
||||||
boolean loginToSecondChatEnabled;
|
boolean loginToSecondChatEnabled;
|
||||||
@@ -309,6 +326,9 @@ public class ChatPreferences {
|
|||||||
boolean guiOptions_defaultFilterPmToOther;
|
boolean guiOptions_defaultFilterPmToOther;
|
||||||
boolean guiOptions_defaultFilterPublicMsgs;
|
boolean guiOptions_defaultFilterPublicMsgs;
|
||||||
|
|
||||||
|
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
||||||
|
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
||||||
|
|
||||||
|
|
||||||
/*********************************************************************************
|
/*********************************************************************************
|
||||||
*
|
*
|
||||||
@@ -371,6 +391,42 @@ public class ChatPreferences {
|
|||||||
this.MYQRGFirstCat.set(MYQRGFirstCat);
|
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() {
|
public String getStn_loginNameSecondCat() {
|
||||||
return stn_loginNameSecondCat;
|
return stn_loginNameSecondCat;
|
||||||
}
|
}
|
||||||
@@ -547,6 +603,22 @@ public class ChatPreferences {
|
|||||||
this.loginToSecondChatEnabled = loginToSecondChatEnabled;
|
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() {
|
public boolean isGuiOptions_defaultFilterNothing() {
|
||||||
return guiOptions_defaultFilterNothing;
|
return guiOptions_defaultFilterNothing;
|
||||||
}
|
}
|
||||||
@@ -1245,6 +1317,63 @@ public class ChatPreferences {
|
|||||||
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
stn_qtfDefault.setTextContent(this.stn_qtfDefault+"");
|
||||||
station.appendChild(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");
|
Element stn_bandActive144 = doc.createElement("stn_bandActive144");
|
||||||
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
stn_bandActive144.setTextContent(this.stn_bandActive144+"");
|
||||||
station.appendChild(stn_bandActive144);
|
station.appendChild(stn_bandActive144);
|
||||||
@@ -1740,6 +1869,18 @@ public class ChatPreferences {
|
|||||||
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
GUIpnl_directedMSGWin_dividerpositionDefault.setTextContent(doubleArrayToCSVString(getGUIpnl_directedMSGWin_dividerpositionDefault()));
|
||||||
guiOptions.appendChild(GUIpnl_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! *************************************
|
****************************** now write this XML! *************************************
|
||||||
****************************************************************************************/
|
****************************************************************************************/
|
||||||
@@ -1856,6 +1997,90 @@ public class ChatPreferences {
|
|||||||
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault");
|
||||||
stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault");
|
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)
|
// Band activity flags (introduced later; if missing -> keep defaults)
|
||||||
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
stn_bandActive144 = getBoolean(stationEl, stn_bandActive144, "stn_bandActive144");
|
||||||
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
stn_bandActive432 = getBoolean(stationEl, stn_bandActive432, "stn_bandActive432");
|
||||||
@@ -2264,6 +2489,16 @@ public class ChatPreferences {
|
|||||||
parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
||||||
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
parseSemicolonDoublesInto(getText(element, null, "GUIsettingsStageSceneSizeHW"), this.getGUIsettingsStageSceneSizeHW());
|
||||||
|
|
||||||
|
parseSemicolonDoublesInto(
|
||||||
|
getText(element, null, "GUIstationMapStageSceneSizeHW"),
|
||||||
|
this.getGUIstationMapStageSceneSizeHW()
|
||||||
|
);
|
||||||
|
|
||||||
|
parseSemicolonDoublesInto(
|
||||||
|
getText(element, null, "GUIstationMapStagePositionXY"),
|
||||||
|
this.getGUIstationMapStagePositionXY()
|
||||||
|
);
|
||||||
|
|
||||||
// Splitpane divider positions
|
// Splitpane divider positions
|
||||||
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition");
|
||||||
if (s1 != null) {
|
if (s1 != null) {
|
||||||
@@ -2660,6 +2895,100 @@ 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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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,12 +7,16 @@ import java.io.FileOutputStream;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Path;
|
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.
|
* This class has utility methods to handle application files inside the home directory.
|
||||||
*/
|
*/
|
||||||
public class ApplicationFileUtils {
|
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.
|
* 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
|
* @param applicationName Name off the application which is used for the hidden directory
|
||||||
@@ -61,8 +65,7 @@ public class ApplicationFileUtils {
|
|||||||
|
|
||||||
resourceStream.transferTo(fileOutputStream);
|
resourceStream.transferTo(fileOutputStream);
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
System.err.println("Exception when copying Application file: " + ex.getMessage());
|
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
|
||||||
ex.printStackTrace(System.err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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,7 +3,12 @@ package kst4contest.view;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.*;
|
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.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
@@ -57,11 +62,16 @@ import kst4contest.model.*;
|
|||||||
import javafx.scene.shape.Line;
|
import javafx.scene.shape.Line;
|
||||||
import javafx.scene.shape.Polygon;
|
import javafx.scene.shape.Polygon;
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
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 {
|
public class Kst4ContestApplication extends Application implements StatusUpdateListener {
|
||||||
// private static final Kst4ContestApplication dbcontroller = new DBController();
|
// 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 Button btnBandUpgradeIndicator = new Button("BAND+");
|
||||||
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
private final Tooltip tipBandUpgradeIndicator = new Tooltip();
|
||||||
@@ -111,6 +121,73 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
ToggleButton[] btnQtfButtonsAvl = new ToggleButton[8];
|
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
|
* helper DTO for planes and arriving time in minutes. Maybe
|
||||||
*/
|
*/
|
||||||
@@ -700,23 +777,21 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
});
|
});
|
||||||
selectedCallSignShowAsPathBtn.setGraphic(createArrow(selectedCallSignInfoStageChatMember.getQTFdirection()));
|
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());
|
Button selectedCallSignTurnAntBtn = new Button("Turn ant1 to " + selectedCallSignInfoStageChatMember.getCallSignRaw());
|
||||||
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
selectedCallSignTurnAntBtn.setOnAction(new EventHandler<ActionEvent>() {
|
||||||
@Override
|
@Override
|
||||||
public void handle(ActionEvent actionEvent) {
|
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());
|
chatcontroller.rotateTo(selectedCallSignInfoStageChatMember.getQTFdirection());
|
||||||
|
|
||||||
|
|
||||||
//TODO: Hier muss was hin
|
//TODO: Hier muss was hin
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -738,7 +813,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1);
|
// selectedCallSignDownerSiteGridPane.add(selectedCallSignShowAsPathBtn, 1,0,1,1);
|
||||||
|
|
||||||
|
HBox selectedCallSignPathAndMapButtons = new HBox(10, selectedCallSignShowAsPathBtn, selectedCallSignShowOnMapBtn);
|
||||||
|
selectedCallSignDownerSiteGridPane.add(selectedCallSignPathAndMapButtons, 1,0,1,1);
|
||||||
|
|
||||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1);
|
selectedCallSignDownerSiteGridPane.add(selectedCallSignTurnAntBtn, 1,1,1,1);
|
||||||
|
|
||||||
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1);
|
selectedCallSignDownerSiteGridPane.add(selectedCallSignShowQRZprofile, 1,2,1,1);
|
||||||
@@ -2273,6 +2352,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
applyQrgUiFormatting(qrgCol); //fills ending 0 to format the qrgs pretty
|
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");
|
TableColumn<ChatMessage, String> msgCol = new TableColumn<ChatMessage, String>("Message");
|
||||||
msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
msgCol.setCellValueFactory(new Callback<CellDataFeatures<ChatMessage, String>, ObservableValue<String>>() {
|
||||||
|
|
||||||
@@ -2280,13 +2377,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> cellDataFeatures) {
|
public ObservableValue<String> call(CellDataFeatures<ChatMessage, String> cellDataFeatures) {
|
||||||
SimpleStringProperty msg = new SimpleStringProperty();
|
SimpleStringProperty msg = new SimpleStringProperty();
|
||||||
|
|
||||||
if (cellDataFeatures.getValue().getMessageText() != null) {
|
if (cellDataFeatures.getValue() != null) {
|
||||||
|
msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue()));
|
||||||
msg.setValue(cellDataFeatures.getValue().getMessageText());
|
|
||||||
} else {
|
} else {
|
||||||
|
msg.setValue("");
|
||||||
msg.setValue("");// TODO: Prevents a bug of not setting all values as a default
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3772,7 +3868,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
|
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_EVENING);
|
||||||
|
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(true);
|
chatcontroller.getChatPreferences().setGUI_darkModeActive(true);
|
||||||
|
if (stationMapBridge != null) {
|
||||||
|
stationMapBridge.applyThemeFromPreferences(); //dark mode for the map
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3791,10 +3889,32 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
clusterAndQSOMonScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||||
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
settingsScene.getStylesheets().add(ApplicationConstants.STYLECSSFILE_DEFAULT_DAYLIGHT);
|
||||||
chatcontroller.getChatPreferences().setGUI_darkModeActive(false);
|
chatcontroller.getChatPreferences().setGUI_darkModeActive(false);
|
||||||
|
|
||||||
|
if (stationMapBridge != null) {
|
||||||
|
stationMapBridge.applyThemeFromPreferences();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
windowMenu.getItems().addAll(window1, window20, window30, window40);
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
Menu helpMenu = new Menu("Info");
|
Menu helpMenu = new Menu("Info");
|
||||||
|
|
||||||
@@ -6513,9 +6633,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText());
|
System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText());
|
||||||
chatcontroller.getChatPreferences().setStn_antennaBeamWidthDeg(Double.parseDouble(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() + "");
|
TextField txtFldstn_maxQRBDefault = new TextField(this.chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||||
txtFldstn_maxQRBDefault.setFocusTraversable(false);
|
txtFldstn_maxQRBDefault.setFocusTraversable(false);
|
||||||
|
|
||||||
@@ -6534,6 +6681,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText());
|
System.out.println("[Main.java, Info]: Setted the QRB: " + txtFldstn_maxQRBDefault.getText());
|
||||||
chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(txtFldstn_maxQRBDefault.getText()));
|
chatcontroller.getChatPreferences().setStn_maxQRBDefault(Double.parseDouble(txtFldstn_maxQRBDefault.getText()));
|
||||||
|
refreshStationMapIfVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6561,6 +6709,144 @@ 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):");
|
Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):");
|
||||||
CheckBox chkBx_station_pstRotatorEnabled = new CheckBox();
|
CheckBox chkBx_station_pstRotatorEnabled = new CheckBox();
|
||||||
chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled());
|
chkBx_station_pstRotatorEnabled.setSelected(chatcontroller.getChatPreferences().isStn_pstRotatorEnabled());
|
||||||
@@ -6584,12 +6870,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
grdPnlStation.add(choiceBxChatChategory, 1, 4);
|
grdPnlStation.add(choiceBxChatChategory, 1, 4);
|
||||||
grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5);
|
grdPnlStation.add(new Label("Antenna beamwidth:"), 0, 5);
|
||||||
grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 5);
|
grdPnlStation.add(txtFldstn_antennaBeamWidthDeg, 1, 5);
|
||||||
grdPnlStation.add(new Label("Default maximum QRB:"), 0, 6);
|
|
||||||
grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 6);
|
grdPnlStation.add(new Label("Own antenna height AGL:"), 0, 8);
|
||||||
grdPnlStation.add(new Label("Default filter QTF:"), 0, 7);
|
grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8);
|
||||||
grdPnlStation.add(txtFldstn_qtfDefault, 1, 7);
|
|
||||||
grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8);
|
grdPnlStation.add(new Label("DEM root directory:"), 0, 9);
|
||||||
grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8);
|
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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
VBox vbxStation = new VBox();
|
VBox vbxStation = new VBox();
|
||||||
vbxStation.setPadding(new Insets(10, 10, 10, 10));
|
vbxStation.setPadding(new Insets(10, 10, 10, 10));
|
||||||
@@ -6741,6 +7051,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// vbxStation.getChildren().add(settings_chkbx_QRV5600);
|
// vbxStation.getChildren().add(settings_chkbx_QRV5600);
|
||||||
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*************************************************************************************
|
/*************************************************************************************
|
||||||
* Log synch settings Tab
|
* Log synch settings Tab
|
||||||
*************************************************************************************/
|
*************************************************************************************/
|
||||||
@@ -8504,9 +8817,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
setupFileLogging();
|
||||||
launch(args);
|
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
|
@Override
|
||||||
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
|
public void onThreadStatusChanged(String key, ThreadStateMessage threadStateMessage) {
|
||||||
|
|
||||||
@@ -8766,6 +9094,49 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8800,6 +9171,7 @@ class ActionButtonTableCell<S, T> extends TableCell<S, T> {
|
|||||||
setGraphic(actionButton);
|
setGraphic(actionButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8838,6 +9210,4 @@ class CheckBoxTableCell<S, T> extends TableCell<S, T> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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
@@ -0,0 +1,228 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,675 @@
|
|||||||
|
package kst4contest.view.map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String createStationMapHtml() {
|
||||||
|
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>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||||
|
crossorigin="">
|
||||||
|
<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 src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||||
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||||
|
crossorigin=""></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;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyThemeClass();
|
||||||
|
|
||||||
|
map = L.map('map', {
|
||||||
|
zoomControl: true
|
||||||
|
}).setView([51.0, 10.0], 6);
|
||||||
|
|
||||||
|
jsLog('Leaflet map initialized');
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 18,
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
init();
|
||||||
|
jsLog('setHome lat=' + lat + ' lon=' + lon + ' zoom=' + zoom);
|
||||||
|
map.setView([lat, lon], zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStations(stationsJson) {
|
||||||
|
init();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
init();
|
||||||
|
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) {
|
||||||
|
init();
|
||||||
|
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) {
|
||||||
|
init();
|
||||||
|
|
||||||
|
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) {
|
||||||
|
init();
|
||||||
|
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>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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
@@ -0,0 +1,212 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
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
@@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kst4contest.view.map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for terrain/profile retrieval.
|
||||||
|
*/
|
||||||
|
public interface TerrainProfileProvider {
|
||||||
|
|
||||||
|
TerrainProfileData loadProfile(TerrainProfileRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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());
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ module praktiKST {
|
|||||||
requires jdk.xml.dom;
|
requires jdk.xml.dom;
|
||||||
requires java.sql;
|
requires java.sql;
|
||||||
requires javafx.media;
|
requires javafx.media;
|
||||||
|
requires jdk.jsobject;
|
||||||
|
requires java.net.http;
|
||||||
|
requires java.desktop;
|
||||||
exports kst4contest.controller.interfaces;
|
exports kst4contest.controller.interfaces;
|
||||||
exports kst4contest.controller;
|
exports kst4contest.controller;
|
||||||
exports kst4contest.locatorUtils;
|
exports kst4contest.locatorUtils;
|
||||||
exports kst4contest.model;
|
exports kst4contest.model;
|
||||||
exports kst4contest.view;
|
exports kst4contest.view;
|
||||||
|
|
||||||
|
opens kst4contest.view.map to javafx.web;
|
||||||
}
|
}
|
||||||
@@ -1781,3 +1781,588 @@ 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
|
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
|
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
|
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