mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-06-22 22:06:49 +02:00
Compare commits
1 Commits
main
..
56fd15f1d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 56fd15f1d6 |
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug Report
|
|
||||||
about: Report a problem with KST4Contest / Ein Problem mit KST4Contest melden
|
|
||||||
title: "[BUG] "
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
**DE:** Bitte fülle alle Felder so vollständig wie möglich aus. Das hilft, den Fehler schneller zu finden.
|
|
||||||
**EN:** Please fill in all fields as completely as possible. This helps to find the bug faster.
|
|
||||||
|
|
||||||
## Description / Beschreibung
|
|
||||||
|
|
||||||
<!-- EN: A clear and concise description of the bug. -->
|
|
||||||
<!-- DE: Eine klare und präzise Beschreibung des Problems. -->
|
|
||||||
|
|
||||||
## Steps to reproduce / Schritte zum Reproduzieren
|
|
||||||
|
|
||||||
<!-- EN: Step-by-step instructions to reproduce the bug. -->
|
|
||||||
<!-- DE: Schritt-für-Schritt-Anleitung, um den Fehler zu reproduzieren. -->
|
|
||||||
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
## Expected behaviour / Erwartetes Verhalten
|
|
||||||
|
|
||||||
<!-- EN: What did you expect to happen? / DE: Was hätte passieren sollen? -->
|
|
||||||
|
|
||||||
## Actual behaviour / Tatsächliches Verhalten
|
|
||||||
|
|
||||||
<!-- EN: What actually happened? / DE: Was ist stattdessen passiert? -->
|
|
||||||
|
|
||||||
## Log file content / Inhalt der Logdatei
|
|
||||||
|
|
||||||
**EN:** Please paste the content of the error log file here. It is written automatically and contains error messages only — no personal data.
|
|
||||||
**DE:** Bitte füge hier den Inhalt der Fehler-Logdatei ein. Sie wird automatisch geschrieben und enthält nur Fehlermeldungen — keine persönlichen Daten.
|
|
||||||
|
|
||||||
| OS | Path / Pfad |
|
|
||||||
|----|-------------|
|
|
||||||
| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` |
|
|
||||||
| Windows | `C:\Users\<YourName>\.praktiKST\kst4contest-errors.log` |
|
|
||||||
|
|
||||||
```
|
|
||||||
Paste log content here / Loginhalt hier einfügen
|
|
||||||
```
|
|
||||||
|
|
||||||
## Version
|
|
||||||
|
|
||||||
**KST4Contest version / Version** (e.g. 1.41.0):
|
|
||||||
|
|
||||||
**Java version / Java-Version** (`java -version`, e.g. 17.0.9):
|
|
||||||
|
|
||||||
**Operating system / Betriebssystem:** <!-- Linux / Windows / macOS -->
|
|
||||||
|
|
||||||
**Logging software / Logprogramm** (if applicable / falls relevant, e.g. UCXLog, N1MM+, WinTest):
|
|
||||||
|
|
||||||
## Checklist / Checkliste
|
|
||||||
|
|
||||||
- [ ] I have attached the log file / Ich habe die Logdatei angehängt
|
|
||||||
- [ ] I have checked that this issue has not been reported before / Ich habe geprüft, dass dieses Problem noch nicht gemeldet wurde
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
blank_issues_enabled: false
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature Request
|
|
||||||
about: Suggest a new feature or improvement / Neue Funktion oder Verbesserung vorschlagen
|
|
||||||
title: "[FEATURE] "
|
|
||||||
labels: enhancement
|
|
||||||
---
|
|
||||||
|
|
||||||
**DE:** Bitte beschreibe deine Idee so genau wie möglich.
|
|
||||||
**EN:** Please describe your idea as precisely as possible.
|
|
||||||
|
|
||||||
## Summary / Zusammenfassung
|
|
||||||
|
|
||||||
<!-- EN: A short summary of the feature you'd like. -->
|
|
||||||
<!-- DE: Eine kurze Zusammenfassung der gewünschten Funktion. -->
|
|
||||||
|
|
||||||
## Motivation / Begründung
|
|
||||||
|
|
||||||
<!-- EN: Why would this feature be useful? What problem does it solve? -->
|
|
||||||
<!-- DE: Warum wäre diese Funktion nützlich? Welches Problem löst sie? -->
|
|
||||||
|
|
||||||
## Detailed description / Detaillierte Beschreibung
|
|
||||||
|
|
||||||
<!-- EN: Describe the feature in detail. How should it work? -->
|
|
||||||
<!-- DE: Beschreibe die Funktion im Detail. Wie soll sie funktionieren? -->
|
|
||||||
|
|
||||||
## Alternatives considered / Geprüfte Alternativen
|
|
||||||
|
|
||||||
<!-- EN: Have you considered any alternative solutions or workarounds? -->
|
|
||||||
<!-- DE: Hast du alternative Lösungen oder Workarounds in Betracht gezogen? -->
|
|
||||||
|
|
||||||
## Checklist / Checkliste
|
|
||||||
|
|
||||||
- [ ] I have checked that this feature has not been requested before / Ich habe geprüft, dass diese Funktion noch nicht angefragt wurde
|
|
||||||
@@ -21,9 +21,6 @@ $endif$
|
|||||||
%% ─── Page layout ──────────────────────────────────────────────────────────
|
%% ─── Page layout ──────────────────────────────────────────────────────────
|
||||||
\usepackage[a4paper, top=2.5cm, bottom=2.5cm, left=2.5cm, right=2.5cm]{geometry}
|
\usepackage[a4paper, top=2.5cm, bottom=2.5cm, left=2.5cm, right=2.5cm]{geometry}
|
||||||
|
|
||||||
%% ─── Text decorations (strikethrough via ~~...~~ in Markdown → \st{}) ────
|
|
||||||
\usepackage{soul}
|
|
||||||
|
|
||||||
%% ─── Colors ───────────────────────────────────────────────────────────────
|
%% ─── Colors ───────────────────────────────────────────────────────────────
|
||||||
\usepackage[dvipsnames,svgnames,x11names]{xcolor}
|
\usepackage[dvipsnames,svgnames,x11names]{xcolor}
|
||||||
\definecolor{brand-green}{RGB}{7,166,54}
|
\definecolor{brand-green}{RGB}{7,166,54}
|
||||||
@@ -194,7 +191,7 @@ $endif$
|
|||||||
\fancyhf{}
|
\fancyhf{}
|
||||||
\fancyhead[L]{\small\color{brand-green}\textbf{KST4Contest}}
|
\fancyhead[L]{\small\color{brand-green}\textbf{KST4Contest}}
|
||||||
\fancyhead[R]{\small\color{brand-green}$if(version)$$version$$endif$}
|
\fancyhead[R]{\small\color{brand-green}$if(version)$$version$$endif$}
|
||||||
\fancyfoot[L]{\small\color{gray}DO5AMF \textbar\ DN9APW}
|
\fancyfoot[L]{\small\color{gray}DO5AMF (Marc Fröhlich) \textbar\ DN9APW (Philipp Wagner)}
|
||||||
\fancyfoot[C]{\small\color{gray}\thepage}
|
\fancyfoot[C]{\small\color{gray}\thepage}
|
||||||
\fancyfoot[R]{\small\color{gray}$title$}
|
\fancyfoot[R]{\small\color{gray}$title$}
|
||||||
\renewcommand{\headrulewidth}{0.4pt}
|
\renewcommand{\headrulewidth}{0.4pt}
|
||||||
@@ -239,7 +236,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,10 +11,6 @@ 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
|
||||||
@@ -87,7 +83,9 @@ 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 "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
||||||
|
echo "ASSET_BASENAME=praktiKST-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Java 17
|
- name: Set up Java 17
|
||||||
uses: actions/setup-java@v4.1.0
|
uses: actions/setup-java@v4.1.0
|
||||||
@@ -108,7 +106,7 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
jpackage \
|
jpackage \
|
||||||
--type app-image \
|
--type app-image \
|
||||||
--name KST4Contest \
|
--name praktiKST \
|
||||||
--input target/dist-libs \
|
--input target/dist-libs \
|
||||||
--main-jar app.jar \
|
--main-jar app.jar \
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
--main-class kst4contest.view.Kst4ContestApplication \
|
||||||
@@ -118,416 +116,39 @@ jobs:
|
|||||||
|
|
||||||
- name: Create AppDir metadata
|
- name: Create AppDir metadata
|
||||||
run: |
|
run: |
|
||||||
rm -rf target/KST4Contest.AppDir
|
rm -rf target/praktiKST.AppDir
|
||||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
cp -a dist/praktiKST target/praktiKST.AppDir
|
||||||
|
|
||||||
cat > target/KST4Contest.AppDir/AppRun << 'EOF'
|
cat > target/praktiKST.AppDir/AppRun << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
HERE="$(dirname "$(readlink -f "$0")")"
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
exec "$HERE/bin/KST4Contest" "$@"
|
exec "$HERE/bin/praktiKST" "$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x target/KST4Contest.AppDir/AppRun
|
chmod +x target/praktiKST.AppDir/AppRun
|
||||||
|
|
||||||
cat > target/KST4Contest.AppDir/KST4Contest.desktop << 'EOF'
|
cat > target/praktiKST.AppDir/praktiKST.desktop << 'EOF'
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=KST4Contest
|
Name=praktiKST
|
||||||
Exec=KST4Contest
|
Exec=praktiKST
|
||||||
Icon=KST4Contest
|
Icon=praktiKST
|
||||||
Categories=Network;HamRadio;
|
Categories=Network;HamRadio;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ -f target/KST4Contest.AppDir/lib/KST4Contest.png ]; then
|
if [ -f target/praktiKST.AppDir/lib/praktiKST.png ]; then
|
||||||
cp target/KST4Contest.AppDir/lib/KST4Contest.png target/KST4Contest.AppDir/KST4Contest.png
|
cp target/praktiKST.AppDir/lib/praktiKST.png target/praktiKST.AppDir/praktiKST.png
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x target/appimagetool.AppImage
|
chmod +x target/appimagetool.AppImage
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/praktiKST.AppDir "dist/${ASSET_BASENAME}-linux-x86_64.AppImage"
|
||||||
|
|
||||||
- name: Upload Linux artifact
|
- name: Upload Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v4.3.4
|
||||||
with:
|
with:
|
||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: dist/KST4Contest-*-linux-x86_64.AppImage
|
path: dist/praktiKST-*-linux-x86_64.AppImage
|
||||||
retention-days: 14
|
|
||||||
|
|
||||||
build-linux-deb:
|
|
||||||
name: Build Debian package
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.1.7
|
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
|
||||||
run: |
|
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
|
||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up Java 17
|
|
||||||
uses: actions/setup-java@v4.1.0
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Install packaging dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt-get update -qq
|
|
||||||
sudo apt-get install -y --no-install-recommends fakeroot
|
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
|
||||||
run: chmod +x mvnw
|
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build Debian package
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
jpackage \
|
|
||||||
--type deb \
|
|
||||||
--name KST4Contest \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
|
||||||
--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:
|
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [macos-latest, macos-15-intel]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.1.7
|
|
||||||
|
|
||||||
- name: Resolve nightly version info
|
|
||||||
run: |
|
|
||||||
VERSION=$(grep -m1 '<version>' pom.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/')
|
|
||||||
SHORT_SHA="${GITHUB_SHA::7}"
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
|
||||||
echo "SHORT_SHA=$SHORT_SHA" >> "$GITHUB_ENV"
|
|
||||||
echo "ASSET_BASENAME=KST4Contest-${VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV"
|
|
||||||
echo "ARCH=$ARCH" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Set up Java 17
|
|
||||||
uses: actions/setup-java@v4.1.0
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
|
||||||
run: chmod +x mvnw
|
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build macOS DMG with jpackage
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
jpackage \
|
|
||||||
--type dmg \
|
|
||||||
--name KST4Contest \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
env:
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
|
||||||
|
|
||||||
- name: Rename DMG artifact
|
|
||||||
run: |
|
|
||||||
DMG=$(ls dist/*.dmg | head -n 1)
|
|
||||||
if [ -z "$DMG" ]; then
|
|
||||||
echo "No DMG produced by jpackage" && exit 1
|
|
||||||
fi
|
|
||||||
mv "$DMG" "dist/${ASSET_BASENAME}-macos-${ARCH}.dmg"
|
|
||||||
|
|
||||||
- name: Upload macOS artifact
|
|
||||||
uses: actions/upload-artifact@v4.3.4
|
|
||||||
with:
|
|
||||||
name: macos-dmg-${{ matrix.os }}
|
|
||||||
path: dist/KST4Contest-*-macos-*.dmg
|
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
@@ -89,7 +88,7 @@ jobs:
|
|||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
jpackage \
|
jpackage \
|
||||||
--type app-image \
|
--type app-image \
|
||||||
--name KST4Contest \
|
--name praktiKST \
|
||||||
--input target/dist-libs \
|
--input target/dist-libs \
|
||||||
--main-jar app.jar \
|
--main-jar app.jar \
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
--main-class kst4contest.view.Kst4ContestApplication \
|
||||||
@@ -99,404 +98,41 @@ jobs:
|
|||||||
|
|
||||||
- name: Create AppDir metadata
|
- name: Create AppDir metadata
|
||||||
run: |
|
run: |
|
||||||
rm -rf target/KST4Contest.AppDir
|
rm -rf target/praktiKST.AppDir
|
||||||
cp -a dist/KST4Contest target/KST4Contest.AppDir
|
cp -a dist/praktiKST target/praktiKST.AppDir
|
||||||
|
|
||||||
cat > target/KST4Contest.AppDir/AppRun << 'EOF'
|
cat > target/praktiKST.AppDir/AppRun << 'EOF'
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
HERE="$(dirname "$(readlink -f "$0")")"
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
exec "$HERE/bin/KST4Contest" "$@"
|
exec "$HERE/bin/praktiKST" "$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x target/KST4Contest.AppDir/AppRun
|
chmod +x target/praktiKST.AppDir/AppRun
|
||||||
|
|
||||||
cat > target/KST4Contest.AppDir/KST4Contest.desktop << 'EOF'
|
cat > target/praktiKST.AppDir/praktiKST.desktop << 'EOF'
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=KST4Contest
|
Name=praktiKST
|
||||||
Exec=KST4Contest
|
Exec=praktiKST
|
||||||
Icon=KST4Contest
|
Icon=praktiKST
|
||||||
Categories=Network;HamRadio;
|
Categories=Network;HamRadio;
|
||||||
Terminal=false
|
Terminal=false
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [ -f target/KST4Contest.AppDir/lib/KST4Contest.png ]; then
|
if [ -f target/praktiKST.AppDir/lib/praktiKST.png ]; then
|
||||||
cp target/KST4Contest.AppDir/lib/KST4Contest.png target/KST4Contest.AppDir/KST4Contest.png
|
cp target/praktiKST.AppDir/lib/praktiKST.png target/praktiKST.AppDir/praktiKST.png
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build AppImage
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
wget -q -O target/appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
|
||||||
chmod +x target/appimagetool.AppImage
|
chmod +x target/appimagetool.AppImage
|
||||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/KST4Contest.AppDir dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 target/appimagetool.AppImage target/praktiKST.AppDir dist/praktiKST-${{ github.ref_name }}-linux-x86_64.AppImage
|
||||||
|
|
||||||
- name: Upload Linux artifact
|
- name: Upload Linux artifact
|
||||||
uses: actions/upload-artifact@v4.3.4
|
uses: actions/upload-artifact@v4.3.4
|
||||||
with:
|
with:
|
||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-linux-x86_64.AppImage
|
path: dist/praktiKST-${{ 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:
|
|
||||||
name: Build macOS DMG (${{ matrix.os }})
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [macos-latest, macos-15-intel]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4.1.7
|
|
||||||
|
|
||||||
- name: Set up Java 17
|
|
||||||
uses: actions/setup-java@v4.1.0
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
|
|
||||||
- name: Ensure mvnw is executable
|
|
||||||
run: chmod +x mvnw
|
|
||||||
|
|
||||||
- name: Build JAR and copy runtime dependencies
|
|
||||||
run: |
|
|
||||||
./mvnw -B -DskipTests package dependency:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/dist-libs
|
|
||||||
cp "$(ls -t target/praktiKST-*.jar | head -n 1)" target/dist-libs/app.jar
|
|
||||||
|
|
||||||
- name: Build macOS DMG with jpackage
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
jpackage \
|
|
||||||
--type dmg \
|
|
||||||
--name KST4Contest \
|
|
||||||
--input target/dist-libs \
|
|
||||||
--main-jar app.jar \
|
|
||||||
--main-class kst4contest.view.Kst4ContestApplication \
|
|
||||||
--module-path target/dist-libs \
|
|
||||||
--add-modules javafx.controls,javafx.graphics,javafx.fxml,javafx.web,javafx.media,java.sql \
|
|
||||||
--dest dist
|
|
||||||
|
|
||||||
env:
|
|
||||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
|
||||||
|
|
||||||
- name: Rename DMG artifact
|
|
||||||
run: |
|
|
||||||
ARCH=$(uname -m)
|
|
||||||
DMG=$(ls dist/*.dmg | head -n 1)
|
|
||||||
if [ -z "$DMG" ]; then
|
|
||||||
echo "No DMG produced by jpackage" && exit 1
|
|
||||||
fi
|
|
||||||
mv "$DMG" "dist/KST4Contest-${{ github.ref_name }}-macos-${ARCH}.dmg"
|
|
||||||
|
|
||||||
- name: Upload macOS artifact
|
|
||||||
uses: actions/upload-artifact@v4.3.4
|
|
||||||
with:
|
|
||||||
name: macos-dmg-${{ matrix.os }}
|
|
||||||
path: dist/KST4Contest-${{ github.ref_name }}-macos-*.dmg
|
|
||||||
|
|
||||||
build-docs-pdf:
|
build-docs-pdf:
|
||||||
name: Build Documentation PDF
|
name: Build Documentation PDF
|
||||||
@@ -575,47 +211,13 @@ 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-flatpak
|
|
||||||
- build-docs-pdf
|
- build-docs-pdf
|
||||||
- publish-flatpak-repo
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
@@ -630,37 +232,6 @@ jobs:
|
|||||||
name: linux-appimage
|
name: linux-appimage
|
||||||
path: release-assets/linux
|
path: release-assets/linux
|
||||||
|
|
||||||
- name: Download macOS artifacts
|
|
||||||
uses: actions/download-artifact@v4.1.3
|
|
||||||
with:
|
|
||||||
pattern: macos-dmg-*
|
|
||||||
merge-multiple: true
|
|
||||||
path: release-assets/macos
|
|
||||||
|
|
||||||
- name: Download Debian artifact
|
|
||||||
uses: actions/download-artifact@v4.1.3
|
|
||||||
with:
|
|
||||||
name: linux-debian
|
|
||||||
path: release-assets/debian
|
|
||||||
|
|
||||||
- name: Download Fedora artifact
|
|
||||||
uses: actions/download-artifact@v4.1.3
|
|
||||||
with:
|
|
||||||
name: linux-fedora
|
|
||||||
path: release-assets/fedora
|
|
||||||
|
|
||||||
- name: Download Arch Linux artifact
|
|
||||||
uses: actions/download-artifact@v4.1.3
|
|
||||||
with:
|
|
||||||
name: linux-arch
|
|
||||||
path: release-assets/archlinux
|
|
||||||
|
|
||||||
- name: Download flatpakref
|
|
||||||
uses: actions/download-artifact@v4.1.3
|
|
||||||
with:
|
|
||||||
name: flatpakref
|
|
||||||
path: release-assets/flatpakref
|
|
||||||
|
|
||||||
- name: Download PDF manuals
|
- name: Download PDF manuals
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v4.1.3
|
||||||
with:
|
with:
|
||||||
@@ -680,11 +251,6 @@ jobs:
|
|||||||
generateReleaseNotes: true
|
generateReleaseNotes: true
|
||||||
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/praktiKST-${{ 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/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 @@
|
|||||||
S53MM
|
dr2x
|
||||||
PA9R
|
oe3cin
|
||||||
+15832
File diff suppressed because it is too large
Load Diff
@@ -32,10 +32,8 @@ Für diesen Dienst ist ein Account erforderlich. Bitte eine Spende für Thomas i
|
|||||||
|
|
||||||
1. AirScout starten.
|
1. AirScout starten.
|
||||||
2. In den AirScout-Einstellungen den OV3T-Feed-Account eintragen (Benutzername, Passwort, URL).
|
2. In den AirScout-Einstellungen den OV3T-Feed-Account eintragen (Benutzername, Passwort, URL).
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
3. Verbindung testen.
|
3. Verbindung testen.
|
||||||
|
|
||||||
### Schritt 2: UDP-Kommunikation für KST4Contest aktivieren
|
### Schritt 2: UDP-Kommunikation für KST4Contest aktivieren
|
||||||
|
|||||||
@@ -8,83 +8,6 @@ Versionsverlauf von KST4Contest / PraktiKST.
|
|||||||
|
|
||||||
letzter Changelog bitte aus GitHub entnehmen. Der bisherige Changelog
|
letzter Changelog bitte aus GitHub entnehmen. Der bisherige Changelog
|
||||||
|
|
||||||
## v1.40 (2026-02-16)
|
|
||||||
**Großes Feature-Release: Score-System, AP-Timeline, Win-Test, PSTRotator**
|
|
||||||
|
|
||||||
**Neu:**
|
|
||||||
- **Chatmember Score-System**: Jeder Chatmember erhält automatisch eine Prioritätsbewertung anhand von Antennenrichtung, Aktivitätszeit, Nachrichtenanzahl, aktiven Bändern, Frequenzen, Sked-Richtung und anderen Faktoren. Die Top-Kandidaten werden in einer eigenen Liste hervorgehoben.
|
|
||||||
- **AP-Timeline**: Für jeden möglichen AP-Ankunftsminuten-Slot werden bis zu 4 hochbewertete Stationen angezeigt, die erreichbar wären. Bevorzugt werden APs mit dem höchsten Potenzial, nicht die schnellste Ankunft. Stationen, auf die die eigene Antenne nicht zeigt, werden transparent dargestellt.
|
|
||||||
- **Win-Test-Unterstützung** (ab v1.31 als Beta, jetzt vollständig konfigurierbar): Log-Synchronisation, Frequenzauswertung und **Sked-Übergabe via UDP** vollständig integriert. In den Preferences aktivier-/deaktivierbar.
|
|
||||||
- **PSTRotator-Interface** (ab v1.31 als Beta, jetzt vollständig konfigurierbar): Aktualisierung der Rotatorposition direkt aus KST4Contest. In den Preferences aktivier-/deaktivierbar.
|
|
||||||
- **QSO-Sniffer**: Nachrichten von konfigurierbaren Rufzeichen-Listen werden automatisch in das PM-Fenster weitergeleitet.
|
|
||||||
- **Band-Alert bei gearbeiteten Stationen**: Wenn eine Station geloggt wird, erscheint ein Hinweis, wenn diese Station ein weiteres Band aktiv hat, auf dem man selbst ebenfalls QRV ist.
|
|
||||||
- **Sked-Erinnerungs-ALERT**: Pro Chatmember kann ein Sked-Alarm mit automatischen Nachrichten in konfigurierbaren Intervallen (2+1 / 5+2+1 / 10+5+2+1 Minuten vor dem Sked) eingerichtet werden, plus akustische und optische Benachrichtigung.
|
|
||||||
- **Chat-Historie beim Start laden**: Beim Verbindungsaufbau wird die Serverhistorie geladen, um aktive Chatmember und letzte Nachrichten sofort sichtbar zu machen.
|
|
||||||
- **Skedfail-Button**: Im FurtherInfo-Panel kann ein Sked-Misserfolg für einen Chatmember markiert werden, was dessen Score senkt.
|
|
||||||
|
|
||||||
**Geändert:**
|
|
||||||
- AP-Notizen in DX-Cluster-Spots integriert.
|
|
||||||
- Scrolling der Chatmember-Tabelle folgt automatisch der aktuellen Nachrichtenauswahl.
|
|
||||||
- Generic Auto-Antwort und QRG-Auto-Antwort senden max. einmal pro 45 Sekunden pro Rufzeichen (verhindert Spam-Schleifen).
|
|
||||||
- Speicherbare Einstellungen erweitert: ServerDNS/Port, PSTRotator-Interface, Win-Test-Interface, Callsign-Sniffer, Dark-Mode-Standard.
|
|
||||||
- Datum in der Chat-Tabelle entfernt (nur Uhrzeit verbleibt – spart Platz).
|
|
||||||
|
|
||||||
**Behoben:**
|
|
||||||
- Benutzerliste wird jetzt bei jedem Neu-Login automatisch sortiert.
|
|
||||||
- Posonpill-Nachrichten beenden jetzt nur genau eine Client-Instanz (nicht alle und nicht wtKST).
|
|
||||||
- wtKST: Absturz bei KST4Contest-Trennung behoben.
|
|
||||||
- Mehrere Probleme mit Rufzeichen-Suffixen wie `/p`, `-2` etc. behoben.
|
|
||||||
- `QTFDefault` wurde nicht korrekt gespeichert → behoben.
|
|
||||||
- AirScout-Watchlist (ASWATCHLIST) wurde nicht korrekt aktualisiert → behoben.
|
|
||||||
- Dark Mode: QRG-Felder wurden nicht vollständig angezeigt → behoben.
|
|
||||||
- Versionsnummer-Anzeige korrigiert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.31 (2025-12-13)
|
|
||||||
**Win-Test + PSTRotator Beta, QSO-Sniffer, DNS-Hotfix**
|
|
||||||
|
|
||||||
**Neu:**
|
|
||||||
- **Win-Test-Unterstützung** (Beta, noch nicht deaktivierbar): Log-Synchronisation und Frequenzauswertung.
|
|
||||||
- **PSTRotator-Unterstützung** (Beta, noch nicht deaktivierbar).
|
|
||||||
- **QSO-Sniffer**: Nachrichten von konfigurierbaren Rufzeichen werden ins PM-Fenster weitergeleitet.
|
|
||||||
|
|
||||||
**Geändert:**
|
|
||||||
- **DNS-Server geändert**: Von `www.on4kst.info` auf `www.on4kst.org` (Hotfix). Der DNS-Server ist ab sofort in den Preferences änderbar.
|
|
||||||
|
|
||||||
**Behoben:**
|
|
||||||
- Endlosschleife im Fehlerfall friert den Client ein → behoben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.266 (2025-10-03)
|
|
||||||
**AirScout-Fix für Rufzeichen mit Suffix**
|
|
||||||
|
|
||||||
**Behoben:**
|
|
||||||
- AirScout-Interface funktionierte nicht, wenn das Login-Rufzeichen einen Suffix enthielt (z. B. `9A1W-2`). AirScout kann mit diesem Format nicht umgehen – es wird jetzt nur noch das Basis-Rufzeichen ohne Suffix an AirScout übergeben.
|
|
||||||
|
|
||||||
*(Fehler gemeldet und getestet von 9A2HM / Kreso – herzlichen Dank!)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.265 (2025-09-28)
|
|
||||||
**Richtungs-Buttons bleiben aktiviert eingefärbt**
|
|
||||||
|
|
||||||
**Behoben:**
|
|
||||||
- Richtungs-Buttons (N / NE / E usw.) behalten jetzt ihre Farbe, wenn sie aktiviert sind, sodass der Aktivierungsstatus auf einen Blick erkennbar ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.264 (2025-08-02)
|
|
||||||
**Simplelogfile: Rufzeichen-Erkennung verbessert**
|
|
||||||
|
|
||||||
**Behoben:**
|
|
||||||
- Rufzeichen wie `S53CC`, `S51A` usw. wurden in der SimpleLogFile-Auswertung nicht als gearbeitet markiert → Erkennungsmuster verbessert.
|
|
||||||
|
|
||||||
*(Fehler gemeldet von Boris, S53CC – danke!)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.263 (2025-06-08)
|
## v1.263 (2025-06-08)
|
||||||
**AirScout-Kommunikation und Login-Name**
|
**AirScout-Kommunikation und Login-Name**
|
||||||
|
|
||||||
@@ -230,6 +153,6 @@ Erste öffentlich veröffentlichte Version. Grundfunktionen:
|
|||||||
## Geplante Features
|
## Geplante Features
|
||||||
|
|
||||||
- `MYQTF`-Variable (eigene Antennenrichtung als Text)
|
- `MYQTF`-Variable (eigene Antennenrichtung als Text)
|
||||||
- ~~Lebensdauer für den Worked-Status (automatisches Zurücksetzen)~~ ✅ **Umgesetzt in v1.40** (3-Tage-Lebensdauer, kein manuelles Zurücksetzen mehr nötig)
|
- Lebensdauer für den Worked-Status (automatisches Zurücksetzen)
|
||||||
- Filterung des „Cluster & QSO der anderen"-Fensters auf eigenes QTF
|
- Filterung des „Cluster & QSO der anderen"-Fensters auf eigenes QTF
|
||||||
- Weitere Topografie-basierte Berechnungen für die Richtungswarnung
|
- Weitere Topografie-basierte Berechnungen für die Richtungswarnung
|
||||||
|
|||||||
@@ -135,86 +135,22 @@ Für ausgewählte Stationen in der Benutzerliste gibt es direkte Buttons, um das
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sked-Erinnerungen mit ALERT (ab v1.40)
|
## Sked-Erinnerungen (Sked Reminder Service)
|
||||||
|
|
||||||
Für jeden Chatmember kann ein Sked-Erinnerungsdienst mit automatischen Nachrichten aktiviert werden. Konfigurierbare Intervallmuster:
|
Für vereinbarte Skeds können automatische Erinnerungs-PMs konfiguriert werden, die X Minuten vor dem vereinbarten Zeitpunkt gesendet werden. Die Erinnerungen werden aus dem FurtherInfo-Panel heraus aktiviert.
|
||||||
|
|
||||||
- **2+1 Minuten**: Nachrichten bei 2 min und 1 min vor dem Sked.
|
|
||||||
- **5+2+1 Minuten**: Nachrichten bei 5, 2 und 1 min vor dem Sked.
|
|
||||||
- **10+5+2+1 Minuten**: Nachrichten bei 10, 5, 2 und 1 min vor dem Sked.
|
|
||||||
|
|
||||||
Zusätzlich zu den Nachrichten an die Gegenstation gibt es eine **akustische und optische Benachrichtigung** für den eigenen Operator, sodass kein Sked vergessen wird.
|
|
||||||
|
|
||||||
Aktivierung: FurtherInfo-Panel der entsprechenden Station.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## QSO-Sniffer (ab v1.31)
|
## Prioritätsliste / Score-Service
|
||||||
|
|
||||||
Der QSO-Sniffer überwacht den Chat auf Nachrichten von einer konfigurierbaren Rufzeichen-Liste und leitet diese automatisch in das **PM-Fenster** weiter. So gehen keine relevanten Nachrichten im allgemeinen Chat-Rauschen unter.
|
KST4Contest berechnet automatisch eine **Prioritätsliste** der interessantesten Gesprächspartner, basierend auf:
|
||||||
|
|
||||||
Konfiguration: [Konfiguration – Sniffer-Einstellungen](de-Konfiguration#sniffer-einstellungen-ab-v131)
|
- Richtungserkennung
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Win-Test-Integration (ab v1.31, vollständig ab v1.40)
|
|
||||||
|
|
||||||
KST4Contest unterstützt [Win-Test](https://www.win-test.com/) vollständig als Logprogramm:
|
|
||||||
|
|
||||||
- **Log-Synchronisation**: Gearbeitete Stationen werden automatisch aus Win-Test übernommen und in der Benutzerliste markiert.
|
|
||||||
- **Frequenz-Auswertung**: Die aktuelle TRX-Frequenz wird aus Win-Test-UDP-Paketen ausgewertet und befüllt die `MYQRG`-Variable.
|
|
||||||
- **Sked-Übergabe (SKED Push via UDP)**: Vereinbarte Skeds aus KST4Contest können direkt an Win-Test übertragen werden, sodass das Rufzeichen der Gegenstation im Win-Test-Sked-Fenster erscheint.
|
|
||||||
|
|
||||||
Details zur Konfiguration: [Konfiguration – Win-Test-Netzwerk-Listener](de-Konfiguration#win-test-netzwerk-listener)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PSTRotator-Interface (ab v1.31, vollständig ab v1.40)
|
|
||||||
|
|
||||||
KST4Contest kann die Antennenrichtung direkt über **PSTRotator** steuern. Wenn in der Benutzerliste eine Station ausgewählt wird, kann der Rotator automatisch auf den QTF zur ausgewählten Station gedreht werden.
|
|
||||||
|
|
||||||
Konfiguration: [Konfiguration – PSTRotator-Einstellungen](de-Konfiguration#pstrotator-einstellungen-ab-v131)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Band-Alert bei neuen QSOs (ab v1.40)
|
|
||||||
|
|
||||||
Wenn eine Station geloggt wird, prüft KST4Contest automatisch, ob diese Station im Chat weitere aktive Bänder angezeigt hat, auf denen man selbst ebenfalls QRV ist. Falls ja, erscheint ein **Hinweis-Alert**, damit keine Multi-Band-Möglichkeit übersehen wird.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked-Tag-Lebensdauer (ab v1.40)
|
|
||||||
|
|
||||||
Gearbeitete Stationen werden nach **3 Tagen** automatisch aus der Datenbank entfernt. Ein manuelles Zurücksetzen der Worked-Datenbank vor jedem Contest ist damit nicht mehr zwingend notwendig – die Datenbank hält sich selbst aktuell.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chatmember Score-System / Prioritätsliste (ab v1.40)
|
|
||||||
|
|
||||||
KST4Contest berechnet automatisch eine **Prioritätsbewertung** für jeden aktiven Chatmember. Der Score setzt sich zusammen aus:
|
|
||||||
|
|
||||||
- Antennenrichtung der Gegenstation (zeigt sie auf mich?)
|
|
||||||
- QRB (Entfernung)
|
- QRB (Entfernung)
|
||||||
- Aktivitätszeit und Nachrichtenanzahl
|
|
||||||
- Aktive Bänder und Frequenzen
|
|
||||||
- AP-Verfügbarkeit (AirScout)
|
- AP-Verfügbarkeit (AirScout)
|
||||||
- Sked-Richtung
|
- Worked-Status
|
||||||
- Sked-Erfolgsrate und Skedfail-Markierungen
|
|
||||||
|
|
||||||
Die Top-Kandidaten werden in einer eigenen Prioritätsliste hervorgehoben und helfen, im Contest-Stress die wichtigsten Stationen nicht zu übersehen.
|
Die Top-Kandidaten werden in einer eigenen Liste angezeigt und helfen, im Contest-Stress die wichtigsten Stationen nicht zu übersehen.
|
||||||
|
|
||||||
Stationen, bei denen ein Sked gescheitert ist, können über den **Skedfail-Button** im FurtherInfo-Panel markiert werden – das senkt ihren Score vorübergehend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AP-Timeline (ab v1.40)
|
|
||||||
|
|
||||||
Eine visuelle Zeitleiste zeigt für jeden möglichen AP-Ankunftsminuten-Slot bis zu 4 hochbewertete Stationen, die per Aircraft Scatter erreichbar wären. Priorisierungskriterien:
|
|
||||||
|
|
||||||
- Bevorzugt werden APs mit dem **höchsten Reflexionspotenzial** (nicht unbedingt die schnellste Ankunft).
|
|
||||||
- Stationen, auf die die eigene Antenne nicht zeigt, werden **transparent** dargestellt.
|
|
||||||
|
|
||||||
So kann der Contest-Operator auf einem Blick sehen, welche Stationen wann und über welche Flugzeuge erreichbar sein werden.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,6 @@ Der ON4KST-Chat ist der De-facto-Standard für Skeds auf den 144-MHz-und-höher-
|
|||||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
- **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,29 +42,11 @@ Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-windows-x64.zip `.
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Mehrere Paketformate stehen auf der Releases-Seite zur Verfügung:
|
Die aktuelle Version kann als AppImage heruntergeladen werden:
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||||
|
|
||||||
| Format | Dateiname | Geeignet für |
|
Der Dateiname hat das Format `praktiKST-v<Versionsnummer>-linux-x86_64.AppImage`.
|
||||||
|---|---|---|
|
|
||||||
| AppImage | `KST4Contest-v<Version>-linux-x86_64.AppImage` | Alle Distributionen |
|
|
||||||
| Debian-Paket | `KST4Contest-v<Version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
|
||||||
| RPM-Paket | `KST4Contest-v<Version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
|
||||||
| Arch-Paket | `KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
|
||||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | Alle Distributionen mit Flatpak |
|
|
||||||
|
|
||||||
> **Empfehlung für Linux:** Die Flatpak-Installation ist der einfachste Weg, immer aktuell zu bleiben – `flatpak update` erledigt alle zukünftigen Updates automatisch. Das Repository ist GPG-signiert.
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
> ⚠️ **Best-Effort-Support:** macOS-Builds werden als zusätzliche Option bereitgestellt, sind aber **nicht umfassend getestet**. Wir bauen und veröffentlichen macOS-Binaries mit jedem Release, können allerdings nicht alle Szenarien unter macOS testen. Bei Problemen freuen wir uns über eine Rückmeldung – wir versuchen unser Bestes, können aber nicht den gleichen Support-Umfang wie für Windows und Linux garantieren.
|
|
||||||
|
|
||||||
Die aktuelle Version kann als DMG-Disk-Image heruntergeladen werden (für Apple-Silicon- und Intel-Macs verfügbar):
|
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
|
||||||
|
|
||||||
Der Dateiname hat das Format `KST4Contest-v<Versionsnummer>-macos-<Architektur>.dmg`, wobei `<Architektur>` entweder `arm64` (Apple Silicon) oder `x86_64` (Intel) ist.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,56 +62,10 @@ 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
|
||||||
|
|
||||||
Die Einstellungen werden immer unter `~/.praktikst/preferences.xml` gespeichert.
|
|
||||||
|
|
||||||
#### AppImage
|
|
||||||
|
|
||||||
1. AppImage herunterladen.
|
1. AppImage herunterladen.
|
||||||
2. Ausführbar machen: `chmod +x KST4Contest-v<Version>-linux-x86_64.AppImage`
|
2. AppImage in gewünschten Ordner entpacken.
|
||||||
3. Starten.
|
3. AppImage ausführbar machen (geht im Terminal mit `chmod +x praktiKST-v<Versionsnummer>-linux-x86_64.AppImage`)
|
||||||
|
4. AppImage ausführen.
|
||||||
#### Debian / Ubuntu
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder die `.deb`-Datei im Dateimanager doppelklicken.
|
|
||||||
|
|
||||||
#### Fedora / RHEL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install ./KST4Contest-v<Version>-fedora-x86_64.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Arch Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Flatpak
|
|
||||||
|
|
||||||
Die Datei `de.x08.KST4Contest.flatpakref` aus dem [aktuellen Release](https://github.com/praktimarc/kst4contest/releases/latest) herunterladen und öffnen, oder:
|
|
||||||
```bash
|
|
||||||
flatpak install de.x08.KST4Contest.flatpakref
|
|
||||||
```
|
|
||||||
|
|
||||||
Oder den Remote manuell hinzufügen:
|
|
||||||
```bash
|
|
||||||
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/
|
|
||||||
flatpak install kst4contest de.x08.KST4Contest
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
1. DMG-Datei für die eigene Architektur herunterladen (Apple Silicon oder Intel).
|
|
||||||
2. DMG-Datei öffnen.
|
|
||||||
3. `KST4Contest.app` in den **Programme**-Ordner ziehen.
|
|
||||||
4. Beim ersten Start zeigt macOS ggf. eine Warnung, da die App nicht notarisiert ist. Zum Öffnen:
|
|
||||||
- Rechtsklick (oder Ctrl-Klick) auf `KST4Contest.app` im Finder → **Öffnen** wählen.
|
|
||||||
- Alternativ: **Systemeinstellungen → Datenschutz & Sicherheit** → **Trotzdem öffnen** klicken.
|
|
||||||
5. KST4Contest aus dem Programme-Ordner oder dem Launchpad starten.
|
|
||||||
|
|
||||||
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
Die Einstellungen werden unter `~/.praktikst/preferences.xml` gespeichert.
|
||||||
|
|
||||||
@@ -157,17 +93,10 @@ Die Einstellungsdatei (`preferences.xml`) bleibt erhalten, da sie im Benutzerord
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
- **AppImage**: Neues AppImage herunterladen, ausführbar machen (`chmod +x`), altes optional löschen.
|
Derzeit folgendermaßen:
|
||||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<Version>-debian-amd64.deb`
|
1. neues AppImage herunterladen
|
||||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<Version>-fedora-x86_64.rpm`
|
2. neues AppImage ausführbar makieren
|
||||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<Version>-archlinux-x86_64.pkg.tar.zst`
|
3. (optional) altes AppImage löschen.
|
||||||
- **Flatpak (Repository)**: `flatpak update` – aktualisiert alle Flatpak-Apps einschließlich KST4Contest.
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
|
|
||||||
1. Neue DMG-Datei herunterladen.
|
|
||||||
2. DMG öffnen.
|
|
||||||
3. Die neue `KST4Contest.app` in den **Programme**-Ordner ziehen und die alte Version ersetzen.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -39,17 +39,6 @@ Maximale Entfernung (in km), für die Richtungs-Warnungen ausgelöst werden soll
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Server-Einstellungen (ab v1.31)
|
|
||||||
|
|
||||||
Der Chat-Server-DNS und -Port sind in den Preferences konfigurierbar:
|
|
||||||
|
|
||||||
- **Server-DNS**: Standard `www.on4kst.org` (ab v1.31 geändert von `www.on4kst.info`).
|
|
||||||
- **Port**: Standardport des ON4KST-Servers.
|
|
||||||
|
|
||||||
Eine Änderung ist nur notwendig, wenn der Server umzieht oder ein alternativer Endpunkt genutzt wird.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Log-Sync-Einstellungen
|
## Log-Sync-Einstellungen
|
||||||
|
|
||||||
Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu markieren. Details: [Log-Synchronisation](de-Log-Synchronisation).
|
Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu markieren. Details: [Log-Synchronisation](de-Log-Synchronisation).
|
||||||
@@ -133,55 +122,14 @@ Neuer Einstellungsbereich mit folgenden Optionen:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Win-Test-Netzwerk-Listener (ab v1.31)
|
|
||||||
|
|
||||||
Dedizierter Empfänger für Win-Test-spezifische UDP-Pakete. Ermöglicht:
|
|
||||||
|
|
||||||
- **Log-Synchronisation**: Gearbeitete Stationen werden aus Win-Test übernommen und in der Benutzerliste markiert.
|
|
||||||
- **Frequenz-Auswertung**: Die aktuelle TRX-Frequenz aus Win-Test befüllt die `MYQRG`-Variable.
|
|
||||||
- **Sked-Übergabe (SKED Push)**: Skeds aus KST4Contest werden via UDP direkt an Win-Test übergeben. Der UDP-Broadcast-Standardport von Win-Test (9871) wird verwendet.
|
|
||||||
|
|
||||||
Einstellungen:
|
|
||||||
- **Aktivieren/Deaktivieren**: Checkbox in den Preferences (ab v1.40).
|
|
||||||
- **Port**: Konfigurierbarer UDP-Port für den Win-Test-Listener.
|
|
||||||
- **Sked-UDP-Adresse und Port**: Zieladresse und Port für die SKED-Übergabe an Win-Test.
|
|
||||||
|
|
||||||
> **Hinweis**: Der Win-Test-Listener ist ein **zusätzlicher** Listener – der Standard-QSO-UDP-Broadcast-Listener auf Port 12060 bleibt davon unabhängig.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PSTRotator-Einstellungen (ab v1.31)
|
|
||||||
|
|
||||||
KST4Contest kann die Antennenrichtung über PSTRotator steuern.
|
|
||||||
|
|
||||||
Einstellungen:
|
|
||||||
- **Aktivieren/Deaktivieren**: Checkbox in den Preferences (ab v1.40).
|
|
||||||
- **IP-Adresse**: IP-Adresse des PSTRotator-Rechners (Standard: `127.0.0.1` bei Betrieb auf demselben PC).
|
|
||||||
- **Port**: Kommunikationsport von PSTRotator.
|
|
||||||
|
|
||||||
> **Hinweis**: Nach einem Klick auf den Richtungs-Button wartet KST4Contest kurz auf die Rotatorantwort. Bei langsamen Rotoren (z. B. SPID) kann es zu einer kleinen Verzögerung kommen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sniffer-Einstellungen (ab v1.31)
|
|
||||||
|
|
||||||
Der QSO-Sniffer filtert Chat-Nachrichten von konfigurierbaren Rufzeichen und leitet sie ins PM-Fenster weiter.
|
|
||||||
|
|
||||||
Einstellungen:
|
|
||||||
- **Rufzeichen-Liste**: Kommagetrennte Liste von Rufzeichen, deren Nachrichten immer in das PM-Fenster weitergeleitet werden sollen.
|
|
||||||
|
|
||||||
Anwendungsfall: Wichtige Stationen (z. B. DX-Peditionen oder feste Verbündete im Contest) im Auge behalten, ohne den Haupt-Chat ständig zu beobachten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked Station Database Settings (Gearbeitete-Stationen-Datenbank)
|
## Worked Station Database Settings (Gearbeitete-Stationen-Datenbank)
|
||||||
|
|
||||||
Die interne Worked-Datenbank enthält:
|
Vor jedem Contest die interne Worked-Datenbank zurücksetzen! Enthält:
|
||||||
|
|
||||||
- Worked-Status aller Stationen (pro Band)
|
- Worked-Status aller Stationen (pro Band)
|
||||||
- NOT-QRV-Tags (seit v1.2)
|
- NOT-QRV-Tags (seit v1.2)
|
||||||
|
|
||||||
**Ab v1.40**: Einträge haben eine automatische Lebensdauer von **3 Tagen** – ein manuelles Zurücksetzen vor jedem Contest ist nicht mehr zwingend notwendig. Für ein vollständiges Reset kann trotzdem die Schaltfläche **„Reinitialize"** verwendet werden.
|
Schaltfläche **„Reinitialize"** unter der Tabelle verwenden. Eine geplante Funktion ist eine automatische Ablaufzeit für den Worked-Status.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -83,26 +83,20 @@ Für den integrierten DX-Cluster-Server: N1MM+ als DX-Cluster-Client konfigurier
|
|||||||
|
|
||||||
### Win-Test
|
### Win-Test
|
||||||
|
|
||||||
Win-Test wird mit einem dedizierten UDP-Netzwerk-Listener unterstützt, der das native Win-Test Netzwerkprotokoll versteht.
|
Win-Test wird mit einem dedizierten UDP-Netzwerk-Listener unterstützt, der das native Win-Test Netzwerkprotokoll versteht.
|
||||||
|
|
||||||
**Vorteile der Win-Test Integration:**
|
**Vorteile der Win-Test Integration:**
|
||||||
- Automatische QSO-Synchronisation zur Markierung gearbeiteter Stationen.
|
- Automatische QSO-Synchronisation zur Markierung gearbeiteter Stationen.
|
||||||
- **Sked-Übergabe (ADDSKED):** Über den Button "Create sked" im Stationsinfo-Panel wird nicht nur in KST4Contest ein Sked angelegt, sondern dieser auch *direkt per UDP an das Win-Test Netzwerk als ADDSKED-Paket gesendet* – automatisch, sobald der Listener aktiv ist.
|
- **Sked-Übergabe (ADDSKED):** Über den Button "Create sked" im Stationsinfo-Panel wird nicht nur in KST4Contest ein Sked angelegt, sondern dieses auch *direkt per UDP an das Win-Test Netzwerk als ADDSKED-Paket gesendet*.
|
||||||
- Es kann zwischen den Sked-Modi "AUTO", "SSB" oder "CW" gewählt werden.
|
- Es kann zwischen den Sked-Modi "AUTO", "SSB" oder "CW" gewählt werden.
|
||||||
- **Automatische QRG-Auflösung für SKEDs:** KST4Contest wählt die Sked-Frequenz intelligent:
|
|
||||||
1. Hat die Gegenstation in einer Chat-Nachricht ihre QRG genannt, wird diese verwendet.
|
|
||||||
2. Sonst wird die eigene aktuelle QRG verwendet (aus Win-Test STATUS oder manueller Eingabe).
|
|
||||||
|
|
||||||
**Einstellungen im Reiter „Log-Synchronisation":**
|
**Notwendige Einstellungen in KST4Contest:**
|
||||||
- `Receive Win-Test network based UDP log messages` aktivieren.
|
|
||||||
- `UDP-Port for Win-Test listener` (Standard: 9871).
|
- `UDP-Port for Win-Test listener` (Standard: 9871).
|
||||||
|
- `Receive Win-Test network based UDP log messages` aktivieren.
|
||||||
|
- `Win-Test sked transmission (push via ADDSKED to Win-Test network)` aktivieren.
|
||||||
- `KST station name in Win-Test network (src of SKED packets)`: Legt fest, unter welchem Stationsnamen KST4Contest im WT-Netzwerk auftritt (z.B. "KST").
|
- `KST station name in Win-Test network (src of SKED packets)`: Legt fest, unter welchem Stationsnamen KST4Contest im WT-Netzwerk auftritt (z.B. "KST").
|
||||||
- `Win-Test network broadcast address`: Wird i.d.R. automatisch erkannt; erforderlich für das Senden von Sked-Paketen.
|
- `Win-Test station name filter`: Wenn hier ein Name eingetragen wird (z.B. "STN1"), werden nur QSOs von dieser bestimmten Win-Test Instanz verarbeitet. Leer lassen, um alle zu akzeptieren.
|
||||||
|
- `Win-Test network broadcast address`: Wird idR automatisch erkannt und ist erforderlich, um die Sked-Pakete ins Netzwerk zu senden.
|
||||||
**Einstellungen im Reiter „TRX-Synchronisation":**
|
|
||||||
- `Win-Test STATUS QRG Sync`: Wenn aktiviert, übernimmt KST4Contest die aktuelle Transceiverfrequenz aus dem Win-Test STATUS-Paket als eigene QRG (MYQRG).
|
|
||||||
- `Use pass frequency from Win-Test STATUS`: Statt der eigenen TRX-QRG wird die im STATUS-Paket enthaltene Pass-Frequenz als MYQRG verwendet (für Multi-Op-Setups, bei denen mit einer Pass-QRG gearbeitet wird).
|
|
||||||
- `Win-Test station name filter`: Wird hier ein Name eingetragen (z.B. "STN1"), verarbeitet KST4Contest nur Pakete dieser Win-Test-Instanz. Leer lassen, um alle zu akzeptieren.
|
|
||||||
|
|
||||||
**Einstellungen in Win-Test:**
|
**Einstellungen in Win-Test:**
|
||||||
- Das Netzwerk in Win-Test muss aktiv sein.
|
- Das Netzwerk in Win-Test muss aktiv sein.
|
||||||
@@ -118,11 +112,6 @@ Neben der QSO-Synchronisation übertragen UCXLog und andere Programme auch die *
|
|||||||
|
|
||||||
**Ergebnis**: Die eigene QRG muss im Chat nie mehr manuell eingegeben werden – ein Klick auf den MYQRG-Button oder die Verwendung der Variable im Beacon genügt.
|
**Ergebnis**: Die eigene QRG muss im Chat nie mehr manuell eingegeben werden – ein Klick auf den MYQRG-Button oder die Verwendung der Variable im Beacon genügt.
|
||||||
|
|
||||||
**Quellen für die eigene QRG (MYQRG):**
|
|
||||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP-Port 12060
|
|
||||||
- Win-Test STATUS-Paket (optional, konfigurierbar im Reiter „TRX-Synchronisation" unter „Win-Test STATUS QRG Sync")
|
|
||||||
- Manuelle Eingabe im QRG-Feld
|
|
||||||
|
|
||||||
> **Hinweis für Multi-Setup**: Bei zwei Logprogrammen an zwei Computern sollte nur **eines** die Frequenzpakete senden. KST4Contest kann nicht zwischen den Quellen unterscheiden und verarbeitet alle eingehenden Pakete.
|
> **Hinweis für Multi-Setup**: Bei zwei Logprogrammen an zwei Computern sollte nur **eines** die Frequenzpakete senden. KST4Contest kann nicht zwischen den Quellen unterscheiden und verarbeitet alle eingehenden Pakete.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -32,10 +32,8 @@ An account is required for this service. Please consider donating to Thomas –
|
|||||||
|
|
||||||
1. Start AirScout.
|
1. Start AirScout.
|
||||||
2. Enter your OV3T feed account details (username, password, URL) in the AirScout settings.
|
2. Enter your OV3T feed account details (username, password, URL) in the AirScout settings.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
3. Test the connection.
|
3. Test the connection.
|
||||||
|
|
||||||
### Step 2: Enable UDP Communication for KST4Contest
|
### Step 2: Enable UDP Communication for KST4Contest
|
||||||
|
|||||||
@@ -8,83 +8,6 @@ Version history of KST4Contest / PraktiKST.
|
|||||||
|
|
||||||
For the latest changelog, please refer to GitHub. The previous changelog is below.
|
For the latest changelog, please refer to GitHub. The previous changelog is below.
|
||||||
|
|
||||||
## v1.40 (2026-02-16)
|
|
||||||
**Major Feature Release: Score System, AP Timeline, Win-Test, PSTRotator**
|
|
||||||
|
|
||||||
**New:**
|
|
||||||
- **Chatmember Score System**: Every chat member is automatically scored based on antenna direction, activity time, message count, active bands, frequencies, sked direction (degrees), and other factors. Top candidates are highlighted in a dedicated list.
|
|
||||||
- **AP Timeline**: For each minute of possible aircraft arrival, up to 4 highly-scored stations are shown that should be workable. Aircraft with the highest potential are preferred over the fastest arrival. Chat members whose antenna is not pointing towards you are shown transparently.
|
|
||||||
- **Win-Test Support** (Beta since v1.31, now fully configurable): Log synchronisation, frequency parsing and **sked handover via UDP** fully integrated. Can be enabled/disabled in Preferences.
|
|
||||||
- **PSTRotator Interface** (Beta since v1.31, now fully configurable): Rotator position updates directly from KST4Contest. Can be enabled/disabled in Preferences.
|
|
||||||
- **QSO Sniffer**: Messages from configurable callsign lists are automatically forwarded to the PM window.
|
|
||||||
- **Band Alert for logged stations**: When a station is logged, a hint appears if that station has another active band that you are also QRV on.
|
|
||||||
- **Sked Reminder ALERT**: A sked alarm with automatic messages in configurable intervals (2+1 / 5+2+1 / 10+5+2+1 minutes before the sked) can be set up for each chat member, plus acoustic and visual notification.
|
|
||||||
- **Load chat history on startup**: Chat server history is loaded on connect to immediately see active members and recent messages.
|
|
||||||
- **Skedfail button**: In the FurtherInfo panel, a sked failure can be marked for a chat member, which lowers their priority score.
|
|
||||||
|
|
||||||
**Changed:**
|
|
||||||
- AP notes added to internal DX cluster spots.
|
|
||||||
- Chat member table scrolling follows the current message selection automatically.
|
|
||||||
- Generic auto-reply and QRG auto-reply now fire a maximum of once every 45 seconds per callsign (prevents spam and message ping-pong).
|
|
||||||
- New saveable settings: ServerDNS/Port, PSTRotator interface, Win-Test interface, callsign sniffer, Dark Mode on by default.
|
|
||||||
- Date column removed from chat table (time only – saves space).
|
|
||||||
|
|
||||||
**Fixed:**
|
|
||||||
- User list now automatically sorted on every new member sign-on.
|
|
||||||
- Posonpill messages now terminate exactly one client instance (no longer affects all instances or wtKST).
|
|
||||||
- wtKST: crash on KST4Contest disconnection fixed.
|
|
||||||
- Multiple issues with callsign suffixes like `/p`, `-2`, etc. fixed throughout.
|
|
||||||
- `QTFDefault` was not saved correctly → fixed.
|
|
||||||
- AirScout watchlist (ASWATCHLIST) was not being updated → fixed.
|
|
||||||
- Dark Mode: QRG fields not displayed at full size → fixed.
|
|
||||||
- Version number display corrected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.31 (2025-12-13)
|
|
||||||
**Win-Test + PSTRotator Beta, QSO Sniffer, DNS Hotfix**
|
|
||||||
|
|
||||||
**New:**
|
|
||||||
- **Win-Test support** (Beta, not yet deactivatable): Log synchronisation and frequency parsing.
|
|
||||||
- **PSTRotator support** (Beta, not yet deactivatable).
|
|
||||||
- **QSO Sniffer**: Messages from configurable callsigns are forwarded to the PM window.
|
|
||||||
|
|
||||||
**Changed:**
|
|
||||||
- **DNS server changed**: From `www.on4kst.info` to `www.on4kst.org` (hotfix). The DNS server is now configurable in Preferences.
|
|
||||||
|
|
||||||
**Fixed:**
|
|
||||||
- Endless loop in error case freezes the client → fixed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.266 (2025-10-03)
|
|
||||||
**AirScout Fix for Callsigns with Suffix**
|
|
||||||
|
|
||||||
**Fixed:**
|
|
||||||
- AirScout interface did not work when the login callsign contained a suffix (e.g. `9A1W-2`). AirScout cannot handle this format – only the base callsign without suffix is now passed to AirScout.
|
|
||||||
|
|
||||||
*(Bug reported and tested by 9A2HM / Kreso – many thanks!)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.265 (2025-09-28)
|
|
||||||
**Direction Buttons Stay Coloured When Active**
|
|
||||||
|
|
||||||
**Fixed:**
|
|
||||||
- Direction buttons (N / NE / E etc.) now keep their highlight colour when activated, making the active state immediately visible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.264 (2025-08-02)
|
|
||||||
**Simplelogfile: Improved Callsign Recognition**
|
|
||||||
|
|
||||||
**Fixed:**
|
|
||||||
- Callsigns like `S53CC`, `S51A`, etc. were not being marked as worked in the SimpleLogFile interpreter → recognition pattern improved.
|
|
||||||
|
|
||||||
*(Bug reported by Boris, S53CC – thank you!)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.263 (2025-06-08)
|
## v1.263 (2025-06-08)
|
||||||
**AirScout Communication and Login Name**
|
**AirScout Communication and Login Name**
|
||||||
|
|
||||||
@@ -230,6 +153,6 @@ First publicly released version. Core features:
|
|||||||
## Planned Features
|
## Planned Features
|
||||||
|
|
||||||
- `MYQTF` variable (own antenna direction as text)
|
- `MYQTF` variable (own antenna direction as text)
|
||||||
- ~~Lifetime for worked status (automatic reset)~~ ✅ **Implemented in v1.40** (3-day lifetime, no manual reset needed anymore)
|
- Lifetime for worked status (automatic reset)
|
||||||
- Filtering the "Cluster & QSO of others" window to own QTF
|
- Filtering the "Cluster & QSO of others" window to own QTF
|
||||||
- Further topography-based calculations for direction warnings
|
- Further topography-based calculations for direction warnings
|
||||||
|
|||||||
@@ -39,20 +39,9 @@ Maximum distance (in km) for which direction warnings should be triggered. A rea
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Server Settings (from v1.31)
|
|
||||||
|
|
||||||
The chat server DNS and port are configurable in the Preferences:
|
|
||||||
|
|
||||||
- **Server DNS**: Default `www.on4kst.org` (changed from `www.on4kst.info` in v1.31 hotfix).
|
|
||||||
- **Port**: Default port of the ON4KST server.
|
|
||||||
|
|
||||||
A change is only needed if the server moves or an alternative endpoint is used.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Log Sync Settings
|
## Log Sync Settings
|
||||||
|
|
||||||
Three methods are available for automatically marking worked stations. Details: [Log Synchronisation](en-Log-Sync).
|
Two methods are available for automatically marking worked stations. Details: [Log Synchronisation](en-Log-Sync).
|
||||||
|
|
||||||
### Universal File Based Callsign Interpreter (Simplelogfile)
|
### Universal File Based Callsign Interpreter (Simplelogfile)
|
||||||
|
|
||||||
@@ -133,55 +122,14 @@ New settings section with the following options:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Win-Test Network Listener (from v1.31)
|
|
||||||
|
|
||||||
A dedicated listener for Win-Test-specific UDP packets. Enables:
|
|
||||||
|
|
||||||
- **Log synchronisation**: Worked stations are retrieved from Win-Test and marked in the user list.
|
|
||||||
- **Frequency parsing**: The current TRX frequency from Win-Test populates the `MYQRG` variable.
|
|
||||||
- **Sked handover (SKED push)**: Skeds from KST4Contest are passed directly to Win-Test via UDP. Win-Test's default UDP broadcast port (9871) is used.
|
|
||||||
|
|
||||||
Settings:
|
|
||||||
- **Enable/Disable**: Checkbox in Preferences (from v1.40).
|
|
||||||
- **Port**: Configurable UDP port for the Win-Test listener.
|
|
||||||
- **Sked UDP address and port**: Target address and port for SKED handover to Win-Test.
|
|
||||||
|
|
||||||
> **Note**: The Win-Test listener is an **additional** listener – the standard QSO UDP broadcast listener on port 12060 remains independent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PSTRotator Settings (from v1.31)
|
|
||||||
|
|
||||||
KST4Contest can control antenna direction via PSTRotator.
|
|
||||||
|
|
||||||
Settings:
|
|
||||||
- **Enable/Disable**: Checkbox in Preferences (from v1.40).
|
|
||||||
- **IP address**: IP address of the PSTRotator computer (default: `127.0.0.1` when running on the same PC).
|
|
||||||
- **Port**: Communication port of PSTRotator.
|
|
||||||
|
|
||||||
> **Note**: After clicking a direction button, KST4Contest waits briefly for the rotator response. With slow rotors (e.g. SPID) there may be a small delay.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sniffer Settings (from v1.31)
|
|
||||||
|
|
||||||
The QSO sniffer filters chat messages from configurable callsigns and forwards them to the PM window.
|
|
||||||
|
|
||||||
Settings:
|
|
||||||
- **Callsign list**: Comma-separated list of callsigns whose messages are always forwarded to the PM window.
|
|
||||||
|
|
||||||
Use case: Keep track of important stations (e.g. DX expeditions or trusted contest allies) without constantly monitoring the main chat.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked Station Database Settings
|
## Worked Station Database Settings
|
||||||
|
|
||||||
The internal worked database contains:
|
Reset the internal worked database before each contest! It contains:
|
||||||
|
|
||||||
- Worked status of all stations (per band)
|
- Worked status of all stations (per band)
|
||||||
- NOT-QRV tags (since v1.2)
|
- NOT-QRV tags (since v1.2)
|
||||||
|
|
||||||
**From v1.40**: Entries have an automatic lifetime of **3 days** – manually resetting before each contest is no longer strictly necessary. For a full reset, the **"Reinitialize"** button is still available.
|
Use the **"Reinitialize"** button below the table. A planned feature is an automatic expiration time for the worked status.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -135,86 +135,22 @@ For selected stations in the user list, there are direct buttons to open the **Q
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sked Reminders with ALERT (from v1.40)
|
## Sked Reminders (Sked Reminder Service)
|
||||||
|
|
||||||
A sked reminder service with automatic messages can be activated for each chat member. Configurable interval patterns:
|
For agreed skeds, automatic reminder PMs can be configured, sent X minutes before the agreed time. Reminders are activated from the FurtherInfo panel.
|
||||||
|
|
||||||
- **2+1 minutes**: Messages at 2 min and 1 min before the sked.
|
|
||||||
- **5+2+1 minutes**: Messages at 5, 2 and 1 min before the sked.
|
|
||||||
- **10+5+2+1 minutes**: Messages at 10, 5, 2 and 1 min before the sked.
|
|
||||||
|
|
||||||
In addition to the automated messages to the remote station, there is an **acoustic and visual notification** for your own operator so no sked is ever missed.
|
|
||||||
|
|
||||||
Activate from the FurtherInfo panel of the corresponding station.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## QSO Sniffer (from v1.31)
|
## Priority List / Score Service
|
||||||
|
|
||||||
The QSO sniffer monitors the chat for messages from a configurable callsign list and automatically forwards them to the **PM window**. This prevents relevant messages from being lost in the general chat traffic.
|
KST4Contest automatically calculates a **priority list** of the most interesting contacts, based on:
|
||||||
|
|
||||||
Configuration: [Configuration – Sniffer Settings](en-Configuration#sniffer-settings-from-v131)
|
- Direction detection
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Win-Test Integration (from v1.31, fully configurable from v1.40)
|
|
||||||
|
|
||||||
KST4Contest fully supports [Win-Test](https://www.win-test.com/) as a logging programme:
|
|
||||||
|
|
||||||
- **Log synchronisation**: Worked stations are automatically retrieved from Win-Test and marked in the user list.
|
|
||||||
- **Frequency parsing**: The current TRX frequency is read from Win-Test UDP packets and populates the `MYQRG` variable.
|
|
||||||
- **Sked handover (SKED push via UDP)**: Agreed skeds from KST4Contest can be pushed directly to Win-Test, so the remote callsign appears in Win-Test's sked window.
|
|
||||||
|
|
||||||
Details: [Configuration – Win-Test Network Listener](en-Configuration#win-test-network-listener)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## PSTRotator Interface (from v1.31, fully configurable from v1.40)
|
|
||||||
|
|
||||||
KST4Contest can control antenna direction directly via **PSTRotator**. When a station is selected in the user list, the rotator can automatically be turned to the QTF of the selected station.
|
|
||||||
|
|
||||||
Configuration: [Configuration – PSTRotator Settings](en-Configuration#pstrotator-settings-from-v131)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Band Alert for New QSOs (from v1.40)
|
|
||||||
|
|
||||||
When a station is logged, KST4Contest automatically checks whether that station has shown any other active bands in the chat that you are also QRV on. If so, a **hint alert** appears so no multi-band opportunity is missed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Worked Tag Lifetime (from v1.40)
|
|
||||||
|
|
||||||
Worked stations are automatically removed from the database after **3 days**. Manually resetting the worked database before each contest is therefore no longer strictly necessary – the database keeps itself up to date.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chatmember Score System / Priority List (from v1.40)
|
|
||||||
|
|
||||||
KST4Contest automatically calculates a **priority score** for each active chat member. The score is derived from:
|
|
||||||
|
|
||||||
- Antenna direction of the remote station (is it pointing towards me?)
|
|
||||||
- QRB (distance)
|
- QRB (distance)
|
||||||
- Activity time and message count
|
|
||||||
- Active bands and frequencies
|
|
||||||
- AP availability (AirScout)
|
- AP availability (AirScout)
|
||||||
- Sked direction (degrees)
|
- Worked status
|
||||||
- Sked success rate and skedfail markings
|
|
||||||
|
|
||||||
The top candidates are highlighted in a dedicated priority list, helping you not to miss the most important contacts during contest stress.
|
The top candidates are shown in a separate list, helping you not to miss the most important stations during contest stress.
|
||||||
|
|
||||||
Stations with a failed sked can be marked using the **Skedfail button** in the FurtherInfo panel – this temporarily lowers their score.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## AP Timeline (from v1.40)
|
|
||||||
|
|
||||||
A visual timeline shows up to 4 highly-scored stations per minute slot that should be workable via aircraft scatter. Prioritisation criteria:
|
|
||||||
|
|
||||||
- **Highest reflection potential** is preferred (not necessarily the fastest arrival).
|
|
||||||
- Stations towards which your antenna is not pointing are shown **transparently**.
|
|
||||||
|
|
||||||
This gives the contest operator a quick overview of which stations will be reachable via which aircraft and at what time.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,6 @@ The ON4KST Chat is the de-facto standard for skeds on the 144 MHz and higher ban
|
|||||||
- **GitHub**: https://github.com/praktimarc/kst4contest
|
- **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,29 +42,11 @@ The filename has the format `praktiKST-v<version_number>-windows-x64.zip`.
|
|||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Multiple package formats are available from the releases page:
|
The latest version can be downloaded as an AppImage:
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
**https://github.com/praktimarc/kst4contest/releases/latest**
|
||||||
|
|
||||||
| Format | Filename | Suitable for |
|
The filename has the format `praktiKST-v<version_number>-linux-x86_64.AppImage`.
|
||||||
|---|---|---|
|
|
||||||
| AppImage | `KST4Contest-v<version>-linux-x86_64.AppImage` | All distributions |
|
|
||||||
| Debian package | `KST4Contest-v<version>-debian-amd64.deb` | Debian, Ubuntu, Linux Mint, … |
|
|
||||||
| RPM package | `KST4Contest-v<version>-fedora-x86_64.rpm` | Fedora, RHEL, openSUSE, … |
|
|
||||||
| Arch package | `KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst` | Arch Linux, Manjaro, … |
|
|
||||||
| Flatpak | `de.x08.KST4Contest.flatpakref` | All distributions with Flatpak |
|
|
||||||
|
|
||||||
> **Recommended for Linux:** The Flatpak installation is the easiest way to stay up to date — `flatpak update` handles all future updates automatically. The repository is GPG-signed for security.
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
|
|
||||||
> ⚠️ **Best-Effort Support:** macOS builds are provided as a convenience but are **not fully tested**. We build and release macOS binaries with every release, but we cannot test every scenario on macOS. If you encounter issues, please report them – we will do our best to address them, but cannot guarantee the same level of support as for Windows and Linux.
|
|
||||||
|
|
||||||
The latest version can be downloaded as a DMG disk image (available for both Apple Silicon and Intel Macs):
|
|
||||||
|
|
||||||
**https://github.com/praktimarc/kst4contest/releases/latest**
|
|
||||||
|
|
||||||
The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, where `<arch>` is `arm64` (Apple Silicon) or `x86_64` (Intel).
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,56 +62,10 @@ The filename has the format `KST4Contest-v<version_number>-macos-<arch>.dmg`, wh
|
|||||||
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
Settings are stored at `%USERPROFILE%\.praktikst\preferences.xml`.
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
Settings are always stored at `~/.praktikst/preferences.xml`.
|
|
||||||
|
|
||||||
#### AppImage
|
|
||||||
|
|
||||||
1. Download the AppImage.
|
1. Download the AppImage.
|
||||||
2. Make it executable: `chmod +x KST4Contest-v<version>-linux-x86_64.AppImage`
|
2. Unzip the AppImage into a folder of your choice.
|
||||||
3. Run it.
|
3. Make the AppImage executable (in the terminal with `chmod +x praktiKST-v<version_number>-linux-x86_64.AppImage`)
|
||||||
|
4. Run the AppImage.
|
||||||
#### Debian / Ubuntu
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install ./KST4Contest-v<version>-debian-amd64.deb
|
|
||||||
```
|
|
||||||
|
|
||||||
Or double-click the `.deb` file in your file manager.
|
|
||||||
|
|
||||||
#### Fedora / RHEL
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install ./KST4Contest-v<version>-fedora-x86_64.rpm
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Arch Linux
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Flatpak
|
|
||||||
|
|
||||||
Download `de.x08.KST4Contest.flatpakref` from the [latest release](https://github.com/praktimarc/kst4contest/releases/latest) and open it, or run:
|
|
||||||
```bash
|
|
||||||
flatpak install de.x08.KST4Contest.flatpakref
|
|
||||||
```
|
|
||||||
|
|
||||||
Or add the remote manually:
|
|
||||||
```bash
|
|
||||||
flatpak remote-add kst4contest https://praktimarc.github.io/kst4contest/
|
|
||||||
flatpak install kst4contest de.x08.KST4Contest
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
1. Download the DMG file for your architecture (Apple Silicon or Intel).
|
|
||||||
2. Open the DMG file.
|
|
||||||
3. Drag `KST4Contest.app` into your **Applications** folder.
|
|
||||||
4. On first launch, macOS may show a warning because the app is not notarised. To open it:
|
|
||||||
- Right-click (or Control-click) on `KST4Contest.app` in Finder and choose **Open**.
|
|
||||||
- Alternatively, go to **System Settings → Privacy & Security** and click **Open Anyway**.
|
|
||||||
5. Run KST4Contest from your Applications folder or Launchpad.
|
|
||||||
|
|
||||||
Settings are stored at `~/.praktikst/preferences.xml`.
|
Settings are stored at `~/.praktikst/preferences.xml`.
|
||||||
|
|
||||||
@@ -157,17 +93,10 @@ The settings file (`preferences.xml`) is preserved because it is stored in the u
|
|||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
- **AppImage**: Download the new AppImage, make it executable (`chmod +x`), optionally delete the old one.
|
Currently as follows:
|
||||||
- **Debian/Ubuntu**: `sudo apt install ./KST4Contest-v<version>-debian-amd64.deb`
|
1. Download the new AppImage
|
||||||
- **Fedora/RHEL**: `sudo dnf upgrade ./KST4Contest-v<version>-fedora-x86_64.rpm`
|
2. Mark the new AppImage as executable
|
||||||
- **Arch Linux**: `sudo pacman -U KST4Contest-v<version>-archlinux-x86_64.pkg.tar.zst`
|
3. (optional) Delete the old AppImage.
|
||||||
- **Flatpak (repository)**: `flatpak update` – updates all Flatpak apps including KST4Contest.
|
|
||||||
|
|
||||||
#### macOS
|
|
||||||
|
|
||||||
1. Download the new DMG file.
|
|
||||||
2. Open the DMG.
|
|
||||||
3. Drag the new `KST4Contest.app` into your **Applications** folder, replacing the old version.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -87,22 +87,16 @@ Win-Test is supported with a dedicated UDP network listener that understands the
|
|||||||
|
|
||||||
**Advantages of Win-Test Integration:**
|
**Advantages of Win-Test Integration:**
|
||||||
- Automatic QSO synchronization to mark worked stations.
|
- Automatic QSO synchronization to mark worked stations.
|
||||||
- **Sked Handover (ADDSKED):** Using the "Create sked" button in the station info panel not only creates a sked in KST4Contest but also *sends it directly via UDP to the Win-Test network as an ADDSKED packet* – automatically, as soon as the listener is active. No separate toggle is needed.
|
- **Sked Handover (ADDSKED):** Using the "Create sked" button in the station info panel not only creates a sked in KST4Contest but also *sends it directly via UDP to the Win-Test network as an ADDSKED packet*.
|
||||||
- You can choose between "AUTO", "SSB", or "CW" sked modes.
|
- You can choose between "AUTO", "SSB", or "CW" sked modes.
|
||||||
- **Automatic QRG resolution for SKEDs:** KST4Contest selects the sked frequency intelligently:
|
|
||||||
1. If the other station mentioned their QRG in a recent chat message, that frequency is used.
|
|
||||||
2. Otherwise, your own current QRG is used (from Win-Test STATUS or manual entry).
|
|
||||||
|
|
||||||
**Settings in the "Log Synchronisation" tab:**
|
**Required Settings in KST4Contest:**
|
||||||
|
- `UDP-Port for Win-Test listener` (Default: 9871).
|
||||||
- Enable `Receive Win-Test network based UDP log messages`.
|
- Enable `Receive Win-Test network based UDP log messages`.
|
||||||
- `UDP-Port for Win-Test listener` (default: 9871).
|
- Enable `Win-Test sked transmission (push via ADDSKED to Win-Test network)`.
|
||||||
- `KST station name in Win-Test network (src of SKED packets)`: Defines the station name KST4Contest uses in the WT network (e.g. "KST").
|
- `KST station name in Win-Test network (src of SKED packets)`: Defines the station name KST4Contest uses in the WT network (e.g., "KST").
|
||||||
- `Win-Test network broadcast address`: Usually detected automatically; required to send sked packets to the network.
|
- `Win-Test station name filter`: If a name is entered here (e.g., "STN1"), only QSOs from that specific Win-Test instance will be processed. Leave empty to accept all.
|
||||||
|
- `Win-Test network broadcast address`: Is usually detected automatically and is required to send sked packets to the network.
|
||||||
**Settings in the "TRX Synchronisation" tab:**
|
|
||||||
- `Win-Test STATUS QRG Sync`: When enabled, KST4Contest takes the current transceiver frequency from the Win-Test STATUS packet and uses it as your own QRG (MYQRG).
|
|
||||||
- `Use pass frequency from Win-Test STATUS`: Instead of the main TRX frequency, the pass frequency contained in the STATUS packet is used as MYQRG (useful for multi-op setups that operate with a dedicated pass QRG).
|
|
||||||
- `Win-Test station name filter`: If a name is entered here (e.g. "STN1"), KST4Contest only processes packets from that specific Win-Test instance. Leave empty to accept all.
|
|
||||||
|
|
||||||
**Settings in Win-Test:**
|
**Settings in Win-Test:**
|
||||||
- The network in Win-Test must be active.
|
- The network in Win-Test must be active.
|
||||||
@@ -118,11 +112,6 @@ In addition to QSO synchronisation, UCXLog and other programs also transmit the
|
|||||||
|
|
||||||
**Result**: Your own QRG never needs to be typed manually in the chat – clicking the MYQRG button or using the variable in the beacon is sufficient.
|
**Result**: Your own QRG never needs to be typed manually in the chat – clicking the MYQRG button or using the variable in the beacon is sufficient.
|
||||||
|
|
||||||
**Sources for your own QRG (MYQRG):**
|
|
||||||
- UCXLog, N1MM+, DXLog.net, QARTest via UDP port 12060
|
|
||||||
- Win-Test STATUS packet (optional, configurable in the "TRX Synchronisation" tab under "Win-Test STATUS QRG Sync")
|
|
||||||
- Manual entry in the QRG field
|
|
||||||
|
|
||||||
> **Note for multi-setup**: With two logging programs on two computers, only **one** should send frequency packets. KST4Contest cannot distinguish between sources and processes all incoming packets.
|
> **Note for multi-setup**: With two logging programs on two computers, only **one** should send frequency packets. KST4Contest cannot distinguish between sources and processes all incoming packets.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,528 +0,0 @@
|
|||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import random
|
|
||||||
import traceback
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
# =====================================
|
|
||||||
# KST-Server-Simulator / DO5AMF
|
|
||||||
# Usage: change configuration below and
|
|
||||||
# run. Enter 127.0.0.1 : 23001 as a
|
|
||||||
# target in KST4Contest or another
|
|
||||||
# KST chat client.
|
|
||||||
# =====================================
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# KONFIGURATION
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
PORT = 23001
|
|
||||||
HOST = '127.0.0.1'
|
|
||||||
|
|
||||||
MSG_TO_USER_INTERVAL = 300.0
|
|
||||||
LOGIN_LOGOUT_INTERVAL = 60.0
|
|
||||||
KEEP_ALIVE_INTERVAL = 10.0
|
|
||||||
CLIENT_WARMUP_TIME = 5.0
|
|
||||||
|
|
||||||
PROB_INACTIVE = 0.10
|
|
||||||
PROB_REACTIVE = 0.20
|
|
||||||
|
|
||||||
# QSY Wahrscheinlichkeit (Wie oft wechselt ein User seine Frequenz?)
|
|
||||||
# 0.05 = 5% Chance pro Nachricht, dass er die Frequenz ändert. Sonst bleibt er stabil.
|
|
||||||
PROB_QSY = 0.05
|
|
||||||
|
|
||||||
BANDS_VHF = { "2m": (144.150, 144.400), "70cm": (432.100, 432.300) }
|
|
||||||
BANDS_UHF = { "23cm": (1296.100, 1296.300), "3cm": (10368.100, 10368.250) }
|
|
||||||
|
|
||||||
CHANNELS_SETUP = {
|
|
||||||
"2": {
|
|
||||||
"NAME": "144/432 MHz",
|
|
||||||
"NUM_USERS": 777,
|
|
||||||
"BANDS": BANDS_VHF,
|
|
||||||
"RATES": {"PUBLIC": 0.5, "DIRECTED": 3.0},
|
|
||||||
"PERMANENT": [
|
|
||||||
{"call": "DK5EW", "name": "Erwin", "loc": "JN47NX"},
|
|
||||||
{"call": "DL1TEST", "name": "TestOp", "loc": "JO50XX"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"NAME": "Microwave",
|
|
||||||
"NUM_USERS": 333,
|
|
||||||
"BANDS": BANDS_UHF,
|
|
||||||
"RATES": {"PUBLIC": 0.2, "DIRECTED": 0.5},
|
|
||||||
"PERMANENT": [
|
|
||||||
{"call": "ON4KST", "name": "Alain", "loc": "JO20HI"},
|
|
||||||
{"call": "G4CBW", "name": "MwTest", "loc": "IO83AA"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
COUNTRY_MAPPING = {
|
|
||||||
"DL": ["JO", "JN"], "DA": ["JO", "JN"], "DF": ["JO", "JN"], "DJ": ["JO", "JN"], "DK": ["JO", "JN"], "DO": ["JO", "JN"],
|
|
||||||
"F": ["JN", "IN", "JO"], "G": ["IO", "JO"], "M": ["IO", "JO"], "2E": ["IO", "JO"],
|
|
||||||
"PA": ["JO"], "ON": ["JO"], "OZ": ["JO"], "SM": ["JO", "JP"], "LA": ["JO", "JP"],
|
|
||||||
"OH": ["KP"], "SP": ["JO", "KO"], "OK": ["JO", "JN"], "OM": ["JN", "KN"],
|
|
||||||
"HA": ["JN", "KN"], "S5": ["JN"], "9A": ["JN"], "HB9": ["JN"], "OE": ["JN"],
|
|
||||||
"I": ["JN", "JM"], "IK": ["JN", "JM"], "IU": ["JN", "JM"], "EA": ["IN", "IM"],
|
|
||||||
"CT": ["IM"], "EI": ["IO"], "GM": ["IO"], "GW": ["IO"], "YO": ["KN"],
|
|
||||||
"YU": ["KN"], "LZ": ["KN"], "SV": ["KM", "KN"], "UR": ["KO", "KN"],
|
|
||||||
"LY": ["KO"], "YL": ["KO"], "ES": ["KO"]
|
|
||||||
}
|
|
||||||
|
|
||||||
NAMES = ["Hans", "Peter", "Jo", "Alain", "Mike", "Sven", "Ole", "Jean", "Bob", "Tom", "Giovanni", "Mario", "Frank", "Steve", "Dave"]
|
|
||||||
|
|
||||||
MSG_TEMPLATES_WITH_FREQ = [
|
|
||||||
"QSY {freq}", "PSE QSY {freq}", "Calling CQ on {freq}", "I am QRV on {freq}",
|
|
||||||
"Listening on {freq}", "Can you try {freq}?", "Signals strong on {freq}",
|
|
||||||
"Scattering on {freq}", "Please go to {freq}", "Running test on {freq}",
|
|
||||||
"Any takers for {freq}?", "Back to {freq}", "QRG {freq}?", "Aircraft scatter {freq}"
|
|
||||||
]
|
|
||||||
|
|
||||||
MSG_TEMPLATES_TEXT_ONLY = [
|
|
||||||
"TNX for QSO", "73 all", "Anyone for sked?", "Good conditions",
|
|
||||||
"Nothing heard", "Rain scatter?", "Waiting for moonrise", "CQ Contest",
|
|
||||||
"QRZ?", "My locator is {loc}", "Band is open"
|
|
||||||
]
|
|
||||||
|
|
||||||
REPLY_TEMPLATES = [
|
|
||||||
"Hello {user}, 599 here", "Rgr {user}, tnx for report", "Yes {user}, QSY?",
|
|
||||||
"Sorry {user}, no copy", "Pse wait 5 min {user}", "Ok {user}, 73",
|
|
||||||
"Locator is {loc}", "Go to {freq} please", "Rgr {user}, gl"
|
|
||||||
]
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# CLIENT WRAPPER
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
class ConnectedClient:
|
|
||||||
def __init__(self, sock, addr):
|
|
||||||
self.sock = sock
|
|
||||||
self.addr = addr
|
|
||||||
self.call = f"GUEST_{random.randint(1000,9999)}"
|
|
||||||
self.channels = {"2"}
|
|
||||||
self.login_time = time.time()
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
|
|
||||||
def send_safe(self, data_str):
|
|
||||||
if not data_str: return True
|
|
||||||
with self.lock:
|
|
||||||
try:
|
|
||||||
self.sock.sendall(data_str.encode('latin-1', errors='replace'))
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
try: self.sock.close()
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# LOGIK KLASSEN
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
class MessageFactory:
|
|
||||||
@staticmethod
|
|
||||||
def get_stable_frequency(user, band_name, min_f, max_f):
|
|
||||||
"""Liefert eine stabile Frequenz für diesen User auf diesem Band"""
|
|
||||||
# Wenn noch keine Frequenz da ist ODER Zufall zuschlägt (QSY)
|
|
||||||
if band_name not in user['freqs'] or random.random() < PROB_QSY:
|
|
||||||
freq_val = round(random.uniform(min_f, max_f), 3)
|
|
||||||
user['freqs'][band_name] = f"{freq_val:.3f}"
|
|
||||||
|
|
||||||
return user['freqs'][band_name]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_chat_message(bands_config, user):
|
|
||||||
try:
|
|
||||||
# Entscheidung: Text mit Frequenz oder ohne?
|
|
||||||
if random.random() < 0.7:
|
|
||||||
# Wähle zufälliges Band aus den verfügbaren
|
|
||||||
band_name = random.choice(list(bands_config.keys()))
|
|
||||||
min_f, max_f = bands_config[band_name]
|
|
||||||
|
|
||||||
# Hole STABILE Frequenz für diesen User
|
|
||||||
freq_str = MessageFactory.get_stable_frequency(user, band_name, min_f, max_f)
|
|
||||||
|
|
||||||
return random.choice(MSG_TEMPLATES_WITH_FREQ).format(freq=freq_str)
|
|
||||||
else:
|
|
||||||
return random.choice(MSG_TEMPLATES_TEXT_ONLY).format(loc=user['loc'])
|
|
||||||
except: return "TNX 73"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_reply_msg(bands, target_call, my_loc):
|
|
||||||
try:
|
|
||||||
tmpl = random.choice(REPLY_TEMPLATES)
|
|
||||||
freq_str = "QSY?"
|
|
||||||
# Bei Replies simulieren wir oft nur "QSY?" ohne konkrete Frequenz,
|
|
||||||
# oder nutzen eine zufällige, da der Kontext fehlt.
|
|
||||||
if "{freq}" in tmpl and bands:
|
|
||||||
band_name = random.choice(list(bands.keys()))
|
|
||||||
min_f, max_f = bands[band_name]
|
|
||||||
freq_str = f"{round(random.uniform(min_f, max_f), 3):.3f}"
|
|
||||||
return tmpl.format(user=target_call, loc=my_loc, freq=freq_str)
|
|
||||||
except: return "TNX 73"
|
|
||||||
|
|
||||||
class UserFactory:
|
|
||||||
registry = {}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_or_create_user(cls, channel_id, current_channel_users):
|
|
||||||
# 1. Reuse existing
|
|
||||||
candidates = [u for call, u in cls.registry.items() if call not in current_channel_users]
|
|
||||||
if candidates and random.random() < 0.5:
|
|
||||||
return random.choice(candidates)
|
|
||||||
|
|
||||||
# 2. Create new
|
|
||||||
return cls._create_new_unique_user(channel_id, current_channel_users)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _create_new_unique_user(cls, channel_id, current_channel_users):
|
|
||||||
while True:
|
|
||||||
prefix = random.choice(list(COUNTRY_MAPPING.keys()))
|
|
||||||
num = random.randint(0, 9)
|
|
||||||
suffix = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=random.randint(1,3)))
|
|
||||||
call = f"{prefix}{num}{suffix}"
|
|
||||||
|
|
||||||
if call in current_channel_users: continue
|
|
||||||
if call in cls.registry: return cls.registry[call]
|
|
||||||
|
|
||||||
valid_grids = COUNTRY_MAPPING[prefix]
|
|
||||||
grid_prefix = random.choice(valid_grids)
|
|
||||||
sq_num = f"{random.randint(0,99):02d}"
|
|
||||||
sub = "".join(random.choices("ABCDEFGHIJKLMNOPQRSTUVWXYZ", k=2))
|
|
||||||
loc = f"{grid_prefix}{sq_num}{sub}"
|
|
||||||
|
|
||||||
name = random.choice(NAMES)
|
|
||||||
rand = random.random()
|
|
||||||
if rand < PROB_INACTIVE: role = "INACTIVE"
|
|
||||||
elif rand < (PROB_INACTIVE + PROB_REACTIVE): role = "REACTIVE"
|
|
||||||
else: role = "ACTIVE"
|
|
||||||
|
|
||||||
# Neu V31: Frequenz-Gedächtnis
|
|
||||||
user_data = {
|
|
||||||
"call": call,
|
|
||||||
"name": name,
|
|
||||||
"loc": loc,
|
|
||||||
"role": role,
|
|
||||||
"freqs": {} # Speicher für { '2m': '144.300' }
|
|
||||||
}
|
|
||||||
|
|
||||||
cls.registry[call] = user_data
|
|
||||||
return user_data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def register_permanent(cls, user_data):
|
|
||||||
# Sicherstellen, dass auch Permanent User Freq-Memory haben
|
|
||||||
if "freqs" not in user_data:
|
|
||||||
user_data["freqs"] = {}
|
|
||||||
cls.registry[user_data['call']] = user_data
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# CHANNEL INSTANCE
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
class ChannelInstance:
|
|
||||||
def __init__(self, cid, config, server):
|
|
||||||
self.id = cid
|
|
||||||
self.config = config
|
|
||||||
self.server = server
|
|
||||||
|
|
||||||
self.users_pool = []
|
|
||||||
self.online_users = {}
|
|
||||||
self.history_chat = []
|
|
||||||
|
|
||||||
self.last_pub = time.time()
|
|
||||||
self.last_dir = time.time()
|
|
||||||
self.last_me = time.time()
|
|
||||||
self.last_login = time.time()
|
|
||||||
|
|
||||||
self.rate_pub = 1.0 / config["RATES"]["PUBLIC"]
|
|
||||||
self.rate_dir = 1.0 / config["RATES"]["DIRECTED"]
|
|
||||||
|
|
||||||
self._init_data()
|
|
||||||
|
|
||||||
def _init_data(self):
|
|
||||||
print(f"[*] Init Channel {self.id} ({self.config['NAME']})...")
|
|
||||||
|
|
||||||
for u in self.config["PERMANENT"]:
|
|
||||||
u_full = u.copy()
|
|
||||||
u_full["role"] = "ACTIVE"
|
|
||||||
UserFactory.register_permanent(u_full)
|
|
||||||
self.online_users[u['call']] = u_full
|
|
||||||
|
|
||||||
for _ in range(self.config["NUM_USERS"]):
|
|
||||||
new_u = UserFactory.get_or_create_user(self.id, self.online_users.keys())
|
|
||||||
self.users_pool.append(new_u)
|
|
||||||
|
|
||||||
fill = int(self.config["NUM_USERS"] * 0.9)
|
|
||||||
for i in range(fill):
|
|
||||||
u = self.users_pool[i]
|
|
||||||
if u['call'] not in self.online_users:
|
|
||||||
self.online_users[u['call']] = u
|
|
||||||
|
|
||||||
print(f"[*] Channel {self.id} ready: {len(self.online_users)} Users.")
|
|
||||||
self._prefill_history()
|
|
||||||
|
|
||||||
def _prefill_history(self):
|
|
||||||
actives = [u for u in self.online_users.values() if u['role'] == "ACTIVE"]
|
|
||||||
if not actives: return
|
|
||||||
start = datetime.now() - timedelta(minutes=15)
|
|
||||||
for i in range(30):
|
|
||||||
msg_time = start + timedelta(seconds=i*30)
|
|
||||||
ts = str(int(msg_time.timestamp()))
|
|
||||||
sender = random.choice(actives)
|
|
||||||
if i % 2 == 0:
|
|
||||||
text = MessageFactory.get_chat_message(self.config["BANDS"], sender)
|
|
||||||
frame = f"CH|{self.id}|{ts}|{sender['call']}|{sender['name']}|0|{text}|0|\r\n"
|
|
||||||
else:
|
|
||||||
target = random.choice(list(self.online_users.values()))
|
|
||||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], target['call'], sender['loc'])
|
|
||||||
frame = f"CH|{self.id}|{ts}|{sender['call']}|{sender['name']}|0|{text}|{target['call']}|\r\n"
|
|
||||||
self.history_chat.append(frame)
|
|
||||||
|
|
||||||
def tick(self, now):
|
|
||||||
actives = [u for u in self.online_users.values() if u['role'] == "ACTIVE"]
|
|
||||||
if not actives: return
|
|
||||||
|
|
||||||
# PUBLIC
|
|
||||||
if now - self.last_pub > self.rate_pub:
|
|
||||||
self.last_pub = now
|
|
||||||
u = random.choice(actives)
|
|
||||||
# V31: Nutzt jetzt get_chat_message, das das Freq-Memory abfragt
|
|
||||||
text = MessageFactory.get_chat_message(self.config["BANDS"], u)
|
|
||||||
ts = str(int(now))
|
|
||||||
frame = f"CH|{self.id}|{ts}|{u['call']}|{u['name']}|0|{text}|0|\r\n"
|
|
||||||
self._add_hist(frame)
|
|
||||||
self.server.broadcast_to_channel(self.id, frame)
|
|
||||||
|
|
||||||
# DIRECTED
|
|
||||||
if now - self.last_dir > self.rate_dir:
|
|
||||||
self.last_dir = now
|
|
||||||
if len(actives) > 5:
|
|
||||||
u1 = random.choice(actives)
|
|
||||||
u2 = random.choice(list(self.online_users.values()))
|
|
||||||
if u1 != u2:
|
|
||||||
if random.random() < 0.5:
|
|
||||||
# Auch hier Frequenzstabilität beachten
|
|
||||||
text = MessageFactory.get_chat_message(self.config["BANDS"], u1)
|
|
||||||
else:
|
|
||||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], u2['call'], u1['loc'])
|
|
||||||
ts = str(int(now))
|
|
||||||
frame = f"CH|{self.id}|{ts}|{u1['call']}|{u1['name']}|0|{text}|{u2['call']}|\r\n"
|
|
||||||
self.server.broadcast_to_channel(self.id, frame)
|
|
||||||
if u2['role'] != "INACTIVE":
|
|
||||||
threading.Thread(target=self._schedule_reply, args=(u2['call'], u1['call']), daemon=True).start()
|
|
||||||
|
|
||||||
# MSG TO YOU
|
|
||||||
if now - self.last_me > MSG_TO_USER_INTERVAL:
|
|
||||||
self.last_me = now
|
|
||||||
target_client = self.server.get_random_subscriber(self.id)
|
|
||||||
if target_client and actives:
|
|
||||||
if not target_client.call.startswith("GUEST"):
|
|
||||||
sender = random.choice(actives)
|
|
||||||
text = MessageFactory.get_chat_message(self.config["BANDS"], sender)
|
|
||||||
print(f"[SIM Ch{self.id}] MSG TO YOU ({target_client.call})")
|
|
||||||
self.process_msg(sender['call'], sender['name'], text, target_client.call)
|
|
||||||
|
|
||||||
# LOGIN/LOGOUT
|
|
||||||
if now - self.last_login > LOGIN_LOGOUT_INTERVAL:
|
|
||||||
self.last_login = now
|
|
||||||
if random.choice(['IN', 'OUT']) == 'OUT' and len(self.online_users) > 20:
|
|
||||||
cands = [c for c in self.online_users if c not in [p['call'] for p in self.config["PERMANENT"]]]
|
|
||||||
if cands:
|
|
||||||
l = random.choice(cands)
|
|
||||||
del self.online_users[l]
|
|
||||||
self.server.broadcast_to_channel(self.id, f"UR6|{self.id}|{l}|\r\n")
|
|
||||||
else:
|
|
||||||
candidates = [u for u in self.users_pool if u['call'] not in self.online_users]
|
|
||||||
if candidates:
|
|
||||||
n = random.choice(candidates)
|
|
||||||
self.online_users[n['call']] = n
|
|
||||||
self.server.broadcast_to_channel(self.id, f"UA5|{self.id}|{n['call']}|{n['name']}|{n['loc']}|2|\r\n")
|
|
||||||
|
|
||||||
def process_msg(self, sender, name, text, target):
|
|
||||||
ts = str(int(time.time()))
|
|
||||||
frame = f"CH|{self.id}|{ts}|{sender}|{name}|0|{text}|{target}|\r\n"
|
|
||||||
if target == "0": self._add_hist(frame)
|
|
||||||
self.server.broadcast_to_channel(self.id, frame)
|
|
||||||
if target in self.online_users:
|
|
||||||
threading.Thread(target=self._schedule_reply, args=(target, sender), daemon=True).start()
|
|
||||||
|
|
||||||
def _schedule_reply(self, sim_sender, real_target):
|
|
||||||
if sim_sender not in self.online_users: return
|
|
||||||
u = self.online_users[sim_sender]
|
|
||||||
if u['role'] == "INACTIVE": return
|
|
||||||
|
|
||||||
time.sleep(random.uniform(2.0, 5.0))
|
|
||||||
if sim_sender in self.online_users:
|
|
||||||
text = MessageFactory.get_reply_msg(self.config["BANDS"], real_target, u['loc'])
|
|
||||||
ts = str(int(time.time()))
|
|
||||||
|
|
||||||
if self.server.is_real_user(real_target):
|
|
||||||
print(f"[REPLY Ch{self.id}] {sim_sender} -> {real_target}")
|
|
||||||
|
|
||||||
frame = f"CH|{self.id}|{ts}|{sim_sender}|{u['name']}|0|{text}|{real_target}|\r\n"
|
|
||||||
self.server.broadcast_to_channel(self.id, frame)
|
|
||||||
|
|
||||||
def _add_hist(self, frame):
|
|
||||||
self.history_chat.append(frame)
|
|
||||||
if len(self.history_chat) > 50: self.history_chat.pop(0)
|
|
||||||
|
|
||||||
def get_full_init_blob(self):
|
|
||||||
blob = ""
|
|
||||||
for u in self.online_users.values():
|
|
||||||
blob += f"UA0|{self.id}|{u['call']}|{u['name']}|{u['loc']}|0|\r\n"
|
|
||||||
for h in self.history_chat: blob += h
|
|
||||||
blob += f"UE|{self.id}|{len(self.online_users)}|\r\n"
|
|
||||||
return blob.encode('latin-1', errors='replace')
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# SERVER
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
class KSTServerV31:
|
|
||||||
def __init__(self):
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
self.running = True
|
|
||||||
self.clients = {}
|
|
||||||
self.channels = {}
|
|
||||||
|
|
||||||
for cid, cfg in CHANNELS_SETUP.items():
|
|
||||||
self.channels[cid] = ChannelInstance(cid, cfg, self)
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
threading.Thread(target=self._sim_loop, daemon=True).start()
|
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
try:
|
|
||||||
s.bind((HOST, PORT))
|
|
||||||
s.listen(5)
|
|
||||||
s.settimeout(1.0)
|
|
||||||
print(f"[*] ON4KST V31 (Stable Frequencies) running on {HOST}:{PORT}")
|
|
||||||
|
|
||||||
while self.running:
|
|
||||||
try:
|
|
||||||
sock, addr = s.accept()
|
|
||||||
print(f"[*] CONNECT: {addr}")
|
|
||||||
threading.Thread(target=self._handle_client, args=(sock,), daemon=True).start()
|
|
||||||
except socket.timeout: continue
|
|
||||||
except OSError: break
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n[!] Stop.")
|
|
||||||
finally:
|
|
||||||
self.running = False
|
|
||||||
try: s.close()
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
def _handle_client(self, sock):
|
|
||||||
client_obj = ConnectedClient(sock, None)
|
|
||||||
with self.lock:
|
|
||||||
self.clients[sock] = client_obj
|
|
||||||
|
|
||||||
buffer = ""
|
|
||||||
try:
|
|
||||||
while self.running:
|
|
||||||
try: data = sock.recv(2048)
|
|
||||||
except: break
|
|
||||||
if not data: break
|
|
||||||
|
|
||||||
buffer += data.decode('latin-1', errors='replace')
|
|
||||||
while '\n' in buffer:
|
|
||||||
line, buffer = buffer.split('\n', 1)
|
|
||||||
line = line.strip()
|
|
||||||
if not line: continue
|
|
||||||
|
|
||||||
parts = line.split('|')
|
|
||||||
cmd = parts[0]
|
|
||||||
|
|
||||||
if cmd == 'LOGIN' or cmd == 'LOGINC':
|
|
||||||
if len(parts) > 1:
|
|
||||||
client_obj.call = parts[1].strip().upper()
|
|
||||||
print(f"[LOGIN] {client_obj.call} (Ch 2)")
|
|
||||||
|
|
||||||
client_obj.send_safe(f"LOGSTAT|100|2|PySimV31|KEY|Conf|3|\r\n")
|
|
||||||
if cmd == 'LOGIN':
|
|
||||||
self._send_channel_init(client_obj, "2")
|
|
||||||
|
|
||||||
elif cmd == 'SDONE':
|
|
||||||
self._send_channel_init(client_obj, "2")
|
|
||||||
|
|
||||||
elif cmd.startswith('ACHAT'):
|
|
||||||
if len(parts) >= 2:
|
|
||||||
new_chan = parts[1]
|
|
||||||
if new_chan in self.channels:
|
|
||||||
client_obj.channels.add(new_chan)
|
|
||||||
print(f"[ACHAT] {client_obj.call} -> Ch {new_chan}")
|
|
||||||
self._send_channel_init(client_obj, new_chan)
|
|
||||||
|
|
||||||
elif cmd == 'MSG':
|
|
||||||
if len(parts) >= 4:
|
|
||||||
cid = parts[1]
|
|
||||||
target = parts[2]
|
|
||||||
text = parts[3]
|
|
||||||
if text.lower().startswith("/cq"):
|
|
||||||
spl = text.split(' ', 2)
|
|
||||||
if len(spl) >= 3:
|
|
||||||
target = spl[1]; text = spl[2]
|
|
||||||
if cid in self.channels:
|
|
||||||
self.channels[cid].process_msg(client_obj.call, "Me", text, target)
|
|
||||||
|
|
||||||
elif cmd == 'CK': pass
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[!] Err: {e}")
|
|
||||||
finally:
|
|
||||||
with self.lock:
|
|
||||||
if sock in self.clients: del self.clients[sock]
|
|
||||||
client_obj.close()
|
|
||||||
|
|
||||||
def _send_channel_init(self, client_obj, cid):
|
|
||||||
if cid in self.channels:
|
|
||||||
full_blob = self.channels[cid].get_full_init_blob()
|
|
||||||
client_obj.send_safe(full_blob.decode('latin-1'))
|
|
||||||
|
|
||||||
def broadcast_to_channel(self, cid, frame):
|
|
||||||
now = time.time()
|
|
||||||
with self.lock:
|
|
||||||
targets = list(self.clients.values())
|
|
||||||
|
|
||||||
for c in targets:
|
|
||||||
if cid in c.channels:
|
|
||||||
if now - c.login_time > CLIENT_WARMUP_TIME:
|
|
||||||
c.send_safe(frame)
|
|
||||||
|
|
||||||
def get_random_subscriber(self, cid):
|
|
||||||
with self.lock:
|
|
||||||
subs = [c for c in self.clients.values() if cid in c.channels and not c.call.startswith("GUEST")]
|
|
||||||
return random.choice(subs) if subs else None
|
|
||||||
|
|
||||||
def is_real_user(self, call):
|
|
||||||
with self.lock:
|
|
||||||
for c in self.clients.values():
|
|
||||||
if c.call.upper() == call.upper() and not c.call.startswith("GUEST"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _sim_loop(self):
|
|
||||||
print("[*] Sim Loop running...")
|
|
||||||
last_ka = time.time()
|
|
||||||
while self.running:
|
|
||||||
now = time.time()
|
|
||||||
time.sleep(0.02)
|
|
||||||
|
|
||||||
for c in self.channels.values():
|
|
||||||
c.tick(now)
|
|
||||||
|
|
||||||
if now - last_ka > KEEP_ALIVE_INTERVAL:
|
|
||||||
last_ka = now
|
|
||||||
self.broadcast_global("CK|\r\n")
|
|
||||||
|
|
||||||
def broadcast_global(self, frame):
|
|
||||||
with self.lock:
|
|
||||||
targets = list(self.clients.values())
|
|
||||||
for c in targets:
|
|
||||||
c.send_safe(frame)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
KSTServerV31().start()
|
|
||||||
@@ -38,24 +38,7 @@ public class ApplicationConstants {
|
|||||||
|
|
||||||
public static final String AUTOANSWER_PREFIX = "[KST4C Automsg] "; // hard-coded marker (user can't remove it)
|
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
|
||||||
|
|||||||
+7
-10
@@ -9,8 +9,6 @@ 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;
|
||||||
@@ -19,7 +17,6 @@ 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) {
|
||||||
@@ -58,7 +55,7 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
ownCallSign = this.client.getChatPreferences().getStn_loginCallSign();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Error parsing callsign", e);
|
System.out.println("[ASPERIODICAL, Error]: " + e.getMessage());
|
||||||
}
|
}
|
||||||
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
String myCallAndMyLocString = ownCallSign + "," + this.client.getChatPreferences().getStn_loginLocatorMainCat(); //bugfix, Airscout do not process 9A1W-2 but 9A1W like formatted calls
|
||||||
|
|
||||||
@@ -108,13 +105,13 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
dsocket.send(packet);
|
dsocket.send(packet);
|
||||||
dsocket.close();
|
dsocket.close();
|
||||||
} catch (UnknownHostException e1) {
|
} catch (UnknownHostException e1) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Unknown host", e1);
|
e1.printStackTrace();
|
||||||
} catch (NoRouteToHostException e) {
|
} catch (NoRouteToHostException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] No route to host", e);
|
e.printStackTrace();
|
||||||
} catch (SocketException e) {
|
} catch (SocketException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] Socket error", e);
|
e.printStackTrace();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending query", e);
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
// System.out.println("[ASUDPTask, info:] sent query " + queryStringToAirScout);
|
||||||
|
|
||||||
@@ -139,9 +136,9 @@ public class AirScoutPeriodicalAPReflectionInquirerTask extends TimerTask {
|
|||||||
dsocket.send(packet);
|
dsocket.send(packet);
|
||||||
dsocket.close();
|
dsocket.close();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ASPERIODICAL] IO error sending watchlist", e);
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
// System.out.println("[ASUDPTask, info:] set watchlist: " + asWatchListStringSuffix);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.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.
|
||||||
@@ -812,24 +810,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw =
|
private final Map<String, ChatCategory> lastInboundCategoryByCallSignRaw =
|
||||||
new java.util.concurrent.ConcurrentHashMap<>();
|
new java.util.concurrent.ConcurrentHashMap<>();
|
||||||
|
|
||||||
/** Tracks the last time WE sent a message containing a QRG to a specific callsign (UPPERCASE).
|
|
||||||
* Compared against knownActiveBands.timestampEpoch to decide whose QRG to use in a SKED. */
|
|
||||||
private final Map<String, Long> lastSentQRGToCallsign =
|
|
||||||
new java.util.concurrent.ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
/** Call this whenever we send a PM to {@code receiverCallsign} that contains our QRG. */
|
|
||||||
public void recordOutboundQRG(String receiverCallsign) {
|
|
||||||
if (receiverCallsign == null) return;
|
|
||||||
lastSentQRGToCallsign.put(receiverCallsign.trim().toUpperCase(), System.currentTimeMillis());
|
|
||||||
System.out.println("[ChatController] Recorded outbound QRG to: " + receiverCallsign);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns epoch-ms of when we last sent our QRG to this callsign, or 0 if never. */
|
|
||||||
public long getLastSentQRGTimestamp(String callsign) {
|
|
||||||
if (callsign == null) return 0L;
|
|
||||||
return lastSentQRGToCallsign.getOrDefault(callsign.trim().toUpperCase(), 0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15);
|
private final ScoreService scoreService = new ScoreService(this, new PriorityCalculator(), 15);
|
||||||
private ScheduledExecutorService scoreScheduler;
|
private ScheduledExecutorService scoreScheduler;
|
||||||
private final StationMetricsService stationMetricsService = new StationMetricsService();
|
private final StationMetricsService stationMetricsService = new StationMetricsService();
|
||||||
@@ -847,7 +827,8 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Push sked to Win-Test via UDP if enabled
|
// Push sked to Win-Test via UDP if enabled
|
||||||
if (chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
|
if (chatPreferences.isLogsynch_wintestNetworkSkedPushEnabled()
|
||||||
|
&& chatPreferences.isLogsynch_wintestNetworkListenerEnabled()) {
|
||||||
pushSkedToWinTest(sked);
|
pushSkedToWinTest(sked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -866,69 +847,16 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
|
|
||||||
WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this);
|
WinTestSkedSender sender = new WinTestSkedSender(stationName, broadcastAddr, port, this);
|
||||||
|
|
||||||
// Frequency resolution:
|
// Get current frequency from QRG property (set by Win-Test STATUS or user)
|
||||||
// Compare WHO sent a QRG most recently in the PM conversation:
|
double freqKHz = 144300.0; // fallback default
|
||||||
// - OM sent their QRG last → use OM's Last Known QRG (ChatMember.frequency)
|
try {
|
||||||
// - WE sent our QRG last → use our own Win-Test QRG (MYQRG)
|
String qrgStr = chatPreferences.getMYQRGFirstCat().get();
|
||||||
// Fallback chain if no timestamps exist: OM's Last Known QRG → hardcoded default
|
if (qrgStr != null && !qrgStr.isBlank()) {
|
||||||
double freqKHz = -1.0;
|
// QRG is in display format like "144.300.00" – strip dots → "14430000" → / 100 → 144300.0 kHz
|
||||||
final long SKED_FREQ_MAX_AGE_MS = 60 * 60 * 1000L; // 60 minutes
|
String cleaned = qrgStr.trim().replace(".", "");
|
||||||
|
freqKHz = Double.parseDouble(cleaned) / 100.0;
|
||||||
ChatMember targetMember = resolveSkedTargetMember(sked.getTargetCallsign());
|
|
||||||
|
|
||||||
// Collect timestamps: when did the OM last mention their QRG? When did WE last send ours?
|
|
||||||
long omLastQRGTimestamp = 0L;
|
|
||||||
double omLastQRGMhz = 0.0;
|
|
||||||
if (targetMember != null && sked.getBand() != null) {
|
|
||||||
ChatMember.ActiveFrequencyInfo fi = targetMember.getKnownActiveBands().get(sked.getBand());
|
|
||||||
if (fi != null && fi.frequency > 0
|
|
||||||
&& (System.currentTimeMillis() - fi.timestampEpoch) <= SKED_FREQ_MAX_AGE_MS) {
|
|
||||||
omLastQRGTimestamp = fi.timestampEpoch;
|
|
||||||
omLastQRGMhz = fi.frequency;
|
|
||||||
}
|
}
|
||||||
}
|
} catch (NumberFormatException ignored) { }
|
||||||
long ourLastQRGTimestamp = getLastSentQRGTimestamp(sked.getTargetCallsign());
|
|
||||||
|
|
||||||
// Decision: who was more recent?
|
|
||||||
if (omLastQRGTimestamp > 0 && omLastQRGTimestamp >= ourLastQRGTimestamp) {
|
|
||||||
// OM mentioned their QRG MORE RECENTLY (or at same time) → use their QRG
|
|
||||||
freqKHz = omLastQRGMhz * 1000.0;
|
|
||||||
System.out.println("[ChatController] SKED freq: OM sent last → "
|
|
||||||
+ omLastQRGMhz + " MHz → " + freqKHz + " kHz");
|
|
||||||
|
|
||||||
} else if (ourLastQRGTimestamp > 0) {
|
|
||||||
// WE sent our QRG more recently → use our Win-Test QRG
|
|
||||||
try {
|
|
||||||
String qrgStr = chatPreferences.getMYQRGFirstCat().get();
|
|
||||||
if (qrgStr != null && !qrgStr.isBlank()) {
|
|
||||||
String cleaned = qrgStr.trim().replace(".", "");
|
|
||||||
double parsed = Double.parseDouble(cleaned) / 100.0;
|
|
||||||
if (parsed > 50000) {
|
|
||||||
freqKHz = parsed;
|
|
||||||
System.out.println("[ChatController] SKED freq: WE sent last → "
|
|
||||||
+ freqKHz + " kHz (raw: " + qrgStr + ")");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException ignored) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback A: OM's Last Known QRG from KST field (if no PM QRG exchange found at all)
|
|
||||||
if (freqKHz < 0 && targetMember != null) {
|
|
||||||
try {
|
|
||||||
String memberQrg = targetMember.getFrequency().get();
|
|
||||||
if (memberQrg != null && !memberQrg.isBlank()) {
|
|
||||||
double mhz = Double.parseDouble(memberQrg.trim());
|
|
||||||
freqKHz = mhz * 1000.0;
|
|
||||||
System.out.println("[ChatController] SKED freq: fallback Last Known QRG → "
|
|
||||||
+ mhz + " MHz → " + freqKHz + " kHz");
|
|
||||||
}
|
|
||||||
} catch (NumberFormatException ignored) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback B: hardcoded default
|
|
||||||
if (freqKHz < 0) {
|
|
||||||
freqKHz = 144300.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build notes string with target locator/azimuth info like reference: [JO02OB - 279°]
|
// Build notes string with target locator/azimuth info like reference: [JO02OB - 279°]
|
||||||
String targetLocator = resolveSkedTargetLocator(sked.getTargetCallsign());
|
String targetLocator = resolveSkedTargetLocator(sked.getTargetCallsign());
|
||||||
@@ -955,22 +883,6 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList
|
|||||||
}, "WinTestSkedPush").start();
|
}, "WinTestSkedPush").start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChatMember resolveSkedTargetMember(String targetCallsignRaw) {
|
|
||||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String normalizedTargetCall = normalizeCallRaw(targetCallsignRaw);
|
|
||||||
synchronized (getLst_chatMemberList()) {
|
|
||||||
for (ChatMember member : getLst_chatMemberList()) {
|
|
||||||
if (member == null || member.getCallSignRaw() == null) continue;
|
|
||||||
if (normalizeCallRaw(member.getCallSignRaw()).equals(normalizedTargetCall)) {
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
private String resolveSkedTargetLocator(String targetCallsignRaw) {
|
||||||
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
if (targetCallsignRaw == null || targetCallsignRaw.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1129,27 +1041,6 @@ 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
|
||||||
@@ -1265,145 +1156,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1641,7 +1393,6 @@ 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();
|
||||||
|
|
||||||
@@ -1658,18 +1409,14 @@ 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;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (isSniffedMessage(chatMessage)) {
|
msgText = ("Sniffed: " + "(" + senderCall + " > ") + receiverCall +") " + msgText;
|
||||||
return true;
|
chatMessage.setMessageText(msgText);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// --- BESTEHENDE LOGIK ---
|
// --- BESTEHENDE LOGIK ---
|
||||||
|
|
||||||
@@ -2552,69 +2299,4 @@ 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,12 +10,9 @@ 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;
|
||||||
@@ -114,7 +111,8 @@ 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) {
|
||||||
LOGGER.log(Level.SEVERE, "DXCThPooledServer: Error accessing value in chatmember object", e);
|
System.out.println("DXCThPooledServer: Error accessing value in chatmember object: " + e.getMessage());
|
||||||
|
// e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Socket socket : clientSockets) {
|
for (Socket socket : clientSockets) {
|
||||||
@@ -148,7 +146,8 @@ public class DXClusterThreadPooledServer implements Runnable{
|
|||||||
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
callBackToController.onThreadStatus(ThreadNickName,threadStateMessage);
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] broadcasting DXC-message to clients went wrong", e);
|
e.printStackTrace();
|
||||||
|
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,7 +159,6 @@ 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;
|
||||||
@@ -199,13 +197,14 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
|||||||
output.write(("\r\n").getBytes());
|
output.write(("\r\n").getBytes());
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] keep-alive broadcast to client failed", e);
|
e.printStackTrace();
|
||||||
|
System.out.println("[DXClusterSrvr, Error:] broadcasting DXC-message to clients went wrong!");
|
||||||
dXCkeepAliveTimer.purge();
|
dXCkeepAliveTimer.purge();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
socket.close();
|
socket.close();
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error closing client socket", ex);
|
ex.printStackTrace();
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
this.cancel();
|
this.cancel();
|
||||||
@@ -225,7 +224,7 @@ class DXClusterServerWorkerRunnable implements Runnable{
|
|||||||
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
System.out.println("[DXClusterThreadPooledServer, Info:] New cluster client connected! "); //TODO: maybe integrate non blocking reader for client identification
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[DXClusterSrvr] error in worker runnable", e);
|
e.printStackTrace();
|
||||||
} finally {
|
} finally {
|
||||||
synchronized(dxClusterClientSocketsConnectedList) {
|
synchronized(dxClusterClientSocketsConnectedList) {
|
||||||
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
dxClusterClientSocketsConnectedList.remove(clientSocket); // Entferne den Client nach Verarbeitung
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ 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;
|
||||||
|
|
||||||
@@ -15,7 +13,6 @@ 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;
|
||||||
@@ -42,7 +39,8 @@ public class InputReaderThread extends Thread {
|
|||||||
try {
|
try {
|
||||||
sendThisMessage23001 = reader.readLine();
|
sendThisMessage23001 = reader.readLine();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "Error reading from stdin", e);
|
// TODO Auto-generated catch block
|
||||||
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
ownMSG.setMessageText("MSG|" + this.client.getChatCategoryMain().getCategoryNumber() + "|0|" + sendThisMessage23001 + "|0|");
|
||||||
@@ -55,8 +53,8 @@ public class InputReaderThread extends Thread {
|
|||||||
try {
|
try {
|
||||||
this.sleep(500);
|
this.sleep(500);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
LOGGER.log(Level.SEVERE, "InputReaderThread interrupted", e);
|
// TODO Auto-generated catch block
|
||||||
Thread.currentThread().interrupt();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -772,9 +772,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
dummy.setCallSign("ALL");
|
dummy.setCallSign("ALL");
|
||||||
newMessageArrived.setReceiver(dummy);
|
newMessageArrived.setReceiver(dummy);
|
||||||
|
|
||||||
this.client.publishChatMessage(newMessageArrived); // sdtout to all message-List (new from v1.7)
|
this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
||||||
|
|
||||||
// this.client.getLst_globalChatMessageList().add(0, newMessageArrived); // sdtout to all message-List
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
//message is directed to another chatmember, process as such!
|
//message is directed to another chatmember, process as such!
|
||||||
@@ -819,9 +817,7 @@ 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');
|
||||||
@@ -964,9 +960,7 @@ 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
|
||||||
@@ -1030,8 +1024,7 @@ 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,8 +1127,7 @@ 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 ");
|
||||||
|
|
||||||
@@ -1174,8 +1166,7 @@ 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
|
||||||
|
|
||||||
@@ -1205,8 +1196,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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1373,8 +1364,7 @@ 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!
|
||||||
@@ -1418,8 +1408,7 @@ 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() + ".");
|
||||||
|
|
||||||
@@ -1432,9 +1421,8 @@ 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
|
||||||
|
|
||||||
@@ -1453,8 +1441,7 @@ 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,8 +3,6 @@ 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;
|
||||||
|
|
||||||
@@ -16,7 +14,6 @@ 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;
|
||||||
@@ -46,7 +43,8 @@ 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) {
|
||||||
LOGGER.log(Level.SEVERE, "Error getting input stream", ex);
|
System.out.println("Error getting input stream: " + ex.getMessage());
|
||||||
|
ex.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,14 +82,15 @@ public class ReadThread extends Thread {
|
|||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception sexc) {
|
catch (Exception sexc) {
|
||||||
LOGGER.log(Level.SEVERE, "[ReadThread] Socket closed unexpectedly", sexc);
|
System.out.println("[ReadThread, CRITICAL: ] Socket geschlossen: " + sexc.getMessage());
|
||||||
try {
|
try {
|
||||||
this.client.getSocket().close();
|
this.client.getSocket().close();
|
||||||
this.interrupt();
|
this.interrupt();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
LOGGER.log(Level.SEVERE, "[ReadThread] Error closing socket", e);
|
// TODO Auto-generated catch block
|
||||||
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package kst4contest.controller;
|
package kst4contest.controller;
|
||||||
|
|
||||||
import javafx.application.Platform;
|
|
||||||
import kst4contest.ApplicationConstants;
|
import kst4contest.ApplicationConstants;
|
||||||
import kst4contest.model.ChatMember;
|
import kst4contest.model.ChatMember;
|
||||||
import kst4contest.model.ThreadStateMessage;
|
import kst4contest.model.ThreadStateMessage;
|
||||||
@@ -76,10 +75,9 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
socket = new DatagramSocket(null); //first init with null, then make ready for reuse
|
socket = new DatagramSocket(null); //first init with null, then make ready for reuse
|
||||||
socket.setReuseAddress(true);
|
socket.setReuseAddress(true);
|
||||||
// socket = new DatagramSocket(PORT);
|
// socket = new DatagramSocket(PORT);
|
||||||
int boundPort = client.getChatPreferences().getLogsynch_wintestNetworkPort();
|
socket.bind(new InetSocketAddress(client.getChatPreferences().getLogsynch_wintestNetworkPort()));
|
||||||
socket.bind(new InetSocketAddress(boundPort));
|
|
||||||
socket.setSoTimeout(3000);
|
socket.setSoTimeout(3000);
|
||||||
System.out.println("[WinTest UDP listener] started at port: " + boundPort);
|
System.out.println("[WinTest UDP listener] started at port: " + PORT);
|
||||||
} catch (SocketException e) {
|
} catch (SocketException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return;
|
return;
|
||||||
@@ -226,43 +224,9 @@ public class ReadUDPByWintestThread extends Thread {
|
|||||||
} else {
|
} else {
|
||||||
formattedQRG = String.format(Locale.US, "%.1f", freqFloat); // fallback
|
formattedQRG = String.format(Locale.US, "%.1f", freqFloat); // fallback
|
||||||
}
|
}
|
||||||
// Parse pass frequency from parts[11] if available (WT STATUS format)
|
this.client.getChatPreferences().getMYQRGFirstCat().set(formattedQRG);
|
||||||
String formattedPassQRG = null;
|
|
||||||
if (parts.size() > 11) {
|
|
||||||
try {
|
|
||||||
String passFreqRaw = parts.get(11);
|
|
||||||
double passFreqFloat = Integer.parseInt(passFreqRaw) / 10.0;
|
|
||||||
if (passFreqFloat > 100) { // Must be a valid radio frequency (> 100 kHz), protects against parsing boolean flag tokens
|
|
||||||
long passFreqHzTimes100 = Math.round(passFreqFloat * 100.0);
|
|
||||||
String passHzStr = String.valueOf(passFreqHzTimes100);
|
|
||||||
if (passHzStr.length() == 8) {
|
|
||||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 3), passHzStr.substring(3, 6), passHzStr.substring(6, 8));
|
|
||||||
} else if (passHzStr.length() == 9) {
|
|
||||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 4), passHzStr.substring(4, 7), passHzStr.substring(7, 9));
|
|
||||||
} else if (passHzStr.length() == 7) {
|
|
||||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 2), passHzStr.substring(2, 5), passHzStr.substring(5, 7));
|
|
||||||
} else if (passHzStr.length() == 6) {
|
|
||||||
formattedPassQRG = String.format("%s.%s.%s", passHzStr.substring(0, 1), passHzStr.substring(1, 4), passHzStr.substring(4, 6));
|
|
||||||
} else {
|
|
||||||
formattedPassQRG = String.format(Locale.US, "%.1f", passFreqFloat);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// parts[11] not a valid frequency, leave formattedPassQRG as null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.client.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) {
|
System.out.println("[WinTest STATUS] stn=" + stn + ", mode=" + mode + ", qrg=" + formattedQRG);
|
||||||
final String qrgToSet = (this.client.getChatPreferences().isLogsynch_wintestUsePassQrg() && formattedPassQRG != null)
|
|
||||||
? formattedPassQRG
|
|
||||||
: formattedQRG;
|
|
||||||
// JavaFX StringProperty must be updated on the FX Application Thread
|
|
||||||
Platform.runLater(() -> this.client.getChatPreferences().getMYQRGFirstCat().set(qrgToSet));
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("[WinTest STATUS] stn=" + stn + ", mode=" + mode + ", qrg=" + formattedQRG
|
|
||||||
+ (formattedPassQRG != null ? ", passQrg=" + formattedPassQRG : "")
|
|
||||||
+ ", syncActive=" + this.client.getChatPreferences().isLogsynch_wintestQrgSyncEnabled());
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println("[WinTest] STATUS parsing error: " + e.getMessage());
|
System.out.println("[WinTest] STATUS parsing error: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,23 +173,6 @@ 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;
|
||||||
@@ -221,8 +204,6 @@ public class ChatPreferences {
|
|||||||
String logsynch_wintestNetworkBroadcastAddress = "255.255.255.255"; // UDP broadcast address for sending to Win-Test
|
String logsynch_wintestNetworkBroadcastAddress = "255.255.255.255"; // UDP broadcast address for sending to Win-Test
|
||||||
boolean logsynch_wintestNetworkSkedPushEnabled = false; // push SKEDs to Win-Test via UDP
|
boolean logsynch_wintestNetworkSkedPushEnabled = false; // push SKEDs to Win-Test via UDP
|
||||||
String logsynch_wintestSkedMode = "SSB"; // CW, SSB or AUTO
|
String logsynch_wintestSkedMode = "SSB"; // CW, SSB or AUTO
|
||||||
boolean logsynch_wintestQrgSyncEnabled = true; // sync QRG from Win-Test STATUS packet
|
|
||||||
boolean logsynch_wintestUsePassQrg = false; // use pass frequency instead of main QRG from STATUS packet
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -326,9 +307,6 @@ public class ChatPreferences {
|
|||||||
boolean guiOptions_defaultFilterPmToOther;
|
boolean guiOptions_defaultFilterPmToOther;
|
||||||
boolean guiOptions_defaultFilterPublicMsgs;
|
boolean guiOptions_defaultFilterPublicMsgs;
|
||||||
|
|
||||||
private double[] GUIstationMapStageSceneSizeHW = new double[] { 1000, 800 };
|
|
||||||
private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN };
|
|
||||||
|
|
||||||
|
|
||||||
/*********************************************************************************
|
/*********************************************************************************
|
||||||
*
|
*
|
||||||
@@ -391,42 +369,6 @@ public class ChatPreferences {
|
|||||||
this.MYQRGFirstCat.set(MYQRGFirstCat);
|
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;
|
||||||
}
|
}
|
||||||
@@ -539,22 +481,6 @@ public class ChatPreferences {
|
|||||||
this.logsynch_wintestSkedMode = logsynch_wintestSkedMode;
|
this.logsynch_wintestSkedMode = logsynch_wintestSkedMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isLogsynch_wintestQrgSyncEnabled() {
|
|
||||||
return logsynch_wintestQrgSyncEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setLogsynch_wintestQrgSyncEnabled(boolean logsynch_wintestQrgSyncEnabled) {
|
|
||||||
this.logsynch_wintestQrgSyncEnabled = logsynch_wintestQrgSyncEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isLogsynch_wintestUsePassQrg() {
|
|
||||||
return logsynch_wintestUsePassQrg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setLogsynch_wintestUsePassQrg(boolean logsynch_wintestUsePassQrg) {
|
|
||||||
this.logsynch_wintestUsePassQrg = logsynch_wintestUsePassQrg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getStn_loginLocatorSecondCat() {
|
public String getStn_loginLocatorSecondCat() {
|
||||||
return stn_loginLocatorSecondCat;
|
return stn_loginLocatorSecondCat;
|
||||||
}
|
}
|
||||||
@@ -603,22 +529,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -1317,63 +1227,6 @@ 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);
|
||||||
@@ -1485,14 +1338,6 @@ public class ChatPreferences {
|
|||||||
logsynch_wintestSkedMode.setTextContent(this.logsynch_wintestSkedMode);
|
logsynch_wintestSkedMode.setTextContent(this.logsynch_wintestSkedMode);
|
||||||
logsynch.appendChild(logsynch_wintestSkedMode);
|
logsynch.appendChild(logsynch_wintestSkedMode);
|
||||||
|
|
||||||
Element logsynch_wintestQrgSyncEnabled = doc.createElement("logsynch_wintestQrgSyncEnabled");
|
|
||||||
logsynch_wintestQrgSyncEnabled.setTextContent(this.logsynch_wintestQrgSyncEnabled + "");
|
|
||||||
logsynch.appendChild(logsynch_wintestQrgSyncEnabled);
|
|
||||||
|
|
||||||
Element logsynch_wintestUsePassQrg = doc.createElement("logsynch_wintestUsePassQrg");
|
|
||||||
logsynch_wintestUsePassQrg.setTextContent(this.logsynch_wintestUsePassQrg + "");
|
|
||||||
logsynch.appendChild(logsynch_wintestUsePassQrg);
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* trxSynchUCX
|
* trxSynchUCX
|
||||||
@@ -1869,18 +1714,6 @@ 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! *************************************
|
||||||
****************************************************************************************/
|
****************************************************************************************/
|
||||||
@@ -1997,90 +1830,6 @@ 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");
|
||||||
@@ -2163,16 +1912,6 @@ public class ChatPreferences {
|
|||||||
logsynch_wintestSkedMode,
|
logsynch_wintestSkedMode,
|
||||||
"logsynch_wintestSkedMode");
|
"logsynch_wintestSkedMode");
|
||||||
|
|
||||||
logsynch_wintestQrgSyncEnabled = getBoolean(
|
|
||||||
logsynchEl,
|
|
||||||
logsynch_wintestQrgSyncEnabled,
|
|
||||||
"logsynch_wintestQrgSyncEnabled");
|
|
||||||
|
|
||||||
logsynch_wintestUsePassQrg = getBoolean(
|
|
||||||
logsynchEl,
|
|
||||||
logsynch_wintestUsePassQrg,
|
|
||||||
"logsynch_wintestUsePassQrg");
|
|
||||||
|
|
||||||
System.out.println(
|
System.out.println(
|
||||||
"[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled);
|
"[ChatPreferences, info]: file based worked-call interpreter: " + logsynch_fileBasedWkdCallInterpreterEnabled);
|
||||||
System.out.println(
|
System.out.println(
|
||||||
@@ -2489,16 +2228,6 @@ public class ChatPreferences {
|
|||||||
parseSemicolonDoublesInto(getText(element, null, "GUIstage_updateStage_SceneSizeHW"), this.getGUIstage_updateStage_SceneSizeHW());
|
parseSemicolonDoublesInto(getText(element, null, "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) {
|
||||||
@@ -2895,100 +2624,6 @@ public class ChatPreferences {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public double getStn_pathAnalysisOwnTxPowerWatts() {
|
|
||||||
return stn_pathAnalysisOwnTxPowerWatts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisOwnTxPowerWatts(double stn_pathAnalysisOwnTxPowerWatts) {
|
|
||||||
this.stn_pathAnalysisOwnTxPowerWatts = stn_pathAnalysisOwnTxPowerWatts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisOwnAntennaGainDbi() {
|
|
||||||
return stn_pathAnalysisOwnAntennaGainDbi;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisOwnAntennaGainDbi(double stn_pathAnalysisOwnAntennaGainDbi) {
|
|
||||||
this.stn_pathAnalysisOwnAntennaGainDbi = stn_pathAnalysisOwnAntennaGainDbi;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisDefaultTargetTxPowerWatts() {
|
|
||||||
return stn_pathAnalysisDefaultTargetTxPowerWatts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisDefaultTargetTxPowerWatts(double stn_pathAnalysisDefaultTargetTxPowerWatts) {
|
|
||||||
this.stn_pathAnalysisDefaultTargetTxPowerWatts = stn_pathAnalysisDefaultTargetTxPowerWatts;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisDefaultTargetAntennaGainDbi() {
|
|
||||||
return stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisDefaultTargetAntennaGainDbi(double stn_pathAnalysisDefaultTargetAntennaGainDbi) {
|
|
||||||
this.stn_pathAnalysisDefaultTargetAntennaGainDbi = stn_pathAnalysisDefaultTargetAntennaGainDbi;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisVhfFeederLossPerStationDb() {
|
|
||||||
return stn_pathAnalysisVhfFeederLossPerStationDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisVhfFeederLossPerStationDb(double stn_pathAnalysisVhfFeederLossPerStationDb) {
|
|
||||||
this.stn_pathAnalysisVhfFeederLossPerStationDb = stn_pathAnalysisVhfFeederLossPerStationDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisFeederLossIncreaseDbPer200MHz() {
|
|
||||||
return stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisFeederLossIncreaseDbPer200MHz(double stn_pathAnalysisFeederLossIncreaseDbPer200MHz) {
|
|
||||||
this.stn_pathAnalysisFeederLossIncreaseDbPer200MHz = stn_pathAnalysisFeederLossIncreaseDbPer200MHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisMaxEstimatedFeederLossPerStationDb() {
|
|
||||||
return stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisMaxEstimatedFeederLossPerStationDb(double stn_pathAnalysisMaxEstimatedFeederLossPerStationDb) {
|
|
||||||
this.stn_pathAnalysisMaxEstimatedFeederLossPerStationDb = stn_pathAnalysisMaxEstimatedFeederLossPerStationDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisRequiredSsbSignalDbm() {
|
|
||||||
return stn_pathAnalysisRequiredSsbSignalDbm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisRequiredSsbSignalDbm(double stn_pathAnalysisRequiredSsbSignalDbm) {
|
|
||||||
this.stn_pathAnalysisRequiredSsbSignalDbm = stn_pathAnalysisRequiredSsbSignalDbm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisRequiredCwSignalDbm() {
|
|
||||||
return stn_pathAnalysisRequiredCwSignalDbm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisRequiredCwSignalDbm(double stn_pathAnalysisRequiredCwSignalDbm) {
|
|
||||||
this.stn_pathAnalysisRequiredCwSignalDbm = stn_pathAnalysisRequiredCwSignalDbm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getStn_pathAnalysisContestMarginDb() {
|
|
||||||
return stn_pathAnalysisContestMarginDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStn_pathAnalysisContestMarginDb(double stn_pathAnalysisContestMarginDb) {
|
|
||||||
this.stn_pathAnalysisContestMarginDb = stn_pathAnalysisContestMarginDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public kst4contest.view.map.PathLinkBudgetSettings buildPathLinkBudgetSettings() {
|
|
||||||
return new kst4contest.view.map.PathLinkBudgetSettings(
|
|
||||||
stn_pathAnalysisOwnTxPowerWatts,
|
|
||||||
stn_pathAnalysisOwnAntennaGainDbi,
|
|
||||||
stn_pathAnalysisDefaultTargetTxPowerWatts,
|
|
||||||
stn_pathAnalysisDefaultTargetAntennaGainDbi,
|
|
||||||
stn_pathAnalysisVhfFeederLossPerStationDb,
|
|
||||||
stn_pathAnalysisFeederLossIncreaseDbPer200MHz,
|
|
||||||
stn_pathAnalysisMaxEstimatedFeederLossPerStationDb,
|
|
||||||
stn_pathAnalysisRequiredSsbSignalDbm,
|
|
||||||
stn_pathAnalysisRequiredCwSignalDbm,
|
|
||||||
stn_pathAnalysisContestMarginDb
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package kst4contest.service.path;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility methods for Fresnel zone calculations.
|
|
||||||
*
|
|
||||||
* <p>This helper intentionally contains only pure mathematical functions.
|
|
||||||
* It has no dependency on UI code or terrain providers.</p>
|
|
||||||
*/
|
|
||||||
public final class FresnelMathUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Speed of light in vacuum in meters per second.
|
|
||||||
*/
|
|
||||||
public static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
|
||||||
|
|
||||||
private FresnelMathUtils() {
|
|
||||||
// Utility class
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the wavelength in meters for the given frequency.
|
|
||||||
*
|
|
||||||
* @param frequencyHz signal frequency in Hz
|
|
||||||
* @return wavelength in meters, or 0 if the input is invalid
|
|
||||||
*/
|
|
||||||
public static double computeWavelengthMeters(final double frequencyHz) {
|
|
||||||
if (frequencyHz <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / frequencyHz;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the radius of the first Fresnel zone at a specific point
|
|
||||||
* along the path.
|
|
||||||
*
|
|
||||||
* <p>Formula:
|
|
||||||
* r = sqrt(lambda * d1 * d2 / (d1 + d2))</p>
|
|
||||||
*
|
|
||||||
* @param frequencyHz signal frequency in Hz
|
|
||||||
* @param distanceFromTxMeters distance from TX to the current point in meters
|
|
||||||
* @param totalPathDistanceMeters full TX-to-RX path length in meters
|
|
||||||
* @return Fresnel radius in meters, or 0 at invalid inputs / path ends
|
|
||||||
*/
|
|
||||||
public static double computeFirstFresnelRadiusMeters(
|
|
||||||
final double frequencyHz,
|
|
||||||
final double distanceFromTxMeters,
|
|
||||||
final double totalPathDistanceMeters) {
|
|
||||||
|
|
||||||
if (frequencyHz <= 0.0 || totalPathDistanceMeters <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double clampedDistanceFromTxMeters = Math.max(
|
|
||||||
0.0,
|
|
||||||
Math.min(distanceFromTxMeters, totalPathDistanceMeters)
|
|
||||||
);
|
|
||||||
|
|
||||||
final double distanceFromPointToRxMeters = totalPathDistanceMeters - clampedDistanceFromTxMeters;
|
|
||||||
|
|
||||||
if (clampedDistanceFromTxMeters <= 0.0 || distanceFromPointToRxMeters <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final double wavelengthMeters = computeWavelengthMeters(frequencyHz);
|
|
||||||
|
|
||||||
return Math.sqrt(
|
|
||||||
wavelengthMeters
|
|
||||||
* clampedDistanceFromTxMeters
|
|
||||||
* distanceFromPointToRxMeters
|
|
||||||
/ totalPathDistanceMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,12 @@ import java.io.FileOutputStream;
|
|||||||
import java.io.IOException;
|
import java.io.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
|
||||||
@@ -65,7 +61,8 @@ public class ApplicationFileUtils {
|
|||||||
|
|
||||||
resourceStream.transferTo(fileOutputStream);
|
resourceStream.transferTo(fileOutputStream);
|
||||||
} catch (IOException ex) {
|
} catch (IOException ex) {
|
||||||
LOGGER.log(Level.SEVERE, "Exception when copying Application file: " + ex.getMessage(), ex);
|
System.err.println("Exception when copying Application file: " + ex.getMessage());
|
||||||
|
ex.printStackTrace(System.err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
package kst4contest.utils;
|
|
||||||
|
|
||||||
import javafx.collections.ObservableListBase;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bounded ObservableList backed by a circular buffer (ring buffer).
|
|
||||||
* <p>
|
|
||||||
* Provides O(1) {@link #addFirst} and {@link #addLast} as well as O(1)
|
|
||||||
* random access via {@link #get}. When the list reaches {@code maxCapacity},
|
|
||||||
* adding a new element at the front automatically evicts the oldest element
|
|
||||||
* at the back — and vice versa.
|
|
||||||
* <p>
|
|
||||||
* This is a drop-in replacement for {@code FXCollections.observableArrayList()}
|
|
||||||
* wherever elements are prepended frequently, e.g. chat message lists.
|
|
||||||
*/
|
|
||||||
public class BoundedDequeObservableList<E> extends ObservableListBase<E> {
|
|
||||||
|
|
||||||
private final int maxCapacity;
|
|
||||||
private final Object[] elements;
|
|
||||||
private int head = 0;
|
|
||||||
private int size = 0;
|
|
||||||
|
|
||||||
public BoundedDequeObservableList(int maxCapacity) {
|
|
||||||
if (maxCapacity <= 0) throw new IllegalArgumentException("maxCapacity must be > 0");
|
|
||||||
this.maxCapacity = maxCapacity;
|
|
||||||
this.elements = new Object[maxCapacity];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── read access ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int size() {
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public E get(int index) {
|
|
||||||
checkIndex(index);
|
|
||||||
return (E) elements[physicalIndex(index)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── O(1) deque operations ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inserts {@code element} at index 0 (newest-first order).
|
|
||||||
* If the list is already at capacity the oldest element (last index) is
|
|
||||||
* removed first — both changes are reported as a single compound change.
|
|
||||||
*/
|
|
||||||
public void addFirst(E element) {
|
|
||||||
beginChange();
|
|
||||||
if (size == maxCapacity) {
|
|
||||||
// evict last element
|
|
||||||
int lastPhysical = physicalIndex(size - 1);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
E evicted = (E) elements[lastPhysical];
|
|
||||||
elements[lastPhysical] = null;
|
|
||||||
size--;
|
|
||||||
nextRemove(size, evicted); // index after decrement == old last index
|
|
||||||
}
|
|
||||||
head = (head - 1 + maxCapacity) % maxCapacity;
|
|
||||||
elements[head] = element;
|
|
||||||
size++;
|
|
||||||
nextAdd(0, 1);
|
|
||||||
endChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends {@code element} at the last index (oldest-first order).
|
|
||||||
* If the list is already at capacity the newest element (index 0) is
|
|
||||||
* removed first.
|
|
||||||
*/
|
|
||||||
public void addLast(E element) {
|
|
||||||
beginChange();
|
|
||||||
if (size == maxCapacity) {
|
|
||||||
// evict first element
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
E evicted = (E) elements[head];
|
|
||||||
elements[head] = null;
|
|
||||||
head = (head + 1) % maxCapacity;
|
|
||||||
size--;
|
|
||||||
nextRemove(0, evicted);
|
|
||||||
}
|
|
||||||
elements[physicalIndex(size)] = element;
|
|
||||||
size++;
|
|
||||||
nextAdd(size - 1, size);
|
|
||||||
endChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── standard List mutation (O(n) — use addFirst/addLast for hot path) ─────
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void add(int index, E element) {
|
|
||||||
if (index == 0) {
|
|
||||||
addFirst(element);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (index == size) {
|
|
||||||
addLast(element);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkIndexForAdd(index);
|
|
||||||
beginChange();
|
|
||||||
if (size == maxCapacity) {
|
|
||||||
int lastPhysical = physicalIndex(size - 1);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
E evicted = (E) elements[lastPhysical];
|
|
||||||
elements[lastPhysical] = null;
|
|
||||||
size--;
|
|
||||||
nextRemove(size, evicted);
|
|
||||||
}
|
|
||||||
// shift elements [index .. size-1] one position towards the end
|
|
||||||
for (int i = size; i > index; i--) {
|
|
||||||
elements[physicalIndex(i)] = elements[physicalIndex(i - 1)];
|
|
||||||
}
|
|
||||||
elements[physicalIndex(index)] = element;
|
|
||||||
size++;
|
|
||||||
nextAdd(index, index + 1);
|
|
||||||
endChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public E remove(int index) {
|
|
||||||
checkIndex(index);
|
|
||||||
beginChange();
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
E removed = (E) elements[physicalIndex(index)];
|
|
||||||
// shift elements [index+1 .. size-1] one position towards the front
|
|
||||||
for (int i = index; i < size - 1; i++) {
|
|
||||||
elements[physicalIndex(i)] = elements[physicalIndex(i + 1)];
|
|
||||||
}
|
|
||||||
elements[physicalIndex(size - 1)] = null;
|
|
||||||
size--;
|
|
||||||
nextRemove(index, removed);
|
|
||||||
endChange();
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public E set(int index, E element) {
|
|
||||||
checkIndex(index);
|
|
||||||
beginChange();
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
E old = (E) elements[physicalIndex(index)];
|
|
||||||
elements[physicalIndex(index)] = element;
|
|
||||||
nextSet(index, old);
|
|
||||||
endChange();
|
|
||||||
return old;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private int physicalIndex(int virtualIndex) {
|
|
||||||
return (head + virtualIndex) % maxCapacity;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkIndex(int index) {
|
|
||||||
if (index < 0 || index >= size)
|
|
||||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkIndexForAdd(int index) {
|
|
||||||
if (index < 0 || index > size)
|
|
||||||
throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,12 +3,7 @@ package kst4contest.view;
|
|||||||
import java.io.File;
|
import java.io.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;
|
||||||
|
|
||||||
@@ -62,16 +57,11 @@ 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();
|
||||||
@@ -121,73 +111,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@@ -777,21 +700,23 @@ 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -813,11 +738,7 @@ 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);
|
||||||
@@ -2352,24 +2273,6 @@ 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>>() {
|
||||||
|
|
||||||
@@ -2377,12 +2280,13 @@ 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() != null) {
|
if (cellDataFeatures.getValue().getMessageText() != null) {
|
||||||
msg.setValue(chatcontroller.formatChatMessageTextForDisplay(cellDataFeatures.getValue()));
|
|
||||||
} else {
|
|
||||||
msg.setValue("");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
msg.setValue(cellDataFeatures.getValue().getMessageText());
|
||||||
|
} else {
|
||||||
|
|
||||||
|
msg.setValue("");// TODO: Prevents a bug of not setting all values as a default
|
||||||
|
}
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3678,57 +3582,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
Menu fileMenu = new Menu("File");
|
Menu fileMenu = new Menu("File");
|
||||||
|
|
||||||
// build "Connect to <configured chat>" label from saved preferences
|
// create menuitems
|
||||||
ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain();
|
|
||||||
String connectLabel = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber());
|
|
||||||
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) {
|
|
||||||
ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond();
|
|
||||||
if (secCat != null) {
|
|
||||||
connectLabel += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
menuItemFileConnect = new MenuItem(connectLabel);
|
|
||||||
menuItemFileConnect.setDisable(false);
|
|
||||||
|
|
||||||
if (chatcontroller.isConnectedAndLoggedIn() || chatcontroller.isConnectedAndNOTLoggedIn()) {
|
|
||||||
menuItemFileConnect.setDisable(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
menuItemFileConnect.setOnAction(event -> {
|
|
||||||
System.out.println("[Info] File menu: Connect clicked, using saved preferences");
|
|
||||||
|
|
||||||
String call = chatcontroller.getChatPreferences().getStn_loginCallSign();
|
|
||||||
String pass = chatcontroller.getChatPreferences().getStn_loginPassword();
|
|
||||||
|
|
||||||
if (call == null || call.isBlank() || pass == null || pass.isBlank()) {
|
|
||||||
Alert alert = new Alert(Alert.AlertType.WARNING);
|
|
||||||
alert.setTitle("Cannot connect");
|
|
||||||
alert.setHeaderText("Login credentials missing");
|
|
||||||
alert.setContentText("Please configure your callsign and password in Settings first.");
|
|
||||||
alert.show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
chatcontroller.execute();
|
|
||||||
|
|
||||||
menuItemFileConnect.setDisable(true);
|
|
||||||
menuItemFileDisconnect.setDisable(false);
|
|
||||||
menuItemOptionsAwayBack.setDisable(false);
|
|
||||||
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
|
||||||
|
|
||||||
chatcontroller.setConnectedAndLoggedIn(true);
|
|
||||||
chatcontroller.setDisconnected(false);
|
|
||||||
|
|
||||||
} catch (InterruptedException | IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
Alert alert = new Alert(Alert.AlertType.ERROR);
|
|
||||||
alert.setTitle("Connection failed");
|
|
||||||
alert.setContentText("Could not connect: " + e.getMessage());
|
|
||||||
alert.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
menuItemFileDisconnect = new MenuItem("Disconnect");
|
menuItemFileDisconnect = new MenuItem("Disconnect");
|
||||||
menuItemFileDisconnect.setDisable(true);
|
menuItemFileDisconnect.setDisable(true);
|
||||||
|
|
||||||
@@ -3741,7 +3595,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
public void handle(ActionEvent event) {
|
public void handle(ActionEvent event) {
|
||||||
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY);
|
chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY);
|
||||||
menuItemFileDisconnect.setDisable(true);
|
menuItemFileDisconnect.setDisable(true);
|
||||||
menuItemFileConnect.setDisable(false);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3754,7 +3607,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
});
|
});
|
||||||
|
|
||||||
// add menu items to menu
|
// add menu items to menu
|
||||||
fileMenu.getItems().add(menuItemFileConnect);
|
|
||||||
fileMenu.getItems().add(menuItemFileDisconnect);
|
fileMenu.getItems().add(menuItemFileDisconnect);
|
||||||
fileMenu.getItems().add(m10);
|
fileMenu.getItems().add(m10);
|
||||||
|
|
||||||
@@ -3868,9 +3720,7 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3889,32 +3739,10 @@ 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");
|
||||||
|
|
||||||
@@ -4182,7 +4010,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
Scene clusterAndQSOMonScene;
|
Scene clusterAndQSOMonScene;
|
||||||
Scene settingsScene;
|
Scene settingsScene;
|
||||||
|
|
||||||
MenuItem menuItemFileConnect;
|
|
||||||
MenuItem menuItemFileDisconnect;
|
MenuItem menuItemFileDisconnect;
|
||||||
MenuItem menuItemOptionsAwayBack;
|
MenuItem menuItemOptionsAwayBack;
|
||||||
|
|
||||||
@@ -4343,15 +4170,10 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
timer_updatePrivatemessageTable.purge();
|
timer_updatePrivatemessageTable.purge();
|
||||||
timer_updatePrivatemessageTable.cancel();
|
timer_updatePrivatemessageTable.cancel();
|
||||||
|
chatcontroller.disconnect("CLOSEALL");
|
||||||
try {
|
|
||||||
chatcontroller.disconnect("CLOSEALL");
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println("[Main.java, Warning:] Exception during disconnect: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform.exit();
|
// Platform.exit();
|
||||||
System.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Queue<Media> musicList = new LinkedList<Media>();
|
private Queue<Media> musicList = new LinkedList<Media>();
|
||||||
@@ -5560,7 +5382,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
FlowPane chatMemberTableFilterQRBHBox = new FlowPane();
|
FlowPane chatMemberTableFilterQRBHBox = new FlowPane();
|
||||||
chatMemberTableFilterQRBHBox.setAlignment(Pos.CENTER_LEFT);
|
chatMemberTableFilterQRBHBox.setAlignment(Pos.CENTER_LEFT);
|
||||||
chatMemberTableFilterQRBHBox.setHgap(2);
|
chatMemberTableFilterQRBHBox.setHgap(2);
|
||||||
chatMemberTableFilterQRBHBox.setPrefWidth(225);
|
chatMemberTableFilterQRBHBox.setPrefWidth(210);
|
||||||
|
|
||||||
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
TextField chatMemberTableFilterMaxQrbTF = new TextField(chatcontroller.getChatPreferences().getStn_maxQRBDefault() + "");
|
||||||
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
|
chatMemberTableFilterMaxQrbTF.setFocusTraversable(false);
|
||||||
@@ -5609,7 +5431,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// HBox chatMemberTableFilterQTFHBox = new HBox();
|
// HBox chatMemberTableFilterQTFHBox = new HBox();
|
||||||
FlowPane chatMemberTableFilterQTFHBox = new FlowPane();
|
FlowPane chatMemberTableFilterQTFHBox = new FlowPane();
|
||||||
chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT);
|
chatMemberTableFilterQTFHBox.setAlignment(Pos.CENTER_LEFT);
|
||||||
chatMemberTableFilterQTFHBox.setPrefWidth(525);
|
chatMemberTableFilterQTFHBox.setPrefWidth(490);
|
||||||
chatMemberTableFilterQTFHBox.setHgap(2);
|
chatMemberTableFilterQTFHBox.setHgap(2);
|
||||||
|
|
||||||
CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:");
|
CheckBox chatMemberTableFilterQtfEnableChkbx = new CheckBox("Show only QTF:");
|
||||||
@@ -6390,7 +6212,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
*
|
*
|
||||||
****************************************************************************/
|
****************************************************************************/
|
||||||
settingsStage = new Stage();
|
settingsStage = new Stage();
|
||||||
settingsStage.setTitle("Change Client Settings");
|
settingsStage.setTitle("Change Client seetings");
|
||||||
|
|
||||||
BorderPane optionsPanel = new BorderPane();
|
BorderPane optionsPanel = new BorderPane();
|
||||||
|
|
||||||
@@ -6451,14 +6273,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
boolean isSecondChatEnabled = this.chatcontroller.getChatPreferences().isLoginToSecondChatEnabled();
|
|
||||||
Label lblNameSecondCat = new Label("Name in Chat 2:");
|
Label lblNameSecondCat = new Label("Name in Chat 2:");
|
||||||
lblNameSecondCat.setVisible(isSecondChatEnabled);
|
lblNameSecondCat.setVisible(false);
|
||||||
lblNameSecondCat.setDisable(!isSecondChatEnabled);
|
|
||||||
TextField txtFldNameInChatSecondCat = new TextField(this.chatcontroller.getChatPreferences().getStn_loginNameSecondCat());
|
TextField txtFldNameInChatSecondCat = new TextField(this.chatcontroller.getChatPreferences().getStn_loginNameSecondCat());
|
||||||
txtFldNameInChatSecondCat.setFocusTraversable(false);
|
txtFldNameInChatSecondCat.setFocusTraversable(false);
|
||||||
txtFldNameInChatSecondCat.setVisible(isSecondChatEnabled);
|
txtFldNameInChatSecondCat.setVisible(false);
|
||||||
txtFldNameInChatSecondCat.setDisable(!isSecondChatEnabled);
|
|
||||||
|
|
||||||
txtFldNameInChatSecondCat.textProperty().addListener(new ChangeListener<String>() {
|
txtFldNameInChatSecondCat.textProperty().addListener(new ChangeListener<String>() {
|
||||||
|
|
||||||
@@ -6578,12 +6397,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
|
|
||||||
CheckBox station_chkBxEnableSecondChat = new CheckBox("2nd Chat: ");
|
CheckBox station_chkBxEnableSecondChat = new CheckBox("2nd Chat: ");
|
||||||
boolean isSecondChatEnabledForCheckbox = chatcontroller.getChatPreferences().isLoginToSecondChatEnabled();
|
station_chkBxEnableSecondChat.setSelected(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||||
station_chkBxEnableSecondChat.setSelected(isSecondChatEnabledForCheckbox);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
stn_choiceBxChatChategorySecond.setDisable(!isSecondChatEnabledForCheckbox);
|
stn_choiceBxChatChategorySecond.setDisable(true);
|
||||||
station_chkBxEnableSecondChat.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
station_chkBxEnableSecondChat.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
||||||
@@ -6613,7 +6431,12 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) {
|
||||||
|
stn_choiceBxChatChategorySecond.setVisible(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||||
|
stn_choiceBxChatChategorySecond.setDisable(!chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||||
|
txtFldNameInChatSecondCat.setVisible(chatcontroller.getChatPreferences().isLoginToSecondChatEnabled());
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
TextField txtFldstn_antennaBeamWidthDeg = new TextField(this.chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + "");
|
TextField txtFldstn_antennaBeamWidthDeg = new TextField(this.chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg() + "");
|
||||||
txtFldstn_antennaBeamWidthDeg.setFocusTraversable(false);
|
txtFldstn_antennaBeamWidthDeg.setFocusTraversable(false);
|
||||||
@@ -6633,36 +6456,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
System.out.println("[Main.java, Info]: Setted the beam: " + txtFldstn_antennaBeamWidthDeg.getText());
|
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);
|
||||||
|
|
||||||
@@ -6681,7 +6477,6 @@ 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();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6709,144 +6504,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
TextField txtFldstn_pathAnalysisOwnAntennaHeightMeters =
|
|
||||||
new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters() + "");
|
|
||||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setFocusTraversable(false);
|
|
||||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setTooltip(new Tooltip(
|
|
||||||
"Own antenna height above local ground in meters.\nThis value is used for path analysis."
|
|
||||||
));
|
|
||||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.textProperty().addListener(new ChangeListener<String>() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void changed(ObservableValue<? extends String> observed, String oldString, String newString) {
|
|
||||||
|
|
||||||
if (newString.equals("")) {
|
|
||||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText("0");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newString.matches("\\d*(\\.\\d*)?")) {
|
|
||||||
txtFldstn_pathAnalysisOwnAntennaHeightMeters.setText(newString.replaceAll("[^\\d.]", ""));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
double value = Double.parseDouble(txtFldstn_pathAnalysisOwnAntennaHeightMeters.getText());
|
|
||||||
chatcontroller.getChatPreferences().setStn_pathAnalysisOwnAntennaHeightMeters(value);
|
|
||||||
refreshStationMapIfVisible();
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
TextField txtFldstn_pathAnalysisDemRootDirectory =
|
|
||||||
new TextField(this.chatcontroller.getChatPreferences().getStn_pathAnalysisDemRootDirectory());
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.setFocusTraversable(false);
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.setTooltip(new Tooltip(
|
|
||||||
"Root directory that contains locally extracted Copernicus GLO-30 DEM tiles.\n" +
|
|
||||||
"The program scans this directory recursively for *_DEM.tif tiles."
|
|
||||||
));
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.focusedProperty().addListener(new ChangeListener<Boolean>() {
|
|
||||||
@Override
|
|
||||||
public void changed(ObservableValue<? extends Boolean> observableValue, Boolean oldValue, Boolean newValue) {
|
|
||||||
if (!newValue) {
|
|
||||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
|
||||||
);
|
|
||||||
refreshStationMapIfVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
OfflineDemImportService offlineDemImportService = new OfflineDemImportService();
|
|
||||||
|
|
||||||
Button btnUseDefaultDemDirectory = new Button("Default");
|
|
||||||
btnUseDefaultDemDirectory.setFocusTraversable(false);
|
|
||||||
btnUseDefaultDemDirectory.setTooltip(new Tooltip(
|
|
||||||
"Creates and uses the default local Copernicus DEM directory below .praktiKST.\n" +
|
|
||||||
"This does not download tiles yet, it only prepares the folder."
|
|
||||||
));
|
|
||||||
btnUseDefaultDemDirectory.setOnAction(event -> {
|
|
||||||
OfflineDemImportService.ImportResult importResult =
|
|
||||||
offlineDemImportService.ensureDefaultCopernicusRootDirectory();
|
|
||||||
|
|
||||||
if (importResult.targetRootDirectory() != null) {
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.setText(
|
|
||||||
importResult.targetRootDirectory().toAbsolutePath().toString()
|
|
||||||
);
|
|
||||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
|
||||||
);
|
|
||||||
refreshStationMapIfVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert alert = new Alert(importResult.success() ? AlertType.INFORMATION : AlertType.WARNING);
|
|
||||||
alert.setTitle("DEM directory");
|
|
||||||
alert.setHeaderText(importResult.success()
|
|
||||||
? "Local Copernicus DEM directory is ready"
|
|
||||||
: "DEM directory could not be prepared");
|
|
||||||
alert.setContentText(importResult.message());
|
|
||||||
alert.show();
|
|
||||||
});
|
|
||||||
|
|
||||||
Button btnImportDemTiles = new Button("Import tiles...");
|
|
||||||
btnImportDemTiles.setFocusTraversable(false);
|
|
||||||
btnImportDemTiles.setTooltip(new Tooltip(
|
|
||||||
"Copies manually selected Copernicus *_DEM.tif files into the configured DEM root directory.\n" +
|
|
||||||
"If no DEM root directory is configured yet, the default .praktiKST/dem/copernicus_glo30 directory is used."
|
|
||||||
));
|
|
||||||
btnImportDemTiles.setOnAction(event -> {
|
|
||||||
FileChooser fileChooser = new FileChooser();
|
|
||||||
fileChooser.setTitle("Import Copernicus GLO-30 DEM tiles");
|
|
||||||
fileChooser.getExtensionFilters().add(
|
|
||||||
new FileChooser.ExtensionFilter("GeoTIFF DEM tiles", "*.tif", "*.tiff")
|
|
||||||
);
|
|
||||||
|
|
||||||
File initialDirectory = resolveInitialDirectoryForDemImport(
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
|
||||||
);
|
|
||||||
if (initialDirectory != null) {
|
|
||||||
fileChooser.setInitialDirectory(initialDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<File> selectedFiles = fileChooser.showOpenMultipleDialog(primaryStage);
|
|
||||||
if (selectedFiles == null || selectedFiles.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OfflineDemImportService.ImportResult importResult =
|
|
||||||
offlineDemImportService.importTiles(
|
|
||||||
selectedFiles,
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (importResult.targetRootDirectory() != null) {
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.setText(
|
|
||||||
importResult.targetRootDirectory().toAbsolutePath().toString()
|
|
||||||
);
|
|
||||||
chatcontroller.getChatPreferences().setStn_pathAnalysisDemRootDirectory(
|
|
||||||
txtFldstn_pathAnalysisDemRootDirectory.getText()
|
|
||||||
);
|
|
||||||
refreshStationMapIfVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
Alert alert = new Alert(
|
|
||||||
importResult.success() && importResult.importedFileCount() > 0
|
|
||||||
? AlertType.INFORMATION
|
|
||||||
: AlertType.WARNING
|
|
||||||
);
|
|
||||||
alert.setTitle("DEM tile import");
|
|
||||||
alert.setHeaderText(
|
|
||||||
importResult.success() && importResult.importedFileCount() > 0
|
|
||||||
? "DEM tiles imported"
|
|
||||||
: "No DEM tiles were imported"
|
|
||||||
);
|
|
||||||
alert.setContentText(importResult.message());
|
|
||||||
alert.show();
|
|
||||||
});
|
|
||||||
|
|
||||||
HBox hbxDemDirectoryActions = new HBox(8.0, btnUseDefaultDemDirectory, btnImportDemTiles);
|
|
||||||
|
|
||||||
Label lbl_station_pstRotatorEnabled = new Label("Enable PSTRotator interface (auto QTF):");
|
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());
|
||||||
@@ -6870,36 +6527,12 @@ 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(new Label("Own antenna height AGL:"), 0, 8);
|
grdPnlStation.add(txtFldstn_maxQRBDefault, 1, 6);
|
||||||
grdPnlStation.add(txtFldstn_pathAnalysisOwnAntennaHeightMeters, 1, 8);
|
grdPnlStation.add(new Label("Default filter QTF:"), 0, 7);
|
||||||
|
grdPnlStation.add(txtFldstn_qtfDefault, 1, 7);
|
||||||
grdPnlStation.add(new Label("DEM root directory:"), 0, 9);
|
grdPnlStation.add(lbl_station_pstRotatorEnabled, 0, 8);
|
||||||
grdPnlStation.add(txtFldstn_pathAnalysisDemRootDirectory, 1, 9);
|
grdPnlStation.add(chkBx_station_pstRotatorEnabled, 1, 8);
|
||||||
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));
|
||||||
@@ -7034,6 +6667,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
grdPnlStation_bands.add(settings_chkbx_QRV3400, 1, 2);
|
grdPnlStation_bands.add(settings_chkbx_QRV3400, 1, 2);
|
||||||
grdPnlStation_bands.add(settings_chkbx_QRV5600, 2, 2);
|
grdPnlStation_bands.add(settings_chkbx_QRV5600, 2, 2);
|
||||||
grdPnlStation_bands.add(settings_chkbx_QRV10G, 0, 3);
|
grdPnlStation_bands.add(settings_chkbx_QRV10G, 0, 3);
|
||||||
|
grdPnlStation_bands.setMaxWidth(555.0);
|
||||||
|
|
||||||
grdPnlStation_bands.setStyle(" -fx-border-color: lightgray;\n" +
|
grdPnlStation_bands.setStyle(" -fx-border-color: lightgray;\n" +
|
||||||
" -fx-vgap: 5;\n" +
|
" -fx-vgap: 5;\n" +
|
||||||
@@ -7051,9 +6685,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// vbxStation.getChildren().add(settings_chkbx_QRV5600);
|
// vbxStation.getChildren().add(settings_chkbx_QRV5600);
|
||||||
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
// vbxStation.getChildren().add(settings_chkbx_QRV10G);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/*************************************************************************************
|
/*************************************************************************************
|
||||||
* Log synch settings Tab
|
* Log synch settings Tab
|
||||||
*************************************************************************************/
|
*************************************************************************************/
|
||||||
@@ -7251,32 +6882,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
grdPnlLog.add(lblUDPByWintest, 0, 8);
|
grdPnlLog.add(lblUDPByWintest, 0, 8);
|
||||||
grdPnlLog.add(txtFldUDPPortforWintest, 1, 8);
|
grdPnlLog.add(txtFldUDPPortforWintest, 1, 8);
|
||||||
|
|
||||||
// --- QRG sync from Win-Test STATUS ---
|
// --- Win-Test SKED push settings ---
|
||||||
Label lblWtQrgSync = new Label("Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)");
|
Label lblEnableSkedPush = new Label("Push SKEDs to Win-Test via UDP (ADDSKED)");
|
||||||
CheckBox chkBxWtQrgSync = new CheckBox();
|
CheckBox chkBxEnableSkedPush = new CheckBox();
|
||||||
chkBxWtQrgSync.setSelected(
|
chkBxEnableSkedPush.setSelected(
|
||||||
this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()
|
this.chatcontroller.getChatPreferences().isLogsynch_wintestNetworkSkedPushEnabled()
|
||||||
);
|
);
|
||||||
chkBxWtQrgSync.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
chkBxEnableSkedPush.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
||||||
chatcontroller.getChatPreferences().setLogsynch_wintestQrgSyncEnabled(newVal);
|
chatcontroller.getChatPreferences().setLogsynch_wintestNetworkSkedPushEnabled(newVal);
|
||||||
System.out.println("[Main.java, Info]: Win-Test QRG sync enabled: " + newVal);
|
System.out.println("[Main.java, Info]: Win-Test SKED push enabled: " + newVal);
|
||||||
boolean anyActive = chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || newVal;
|
|
||||||
if (!anyActive) {
|
|
||||||
txt_ownqrgMainCategory.textProperty().unbind();
|
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)"));
|
|
||||||
} else {
|
|
||||||
txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Label lblWtUsePassQrg = new Label("Use pass frequency from Win-Test STATUS (instead of own QRG)");
|
|
||||||
CheckBox chkBxWtUsePassQrg = new CheckBox();
|
|
||||||
chkBxWtUsePassQrg.setSelected(
|
|
||||||
this.chatcontroller.getChatPreferences().isLogsynch_wintestUsePassQrg()
|
|
||||||
);
|
|
||||||
chkBxWtUsePassQrg.selectedProperty().addListener((obs, oldVal, newVal) -> {
|
|
||||||
chatcontroller.getChatPreferences().setLogsynch_wintestUsePassQrg(newVal);
|
|
||||||
System.out.println("[Main.java, Info]: Win-Test use pass QRG: " + newVal);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Label lblWtStationName = new Label("KST station name in Win-Test network (src of SKED packets)");
|
Label lblWtStationName = new Label("KST station name in Win-Test network (src of SKED packets)");
|
||||||
@@ -7321,8 +6935,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
grdPnlLog.add(lblWtStationName, 0, 9);
|
grdPnlLog.add(lblEnableSkedPush, 0, 9);
|
||||||
grdPnlLog.add(txtFldWtStationName, 1, 9);
|
grdPnlLog.add(chkBxEnableSkedPush, 1, 9);
|
||||||
|
|
||||||
|
grdPnlLog.add(lblWtStationName, 0, 11);
|
||||||
|
grdPnlLog.add(txtFldWtStationName, 1, 11);
|
||||||
|
grdPnlLog.add(lblWtStationFilter, 0, 12);
|
||||||
|
grdPnlLog.add(txtFldWtStationFilter, 1, 12);
|
||||||
|
|
||||||
// Auto-detect subnet broadcast if preference is still the default
|
// Auto-detect subnet broadcast if preference is still the default
|
||||||
String currentBroadcast = this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress();
|
String currentBroadcast = this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress();
|
||||||
@@ -7340,8 +6959,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
// Re-read (may have been auto-detected)
|
// Re-read (may have been auto-detected)
|
||||||
txtFldWtBroadcastAddr.setText(this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
|
txtFldWtBroadcastAddr.setText(this.chatcontroller.getChatPreferences().getLogsynch_wintestNetworkBroadcastAddress());
|
||||||
|
|
||||||
grdPnlLog.add(lblWtBroadcastAddr, 0, 10);
|
grdPnlLog.add(lblWtBroadcastAddr, 0, 13);
|
||||||
grdPnlLog.add(txtFldWtBroadcastAddr, 1, 10);
|
grdPnlLog.add(txtFldWtBroadcastAddr, 1, 13);
|
||||||
|
|
||||||
VBox vbxLog = new VBox();
|
VBox vbxLog = new VBox();
|
||||||
vbxLog.setPadding(new Insets(10, 10, 10, 10));
|
vbxLog.setPadding(new Insets(10, 10, 10, 10));
|
||||||
@@ -7376,45 +6995,51 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
chkBxEnableTRXMsgbyUCX.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
chkBxEnableTRXMsgbyUCX.selectedProperty().addListener(new ChangeListener<Boolean>() {
|
||||||
@Override
|
@Override
|
||||||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) {
|
||||||
chatcontroller.getChatPreferences().setTrxSynch_ucxLogUDPListenerEnabled(newValue);
|
// chk2.setSelected(!newValue);
|
||||||
boolean anyActive = newValue || chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled();
|
if (!newValue) {
|
||||||
if (!anyActive) {
|
chatcontroller.getChatPreferences()
|
||||||
|
.setTrxSynch_ucxLogUDPListenerEnabled(chkBxEnableTRXMsgbyUCX.isSelected());
|
||||||
txt_ownqrgMainCategory.textProperty().unbind();
|
txt_ownqrgMainCategory.textProperty().unbind();
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)"));
|
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by hand (watch prefs!)"));
|
||||||
System.out.println("[Main.java, Info]: MYQRG will be changed only by User input");
|
System.out.println("[Main.java, Info]: MYQRG will be changed only by User input");
|
||||||
|
System.out.println("[Main.java, Info]: setted the trx-frequency updated by ucxlog to: "
|
||||||
|
+ chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled());
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
chatcontroller.getChatPreferences()
|
||||||
|
.setTrxSynch_ucxLogUDPListenerEnabled(chkBxEnableTRXMsgbyUCX.isSelected());
|
||||||
txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
txt_ownqrgMainCategory.textProperty().bind(chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||||
|
System.out.println("[Main.java, Info]: setted the trx-frequency updated by ucxlog to: "
|
||||||
|
+ chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unconditionally add listener to manually sync the textfield input to the button
|
// Thats the default behaviour of the myqrg textfield
|
||||||
// (this listener also fires correctly when the value is updated by the binding)
|
if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled()) {
|
||||||
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
|
|
||||||
MYQRGButton.textProperty().set(newValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
// That's the default behaviour of the myqrg textfield
|
|
||||||
if (this.chatcontroller.getChatPreferences().isTrxSynch_ucxLogUDPListenerEnabled() || this.chatcontroller.getChatPreferences().isLogsynch_wintestQrgSyncEnabled()) {
|
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
txt_ownqrgMainCategory.setTooltip(new Tooltip("Your cq qrg will be updated by the log program (watch prefs!)"));
|
||||||
txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat());
|
txt_ownqrgMainCategory.textProperty().bind(this.chatcontroller.getChatPreferences().getMYQRGFirstCat());// TODO: Bind darf nur
|
||||||
|
// gemacht werden, wenn
|
||||||
|
// ucxlog-Frequenznachrichten
|
||||||
|
// ausgewerttet werden!
|
||||||
|
// System.out.println("[Main.java, Info]: MYQRG will be changed only by UCXListener");
|
||||||
} else {
|
} else {
|
||||||
txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here"));
|
txt_ownqrgMainCategory.setTooltip(new Tooltip("enter your cq qrg here"));
|
||||||
|
// System.out.println("[Main.java, Info]: MYQRG will be changed only by User input");
|
||||||
|
txt_ownqrgMainCategory.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"[Main.java, Info]: MYQRG Text changed from " + oldValue + " to " + newValue + " by hand");
|
||||||
|
MYQRGButton.textProperty().set(newValue);
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
grdPnltrx.add(generateLabeledSeparator(100, "Receive UCXLog TRX info"), 0, 0, 2, 1);
|
grdPnltrx.add(generateLabeledSeparator(100, "Receive UCXLog TRX info"), 0, 0, 2, 1);
|
||||||
grdPnltrx.add(lblEnableTRXMsgbyUCX, 0, 1);
|
grdPnltrx.add(lblEnableTRXMsgbyUCX, 0, 1);
|
||||||
grdPnltrx.add(chkBxEnableTRXMsgbyUCX, 1, 1);
|
grdPnltrx.add(chkBxEnableTRXMsgbyUCX, 1, 1);
|
||||||
|
|
||||||
grdPnltrx.add(generateLabeledSeparator(100, "Win-Test TRX sync"), 0, 2, 2, 1);
|
|
||||||
grdPnltrx.add(lblWtQrgSync, 0, 3);
|
|
||||||
grdPnltrx.add(chkBxWtQrgSync, 1, 3);
|
|
||||||
grdPnltrx.add(lblWtUsePassQrg, 0, 4);
|
|
||||||
grdPnltrx.add(chkBxWtUsePassQrg, 1, 4);
|
|
||||||
grdPnltrx.add(lblWtStationFilter, 0, 5);
|
|
||||||
grdPnltrx.add(txtFldWtStationFilter, 1, 5);
|
|
||||||
|
|
||||||
VBox vbxTRXSynch = new VBox();
|
VBox vbxTRXSynch = new VBox();
|
||||||
vbxTRXSynch.setPadding(new Insets(10, 10, 10, 10));
|
vbxTRXSynch.setPadding(new Insets(10, 10, 10, 10));
|
||||||
vbxTRXSynch.getChildren().addAll(grdPnltrx);
|
vbxTRXSynch.getChildren().addAll(grdPnltrx);
|
||||||
@@ -8499,7 +8124,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
else if (chatcontroller.isConnectedAndLoggedIn()) {
|
else if (chatcontroller.isConnectedAndLoggedIn()) {
|
||||||
btnOptionspnlDisconnectOnly.setDisable(false);
|
btnOptionspnlDisconnectOnly.setDisable(false);
|
||||||
menuItemFileDisconnect.setDisable(false);
|
menuItemFileDisconnect.setDisable(false);
|
||||||
menuItemFileConnect.setDisable(true);
|
|
||||||
menuItemOptionsAwayBack.setDisable(false);
|
menuItemOptionsAwayBack.setDisable(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8523,19 +8147,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
txtFldstn_maxQRBDefault.setDisable(false);
|
txtFldstn_maxQRBDefault.setDisable(false);
|
||||||
menuItemOptionsSetFrequencyAsName.setDisable(true);
|
menuItemOptionsSetFrequencyAsName.setDisable(true);
|
||||||
menuItemOptionsAwayBack.setDisable(true);
|
menuItemOptionsAwayBack.setDisable(true);
|
||||||
menuItemFileConnect.setDisable(false);
|
|
||||||
station_chkBxEnableSecondChat.setDisable(false);
|
station_chkBxEnableSecondChat.setDisable(false);
|
||||||
stn_choiceBxChatChategorySecond.setDisable(false);
|
stn_choiceBxChatChategorySecond.setDisable(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
String btnText = "Connect to " + chatcontroller.getChatPreferences().getLoginChatCategoryMain()
|
btnOptionspnlConnect = new Button("Connect to " + chatcontroller.getChatPreferences().getLoginChatCategoryMain()
|
||||||
.getChatCategoryName(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber());
|
.getChatCategoryName(choiceBxChatChategory.getSelectionModel().getSelectedItem().getCategoryNumber()));
|
||||||
ChatCategory secCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond();
|
|
||||||
if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled() && secCat != null) {
|
|
||||||
btnText += " & " + secCat.getChatCategoryName(secCat.getCategoryNumber());
|
|
||||||
}
|
|
||||||
btnOptionspnlConnect = new Button(btnText);
|
|
||||||
btnOptionspnlConnect.setOnAction(new EventHandler<ActionEvent>() {
|
btnOptionspnlConnect.setOnAction(new EventHandler<ActionEvent>() {
|
||||||
@Override
|
@Override
|
||||||
public void handle(ActionEvent event) {
|
public void handle(ActionEvent event) {
|
||||||
@@ -8567,7 +8185,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
|
|||||||
|
|
||||||
btnOptionspnlDisconnectOnly.setDisable(false);
|
btnOptionspnlDisconnectOnly.setDisable(false);
|
||||||
menuItemFileDisconnect.setDisable(false);
|
menuItemFileDisconnect.setDisable(false);
|
||||||
menuItemFileConnect.setDisable(true);
|
|
||||||
menuItemOptionsAwayBack.setDisable(false);
|
menuItemOptionsAwayBack.setDisable(false);
|
||||||
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
menuItemOptionsSetFrequencyAsName.setDisable(false);
|
||||||
|
|
||||||
@@ -8817,24 +8434,9 @@ 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) {
|
||||||
|
|
||||||
@@ -9094,49 +8696,6 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9171,7 +8730,6 @@ class ActionButtonTableCell<S, T> extends TableCell<S, T> {
|
|||||||
setGraphic(actionButton);
|
setGraphic(actionButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9210,4 +8768,6 @@ class CheckBoxTableCell<S, T> extends TableCell<S, T> {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries terrain providers in order and returns the first usable profile.
|
|
||||||
*/
|
|
||||||
public final class ChainedTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
private final List<TerrainProfileProvider> providers;
|
|
||||||
|
|
||||||
public ChainedTerrainProfileProvider(List<TerrainProfileProvider> providers) {
|
|
||||||
this.providers = providers == null ? List.of() : List.copyOf(providers);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
TerrainProfileData lastResult = TerrainProfileData.empty("No terrain provider");
|
|
||||||
|
|
||||||
for (TerrainProfileProvider provider : providers) {
|
|
||||||
if (provider == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainProfileData currentResult = provider.loadProfile(request);
|
|
||||||
if (currentResult != null) {
|
|
||||||
lastResult = currentResult;
|
|
||||||
if (currentResult.hasUsableProfile()) {
|
|
||||||
return currentResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastResult;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import javax.imageio.ImageReader;
|
|
||||||
import javax.imageio.stream.ImageInputStream;
|
|
||||||
import java.awt.image.Raster;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offline terrain provider for locally extracted Copernicus GLO-30 DGED DEM tiles.
|
|
||||||
*
|
|
||||||
* <p>Assumptions of this reader:
|
|
||||||
* <ul>
|
|
||||||
* <li>local tiles already exist on disk</li>
|
|
||||||
* <li>official DGED GeoTIFF filenames are used</li>
|
|
||||||
* <li>tiles represent 1° x 1° geocells</li>
|
|
||||||
* <li>the raster uses RasterPixelIsPoint semantics</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The active improvement step uses great-circle interpolation for the
|
|
||||||
* sampled path points. This avoids the path distortion of simple linear
|
|
||||||
* latitude/longitude interpolation on longer Europe-wide paths.</p>
|
|
||||||
*/
|
|
||||||
public final class CopernicusGlo30TerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
private static final String SOURCE_NAME = "Copernicus GLO-30 offline DEM";
|
|
||||||
private static final double NODATA_VALUE = -32767.0;
|
|
||||||
private static final int MAX_LOADED_TILES = 8;
|
|
||||||
|
|
||||||
private final Supplier<String> demRootDirectorySupplier;
|
|
||||||
private final OfflineDemManager offlineDemManager;
|
|
||||||
|
|
||||||
private final Map<Path, LoadedTile> loadedTileCache =
|
|
||||||
new LinkedHashMap<>(16, 0.75f, true) {
|
|
||||||
@Override
|
|
||||||
protected boolean removeEldestEntry(Map.Entry<Path, LoadedTile> eldest) {
|
|
||||||
return size() > MAX_LOADED_TILES;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public CopernicusGlo30TerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
|
||||||
OfflineDemManager offlineDemManager) {
|
|
||||||
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
|
|
||||||
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
OfflineDemManager.OfflineDemIndex demIndex =
|
|
||||||
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), DemDataset.COPERNICUS_GLO_30);
|
|
||||||
|
|
||||||
if (!demIndex.usable()) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
int sampleCount = Math.max(2, request.requestedSampleCount());
|
|
||||||
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
|
|
||||||
|
|
||||||
for (int i = 0; i < sampleCount; i++) {
|
|
||||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
|
||||||
|
|
||||||
PathGeometryUtils.GeoPoint interpolatedPoint =
|
|
||||||
PathGeometryUtils.interpolateGreatCirclePoint(
|
|
||||||
request.fromLatitudeDeg(),
|
|
||||||
request.fromLongitudeDeg(),
|
|
||||||
request.toLatitudeDeg(),
|
|
||||||
request.toLongitudeDeg(),
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
double latitudeDeg = interpolatedPoint.latitudeDeg();
|
|
||||||
double longitudeDeg = interpolatedPoint.longitudeDeg();
|
|
||||||
double distanceKm = request.totalDistanceKm() * t;
|
|
||||||
|
|
||||||
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " path interpolation failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
Path tilePath = demIndex.findTilePath(latitudeDeg, longitudeDeg);
|
|
||||||
if (tilePath == null) {
|
|
||||||
return TerrainProfileData.empty(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%s missing required tile(s) near %.5f / %.5f",
|
|
||||||
SOURCE_NAME,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadedTile loadedTile = getOrLoadTile(tilePath);
|
|
||||||
if (loadedTile == null) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " tile read failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
double elevationMeters = sampleElevationMeters(loadedTile, latitudeDeg, longitudeDeg);
|
|
||||||
if (!Double.isFinite(elevationMeters)) {
|
|
||||||
return TerrainProfileData.empty(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%s contains no-data sample(s) near %.5f / %.5f",
|
|
||||||
SOURCE_NAME,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
points.add(new PathProfilePoint(
|
|
||||||
distanceKm,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg,
|
|
||||||
elevationMeters
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainProfileData(points, SOURCE_NAME, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized LoadedTile getOrLoadTile(Path tilePath) {
|
|
||||||
LoadedTile cachedTile = loadedTileCache.get(tilePath);
|
|
||||||
if (cachedTile != null) {
|
|
||||||
return cachedTile;
|
|
||||||
}
|
|
||||||
|
|
||||||
LoadedTile loadedTile = loadTile(tilePath);
|
|
||||||
if (loadedTile != null) {
|
|
||||||
loadedTileCache.put(tilePath, loadedTile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadedTile;
|
|
||||||
}
|
|
||||||
|
|
||||||
private LoadedTile loadTile(Path tilePath) {
|
|
||||||
if (tilePath == null || !Files.isRegularFile(tilePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (ImageInputStream imageInputStream = ImageIO.createImageInputStream(tilePath.toFile())) {
|
|
||||||
if (imageInputStream == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInputStream);
|
|
||||||
if (!readers.hasNext()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImageReader imageReader = readers.next();
|
|
||||||
try {
|
|
||||||
imageReader.setInput(imageInputStream, true, true);
|
|
||||||
Raster raster = imageReader.readRaster(0, null);
|
|
||||||
|
|
||||||
if (raster == null || raster.getWidth() < 2 || raster.getHeight() < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LoadedTile(
|
|
||||||
tilePath,
|
|
||||||
raster,
|
|
||||||
raster.getWidth(),
|
|
||||||
raster.getHeight(),
|
|
||||||
parseSouthDeg(tilePath.getFileName().toString()),
|
|
||||||
parseWestDeg(tilePath.getFileName().toString())
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
imageReader.dispose();
|
|
||||||
}
|
|
||||||
} catch (IOException exception) {
|
|
||||||
System.err.println("[StationMap] Could not read DEM tile " + tilePath + ": " + exception.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int parseSouthDeg(String filename) {
|
|
||||||
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
|
|
||||||
return key == null ? 0 : key.southDeg();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int parseWestDeg(String filename) {
|
|
||||||
ParsedTileKey key = ParsedTileKey.fromFilename(filename);
|
|
||||||
return key == null ? 0 : key.westDeg();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Samples one DEM tile using bilinear interpolation.
|
|
||||||
*
|
|
||||||
* <p>The current reader assumes 1° x 1° geocells and derives raster
|
|
||||||
* coordinates directly from the sample latitude/longitude.</p>
|
|
||||||
*
|
|
||||||
* @param tile loaded DEM tile
|
|
||||||
* @param latitudeDeg sample latitude in degrees
|
|
||||||
* @param longitudeDeg sample longitude in degrees
|
|
||||||
* @return interpolated elevation in meters or NaN
|
|
||||||
*/
|
|
||||||
private double sampleElevationMeters(LoadedTile tile, double latitudeDeg, double longitudeDeg) {
|
|
||||||
if (tile == null) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double x = (longitudeDeg - tile.westDeg()) * (tile.width() - 1);
|
|
||||||
double y = ((tile.southDeg() + 1.0) - latitudeDeg) * (tile.height() - 1);
|
|
||||||
|
|
||||||
x = clamp(x, 0.0, tile.width() - 1.0);
|
|
||||||
y = clamp(y, 0.0, tile.height() - 1.0);
|
|
||||||
|
|
||||||
int x0 = (int) Math.floor(x);
|
|
||||||
int y0 = (int) Math.floor(y);
|
|
||||||
int x1 = Math.min(x0 + 1, tile.width() - 1);
|
|
||||||
int y1 = Math.min(y0 + 1, tile.height() - 1);
|
|
||||||
|
|
||||||
double q11 = readSample(tile.raster(), x0, y0);
|
|
||||||
double q21 = readSample(tile.raster(), x1, y0);
|
|
||||||
double q12 = readSample(tile.raster(), x0, y1);
|
|
||||||
double q22 = readSample(tile.raster(), x1, y1);
|
|
||||||
|
|
||||||
if (!Double.isFinite(q11) || !Double.isFinite(q21) || !Double.isFinite(q12) || !Double.isFinite(q22)) {
|
|
||||||
double nearest = readSample(tile.raster(), (int) Math.round(x), (int) Math.round(y));
|
|
||||||
return Double.isFinite(nearest) ? nearest : Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double dx = x - x0;
|
|
||||||
double dy = y - y0;
|
|
||||||
|
|
||||||
double top = q11 + (q21 - q11) * dx;
|
|
||||||
double bottom = q12 + (q22 - q12) * dx;
|
|
||||||
|
|
||||||
return top + (bottom - top) * dy;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double readSample(Raster raster, int x, int y) {
|
|
||||||
double value = raster.getSampleDouble(x, y, 0);
|
|
||||||
if (!Double.isFinite(value) || value <= NODATA_VALUE) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double clamp(double value, double minValue, double maxValue) {
|
|
||||||
return Math.max(minValue, Math.min(maxValue, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private record LoadedTile(
|
|
||||||
Path path,
|
|
||||||
Raster raster,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
int southDeg,
|
|
||||||
int westDeg
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record ParsedTileKey(int southDeg, int westDeg) {
|
|
||||||
|
|
||||||
private static final java.util.regex.Pattern TILE_PATTERN =
|
|
||||||
java.util.regex.Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
|
|
||||||
|
|
||||||
static ParsedTileKey fromFilename(String filename) {
|
|
||||||
if (filename == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var matcher = TILE_PATTERN.matcher(filename);
|
|
||||||
if (!matcher.matches()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int south = signed(matcher.group(1), matcher.group(2));
|
|
||||||
int west = signed(matcher.group(4), matcher.group(5));
|
|
||||||
|
|
||||||
return new ParsedTileKey(south, west);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int signed(String direction, String degrees) {
|
|
||||||
int value = Integer.parseInt(degrees);
|
|
||||||
if ("S".equalsIgnoreCase(direction) || "W".equalsIgnoreCase(direction)) {
|
|
||||||
return -value;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported offline DEM datasets.
|
|
||||||
*
|
|
||||||
* First real offline target:
|
|
||||||
* Copernicus DEM GLO-30 DGED GeoTIFF.
|
|
||||||
*/
|
|
||||||
public enum DemDataset {
|
|
||||||
|
|
||||||
COPERNICUS_GLO_30("copernicus_glo_30", "Copernicus DEM GLO-30");
|
|
||||||
|
|
||||||
private final String id;
|
|
||||||
private final String displayName;
|
|
||||||
|
|
||||||
DemDataset(String id, String displayName) {
|
|
||||||
this.id = id;
|
|
||||||
this.displayName = displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String id() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String displayName() {
|
|
||||||
return displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static DemDataset fromId(String id) {
|
|
||||||
if (id == null || id.isBlank()) {
|
|
||||||
return COPERNICUS_GLO_30;
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalized = id.trim().toLowerCase(Locale.ROOT);
|
|
||||||
for (DemDataset dataset : values()) {
|
|
||||||
if (dataset.id.equals(normalized)) {
|
|
||||||
return dataset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return COPERNICUS_GLO_30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terrain provider wrapper that tries a primary source first and falls back
|
|
||||||
* to a secondary provider when the primary source returns no usable profile.
|
|
||||||
*/
|
|
||||||
public final class FallbackTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
private final TerrainProfileProvider primaryProvider;
|
|
||||||
private final TerrainProfileProvider fallbackProvider;
|
|
||||||
|
|
||||||
public FallbackTerrainProfileProvider(TerrainProfileProvider primaryProvider,
|
|
||||||
TerrainProfileProvider fallbackProvider) {
|
|
||||||
this.primaryProvider = Objects.requireNonNull(primaryProvider, "primaryProvider");
|
|
||||||
this.fallbackProvider = Objects.requireNonNull(fallbackProvider, "fallbackProvider");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
TerrainProfileData primaryData = safeLoad(primaryProvider, request);
|
|
||||||
if (primaryData.hasUsableProfile()) {
|
|
||||||
return primaryData;
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainProfileData fallbackData = safeLoad(fallbackProvider, request);
|
|
||||||
if (fallbackData.hasUsableProfile()) {
|
|
||||||
return fallbackData;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackData.profilePoints().isEmpty() ? primaryData : fallbackData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerrainProfileData safeLoad(TerrainProfileProvider provider, TerrainProfileRequest request) {
|
|
||||||
try {
|
|
||||||
TerrainProfileData result = provider.loadProfile(request);
|
|
||||||
return result == null
|
|
||||||
? TerrainProfileData.empty(provider.getClass().getSimpleName())
|
|
||||||
: result;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
System.err.println("[StationMap] Terrain provider failed: "
|
|
||||||
+ provider.getClass().getSimpleName()
|
|
||||||
+ " -> " + exception.getMessage());
|
|
||||||
return TerrainProfileData.empty(provider.getClass().getSimpleName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,228 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import static kst4contest.view.map.MaidenheadGridUtils.GridPrecision;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chooses a grid rendering strategy for the current viewport.
|
|
||||||
*
|
|
||||||
* Goals:
|
|
||||||
* - keep the current zoom based progression as a baseline
|
|
||||||
* - avoid overly dense grid rendering on unlucky viewport sizes
|
|
||||||
* - make label visibility depend on actual on-screen cell size
|
|
||||||
* - expose row/column strides so labels form a stable raster pattern
|
|
||||||
*/
|
|
||||||
public final class MaidenheadGridRenderPlanner {
|
|
||||||
|
|
||||||
private static final int MAX_SUBSQUARE_CELLS = 4500;
|
|
||||||
private static final int MAX_SQUARE_CELLS = 2500;
|
|
||||||
|
|
||||||
private static final double MIN_SUBSQUARE_CELL_WIDTH_PX = 8.0;
|
|
||||||
private static final double MIN_SUBSQUARE_CELL_HEIGHT_PX = 7.0;
|
|
||||||
private static final double MIN_SQUARE_CELL_WIDTH_PX = 10.0;
|
|
||||||
private static final double MIN_SQUARE_CELL_HEIGHT_PX = 9.0;
|
|
||||||
|
|
||||||
private MaidenheadGridRenderPlanner() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static GridRenderPlan createPlan(int leafletZoom,
|
|
||||||
double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
double viewportWidthPx,
|
|
||||||
double viewportHeightPx) {
|
|
||||||
|
|
||||||
double safeViewportWidthPx = Math.max(1.0, viewportWidthPx);
|
|
||||||
double safeViewportHeightPx = Math.max(1.0, viewportHeightPx);
|
|
||||||
|
|
||||||
GridPrecision requestedPrecision = MaidenheadGridUtils.precisionForZoom(leafletZoom);
|
|
||||||
GridPrecision effectivePrecision = chooseEffectivePrecision(
|
|
||||||
requestedPrecision,
|
|
||||||
southLat,
|
|
||||||
westLon,
|
|
||||||
northLat,
|
|
||||||
eastLon,
|
|
||||||
safeViewportWidthPx,
|
|
||||||
safeViewportHeightPx
|
|
||||||
);
|
|
||||||
|
|
||||||
double estimatedCellWidthPx = estimateCellWidthPx(effectivePrecision, westLon, eastLon, safeViewportWidthPx);
|
|
||||||
double estimatedCellHeightPx = estimateCellHeightPx(effectivePrecision, southLat, northLat, safeViewportHeightPx);
|
|
||||||
|
|
||||||
boolean showLabels = shouldShowLabels(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx);
|
|
||||||
int labelColumnStride = showLabels ? computeStride(estimatedCellWidthPx, desiredLabelWidthPx(effectivePrecision)) : Integer.MAX_VALUE;
|
|
||||||
int labelRowStride = showLabels ? computeStride(estimatedCellHeightPx, desiredLabelHeightPx(effectivePrecision)) : Integer.MAX_VALUE;
|
|
||||||
double labelFontSizePx = estimateLabelFontSizePx(effectivePrecision, estimatedCellWidthPx, estimatedCellHeightPx);
|
|
||||||
|
|
||||||
return new GridRenderPlan(
|
|
||||||
effectivePrecision,
|
|
||||||
showLabels,
|
|
||||||
labelRowStride,
|
|
||||||
labelColumnStride,
|
|
||||||
estimatedCellWidthPx,
|
|
||||||
estimatedCellHeightPx,
|
|
||||||
labelFontSizePx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static GridPrecision chooseEffectivePrecision(GridPrecision requestedPrecision,
|
|
||||||
double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
double viewportWidthPx,
|
|
||||||
double viewportHeightPx) {
|
|
||||||
|
|
||||||
GridPrecision effectivePrecision = requestedPrecision;
|
|
||||||
|
|
||||||
if (effectivePrecision == GridPrecision.SUBSQUARE_6
|
|
||||||
&& !canRenderSubsquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) {
|
|
||||||
effectivePrecision = GridPrecision.SQUARE_4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectivePrecision == GridPrecision.SQUARE_4
|
|
||||||
&& !canRenderSquareGrid(southLat, westLon, northLat, eastLon, viewportWidthPx, viewportHeightPx)) {
|
|
||||||
effectivePrecision = GridPrecision.FIELD_2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return effectivePrecision;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean canRenderSubsquareGrid(double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
double viewportWidthPx,
|
|
||||||
double viewportHeightPx) {
|
|
||||||
|
|
||||||
double cellWidthPx = estimateCellWidthPx(GridPrecision.SUBSQUARE_6, westLon, eastLon, viewportWidthPx);
|
|
||||||
double cellHeightPx = estimateCellHeightPx(GridPrecision.SUBSQUARE_6, southLat, northLat, viewportHeightPx);
|
|
||||||
int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SUBSQUARE_6, southLat, westLon, northLat, eastLon);
|
|
||||||
|
|
||||||
return cellWidthPx >= MIN_SUBSQUARE_CELL_WIDTH_PX
|
|
||||||
&& cellHeightPx >= MIN_SUBSQUARE_CELL_HEIGHT_PX
|
|
||||||
&& estimatedCellCount <= MAX_SUBSQUARE_CELLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean canRenderSquareGrid(double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
double viewportWidthPx,
|
|
||||||
double viewportHeightPx) {
|
|
||||||
|
|
||||||
double cellWidthPx = estimateCellWidthPx(GridPrecision.SQUARE_4, westLon, eastLon, viewportWidthPx);
|
|
||||||
double cellHeightPx = estimateCellHeightPx(GridPrecision.SQUARE_4, southLat, northLat, viewportHeightPx);
|
|
||||||
int estimatedCellCount = estimateVisibleCellCount(GridPrecision.SQUARE_4, southLat, westLon, northLat, eastLon);
|
|
||||||
|
|
||||||
return cellWidthPx >= MIN_SQUARE_CELL_WIDTH_PX
|
|
||||||
&& cellHeightPx >= MIN_SQUARE_CELL_HEIGHT_PX
|
|
||||||
&& estimatedCellCount <= MAX_SQUARE_CELLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int estimateVisibleCellCount(GridPrecision precision,
|
|
||||||
double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon) {
|
|
||||||
|
|
||||||
double lonSpanDeg = Math.max(1e-6, eastLon - westLon);
|
|
||||||
double latSpanDeg = Math.max(1e-6, northLat - southLat);
|
|
||||||
|
|
||||||
int columns = Math.max(1, (int) Math.ceil(lonSpanDeg / precision.cellWidthDeg()));
|
|
||||||
int rows = Math.max(1, (int) Math.ceil(latSpanDeg / precision.cellHeightDeg()));
|
|
||||||
return columns * rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double estimateCellWidthPx(GridPrecision precision,
|
|
||||||
double westLon,
|
|
||||||
double eastLon,
|
|
||||||
double viewportWidthPx) {
|
|
||||||
|
|
||||||
double lonSpanDeg = Math.max(1e-6, eastLon - westLon);
|
|
||||||
double visibleColumns = Math.max(1.0, lonSpanDeg / precision.cellWidthDeg());
|
|
||||||
return viewportWidthPx / visibleColumns;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double estimateCellHeightPx(GridPrecision precision,
|
|
||||||
double southLat,
|
|
||||||
double northLat,
|
|
||||||
double viewportHeightPx) {
|
|
||||||
|
|
||||||
double latSpanDeg = Math.max(1e-6, northLat - southLat);
|
|
||||||
double visibleRows = Math.max(1.0, latSpanDeg / precision.cellHeightDeg());
|
|
||||||
return viewportHeightPx / visibleRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean shouldShowLabels(GridPrecision precision, double cellWidthPx, double cellHeightPx) {
|
|
||||||
return switch (precision) {
|
|
||||||
case FIELD_2 -> cellWidthPx >= 28.0 && cellHeightPx >= 14.0;
|
|
||||||
case SQUARE_4 -> cellWidthPx >= 22.0 && cellHeightPx >= 14.0;
|
|
||||||
case SUBSQUARE_6 -> cellWidthPx >= 18.0 && cellHeightPx >= 11.0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double desiredLabelWidthPx(GridPrecision precision) {
|
|
||||||
return switch (precision) {
|
|
||||||
case FIELD_2 -> 30.0;
|
|
||||||
case SQUARE_4 -> 44.0;
|
|
||||||
case SUBSQUARE_6 -> 56.0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double desiredLabelHeightPx(GridPrecision precision) {
|
|
||||||
return switch (precision) {
|
|
||||||
case FIELD_2 -> 18.0;
|
|
||||||
case SQUARE_4 -> 18.0;
|
|
||||||
case SUBSQUARE_6 -> 16.0;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double estimateLabelFontSizePx(GridPrecision precision,
|
|
||||||
double cellWidthPx,
|
|
||||||
double cellHeightPx) {
|
|
||||||
|
|
||||||
double minFontSizePx = switch (precision) {
|
|
||||||
case FIELD_2 -> 14.0;
|
|
||||||
case SQUARE_4 -> 11.5;
|
|
||||||
case SUBSQUARE_6 -> 10.5;
|
|
||||||
};
|
|
||||||
|
|
||||||
double maxFontSizePx = switch (precision) {
|
|
||||||
case FIELD_2 -> 18.0;
|
|
||||||
case SQUARE_4 -> 15.0;
|
|
||||||
case SUBSQUARE_6 -> 13.5;
|
|
||||||
};
|
|
||||||
|
|
||||||
double estimatedFontSizePx = Math.min(cellHeightPx * 0.55, cellWidthPx * 0.24);
|
|
||||||
return clamp(estimatedFontSizePx, minFontSizePx, maxFontSizePx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int computeStride(double cellSizePx, double desiredLabelSizePx) {
|
|
||||||
return Math.max(1, (int) Math.ceil(desiredLabelSizePx / Math.max(1.0, cellSizePx)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double clamp(double value, double min, double max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
public record GridRenderPlan(
|
|
||||||
GridPrecision precision,
|
|
||||||
boolean showLabels,
|
|
||||||
int labelRowStride,
|
|
||||||
int labelColumnStride,
|
|
||||||
double estimatedCellWidthPx,
|
|
||||||
double estimatedCellHeightPx,
|
|
||||||
double labelFontSizePx
|
|
||||||
) {
|
|
||||||
|
|
||||||
public boolean shouldShowLabel(MaidenheadGridUtils.GridCell cell) {
|
|
||||||
if (!showLabels || cell == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (cell.rowIndex() % labelRowStride) == 0
|
|
||||||
&& (cell.columnIndex() % labelColumnStride) == 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility methods for generating visible Maidenhead grid rectangles.
|
|
||||||
*
|
|
||||||
* Supported levels:
|
|
||||||
* - 2 characters (fields)
|
|
||||||
* - 4 characters (squares)
|
|
||||||
* - 6 characters (subsquares)
|
|
||||||
*/
|
|
||||||
public final class MaidenheadGridUtils {
|
|
||||||
|
|
||||||
private static final double EPSILON = 1e-9;
|
|
||||||
|
|
||||||
private MaidenheadGridUtils() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum GridPrecision {
|
|
||||||
FIELD_2(2, 20.0, 10.0),
|
|
||||||
SQUARE_4(4, 2.0, 1.0),
|
|
||||||
SUBSQUARE_6(6, 5.0 / 60.0, 2.5 / 60.0);
|
|
||||||
|
|
||||||
private final int locatorLength;
|
|
||||||
private final double cellWidthDeg;
|
|
||||||
private final double cellHeightDeg;
|
|
||||||
|
|
||||||
GridPrecision(int locatorLength, double cellWidthDeg, double cellHeightDeg) {
|
|
||||||
this.locatorLength = locatorLength;
|
|
||||||
this.cellWidthDeg = cellWidthDeg;
|
|
||||||
this.cellHeightDeg = cellHeightDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int locatorLength() {
|
|
||||||
return locatorLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double cellWidthDeg() {
|
|
||||||
return cellWidthDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double cellHeightDeg() {
|
|
||||||
return cellHeightDeg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record GridCell(
|
|
||||||
String locatorLabel,
|
|
||||||
double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
int rowIndex,
|
|
||||||
int columnIndex
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public static GridPrecision precisionForZoom(int leafletZoom) {
|
|
||||||
if (leafletZoom <= 5) {
|
|
||||||
return GridPrecision.FIELD_2;
|
|
||||||
}
|
|
||||||
if (leafletZoom <= 7) {
|
|
||||||
return GridPrecision.SQUARE_4;
|
|
||||||
}
|
|
||||||
return GridPrecision.SUBSQUARE_6;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<GridCell> buildVisibleCells(double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
int leafletZoom) {
|
|
||||||
return buildVisibleCells(southLat, westLon, northLat, eastLon, precisionForZoom(leafletZoom));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<GridCell> buildVisibleCells(double southLat,
|
|
||||||
double westLon,
|
|
||||||
double northLat,
|
|
||||||
double eastLon,
|
|
||||||
GridPrecision precision) {
|
|
||||||
|
|
||||||
if (westLon > eastLon) {
|
|
||||||
// Anti-meridian handling is not needed for Europe in this project stage.
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
double clampedSouth = clamp(southLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
|
||||||
double clampedNorth = clamp(northLat, -90.0 + EPSILON, 90.0 - EPSILON);
|
|
||||||
double clampedWest = clamp(westLon, -180.0 + EPSILON, 180.0 - EPSILON);
|
|
||||||
double clampedEast = clamp(eastLon, -180.0 + EPSILON, 180.0 - EPSILON);
|
|
||||||
|
|
||||||
return switch (precision) {
|
|
||||||
case FIELD_2 -> build2CharFields(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
|
||||||
case SQUARE_4 -> build4CharSquares(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
|
||||||
case SUBSQUARE_6 -> build6CharSubsquares(clampedSouth, clampedWest, clampedNorth, clampedEast);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<GridCell> build2CharFields(double southLat, double westLon, double northLat, double eastLon) {
|
|
||||||
List<GridCell> cells = new ArrayList<>();
|
|
||||||
|
|
||||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 20.0), 0, 17);
|
|
||||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 20.0), 0, 17);
|
|
||||||
|
|
||||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 10.0), 0, 17);
|
|
||||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 10.0), 0, 17);
|
|
||||||
|
|
||||||
for (int lonIndex = lonStart; lonIndex <= lonEnd; lonIndex++) {
|
|
||||||
for (int latIndex = latStart; latIndex <= latEnd; latIndex++) {
|
|
||||||
double west = -180.0 + lonIndex * 20.0;
|
|
||||||
double east = west + 20.0;
|
|
||||||
double south = -90.0 + latIndex * 10.0;
|
|
||||||
double north = south + 10.0;
|
|
||||||
|
|
||||||
String label = "" + (char) ('A' + lonIndex) + (char) ('A' + latIndex);
|
|
||||||
cells.add(new GridCell(label, south, west, north, east, latIndex, lonIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<GridCell> build4CharSquares(double southLat, double westLon, double northLat, double eastLon) {
|
|
||||||
List<GridCell> cells = new ArrayList<>();
|
|
||||||
|
|
||||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / 2.0), 0, 179);
|
|
||||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / 2.0), 0, 179);
|
|
||||||
|
|
||||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / 1.0), 0, 179);
|
|
||||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / 1.0), 0, 179);
|
|
||||||
|
|
||||||
for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) {
|
|
||||||
for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) {
|
|
||||||
|
|
||||||
int lonFieldIndex = lonTotalIndex / 10;
|
|
||||||
int lonSquareIndex = lonTotalIndex % 10;
|
|
||||||
|
|
||||||
int latFieldIndex = latTotalIndex / 10;
|
|
||||||
int latSquareIndex = latTotalIndex % 10;
|
|
||||||
|
|
||||||
double west = -180.0 + lonTotalIndex * 2.0;
|
|
||||||
double east = west + 2.0;
|
|
||||||
double south = -90.0 + latTotalIndex;
|
|
||||||
double north = south + 1.0;
|
|
||||||
|
|
||||||
String label = String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"%c%c%d%d",
|
|
||||||
(char) ('A' + lonFieldIndex),
|
|
||||||
(char) ('A' + latFieldIndex),
|
|
||||||
lonSquareIndex,
|
|
||||||
latSquareIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<GridCell> build6CharSubsquares(double southLat, double westLon, double northLat, double eastLon) {
|
|
||||||
List<GridCell> cells = new ArrayList<>();
|
|
||||||
|
|
||||||
double lonStepDeg = 5.0 / 60.0;
|
|
||||||
double latStepDeg = 2.5 / 60.0;
|
|
||||||
|
|
||||||
int lonStart = clampIndex((int) Math.floor((westLon + 180.0) / lonStepDeg), 0, 4319);
|
|
||||||
int lonEnd = clampIndex((int) Math.floor((eastLon + 180.0 - EPSILON) / lonStepDeg), 0, 4319);
|
|
||||||
|
|
||||||
int latStart = clampIndex((int) Math.floor((southLat + 90.0) / latStepDeg), 0, 4319);
|
|
||||||
int latEnd = clampIndex((int) Math.floor((northLat + 90.0 - EPSILON) / latStepDeg), 0, 4319);
|
|
||||||
|
|
||||||
for (int lonTotalIndex = lonStart; lonTotalIndex <= lonEnd; lonTotalIndex++) {
|
|
||||||
for (int latTotalIndex = latStart; latTotalIndex <= latEnd; latTotalIndex++) {
|
|
||||||
|
|
||||||
int lonFieldIndex = lonTotalIndex / 240;
|
|
||||||
int lonWithinField = lonTotalIndex % 240;
|
|
||||||
int lonSquareIndex = lonWithinField / 24;
|
|
||||||
int lonSubsquareIndex = lonWithinField % 24;
|
|
||||||
|
|
||||||
int latFieldIndex = latTotalIndex / 240;
|
|
||||||
int latWithinField = latTotalIndex % 240;
|
|
||||||
int latSquareIndex = latWithinField / 24;
|
|
||||||
int latSubsquareIndex = latWithinField % 24;
|
|
||||||
|
|
||||||
double west = -180.0 + lonTotalIndex * lonStepDeg;
|
|
||||||
double east = west + lonStepDeg;
|
|
||||||
double south = -90.0 + latTotalIndex * latStepDeg;
|
|
||||||
double north = south + latStepDeg;
|
|
||||||
|
|
||||||
String label = String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"%c%c%d%d%c%c",
|
|
||||||
(char) ('A' + lonFieldIndex),
|
|
||||||
(char) ('A' + latFieldIndex),
|
|
||||||
lonSquareIndex,
|
|
||||||
latSquareIndex,
|
|
||||||
(char) ('a' + lonSubsquareIndex),
|
|
||||||
(char) ('a' + latSubsquareIndex)
|
|
||||||
);
|
|
||||||
|
|
||||||
cells.add(new GridCell(label, south, west, north, east, latTotalIndex, lonTotalIndex));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int clampIndex(int value, int min, int max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double clamp(double value, double min, double max) {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.StringJoiner;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable map render model.
|
|
||||||
*
|
|
||||||
* One snapshot represents exactly one visible marker on the map,
|
|
||||||
* aggregated by callSignRaw.
|
|
||||||
*/
|
|
||||||
public record MapCallsignRawSnapshot(
|
|
||||||
String callSignRaw,
|
|
||||||
String displayCallSign,
|
|
||||||
String locator6,
|
|
||||||
double latitudeDeg,
|
|
||||||
double longitudeDeg,
|
|
||||||
String bandSummary,
|
|
||||||
Map<String, String> lastKnownFrequenciesByBand,
|
|
||||||
boolean warningToMyDirection,
|
|
||||||
boolean worked,
|
|
||||||
boolean selected,
|
|
||||||
double qrbKm,
|
|
||||||
double qtfDeg,
|
|
||||||
int reachableAirplanes,
|
|
||||||
long lastActivityEpochMs
|
|
||||||
) {
|
|
||||||
|
|
||||||
public MapCallsignRawSnapshot {
|
|
||||||
callSignRaw = normalizeUpper(callSignRaw);
|
|
||||||
displayCallSign = (displayCallSign == null || displayCallSign.isBlank())
|
|
||||||
? callSignRaw
|
|
||||||
: displayCallSign.trim();
|
|
||||||
|
|
||||||
locator6 = locator6 == null ? "" : locator6.trim().toUpperCase(Locale.ROOT);
|
|
||||||
bandSummary = bandSummary == null ? "" : bandSummary.trim();
|
|
||||||
|
|
||||||
LinkedHashMap<String, String> orderedFrequencies = new LinkedHashMap<>();
|
|
||||||
if (lastKnownFrequenciesByBand != null) {
|
|
||||||
orderedFrequencies.putAll(lastKnownFrequenciesByBand);
|
|
||||||
}
|
|
||||||
lastKnownFrequenciesByBand = Collections.unmodifiableMap(orderedFrequencies);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsablePosition() {
|
|
||||||
return !locator6.isBlank()
|
|
||||||
&& Double.isFinite(latitudeDeg)
|
|
||||||
&& Double.isFinite(longitudeDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String markerLabel() {
|
|
||||||
return bandSummary.isBlank()
|
|
||||||
? displayCallSign
|
|
||||||
: displayCallSign + " (" + bandSummary + ")";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String detailFrequencyText() {
|
|
||||||
if (lastKnownFrequenciesByBand.isEmpty()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
StringJoiner joiner = new StringJoiner("\n");
|
|
||||||
for (Map.Entry<String, String> entry : lastKnownFrequenciesByBand.entrySet()) {
|
|
||||||
joiner.add(entry.getKey() + ": " + entry.getValue());
|
|
||||||
}
|
|
||||||
return joiner.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.locatorUtils.Location;
|
|
||||||
import kst4contest.model.AirPlaneReflectionInfo;
|
|
||||||
import kst4contest.model.Band;
|
|
||||||
import kst4contest.model.ChatMember;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.EnumMap;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds immutable map snapshots from the currently visible chat members.
|
|
||||||
*
|
|
||||||
* The aggregation key is callSignRaw because the map shall show exactly one marker
|
|
||||||
* per base callsign, even if the same station exists in multiple chat categories.
|
|
||||||
*/
|
|
||||||
public final class MapCallsignRawSnapshotBuilder {
|
|
||||||
|
|
||||||
public List<MapCallsignRawSnapshot> buildSnapshots(Collection<ChatMember> visibleChatMembers,
|
|
||||||
ChatMember selectedChatMember) {
|
|
||||||
|
|
||||||
if (visibleChatMembers == null || visibleChatMembers.isEmpty()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
String selectedCallsignRaw = normalizeCallsignRaw(
|
|
||||||
selectedChatMember == null ? null : selectedChatMember.getCallSignRaw()
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, List<ChatMember>> groupedByCallsignRaw = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
for (ChatMember chatMember : visibleChatMembers) {
|
|
||||||
if (chatMember == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String callSignRaw = normalizeCallsignRaw(chatMember.getCallSignRaw());
|
|
||||||
if (callSignRaw.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
groupedByCallsignRaw
|
|
||||||
.computeIfAbsent(callSignRaw, ignored -> new ArrayList<>())
|
|
||||||
.add(chatMember);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MapCallsignRawSnapshot> snapshots = new ArrayList<>(groupedByCallsignRaw.size());
|
|
||||||
|
|
||||||
for (Map.Entry<String, List<ChatMember>> entry : groupedByCallsignRaw.entrySet()) {
|
|
||||||
String callSignRaw = entry.getKey();
|
|
||||||
List<ChatMember> variants = entry.getValue();
|
|
||||||
|
|
||||||
ChatMember representative = chooseRepresentative(variants);
|
|
||||||
if (representative == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String locator6 = findBestLocator6(variants);
|
|
||||||
if (locator6.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Location location = new Location(locator6);
|
|
||||||
|
|
||||||
LinkedHashMap<String, String> frequenciesByBand = collectLastKnownFrequenciesByBand(variants);
|
|
||||||
String bandSummary = String.join(", ", frequenciesByBand.keySet());
|
|
||||||
|
|
||||||
boolean warningToMyDirection = variants.stream().anyMatch(ChatMember::isInAngleAndRange);
|
|
||||||
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
|
|
||||||
boolean selected = callSignRaw.equals(selectedCallsignRaw);
|
|
||||||
|
|
||||||
double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0;
|
|
||||||
double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0;
|
|
||||||
|
|
||||||
int reachableAirplanes = variants.stream()
|
|
||||||
.map(ChatMember::getAirPlaneReflectInfo)
|
|
||||||
.mapToInt(this::extractReachableAirplanes)
|
|
||||||
.max()
|
|
||||||
.orElse(0);
|
|
||||||
|
|
||||||
snapshots.add(new MapCallsignRawSnapshot(
|
|
||||||
callSignRaw,
|
|
||||||
bestDisplayCallsign(variants, representative),
|
|
||||||
locator6,
|
|
||||||
location.getLatitude().toDegrees(),
|
|
||||||
location.getLongitude().toDegrees(),
|
|
||||||
bandSummary,
|
|
||||||
frequenciesByBand,
|
|
||||||
warningToMyDirection,
|
|
||||||
worked,
|
|
||||||
selected,
|
|
||||||
qrbKm,
|
|
||||||
qtfDeg,
|
|
||||||
reachableAirplanes,
|
|
||||||
representative.getActivityTimeLastInEpoch()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots.sort(Comparator.comparing(
|
|
||||||
MapCallsignRawSnapshot::displayCallSign,
|
|
||||||
String.CASE_INSENSITIVE_ORDER
|
|
||||||
));
|
|
||||||
|
|
||||||
return List.copyOf(snapshots);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ChatMember chooseRepresentative(List<ChatMember> variants) {
|
|
||||||
return variants.stream()
|
|
||||||
.filter(chatMember -> chatMember != null && normalizeLocator6(chatMember.getQra()).length() == 6)
|
|
||||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
|
||||||
.orElseGet(() -> variants.stream()
|
|
||||||
.filter(chatMember -> chatMember != null)
|
|
||||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
|
||||||
.orElse(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String bestDisplayCallsign(List<ChatMember> variants, ChatMember representative) {
|
|
||||||
if (representative != null && representative.getCallSign() != null && !representative.getCallSign().isBlank()) {
|
|
||||||
return representative.getCallSign().trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (ChatMember variant : variants) {
|
|
||||||
if (variant != null && variant.getCallSign() != null && !variant.getCallSign().isBlank()) {
|
|
||||||
return variant.getCallSign().trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return representative == null ? "" : normalizeCallsignRaw(representative.getCallSignRaw());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String findBestLocator6(List<ChatMember> variants) {
|
|
||||||
return variants.stream()
|
|
||||||
.sorted(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch).reversed())
|
|
||||||
.map(ChatMember::getQra)
|
|
||||||
.map(this::normalizeLocator6)
|
|
||||||
.filter(locator -> locator.length() == 6)
|
|
||||||
.findFirst()
|
|
||||||
.orElse("");
|
|
||||||
}
|
|
||||||
|
|
||||||
private LinkedHashMap<String, String> collectLastKnownFrequenciesByBand(List<ChatMember> variants) {
|
|
||||||
|
|
||||||
Map<Band, FrequencyCandidate> latestByBand = new EnumMap<>(Band.class);
|
|
||||||
|
|
||||||
for (ChatMember variant : variants) {
|
|
||||||
if (variant == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Map.Entry<Band, ChatMember.ActiveFrequencyInfo> bandEntry : variant.getKnownActiveBands().entrySet()) {
|
|
||||||
Band band = bandEntry.getKey();
|
|
||||||
ChatMember.ActiveFrequencyInfo activeFrequencyInfo = bandEntry.getValue();
|
|
||||||
|
|
||||||
if (band == null || activeFrequencyInfo == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
FrequencyCandidate previous = latestByBand.get(band);
|
|
||||||
if (previous == null || activeFrequencyInfo.timestampEpoch > previous.timestampEpochMs()) {
|
|
||||||
latestByBand.put(band, new FrequencyCandidate(
|
|
||||||
band,
|
|
||||||
formatFrequency(activeFrequencyInfo.frequency),
|
|
||||||
activeFrequencyInfo.timestampEpoch
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addFallbackCurrentFrequencyIfUseful(variant, latestByBand);
|
|
||||||
}
|
|
||||||
|
|
||||||
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
|
||||||
latestByBand.entrySet().stream()
|
|
||||||
.sorted(Map.Entry.comparingByKey())
|
|
||||||
.forEach(entry -> ordered.put(toBandDisplayLabel(entry.getKey()), entry.getValue().formattedFrequency()));
|
|
||||||
|
|
||||||
return ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback for stations where the current displayed QRG exists but the
|
|
||||||
* knownActiveBands history has not yet been filled.
|
|
||||||
*
|
|
||||||
* This parsing is intentionally tolerant so strings like "144.300 MHz"
|
|
||||||
* can still be used.
|
|
||||||
*/
|
|
||||||
private void addFallbackCurrentFrequencyIfUseful(ChatMember variant, Map<Band, FrequencyCandidate> latestByBand) {
|
|
||||||
if (variant == null || variant.getFrequency() == null || variant.getFrequency().getValue() == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String rawFrequency = variant.getFrequency().getValue().trim();
|
|
||||||
if (rawFrequency.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
double parsedFrequencyMHz = PathGeometryUtils.tryParseFrequencyMHz(rawFrequency);
|
|
||||||
if (!Double.isFinite(parsedFrequencyMHz) || parsedFrequencyMHz <= 0.0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Band detectedBand = Band.fromFrequency(parsedFrequencyMHz);
|
|
||||||
if (detectedBand == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
FrequencyCandidate previous = latestByBand.get(detectedBand);
|
|
||||||
if (previous != null && previous.timestampEpochMs() >= variant.getActivityTimeLastInEpoch()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
latestByBand.put(detectedBand, new FrequencyCandidate(
|
|
||||||
detectedBand,
|
|
||||||
formatFrequency(parsedFrequencyMHz),
|
|
||||||
variant.getActivityTimeLastInEpoch()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isWorkedAtAnyBand(ChatMember member) {
|
|
||||||
return member.isWorked()
|
|
||||||
|| member.isWorked50()
|
|
||||||
|| member.isWorked70()
|
|
||||||
|| member.isWorked144()
|
|
||||||
|| member.isWorked432()
|
|
||||||
|| member.isWorked1240()
|
|
||||||
|| member.isWorked2300()
|
|
||||||
|| member.isWorked3400()
|
|
||||||
|| member.isWorked5600()
|
|
||||||
|| member.isWorked10G()
|
|
||||||
|| member.isWorked24G()
|
|
||||||
|| member.isWorked47G()
|
|
||||||
|| member.isWorked76G();
|
|
||||||
}
|
|
||||||
|
|
||||||
private int extractReachableAirplanes(AirPlaneReflectionInfo airPlaneReflectionInfo) {
|
|
||||||
return airPlaneReflectionInfo == null ? 0 : airPlaneReflectionInfo.getAirPlanesReachableCntr();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeCallsignRaw(String callSignRaw) {
|
|
||||||
if (callSignRaw == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeLocator6(String locator) {
|
|
||||||
if (locator == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalized = locator.trim().toUpperCase(Locale.ROOT);
|
|
||||||
if (normalized.length() >= 6) {
|
|
||||||
normalized = normalized.substring(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalized.matches("^[A-R]{2}[0-9]{2}[A-X]{2}$")) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toBandDisplayLabel(Band band) {
|
|
||||||
return switch (band) {
|
|
||||||
case B_144 -> "144";
|
|
||||||
case B_432 -> "432";
|
|
||||||
case B_1296 -> "1296";
|
|
||||||
case B_2320 -> "2320";
|
|
||||||
case B_3400 -> "3400";
|
|
||||||
case B_5760 -> "5760";
|
|
||||||
case B_10G -> "10368";
|
|
||||||
case B_24G -> "24048";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatFrequency(double frequencyMHz) {
|
|
||||||
return String.format(Locale.US, "%.3f", frequencyMHz);
|
|
||||||
}
|
|
||||||
|
|
||||||
private record FrequencyCandidate(Band band, String formattedFrequency, long timestampEpochMs) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,675 +0,0 @@
|
|||||||
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>
|
|
||||||
""";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder terrain provider.
|
|
||||||
*/
|
|
||||||
public final class NoOpTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
return TerrainProfileData.empty("No terrain provider");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small helper service for preparing and filling the local offline DEM directory.
|
|
||||||
*
|
|
||||||
* <p>This is intentionally the first practical step before adding a real network
|
|
||||||
* downloader:
|
|
||||||
* <ul>
|
|
||||||
* <li>create a known default Copernicus DEM root directory below .praktiKST</li>
|
|
||||||
* <li>copy manually selected local *_DEM.tif files into that directory</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The active terrain provider already scans the configured root directory
|
|
||||||
* recursively. Therefore it is sufficient to import valid Copernicus DGED
|
|
||||||
* GeoTIFF files into one dedicated folder tree.</p>
|
|
||||||
*/
|
|
||||||
public final class OfflineDemImportService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default relative DEM directory below the application's hidden home folder.
|
|
||||||
*/
|
|
||||||
public static final String DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY = "dem/copernicus_glo30";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the default local Copernicus DEM root directory below .praktiKST.
|
|
||||||
*
|
|
||||||
* @return default DEM root directory path
|
|
||||||
*/
|
|
||||||
public Path resolveDefaultCopernicusRootDirectory() {
|
|
||||||
return Path.of(
|
|
||||||
ApplicationFileUtils.getFilePath(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DEFAULT_RELATIVE_COPERNICUS_ROOT_DIRECTORY
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures that the default local Copernicus DEM root directory exists.
|
|
||||||
*
|
|
||||||
* @return preparation result including the effective target directory
|
|
||||||
*/
|
|
||||||
public ImportResult ensureDefaultCopernicusRootDirectory() {
|
|
||||||
Path targetRootDirectory = resolveDefaultCopernicusRootDirectory();
|
|
||||||
return ensureTargetDirectoryExists(targetRootDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports manually selected Copernicus DGED GeoTIFF tiles into the configured
|
|
||||||
* DEM root directory.
|
|
||||||
*
|
|
||||||
* <p>If the configured DEM root directory is blank, the default directory
|
|
||||||
* below .praktiKST is used automatically.</p>
|
|
||||||
*
|
|
||||||
* @param selectedFiles selected local files from the file chooser
|
|
||||||
* @param configuredDemRootDirectory current configured DEM root directory text
|
|
||||||
* @return import result with counts and a user-friendly summary message
|
|
||||||
*/
|
|
||||||
public ImportResult importTiles(List<File> selectedFiles, String configuredDemRootDirectory) {
|
|
||||||
Path targetRootDirectory = resolveEffectiveTargetRootDirectory(configuredDemRootDirectory);
|
|
||||||
|
|
||||||
ImportResult directoryPreparationResult = ensureTargetDirectoryExists(targetRootDirectory);
|
|
||||||
if (!directoryPreparationResult.success()) {
|
|
||||||
return directoryPreparationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedFiles == null || selectedFiles.isEmpty()) {
|
|
||||||
return new ImportResult(
|
|
||||||
targetRootDirectory,
|
|
||||||
true,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
List.of(),
|
|
||||||
"No files were selected for import."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int importedFileCount = 0;
|
|
||||||
int skippedFileCount = 0;
|
|
||||||
List<String> skippedFilenames = new ArrayList<>();
|
|
||||||
|
|
||||||
for (File selectedFile : selectedFiles) {
|
|
||||||
if (selectedFile == null || !selectedFile.isFile() || !selectedFile.canRead()) {
|
|
||||||
skippedFileCount++;
|
|
||||||
skippedFilenames.add(selectedFile == null ? "<null>" : selectedFile.getName());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(selectedFile.getName())) {
|
|
||||||
skippedFileCount++;
|
|
||||||
skippedFilenames.add(selectedFile.getName());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Files.copy(
|
|
||||||
selectedFile.toPath(),
|
|
||||||
targetRootDirectory.resolve(selectedFile.getName()),
|
|
||||||
StandardCopyOption.REPLACE_EXISTING
|
|
||||||
);
|
|
||||||
importedFileCount++;
|
|
||||||
} catch (Exception exception) {
|
|
||||||
skippedFileCount++;
|
|
||||||
skippedFilenames.add(selectedFile.getName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
|
||||||
message.append(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"Imported %d DEM tile(s) into:%n%s",
|
|
||||||
importedFileCount,
|
|
||||||
targetRootDirectory.toAbsolutePath()
|
|
||||||
));
|
|
||||||
|
|
||||||
if (skippedFileCount > 0) {
|
|
||||||
message.append(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%n%nSkipped %d file(s) because they were unreadable or did not match the supported Copernicus *_DEM.tif naming scheme.",
|
|
||||||
skippedFileCount
|
|
||||||
));
|
|
||||||
|
|
||||||
int previewCount = Math.min(8, skippedFilenames.size());
|
|
||||||
if (previewCount > 0) {
|
|
||||||
message.append(String.format(Locale.US, "%n%nSkipped examples:%n"));
|
|
||||||
for (int i = 0; i < previewCount; i++) {
|
|
||||||
message.append("- ").append(skippedFilenames.get(i)).append(System.lineSeparator());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImportResult(
|
|
||||||
targetRootDirectory,
|
|
||||||
true,
|
|
||||||
importedFileCount,
|
|
||||||
skippedFileCount,
|
|
||||||
List.copyOf(skippedFilenames),
|
|
||||||
message.toString().trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path resolveEffectiveTargetRootDirectory(String configuredDemRootDirectory) {
|
|
||||||
if (configuredDemRootDirectory == null || configuredDemRootDirectory.isBlank()) {
|
|
||||||
return resolveDefaultCopernicusRootDirectory();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.of(configuredDemRootDirectory.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
private ImportResult ensureTargetDirectoryExists(Path targetRootDirectory) {
|
|
||||||
if (targetRootDirectory == null) {
|
|
||||||
return new ImportResult(
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
List.of(),
|
|
||||||
"DEM target directory is undefined."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (Files.exists(targetRootDirectory) && !Files.isDirectory(targetRootDirectory)) {
|
|
||||||
return new ImportResult(
|
|
||||||
targetRootDirectory,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
List.of(),
|
|
||||||
"Configured DEM root path exists but is not a directory:\n"
|
|
||||||
+ targetRootDirectory.toAbsolutePath()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.createDirectories(targetRootDirectory);
|
|
||||||
|
|
||||||
return new ImportResult(
|
|
||||||
targetRootDirectory,
|
|
||||||
true,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
List.of(),
|
|
||||||
"Using local Copernicus DEM directory:\n"
|
|
||||||
+ targetRootDirectory.toAbsolutePath()
|
|
||||||
+ "\n\nYou can now import extracted Copernicus *_DEM.tif tiles into this folder."
|
|
||||||
);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return new ImportResult(
|
|
||||||
targetRootDirectory,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
List.of(),
|
|
||||||
"Could not create DEM root directory:\n"
|
|
||||||
+ targetRootDirectory.toAbsolutePath()
|
|
||||||
+ "\n\nReason: "
|
|
||||||
+ exception.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable result of a DEM directory preparation or tile import action.
|
|
||||||
*
|
|
||||||
* @param targetRootDirectory effective DEM root directory
|
|
||||||
* @param success true if the action succeeded
|
|
||||||
* @param importedFileCount number of imported files
|
|
||||||
* @param skippedFileCount number of skipped files
|
|
||||||
* @param skippedFilenames skipped filenames for diagnostics
|
|
||||||
* @param message user-friendly summary message
|
|
||||||
*/
|
|
||||||
public record ImportResult(
|
|
||||||
Path targetRootDirectory,
|
|
||||||
boolean success,
|
|
||||||
int importedFileCount,
|
|
||||||
int skippedFileCount,
|
|
||||||
List<String> skippedFilenames,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small manager for offline DEM discovery and indexing.
|
|
||||||
*
|
|
||||||
* This class intentionally supports both intermediate API shapes that appeared
|
|
||||||
* during development:
|
|
||||||
*
|
|
||||||
* - old style:
|
|
||||||
* inspect(...) -> OfflineDemStatus
|
|
||||||
*
|
|
||||||
* - new style:
|
|
||||||
* inspectAndIndex(...) -> OfflineDemIndex
|
|
||||||
*
|
|
||||||
* This keeps the current codebase compilable even if older helper/provider
|
|
||||||
* classes are still present in the source tree.
|
|
||||||
*/
|
|
||||||
public final class OfflineDemManager {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches official DGED DEM filenames like:
|
|
||||||
* Copernicus_DSM_10_N50_00_E020_00_DEM.tif
|
|
||||||
*/
|
|
||||||
private static final Pattern COPERNICUS_GLO_30_DEM_FILE_PATTERN =
|
|
||||||
Pattern.compile("(?i)^Copernicus_[A-Z]{3}_10_([NS])(\\d{2})_(\\d{2})_([EW])(\\d{3})_(\\d{2})_DEM\\.tif$");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the filename matches the currently supported Copernicus
|
|
||||||
* GLO-30 DGED GeoTIFF tile naming scheme.
|
|
||||||
*
|
|
||||||
* <p>This helper is intentionally public so that UI/import helpers can
|
|
||||||
* validate manually selected files before copying them into the DEM root.</p>
|
|
||||||
*
|
|
||||||
* @param filename candidate filename
|
|
||||||
* @return true if the filename looks like a supported local DEM tile
|
|
||||||
*/
|
|
||||||
public static boolean isSupportedCopernicusGlo30DemFilename(String filename) {
|
|
||||||
return filename != null
|
|
||||||
&& COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename).matches();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String lastIndexedRootDirectory = null;
|
|
||||||
private DemDataset lastIndexedDataset = null;
|
|
||||||
private OfflineDemIndex lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Newer API used by the active Copernicus provider.
|
|
||||||
*/
|
|
||||||
public synchronized OfflineDemIndex inspectAndIndex(String demRootDirectory, DemDataset dataset) {
|
|
||||||
DemDataset effectiveDataset = dataset == null ? DemDataset.COPERNICUS_GLO_30 : dataset;
|
|
||||||
|
|
||||||
if (demRootDirectory == null || demRootDirectory.isBlank()) {
|
|
||||||
lastIndexedRootDirectory = demRootDirectory;
|
|
||||||
lastIndexedDataset = effectiveDataset;
|
|
||||||
lastIndex = OfflineDemIndex.empty("Offline DEM root directory is not configured.");
|
|
||||||
return lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalizedRootDirectory = demRootDirectory.trim();
|
|
||||||
|
|
||||||
if (normalizedRootDirectory.equals(lastIndexedRootDirectory) && effectiveDataset == lastIndexedDataset) {
|
|
||||||
return lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastIndexedRootDirectory = normalizedRootDirectory;
|
|
||||||
lastIndexedDataset = effectiveDataset;
|
|
||||||
lastIndex = buildIndex(normalizedRootDirectory, effectiveDataset);
|
|
||||||
|
|
||||||
return lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Older compatibility API still referenced by older intermediate provider code.
|
|
||||||
*/
|
|
||||||
public synchronized OfflineDemStatus inspect(String demRootDirectory, DemDataset dataset) {
|
|
||||||
OfflineDemIndex index = inspectAndIndex(demRootDirectory, dataset);
|
|
||||||
|
|
||||||
return new OfflineDemStatus(
|
|
||||||
index.dataset(),
|
|
||||||
index.rootDirectory(),
|
|
||||||
demRootDirectory != null && !demRootDirectory.isBlank(),
|
|
||||||
index.usable(),
|
|
||||||
index.message()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private OfflineDemIndex buildIndex(String demRootDirectory, DemDataset dataset) {
|
|
||||||
Path rootPath = Paths.get(demRootDirectory);
|
|
||||||
|
|
||||||
if (!Files.exists(rootPath)) {
|
|
||||||
return OfflineDemIndex.empty("Configured DEM root directory does not exist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isDirectory(rootPath)) {
|
|
||||||
return OfflineDemIndex.empty("Configured DEM root path is not a directory.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Files.isReadable(rootPath)) {
|
|
||||||
return OfflineDemIndex.empty("Configured DEM root directory is not readable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Path> tilePathByGeocellKey = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
try (var pathStream = Files.walk(rootPath)) {
|
|
||||||
pathStream
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.forEach(path -> tryRegisterTile(path, tilePathByGeocellKey));
|
|
||||||
} catch (IOException exception) {
|
|
||||||
return OfflineDemIndex.empty("Scanning DEM root directory failed: " + exception.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tilePathByGeocellKey.isEmpty()) {
|
|
||||||
return new OfflineDemIndex(
|
|
||||||
dataset,
|
|
||||||
rootPath.toAbsolutePath().toString(),
|
|
||||||
false,
|
|
||||||
Collections.emptyMap(),
|
|
||||||
"No local Copernicus GLO-30 DEM tiles were found below the configured root directory."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new OfflineDemIndex(
|
|
||||||
dataset,
|
|
||||||
rootPath.toAbsolutePath().toString(),
|
|
||||||
true,
|
|
||||||
Map.copyOf(tilePathByGeocellKey),
|
|
||||||
"Found " + tilePathByGeocellKey.size() + " local Copernicus GLO-30 DEM tiles."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void tryRegisterTile(Path path, Map<String, Path> tilePathByGeocellKey) {
|
|
||||||
String filename = path.getFileName().toString();
|
|
||||||
Matcher matcher = COPERNICUS_GLO_30_DEM_FILE_PATTERN.matcher(filename);
|
|
||||||
|
|
||||||
if (!matcher.matches()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int southDeg = signedDegrees(matcher.group(1), matcher.group(2), matcher.group(3));
|
|
||||||
int westDeg = signedDegrees(matcher.group(4), matcher.group(5), matcher.group(6));
|
|
||||||
|
|
||||||
tilePathByGeocellKey.put(toGeocellKey(southDeg, westDeg), path.toAbsolutePath());
|
|
||||||
}
|
|
||||||
|
|
||||||
private int signedDegrees(String hemisphereOrDirection, String integerPart, String decimalPart) {
|
|
||||||
int sign = ("S".equalsIgnoreCase(hemisphereOrDirection) || "W".equalsIgnoreCase(hemisphereOrDirection)) ? -1 : 1;
|
|
||||||
int degrees = Integer.parseInt(integerPart);
|
|
||||||
|
|
||||||
if (!"00".equals(decimalPart)) {
|
|
||||||
// The current reader expects whole-degree LL-corner geocells.
|
|
||||||
return sign * degrees;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sign * degrees;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toGeocellKey(int southDeg, int westDeg) {
|
|
||||||
return southDeg + ":" + westDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Newer indexed offline DEM state used by the active Copernicus provider.
|
|
||||||
*/
|
|
||||||
public record OfflineDemIndex(
|
|
||||||
DemDataset dataset,
|
|
||||||
String rootDirectory,
|
|
||||||
boolean usable,
|
|
||||||
Map<String, Path> tilePathByGeocellKey,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static OfflineDemIndex empty(String message) {
|
|
||||||
return new OfflineDemIndex(
|
|
||||||
DemDataset.COPERNICUS_GLO_30,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
Collections.emptyMap(),
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Path findTilePath(double latitudeDeg, double longitudeDeg) {
|
|
||||||
int southDeg = (int) Math.floor(latitudeDeg);
|
|
||||||
int westDeg = (int) Math.floor(longitudeDeg);
|
|
||||||
return tilePathByGeocellKey.get(southDeg + ":" + westDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public int availableTileCount() {
|
|
||||||
return tilePathByGeocellKey.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Older compatibility status shape still referenced by older intermediate code.
|
|
||||||
*/
|
|
||||||
public record OfflineDemStatus(
|
|
||||||
DemDataset dataset,
|
|
||||||
String demRootDirectory,
|
|
||||||
boolean configured,
|
|
||||||
boolean usableRootDirectory,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.function.Supplier;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compatibility offline-first provider.
|
|
||||||
*
|
|
||||||
* This class is currently not part of the active StationMap pipeline anymore,
|
|
||||||
* but it is kept compilable so the source tree stays consistent while the
|
|
||||||
* Copernicus provider is the primary offline implementation.
|
|
||||||
*/
|
|
||||||
public final class OfflineDemTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
private final Supplier<String> demRootDirectorySupplier;
|
|
||||||
private final Supplier<DemDataset> datasetSupplier;
|
|
||||||
private final OfflineDemManager offlineDemManager;
|
|
||||||
|
|
||||||
public OfflineDemTerrainProfileProvider(Supplier<String> demRootDirectorySupplier,
|
|
||||||
Supplier<DemDataset> datasetSupplier,
|
|
||||||
OfflineDemManager offlineDemManager) {
|
|
||||||
this.demRootDirectorySupplier = Objects.requireNonNull(demRootDirectorySupplier, "demRootDirectorySupplier");
|
|
||||||
this.datasetSupplier = Objects.requireNonNull(datasetSupplier, "datasetSupplier");
|
|
||||||
this.offlineDemManager = Objects.requireNonNull(offlineDemManager, "offlineDemManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
DemDataset dataset = datasetSupplier.get();
|
|
||||||
if (dataset == null) {
|
|
||||||
dataset = DemDataset.COPERNICUS_GLO_30;
|
|
||||||
}
|
|
||||||
|
|
||||||
OfflineDemManager.OfflineDemIndex index =
|
|
||||||
offlineDemManager.inspectAndIndex(demRootDirectorySupplier.get(), dataset);
|
|
||||||
|
|
||||||
if (!index.usable()) {
|
|
||||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM unavailable");
|
|
||||||
}
|
|
||||||
|
|
||||||
return TerrainProfileData.empty(dataset.displayName() + " offline DEM indexed but not used by this compatibility provider");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terrain provider using the Open-Meteo Elevation API.
|
|
||||||
*
|
|
||||||
* <p>Important API constraint: Open-Meteo accepts up to 100 coordinate pairs per
|
|
||||||
* elevation request. Earlier KST4Contest profile calculations could ask for up
|
|
||||||
* to 1201 samples, which forced many HTTP requests for one visible profile. This
|
|
||||||
* provider intentionally clamps the online profile to one API request. The
|
|
||||||
* offline Copernicus path can later keep using the denser local sample count.</p>
|
|
||||||
*
|
|
||||||
* <p>The provider keeps a small in-memory cache for the running session. This is
|
|
||||||
* deliberately local and lightweight: it avoids repeated API calls when the user
|
|
||||||
* re-selects the same station, but it does not replace the existing long-term
|
|
||||||
* offline DEM/download architecture.</p>
|
|
||||||
*/
|
|
||||||
public final class OpenMeteoTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
private static final String SOURCE_NAME = "Open-Meteo Copernicus GLO-90";
|
|
||||||
private static final String DEFAULT_BASE_URL = "https://api.open-meteo.com/v1/elevation";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open-Meteo elevation requests accept up to 100 coordinate pairs. Keeping the
|
|
||||||
* online provider at exactly one request per path makes limit behavior
|
|
||||||
* predictable and avoids request bursts while clicking through stations.
|
|
||||||
*/
|
|
||||||
private static final int MAX_COORDINATES_PER_REQUEST = 100;
|
|
||||||
private static final int MAX_ONLINE_PROFILE_SAMPLE_COUNT = MAX_COORDINATES_PER_REQUEST;
|
|
||||||
private static final int MIN_ONLINE_PROFILE_SAMPLE_COUNT = 2;
|
|
||||||
|
|
||||||
private static final int MAX_CACHED_PROFILES = 256;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The free tier allows many more calls per minute, but this soft limiter keeps
|
|
||||||
* accidental click/refresh bursts polite and easier to diagnose.
|
|
||||||
*/
|
|
||||||
private static final Duration MINIMUM_REQUEST_INTERVAL = Duration.ofMillis(250);
|
|
||||||
|
|
||||||
private static final Pattern ELEVATION_ARRAY_PATTERN =
|
|
||||||
Pattern.compile("\\\"elevation\\\"\\s*:\\s*\\[(.*?)]", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private static final Pattern ERROR_REASON_PATTERN =
|
|
||||||
Pattern.compile("\\\"reason\\\"\\s*:\\s*\\\"(.*?)\\\"", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private final HttpClient httpClient;
|
|
||||||
private final Duration requestTimeout;
|
|
||||||
private final String baseUrl;
|
|
||||||
|
|
||||||
private final Map<RequestCacheKey, TerrainProfileData> profileCache =
|
|
||||||
new LinkedHashMap<>(32, 0.75f, true) {
|
|
||||||
@Override
|
|
||||||
protected boolean removeEldestEntry(Map.Entry<RequestCacheKey, TerrainProfileData> eldest) {
|
|
||||||
return size() > MAX_CACHED_PROFILES;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private long lastRequestStartEpochMillis = 0L;
|
|
||||||
|
|
||||||
public OpenMeteoTerrainProfileProvider() {
|
|
||||||
this(
|
|
||||||
HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(4))
|
|
||||||
.build(),
|
|
||||||
Duration.ofSeconds(8),
|
|
||||||
DEFAULT_BASE_URL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public OpenMeteoTerrainProfileProvider(HttpClient httpClient,
|
|
||||||
Duration requestTimeout,
|
|
||||||
String baseUrl) {
|
|
||||||
this.httpClient = httpClient;
|
|
||||||
this.requestTimeout = requestTimeout == null ? Duration.ofSeconds(8) : requestTimeout;
|
|
||||||
this.baseUrl = (baseUrl == null || baseUrl.isBlank()) ? DEFAULT_BASE_URL : baseUrl.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
int sampleCount = resolveOnlineSampleCount(request.requestedSampleCount());
|
|
||||||
RequestCacheKey cacheKey = RequestCacheKey.from(request, sampleCount);
|
|
||||||
|
|
||||||
TerrainProfileData cachedProfile = loadFromCache(cacheKey);
|
|
||||||
if (cachedProfile != null) {
|
|
||||||
return cachedProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SamplePoint> samplePoints = buildSamplePoints(request, sampleCount);
|
|
||||||
TerrainProfileData profileData = fetchProfile(samplePoints);
|
|
||||||
|
|
||||||
if (profileData.hasUsableProfile()) {
|
|
||||||
saveToCache(cacheKey, profileData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return profileData;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int resolveOnlineSampleCount(int requestedSampleCount) {
|
|
||||||
return Math.max(
|
|
||||||
MIN_ONLINE_PROFILE_SAMPLE_COUNT,
|
|
||||||
Math.min(requestedSampleCount, MAX_ONLINE_PROFILE_SAMPLE_COUNT)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerrainProfileData loadFromCache(RequestCacheKey cacheKey) {
|
|
||||||
synchronized (profileCache) {
|
|
||||||
return profileCache.get(cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveToCache(RequestCacheKey cacheKey, TerrainProfileData profileData) {
|
|
||||||
synchronized (profileCache) {
|
|
||||||
profileCache.put(cacheKey, profileData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerrainProfileData fetchProfile(List<SamplePoint> samplePoints) {
|
|
||||||
try {
|
|
||||||
List<Double> elevations = fetchElevations(samplePoints);
|
|
||||||
|
|
||||||
if (elevations.size() != samplePoints.size()) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " returned an incomplete elevation array");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PathProfilePoint> profilePoints = new ArrayList<>(samplePoints.size());
|
|
||||||
|
|
||||||
for (int i = 0; i < samplePoints.size(); i++) {
|
|
||||||
SamplePoint samplePoint = samplePoints.get(i);
|
|
||||||
double elevationMeters = elevations.get(i);
|
|
||||||
|
|
||||||
if (!Double.isFinite(elevationMeters)) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " returned no-data elevation samples");
|
|
||||||
}
|
|
||||||
|
|
||||||
profilePoints.add(new PathProfilePoint(
|
|
||||||
samplePoint.distanceKm(),
|
|
||||||
samplePoint.latitudeDeg(),
|
|
||||||
samplePoint.longitudeDeg(),
|
|
||||||
elevationMeters
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainProfileData(profilePoints, SOURCE_NAME, false);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return TerrainProfileData.empty(SOURCE_NAME + " failed: " + buildShortFailureMessage(exception));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<SamplePoint> buildSamplePoints(TerrainProfileRequest request, int sampleCount) {
|
|
||||||
List<SamplePoint> points = new ArrayList<>(sampleCount);
|
|
||||||
|
|
||||||
for (int i = 0; i < sampleCount; i++) {
|
|
||||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
|
||||||
|
|
||||||
PathGeometryUtils.GeoPoint interpolatedPoint =
|
|
||||||
PathGeometryUtils.interpolateGreatCirclePoint(
|
|
||||||
request.fromLatitudeDeg(),
|
|
||||||
request.fromLongitudeDeg(),
|
|
||||||
request.toLatitudeDeg(),
|
|
||||||
request.toLongitudeDeg(),
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
double latitudeDeg = interpolatedPoint.latitudeDeg();
|
|
||||||
double longitudeDeg = interpolatedPoint.longitudeDeg();
|
|
||||||
|
|
||||||
if (!Double.isFinite(latitudeDeg) || !Double.isFinite(longitudeDeg)) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
points.add(new SamplePoint(
|
|
||||||
request.totalDistanceKm() * t,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Double> fetchElevations(List<SamplePoint> samplePoints) throws Exception {
|
|
||||||
if (samplePoints == null || samplePoints.isEmpty()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (samplePoints.size() > MAX_COORDINATES_PER_REQUEST) {
|
|
||||||
throw new IllegalArgumentException("Open-Meteo request would contain more than 100 coordinates");
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForRateLimitSlot();
|
|
||||||
|
|
||||||
String latitudeParameter = samplePoints.stream()
|
|
||||||
.map(point -> formatCoordinate(point.latitudeDeg()))
|
|
||||||
.collect(Collectors.joining(","));
|
|
||||||
|
|
||||||
String longitudeParameter = samplePoints.stream()
|
|
||||||
.map(point -> formatCoordinate(point.longitudeDeg()))
|
|
||||||
.collect(Collectors.joining(","));
|
|
||||||
|
|
||||||
String requestUrl = baseUrl
|
|
||||||
+ "?latitude=" + latitudeParameter
|
|
||||||
+ "&longitude=" + longitudeParameter;
|
|
||||||
|
|
||||||
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(requestUrl))
|
|
||||||
.timeout(requestTimeout)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.header("User-Agent", "KST4Contest path-analysis")
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (response.statusCode() / 100 != 2) {
|
|
||||||
String reason = extractErrorReason(response.body());
|
|
||||||
throw new IOException("HTTP " + response.statusCode() + (reason.isBlank() ? "" : " - " + reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseElevationArray(response.body());
|
|
||||||
}
|
|
||||||
|
|
||||||
private synchronized void waitForRateLimitSlot() throws InterruptedException {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
long waitMillis = MINIMUM_REQUEST_INTERVAL.toMillis() - (now - lastRequestStartEpochMillis);
|
|
||||||
|
|
||||||
if (waitMillis > 0L) {
|
|
||||||
Thread.sleep(waitMillis);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRequestStartEpochMillis = System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Double> parseElevationArray(String responseBody) throws IOException {
|
|
||||||
if (responseBody == null || responseBody.isBlank()) {
|
|
||||||
throw new IOException("Empty response body");
|
|
||||||
}
|
|
||||||
|
|
||||||
Matcher elevationArrayMatcher = ELEVATION_ARRAY_PATTERN.matcher(responseBody);
|
|
||||||
if (!elevationArrayMatcher.find()) {
|
|
||||||
String reason = extractErrorReason(responseBody);
|
|
||||||
throw new IOException("No elevation array found" + (reason.isBlank() ? "" : ": " + reason));
|
|
||||||
}
|
|
||||||
|
|
||||||
String arrayContent = elevationArrayMatcher.group(1).trim();
|
|
||||||
if (arrayContent.isBlank()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] parts = arrayContent.split("\\s*,\\s*");
|
|
||||||
List<Double> elevations = new ArrayList<>(parts.length);
|
|
||||||
|
|
||||||
for (String part : parts) {
|
|
||||||
String value = part.trim();
|
|
||||||
|
|
||||||
if (value.isBlank()
|
|
||||||
|| "null".equalsIgnoreCase(value)
|
|
||||||
|| "nan".equalsIgnoreCase(value)) {
|
|
||||||
elevations.add(Double.NaN);
|
|
||||||
} else {
|
|
||||||
elevations.add(Double.parseDouble(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return elevations;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractErrorReason(String responseBody) {
|
|
||||||
Matcher matcher = ERROR_REASON_PATTERN.matcher(responseBody == null ? "" : responseBody);
|
|
||||||
if (matcher.find()) {
|
|
||||||
return matcher.group(1);
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildShortFailureMessage(Exception exception) {
|
|
||||||
if (exception == null) {
|
|
||||||
return "unknown error";
|
|
||||||
}
|
|
||||||
|
|
||||||
Throwable current = exception;
|
|
||||||
while (current.getCause() != null) {
|
|
||||||
current = current.getCause();
|
|
||||||
}
|
|
||||||
|
|
||||||
String message = current.getMessage();
|
|
||||||
if (message == null || message.isBlank()) {
|
|
||||||
message = exception.getMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return message == null || message.isBlank()
|
|
||||||
? current.getClass().getSimpleName()
|
|
||||||
: message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatCoordinate(double value) {
|
|
||||||
return String.format(Locale.US, "%.6f", value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private record SamplePoint(double distanceKm, double latitudeDeg, double longitudeDeg) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private record RequestCacheKey(
|
|
||||||
String fromLatitudeDeg,
|
|
||||||
String fromLongitudeDeg,
|
|
||||||
String toLatitudeDeg,
|
|
||||||
String toLongitudeDeg,
|
|
||||||
int sampleCount
|
|
||||||
) {
|
|
||||||
private static RequestCacheKey from(TerrainProfileRequest request, int sampleCount) {
|
|
||||||
return new RequestCacheKey(
|
|
||||||
normalizeCoordinate(request.fromLatitudeDeg()),
|
|
||||||
normalizeCoordinate(request.fromLongitudeDeg()),
|
|
||||||
normalizeCoordinate(request.toLatitudeDeg()),
|
|
||||||
normalizeCoordinate(request.toLongitudeDeg()),
|
|
||||||
sampleCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeCoordinate(double value) {
|
|
||||||
return String.format(Locale.US, "%.6f", value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable input for one path analysis run.
|
|
||||||
*/
|
|
||||||
public record PathAnalysisRequest(
|
|
||||||
String fromLocator6,
|
|
||||||
double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
String toCallsignRaw,
|
|
||||||
String toLocator6,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg,
|
|
||||||
double frequencyMHz,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double effectiveEarthRadiusFactor,
|
|
||||||
PathLinkBudgetSettings linkBudgetSettings
|
|
||||||
) {
|
|
||||||
|
|
||||||
public PathAnalysisRequest(String fromLocator6,
|
|
||||||
double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
String toCallsignRaw,
|
|
||||||
String toLocator6,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg,
|
|
||||||
double frequencyMHz,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters) {
|
|
||||||
this(
|
|
||||||
fromLocator6,
|
|
||||||
fromLatitudeDeg,
|
|
||||||
fromLongitudeDeg,
|
|
||||||
toCallsignRaw,
|
|
||||||
toLocator6,
|
|
||||||
toLatitudeDeg,
|
|
||||||
toLongitudeDeg,
|
|
||||||
frequencyMHz,
|
|
||||||
homeAntennaHeightMeters,
|
|
||||||
targetAntennaHeightMeters,
|
|
||||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
|
||||||
PathLinkBudgetSettings.defaults()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PathAnalysisRequest {
|
|
||||||
fromLocator6 = normalizeLocator(fromLocator6);
|
|
||||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
|
||||||
toLocator6 = normalizeLocator(toLocator6);
|
|
||||||
|
|
||||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
|
||||||
frequencyMHz = Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Double.isFinite(homeAntennaHeightMeters) || homeAntennaHeightMeters < 0.0) {
|
|
||||||
homeAntennaHeightMeters = Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Double.isFinite(targetAntennaHeightMeters) || targetAntennaHeightMeters < 0.0) {
|
|
||||||
targetAntennaHeightMeters = Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
|
||||||
|
|
||||||
linkBudgetSettings = linkBudgetSettings == null
|
|
||||||
? PathLinkBudgetSettings.defaults()
|
|
||||||
: linkBudgetSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeLocator(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
if (value == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableHome() {
|
|
||||||
return fromLocator6.length() == 6
|
|
||||||
&& Double.isFinite(fromLatitudeDeg)
|
|
||||||
&& Double.isFinite(fromLongitudeDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableTarget() {
|
|
||||||
return toLocator6.length() == 6
|
|
||||||
&& Double.isFinite(toLatitudeDeg)
|
|
||||||
&& Double.isFinite(toLongitudeDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableFrequency() {
|
|
||||||
return Double.isFinite(frequencyMHz) && frequencyMHz > 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,710 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable UI-facing result for one path analysis run.
|
|
||||||
*
|
|
||||||
* <p>This result intentionally combines:
|
|
||||||
* <ul>
|
|
||||||
* <li>basic path metadata used by the detail panel</li>
|
|
||||||
* <li>the enriched path profile used by the preview chart</li>
|
|
||||||
* <li>formatted state helpers for concise UI binding</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The chart and the numeric evaluation both consume the same already
|
|
||||||
* computed profile data to avoid drift between visual and numeric output.</p>
|
|
||||||
*/
|
|
||||||
public record PathAnalysisResult(
|
|
||||||
String analysisMode,
|
|
||||||
String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw,
|
|
||||||
double distanceKm,
|
|
||||||
double bearingDeg,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
boolean lineOfSightClear,
|
|
||||||
boolean fresnelClear,
|
|
||||||
double minimumLineOfSightClearanceMeters,
|
|
||||||
double minimumLowerFresnelClearanceMeters,
|
|
||||||
double worstFresnelIntrusionMeters,
|
|
||||||
double worstFresnelIntrusionRatio,
|
|
||||||
double worstFresnelDistanceKm,
|
|
||||||
int worstFresnelSampleIndex,
|
|
||||||
double effectiveEarthRadiusFactor,
|
|
||||||
PathHorizonSummary horizonSummary,
|
|
||||||
PathObstructionSummary obstructionSummary,
|
|
||||||
PathPropagationAssessment propagationAssessment,
|
|
||||||
String statusText,
|
|
||||||
List<PathProfilePoint> profilePoints,
|
|
||||||
PathLinkBudgetSummary linkBudgetSummary
|
|
||||||
) {
|
|
||||||
|
|
||||||
public PathAnalysisResult {
|
|
||||||
analysisMode = normalizeText(analysisMode);
|
|
||||||
fromLocator6 = normalizeUpper(fromLocator6);
|
|
||||||
toLocator6 = normalizeUpper(toLocator6);
|
|
||||||
toCallsignRaw = normalizeUpper(toCallsignRaw);
|
|
||||||
statusText = normalizeText(statusText);
|
|
||||||
profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints);
|
|
||||||
effectiveEarthRadiusFactor =
|
|
||||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
|
||||||
|
|
||||||
horizonSummary = horizonSummary == null ? PathHorizonSummary.empty() : horizonSummary;
|
|
||||||
obstructionSummary = obstructionSummary == null ? PathObstructionSummary.empty() : obstructionSummary;
|
|
||||||
|
|
||||||
propagationAssessment = propagationAssessment == null
|
|
||||||
? PathPropagationAssessment.unknown()
|
|
||||||
: propagationAssessment;
|
|
||||||
|
|
||||||
linkBudgetSummary = linkBudgetSummary == null
|
|
||||||
? PathLinkBudgetSummary.empty()
|
|
||||||
: linkBudgetSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
public PathAnalysisResult(String analysisMode,
|
|
||||||
String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw,
|
|
||||||
double distanceKm,
|
|
||||||
double bearingDeg,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
boolean lineOfSightClear,
|
|
||||||
boolean fresnelClear,
|
|
||||||
double minimumLineOfSightClearanceMeters,
|
|
||||||
double minimumLowerFresnelClearanceMeters,
|
|
||||||
double worstFresnelIntrusionMeters,
|
|
||||||
double worstFresnelIntrusionRatio,
|
|
||||||
double worstFresnelDistanceKm,
|
|
||||||
int worstFresnelSampleIndex,
|
|
||||||
String statusText,
|
|
||||||
List<PathProfilePoint> profilePoints) {
|
|
||||||
this(
|
|
||||||
analysisMode,
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
toCallsignRaw,
|
|
||||||
distanceKm,
|
|
||||||
bearingDeg,
|
|
||||||
homeAntennaHeightMeters,
|
|
||||||
targetAntennaHeightMeters,
|
|
||||||
analysisFrequencyMHz,
|
|
||||||
lineOfSightClear,
|
|
||||||
fresnelClear,
|
|
||||||
minimumLineOfSightClearanceMeters,
|
|
||||||
minimumLowerFresnelClearanceMeters,
|
|
||||||
worstFresnelIntrusionMeters,
|
|
||||||
worstFresnelIntrusionRatio,
|
|
||||||
worstFresnelDistanceKm,
|
|
||||||
worstFresnelSampleIndex,
|
|
||||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
|
||||||
PathHorizonSummary.empty(),
|
|
||||||
PathObstructionSummary.empty(),
|
|
||||||
PathPropagationAssessment.unknown(),
|
|
||||||
statusText,
|
|
||||||
profilePoints,
|
|
||||||
PathLinkBudgetSummary.empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a placeholder result shown while no target station is selected.
|
|
||||||
*
|
|
||||||
* @param fromLocator6 normalized own locator if known
|
|
||||||
* @return waiting result
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult waitingForSelection(String fromLocator6) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
"Waiting",
|
|
||||||
fromLocator6,
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
"Select a station to prepare path and terrain analysis.",
|
|
||||||
List.of()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a placeholder result shown while analysis is currently running.
|
|
||||||
*
|
|
||||||
* @param fromLocator6 own locator
|
|
||||||
* @param toLocator6 target locator if known
|
|
||||||
* @param toCallsignRaw target callsign if known
|
|
||||||
* @return loading result
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult loading(String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
"Loading",
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
toCallsignRaw,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
"Loading terrain/profile data and evaluating the path.",
|
|
||||||
List.of()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a placeholder result shown when the own locator is not usable.
|
|
||||||
*
|
|
||||||
* @param fromLocator6 own locator candidate
|
|
||||||
* @param toLocator6 target locator candidate
|
|
||||||
* @return waiting result with home-locator warning
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult waitingForValidHomeLocator(String fromLocator6,
|
|
||||||
String toLocator6) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
"Waiting",
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
"",
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
"Own locator is missing or invalid. Path analysis cannot start yet.",
|
|
||||||
List.of()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a placeholder result shown when the selected target has no usable position.
|
|
||||||
*
|
|
||||||
* @param fromLocator6 own locator
|
|
||||||
* @param toLocator6 target locator candidate
|
|
||||||
* @return waiting result with target-position warning
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult waitingForValidTarget(String fromLocator6,
|
|
||||||
String toLocator6) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
"Waiting",
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
"",
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
"Selected station has no usable locator/position for path analysis.",
|
|
||||||
List.of()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a finished result without a usable terrain profile.
|
|
||||||
*
|
|
||||||
* @param analysisMode source/mode text
|
|
||||||
* @param fromLocator6 own locator
|
|
||||||
* @param toLocator6 target locator
|
|
||||||
* @param toCallsignRaw target callsign
|
|
||||||
* @param distanceKm path distance in kilometers
|
|
||||||
* @param bearingDeg initial bearing in degrees
|
|
||||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
|
||||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
|
||||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
|
||||||
* @param statusText human-readable status text
|
|
||||||
* @return finished result without profile points
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult noProfile(String analysisMode,
|
|
||||||
String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw,
|
|
||||||
double distanceKm,
|
|
||||||
double bearingDeg,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
String statusText) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
analysisMode,
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
toCallsignRaw,
|
|
||||||
distanceKm,
|
|
||||||
bearingDeg,
|
|
||||||
homeAntennaHeightMeters,
|
|
||||||
targetAntennaHeightMeters,
|
|
||||||
analysisFrequencyMHz,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
statusText,
|
|
||||||
List.of()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a fully evaluated result.
|
|
||||||
*
|
|
||||||
* @param analysisMode source/mode text
|
|
||||||
* @param fromLocator6 own locator
|
|
||||||
* @param toLocator6 target locator
|
|
||||||
* @param toCallsignRaw target callsign
|
|
||||||
* @param distanceKm path distance in kilometers
|
|
||||||
* @param bearingDeg initial bearing in degrees
|
|
||||||
* @param homeAntennaHeightMeters own antenna height in meters AGL
|
|
||||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
|
||||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
|
||||||
* @param lineOfSightClear true if the direct path is clear
|
|
||||||
* @param fresnelClear true if the lower Fresnel hull is not intruded
|
|
||||||
* @param minimumLineOfSightClearanceMeters minimum LOS clearance in meters
|
|
||||||
* @param minimumLowerFresnelClearanceMeters minimum lower Fresnel clearance in meters
|
|
||||||
* @param worstFresnelIntrusionMeters maximum lower Fresnel intrusion in meters
|
|
||||||
* @param worstFresnelIntrusionRatio intrusion divided by local Fresnel radius
|
|
||||||
* @param worstFresnelDistanceKm distance of the worst intrusion from TX in kilometers
|
|
||||||
* @param worstFresnelSampleIndex sample index of the worst intrusion
|
|
||||||
* @param statusText human-readable status text
|
|
||||||
* @param profilePoints enriched profile points
|
|
||||||
* @return completed path analysis result
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult completed(String analysisMode,
|
|
||||||
String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw,
|
|
||||||
double distanceKm,
|
|
||||||
double bearingDeg,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
boolean lineOfSightClear,
|
|
||||||
boolean fresnelClear,
|
|
||||||
double minimumLineOfSightClearanceMeters,
|
|
||||||
double minimumLowerFresnelClearanceMeters,
|
|
||||||
double worstFresnelIntrusionMeters,
|
|
||||||
double worstFresnelIntrusionRatio,
|
|
||||||
double worstFresnelDistanceKm,
|
|
||||||
int worstFresnelSampleIndex,
|
|
||||||
String statusText,
|
|
||||||
List<PathProfilePoint> profilePoints) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
analysisMode,
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
toCallsignRaw,
|
|
||||||
distanceKm,
|
|
||||||
bearingDeg,
|
|
||||||
homeAntennaHeightMeters,
|
|
||||||
targetAntennaHeightMeters,
|
|
||||||
analysisFrequencyMHz,
|
|
||||||
lineOfSightClear,
|
|
||||||
fresnelClear,
|
|
||||||
minimumLineOfSightClearanceMeters,
|
|
||||||
minimumLowerFresnelClearanceMeters,
|
|
||||||
worstFresnelIntrusionMeters,
|
|
||||||
worstFresnelIntrusionRatio,
|
|
||||||
worstFresnelDistanceKm,
|
|
||||||
worstFresnelSampleIndex,
|
|
||||||
statusText,
|
|
||||||
profilePoints
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a fully evaluated result including horizon/refraction metadata.
|
|
||||||
*
|
|
||||||
* @return completed path analysis result
|
|
||||||
*/
|
|
||||||
public static PathAnalysisResult completed(String analysisMode,
|
|
||||||
String fromLocator6,
|
|
||||||
String toLocator6,
|
|
||||||
String toCallsignRaw,
|
|
||||||
double distanceKm,
|
|
||||||
double bearingDeg,
|
|
||||||
double homeAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
boolean lineOfSightClear,
|
|
||||||
boolean fresnelClear,
|
|
||||||
double minimumLineOfSightClearanceMeters,
|
|
||||||
double minimumLowerFresnelClearanceMeters,
|
|
||||||
double worstFresnelIntrusionMeters,
|
|
||||||
double worstFresnelIntrusionRatio,
|
|
||||||
double worstFresnelDistanceKm,
|
|
||||||
int worstFresnelSampleIndex,
|
|
||||||
double effectiveEarthRadiusFactor,
|
|
||||||
PathHorizonSummary horizonSummary,
|
|
||||||
PathObstructionSummary obstructionSummary,
|
|
||||||
PathLinkBudgetSummary linkBudgetSummary,
|
|
||||||
PathPropagationAssessment propagationAssessment,
|
|
||||||
String statusText,
|
|
||||||
List<PathProfilePoint> profilePoints) {
|
|
||||||
return new PathAnalysisResult(
|
|
||||||
analysisMode,
|
|
||||||
fromLocator6,
|
|
||||||
toLocator6,
|
|
||||||
toCallsignRaw,
|
|
||||||
distanceKm,
|
|
||||||
bearingDeg,
|
|
||||||
homeAntennaHeightMeters,
|
|
||||||
targetAntennaHeightMeters,
|
|
||||||
analysisFrequencyMHz,
|
|
||||||
lineOfSightClear,
|
|
||||||
fresnelClear,
|
|
||||||
minimumLineOfSightClearanceMeters,
|
|
||||||
minimumLowerFresnelClearanceMeters,
|
|
||||||
worstFresnelIntrusionMeters,
|
|
||||||
worstFresnelIntrusionRatio,
|
|
||||||
worstFresnelDistanceKm,
|
|
||||||
worstFresnelSampleIndex,
|
|
||||||
effectiveEarthRadiusFactor,
|
|
||||||
horizonSummary,
|
|
||||||
obstructionSummary,
|
|
||||||
propagationAssessment,
|
|
||||||
statusText,
|
|
||||||
profilePoints,
|
|
||||||
linkBudgetSummary = linkBudgetSummary == null
|
|
||||||
? PathLinkBudgetSummary.empty()
|
|
||||||
: linkBudgetSummary
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the result contains a profile usable for charting/evaluation.
|
|
||||||
*
|
|
||||||
* @return true if at least two profile points are available
|
|
||||||
*/
|
|
||||||
public boolean hasUsableProfile() {
|
|
||||||
return profilePoints.size() >= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the dominant obstruction / diffraction candidate text.
|
|
||||||
*
|
|
||||||
* @return obstruction summary text
|
|
||||||
*/
|
|
||||||
public String obstructionText() {
|
|
||||||
return obstructionSummary == null
|
|
||||||
? "-"
|
|
||||||
: obstructionSummary.obstructionText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a short UI text for direct LOS state.
|
|
||||||
*
|
|
||||||
* @return LOS summary text
|
|
||||||
*/
|
|
||||||
public String losText() {
|
|
||||||
if (!hasUsableProfile()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return lineOfSightClear
|
|
||||||
? String.format(Locale.US, "Geometric LOS clear (min %+,.1f m)", minimumLineOfSightClearanceMeters)
|
|
||||||
: String.format(Locale.US, "Geometric LOS blocked (min %+,.1f m)", minimumLineOfSightClearanceMeters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a short UI text for the minimum LOS clearance.
|
|
||||||
*
|
|
||||||
* @return worst direct-path clearance text
|
|
||||||
*/
|
|
||||||
public String worstClearanceText() {
|
|
||||||
if (!hasUsableProfile() || !Double.isFinite(minimumLineOfSightClearanceMeters)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return String.format(Locale.US, "%.1f m", minimumLineOfSightClearanceMeters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a short UI text for the analysis frequency.
|
|
||||||
*
|
|
||||||
* @return frequency text or "-"
|
|
||||||
*/
|
|
||||||
public String analysisFrequencyText() {
|
|
||||||
if (!Double.isFinite(analysisFrequencyMHz) || analysisFrequencyMHz <= 0.0) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return String.format(Locale.US, "%.3f MHz", analysisFrequencyMHz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a short UI text for the Fresnel state.
|
|
||||||
*
|
|
||||||
* @return Fresnel summary text
|
|
||||||
*/
|
|
||||||
public String fresnelText() {
|
|
||||||
if (!hasUsableProfile()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fresnelClear) {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"1st Fresnel clear (min %+,.1f m)",
|
|
||||||
minimumLowerFresnelClearanceMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"1st Fresnel intruded (worst %.1f m)",
|
|
||||||
worstFresnelIntrusionMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a short UI text for the worst Fresnel intrusion.
|
|
||||||
*
|
|
||||||
* @return worst Fresnel intrusion text
|
|
||||||
*/
|
|
||||||
public String worstFresnelClearanceText() {
|
|
||||||
if (!hasUsableProfile()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Double.isFinite(worstFresnelIntrusionMeters) && worstFresnelIntrusionMeters > 0.0) {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%.1f m @ %.1f km (%.0f%%)",
|
|
||||||
worstFresnelIntrusionMeters,
|
|
||||||
worstFresnelDistanceKm,
|
|
||||||
worstFresnelIntrusionRatio * 100.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Double.isFinite(minimumLowerFresnelClearanceMeters)) {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"No intrusion (min %.1f m)",
|
|
||||||
minimumLowerFresnelClearanceMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the effective Earth radius / refraction model text used by this
|
|
||||||
* analysis result.
|
|
||||||
*
|
|
||||||
* @return k-factor text
|
|
||||||
*/
|
|
||||||
public String effectiveEarthRadiusText() {
|
|
||||||
return horizonSummary == null
|
|
||||||
? "-"
|
|
||||||
: horizonSummary.effectiveEarthRadiusText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a concise simple radio-horizon summary for both endpoint antenna
|
|
||||||
* heights.
|
|
||||||
*
|
|
||||||
* @return radio-horizon summary
|
|
||||||
*/
|
|
||||||
public String radioHorizonText() {
|
|
||||||
return horizonSummary == null
|
|
||||||
? "-"
|
|
||||||
: horizonSummary.simpleRadioHorizonText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the actual terrain horizon derived from the current terrain profile.
|
|
||||||
*
|
|
||||||
* @return terrain-horizon summary
|
|
||||||
*/
|
|
||||||
public String terrainHorizonText() {
|
|
||||||
return horizonSummary == null
|
|
||||||
? "-"
|
|
||||||
: horizonSummary.terrainHorizonText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the distance as formatted text.
|
|
||||||
*
|
|
||||||
* @return distance text or "-"
|
|
||||||
*/
|
|
||||||
public String distanceText() {
|
|
||||||
if (!Double.isFinite(distanceKm) || distanceKm < 0.0) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return String.format(Locale.US, "%.1f km", distanceKm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the initial path bearing as formatted text.
|
|
||||||
*
|
|
||||||
* @return bearing text or "-"
|
|
||||||
*/
|
|
||||||
public String bearingText() {
|
|
||||||
if (!Double.isFinite(bearingDeg)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
return String.format(Locale.US, "%.0f°", bearingDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a concise endpoint summary with ground level and configured antenna heights.
|
|
||||||
*
|
|
||||||
* @return endpoint/antenna summary text
|
|
||||||
*/
|
|
||||||
public String endpointSummaryText() {
|
|
||||||
PathProfilePoint firstPoint = firstProfilePoint();
|
|
||||||
PathProfilePoint lastPoint = lastProfilePoint();
|
|
||||||
|
|
||||||
boolean hasHomeGround = firstPoint != null && Double.isFinite(firstPoint.elevationMeters());
|
|
||||||
boolean hasTargetGround = lastPoint != null && Double.isFinite(lastPoint.elevationMeters());
|
|
||||||
|
|
||||||
boolean hasHomeAntenna = Double.isFinite(homeAntennaHeightMeters);
|
|
||||||
boolean hasTargetAntenna = Double.isFinite(targetAntennaHeightMeters);
|
|
||||||
|
|
||||||
String homeText;
|
|
||||||
if (hasHomeGround && hasHomeAntenna) {
|
|
||||||
homeText = String.format(
|
|
||||||
Locale.US,
|
|
||||||
"Home %.0f m ASL + %.0f m AGL",
|
|
||||||
firstPoint.elevationMeters(),
|
|
||||||
homeAntennaHeightMeters
|
|
||||||
);
|
|
||||||
} else if (hasHomeAntenna) {
|
|
||||||
homeText = String.format(Locale.US, "Home +%.0f m AGL", homeAntennaHeightMeters);
|
|
||||||
} else {
|
|
||||||
homeText = "Home -";
|
|
||||||
}
|
|
||||||
|
|
||||||
String targetText;
|
|
||||||
if (hasTargetGround && hasTargetAntenna) {
|
|
||||||
targetText = String.format(
|
|
||||||
Locale.US,
|
|
||||||
"DX %.0f m ASL + %.0f m AGL",
|
|
||||||
lastPoint.elevationMeters(),
|
|
||||||
targetAntennaHeightMeters
|
|
||||||
);
|
|
||||||
} else if (hasTargetAntenna) {
|
|
||||||
targetText = String.format(Locale.US, "DX +%.0f m AGL", targetAntennaHeightMeters);
|
|
||||||
} else {
|
|
||||||
targetText = "DX -";
|
|
||||||
}
|
|
||||||
|
|
||||||
return homeText + " | " + targetText;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the operator-facing propagation assessment.
|
|
||||||
*
|
|
||||||
* @return short propagation assessment text
|
|
||||||
*/
|
|
||||||
public String propagationAssessmentText() {
|
|
||||||
return propagationAssessment == null
|
|
||||||
? "-"
|
|
||||||
: propagationAssessment.shortText();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the likely propagation mechanisms suggested by the current assessment.
|
|
||||||
*
|
|
||||||
* @return mechanism summary
|
|
||||||
*/
|
|
||||||
public String propagationMechanismsText() {
|
|
||||||
return propagationAssessment == null
|
|
||||||
? "-"
|
|
||||||
: propagationAssessment.likelyMechanisms();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a numeric severity level from 0 to 5.
|
|
||||||
*
|
|
||||||
* @return severity level
|
|
||||||
*/
|
|
||||||
public int propagationSeverityLevel() {
|
|
||||||
return propagationAssessment == null
|
|
||||||
? 0
|
|
||||||
: propagationAssessment.severityLevel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private PathProfilePoint firstProfilePoint() {
|
|
||||||
return profilePoints.isEmpty() ? null : profilePoints.get(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PathProfilePoint lastProfilePoint() {
|
|
||||||
return profilePoints.isEmpty() ? null : profilePoints.get(profilePoints.size() - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeText(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String linkBudgetText() {
|
|
||||||
return linkBudgetSummary == null
|
|
||||||
? "-"
|
|
||||||
: linkBudgetSummary.ssbMarginText();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String linkBudgetRxPowerText() {
|
|
||||||
return linkBudgetSummary == null
|
|
||||||
? "-"
|
|
||||||
: linkBudgetSummary.rxPowerText();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String linkBudgetDetailText() {
|
|
||||||
return linkBudgetSummary == null
|
|
||||||
? "-"
|
|
||||||
: linkBudgetSummary.linkBudgetDetailText();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String cwHintText() {
|
|
||||||
return linkBudgetSummary == null
|
|
||||||
? "-"
|
|
||||||
: linkBudgetSummary.cwHintText();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstraction for path analysis.
|
|
||||||
*
|
|
||||||
* First implementation can be geometry-only.
|
|
||||||
* Later this can call terrain APIs, caching layers, LOS checks, etc.
|
|
||||||
*/
|
|
||||||
public interface PathAnalysisService {
|
|
||||||
|
|
||||||
PathAnalysisResult analyze(PathAnalysisRequest request);
|
|
||||||
}
|
|
||||||
@@ -1,698 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared helper methods for path geometry and radio-path calculations.
|
|
||||||
*
|
|
||||||
* <p>This class intentionally centralizes:
|
|
||||||
* <ul>
|
|
||||||
* <li>great-circle geometry</li>
|
|
||||||
* <li>Earth curvature calculations</li>
|
|
||||||
* <li>Fresnel calculations</li>
|
|
||||||
* <li>adaptive profile sampling heuristics</li>
|
|
||||||
* <li>default/fallback frequency handling</li>
|
|
||||||
* <li>tolerant frequency parsing from UI/chat strings</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public final class PathGeometryUtils {
|
|
||||||
|
|
||||||
private static final double EARTH_RADIUS_METERS = 6_371_009.0;
|
|
||||||
private static final double EARTH_RADIUS_KM = EARTH_RADIUS_METERS / 1000.0;
|
|
||||||
private static final double SPEED_OF_LIGHT_METERS_PER_SECOND = 299_792_458.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default effective Earth radius factor used for VHF/UHF path geometry.
|
|
||||||
*
|
|
||||||
* <p>k = 4/3 is the common standard-atmosphere approximation. It bends the
|
|
||||||
* radio path slightly with the atmosphere and therefore reduces the apparent
|
|
||||||
* Earth bulge compared with pure optical geometry.</p>
|
|
||||||
*
|
|
||||||
* <p>This is still only a geometric/refraction approximation. It does not model
|
|
||||||
* troposcatter, aircraft scatter, ducting or diffraction loss numerically.</p>
|
|
||||||
*/
|
|
||||||
public static final double DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR = 4.0 / 3.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Central fallback frequency for path analysis when no station frequency
|
|
||||||
* could be extracted from the chat member / aggregated marker data.
|
|
||||||
*
|
|
||||||
* Easy to change later if users mainly work on another band.
|
|
||||||
*/
|
|
||||||
public static final double DEFAULT_ANALYSIS_FREQUENCY_MHZ = 144.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Common practical recommendation: at least 60% of the first Fresnel zone
|
|
||||||
* should remain clear.
|
|
||||||
*/
|
|
||||||
public static final double DEFAULT_FRESNEL_CLEARANCE_FACTOR = 0.60;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current sampling heuristic for terrain/profile analysis.
|
|
||||||
*
|
|
||||||
* <p>The active goal is to sample approximately every 0.5 km while
|
|
||||||
* keeping the UI responsive. A later batch/service variant can use
|
|
||||||
* different limits.</p>
|
|
||||||
*/
|
|
||||||
public static final double DEFAULT_TARGET_SAMPLE_STEP_KM = 0.5;
|
|
||||||
public static final int MIN_PROFILE_SAMPLE_COUNT = 121;
|
|
||||||
public static final int MAX_PROFILE_SAMPLE_COUNT = 1201;
|
|
||||||
|
|
||||||
private static final Pattern FREQUENCY_MHZ_PATTERN =
|
|
||||||
Pattern.compile("(?i)(\\d{2,6}(?:[\\.,]\\d+)?)\\s*(?:mhz)?");
|
|
||||||
|
|
||||||
private PathGeometryUtils() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Great-circle distance in kilometers using the haversine formula.
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @return great-circle distance in kilometers
|
|
||||||
*/
|
|
||||||
public static double calculateGreatCircleDistanceKm(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg) {
|
|
||||||
|
|
||||||
if (!Double.isFinite(fromLatitudeDeg)
|
|
||||||
|| !Double.isFinite(fromLongitudeDeg)
|
|
||||||
|| !Double.isFinite(toLatitudeDeg)
|
|
||||||
|| !Double.isFinite(toLongitudeDeg)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
|
||||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
|
||||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
|
||||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
|
||||||
|
|
||||||
double deltaLatitude = toLatitudeRad - fromLatitudeRad;
|
|
||||||
double deltaLongitude = toLongitudeRad - fromLongitudeRad;
|
|
||||||
|
|
||||||
double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0)
|
|
||||||
+ Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad)
|
|
||||||
* Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0);
|
|
||||||
|
|
||||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a)));
|
|
||||||
|
|
||||||
return EARTH_RADIUS_KM * c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initial great-circle bearing in degrees from north.
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @return initial bearing in degrees within [0, 360)
|
|
||||||
*/
|
|
||||||
public static double calculateInitialBearingDeg(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg) {
|
|
||||||
|
|
||||||
if (!Double.isFinite(fromLatitudeDeg)
|
|
||||||
|| !Double.isFinite(fromLongitudeDeg)
|
|
||||||
|| !Double.isFinite(toLatitudeDeg)
|
|
||||||
|| !Double.isFinite(toLongitudeDeg)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
|
||||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
|
||||||
double deltaLongitudeRad = Math.toRadians(toLongitudeDeg - fromLongitudeDeg);
|
|
||||||
|
|
||||||
double y = Math.sin(deltaLongitudeRad) * Math.cos(toLatitudeRad);
|
|
||||||
double x = Math.cos(fromLatitudeRad) * Math.sin(toLatitudeRad)
|
|
||||||
- Math.sin(fromLatitudeRad) * Math.cos(toLatitudeRad) * Math.cos(deltaLongitudeRad);
|
|
||||||
|
|
||||||
double bearingDeg = Math.toDegrees(Math.atan2(y, x));
|
|
||||||
|
|
||||||
return normalizeBearingDeg(bearingDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interpolates one point on the great-circle path between two endpoints.
|
|
||||||
*
|
|
||||||
* <p>This uses spherical linear interpolation (slerp) on the unit sphere.
|
|
||||||
* It avoids the path distortion that appears when latitude and longitude
|
|
||||||
* are interpolated independently.</p>
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @param t interpolation factor in [0, 1]
|
|
||||||
* @return interpolated great-circle point
|
|
||||||
*/
|
|
||||||
public static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg,
|
|
||||||
double t) {
|
|
||||||
|
|
||||||
if (!Double.isFinite(fromLatitudeDeg)
|
|
||||||
|| !Double.isFinite(fromLongitudeDeg)
|
|
||||||
|| !Double.isFinite(toLatitudeDeg)
|
|
||||||
|| !Double.isFinite(toLongitudeDeg)
|
|
||||||
|| !Double.isFinite(t)) {
|
|
||||||
return new GeoPoint(Double.NaN, Double.NaN);
|
|
||||||
}
|
|
||||||
|
|
||||||
double clampedT = clamp(t, 0.0, 1.0);
|
|
||||||
|
|
||||||
if (clampedT <= 0.0) {
|
|
||||||
return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clampedT >= 1.0) {
|
|
||||||
return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg));
|
|
||||||
}
|
|
||||||
|
|
||||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
|
||||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
|
||||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
|
||||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
|
||||||
|
|
||||||
double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad);
|
|
||||||
double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad);
|
|
||||||
double z1 = Math.sin(fromLatitudeRad);
|
|
||||||
|
|
||||||
double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad);
|
|
||||||
double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad);
|
|
||||||
double z2 = Math.sin(toLatitudeRad);
|
|
||||||
|
|
||||||
double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0);
|
|
||||||
double omega = Math.acos(dot);
|
|
||||||
|
|
||||||
if (omega < 1e-12) {
|
|
||||||
double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT;
|
|
||||||
double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT);
|
|
||||||
return new GeoPoint(latitudeDeg, longitudeDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
double sinOmega = Math.sin(omega);
|
|
||||||
double a = Math.sin((1.0 - clampedT) * omega) / sinOmega;
|
|
||||||
double b = Math.sin(clampedT * omega) / sinOmega;
|
|
||||||
|
|
||||||
double x = a * x1 + b * x2;
|
|
||||||
double y = a * y1 + b * y2;
|
|
||||||
double z = a * z1 + b * z2;
|
|
||||||
|
|
||||||
double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y));
|
|
||||||
double longitudeRad = Math.atan2(y, x);
|
|
||||||
|
|
||||||
return new GeoPoint(
|
|
||||||
Math.toDegrees(latitudeRad),
|
|
||||||
normalizeLongitudeDeg(Math.toDegrees(longitudeRad))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves an adaptive terrain/profile sample count from the full path distance.
|
|
||||||
*
|
|
||||||
* <p>Current heuristic:
|
|
||||||
* <ul>
|
|
||||||
* <li>target spacing about 0.5 km</li>
|
|
||||||
* <li>minimum 121 samples</li>
|
|
||||||
* <li>maximum 1201 samples</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param totalDistanceKm full path distance in kilometers
|
|
||||||
* @return clamped sample count
|
|
||||||
*/
|
|
||||||
public static int resolveAdaptiveSampleCount(double totalDistanceKm) {
|
|
||||||
if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) {
|
|
||||||
return MIN_PROFILE_SAMPLE_COUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
int computedSampleCount = (int) Math.ceil(totalDistanceKm / DEFAULT_TARGET_SAMPLE_STEP_KM) + 1;
|
|
||||||
|
|
||||||
return clampInt(computedSampleCount, MIN_PROFILE_SAMPLE_COUNT, MAX_PROFILE_SAMPLE_COUNT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Earth bulge above the straight endpoint chord at one point
|
|
||||||
* along the path.
|
|
||||||
*
|
|
||||||
* <p>Distances are given along the path in kilometers.</p>
|
|
||||||
*
|
|
||||||
* <p>Approximation:
|
|
||||||
* bulge = d1 * d2 / (2 * R_eff)</p>
|
|
||||||
*/
|
|
||||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
|
||||||
double totalDistanceKm) {
|
|
||||||
return calculateEarthBulgeMeters(
|
|
||||||
distanceFromStartKm,
|
|
||||||
totalDistanceKm,
|
|
||||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static double calculateEarthBulgeMeters(double distanceFromStartKm,
|
|
||||||
double totalDistanceKm,
|
|
||||||
double effectiveEarthRadiusFactor) {
|
|
||||||
|
|
||||||
if (!Double.isFinite(distanceFromStartKm)
|
|
||||||
|| !Double.isFinite(totalDistanceKm)
|
|
||||||
|| !Double.isFinite(effectiveEarthRadiusFactor)
|
|
||||||
|| totalDistanceKm <= 0.0
|
|
||||||
|| effectiveEarthRadiusFactor <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
|
||||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
|
||||||
|
|
||||||
double distanceFromStartMeters = clampedDistanceFromStartKm * 1000.0;
|
|
||||||
double distanceToTargetMeters = distanceToTargetKm * 1000.0;
|
|
||||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * effectiveEarthRadiusFactor;
|
|
||||||
|
|
||||||
return (distanceFromStartMeters * distanceToTargetMeters) / (2.0 * effectiveEarthRadiusMeters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns terrain height plus Earth curvature using the default effective Earth
|
|
||||||
* radius factor.
|
|
||||||
*
|
|
||||||
* @param point terrain/profile point
|
|
||||||
* @param totalDistanceKm full path distance in kilometers
|
|
||||||
* @return curvature-adjusted terrain height in meters
|
|
||||||
*/
|
|
||||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
|
||||||
double totalDistanceKm) {
|
|
||||||
return calculateCurvatureAdjustedElevationMeters(
|
|
||||||
point,
|
|
||||||
totalDistanceKm,
|
|
||||||
DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns terrain height plus Earth curvature using the given effective Earth
|
|
||||||
* radius factor.
|
|
||||||
*
|
|
||||||
* <p>k = 1.0 means optical/geometric Earth curvature. k = 4/3 is the common
|
|
||||||
* standard radio-refraction approximation for VHF/UHF path previews.</p>
|
|
||||||
*
|
|
||||||
* @param point terrain/profile point
|
|
||||||
* @param totalDistanceKm full path distance in kilometers
|
|
||||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
|
||||||
* @return curvature-adjusted terrain height in meters
|
|
||||||
*/
|
|
||||||
public static double calculateCurvatureAdjustedElevationMeters(PathProfilePoint point,
|
|
||||||
double totalDistanceKm,
|
|
||||||
double effectiveEarthRadiusFactor) {
|
|
||||||
if (point == null || !Double.isFinite(point.elevationMeters())) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return point.elevationMeters()
|
|
||||||
+ calculateEarthBulgeMeters(
|
|
||||||
point.distanceKm(),
|
|
||||||
totalDistanceKm,
|
|
||||||
sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the simple radio horizon distance from antenna height above ground.
|
|
||||||
*
|
|
||||||
* <p>The result is based on the effective Earth radius model:
|
|
||||||
* d = sqrt(2 * R_eff * h)</p>
|
|
||||||
*
|
|
||||||
* <p>This is a local tangent-horizon approximation. It is useful as an operator
|
|
||||||
* hint, but it is not a complete propagation prediction.</p>
|
|
||||||
*
|
|
||||||
* @param antennaHeightMeters antenna height above local ground in meters
|
|
||||||
* @param effectiveEarthRadiusFactor k-factor for effective Earth radius
|
|
||||||
* @return radio horizon distance in kilometers
|
|
||||||
*/
|
|
||||||
public static double calculateRadioHorizonDistanceKm(double antennaHeightMeters,
|
|
||||||
double effectiveEarthRadiusFactor) {
|
|
||||||
if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double sanitizedFactor = sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
|
||||||
double effectiveEarthRadiusMeters = EARTH_RADIUS_METERS * sanitizedFactor;
|
|
||||||
|
|
||||||
return Math.sqrt(2.0 * effectiveEarthRadiusMeters * antennaHeightMeters) / 1000.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the apparent elevation angle from an observer height to a target
|
|
||||||
* height over a given distance.
|
|
||||||
*
|
|
||||||
* @param observerHeightMeters observer height in the already curvature-adjusted profile space
|
|
||||||
* @param targetHeightMeters target height in the same profile space
|
|
||||||
* @param distanceKm distance between observer and target in kilometers
|
|
||||||
* @return elevation angle in degrees
|
|
||||||
*/
|
|
||||||
public static double calculateElevationAngleDeg(double observerHeightMeters,
|
|
||||||
double targetHeightMeters,
|
|
||||||
double distanceKm) {
|
|
||||||
if (!Double.isFinite(observerHeightMeters)
|
|
||||||
|| !Double.isFinite(targetHeightMeters)
|
|
||||||
|| !Double.isFinite(distanceKm)
|
|
||||||
|| distanceKm <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double distanceMeters = distanceKm * 1000.0;
|
|
||||||
return Math.toDegrees(Math.atan2(targetHeightMeters - observerHeightMeters, distanceMeters));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitizes the effective Earth radius factor.
|
|
||||||
*
|
|
||||||
* <p>Values outside a practical range are folded back to the standard default
|
|
||||||
* so broken preferences or malformed future XML values cannot destabilize path
|
|
||||||
* analysis.</p>
|
|
||||||
*
|
|
||||||
* @param effectiveEarthRadiusFactor raw k-factor
|
|
||||||
* @return usable k-factor
|
|
||||||
*/
|
|
||||||
public static double sanitizeEffectiveEarthRadiusFactor(double effectiveEarthRadiusFactor) {
|
|
||||||
if (!Double.isFinite(effectiveEarthRadiusFactor)
|
|
||||||
|| effectiveEarthRadiusFactor < 0.5
|
|
||||||
|| effectiveEarthRadiusFactor > 10.0) {
|
|
||||||
return DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
return effectiveEarthRadiusFactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a frequency in MHz to wavelength in meters.
|
|
||||||
*
|
|
||||||
* @param frequencyMHz signal frequency in MHz
|
|
||||||
* @return wavelength in meters
|
|
||||||
*/
|
|
||||||
public static double calculateWavelengthMeters(double frequencyMHz) {
|
|
||||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
return SPEED_OF_LIGHT_METERS_PER_SECOND / (frequencyMHz * 1_000_000.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First Fresnel zone radius at one point along the path.
|
|
||||||
*
|
|
||||||
* @param distanceFromStartKm distance from TX in kilometers
|
|
||||||
* @param totalDistanceKm total path distance in kilometers
|
|
||||||
* @param frequencyMHz signal frequency in MHz
|
|
||||||
* @return first Fresnel radius in meters
|
|
||||||
*/
|
|
||||||
public static double calculateFirstFresnelRadiusMeters(double distanceFromStartKm,
|
|
||||||
double totalDistanceKm,
|
|
||||||
double frequencyMHz) {
|
|
||||||
|
|
||||||
if (!Double.isFinite(distanceFromStartKm)
|
|
||||||
|| !Double.isFinite(totalDistanceKm)
|
|
||||||
|| !Double.isFinite(frequencyMHz)
|
|
||||||
|| totalDistanceKm <= 0.0
|
|
||||||
|| frequencyMHz <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double clampedDistanceFromStartKm = Math.max(0.0, Math.min(distanceFromStartKm, totalDistanceKm));
|
|
||||||
double distanceToTargetKm = totalDistanceKm - clampedDistanceFromStartKm;
|
|
||||||
|
|
||||||
double d1Meters = clampedDistanceFromStartKm * 1000.0;
|
|
||||||
double d2Meters = distanceToTargetKm * 1000.0;
|
|
||||||
|
|
||||||
if (d1Meters <= 0.0 || d2Meters <= 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
|
||||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.sqrt((wavelengthMeters * d1Meters * d2Meters) / (d1Meters + d2Meters));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recommended minimum clearance, currently 60% of the first Fresnel zone.
|
|
||||||
*
|
|
||||||
* @param distanceFromStartKm distance from TX in kilometers
|
|
||||||
* @param totalDistanceKm total path distance in kilometers
|
|
||||||
* @param frequencyMHz signal frequency in MHz
|
|
||||||
* @return recommended clearance in meters
|
|
||||||
*/
|
|
||||||
public static double calculateRecommendedFresnelClearanceMeters(double distanceFromStartKm,
|
|
||||||
double totalDistanceKm,
|
|
||||||
double frequencyMHz) {
|
|
||||||
|
|
||||||
double firstFresnelRadiusMeters =
|
|
||||||
calculateFirstFresnelRadiusMeters(distanceFromStartKm, totalDistanceKm, frequencyMHz);
|
|
||||||
|
|
||||||
if (!Double.isFinite(firstFresnelRadiusMeters)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstFresnelRadiusMeters * DEFAULT_FRESNEL_CLEARANCE_FACTOR;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries to extract a MHz frequency from strings such as:
|
|
||||||
* <ul>
|
|
||||||
* <li>"144.300"</li>
|
|
||||||
* <li>"144,300"</li>
|
|
||||||
* <li>"144.300 MHz"</li>
|
|
||||||
* <li>"QRG 432.174 MHz"</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param rawText free-form raw text
|
|
||||||
* @return parsed frequency in MHz or NaN
|
|
||||||
*/
|
|
||||||
public static double tryParseFrequencyMHz(String rawText) {
|
|
||||||
if (rawText == null || rawText.isBlank()) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
Matcher matcher = FREQUENCY_MHZ_PATTERN.matcher(rawText.trim());
|
|
||||||
if (!matcher.find()) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
String numericText = matcher.group(1).replace(',', '.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
double parsedFrequencyMHz = Double.parseDouble(numericText);
|
|
||||||
return parsedFrequencyMHz > 0.0 ? parsedFrequencyMHz : Double.NaN;
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves one usable analysis frequency from aggregated marker frequency data.
|
|
||||||
*
|
|
||||||
* <p>Strategy:
|
|
||||||
* <ol>
|
|
||||||
* <li>Prefer 144 MHz data if available</li>
|
|
||||||
* <li>Otherwise use the first parsable known station frequency</li>
|
|
||||||
* <li>Otherwise use the central default frequency</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* @param frequenciesByBand known frequencies grouped by band
|
|
||||||
* @return resolved analysis frequency in MHz
|
|
||||||
*/
|
|
||||||
public static double resolveAnalysisFrequencyMHz(Map<String, String> frequenciesByBand) {
|
|
||||||
if (frequenciesByBand != null && !frequenciesByBand.isEmpty()) {
|
|
||||||
String band144Text = frequenciesByBand.get("144");
|
|
||||||
double parsed144 = tryParseFrequencyMHz(band144Text);
|
|
||||||
if (Double.isFinite(parsed144) && parsed144 > 0.0) {
|
|
||||||
return parsed144;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String value : frequenciesByBand.values()) {
|
|
||||||
double parsedFrequencyMHz = tryParseFrequencyMHz(value);
|
|
||||||
if (Double.isFinite(parsedFrequencyMHz) && parsedFrequencyMHz > 0.0) {
|
|
||||||
return parsedFrequencyMHz;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEFAULT_ANALYSIS_FREQUENCY_MHZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small immutable geographic point used by great-circle interpolation.
|
|
||||||
*
|
|
||||||
* @param latitudeDeg latitude in degrees
|
|
||||||
* @param longitudeDeg longitude in degrees
|
|
||||||
*/
|
|
||||||
public record GeoPoint(double latitudeDeg, double longitudeDeg) {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double normalizeLongitudeDeg(double longitudeDeg) {
|
|
||||||
if (!Double.isFinite(longitudeDeg)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double normalized = longitudeDeg % 360.0;
|
|
||||||
if (normalized > 180.0) {
|
|
||||||
normalized -= 360.0;
|
|
||||||
} else if (normalized <= -180.0) {
|
|
||||||
normalized += 360.0;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double normalizeBearingDeg(double bearingDeg) {
|
|
||||||
if (!Double.isFinite(bearingDeg)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double normalized = bearingDeg % 360.0;
|
|
||||||
if (normalized < 0.0) {
|
|
||||||
normalized += 360.0;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double clamp(double value, double minValue, double maxValue) {
|
|
||||||
return Math.max(minValue, Math.min(maxValue, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int clampInt(int value, int minValue, int maxValue) {
|
|
||||||
return Math.max(minValue, Math.min(maxValue, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates the Fresnel-Kirchhoff diffraction parameter v for a single
|
|
||||||
* obstruction.
|
|
||||||
*
|
|
||||||
* <p>Positive height means the obstruction is above the direct line of sight.
|
|
||||||
* This method is useful for a rough single-knife-edge severity estimate.</p>
|
|
||||||
*
|
|
||||||
* @param heightAboveLosMeters obstruction height above direct LOS in meters
|
|
||||||
* @param distanceFromHomeKm distance from home endpoint to obstruction
|
|
||||||
* @param distanceFromTargetKm distance from target endpoint to obstruction
|
|
||||||
* @param frequencyMHz analysis frequency in MHz
|
|
||||||
* @return diffraction parameter v
|
|
||||||
*/
|
|
||||||
public static double calculateKnifeEdgeVParameter(double heightAboveLosMeters,
|
|
||||||
double distanceFromHomeKm,
|
|
||||||
double distanceFromTargetKm,
|
|
||||||
double frequencyMHz) {
|
|
||||||
if (!Double.isFinite(heightAboveLosMeters)
|
|
||||||
|| !Double.isFinite(distanceFromHomeKm)
|
|
||||||
|| !Double.isFinite(distanceFromTargetKm)
|
|
||||||
|| distanceFromHomeKm <= 0.0
|
|
||||||
|| distanceFromTargetKm <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double wavelengthMeters = calculateWavelengthMeters(frequencyMHz);
|
|
||||||
if (!Double.isFinite(wavelengthMeters) || wavelengthMeters <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
double d1Meters = distanceFromHomeKm * 1000.0;
|
|
||||||
double d2Meters = distanceFromTargetKm * 1000.0;
|
|
||||||
|
|
||||||
return heightAboveLosMeters
|
|
||||||
* Math.sqrt(2.0 * (d1Meters + d2Meters) / (wavelengthMeters * d1Meters * d2Meters));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates the additional diffraction loss for a single knife edge.
|
|
||||||
*
|
|
||||||
* <p>This is a rough operator-facing indicator. It must not be interpreted as a
|
|
||||||
* complete path-loss model.</p>
|
|
||||||
*
|
|
||||||
* @param v Fresnel-Kirchhoff diffraction parameter
|
|
||||||
* @return estimated additional loss in dB
|
|
||||||
*/
|
|
||||||
public static double calculateSingleKnifeEdgeLossDb(double v) {
|
|
||||||
if (!Double.isFinite(v)) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (v <= -0.78) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 6.9 + 20.0 * Math.log10(
|
|
||||||
Math.sqrt(Math.pow(v - 0.1, 2.0) + 1.0) + v - 0.1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts RF power from watts to dBm.
|
|
||||||
*
|
|
||||||
* @param watts power in watts
|
|
||||||
* @return power in dBm
|
|
||||||
*/
|
|
||||||
public static double wattsToDbm(double watts) {
|
|
||||||
if (!Double.isFinite(watts) || watts <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 10.0 * Math.log10(watts * 1000.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates free-space path loss.
|
|
||||||
*
|
|
||||||
* @param distanceKm path distance in kilometers
|
|
||||||
* @param frequencyMHz frequency in MHz
|
|
||||||
* @return free-space path loss in dB
|
|
||||||
*/
|
|
||||||
public static double calculateFreeSpacePathLossDb(double distanceKm, double frequencyMHz) {
|
|
||||||
if (!Double.isFinite(distanceKm)
|
|
||||||
|| !Double.isFinite(frequencyMHz)
|
|
||||||
|| distanceKm <= 0.0
|
|
||||||
|| frequencyMHz <= 0.0) {
|
|
||||||
return Double.NaN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 32.44 + 20.0 * Math.log10(distanceKm) + 20.0 * Math.log10(frequencyMHz);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates per-station feeder loss from a simple VHF baseline and a frequency
|
|
||||||
* dependent increase.
|
|
||||||
*
|
|
||||||
* <p>The result is capped because a linear MHz-based feeder heuristic becomes
|
|
||||||
* unrealistic on microwave bands where transverters are often placed near the
|
|
||||||
* antenna. Later this should become a per-band user setting.</p>
|
|
||||||
*
|
|
||||||
* @param frequencyMHz frequency in MHz
|
|
||||||
* @param settings link-budget settings
|
|
||||||
* @return estimated per-station feeder loss in dB
|
|
||||||
*/
|
|
||||||
public static double estimateFeederLossPerStationDb(double frequencyMHz,
|
|
||||||
PathLinkBudgetSettings settings) {
|
|
||||||
PathLinkBudgetSettings safeSettings = settings == null
|
|
||||||
? PathLinkBudgetSettings.defaults()
|
|
||||||
: settings;
|
|
||||||
|
|
||||||
double baseLossDb = safeSettings.vhfFeederLossPerStationDb();
|
|
||||||
|
|
||||||
if (!Double.isFinite(frequencyMHz) || frequencyMHz <= 0.0) {
|
|
||||||
return baseLossDb;
|
|
||||||
}
|
|
||||||
|
|
||||||
double additionalLossDb = Math.max(0.0, (frequencyMHz - 144.0) / 200.0)
|
|
||||||
* safeSettings.feederLossIncreaseDbPer200MHz();
|
|
||||||
|
|
||||||
double estimatedLossDb = baseLossDb + additionalLossDb;
|
|
||||||
|
|
||||||
return Math.min(estimatedLossDb, safeSettings.maxEstimatedFeederLossPerStationDb());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable summary of simple radio-horizon and terrain-profile horizon data.
|
|
||||||
*
|
|
||||||
* <p>The simple radio horizon is based only on antenna height and effective
|
|
||||||
* Earth radius. The terrain horizon is derived from the actual path profile and
|
|
||||||
* therefore represents the highest apparent terrain angle from each endpoint.</p>
|
|
||||||
*/
|
|
||||||
public record PathHorizonSummary(
|
|
||||||
double effectiveEarthRadiusFactor,
|
|
||||||
|
|
||||||
double homeSimpleRadioHorizonKm,
|
|
||||||
double targetSimpleRadioHorizonKm,
|
|
||||||
double combinedSimpleRadioHorizonKm,
|
|
||||||
|
|
||||||
double homeTerrainHorizonPathDistanceKm,
|
|
||||||
double homeTerrainHorizonElevationAngleDeg,
|
|
||||||
int homeTerrainHorizonSampleIndex,
|
|
||||||
|
|
||||||
double targetTerrainHorizonPathDistanceKm,
|
|
||||||
double targetTerrainHorizonDistanceFromTargetKm,
|
|
||||||
double targetTerrainHorizonElevationAngleDeg,
|
|
||||||
int targetTerrainHorizonSampleIndex
|
|
||||||
) {
|
|
||||||
|
|
||||||
public PathHorizonSummary {
|
|
||||||
effectiveEarthRadiusFactor =
|
|
||||||
PathGeometryUtils.sanitizeEffectiveEarthRadiusFactor(effectiveEarthRadiusFactor);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathHorizonSummary empty() {
|
|
||||||
return new PathHorizonSummary(
|
|
||||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasHomeTerrainHorizon() {
|
|
||||||
return homeTerrainHorizonSampleIndex >= 0
|
|
||||||
&& Double.isFinite(homeTerrainHorizonPathDistanceKm)
|
|
||||||
&& Double.isFinite(homeTerrainHorizonElevationAngleDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasTargetTerrainHorizon() {
|
|
||||||
return targetTerrainHorizonSampleIndex >= 0
|
|
||||||
&& Double.isFinite(targetTerrainHorizonPathDistanceKm)
|
|
||||||
&& Double.isFinite(targetTerrainHorizonDistanceFromTargetKm)
|
|
||||||
&& Double.isFinite(targetTerrainHorizonElevationAngleDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String effectiveEarthRadiusText() {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"k = %.2f effective Earth radius",
|
|
||||||
effectiveEarthRadiusFactor
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String simpleRadioHorizonText() {
|
|
||||||
if (!Double.isFinite(homeSimpleRadioHorizonKm)
|
|
||||||
|| !Double.isFinite(targetSimpleRadioHorizonKm)
|
|
||||||
|| !Double.isFinite(combinedSimpleRadioHorizonKm)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"Home %.1f km | DX %.1f km | combined %.1f km",
|
|
||||||
homeSimpleRadioHorizonKm,
|
|
||||||
targetSimpleRadioHorizonKm,
|
|
||||||
combinedSimpleRadioHorizonKm
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String terrainHorizonText() {
|
|
||||||
String homeText = hasHomeTerrainHorizon()
|
|
||||||
? String.format(
|
|
||||||
Locale.US,
|
|
||||||
"Home %.1f km / %+.2f°",
|
|
||||||
homeTerrainHorizonPathDistanceKm,
|
|
||||||
homeTerrainHorizonElevationAngleDeg
|
|
||||||
)
|
|
||||||
: "Home -";
|
|
||||||
|
|
||||||
String targetText = hasTargetTerrainHorizon()
|
|
||||||
? String.format(
|
|
||||||
Locale.US,
|
|
||||||
"DX %.1f km from DX / %+.2f°",
|
|
||||||
targetTerrainHorizonDistanceFromTargetKm,
|
|
||||||
targetTerrainHorizonElevationAngleDeg
|
|
||||||
)
|
|
||||||
: "DX -";
|
|
||||||
|
|
||||||
return homeText + " | " + targetText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User/configuration values for the path link-budget estimate.
|
|
||||||
*
|
|
||||||
* <p>All antenna gains are dBi. If the user knows dBd values, add 2.15 dB
|
|
||||||
* before entering them as dBi. Example: 12 dBd = 14.15 dBi.</p>
|
|
||||||
*/
|
|
||||||
public record PathLinkBudgetSettings(
|
|
||||||
double ownTxPowerWatts,
|
|
||||||
double ownAntennaGainDbi,
|
|
||||||
double targetTxPowerWatts,
|
|
||||||
double targetAntennaGainDbi,
|
|
||||||
double vhfFeederLossPerStationDb,
|
|
||||||
double feederLossIncreaseDbPer200MHz,
|
|
||||||
double maxEstimatedFeederLossPerStationDb,
|
|
||||||
double requiredSsbSignalDbm,
|
|
||||||
double requiredCwSignalDbm,
|
|
||||||
double contestMarginDb
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static PathLinkBudgetSettings defaults() {
|
|
||||||
return new PathLinkBudgetSettings(
|
|
||||||
750.0,
|
|
||||||
8.0,
|
|
||||||
100.0,
|
|
||||||
8.0,
|
|
||||||
2.0,
|
|
||||||
2.0,
|
|
||||||
20.0,
|
|
||||||
-126.0,
|
|
||||||
-132.0,
|
|
||||||
6.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public PathLinkBudgetSettings {
|
|
||||||
ownTxPowerWatts = sanitizePositive(ownTxPowerWatts, 750.0);
|
|
||||||
ownAntennaGainDbi = sanitizeFinite(ownAntennaGainDbi, 8.0);
|
|
||||||
|
|
||||||
targetTxPowerWatts = sanitizePositive(targetTxPowerWatts, 100.0);
|
|
||||||
targetAntennaGainDbi = sanitizeFinite(targetAntennaGainDbi, 8.0);
|
|
||||||
|
|
||||||
vhfFeederLossPerStationDb = sanitizeNonNegative(vhfFeederLossPerStationDb, 2.0);
|
|
||||||
feederLossIncreaseDbPer200MHz = sanitizeNonNegative(feederLossIncreaseDbPer200MHz, 2.0);
|
|
||||||
maxEstimatedFeederLossPerStationDb = sanitizeNonNegative(maxEstimatedFeederLossPerStationDb, 20.0);
|
|
||||||
|
|
||||||
requiredSsbSignalDbm = sanitizeFinite(requiredSsbSignalDbm, -126.0);
|
|
||||||
requiredCwSignalDbm = sanitizeFinite(requiredCwSignalDbm, -132.0);
|
|
||||||
contestMarginDb = sanitizeNonNegative(contestMarginDb, 6.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double sanitizePositive(double value, double fallback) {
|
|
||||||
return Double.isFinite(value) && value > 0.0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double sanitizeNonNegative(double value, double fallback) {
|
|
||||||
return Double.isFinite(value) && value >= 0.0 ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double sanitizeFinite(double value, double fallback) {
|
|
||||||
return Double.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bidirectional link-budget estimate for one path.
|
|
||||||
*
|
|
||||||
* <p>The estimate is intentionally operator-facing. It combines free-space
|
|
||||||
* loss, the current diffraction/obstruction estimate, antenna gain, feeder loss,
|
|
||||||
* TX power and required SSB/CW receiver levels.</p>
|
|
||||||
*/
|
|
||||||
public record PathLinkBudgetSummary(
|
|
||||||
double frequencyMHz,
|
|
||||||
|
|
||||||
double ownTxPowerDbm,
|
|
||||||
double targetTxPowerDbm,
|
|
||||||
double ownAntennaGainDbi,
|
|
||||||
double targetAntennaGainDbi,
|
|
||||||
|
|
||||||
double ownFeederLossDb,
|
|
||||||
double targetFeederLossDb,
|
|
||||||
double freeSpacePathLossDb,
|
|
||||||
double diffractionLossDb,
|
|
||||||
|
|
||||||
double homeToTargetRxPowerDbm,
|
|
||||||
double targetToHomeRxPowerDbm,
|
|
||||||
|
|
||||||
double requiredSsbSignalDbm,
|
|
||||||
double requiredCwSignalDbm,
|
|
||||||
double contestMarginDb,
|
|
||||||
|
|
||||||
double homeToTargetSsbMarginDb,
|
|
||||||
double targetToHomeSsbMarginDb,
|
|
||||||
double bidirectionalSsbMarginDb,
|
|
||||||
|
|
||||||
double homeToTargetCwMarginDb,
|
|
||||||
double targetToHomeCwMarginDb,
|
|
||||||
double bidirectionalCwMarginDb
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static PathLinkBudgetSummary empty() {
|
|
||||||
return new PathLinkBudgetSummary(
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableBudget() {
|
|
||||||
return Double.isFinite(bidirectionalSsbMarginDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String ssbMarginText() {
|
|
||||||
if (!hasUsableBudget()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"SSB QSO margin %+.1f dB | H→DX %+.1f dB | DX→H %+.1f dB",
|
|
||||||
bidirectionalSsbMarginDb,
|
|
||||||
homeToTargetSsbMarginDb,
|
|
||||||
targetToHomeSsbMarginDb
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String rxPowerText() {
|
|
||||||
if (!Double.isFinite(homeToTargetRxPowerDbm)
|
|
||||||
|| !Double.isFinite(targetToHomeRxPowerDbm)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"H→DX %.1f dBm | DX→H %.1f dBm",
|
|
||||||
homeToTargetRxPowerDbm,
|
|
||||||
targetToHomeRxPowerDbm
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String linkBudgetDetailText() {
|
|
||||||
if (!hasUsableBudget()) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"FSPL %.1f dB, diffraction %.1f dB, feeder H %.1f dB / DX %.1f dB, required SSB %.1f dBm + %.1f dB margin",
|
|
||||||
freeSpacePathLossDb,
|
|
||||||
diffractionLossDb,
|
|
||||||
ownFeederLossDb,
|
|
||||||
targetFeederLossDb,
|
|
||||||
requiredSsbSignalDbm,
|
|
||||||
contestMarginDb
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String cwHintText() {
|
|
||||||
if (!Double.isFinite(bidirectionalCwMarginDb)) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bidirectionalSsbMarginDb >= 0.0) {
|
|
||||||
return String.format(Locale.US, "SSB workable; CW margin %+.1f dB.", bidirectionalCwMarginDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bidirectionalCwMarginDb >= 0.0) {
|
|
||||||
return String.format(Locale.US, "SSB marginal/weak, but CW may still be workable (%+.1f dB).", bidirectionalCwMarginDb);
|
|
||||||
}
|
|
||||||
|
|
||||||
return String.format(Locale.US, "Even CW budget is negative (%+.1f dB).", bidirectionalCwMarginDb);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summary of the dominant terrain obstruction and a rough single-knife-edge
|
|
||||||
* diffraction estimate.
|
|
||||||
*
|
|
||||||
* <p>This is intentionally only a severity indicator. It does not replace a full
|
|
||||||
* propagation model with multiple diffraction edges, clutter, refractivity,
|
|
||||||
* troposcatter, aircraft scatter or ducting.</p>
|
|
||||||
*/
|
|
||||||
public record PathObstructionSummary(
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
|
|
||||||
int dominantObstructionSampleIndex,
|
|
||||||
double dominantObstructionPathDistanceKm,
|
|
||||||
double dominantObstructionHeightAboveLosMeters,
|
|
||||||
double localFirstFresnelRadiusMeters,
|
|
||||||
double obstructionFresnelRatio,
|
|
||||||
double diffractionVParameter,
|
|
||||||
double estimatedKnifeEdgeLossDb,
|
|
||||||
|
|
||||||
int worstFresnelIntrusionSampleIndex,
|
|
||||||
double worstFresnelIntrusionPathDistanceKm,
|
|
||||||
double worstFresnelIntrusionMeters,
|
|
||||||
double worstFresnelIntrusionRatio
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static PathObstructionSummary empty() {
|
|
||||||
return new PathObstructionSummary(
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
-1,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasDominantLosObstruction() {
|
|
||||||
return dominantObstructionSampleIndex >= 0
|
|
||||||
&& Double.isFinite(dominantObstructionPathDistanceKm)
|
|
||||||
&& Double.isFinite(dominantObstructionHeightAboveLosMeters)
|
|
||||||
&& dominantObstructionHeightAboveLosMeters > 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasFresnelIntrusion() {
|
|
||||||
return worstFresnelIntrusionSampleIndex >= 0
|
|
||||||
&& Double.isFinite(worstFresnelIntrusionMeters)
|
|
||||||
&& worstFresnelIntrusionMeters > 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String obstructionText() {
|
|
||||||
if (hasDominantLosObstruction()) {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%.1f km | %.1f m above LOS | %.2f Fresnel radii | knife-edge ≈ %.1f dB (%s)",
|
|
||||||
dominantObstructionPathDistanceKm,
|
|
||||||
dominantObstructionHeightAboveLosMeters,
|
|
||||||
obstructionFresnelRatio,
|
|
||||||
estimatedKnifeEdgeLossDb,
|
|
||||||
severityText()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFresnelIntrusion()) {
|
|
||||||
return String.format(
|
|
||||||
Locale.US,
|
|
||||||
"No LOS-blocking edge. Fresnel intrusion %.1f m at %.1f km (%.0f%%).",
|
|
||||||
worstFresnelIntrusionMeters,
|
|
||||||
worstFresnelIntrusionPathDistanceKm,
|
|
||||||
worstFresnelIntrusionRatio * 100.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "No dominant LOS obstruction detected.";
|
|
||||||
}
|
|
||||||
|
|
||||||
public String severityText() {
|
|
||||||
if (!Double.isFinite(estimatedKnifeEdgeLossDb)) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimatedKnifeEdgeLossDb < 6.0) {
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimatedKnifeEdgeLossDb < 15.0) {
|
|
||||||
return "moderate";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimatedKnifeEdgeLossDb < 25.0) {
|
|
||||||
return "high";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "severe";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable terrain sample plus optional derived radio-path geometry.
|
|
||||||
*
|
|
||||||
* <p>The first four properties are the raw terrain profile payload:
|
|
||||||
* <ul>
|
|
||||||
* <li>distance along the path in kilometers</li>
|
|
||||||
* <li>sample latitude in degrees</li>
|
|
||||||
* <li>sample longitude in degrees</li>
|
|
||||||
* <li>terrain elevation in meters above mean sea level</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The remaining properties are optional derived values filled by
|
|
||||||
* {@link GeometryOnlyPathAnalysisService}. They are all expressed in the
|
|
||||||
* same chart/analysis space as the curvature-adjusted terrain profile:
|
|
||||||
* <ul>
|
|
||||||
* <li>curvature-adjusted terrain elevation</li>
|
|
||||||
* <li>direct line-of-sight height</li>
|
|
||||||
* <li>upper / lower first Fresnel hull</li>
|
|
||||||
* <li>LOS and Fresnel clearances</li>
|
|
||||||
* <li>worst-intrusion support values</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public final class PathProfilePoint {
|
|
||||||
|
|
||||||
private final int sampleIndex;
|
|
||||||
private final double distanceKm;
|
|
||||||
private final double latitudeDeg;
|
|
||||||
private final double longitudeDeg;
|
|
||||||
private final double elevationMeters;
|
|
||||||
|
|
||||||
private final double curvatureAdjustedElevationMeters;
|
|
||||||
private final double lineOfSightHeightMeters;
|
|
||||||
private final double fresnelUpperHeightMeters;
|
|
||||||
private final double fresnelLowerHeightMeters;
|
|
||||||
private final double lineOfSightClearanceMeters;
|
|
||||||
private final double lowerFresnelClearanceMeters;
|
|
||||||
private final double fresnelIntrusionMeters;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a raw terrain profile sample without derived LOS/Fresnel values.
|
|
||||||
*
|
|
||||||
* <p>This constructor is used by terrain providers and cache deserialization.
|
|
||||||
* Derived geometry values remain unavailable until the path analysis service
|
|
||||||
* enriches the profile.</p>
|
|
||||||
*
|
|
||||||
* @param distanceKm distance from the path origin in kilometers
|
|
||||||
* @param latitudeDeg sample latitude in degrees
|
|
||||||
* @param longitudeDeg sample longitude in degrees
|
|
||||||
* @param elevationMeters terrain elevation in meters above mean sea level
|
|
||||||
*/
|
|
||||||
public PathProfilePoint(double distanceKm,
|
|
||||||
double latitudeDeg,
|
|
||||||
double longitudeDeg,
|
|
||||||
double elevationMeters) {
|
|
||||||
this(
|
|
||||||
-1,
|
|
||||||
distanceKm,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg,
|
|
||||||
elevationMeters,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN,
|
|
||||||
Double.NaN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a terrain profile sample enriched with derived radio-path geometry.
|
|
||||||
*
|
|
||||||
* @param sampleIndex zero-based sample index in the analyzed path
|
|
||||||
* @param distanceKm distance from the path origin in kilometers
|
|
||||||
* @param latitudeDeg sample latitude in degrees
|
|
||||||
* @param longitudeDeg sample longitude in degrees
|
|
||||||
* @param elevationMeters raw terrain elevation in meters above mean sea level
|
|
||||||
* @param curvatureAdjustedElevationMeters terrain elevation plus Earth bulge in meters
|
|
||||||
* @param lineOfSightHeightMeters direct path height in the same chart space
|
|
||||||
* @param fresnelUpperHeightMeters upper first Fresnel hull in the same chart space
|
|
||||||
* @param fresnelLowerHeightMeters lower first Fresnel hull in the same chart space
|
|
||||||
* @param lineOfSightClearanceMeters direct LOS clearance against curvature-adjusted terrain
|
|
||||||
* @param lowerFresnelClearanceMeters lower Fresnel clearance against curvature-adjusted terrain
|
|
||||||
* @param fresnelIntrusionMeters terrain intrusion into the lower Fresnel hull
|
|
||||||
*/
|
|
||||||
public PathProfilePoint(int sampleIndex,
|
|
||||||
double distanceKm,
|
|
||||||
double latitudeDeg,
|
|
||||||
double longitudeDeg,
|
|
||||||
double elevationMeters,
|
|
||||||
double curvatureAdjustedElevationMeters,
|
|
||||||
double lineOfSightHeightMeters,
|
|
||||||
double fresnelUpperHeightMeters,
|
|
||||||
double fresnelLowerHeightMeters,
|
|
||||||
double lineOfSightClearanceMeters,
|
|
||||||
double lowerFresnelClearanceMeters,
|
|
||||||
double fresnelIntrusionMeters) {
|
|
||||||
|
|
||||||
this.sampleIndex = sampleIndex;
|
|
||||||
this.distanceKm = distanceKm;
|
|
||||||
this.latitudeDeg = latitudeDeg;
|
|
||||||
this.longitudeDeg = longitudeDeg;
|
|
||||||
this.elevationMeters = elevationMeters;
|
|
||||||
this.curvatureAdjustedElevationMeters = curvatureAdjustedElevationMeters;
|
|
||||||
this.lineOfSightHeightMeters = lineOfSightHeightMeters;
|
|
||||||
this.fresnelUpperHeightMeters = fresnelUpperHeightMeters;
|
|
||||||
this.fresnelLowerHeightMeters = fresnelLowerHeightMeters;
|
|
||||||
this.lineOfSightClearanceMeters = lineOfSightClearanceMeters;
|
|
||||||
this.lowerFresnelClearanceMeters = lowerFresnelClearanceMeters;
|
|
||||||
this.fresnelIntrusionMeters = fresnelIntrusionMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int sampleIndex() {
|
|
||||||
return sampleIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double distanceKm() {
|
|
||||||
return distanceKm;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double latitudeDeg() {
|
|
||||||
return latitudeDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double longitudeDeg() {
|
|
||||||
return longitudeDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double elevationMeters() {
|
|
||||||
return elevationMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double curvatureAdjustedElevationMeters() {
|
|
||||||
return curvatureAdjustedElevationMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double lineOfSightHeightMeters() {
|
|
||||||
return lineOfSightHeightMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double fresnelUpperHeightMeters() {
|
|
||||||
return fresnelUpperHeightMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double fresnelLowerHeightMeters() {
|
|
||||||
return fresnelLowerHeightMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double lineOfSightClearanceMeters() {
|
|
||||||
return lineOfSightClearanceMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double lowerFresnelClearanceMeters() {
|
|
||||||
return lowerFresnelClearanceMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
public double fresnelIntrusionMeters() {
|
|
||||||
return fresnelIntrusionMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if enriched LOS/Fresnel geometry is available.
|
|
||||||
*
|
|
||||||
* @return true if this sample was enriched by the path analysis service
|
|
||||||
*/
|
|
||||||
public boolean hasDerivedGeometry() {
|
|
||||||
return Double.isFinite(curvatureAdjustedElevationMeters)
|
|
||||||
|| Double.isFinite(lineOfSightHeightMeters)
|
|
||||||
|| Double.isFinite(fresnelUpperHeightMeters)
|
|
||||||
|| Double.isFinite(fresnelLowerHeightMeters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the terrain blocks the direct line of sight at this sample.
|
|
||||||
*
|
|
||||||
* @return true if the LOS clearance is negative
|
|
||||||
*/
|
|
||||||
public boolean isLineOfSightBlocked() {
|
|
||||||
return Double.isFinite(lineOfSightClearanceMeters) && lineOfSightClearanceMeters < 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the terrain intrudes into the lower Fresnel hull.
|
|
||||||
*
|
|
||||||
* @return true if Fresnel intrusion is positive
|
|
||||||
*/
|
|
||||||
public boolean hasFresnelIntrusion() {
|
|
||||||
return Double.isFinite(fresnelIntrusionMeters) && fresnelIntrusionMeters > 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String toString() {
|
|
||||||
return String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"PathProfilePoint{index=%d, distanceKm=%.3f, lat=%.6f, lon=%.6f, elevation=%.2f, curvature=%.2f, los=%.2f, fresnelLower=%.2f, intrusion=%.2f}",
|
|
||||||
sampleIndex,
|
|
||||||
distanceKm,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg,
|
|
||||||
elevationMeters,
|
|
||||||
curvatureAdjustedElevationMeters,
|
|
||||||
lineOfSightHeightMeters,
|
|
||||||
fresnelLowerHeightMeters,
|
|
||||||
fresnelIntrusionMeters
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Operator-facing propagation assessment derived from geometric path analysis.
|
|
||||||
*
|
|
||||||
* <p>This class deliberately avoids binary "possible/impossible" wording.
|
|
||||||
* VHF/UHF paths can work despite blocked geometric LOS due to diffraction,
|
|
||||||
* troposcatter, enhanced tropospheric refraction, ducting or aircraft scatter.</p>
|
|
||||||
*/
|
|
||||||
public record PathPropagationAssessment(
|
|
||||||
String category,
|
|
||||||
String shortText,
|
|
||||||
String detailText,
|
|
||||||
String likelyMechanisms,
|
|
||||||
int severityLevel
|
|
||||||
) {
|
|
||||||
|
|
||||||
public static PathPropagationAssessment unknown() {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Unknown",
|
|
||||||
"No propagation assessment available.",
|
|
||||||
"No usable terrain/profile data is available.",
|
|
||||||
"-",
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment directFavorable() {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Direct path favorable",
|
|
||||||
"Direct path likely",
|
|
||||||
"The geometric line of sight and the first Fresnel zone are clear. A direct tropospheric path is plausible.",
|
|
||||||
"Direct tropospheric path",
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment directLossy(double fresnelIntrusionRatio) {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Direct path lossy",
|
|
||||||
"Direct path plausible, but lossy",
|
|
||||||
String.format(
|
|
||||||
Locale.US,
|
|
||||||
"The direct line of sight is clear, but the first Fresnel zone is obstructed by about %.0f%% of the local Fresnel radius. Expect additional loss.",
|
|
||||||
fresnelIntrusionRatio * 100.0
|
|
||||||
),
|
|
||||||
"Direct path with Fresnel loss, possible mild diffraction",
|
|
||||||
2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment diffractionPlausible(double knifeEdgeLossDb) {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Diffraction plausible",
|
|
||||||
"Obstructed, diffraction may still be plausible",
|
|
||||||
String.format(
|
|
||||||
Locale.US,
|
|
||||||
"The direct geometric path is blocked, but the rough single-knife-edge estimate is moderate at about %.1f dB. A QSO may still be possible with sufficient antennas, power and conditions.",
|
|
||||||
knifeEdgeLossDb
|
|
||||||
),
|
|
||||||
"Terrain diffraction, tropo enhancement",
|
|
||||||
3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment obstructedNeedsHelp(double knifeEdgeLossDb) {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Obstructed",
|
|
||||||
"Obstructed, enhanced propagation likely required",
|
|
||||||
String.format(
|
|
||||||
Locale.US,
|
|
||||||
"The geometric path is blocked and the rough single-knife-edge estimate is high at about %.1f dB. Direct diffraction alone may be weak; tropo enhancement or scatter mechanisms become more relevant.",
|
|
||||||
knifeEdgeLossDb
|
|
||||||
),
|
|
||||||
"Diffraction, troposcatter, tropo enhancement, aircraft scatter",
|
|
||||||
4
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment severelyObstructed(double knifeEdgeLossDb) {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Severely obstructed",
|
|
||||||
"Severely obstructed, special propagation probably required",
|
|
||||||
String.format(
|
|
||||||
Locale.US,
|
|
||||||
"The geometric path is strongly blocked. The rough single-knife-edge estimate is about %.1f dB, so a normal direct path is unlikely. This does not mean impossible on VHF/UHF, but special propagation is probably needed.",
|
|
||||||
knifeEdgeLossDb
|
|
||||||
),
|
|
||||||
"Aircraft scatter, tropo ducting/enhancement, troposcatter, strong diffraction only in exceptional cases",
|
|
||||||
5
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PathPropagationAssessment blockedNoLossEstimate() {
|
|
||||||
return new PathPropagationAssessment(
|
|
||||||
"Blocked",
|
|
||||||
"Geometrically blocked",
|
|
||||||
"The geometric line of sight is blocked. No reliable diffraction-loss estimate is available for this profile.",
|
|
||||||
"Diffraction, tropo enhancement, troposcatter, aircraft scatter",
|
|
||||||
4
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import javafx.animation.PauseTransition;
|
|
||||||
import javafx.application.Platform;
|
|
||||||
import javafx.collections.ListChangeListener;
|
|
||||||
import javafx.scene.control.TableView;
|
|
||||||
import javafx.util.Duration;
|
|
||||||
import kst4contest.controller.ChatController;
|
|
||||||
import kst4contest.locatorUtils.Location;
|
|
||||||
import kst4contest.model.ChatMember;
|
|
||||||
import kst4contest.model.ChatPreferences;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ThreadFactory;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Synchronizes the application state with the station map window.
|
|
||||||
*
|
|
||||||
* Responsibilities:
|
|
||||||
* - observes the visible filtered table content
|
|
||||||
* - observes the central selection
|
|
||||||
* - forwards marker clicks back into the main application
|
|
||||||
* - triggers explicit DXCluster spots from the map detail panel
|
|
||||||
*/
|
|
||||||
public final class StationMapBridge {
|
|
||||||
|
|
||||||
private final ExecutorService pathAnalysisExecutor = Executors.newSingleThreadExecutor(new PathAnalysisThreadFactory());
|
|
||||||
private final AtomicLong pathAnalysisGeneration = new AtomicLong(0);
|
|
||||||
|
|
||||||
private final ChatController chatController;
|
|
||||||
private final TableView<ChatMember> chatMemberTable;
|
|
||||||
private final StationMapView stationMapView;
|
|
||||||
private final Consumer<ChatMember> focusChatMemberConsumer;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private final MapCallsignRawSnapshotBuilder snapshotBuilder = new MapCallsignRawSnapshotBuilder();
|
|
||||||
// private final OfflineDemManager offlineDemManager = new OfflineDemManager();
|
|
||||||
private final PathAnalysisService pathAnalysisService;
|
|
||||||
|
|
||||||
private String lastPathAnalysisRequestSignature = "";
|
|
||||||
|
|
||||||
private final PauseTransition refreshCoalescer = new PauseTransition(Duration.seconds(1.0));
|
|
||||||
|
|
||||||
public StationMapBridge(ChatController chatController,
|
|
||||||
TableView<ChatMember> chatMemberTable,
|
|
||||||
StationMapView stationMapView,
|
|
||||||
Consumer<ChatMember> focusChatMemberConsumer) {
|
|
||||||
|
|
||||||
this.chatController = Objects.requireNonNull(chatController, "chatController");
|
|
||||||
this.chatMemberTable = Objects.requireNonNull(chatMemberTable, "chatMemberTable");
|
|
||||||
this.stationMapView = Objects.requireNonNull(stationMapView, "stationMapView");
|
|
||||||
this.focusChatMemberConsumer = Objects.requireNonNull(focusChatMemberConsumer, "focusChatMemberConsumer");
|
|
||||||
|
|
||||||
this.refreshCoalescer.setOnFinished(event -> refreshNow());
|
|
||||||
|
|
||||||
this.pathAnalysisService = new GeometryOnlyPathAnalysisService(
|
|
||||||
new OpenMeteoTerrainProfileProvider()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void install() {
|
|
||||||
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
|
||||||
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
|
||||||
|
|
||||||
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
|
||||||
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
|
||||||
);
|
|
||||||
|
|
||||||
chatController.getScoreService().selectedChatMemberProperty().addListener(
|
|
||||||
(obs, oldValue, newValue) -> requestImmediateRefresh()
|
|
||||||
);
|
|
||||||
|
|
||||||
chatController.getChatPreferences().getActualQTF().addListener(
|
|
||||||
(obs, oldValue, newValue) -> scheduleRefresh()
|
|
||||||
);
|
|
||||||
|
|
||||||
requestImmediateRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void showWindow() {
|
|
||||||
stationMapView.showWindow();
|
|
||||||
requestImmediateRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void hideWindow() {
|
|
||||||
stationMapView.hideWindow();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void toggleWindow() {
|
|
||||||
if (stationMapView.isShowing()) {
|
|
||||||
hideWindow();
|
|
||||||
} else {
|
|
||||||
showWindow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void requestImmediateRefresh() {
|
|
||||||
if (Platform.isFxApplicationThread()) {
|
|
||||||
refreshNow();
|
|
||||||
} else {
|
|
||||||
Platform.runLater(this::refreshNow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void focusSelectedCallsign() {
|
|
||||||
showWindow();
|
|
||||||
|
|
||||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
|
||||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
|
||||||
stationMapView.focusCallsignRaw(selectedChatMember.getCallSignRaw());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void applyThemeFromPreferences() {
|
|
||||||
if (Platform.isFxApplicationThread()) {
|
|
||||||
stationMapView.applyThemeFromPreferences();
|
|
||||||
} else {
|
|
||||||
Platform.runLater(stationMapView::applyThemeFromPreferences);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void scheduleRefresh() {
|
|
||||||
if (Platform.isFxApplicationThread()) {
|
|
||||||
refreshCoalescer.playFromStart();
|
|
||||||
} else {
|
|
||||||
Platform.runLater(() -> refreshCoalescer.playFromStart());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refreshNow() {
|
|
||||||
List<ChatMember> visibleChatMembers = new ArrayList<>(chatController.getLst_chatMemberSortedFilteredList());
|
|
||||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
|
||||||
|
|
||||||
List<MapCallsignRawSnapshot> snapshots = snapshotBuilder.buildSnapshots(visibleChatMembers, selectedChatMember);
|
|
||||||
|
|
||||||
MapCallsignRawSnapshot selectedSnapshot = null;
|
|
||||||
if (selectedChatMember != null && selectedChatMember.getCallSignRaw() != null) {
|
|
||||||
String selectedCallsignRaw = normalizeCallsignRaw(selectedChatMember.getCallSignRaw());
|
|
||||||
selectedSnapshot = snapshots.stream()
|
|
||||||
.filter(snapshot -> snapshot.callSignRaw().equals(selectedCallsignRaw))
|
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean filteredViewActive = visibleChatMembers.size() < chatController.getLst_chatMemberList().size();
|
|
||||||
|
|
||||||
ChatPreferences preferences = chatController.getChatPreferences();
|
|
||||||
|
|
||||||
stationMapView.refreshMap(
|
|
||||||
snapshots,
|
|
||||||
selectedSnapshot,
|
|
||||||
preferences.getStn_loginLocatorMainCat(),
|
|
||||||
preferences.getActualQTF().get(),
|
|
||||||
preferences.getStn_antennaBeamWidthDeg(),
|
|
||||||
preferences.getStn_maxQRBDefault(),
|
|
||||||
filteredViewActive
|
|
||||||
);
|
|
||||||
|
|
||||||
requestPathAnalysisAsync(preferences.getStn_loginLocatorMainCat(), selectedSnapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void requestPathAnalysisAsync(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
|
||||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
|
||||||
|
|
||||||
String requestSignature = buildPathAnalysisRequestSignature(normalizedOwnLocator6, selectedSnapshot);
|
|
||||||
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
lastPathAnalysisRequestSignature = "";
|
|
||||||
} else if (requestSignature.equals(lastPathAnalysisRequestSignature)) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
lastPathAnalysisRequestSignature = requestSignature;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
if (generation == pathAnalysisGeneration.get()) {
|
|
||||||
stationMapView.setPathAnalysisResult(PathAnalysisResult.waitingForSelection(normalizedOwnLocator6));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
|
||||||
String targetCallsignRaw = selectedSnapshot.callSignRaw();
|
|
||||||
|
|
||||||
long generation = pathAnalysisGeneration.incrementAndGet();
|
|
||||||
|
|
||||||
stationMapView.setPathAnalysisResult(
|
|
||||||
PathAnalysisResult.loading(normalizedOwnLocator6, normalizedTargetLocator6, targetCallsignRaw)
|
|
||||||
);
|
|
||||||
|
|
||||||
pathAnalysisExecutor.submit(() -> {
|
|
||||||
PathAnalysisResult result = buildPathAnalysisResult(normalizedOwnLocator6, selectedSnapshot);
|
|
||||||
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
if (generation != pathAnalysisGeneration.get()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stationMapView.setPathAnalysisResult(result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void dispose() {
|
|
||||||
pathAnalysisExecutor.shutdownNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleMapCallsignSelection(String callSignRaw) {
|
|
||||||
System.out.println("########################### map selected callsign " + callSignRaw);
|
|
||||||
|
|
||||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
|
||||||
if (resolved == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Platform.runLater(() -> {
|
|
||||||
chatController.getScoreService().setSelectedChatMember(resolved);
|
|
||||||
|
|
||||||
chatMemberTable.getSelectionModel().select(resolved);
|
|
||||||
chatMemberTable.scrollTo(resolved);
|
|
||||||
|
|
||||||
focusChatMemberConsumer.accept(resolved);
|
|
||||||
requestImmediateRefresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleExplicitClusterSpot(String callSignRaw) {
|
|
||||||
ChatMember resolved = resolveBestChatMember(callSignRaw);
|
|
||||||
if (resolved == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatController.getDxClusterServer() != null) {
|
|
||||||
chatController.getDxClusterServer().broadcastSingleDXClusterEntryToLoggers(resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ChatMember resolveBestChatMember(String callSignRaw) {
|
|
||||||
String normalizedCallsignRaw = normalizeCallsignRaw(callSignRaw);
|
|
||||||
if (normalizedCallsignRaw.isBlank()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatMember selectedChatMember = chatController.getScoreService().getSelectedChatMember();
|
|
||||||
if (selectedChatMember != null
|
|
||||||
&& normalizedCallsignRaw.equals(normalizeCallsignRaw(selectedChatMember.getCallSignRaw()))) {
|
|
||||||
return selectedChatMember;
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatMember visibleBest = chatMemberTable.getItems().stream()
|
|
||||||
.filter(chatMember -> chatMember != null)
|
|
||||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
|
||||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (visibleBest != null) {
|
|
||||||
return visibleBest;
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized (chatController.getLst_chatMemberList()) {
|
|
||||||
return chatController.getLst_chatMemberList().stream()
|
|
||||||
.filter(chatMember -> chatMember != null)
|
|
||||||
.filter(chatMember -> normalizedCallsignRaw.equals(normalizeCallsignRaw(chatMember.getCallSignRaw())))
|
|
||||||
.max(Comparator.comparingLong(ChatMember::getActivityTimeLastInEpoch))
|
|
||||||
.orElse(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeCallsignRaw(String callSignRaw) {
|
|
||||||
if (callSignRaw == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return callSignRaw.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private PathAnalysisResult buildPathAnalysisResult(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
|
||||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
|
||||||
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
return PathAnalysisResult.waitingForSelection(normalizedOwnLocator6);
|
|
||||||
}
|
|
||||||
|
|
||||||
String normalizedTargetLocator6 = normalizeLocator6(selectedSnapshot.locator6());
|
|
||||||
|
|
||||||
if (normalizedOwnLocator6.length() != 6) {
|
|
||||||
return PathAnalysisResult.waitingForValidHomeLocator(normalizedOwnLocator6, normalizedTargetLocator6);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedSnapshot.hasUsablePosition()) {
|
|
||||||
return PathAnalysisResult.waitingForValidTarget(normalizedOwnLocator6, normalizedTargetLocator6);
|
|
||||||
}
|
|
||||||
|
|
||||||
Location homeLocation = new Location(normalizedOwnLocator6);
|
|
||||||
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
|
|
||||||
|
|
||||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
|
||||||
normalizedOwnLocator6,
|
|
||||||
homeLocation.getLatitude().toDegrees(),
|
|
||||||
homeLocation.getLongitude().toDegrees(),
|
|
||||||
selectedSnapshot.callSignRaw(),
|
|
||||||
normalizedTargetLocator6,
|
|
||||||
selectedSnapshot.latitudeDeg(),
|
|
||||||
selectedSnapshot.longitudeDeg(),
|
|
||||||
analysisFrequencyMHz,
|
|
||||||
chatController.getChatPreferences().getStn_pathAnalysisOwnAntennaHeightMeters(),
|
|
||||||
chatController.getChatPreferences().getStn_pathAnalysisDefaultTargetAntennaHeightMeters(),
|
|
||||||
PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
|
|
||||||
chatController.getChatPreferences().buildPathLinkBudgetSettings()
|
|
||||||
);
|
|
||||||
|
|
||||||
return pathAnalysisService.analyze(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double resolveAnalysisFrequencyMHz(MapCallsignRawSnapshot selectedSnapshot) {
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
return PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PathGeometryUtils.resolveAnalysisFrequencyMHz(selectedSnapshot.lastKnownFrequenciesByBand());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private String buildPathAnalysisRequestSignature(String ownLocator6, MapCallsignRawSnapshot selectedSnapshot) {
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
double analysisFrequencyMHz = resolveAnalysisFrequencyMHz(selectedSnapshot);
|
|
||||||
ChatPreferences preferences = chatController.getChatPreferences();
|
|
||||||
|
|
||||||
return normalizeLocator6(ownLocator6)
|
|
||||||
+ "|"
|
|
||||||
+ selectedSnapshot.callSignRaw()
|
|
||||||
+ "|"
|
|
||||||
+ normalizeLocator6(selectedSnapshot.locator6())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.latitudeDeg())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.5f", selectedSnapshot.longitudeDeg())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.3f", analysisFrequencyMHz)
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnAntennaHeightMeters())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetAntennaHeightMeters())
|
|
||||||
+ "|"
|
|
||||||
+ preferences.getStn_pathAnalysisDemRootDirectory()
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisOwnTxPowerWatts())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisOwnAntennaGainDbi())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.1f", preferences.getStn_pathAnalysisDefaultTargetTxPowerWatts())
|
|
||||||
+ "|"
|
|
||||||
+ String.format(Locale.US, "%.2f", preferences.getStn_pathAnalysisDefaultTargetAntennaGainDbi());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeLocator6(String locator) {
|
|
||||||
if (locator == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return locator.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class PathAnalysisThreadFactory implements ThreadFactory {
|
|
||||||
@Override
|
|
||||||
public Thread newThread(Runnable runnable) {
|
|
||||||
Thread thread = new Thread(runnable, "station-map-path-analysis");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic synthetic fallback profile provider for UI/testing.
|
|
||||||
*
|
|
||||||
* This provider intentionally stays fully offline and deterministic.
|
|
||||||
* It is only used when no real offline DEM data is available.
|
|
||||||
*/
|
|
||||||
public final class SyntheticTerrainProfileProvider implements TerrainProfileProvider {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TerrainProfileData loadProfile(TerrainProfileRequest request) {
|
|
||||||
if (request == null || !request.hasUsableEndpoints() || request.requestedSampleCount() < 2) {
|
|
||||||
return TerrainProfileData.empty("Synthetic fallback profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
int sampleCount = Math.max(2, request.requestedSampleCount());
|
|
||||||
List<PathProfilePoint> points = new ArrayList<>(sampleCount);
|
|
||||||
|
|
||||||
for (int i = 0; i < sampleCount; i++) {
|
|
||||||
double t = sampleCount == 1 ? 0.0 : (double) i / (double) (sampleCount - 1);
|
|
||||||
|
|
||||||
double latitudeDeg = interpolate(request.fromLatitudeDeg(), request.toLatitudeDeg(), t);
|
|
||||||
double longitudeDeg = interpolate(request.fromLongitudeDeg(), request.toLongitudeDeg(), t);
|
|
||||||
double distanceKm = request.totalDistanceKm() * t;
|
|
||||||
|
|
||||||
double elevationMeters = syntheticElevationMeters(t, request.totalDistanceKm());
|
|
||||||
|
|
||||||
points.add(new PathProfilePoint(
|
|
||||||
distanceKm,
|
|
||||||
latitudeDeg,
|
|
||||||
longitudeDeg,
|
|
||||||
elevationMeters
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainProfileData(points, "Synthetic fallback profile", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double interpolate(double start, double end, double t) {
|
|
||||||
return start + (end - start) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
private double syntheticElevationMeters(double t, double totalDistanceKm) {
|
|
||||||
double baseLevelMeters = 80.0 + Math.min(60.0, totalDistanceKm * 0.12);
|
|
||||||
|
|
||||||
double broadHill = 90.0 * Math.sin(Math.PI * t);
|
|
||||||
double window = Math.pow(Math.sin(Math.PI * t), 1.35);
|
|
||||||
double secondaryShape = window * 28.0 * Math.sin(3.0 * Math.PI * t + 0.55);
|
|
||||||
double fineStructure = window * 10.0 * Math.cos(7.0 * Math.PI * t + 0.25);
|
|
||||||
|
|
||||||
return Math.max(0.0, baseLevelMeters + broadHill + secondaryShape + fineStructure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable terrain catalog describing all downloadable terrain packages
|
|
||||||
* currently offered by the service.
|
|
||||||
*
|
|
||||||
* <p>The catalog intentionally contains the required attribution/license texts
|
|
||||||
* so they can later be shown both in the client UI and in service responses.</p>
|
|
||||||
*/
|
|
||||||
public record TerrainCatalog(
|
|
||||||
int schemaVersion,
|
|
||||||
int catalogVersion,
|
|
||||||
String generatedAtUtc,
|
|
||||||
String regionSet,
|
|
||||||
String packageBaseUrl,
|
|
||||||
String sourceAttribution,
|
|
||||||
String licenseNotice,
|
|
||||||
String disclaimerNotice,
|
|
||||||
List<TerrainCatalogPackageEntry> packages
|
|
||||||
) {
|
|
||||||
|
|
||||||
public TerrainCatalog {
|
|
||||||
generatedAtUtc = normalizeText(generatedAtUtc);
|
|
||||||
regionSet = normalizeLower(regionSet);
|
|
||||||
packageBaseUrl = normalizeText(packageBaseUrl);
|
|
||||||
sourceAttribution = normalizeText(sourceAttribution);
|
|
||||||
licenseNotice = normalizeText(licenseNotice);
|
|
||||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
|
||||||
packages = packages == null ? List.of() : List.copyOf(packages);
|
|
||||||
|
|
||||||
if (schemaVersion < 0) {
|
|
||||||
schemaVersion = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catalogVersion < 0) {
|
|
||||||
catalogVersion = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the catalog contains at least one package entry.
|
|
||||||
*
|
|
||||||
* @return true if the catalog is non-empty
|
|
||||||
*/
|
|
||||||
public boolean hasPackages() {
|
|
||||||
return !packages.isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds one package entry by its canonical package id.
|
|
||||||
*
|
|
||||||
* @param packageId canonical package id
|
|
||||||
* @return matching catalog entry if found
|
|
||||||
*/
|
|
||||||
public Optional<TerrainCatalogPackageEntry> findPackageById(String packageId) {
|
|
||||||
String normalizedPackageId = normalizeLower(packageId);
|
|
||||||
|
|
||||||
return packages.stream()
|
|
||||||
.filter(entry -> entry.packageId().equals(normalizedPackageId))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds one package entry by region type and region id.
|
|
||||||
*
|
|
||||||
* @param regionType region type, e.g. "maidenhead4"
|
|
||||||
* @param regionId region id, e.g. "JO22"
|
|
||||||
* @return matching package entry if found
|
|
||||||
*/
|
|
||||||
public Optional<TerrainCatalogPackageEntry> findPackageByRegion(String regionType, String regionId) {
|
|
||||||
String normalizedRegionType = normalizeLower(regionType);
|
|
||||||
String normalizedRegionId = normalizeUpper(regionId);
|
|
||||||
|
|
||||||
return packages.stream()
|
|
||||||
.filter(entry -> entry.regionType().equals(normalizedRegionType))
|
|
||||||
.filter(entry -> entry.regionId().equals(normalizedRegionId))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeText(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeLower(String value) {
|
|
||||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
|
||||||
import org.w3c.dom.Document;
|
|
||||||
import org.w3c.dom.Element;
|
|
||||||
import org.w3c.dom.Node;
|
|
||||||
|
|
||||||
import javax.xml.XMLConstants;
|
|
||||||
import javax.xml.parsers.DocumentBuilder;
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
|
||||||
import java.io.File;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads and parses the terrain package catalog.
|
|
||||||
*
|
|
||||||
* <p>The first implementation intentionally uses XML instead of JSON because:
|
|
||||||
* <ul>
|
|
||||||
* <li>the current project already contains XML parsing patterns</li>
|
|
||||||
* <li>no additional JSON dependency is required</li>
|
|
||||||
* <li>we can move faster toward a working package download/install flow</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The parsed result still maps into the shared terrain catalog model classes.</p>
|
|
||||||
*/
|
|
||||||
public final class TerrainCatalogClient {
|
|
||||||
|
|
||||||
private static final String LOCAL_TERRAIN_CATALOG_RELATIVE_PATH = "terrain/catalog/terrain-catalog-v1.xml";
|
|
||||||
|
|
||||||
private final HttpClient httpClient;
|
|
||||||
|
|
||||||
public TerrainCatalogClient() {
|
|
||||||
this.httpClient = HttpClient.newBuilder()
|
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the remote XML catalog and stores it below the local .praktiKST directory.
|
|
||||||
*
|
|
||||||
* @param catalogUrl full catalog URL
|
|
||||||
* @return download result
|
|
||||||
*/
|
|
||||||
public CatalogDownloadResult downloadCatalog(String catalogUrl) {
|
|
||||||
Path localCatalogFile = resolveLocalCatalogFile();
|
|
||||||
|
|
||||||
try {
|
|
||||||
Files.createDirectories(localCatalogFile.getParent());
|
|
||||||
|
|
||||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(catalogUrl))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = httpClient.send(
|
|
||||||
httpRequest,
|
|
||||||
HttpResponse.BodyHandlers.ofFile(localCatalogFile)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
||||||
return new CatalogDownloadResult(
|
|
||||||
localCatalogFile,
|
|
||||||
false,
|
|
||||||
"Catalog download failed with HTTP status " + response.statusCode() + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CatalogDownloadResult(
|
|
||||||
localCatalogFile,
|
|
||||||
true,
|
|
||||||
"Catalog downloaded successfully to:\n" + localCatalogFile.toAbsolutePath()
|
|
||||||
);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return new CatalogDownloadResult(
|
|
||||||
localCatalogFile,
|
|
||||||
false,
|
|
||||||
"Catalog download failed: " + exception.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the previously downloaded local catalog file.
|
|
||||||
*
|
|
||||||
* @return parsed catalog load result
|
|
||||||
*/
|
|
||||||
public CatalogLoadResult loadLocalCatalog() {
|
|
||||||
return loadCatalog(resolveLocalCatalogFile());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and parses one XML catalog file.
|
|
||||||
*
|
|
||||||
* @param catalogFile local catalog XML file
|
|
||||||
* @return parsed catalog load result
|
|
||||||
*/
|
|
||||||
public CatalogLoadResult loadCatalog(Path catalogFile) {
|
|
||||||
if (catalogFile == null || !Files.isRegularFile(catalogFile)) {
|
|
||||||
return new CatalogLoadResult(
|
|
||||||
null,
|
|
||||||
catalogFile,
|
|
||||||
false,
|
|
||||||
"Local terrain catalog file does not exist."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Document document = parseXmlDocument(catalogFile.toFile());
|
|
||||||
TerrainCatalog terrainCatalog = parseTerrainCatalog(document);
|
|
||||||
|
|
||||||
return new CatalogLoadResult(
|
|
||||||
terrainCatalog,
|
|
||||||
catalogFile,
|
|
||||||
terrainCatalog != null && terrainCatalog.hasPackages(),
|
|
||||||
terrainCatalog != null && terrainCatalog.hasPackages()
|
|
||||||
? "Terrain catalog loaded successfully."
|
|
||||||
: "Terrain catalog was loaded but contains no packages."
|
|
||||||
);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return new CatalogLoadResult(
|
|
||||||
null,
|
|
||||||
catalogFile,
|
|
||||||
false,
|
|
||||||
"Could not parse terrain catalog: " + exception.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path resolveLocalCatalogFile() {
|
|
||||||
return Path.of(ApplicationFileUtils.getFilePath(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
LOCAL_TERRAIN_CATALOG_RELATIVE_PATH
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Document parseXmlDocument(File xmlFile) throws Exception {
|
|
||||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
|
||||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
|
||||||
|
|
||||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
|
||||||
return documentBuilder.parse(xmlFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerrainCatalog parseTerrainCatalog(Document document) {
|
|
||||||
Element root = document.getDocumentElement();
|
|
||||||
if (root == null || !"terrainCatalog".equals(root.getTagName())) {
|
|
||||||
throw new IllegalArgumentException("Unexpected catalog root element.");
|
|
||||||
}
|
|
||||||
|
|
||||||
int schemaVersion = parseIntAttribute(root, "schemaVersion", 1);
|
|
||||||
int catalogVersion = parseIntAttribute(root, "catalogVersion", 1);
|
|
||||||
String generatedAtUtc = root.getAttribute("generatedAtUtc");
|
|
||||||
String regionSet = root.getAttribute("regionSet");
|
|
||||||
String packageBaseUrl = root.getAttribute("packageBaseUrl");
|
|
||||||
|
|
||||||
String sourceAttribution = getDirectChildText(root, "sourceAttribution");
|
|
||||||
String licenseNotice = getDirectChildText(root, "licenseNotice");
|
|
||||||
String disclaimerNotice = getDirectChildText(root, "disclaimerNotice");
|
|
||||||
|
|
||||||
Element packagesElement = getFirstDirectChild(root, "packages");
|
|
||||||
List<TerrainCatalogPackageEntry> packageEntries = new ArrayList<>();
|
|
||||||
|
|
||||||
if (packagesElement != null) {
|
|
||||||
List<Element> packageElements = getDirectChildElements(packagesElement, "package");
|
|
||||||
|
|
||||||
for (Element packageElement : packageElements) {
|
|
||||||
List<String> tileIds = new ArrayList<>();
|
|
||||||
|
|
||||||
Element tileIdsElement = getFirstDirectChild(packageElement, "tileIds");
|
|
||||||
if (tileIdsElement != null) {
|
|
||||||
for (Element tileIdElement : getDirectChildElements(tileIdsElement, "tileId")) {
|
|
||||||
tileIds.add(tileIdElement.getTextContent());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
packageEntries.add(new TerrainCatalogPackageEntry(
|
|
||||||
packageElement.getAttribute("packageId"),
|
|
||||||
packageElement.getAttribute("regionType"),
|
|
||||||
packageElement.getAttribute("regionId"),
|
|
||||||
parseIntAttribute(packageElement, "packageVersion", 1),
|
|
||||||
packageElement.getAttribute("downloadUrl"),
|
|
||||||
parseLongAttribute(packageElement, "sizeBytes", 0L),
|
|
||||||
packageElement.getAttribute("sha256"),
|
|
||||||
parseDoubleAttribute(packageElement, "minLatitudeDeg", Double.NaN),
|
|
||||||
parseDoubleAttribute(packageElement, "maxLatitudeDeg", Double.NaN),
|
|
||||||
parseDoubleAttribute(packageElement, "minLongitudeDeg", Double.NaN),
|
|
||||||
parseDoubleAttribute(packageElement, "maxLongitudeDeg", Double.NaN),
|
|
||||||
tileIds,
|
|
||||||
packageElement.getAttribute("sourceDataset"),
|
|
||||||
getDirectChildText(packageElement, "sourceAttribution"),
|
|
||||||
getDirectChildText(packageElement, "derivedProductNotice"),
|
|
||||||
getDirectChildText(packageElement, "disclaimerNotice")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainCatalog(
|
|
||||||
schemaVersion,
|
|
||||||
catalogVersion,
|
|
||||||
generatedAtUtc,
|
|
||||||
regionSet,
|
|
||||||
packageBaseUrl,
|
|
||||||
sourceAttribution,
|
|
||||||
licenseNotice,
|
|
||||||
disclaimerNotice,
|
|
||||||
packageEntries
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Element> getDirectChildElements(Element parent, String tagName) {
|
|
||||||
List<Element> result = new ArrayList<>();
|
|
||||||
|
|
||||||
if (parent == null) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int index = 0; index < parent.getChildNodes().getLength(); index++) {
|
|
||||||
Node node = parent.getChildNodes().item(index);
|
|
||||||
if (node instanceof Element element && tagName.equals(element.getTagName())) {
|
|
||||||
result.add(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Element getFirstDirectChild(Element parent, String tagName) {
|
|
||||||
for (Element element : getDirectChildElements(parent, tagName)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String getDirectChildText(Element parent, String tagName) {
|
|
||||||
Element child = getFirstDirectChild(parent, tagName);
|
|
||||||
return child == null ? "" : child.getTextContent().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int parseIntAttribute(Element element, String attributeName, int defaultValue) {
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(element.getAttribute(attributeName));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long parseLongAttribute(Element element, String attributeName, long defaultValue) {
|
|
||||||
try {
|
|
||||||
return Long.parseLong(element.getAttribute(attributeName));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) {
|
|
||||||
try {
|
|
||||||
return Double.parseDouble(element.getAttribute(attributeName));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download result for the XML terrain catalog.
|
|
||||||
*
|
|
||||||
* @param localCatalogFile target local file path
|
|
||||||
* @param success true if the download succeeded
|
|
||||||
* @param message human-readable result text
|
|
||||||
*/
|
|
||||||
public record CatalogDownloadResult(
|
|
||||||
Path localCatalogFile,
|
|
||||||
boolean success,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse/load result for one terrain catalog.
|
|
||||||
*
|
|
||||||
* @param terrainCatalog parsed catalog, or null
|
|
||||||
* @param localCatalogFile source file path
|
|
||||||
* @param success true if a usable catalog was loaded
|
|
||||||
* @param message human-readable result text
|
|
||||||
*/
|
|
||||||
public record CatalogLoadResult(
|
|
||||||
TerrainCatalog terrainCatalog,
|
|
||||||
Path localCatalogFile,
|
|
||||||
boolean success,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable catalog entry describing one downloadable terrain package.
|
|
||||||
*
|
|
||||||
* <p>This model is intentionally shared between the future server-side catalog
|
|
||||||
* generation and the future desktop downloader.</p>
|
|
||||||
*/
|
|
||||||
public record TerrainCatalogPackageEntry(
|
|
||||||
String packageId,
|
|
||||||
String regionType,
|
|
||||||
String regionId,
|
|
||||||
int packageVersion,
|
|
||||||
String downloadUrl,
|
|
||||||
long sizeBytes,
|
|
||||||
String sha256,
|
|
||||||
double minLatitudeDeg,
|
|
||||||
double maxLatitudeDeg,
|
|
||||||
double minLongitudeDeg,
|
|
||||||
double maxLongitudeDeg,
|
|
||||||
List<String> tileIds,
|
|
||||||
String sourceDataset,
|
|
||||||
String sourceAttribution,
|
|
||||||
String derivedProductNotice,
|
|
||||||
String disclaimerNotice
|
|
||||||
) {
|
|
||||||
|
|
||||||
public TerrainCatalogPackageEntry {
|
|
||||||
packageId = normalizeLower(packageId);
|
|
||||||
regionType = normalizeLower(regionType);
|
|
||||||
regionId = normalizeUpper(regionId);
|
|
||||||
downloadUrl = normalizeText(downloadUrl);
|
|
||||||
sha256 = normalizeLower(sha256);
|
|
||||||
tileIds = tileIds == null ? List.of() : tileIds.stream()
|
|
||||||
.map(TerrainCatalogPackageEntry::normalizeUpper)
|
|
||||||
.filter(value -> !value.isBlank())
|
|
||||||
.distinct()
|
|
||||||
.toList();
|
|
||||||
sourceDataset = normalizeLower(sourceDataset);
|
|
||||||
sourceAttribution = normalizeText(sourceAttribution);
|
|
||||||
derivedProductNotice = normalizeText(derivedProductNotice);
|
|
||||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
|
||||||
|
|
||||||
if (packageVersion < 0) {
|
|
||||||
packageVersion = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sizeBytes < 0L) {
|
|
||||||
sizeBytes = 0L;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the entry contains enough information for download/install logic.
|
|
||||||
*
|
|
||||||
* @return true if the entry is usable
|
|
||||||
*/
|
|
||||||
public boolean isUsable() {
|
|
||||||
return !packageId.isBlank()
|
|
||||||
&& !regionType.isBlank()
|
|
||||||
&& !regionId.isBlank()
|
|
||||||
&& !downloadUrl.isBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the package matches the given region identifier.
|
|
||||||
*
|
|
||||||
* @param expectedRegionType region type, e.g. "maidenhead4"
|
|
||||||
* @param expectedRegionId region id, e.g. "JO22"
|
|
||||||
* @return true if both match
|
|
||||||
*/
|
|
||||||
public boolean matchesRegion(String expectedRegionType, String expectedRegionId) {
|
|
||||||
return regionType.equals(normalizeLower(expectedRegionType))
|
|
||||||
&& regionId.equals(normalizeUpper(expectedRegionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the bounding box covers the given point.
|
|
||||||
*
|
|
||||||
* @param latitudeDeg latitude in degrees
|
|
||||||
* @param longitudeDeg longitude in degrees
|
|
||||||
* @return true if the point lies inside the package coverage box
|
|
||||||
*/
|
|
||||||
public boolean covers(double latitudeDeg, double longitudeDeg) {
|
|
||||||
return Double.isFinite(latitudeDeg)
|
|
||||||
&& Double.isFinite(longitudeDeg)
|
|
||||||
&& latitudeDeg >= minLatitudeDeg
|
|
||||||
&& latitudeDeg <= maxLatitudeDeg
|
|
||||||
&& longitudeDeg >= minLongitudeDeg
|
|
||||||
&& longitudeDeg <= maxLongitudeDeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeText(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeLower(String value) {
|
|
||||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.locatorUtils.Location;
|
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves which terrain packages and which 1° tiles are required for
|
|
||||||
* a path analysis request.
|
|
||||||
*
|
|
||||||
* <p>This class is intentionally self-contained so it can later be reused
|
|
||||||
* unchanged both in the desktop client and in a future Spring service.</p>
|
|
||||||
*/
|
|
||||||
public final class TerrainCoverageResolver {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current package scheme groups terrain downloads by Maidenhead-4 region.
|
|
||||||
*/
|
|
||||||
public static final String REGION_TYPE_MAIDENHEAD4 = "maidenhead4";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed region set label for the first Europe-wide terrain service generation.
|
|
||||||
*/
|
|
||||||
public static final String REGION_SET_EU = "eu";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sampling step used to determine required coverage along a path.
|
|
||||||
*
|
|
||||||
* <p>This is intentionally finer than the final profile sampling so that
|
|
||||||
* package/tile coverage is not missed on diagonal or border-crossing paths.</p>
|
|
||||||
*/
|
|
||||||
private static final double COVERAGE_SAMPLE_STEP_KM = 10.0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safety minimum number of sampling points per path.
|
|
||||||
*/
|
|
||||||
private static final int MIN_COVERAGE_SAMPLE_COUNT = 32;
|
|
||||||
|
|
||||||
public TerrainCoverageResolver() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves all package and tile requirements for the given path request.
|
|
||||||
*
|
|
||||||
* @param request immutable path analysis request
|
|
||||||
* @return combined package/tile coverage selection
|
|
||||||
*/
|
|
||||||
public TerrainCoverageSelection resolveCoverageForPath(PathAnalysisRequest request) {
|
|
||||||
if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) {
|
|
||||||
return TerrainCoverageSelection.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveCoverageForPath(
|
|
||||||
request.fromLatitudeDeg(),
|
|
||||||
request.fromLongitudeDeg(),
|
|
||||||
request.toLatitudeDeg(),
|
|
||||||
request.toLongitudeDeg()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves all package and tile requirements for the given geographic path.
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @return combined package/tile coverage selection
|
|
||||||
*/
|
|
||||||
public TerrainCoverageSelection resolveCoverageForPath(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg) {
|
|
||||||
|
|
||||||
if (!areUsableCoordinates(fromLatitudeDeg, fromLongitudeDeg)
|
|
||||||
|| !areUsableCoordinates(toLatitudeDeg, toLongitudeDeg)) {
|
|
||||||
return TerrainCoverageSelection.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
double totalDistanceKm = calculateGreatCircleDistanceKm(
|
|
||||||
fromLatitudeDeg,
|
|
||||||
fromLongitudeDeg,
|
|
||||||
toLatitudeDeg,
|
|
||||||
toLongitudeDeg
|
|
||||||
);
|
|
||||||
|
|
||||||
int coverageSampleCount = resolveCoverageSampleCount(totalDistanceKm);
|
|
||||||
|
|
||||||
LinkedHashSet<String> requiredRegionIds = new LinkedHashSet<>();
|
|
||||||
LinkedHashSet<String> requiredPackageIds = new LinkedHashSet<>();
|
|
||||||
LinkedHashSet<String> requiredTileIds = new LinkedHashSet<>();
|
|
||||||
|
|
||||||
for (int sampleIndex = 0; sampleIndex < coverageSampleCount; sampleIndex++) {
|
|
||||||
double t = coverageSampleCount == 1
|
|
||||||
? 0.0
|
|
||||||
: (double) sampleIndex / (double) (coverageSampleCount - 1);
|
|
||||||
|
|
||||||
GeoPoint point = interpolateGreatCirclePoint(
|
|
||||||
fromLatitudeDeg,
|
|
||||||
fromLongitudeDeg,
|
|
||||||
toLatitudeDeg,
|
|
||||||
toLongitudeDeg,
|
|
||||||
t
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!areUsableCoordinates(point.latitudeDeg(), point.longitudeDeg())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String maidenhead4 = toMaidenhead4(point.latitudeDeg(), point.longitudeDeg());
|
|
||||||
if (!maidenhead4.isBlank()) {
|
|
||||||
requiredRegionIds.add(maidenhead4);
|
|
||||||
requiredPackageIds.add(buildPackageId(REGION_SET_EU, maidenhead4, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
int southDeg = floorDegree(point.latitudeDeg());
|
|
||||||
int westDeg = floorDegree(point.longitudeDeg());
|
|
||||||
requiredTileIds.add(TerrainTileMetadata.buildTileId(southDeg, westDeg));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainCoverageSelection(
|
|
||||||
List.copyOf(requiredRegionIds),
|
|
||||||
List.copyOf(requiredPackageIds),
|
|
||||||
List.copyOf(requiredTileIds),
|
|
||||||
totalDistanceKm,
|
|
||||||
coverageSampleCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves only the required package ids for the given path.
|
|
||||||
*
|
|
||||||
* @param request immutable path analysis request
|
|
||||||
* @return ordered distinct package ids
|
|
||||||
*/
|
|
||||||
public List<String> resolveRequiredPackageIdsForPath(PathAnalysisRequest request) {
|
|
||||||
return resolveCoverageForPath(request).packageIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves only the required tile ids for the given path.
|
|
||||||
*
|
|
||||||
* @param request immutable path analysis request
|
|
||||||
* @return ordered distinct tile ids
|
|
||||||
*/
|
|
||||||
public List<String> resolveRequiredTileIdsForPath(PathAnalysisRequest request) {
|
|
||||||
return resolveCoverageForPath(request).tileIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the canonical package id for one region package.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* <ul>
|
|
||||||
* <li>terrain-eu-jo22-v1</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param regionSet region set, e.g. "eu"
|
|
||||||
* @param regionId region id, e.g. "JO22"
|
|
||||||
* @param packageVersion package version
|
|
||||||
* @return canonical package id
|
|
||||||
*/
|
|
||||||
public static String buildPackageId(String regionSet, String regionId, int packageVersion) {
|
|
||||||
String normalizedRegionSet = regionSet == null ? "" : regionSet.trim().toLowerCase(Locale.ROOT);
|
|
||||||
String normalizedRegionId = regionId == null ? "" : regionId.trim().toLowerCase(Locale.ROOT);
|
|
||||||
int normalizedPackageVersion = Math.max(0, packageVersion);
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"terrain-%s-%s-v%d",
|
|
||||||
normalizedRegionSet,
|
|
||||||
normalizedRegionId,
|
|
||||||
normalizedPackageVersion
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the Maidenhead-4 region id for the given point.
|
|
||||||
*
|
|
||||||
* @param latitudeDeg latitude in degrees
|
|
||||||
* @param longitudeDeg longitude in degrees
|
|
||||||
* @return Maidenhead-4 id such as JO22, or empty string if conversion failed
|
|
||||||
*/
|
|
||||||
public static String toMaidenhead4(double latitudeDeg, double longitudeDeg) {
|
|
||||||
if (!areUsableCoordinates(latitudeDeg, longitudeDeg)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
String maidenhead6 = Location.toMaidenhead(latitudeDeg, longitudeDeg);
|
|
||||||
if (maidenhead6 == null || maidenhead6.length() < 4) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return maidenhead6.substring(0, 4).toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int resolveCoverageSampleCount(double totalDistanceKm) {
|
|
||||||
if (!Double.isFinite(totalDistanceKm) || totalDistanceKm <= 0.0) {
|
|
||||||
return MIN_COVERAGE_SAMPLE_COUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
int computedSampleCount = (int) Math.ceil(totalDistanceKm / COVERAGE_SAMPLE_STEP_KM) + 1;
|
|
||||||
return Math.max(MIN_COVERAGE_SAMPLE_COUNT, computedSampleCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int floorDegree(double value) {
|
|
||||||
return (int) Math.floor(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean areUsableCoordinates(double latitudeDeg, double longitudeDeg) {
|
|
||||||
return Double.isFinite(latitudeDeg)
|
|
||||||
&& Double.isFinite(longitudeDeg)
|
|
||||||
&& latitudeDeg >= -90.0
|
|
||||||
&& latitudeDeg <= 90.0
|
|
||||||
&& longitudeDeg >= -180.0
|
|
||||||
&& longitudeDeg <= 180.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Great-circle distance in kilometers using the haversine formula.
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @return great-circle distance in kilometers
|
|
||||||
*/
|
|
||||||
private static double calculateGreatCircleDistanceKm(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg) {
|
|
||||||
|
|
||||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
|
||||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
|
||||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
|
||||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
|
||||||
|
|
||||||
double deltaLatitude = toLatitudeRad - fromLatitudeRad;
|
|
||||||
double deltaLongitude = toLongitudeRad - fromLongitudeRad;
|
|
||||||
|
|
||||||
double a = Math.sin(deltaLatitude / 2.0) * Math.sin(deltaLatitude / 2.0)
|
|
||||||
+ Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad)
|
|
||||||
* Math.sin(deltaLongitude / 2.0) * Math.sin(deltaLongitude / 2.0);
|
|
||||||
|
|
||||||
double c = 2.0 * Math.atan2(Math.sqrt(a), Math.sqrt(Math.max(0.0, 1.0 - a)));
|
|
||||||
|
|
||||||
return 6371.009 * c;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interpolates one point on the great-circle path between the given endpoints.
|
|
||||||
*
|
|
||||||
* @param fromLatitudeDeg source latitude in degrees
|
|
||||||
* @param fromLongitudeDeg source longitude in degrees
|
|
||||||
* @param toLatitudeDeg target latitude in degrees
|
|
||||||
* @param toLongitudeDeg target longitude in degrees
|
|
||||||
* @param t interpolation factor within [0, 1]
|
|
||||||
* @return interpolated geographic point
|
|
||||||
*/
|
|
||||||
private static GeoPoint interpolateGreatCirclePoint(double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg,
|
|
||||||
double t) {
|
|
||||||
|
|
||||||
double clampedT = clamp(t, 0.0, 1.0);
|
|
||||||
|
|
||||||
if (clampedT <= 0.0) {
|
|
||||||
return new GeoPoint(fromLatitudeDeg, normalizeLongitudeDeg(fromLongitudeDeg));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clampedT >= 1.0) {
|
|
||||||
return new GeoPoint(toLatitudeDeg, normalizeLongitudeDeg(toLongitudeDeg));
|
|
||||||
}
|
|
||||||
|
|
||||||
double fromLatitudeRad = Math.toRadians(fromLatitudeDeg);
|
|
||||||
double fromLongitudeRad = Math.toRadians(fromLongitudeDeg);
|
|
||||||
double toLatitudeRad = Math.toRadians(toLatitudeDeg);
|
|
||||||
double toLongitudeRad = Math.toRadians(toLongitudeDeg);
|
|
||||||
|
|
||||||
double x1 = Math.cos(fromLatitudeRad) * Math.cos(fromLongitudeRad);
|
|
||||||
double y1 = Math.cos(fromLatitudeRad) * Math.sin(fromLongitudeRad);
|
|
||||||
double z1 = Math.sin(fromLatitudeRad);
|
|
||||||
|
|
||||||
double x2 = Math.cos(toLatitudeRad) * Math.cos(toLongitudeRad);
|
|
||||||
double y2 = Math.cos(toLatitudeRad) * Math.sin(toLongitudeRad);
|
|
||||||
double z2 = Math.sin(toLatitudeRad);
|
|
||||||
|
|
||||||
double dot = clamp(x1 * x2 + y1 * y2 + z1 * z2, -1.0, 1.0);
|
|
||||||
double omega = Math.acos(dot);
|
|
||||||
|
|
||||||
if (omega < 1e-12) {
|
|
||||||
double latitudeDeg = fromLatitudeDeg + (toLatitudeDeg - fromLatitudeDeg) * clampedT;
|
|
||||||
double longitudeDeg = normalizeLongitudeDeg(fromLongitudeDeg + (toLongitudeDeg - fromLongitudeDeg) * clampedT);
|
|
||||||
return new GeoPoint(latitudeDeg, longitudeDeg);
|
|
||||||
}
|
|
||||||
|
|
||||||
double sinOmega = Math.sin(omega);
|
|
||||||
double a = Math.sin((1.0 - clampedT) * omega) / sinOmega;
|
|
||||||
double b = Math.sin(clampedT * omega) / sinOmega;
|
|
||||||
|
|
||||||
double x = a * x1 + b * x2;
|
|
||||||
double y = a * y1 + b * y2;
|
|
||||||
double z = a * z1 + b * z2;
|
|
||||||
|
|
||||||
double latitudeRad = Math.atan2(z, Math.sqrt(x * x + y * y));
|
|
||||||
double longitudeRad = Math.atan2(y, x);
|
|
||||||
|
|
||||||
return new GeoPoint(
|
|
||||||
Math.toDegrees(latitudeRad),
|
|
||||||
normalizeLongitudeDeg(Math.toDegrees(longitudeRad))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double normalizeLongitudeDeg(double longitudeDeg) {
|
|
||||||
double normalized = longitudeDeg % 360.0;
|
|
||||||
|
|
||||||
if (normalized > 180.0) {
|
|
||||||
normalized -= 360.0;
|
|
||||||
} else if (normalized <= -180.0) {
|
|
||||||
normalized += 360.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double clamp(double value, double minValue, double maxValue) {
|
|
||||||
return Math.max(minValue, Math.min(maxValue, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined path coverage selection.
|
|
||||||
*
|
|
||||||
* @param regionIds ordered distinct Maidenhead-4 region ids
|
|
||||||
* @param packageIds ordered distinct package ids
|
|
||||||
* @param tileIds ordered distinct 1° tile ids
|
|
||||||
* @param totalDistanceKm great-circle path distance in kilometers
|
|
||||||
* @param coverageSampleCount number of sampling points used for coverage resolution
|
|
||||||
*/
|
|
||||||
public record TerrainCoverageSelection(
|
|
||||||
List<String> regionIds,
|
|
||||||
List<String> packageIds,
|
|
||||||
List<String> tileIds,
|
|
||||||
double totalDistanceKm,
|
|
||||||
int coverageSampleCount
|
|
||||||
) {
|
|
||||||
public TerrainCoverageSelection {
|
|
||||||
regionIds = regionIds == null ? List.of() : List.copyOf(regionIds);
|
|
||||||
packageIds = packageIds == null ? List.of() : List.copyOf(packageIds);
|
|
||||||
tileIds = tileIds == null ? List.of() : List.copyOf(tileIds);
|
|
||||||
|
|
||||||
if (coverageSampleCount < 0) {
|
|
||||||
coverageSampleCount = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TerrainCoverageSelection empty() {
|
|
||||||
return new TerrainCoverageSelection(List.of(), List.of(), List.of(), Double.NaN, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasCoverage() {
|
|
||||||
return !packageIds.isEmpty() || !tileIds.isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small immutable geographic point for great-circle interpolation.
|
|
||||||
*
|
|
||||||
* @param latitudeDeg latitude in degrees
|
|
||||||
* @param longitudeDeg longitude in degrees
|
|
||||||
*/
|
|
||||||
private record GeoPoint(double latitudeDeg, double longitudeDeg) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads terrain package archives (*.tpak) to the local .praktiKST directory.
|
|
||||||
*
|
|
||||||
* <p>This implementation is intentionally simple and robust:
|
|
||||||
* <ul>
|
|
||||||
* <li>download packages by catalog entry</li>
|
|
||||||
* <li>store them below terrain/packages</li>
|
|
||||||
* <li>reuse existing files when the checksum already matches</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public final class TerrainPackageDownloader {
|
|
||||||
|
|
||||||
private static final String LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY = "terrain/packages";
|
|
||||||
|
|
||||||
private final HttpClient httpClient;
|
|
||||||
|
|
||||||
public TerrainPackageDownloader() {
|
|
||||||
this.httpClient = HttpClient.newBuilder()
|
|
||||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads all required package ids that are present in the catalog.
|
|
||||||
*
|
|
||||||
* @param terrainCatalog current terrain catalog
|
|
||||||
* @param requiredPackageIds ordered package ids
|
|
||||||
* @return batch download result
|
|
||||||
*/
|
|
||||||
public BatchDownloadResult downloadPackages(TerrainCatalog terrainCatalog, List<String> requiredPackageIds) {
|
|
||||||
List<PackageDownloadResult> itemResults = new ArrayList<>();
|
|
||||||
|
|
||||||
if (terrainCatalog == null || requiredPackageIds == null || requiredPackageIds.isEmpty()) {
|
|
||||||
return new BatchDownloadResult(List.of(), "No terrain packages were requested.");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (String packageId : requiredPackageIds) {
|
|
||||||
PackageDownloadResult itemResult = terrainCatalog.findPackageById(packageId)
|
|
||||||
.map(this::downloadPackage)
|
|
||||||
.orElseGet(() -> new PackageDownloadResult(
|
|
||||||
packageId,
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"Package id is not present in the loaded catalog."
|
|
||||||
));
|
|
||||||
|
|
||||||
itemResults.add(itemResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new BatchDownloadResult(itemResults, buildBatchMessage(itemResults));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads one package archive from one catalog entry.
|
|
||||||
*
|
|
||||||
* @param packageEntry catalog package entry
|
|
||||||
* @return download result
|
|
||||||
*/
|
|
||||||
public PackageDownloadResult downloadPackage(TerrainCatalogPackageEntry packageEntry) {
|
|
||||||
if (packageEntry == null || !packageEntry.isUsable()) {
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
"",
|
|
||||||
null,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"Terrain package entry is missing or incomplete."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path packagesDirectory = resolveLocalPackagesDirectory();
|
|
||||||
Path localPackageFile = packagesDirectory.resolve(packageEntry.packageId() + ".tpak");
|
|
||||||
|
|
||||||
try {
|
|
||||||
Files.createDirectories(packagesDirectory);
|
|
||||||
|
|
||||||
if (Files.isRegularFile(localPackageFile)
|
|
||||||
&& !packageEntry.sha256().isBlank()
|
|
||||||
&& packageEntry.sha256().equalsIgnoreCase(computeSha256(localPackageFile))) {
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
packageEntry.packageId(),
|
|
||||||
localPackageFile,
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
"Terrain package is already present locally and matches the expected checksum."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path tempFile = packagesDirectory.resolve(packageEntry.packageId() + ".download");
|
|
||||||
|
|
||||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(packageEntry.downloadUrl()))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = httpClient.send(
|
|
||||||
httpRequest,
|
|
||||||
HttpResponse.BodyHandlers.ofFile(tempFile)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
|
||||||
Files.deleteIfExists(tempFile);
|
|
||||||
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
packageEntry.packageId(),
|
|
||||||
localPackageFile,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"Terrain package download failed with HTTP status " + response.statusCode() + "."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packageEntry.sha256().isBlank()) {
|
|
||||||
String actualSha256 = computeSha256(tempFile);
|
|
||||||
if (!packageEntry.sha256().equalsIgnoreCase(actualSha256)) {
|
|
||||||
Files.deleteIfExists(tempFile);
|
|
||||||
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
packageEntry.packageId(),
|
|
||||||
localPackageFile,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"Downloaded terrain package checksum does not match the catalog."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.move(tempFile, localPackageFile, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
packageEntry.packageId(),
|
|
||||||
localPackageFile,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
"Terrain package downloaded successfully."
|
|
||||||
);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return new PackageDownloadResult(
|
|
||||||
packageEntry.packageId(),
|
|
||||||
localPackageFile,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
"Terrain package download failed: " + exception.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path resolveLocalPackagesDirectory() {
|
|
||||||
return Path.of(ApplicationFileUtils.getFilePath(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
LOCAL_TERRAIN_PACKAGES_RELATIVE_DIRECTORY
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String computeSha256(Path file) throws Exception {
|
|
||||||
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
|
|
||||||
|
|
||||||
try (InputStream inputStream = Files.newInputStream(file)) {
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int bytesRead;
|
|
||||||
|
|
||||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
|
||||||
messageDigest.update(buffer, 0, bytesRead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return HexFormat.of().formatHex(messageDigest.digest());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildBatchMessage(List<PackageDownloadResult> itemResults) {
|
|
||||||
int successfulCount = 0;
|
|
||||||
int downloadedCount = 0;
|
|
||||||
int reusedCount = 0;
|
|
||||||
|
|
||||||
for (PackageDownloadResult itemResult : itemResults) {
|
|
||||||
if (itemResult.success()) {
|
|
||||||
successfulCount++;
|
|
||||||
}
|
|
||||||
if (itemResult.downloadedNow()) {
|
|
||||||
downloadedCount++;
|
|
||||||
} else if (itemResult.success()) {
|
|
||||||
reusedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Terrain packages processed: "
|
|
||||||
+ itemResults.size()
|
|
||||||
+ ", successful: "
|
|
||||||
+ successfulCount
|
|
||||||
+ ", downloaded now: "
|
|
||||||
+ downloadedCount
|
|
||||||
+ ", reused locally: "
|
|
||||||
+ reusedCount
|
|
||||||
+ ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of one terrain package download attempt.
|
|
||||||
*
|
|
||||||
* @param packageId canonical package id
|
|
||||||
* @param localPackageFile local target package file
|
|
||||||
* @param success true if a usable local package file is available afterward
|
|
||||||
* @param downloadedNow true if the package was downloaded in this run
|
|
||||||
* @param message human-readable result text
|
|
||||||
*/
|
|
||||||
public record PackageDownloadResult(
|
|
||||||
String packageId,
|
|
||||||
Path localPackageFile,
|
|
||||||
boolean success,
|
|
||||||
boolean downloadedNow,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a batch terrain package download attempt.
|
|
||||||
*
|
|
||||||
* @param itemResults one result per requested package
|
|
||||||
* @param message human-readable summary text
|
|
||||||
*/
|
|
||||||
public record BatchDownloadResult(
|
|
||||||
List<PackageDownloadResult> itemResults,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
public BatchDownloadResult {
|
|
||||||
itemResults = itemResults == null ? List.of() : List.copyOf(itemResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean allSuccessful() {
|
|
||||||
return !itemResults.isEmpty() && itemResults.stream().allMatch(PackageDownloadResult::success);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Path> successfulPackageFiles() {
|
|
||||||
return itemResults.stream()
|
|
||||||
.filter(PackageDownloadResult::success)
|
|
||||||
.map(PackageDownloadResult::localPackageFile)
|
|
||||||
.filter(path -> path != null)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
|
||||||
import org.w3c.dom.Document;
|
|
||||||
import org.w3c.dom.Element;
|
|
||||||
import org.w3c.dom.Node;
|
|
||||||
|
|
||||||
import javax.xml.XMLConstants;
|
|
||||||
import javax.xml.parsers.DocumentBuilder;
|
|
||||||
import javax.xml.parsers.DocumentBuilderFactory;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import java.util.zip.ZipFile;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs one downloaded terrain package into the local DEM directory.
|
|
||||||
*
|
|
||||||
* <p>This first vertical slice intentionally extracts raw Copernicus *_DEM.tif
|
|
||||||
* files, because the current runtime already knows how to scan and load them.
|
|
||||||
* That means we get an end-to-end working package flow quickly without first
|
|
||||||
* introducing a second runtime terrain format.</p>
|
|
||||||
*/
|
|
||||||
public final class TerrainPackageInstaller {
|
|
||||||
|
|
||||||
private static final String DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY = "dem/copernicus_glo30";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs one terrain package into the configured DEM root directory.
|
|
||||||
*
|
|
||||||
* <p>If no DEM root directory is configured, the default directory below
|
|
||||||
* .praktiKST is used automatically.</p>
|
|
||||||
*
|
|
||||||
* @param packageFile local *.tpak file
|
|
||||||
* @param configuredDemRootDirectory user-configured DEM root directory text
|
|
||||||
* @return installation result
|
|
||||||
*/
|
|
||||||
public InstallResult installPackage(Path packageFile, String configuredDemRootDirectory) {
|
|
||||||
if (packageFile == null || !Files.isRegularFile(packageFile)) {
|
|
||||||
return new InstallResult(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
"Terrain package file does not exist."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try (ZipFile zipFile = new ZipFile(packageFile.toFile())) {
|
|
||||||
ZipEntry manifestEntry = zipFile.getEntry("manifest.xml");
|
|
||||||
if (manifestEntry == null) {
|
|
||||||
return new InstallResult(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
"Terrain package does not contain manifest.xml."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainPackageManifest terrainPackageManifest;
|
|
||||||
try (InputStream manifestInputStream = zipFile.getInputStream(manifestEntry)) {
|
|
||||||
terrainPackageManifest = parseManifest(manifestInputStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terrainPackageManifest == null || !terrainPackageManifest.hasUsableTiles()) {
|
|
||||||
return new InstallResult(
|
|
||||||
terrainPackageManifest,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
"Terrain package manifest is missing or contains no usable tiles."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Path demRootDirectory = resolveEffectiveDemRootDirectory(configuredDemRootDirectory);
|
|
||||||
Path targetPackageDirectory = demRootDirectory
|
|
||||||
.resolve("packages")
|
|
||||||
.resolve(terrainPackageManifest.packageId());
|
|
||||||
|
|
||||||
Files.createDirectories(targetPackageDirectory);
|
|
||||||
|
|
||||||
int installedTileCount = 0;
|
|
||||||
|
|
||||||
List<? extends ZipEntry> zipEntries = zipFile.stream().toList();
|
|
||||||
for (ZipEntry zipEntry : zipEntries) {
|
|
||||||
if (zipEntry.isDirectory()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String entryName = zipEntry.getName();
|
|
||||||
String fileName = Path.of(entryName).getFileName().toString();
|
|
||||||
|
|
||||||
if ("manifest.xml".equalsIgnoreCase(fileName)) {
|
|
||||||
Path targetFile = targetPackageDirectory.resolve("manifest.xml");
|
|
||||||
extractZipEntry(zipFile, zipEntry, targetFile);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!OfflineDemManager.isSupportedCopernicusGlo30DemFilename(fileName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Path targetFile = targetPackageDirectory.resolve(fileName);
|
|
||||||
extractZipEntry(zipFile, zipEntry, targetFile);
|
|
||||||
installedTileCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (installedTileCount <= 0) {
|
|
||||||
return new InstallResult(
|
|
||||||
terrainPackageManifest,
|
|
||||||
targetPackageDirectory,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
"Terrain package did not contain any supported Copernicus *_DEM.tif files."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new InstallResult(
|
|
||||||
terrainPackageManifest,
|
|
||||||
targetPackageDirectory,
|
|
||||||
installedTileCount,
|
|
||||||
true,
|
|
||||||
"Installed "
|
|
||||||
+ installedTileCount
|
|
||||||
+ " DEM tile(s) into:\n"
|
|
||||||
+ targetPackageDirectory.toAbsolutePath()
|
|
||||||
);
|
|
||||||
} catch (Exception exception) {
|
|
||||||
return new InstallResult(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
"Terrain package installation failed: " + exception.getMessage()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Path resolveEffectiveDemRootDirectory(String configuredDemRootDirectory) {
|
|
||||||
if (configuredDemRootDirectory != null && !configuredDemRootDirectory.isBlank()) {
|
|
||||||
return Path.of(configuredDemRootDirectory.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Path.of(ApplicationFileUtils.getFilePath(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DEFAULT_LOCAL_DEM_ROOT_RELATIVE_DIRECTORY
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void extractZipEntry(ZipFile zipFile, ZipEntry zipEntry, Path targetFile) throws Exception {
|
|
||||||
Path normalizedTarget = targetFile.normalize();
|
|
||||||
|
|
||||||
if (normalizedTarget.getParent() != null) {
|
|
||||||
Files.createDirectories(normalizedTarget.getParent());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (InputStream entryInputStream = zipFile.getInputStream(zipEntry)) {
|
|
||||||
Files.copy(entryInputStream, normalizedTarget, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private TerrainPackageManifest parseManifest(InputStream inputStream) throws Exception {
|
|
||||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
|
||||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
|
||||||
|
|
||||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
|
||||||
Document document = documentBuilder.parse(inputStream);
|
|
||||||
|
|
||||||
Element root = document.getDocumentElement();
|
|
||||||
if (root == null || !"terrainPackageManifest".equals(root.getTagName())) {
|
|
||||||
throw new IllegalArgumentException("Unexpected package manifest root element.");
|
|
||||||
}
|
|
||||||
|
|
||||||
int schemaVersion = parseIntAttribute(root, "schemaVersion", 1);
|
|
||||||
String packageId = root.getAttribute("packageId");
|
|
||||||
int packageVersion = parseIntAttribute(root, "packageVersion", 1);
|
|
||||||
String regionType = root.getAttribute("regionType");
|
|
||||||
String regionId = root.getAttribute("regionId");
|
|
||||||
|
|
||||||
double minLatitudeDeg = parseDoubleAttribute(root, "minLatitudeDeg", Double.NaN);
|
|
||||||
double maxLatitudeDeg = parseDoubleAttribute(root, "maxLatitudeDeg", Double.NaN);
|
|
||||||
double minLongitudeDeg = parseDoubleAttribute(root, "minLongitudeDeg", Double.NaN);
|
|
||||||
double maxLongitudeDeg = parseDoubleAttribute(root, "maxLongitudeDeg", Double.NaN);
|
|
||||||
|
|
||||||
String primaryDataset = root.getAttribute("primaryDataset");
|
|
||||||
String fallbackDataset = root.getAttribute("fallbackDataset");
|
|
||||||
String packageBuiltAtUtc = root.getAttribute("packageBuiltAtUtc");
|
|
||||||
String packageSha256 = root.getAttribute("packageSha256");
|
|
||||||
|
|
||||||
String sourceAttribution = getDirectChildText(root, "sourceAttribution");
|
|
||||||
String derivedProductNotice = getDirectChildText(root, "derivedProductNotice");
|
|
||||||
String disclaimerNotice = getDirectChildText(root, "disclaimerNotice");
|
|
||||||
|
|
||||||
List<TerrainTileMetadata> tiles = new ArrayList<>();
|
|
||||||
Element tilesElement = getFirstDirectChild(root, "tiles");
|
|
||||||
|
|
||||||
if (tilesElement != null) {
|
|
||||||
for (Element tileElement : getDirectChildElements(tilesElement, "tile")) {
|
|
||||||
tiles.add(new TerrainTileMetadata(
|
|
||||||
tileElement.getAttribute("tileId"),
|
|
||||||
tileElement.getAttribute("fileName"),
|
|
||||||
parseIntAttribute(tileElement, "southDeg", 0),
|
|
||||||
parseIntAttribute(tileElement, "westDeg", 0),
|
|
||||||
parseIntAttribute(tileElement, "width", 3601),
|
|
||||||
parseIntAttribute(tileElement, "height", 3601),
|
|
||||||
parseIntAttribute(tileElement, "arcSecondResolution", 1),
|
|
||||||
(short) parseIntAttribute(tileElement, "noDataValue", -32768),
|
|
||||||
tileElement.getAttribute("sourceDataset"),
|
|
||||||
tileElement.getAttribute("sha256")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TerrainPackageManifest(
|
|
||||||
schemaVersion,
|
|
||||||
packageId,
|
|
||||||
packageVersion,
|
|
||||||
regionType,
|
|
||||||
regionId,
|
|
||||||
minLatitudeDeg,
|
|
||||||
maxLatitudeDeg,
|
|
||||||
minLongitudeDeg,
|
|
||||||
maxLongitudeDeg,
|
|
||||||
primaryDataset,
|
|
||||||
fallbackDataset,
|
|
||||||
packageBuiltAtUtc,
|
|
||||||
sourceAttribution,
|
|
||||||
derivedProductNotice,
|
|
||||||
disclaimerNotice,
|
|
||||||
packageSha256,
|
|
||||||
tiles
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Element> getDirectChildElements(Element parent, String tagName) {
|
|
||||||
List<Element> result = new ArrayList<>();
|
|
||||||
|
|
||||||
if (parent == null) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int index = 0; index < parent.getChildNodes().getLength(); index++) {
|
|
||||||
Node node = parent.getChildNodes().item(index);
|
|
||||||
if (node instanceof Element element && tagName.equals(element.getTagName())) {
|
|
||||||
result.add(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Element getFirstDirectChild(Element parent, String tagName) {
|
|
||||||
for (Element element : getDirectChildElements(parent, tagName)) {
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String getDirectChildText(Element parent, String tagName) {
|
|
||||||
Element child = getFirstDirectChild(parent, tagName);
|
|
||||||
return child == null ? "" : child.getTextContent().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int parseIntAttribute(Element element, String attributeName, int defaultValue) {
|
|
||||||
try {
|
|
||||||
return Integer.parseInt(element.getAttribute(attributeName));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double parseDoubleAttribute(Element element, String attributeName, double defaultValue) {
|
|
||||||
try {
|
|
||||||
return Double.parseDouble(element.getAttribute(attributeName));
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of one terrain package installation.
|
|
||||||
*
|
|
||||||
* @param terrainPackageManifest parsed manifest, or null
|
|
||||||
* @param targetPackageDirectory extraction target directory
|
|
||||||
* @param installedTileCount number of extracted DEM GeoTIFF files
|
|
||||||
* @param success true if at least one usable tile was installed
|
|
||||||
* @param message human-readable result text
|
|
||||||
*/
|
|
||||||
public record InstallResult(
|
|
||||||
TerrainPackageManifest terrainPackageManifest,
|
|
||||||
Path targetPackageDirectory,
|
|
||||||
int installedTileCount,
|
|
||||||
boolean success,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable manifest describing one locally installable terrain package.
|
|
||||||
*
|
|
||||||
* <p>This model is the authoritative description of the package contents after
|
|
||||||
* download and before/after installation.</p>
|
|
||||||
*/
|
|
||||||
public record TerrainPackageManifest(
|
|
||||||
int schemaVersion,
|
|
||||||
String packageId,
|
|
||||||
int packageVersion,
|
|
||||||
String regionType,
|
|
||||||
String regionId,
|
|
||||||
double minLatitudeDeg,
|
|
||||||
double maxLatitudeDeg,
|
|
||||||
double minLongitudeDeg,
|
|
||||||
double maxLongitudeDeg,
|
|
||||||
String primaryDataset,
|
|
||||||
String fallbackDataset,
|
|
||||||
String packageBuiltAtUtc,
|
|
||||||
String sourceAttribution,
|
|
||||||
String derivedProductNotice,
|
|
||||||
String disclaimerNotice,
|
|
||||||
String packageSha256,
|
|
||||||
List<TerrainTileMetadata> tiles
|
|
||||||
) {
|
|
||||||
|
|
||||||
public TerrainPackageManifest {
|
|
||||||
packageId = normalizeLower(packageId);
|
|
||||||
regionType = normalizeLower(regionType);
|
|
||||||
regionId = normalizeUpper(regionId);
|
|
||||||
primaryDataset = normalizeLower(primaryDataset);
|
|
||||||
fallbackDataset = normalizeLower(fallbackDataset);
|
|
||||||
packageBuiltAtUtc = normalizeText(packageBuiltAtUtc);
|
|
||||||
sourceAttribution = normalizeText(sourceAttribution);
|
|
||||||
derivedProductNotice = normalizeText(derivedProductNotice);
|
|
||||||
disclaimerNotice = normalizeText(disclaimerNotice);
|
|
||||||
packageSha256 = normalizeLower(packageSha256);
|
|
||||||
tiles = tiles == null ? List.of() : List.copyOf(tiles);
|
|
||||||
|
|
||||||
if (schemaVersion < 0) {
|
|
||||||
schemaVersion = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packageVersion < 0) {
|
|
||||||
packageVersion = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the manifest contains at least one usable tile.
|
|
||||||
*
|
|
||||||
* @return true if the manifest appears installable
|
|
||||||
*/
|
|
||||||
public boolean hasUsableTiles() {
|
|
||||||
return tiles.stream().anyMatch(TerrainTileMetadata::isUsable);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds one tile metadata entry by canonical tile id.
|
|
||||||
*
|
|
||||||
* @param tileId canonical tile id
|
|
||||||
* @return tile metadata if found
|
|
||||||
*/
|
|
||||||
public Optional<TerrainTileMetadata> findTile(String tileId) {
|
|
||||||
String normalizedTileId = normalizeUpper(tileId);
|
|
||||||
|
|
||||||
return tiles.stream()
|
|
||||||
.filter(tile -> tile.tileId().equals(normalizedTileId))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the manifest belongs to the given region.
|
|
||||||
*
|
|
||||||
* @param expectedRegionType region type
|
|
||||||
* @param expectedRegionId region id
|
|
||||||
* @return true if region type and id match
|
|
||||||
*/
|
|
||||||
public boolean matchesRegion(String expectedRegionType, String expectedRegionId) {
|
|
||||||
return regionType.equals(normalizeLower(expectedRegionType))
|
|
||||||
&& regionId.equals(normalizeUpper(expectedRegionId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeText(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeLower(String value) {
|
|
||||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.locatorUtils.Location;
|
|
||||||
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* High-level orchestration service for terrain package preparation.
|
|
||||||
*
|
|
||||||
* <p>This is the first end-to-end vertical slice that connects:
|
|
||||||
* <ul>
|
|
||||||
* <li>path coverage resolution</li>
|
|
||||||
* <li>catalog download/load</li>
|
|
||||||
* <li>package download</li>
|
|
||||||
* <li>package installation into the local DEM directory</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>The current implementation intentionally installs Copernicus *_DEM.tif files
|
|
||||||
* into the DEM root that is already used by the existing terrain provider chain.
|
|
||||||
* That allows us to reach a working automated download flow with very few
|
|
||||||
* intermediate refactorings.</p>
|
|
||||||
*/
|
|
||||||
public final class TerrainPackageService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First default catalog URL for the future modular terrain service on hamradioonline.de.
|
|
||||||
*
|
|
||||||
* <p>This can later move into user preferences without changing the orchestration flow.</p>
|
|
||||||
*/
|
|
||||||
public static final String DEFAULT_TERRAIN_CATALOG_URL =
|
|
||||||
"https://terrain.hamradioonline.de/catalog/terrain-catalog-v1.xml";
|
|
||||||
|
|
||||||
private final TerrainCoverageResolver terrainCoverageResolver;
|
|
||||||
private final TerrainCatalogClient terrainCatalogClient;
|
|
||||||
private final TerrainPackageDownloader terrainPackageDownloader;
|
|
||||||
private final TerrainPackageInstaller terrainPackageInstaller;
|
|
||||||
|
|
||||||
public TerrainPackageService() {
|
|
||||||
this(
|
|
||||||
new TerrainCoverageResolver(),
|
|
||||||
new TerrainCatalogClient(),
|
|
||||||
new TerrainPackageDownloader(),
|
|
||||||
new TerrainPackageInstaller()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TerrainPackageService(TerrainCoverageResolver terrainCoverageResolver,
|
|
||||||
TerrainCatalogClient terrainCatalogClient,
|
|
||||||
TerrainPackageDownloader terrainPackageDownloader,
|
|
||||||
TerrainPackageInstaller terrainPackageInstaller) {
|
|
||||||
this.terrainCoverageResolver = terrainCoverageResolver;
|
|
||||||
this.terrainCatalogClient = terrainCatalogClient;
|
|
||||||
this.terrainPackageDownloader = terrainPackageDownloader;
|
|
||||||
this.terrainPackageInstaller = terrainPackageInstaller;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepares all terrain packages required for the given path using the default catalog URL.
|
|
||||||
*
|
|
||||||
* @param request path analysis request
|
|
||||||
* @param configuredDemRootDirectory currently configured DEM root directory
|
|
||||||
* @return combined terrain preparation result
|
|
||||||
*/
|
|
||||||
public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request,
|
|
||||||
String configuredDemRootDirectory) {
|
|
||||||
return prepareTerrainForPath(
|
|
||||||
request,
|
|
||||||
DEFAULT_TERRAIN_CATALOG_URL,
|
|
||||||
configuredDemRootDirectory
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepares all terrain packages required for the given path.
|
|
||||||
*
|
|
||||||
* <p>Workflow:
|
|
||||||
* <ol>
|
|
||||||
* <li>resolve required package ids from the path geometry</li>
|
|
||||||
* <li>download the catalog</li>
|
|
||||||
* <li>fallback to the last local catalog if the download fails</li>
|
|
||||||
* <li>download all matching required packages</li>
|
|
||||||
* <li>install all successfully downloaded packages into the DEM root</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* @param request path analysis request
|
|
||||||
* @param catalogUrl terrain catalog URL
|
|
||||||
* @param configuredDemRootDirectory currently configured DEM root directory
|
|
||||||
* @return combined terrain preparation result
|
|
||||||
*/
|
|
||||||
public TerrainPreparationResult prepareTerrainForPath(PathAnalysisRequest request,
|
|
||||||
String catalogUrl,
|
|
||||||
String configuredDemRootDirectory) {
|
|
||||||
|
|
||||||
if (request == null || !request.hasUsableHome() || !request.hasUsableTarget()) {
|
|
||||||
return TerrainPreparationResult.failure(
|
|
||||||
request,
|
|
||||||
TerrainCoverageResolver.TerrainCoverageSelection.empty(),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
"Path request is missing or does not contain usable endpoints."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection =
|
|
||||||
terrainCoverageResolver.resolveCoverageForPath(request);
|
|
||||||
|
|
||||||
if (!coverageSelection.hasCoverage()) {
|
|
||||||
return TerrainPreparationResult.failure(
|
|
||||||
request,
|
|
||||||
coverageSelection,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
"No terrain coverage could be resolved for the requested path."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult =
|
|
||||||
terrainCatalogClient.downloadCatalog(catalogUrl);
|
|
||||||
|
|
||||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult =
|
|
||||||
terrainCatalogClient.loadLocalCatalog();
|
|
||||||
|
|
||||||
if (!catalogLoadResult.success() || catalogLoadResult.terrainCatalog() == null) {
|
|
||||||
StringBuilder message = new StringBuilder();
|
|
||||||
message.append("Terrain catalog is not available.");
|
|
||||||
|
|
||||||
if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) {
|
|
||||||
message.append("\n\nDownload: ").append(catalogDownloadResult.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) {
|
|
||||||
message.append("\n\nLoad: ").append(catalogLoadResult.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
return TerrainPreparationResult.failure(
|
|
||||||
request,
|
|
||||||
coverageSelection,
|
|
||||||
catalogDownloadResult,
|
|
||||||
catalogLoadResult,
|
|
||||||
null,
|
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
message.toString()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainCatalog terrainCatalog = catalogLoadResult.terrainCatalog();
|
|
||||||
|
|
||||||
List<String> missingPackageIds = coverageSelection.packageIds().stream()
|
|
||||||
.filter(packageId -> terrainCatalog.findPackageById(packageId).isEmpty())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
List<String> availablePackageIds = coverageSelection.packageIds().stream()
|
|
||||||
.filter(packageId -> terrainCatalog.findPackageById(packageId).isPresent())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (availablePackageIds.isEmpty()) {
|
|
||||||
return TerrainPreparationResult.failure(
|
|
||||||
request,
|
|
||||||
coverageSelection,
|
|
||||||
catalogDownloadResult,
|
|
||||||
catalogLoadResult,
|
|
||||||
null,
|
|
||||||
missingPackageIds,
|
|
||||||
List.of(),
|
|
||||||
"None of the required terrain packages are present in the loaded catalog."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult =
|
|
||||||
terrainPackageDownloader.downloadPackages(terrainCatalog, availablePackageIds);
|
|
||||||
|
|
||||||
List<TerrainPackageInstaller.InstallResult> installResults = new ArrayList<>();
|
|
||||||
for (Path packageFile : batchDownloadResult.successfulPackageFiles()) {
|
|
||||||
installResults.add(
|
|
||||||
terrainPackageInstaller.installPackage(packageFile, configuredDemRootDirectory)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String message = buildSummaryMessage(
|
|
||||||
coverageSelection,
|
|
||||||
catalogDownloadResult,
|
|
||||||
catalogLoadResult,
|
|
||||||
batchDownloadResult,
|
|
||||||
missingPackageIds,
|
|
||||||
installResults
|
|
||||||
);
|
|
||||||
|
|
||||||
boolean success = missingPackageIds.isEmpty()
|
|
||||||
&& batchDownloadResult != null
|
|
||||||
&& batchDownloadResult.allSuccessful()
|
|
||||||
&& installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success);
|
|
||||||
|
|
||||||
return new TerrainPreparationResult(
|
|
||||||
request,
|
|
||||||
coverageSelection,
|
|
||||||
catalogDownloadResult,
|
|
||||||
catalogLoadResult,
|
|
||||||
batchDownloadResult,
|
|
||||||
List.copyOf(missingPackageIds),
|
|
||||||
List.copyOf(installResults),
|
|
||||||
success,
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience helper for the likely future main workflow where only locators
|
|
||||||
* are available from the chat/channel context.
|
|
||||||
*
|
|
||||||
* @param ownLocator6 own 6-digit Maidenhead locator
|
|
||||||
* @param targetLocator6 target 6-digit Maidenhead locator
|
|
||||||
* @param targetCallsignRaw target callsign
|
|
||||||
* @param analysisFrequencyMHz analysis frequency in MHz
|
|
||||||
* @param ownAntennaHeightMeters own antenna height in meters AGL
|
|
||||||
* @param targetAntennaHeightMeters target antenna height in meters AGL
|
|
||||||
* @param configuredDemRootDirectory configured DEM root directory
|
|
||||||
* @return combined terrain preparation result
|
|
||||||
*/
|
|
||||||
public TerrainPreparationResult prepareTerrainForLocators(String ownLocator6,
|
|
||||||
String targetLocator6,
|
|
||||||
String targetCallsignRaw,
|
|
||||||
double analysisFrequencyMHz,
|
|
||||||
double ownAntennaHeightMeters,
|
|
||||||
double targetAntennaHeightMeters,
|
|
||||||
String configuredDemRootDirectory) {
|
|
||||||
|
|
||||||
String normalizedOwnLocator6 = normalizeLocator6(ownLocator6);
|
|
||||||
String normalizedTargetLocator6 = normalizeLocator6(targetLocator6);
|
|
||||||
|
|
||||||
if (normalizedOwnLocator6.length() != 6 || normalizedTargetLocator6.length() != 6) {
|
|
||||||
return TerrainPreparationResult.failure(
|
|
||||||
null,
|
|
||||||
TerrainCoverageResolver.TerrainCoverageSelection.empty(),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
List.of(),
|
|
||||||
List.of(),
|
|
||||||
"Own locator or target locator is missing/invalid."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Location ownLocation = new Location(normalizedOwnLocator6);
|
|
||||||
Location targetLocation = new Location(normalizedTargetLocator6);
|
|
||||||
|
|
||||||
PathAnalysisRequest request = new PathAnalysisRequest(
|
|
||||||
normalizedOwnLocator6,
|
|
||||||
ownLocation.getLatitude().toDegrees(),
|
|
||||||
ownLocation.getLongitude().toDegrees(),
|
|
||||||
normalizeCallsignRaw(targetCallsignRaw),
|
|
||||||
normalizedTargetLocator6,
|
|
||||||
targetLocation.getLatitude().toDegrees(),
|
|
||||||
targetLocation.getLongitude().toDegrees(),
|
|
||||||
Double.isFinite(analysisFrequencyMHz) && analysisFrequencyMHz > 0.0
|
|
||||||
? analysisFrequencyMHz
|
|
||||||
: PathGeometryUtils.DEFAULT_ANALYSIS_FREQUENCY_MHZ,
|
|
||||||
sanitizeAntennaHeightMeters(ownAntennaHeightMeters),
|
|
||||||
sanitizeAntennaHeightMeters(targetAntennaHeightMeters)
|
|
||||||
);
|
|
||||||
|
|
||||||
return prepareTerrainForPath(request, configuredDemRootDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildSummaryMessage(TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
|
||||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
|
||||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
|
||||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
|
||||||
List<String> missingPackageIds,
|
|
||||||
List<TerrainPackageInstaller.InstallResult> installResults) {
|
|
||||||
|
|
||||||
int successfulInstallCount = (int) installResults.stream()
|
|
||||||
.filter(TerrainPackageInstaller.InstallResult::success)
|
|
||||||
.count();
|
|
||||||
|
|
||||||
int installedTileCount = installResults.stream()
|
|
||||||
.filter(TerrainPackageInstaller.InstallResult::success)
|
|
||||||
.mapToInt(TerrainPackageInstaller.InstallResult::installedTileCount)
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
StringBuilder message = new StringBuilder();
|
|
||||||
|
|
||||||
message.append(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"Required coverage: %d package(s), %d tile(s), %.1f km path, %d coverage samples.",
|
|
||||||
coverageSelection.packageIds().size(),
|
|
||||||
coverageSelection.tileIds().size(),
|
|
||||||
coverageSelection.totalDistanceKm(),
|
|
||||||
coverageSelection.coverageSampleCount()
|
|
||||||
));
|
|
||||||
|
|
||||||
if (catalogDownloadResult != null && catalogDownloadResult.message() != null && !catalogDownloadResult.message().isBlank()) {
|
|
||||||
message.append("\n\nCatalog download: ").append(catalogDownloadResult.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (catalogLoadResult != null && catalogLoadResult.message() != null && !catalogLoadResult.message().isBlank()) {
|
|
||||||
message.append("\n\nCatalog load: ").append(catalogLoadResult.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batchDownloadResult != null && batchDownloadResult.message() != null && !batchDownloadResult.message().isBlank()) {
|
|
||||||
message.append("\n\nPackage download: ").append(batchDownloadResult.message());
|
|
||||||
}
|
|
||||||
|
|
||||||
message.append(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"\n\nInstallation: %d package(s) installed successfully, %d DEM tile(s) extracted.",
|
|
||||||
successfulInstallCount,
|
|
||||||
installedTileCount
|
|
||||||
));
|
|
||||||
|
|
||||||
if (missingPackageIds != null && !missingPackageIds.isEmpty()) {
|
|
||||||
message.append("\n\nMissing package ids in catalog:");
|
|
||||||
for (String packageId : missingPackageIds) {
|
|
||||||
message.append("\n- ").append(packageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<TerrainPackageInstaller.InstallResult> failedInstalls = installResults.stream()
|
|
||||||
.filter(result -> !result.success())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (!failedInstalls.isEmpty()) {
|
|
||||||
message.append("\n\nFailed installations:");
|
|
||||||
for (TerrainPackageInstaller.InstallResult failedInstall : failedInstalls) {
|
|
||||||
String packageId = failedInstall.terrainPackageManifest() == null
|
|
||||||
? "<unknown>"
|
|
||||||
: failedInstall.terrainPackageManifest().packageId();
|
|
||||||
message.append("\n- ").append(packageId).append(": ").append(failedInstall.message());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return message.toString().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeLocator6(String locator6) {
|
|
||||||
if (locator6 == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
String trimmed = locator6.trim().toUpperCase(Locale.ROOT);
|
|
||||||
return trimmed.length() >= 6 ? trimmed.substring(0, 6) : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeCallsignRaw(String callSignRaw) {
|
|
||||||
return callSignRaw == null ? "" : callSignRaw.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private double sanitizeAntennaHeightMeters(double antennaHeightMeters) {
|
|
||||||
if (!Double.isFinite(antennaHeightMeters) || antennaHeightMeters < 0.0) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
return antennaHeightMeters;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined result of one terrain preparation run.
|
|
||||||
*
|
|
||||||
* @param request original path request
|
|
||||||
* @param coverageSelection resolved required coverage
|
|
||||||
* @param catalogDownloadResult catalog download result
|
|
||||||
* @param catalogLoadResult catalog load result
|
|
||||||
* @param batchDownloadResult package download result
|
|
||||||
* @param missingPackageIds required package ids that are not present in the catalog
|
|
||||||
* @param installResults installation results for downloaded packages
|
|
||||||
* @param success true if full required package coverage was available and at least one package was installed successfully
|
|
||||||
* @param message human-readable summary message
|
|
||||||
*/
|
|
||||||
public record TerrainPreparationResult(
|
|
||||||
PathAnalysisRequest request,
|
|
||||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
|
||||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
|
||||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
|
||||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
|
||||||
List<String> missingPackageIds,
|
|
||||||
List<TerrainPackageInstaller.InstallResult> installResults,
|
|
||||||
boolean success,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
public TerrainPreparationResult {
|
|
||||||
missingPackageIds = missingPackageIds == null ? List.of() : List.copyOf(missingPackageIds);
|
|
||||||
installResults = installResults == null ? List.of() : List.copyOf(installResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TerrainPreparationResult failure(PathAnalysisRequest request,
|
|
||||||
TerrainCoverageResolver.TerrainCoverageSelection coverageSelection,
|
|
||||||
TerrainCatalogClient.CatalogDownloadResult catalogDownloadResult,
|
|
||||||
TerrainCatalogClient.CatalogLoadResult catalogLoadResult,
|
|
||||||
TerrainPackageDownloader.BatchDownloadResult batchDownloadResult,
|
|
||||||
List<String> missingPackageIds,
|
|
||||||
List<TerrainPackageInstaller.InstallResult> installResults,
|
|
||||||
String message) {
|
|
||||||
return new TerrainPreparationResult(
|
|
||||||
request,
|
|
||||||
coverageSelection,
|
|
||||||
catalogDownloadResult,
|
|
||||||
catalogLoadResult,
|
|
||||||
batchDownloadResult,
|
|
||||||
missingPackageIds,
|
|
||||||
installResults,
|
|
||||||
false,
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasInstalledAnything() {
|
|
||||||
return installResults.stream().anyMatch(TerrainPackageInstaller.InstallResult::success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import kst4contest.ApplicationConstants;
|
|
||||||
import kst4contest.controller.DBController;
|
|
||||||
import kst4contest.utils.ApplicationFileUtils;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.PreparedStatement;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persistent terrain profile cache stored in the application's existing SQLite database.
|
|
||||||
*
|
|
||||||
* The cache is intentionally owner-bound:
|
|
||||||
* if the configured own callsign or own locator changes, all cached terrain
|
|
||||||
* profiles are cleared automatically.
|
|
||||||
*/
|
|
||||||
public final class TerrainProfileCacheRepository {
|
|
||||||
|
|
||||||
private static final String META_KEY_OWNER_CALLSIGN_RAW = "terrain_cache_owner_callsign_raw";
|
|
||||||
private static final String META_KEY_OWNER_LOCATOR6 = "terrain_cache_owner_locator6";
|
|
||||||
|
|
||||||
private final String databasePath;
|
|
||||||
|
|
||||||
public TerrainProfileCacheRepository() {
|
|
||||||
ApplicationFileUtils.copyResourceIfRequired(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DBController.DATABASE_RESOURCE,
|
|
||||||
DBController.DATABASE_FILE
|
|
||||||
);
|
|
||||||
|
|
||||||
this.databasePath = ApplicationFileUtils.getFilePath(
|
|
||||||
ApplicationConstants.APPLICATION_NAME,
|
|
||||||
DBController.DATABASE_FILE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized Optional<TerrainProfileData> load(String ownerCallsignRaw,
|
|
||||||
String ownerLocator6,
|
|
||||||
String targetCallsignRaw,
|
|
||||||
String targetLocator6,
|
|
||||||
int sampleCount,
|
|
||||||
String providerId) {
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
ensureSchema(connection);
|
|
||||||
ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6);
|
|
||||||
|
|
||||||
String sql = """
|
|
||||||
SELECT profile_points_text, source_name, synthetic
|
|
||||||
FROM TerrainProfileCache
|
|
||||||
WHERE owner_callsign_raw = ?
|
|
||||||
AND owner_locator6 = ?
|
|
||||||
AND target_callsign_raw = ?
|
|
||||||
AND target_locator6 = ?
|
|
||||||
AND sample_count = ?
|
|
||||||
AND provider_id = ?
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
|
||||||
statement.setString(1, normalize(ownerCallsignRaw));
|
|
||||||
statement.setString(2, normalize(ownerLocator6));
|
|
||||||
statement.setString(3, normalize(targetCallsignRaw));
|
|
||||||
statement.setString(4, normalize(targetLocator6));
|
|
||||||
statement.setInt(5, sampleCount);
|
|
||||||
statement.setString(6, normalize(providerId));
|
|
||||||
|
|
||||||
try (ResultSet resultSet = statement.executeQuery()) {
|
|
||||||
if (!resultSet.next()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
String serializedProfile = resultSet.getString("profile_points_text");
|
|
||||||
String sourceName = resultSet.getString("source_name");
|
|
||||||
boolean synthetic = resultSet.getInt("synthetic") != 0;
|
|
||||||
|
|
||||||
List<PathProfilePoint> points = deserializeProfile(serializedProfile);
|
|
||||||
TerrainProfileData result = new TerrainProfileData(points, sourceName, synthetic);
|
|
||||||
|
|
||||||
return result.hasUsableProfile() ? Optional.of(result) : Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception exception) {
|
|
||||||
System.err.println("[StationMap] Terrain cache load failed: " + exception.getMessage());
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized void save(String ownerCallsignRaw,
|
|
||||||
String ownerLocator6,
|
|
||||||
String targetCallsignRaw,
|
|
||||||
String targetLocator6,
|
|
||||||
int sampleCount,
|
|
||||||
String providerId,
|
|
||||||
TerrainProfileData terrainProfileData) {
|
|
||||||
|
|
||||||
if (terrainProfileData == null || !terrainProfileData.hasUsableProfile() || terrainProfileData.synthetic()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try (Connection connection = openConnection()) {
|
|
||||||
ensureSchema(connection);
|
|
||||||
ensureOwnerIdentity(connection, ownerCallsignRaw, ownerLocator6);
|
|
||||||
|
|
||||||
String sql = """
|
|
||||||
INSERT INTO TerrainProfileCache (
|
|
||||||
owner_callsign_raw,
|
|
||||||
owner_locator6,
|
|
||||||
target_callsign_raw,
|
|
||||||
target_locator6,
|
|
||||||
sample_count,
|
|
||||||
provider_id,
|
|
||||||
profile_points_text,
|
|
||||||
source_name,
|
|
||||||
synthetic,
|
|
||||||
created_at_epoch_ms
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(
|
|
||||||
owner_callsign_raw,
|
|
||||||
owner_locator6,
|
|
||||||
target_callsign_raw,
|
|
||||||
target_locator6,
|
|
||||||
sample_count,
|
|
||||||
provider_id
|
|
||||||
) DO UPDATE SET
|
|
||||||
profile_points_text = excluded.profile_points_text,
|
|
||||||
source_name = excluded.source_name,
|
|
||||||
synthetic = excluded.synthetic,
|
|
||||||
created_at_epoch_ms = excluded.created_at_epoch_ms
|
|
||||||
""";
|
|
||||||
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
|
||||||
statement.setString(1, normalize(ownerCallsignRaw));
|
|
||||||
statement.setString(2, normalize(ownerLocator6));
|
|
||||||
statement.setString(3, normalize(targetCallsignRaw));
|
|
||||||
statement.setString(4, normalize(targetLocator6));
|
|
||||||
statement.setInt(5, sampleCount);
|
|
||||||
statement.setString(6, normalize(providerId));
|
|
||||||
statement.setString(7, serializeProfile(terrainProfileData.profilePoints()));
|
|
||||||
statement.setString(8, terrainProfileData.sourceName());
|
|
||||||
statement.setInt(9, terrainProfileData.synthetic() ? 1 : 0);
|
|
||||||
statement.setLong(10, System.currentTimeMillis());
|
|
||||||
statement.executeUpdate();
|
|
||||||
}
|
|
||||||
} catch (Exception exception) {
|
|
||||||
System.err.println("[StationMap] Terrain cache save failed: " + exception.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Connection openConnection() throws Exception {
|
|
||||||
return DriverManager.getConnection("jdbc:sqlite:" + databasePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ensureSchema(Connection connection) throws Exception {
|
|
||||||
try (Statement statement = connection.createStatement()) {
|
|
||||||
statement.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS TerrainProfileCache (
|
|
||||||
owner_callsign_raw TEXT NOT NULL,
|
|
||||||
owner_locator6 TEXT NOT NULL,
|
|
||||||
target_callsign_raw TEXT NOT NULL,
|
|
||||||
target_locator6 TEXT NOT NULL,
|
|
||||||
sample_count INTEGER NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
profile_points_text TEXT NOT NULL,
|
|
||||||
source_name TEXT NOT NULL,
|
|
||||||
synthetic INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at_epoch_ms INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (
|
|
||||||
owner_callsign_raw,
|
|
||||||
owner_locator6,
|
|
||||||
target_callsign_raw,
|
|
||||||
target_locator6,
|
|
||||||
sample_count,
|
|
||||||
provider_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
|
|
||||||
statement.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS TerrainProfileCacheMeta (
|
|
||||||
meta_key TEXT NOT NULL PRIMARY KEY,
|
|
||||||
meta_value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ensureOwnerIdentity(Connection connection,
|
|
||||||
String currentOwnerCallsignRaw,
|
|
||||||
String currentOwnerLocator6) throws Exception {
|
|
||||||
|
|
||||||
String normalizedOwnerCallsignRaw = normalize(currentOwnerCallsignRaw);
|
|
||||||
String normalizedOwnerLocator6 = normalize(currentOwnerLocator6);
|
|
||||||
|
|
||||||
String storedOwnerCallsignRaw = readMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW);
|
|
||||||
String storedOwnerLocator6 = readMetaValue(connection, META_KEY_OWNER_LOCATOR6);
|
|
||||||
|
|
||||||
boolean callsignChanged = storedOwnerCallsignRaw != null && !storedOwnerCallsignRaw.equals(normalizedOwnerCallsignRaw);
|
|
||||||
boolean locatorChanged = storedOwnerLocator6 != null && !storedOwnerLocator6.equals(normalizedOwnerLocator6);
|
|
||||||
|
|
||||||
if (callsignChanged || locatorChanged) {
|
|
||||||
clearTerrainCache(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeMetaValue(connection, META_KEY_OWNER_CALLSIGN_RAW, normalizedOwnerCallsignRaw);
|
|
||||||
writeMetaValue(connection, META_KEY_OWNER_LOCATOR6, normalizedOwnerLocator6);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void clearTerrainCache(Connection connection) throws Exception {
|
|
||||||
try (Statement statement = connection.createStatement()) {
|
|
||||||
statement.executeUpdate("DELETE FROM TerrainProfileCache");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String readMetaValue(Connection connection, String key) throws Exception {
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement(
|
|
||||||
"SELECT meta_value FROM TerrainProfileCacheMeta WHERE meta_key = ?")) {
|
|
||||||
statement.setString(1, key);
|
|
||||||
|
|
||||||
try (ResultSet resultSet = statement.executeQuery()) {
|
|
||||||
return resultSet.next() ? resultSet.getString(1) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeMetaValue(Connection connection, String key, String value) throws Exception {
|
|
||||||
try (PreparedStatement statement = connection.prepareStatement("""
|
|
||||||
INSERT INTO TerrainProfileCacheMeta (meta_key, meta_value)
|
|
||||||
VALUES (?, ?)
|
|
||||||
ON CONFLICT(meta_key) DO UPDATE SET meta_value = excluded.meta_value
|
|
||||||
""")) {
|
|
||||||
statement.setString(1, key);
|
|
||||||
statement.setString(2, value == null ? "" : value);
|
|
||||||
statement.executeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String serializeProfile(List<PathProfilePoint> profilePoints) {
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
|
|
||||||
for (PathProfilePoint point : profilePoints) {
|
|
||||||
if (point == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!builder.isEmpty()) {
|
|
||||||
builder.append('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.append(String.format(
|
|
||||||
Locale.US,
|
|
||||||
"%.6f;%.8f;%.8f;%.3f",
|
|
||||||
point.distanceKm(),
|
|
||||||
point.latitudeDeg(),
|
|
||||||
point.longitudeDeg(),
|
|
||||||
point.elevationMeters()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<PathProfilePoint> deserializeProfile(String serializedProfile) {
|
|
||||||
if (serializedProfile == null || serializedProfile.isBlank()) {
|
|
||||||
return List.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] lines = serializedProfile.split("\\R+");
|
|
||||||
List<PathProfilePoint> points = new ArrayList<>(lines.length);
|
|
||||||
|
|
||||||
for (String line : lines) {
|
|
||||||
String[] parts = line.split(";");
|
|
||||||
if (parts.length != 4) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
points.add(new PathProfilePoint(
|
|
||||||
Double.parseDouble(parts[0]),
|
|
||||||
Double.parseDouble(parts[1]),
|
|
||||||
Double.parseDouble(parts[2]),
|
|
||||||
Double.parseDouble(parts[3])
|
|
||||||
));
|
|
||||||
} catch (NumberFormatException ignored) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return List.copyOf(points);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalize(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable terrain/profile payload including source metadata.
|
|
||||||
*/
|
|
||||||
public record TerrainProfileData(
|
|
||||||
List<PathProfilePoint> profilePoints,
|
|
||||||
String sourceName,
|
|
||||||
boolean synthetic
|
|
||||||
) {
|
|
||||||
|
|
||||||
public TerrainProfileData {
|
|
||||||
profilePoints = profilePoints == null ? List.of() : List.copyOf(profilePoints);
|
|
||||||
sourceName = sourceName == null ? "" : sourceName.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static TerrainProfileData empty(String sourceName) {
|
|
||||||
return new TerrainProfileData(List.of(), sourceName, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableProfile() {
|
|
||||||
return profilePoints.size() >= 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstraction for terrain/profile retrieval.
|
|
||||||
*/
|
|
||||||
public interface TerrainProfileProvider {
|
|
||||||
|
|
||||||
TerrainProfileData loadProfile(TerrainProfileRequest request);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable request for terrain/profile sampling between two endpoints.
|
|
||||||
*/
|
|
||||||
public record TerrainProfileRequest(
|
|
||||||
double fromLatitudeDeg,
|
|
||||||
double fromLongitudeDeg,
|
|
||||||
double toLatitudeDeg,
|
|
||||||
double toLongitudeDeg,
|
|
||||||
double totalDistanceKm,
|
|
||||||
int requestedSampleCount
|
|
||||||
) {
|
|
||||||
public TerrainProfileRequest {
|
|
||||||
requestedSampleCount = Math.max(0, requestedSampleCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasUsableEndpoints() {
|
|
||||||
return Double.isFinite(fromLatitudeDeg)
|
|
||||||
&& Double.isFinite(fromLongitudeDeg)
|
|
||||||
&& Double.isFinite(toLatitudeDeg)
|
|
||||||
&& Double.isFinite(toLongitudeDeg)
|
|
||||||
&& Double.isFinite(totalDistanceKm)
|
|
||||||
&& totalDistanceKm >= 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable metadata for one internal terrain tile.
|
|
||||||
*
|
|
||||||
* <p>This model intentionally describes the runtime-ready tile after ingestion
|
|
||||||
* into the KST4Contest terrain format, not the original upstream GeoTIFF.</p>
|
|
||||||
*/
|
|
||||||
public record TerrainTileMetadata(
|
|
||||||
String tileId,
|
|
||||||
String fileName,
|
|
||||||
int southDeg,
|
|
||||||
int westDeg,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
int arcSecondResolution,
|
|
||||||
short noDataValue,
|
|
||||||
String sourceDataset,
|
|
||||||
String sha256
|
|
||||||
) {
|
|
||||||
|
|
||||||
public TerrainTileMetadata {
|
|
||||||
tileId = normalizeUpper(tileId);
|
|
||||||
fileName = normalizeText(fileName);
|
|
||||||
sourceDataset = normalizeText(sourceDataset);
|
|
||||||
sha256 = normalizeLower(sha256);
|
|
||||||
|
|
||||||
if (width < 0) {
|
|
||||||
width = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (height < 0) {
|
|
||||||
height = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arcSecondResolution < 0) {
|
|
||||||
arcSecondResolution = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the northern edge of the covered 1° x 1° geocell.
|
|
||||||
*
|
|
||||||
* @return north edge latitude in degrees
|
|
||||||
*/
|
|
||||||
public int northDeg() {
|
|
||||||
return southDeg + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the eastern edge of the covered 1° x 1° geocell.
|
|
||||||
*
|
|
||||||
* @return east edge longitude in degrees
|
|
||||||
*/
|
|
||||||
public int eastDeg() {
|
|
||||||
return westDeg + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if this metadata appears complete enough for installation/runtime.
|
|
||||||
*
|
|
||||||
* @return true if the essential fields are usable
|
|
||||||
*/
|
|
||||||
public boolean isUsable() {
|
|
||||||
return !tileId.isBlank()
|
|
||||||
&& !fileName.isBlank()
|
|
||||||
&& width > 0
|
|
||||||
&& height > 0
|
|
||||||
&& arcSecondResolution > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the tile covers the given geographic sample point.
|
|
||||||
*
|
|
||||||
* <p>The tile is interpreted as the 1° x 1° cell
|
|
||||||
* [southDeg, southDeg+1) x [westDeg, westDeg+1).</p>
|
|
||||||
*
|
|
||||||
* @param latitudeDeg sample latitude in degrees
|
|
||||||
* @param longitudeDeg sample longitude in degrees
|
|
||||||
* @return true if the point lies inside this tile
|
|
||||||
*/
|
|
||||||
public boolean covers(double latitudeDeg, double longitudeDeg) {
|
|
||||||
return Double.isFinite(latitudeDeg)
|
|
||||||
&& Double.isFinite(longitudeDeg)
|
|
||||||
&& latitudeDeg >= southDeg
|
|
||||||
&& latitudeDeg < northDeg()
|
|
||||||
&& longitudeDeg >= westDeg
|
|
||||||
&& longitudeDeg < eastDeg();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the canonical internal tile id for one 1° x 1° cell.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* <ul>
|
|
||||||
* <li>N51_E007</li>
|
|
||||||
* <li>N52_W003</li>
|
|
||||||
* <li>S01_E010</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* @param southDeg southern cell boundary in degrees
|
|
||||||
* @param westDeg western cell boundary in degrees
|
|
||||||
* @return canonical tile id
|
|
||||||
*/
|
|
||||||
public static String buildTileId(int southDeg, int westDeg) {
|
|
||||||
String latPrefix = southDeg >= 0 ? "N" : "S";
|
|
||||||
String lonPrefix = westDeg >= 0 ? "E" : "W";
|
|
||||||
|
|
||||||
return String.format(
|
|
||||||
Locale.ROOT,
|
|
||||||
"%s%02d_%s%03d",
|
|
||||||
latPrefix,
|
|
||||||
Math.abs(southDeg),
|
|
||||||
lonPrefix,
|
|
||||||
Math.abs(westDeg)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeText(String value) {
|
|
||||||
return value == null ? "" : value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeUpper(String value) {
|
|
||||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizeLower(String value) {
|
|
||||||
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package kst4contest.view.map;
|
|
||||||
|
|
||||||
public class mapTest {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
|
|
||||||
TerrainPackageService terrainPackageService = new TerrainPackageService();
|
|
||||||
|
|
||||||
TerrainPackageService.TerrainPreparationResult result =
|
|
||||||
terrainPackageService.prepareTerrainForLocators(
|
|
||||||
"JO51IJ",
|
|
||||||
"JO22JK",
|
|
||||||
"TEST",
|
|
||||||
144.300,
|
|
||||||
10.0,
|
|
||||||
10.0,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
|
|
||||||
System.out.println(result.success());
|
|
||||||
System.out.println(result.message());
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
module praktiKST {
|
module praktiKST {
|
||||||
requires javafx.controls;
|
requires javafx.controls;
|
||||||
requires javafx.fxml;
|
|
||||||
requires javafx.web;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
+1
-586
@@ -1780,589 +1780,4 @@ OK4C-7;Klondajk 70cm;JN79BU;StringProperty [value: t127 432.310 ]; wkd true; wkd
|
|||||||
DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.230 ]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
DL8NAS;Sigi-70cm;JN59LE;StringProperty [value: 432.230 ]; wkd true; wkd144 false; wkd432true; 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
|
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